diff --git a/asset/asset.go b/asset/asset.go index 2f350f20f9..b863aa6b42 100644 --- a/asset/asset.go +++ b/asset/asset.go @@ -348,6 +348,17 @@ func (g Genesis) TagHash() [sha256.Size]byte { return sha256.Sum256([]byte(g.Tag)) } +// Copy returns a deep copy of *Genesis. Every field is value-typed +// (OutPoint, string, fixed-size array, scalars), so a struct value +// copy is itself a deep copy. Returns nil if g is nil. +func (g *Genesis) Copy() *Genesis { + if g == nil { + return nil + } + cpy := *g + return &cpy +} + // ID serves as a unique identifier of an asset, resulting from: // // sha256(genesisOutPoint || sha256(tag) || sha256(metadata) || @@ -1165,6 +1176,18 @@ func (a *AssetGroup) IsGroupAnchor() (bool, error) { return a.GroupKey.IsGroupAnchor(a.Genesis.ID()) } +// Copy returns a deep copy of *AssetGroup. The embedded *Genesis and +// *GroupKey are both cloned independently. Returns nil if a is nil. +func (a *AssetGroup) Copy() *AssetGroup { + if a == nil { + return nil + } + return &AssetGroup{ + Genesis: a.Genesis.Copy(), + GroupKey: a.GroupKey.Copy(), + } +} + // ExternalKey represents an external key used for deriving and managing // hierarchical deterministic (HD) wallet addresses according to BIP-86. type ExternalKey struct { @@ -1246,6 +1269,22 @@ func (e *ExternalKey) PubKey() (btcec.PublicKey, error) { return *pubKey, nil } +// Copy returns a deep copy of an ExternalKey. The DerivationPath +// slice is duplicated; the embedded hdkeychain.ExtendedKey is value- +// copied (its internal byte slices are private and never mutated +// externally, so shared references are safe in practice). +func (e ExternalKey) Copy() ExternalKey { + out := ExternalKey{ + XPub: e.XPub, + MasterFingerprint: e.MasterFingerprint, + } + if e.DerivationPath != nil { + out.DerivationPath = make([]uint32, len(e.DerivationPath)) + copy(out.DerivationPath, e.DerivationPath) + } + return out +} + // EqualKeyDescriptors returns true if the two key descriptors are equal. func EqualKeyDescriptors(a, o keychain.KeyDescriptor) bool { if a.KeyLocator != o.KeyLocator { @@ -1259,6 +1298,29 @@ func EqualKeyDescriptors(a, o keychain.KeyDescriptor) bool { return a.PubKey.IsEqual(o.PubKey) } +// CopyPubKey clones a btcec.PublicKey by value-copying its underlying +// secp256k1 struct (two FieldVal coordinates -- entirely value-typed). +// Returns nil if pk is nil. Free function since btcec.PublicKey is a +// foreign type we cannot add methods to. +func CopyPubKey(pk *btcec.PublicKey) *btcec.PublicKey { + if pk == nil { + return nil + } + cpy := *pk + return &cpy +} + +// CopyKeyDescriptor returns a KeyDescriptor whose PubKey points to a +// fresh PublicKey value. KeyLocator is two uint32 fields and copied +// trivially. Free function since keychain.KeyDescriptor is a foreign +// type we cannot add methods to. +func CopyKeyDescriptor(kd keychain.KeyDescriptor) keychain.KeyDescriptor { + return keychain.KeyDescriptor{ + KeyLocator: kd.KeyLocator, + PubKey: CopyPubKey(kd.PubKey), + } +} + // ScriptKeyDerivationMethod is the method used to derive the script key of an // asset send output from the recipient's internal key and the asset ID of // the output. This is used to ensure that the script keys are unique for each @@ -1354,6 +1416,20 @@ func (ts *TweakedScriptKey) IsEqual(other *TweakedScriptKey) bool { return EqualKeyDescriptors(ts.RawKey, other.RawKey) } +// Copy returns a deep copy of *TweakedScriptKey: the raw key +// descriptor is cloned and the tweak bytes are duplicated. Returns +// nil if ts is nil. +func (ts *TweakedScriptKey) Copy() *TweakedScriptKey { + if ts == nil { + return nil + } + return &TweakedScriptKey{ + RawKey: CopyKeyDescriptor(ts.RawKey), + Tweak: bytes.Clone(ts.Tweak), + Type: ts.Type, + } +} + // ScriptKey represents a tweaked Taproot output key encumbering the different // ways an asset can be spent. type ScriptKey struct { @@ -1400,6 +1476,15 @@ func (s *ScriptKey) IsEqual(otherScriptKey *ScriptKey) bool { return s.PubKey.IsEqual(otherScriptKey.PubKey) } +// Copy returns a deep copy of ScriptKey: the tweaked sub-key is +// cloned and the pubkey is value-copied. +func (s ScriptKey) Copy() ScriptKey { + return ScriptKey{ + PubKey: CopyPubKey(s.PubKey), + TweakedScriptKey: s.TweakedScriptKey.Copy(), + } +} + // DeclaredAsKnown returns true if this script key has either been derived by // the local wallet or was explicitly declared to be known by using the // DeclareScriptKey RPC. Knowing the key conceptually means the key belongs to diff --git a/asset/group_key.go b/asset/group_key.go index 3ec14b3e1e..2cf47ea041 100644 --- a/asset/group_key.go +++ b/asset/group_key.go @@ -124,6 +124,29 @@ func (g *GroupKey) IsGroupAnchor(assetID ID) (bool, error) { return expectedGroupPubKey.IsEqual(derivedGroupPubKey), nil } +// Copy returns a deep copy of *GroupKey: all slice/witness +// substructure is cloned and the embedded key descriptor is rebuilt. +// Returns nil if g is nil. +func (g *GroupKey) Copy() *GroupKey { + if g == nil { + return nil + } + out := &GroupKey{ + Version: g.Version, + RawKey: CopyKeyDescriptor(g.RawKey), + GroupPubKey: g.GroupPubKey, + TapscriptRoot: bytes.Clone(g.TapscriptRoot), + CustomTapscriptRoot: g.CustomTapscriptRoot, + } + if g.Witness != nil { + out.Witness = make(wire.TxWitness, len(g.Witness)) + for i, w := range g.Witness { + out.Witness[i] = bytes.Clone(w) + } + } + return out +} + // GroupKeyRequest contains the essential fields used to derive a group key. type GroupKeyRequest struct { // Version is the version of the group key construction. diff --git a/backup/rehydrate.go b/backup/rehydrate.go index 94eda9ae85..c8b6c0e0b8 100644 --- a/backup/rehydrate.go +++ b/backup/rehydrate.go @@ -10,7 +10,7 @@ import ( ) // ChainQuerier is the interface needed to fetch blockchain data during proof -// rehydration. The tapgarden.ChainBridge interface satisfies this. +// rehydration. The tapnode.ChainBridge interface satisfies this. type ChainQuerier interface { // GetBlockByHeight returns a full block given its height. GetBlockByHeight(ctx context.Context, diff --git a/cmd/tapd/main.go b/cmd/tapd/main.go index 4b89dd9e66..e2eb7b8282 100644 --- a/cmd/tapd/main.go +++ b/cmd/tapd/main.go @@ -35,6 +35,19 @@ func main() { os.Exit(0) } + // If the operator has invoked tapd in one-shot repair mode, run + // the requested repair against the database and exit before + // constructing the full server. The repair tool opens the DB + // with migrations skipped, so it can recover a legacy DB whose + // state would otherwise block a migration from applying. + if cfg.Repair != nil && cfg.Repair.CancelDuplicateBatches { + if err := tapcfg.RunRepairTool(cfg, cfgLogger); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) + } + // Enable http profiling server if requested. if cfg.Profile != "" { go func() { diff --git a/docs/release-notes/release-notes-0.9.0.md b/docs/release-notes/release-notes-0.9.0.md new file mode 100644 index 0000000000..39820d17cb --- /dev/null +++ b/docs/release-notes/release-notes-0.9.0.md @@ -0,0 +1,75 @@ +# Release Notes +- [Bug Fixes](#bug-fixes) +- [New Features](#new-features) + - [Functional Enhancements](#functional-enhancements) + - [RPC Additions](#rpc-additions) + - [tapcli Additions](#tapcli-additions) +- [Improvements](#improvements) + - [Functional Updates](#functional-updates) + - [RPC Updates](#rpc-updates) + - [tapcli Updates](#tapcli-updates) + - [Config Changes](#config-changes) + - [Breaking Changes](#breaking-changes) + - [Performance Improvements](#performance-improvements) + - [Deprecations](#deprecations) +- [Technical and Architectural Updates](#technical-and-architectural-updates) + - [BIP/bLIP Spec Updates](#bipblip-spec-updates) + - [Testing](#testing) + - [Database](#database) + - [Code Health](#code-health) + - [Tooling and Documentation](#tooling-and-documentation) + +# Bug Fixes + +* [PR#2153](https://github.com/lightninglabs/taproot-assets/pull/2153) + closes several remaining classes of inconsistent-state bugs in the + minting flow around persistence atomicity, restart idempotence, and + the pre-broadcast batch singleton. + +# New Features + +## Functional Enhancements + +## RPC Additions + +## tapcli Additions + +# Improvements + +## Functional Updates + +## RPC Updates + +## tapcli Updates + +## Config Changes + +## Code Health + +## Breaking Changes + +## Performance Improvements + +## Deprecations + +# Technical and Architectural Updates + +## BIP/bLIP Spec Updates + +## Testing + +## Database + +## Code Health + +* [PR#2153](https://github.com/lightninglabs/taproot-assets/pull/2153) + decomposes the `tapgarden` package, moving node-side interfaces and + proof-verifier helpers into a new `tapnode` package, the receive path + into `tapcustody`, the re-org watcher into `tapreorg`, and routing + supply-commit participation and universe publication through new + `GenesisTxAugmenter` and `MintProofPublisher` interfaces so the two + concerns evolve independently of the minting state machine. + +## Tooling and Documentation + +# Contributors (Alphabetical Order) diff --git a/itest/sign_finalize_psbt_test.go b/itest/sign_finalize_psbt_test.go new file mode 100644 index 0000000000..6b8ba70b11 --- /dev/null +++ b/itest/sign_finalize_psbt_test.go @@ -0,0 +1,90 @@ +package itest + +import ( + "bytes" + "context" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/taproot-assets/lndservices" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/require" +) + +// testSignAndFinalizePsbtDeterministic pins the assumption that +// Wallet.SignAndFinalizePsbt produces byte-identical output when invoked +// twice on the same unsigned PSBT. The minting caretaker's Committed +// branch relies on this on restart: a crash after sign-and-finalize but +// before persisting the signed PSBT causes the next run to re-sign the +// same unsigned PSBT loaded from disk, and we expect the resulting +// signed bytes to match. lnd uses BIP-340 RFC-6979 deterministic +// Schnorr nonces, so this should hold, but it is load-bearing for +// idempotent restart semantics and worth verifying directly. +func testSignAndFinalizePsbtDeterministic(t *harnessTest) { + ctxb := context.Background() + ctx, cancel := context.WithCancel(ctxb) + defer cancel() + + lndClient, err := t.newLndClient(t.tapd.cfg.LndNode) + require.NoError(t.t, err) + defer lndClient.Close() + + walletAnchor := lndservices.NewLndRpcWalletAnchor( + &lndClient.LndServices, + ) + + // Build a minimal unsigned tx with one P2TR-shaped dummy output; + // lnd will fund it by adding a wallet input and a change output. + dummyScript := append( + []byte{txscript.OP_1, txscript.OP_DATA_32}, + bytes.Repeat([]byte{0x00}, 32)..., + ) + tx := wire.NewMsgTx(2) + tx.AddTxOut(&wire.TxOut{ + Value: int64(btcutil.Amount(1000)), + PkScript: dummyScript, + }) + + unsignedPkt, err := psbt.NewFromUnsignedTx(tx) + require.NoError(t.t, err) + + fundedPkt, err := walletAnchor.FundPsbt( + ctx, unsignedPkt, 1, chainfee.SatPerKWeight(3000), -1, + ) + require.NoError(t.t, err) + + // SignAndFinalizePsbt mutates the input, so each call gets its + // own deep-cloned copy of the unsigned-but-funded PSBT. Round- + // tripping through Serialize/NewFromRawBytes is the cleanest way + // to get an independent value. + clonePsbt := func(p *psbt.Packet) *psbt.Packet { + var buf bytes.Buffer + require.NoError(t.t, p.Serialize(&buf)) + clone, err := psbt.NewFromRawBytes( + bytes.NewReader(buf.Bytes()), false, + ) + require.NoError(t.t, err) + return clone + } + + signed1, err := walletAnchor.SignAndFinalizePsbt( + ctx, clonePsbt(fundedPkt.Pkt), + ) + require.NoError(t.t, err) + + signed2, err := walletAnchor.SignAndFinalizePsbt( + ctx, clonePsbt(fundedPkt.Pkt), + ) + require.NoError(t.t, err) + + var buf1, buf2 bytes.Buffer + require.NoError(t.t, signed1.Serialize(&buf1)) + require.NoError(t.t, signed2.Serialize(&buf2)) + + require.Equal(t.t, buf1.Bytes(), buf2.Bytes(), + "SignAndFinalizePsbt must produce byte-identical output "+ + "for the same unsigned input; the minting caretaker "+ + "restart path relies on this") +} diff --git a/itest/supply_commit_test.go b/itest/supply_commit_test.go index 9ef320f4f1..bda1bae463 100644 --- a/itest/supply_commit_test.go +++ b/itest/supply_commit_test.go @@ -18,7 +18,6 @@ import ( "github.com/lightninglabs/taproot-assets/mssmt" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/rpcserver" - "github.com/lightninglabs/taproot-assets/tapgarden" "github.com/lightninglabs/taproot-assets/taprpc" "github.com/lightninglabs/taproot-assets/taprpc/mintrpc" unirpc "github.com/lightninglabs/taproot-assets/taprpc/universerpc" @@ -65,7 +64,7 @@ func assertAnchorTxPreCommitOut( ) require.NoError(t.t, err) - expectedTxOut, err := tapgarden.PreCommitTxOut(*delegationKey) + expectedTxOut, err := supplycommit.PreCommitTxOut(*delegationKey) require.NoError(t.t, err) // The pre-commitment output should be present in the anchor tx exactly diff --git a/itest/test_list_on_test.go b/itest/test_list_on_test.go index 070dcd8e92..2969281bf2 100644 --- a/itest/test_list_on_test.go +++ b/itest/test_list_on_test.go @@ -53,6 +53,10 @@ var allTestCases = []*testCase{ name: "mint external group key chantools", test: testMintExternalGroupKeyChantools, }, + { + name: "sign and finalize psbt deterministic", + test: testSignAndFinalizePsbtDeterministic, + }, { name: "mint asset decimal display", test: testMintAssetWithDecimalDisplayMetaField, diff --git a/lndservices/chain_bridge.go b/lndservices/chain_bridge.go index 68e7146727..a3058db6f7 100644 --- a/lndservices/chain_bridge.go +++ b/lndservices/chain_bridge.go @@ -14,7 +14,7 @@ import ( "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapdb" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/lnwallet/chainfee" ) @@ -31,7 +31,7 @@ var ( errTxNotFound = fmt.Errorf("transaction not found in proof file") ) -// LndRpcChainBridge is an implementation of the tapgarden.ChainBridge +// LndRpcChainBridge is an implementation of the tapnode.ChainBridge // interface backed by an active remote lnd node. type LndRpcChainBridge struct { // lnd is the active lnd services client. @@ -371,14 +371,14 @@ func (l *LndRpcChainBridge) GenProofChainLookup( } // A compile time assertion to ensure LndRpcChainBridge meets the -// tapgarden.ChainBridge interface. -var _ tapgarden.ChainBridge = (*LndRpcChainBridge)(nil) +// tapnode.ChainBridge interface. +var _ tapnode.ChainBridge = (*LndRpcChainBridge)(nil) // ProofChainLookup is an implementation of the asset.ChainLookup interface // that uses a proof file to look up block height information of previous inputs // while validating proofs. type ProofChainLookup struct { - chainBridge tapgarden.ChainBridge + chainBridge tapnode.ChainBridge assetStore *tapdb.AssetStore @@ -386,7 +386,7 @@ type ProofChainLookup struct { } // NewProofChainLookup creates a new ProofChainLookup instance. -func NewProofChainLookup(chainBridge tapgarden.ChainBridge, +func NewProofChainLookup(chainBridge tapnode.ChainBridge, assetStore *tapdb.AssetStore, proofFile *proof.File) *ProofChainLookup { return &ProofChainLookup{ diff --git a/lndservices/key_ring.go b/lndservices/key_ring.go index 21c7e3a584..140e9a9d21 100644 --- a/lndservices/key_ring.go +++ b/lndservices/key_ring.go @@ -8,7 +8,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/taproot-assets/asset" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightningnetwork/lnd/keychain" ) @@ -108,5 +108,5 @@ func (l *LndRpcKeyRing) DeriveSharedKey(ctx context.Context, } // A compile time assertion to ensure LndRpcKeyRing meets the -// tapgarden.KeyRing interface. -var _ tapgarden.KeyRing = (*LndRpcKeyRing)(nil) +// tapnode.KeyRing interface. +var _ tapnode.KeyRing = (*LndRpcKeyRing)(nil) diff --git a/lndservices/wallet_anchor.go b/lndservices/wallet_anchor.go index fe6bf2e973..94b937fdd6 100644 --- a/lndservices/wallet_anchor.go +++ b/lndservices/wallet_anchor.go @@ -14,7 +14,7 @@ import ( "github.com/lightninglabs/lndclient" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/tapfreighter" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lnwallet" @@ -28,7 +28,7 @@ import ( // useful in high fee environments where we'd otherwise fail to fund the psbt. const DefaultPsbtMaxFeeRatio = 0.75 -// LndRpcWalletAnchor is an implementation of the tapgarden.WalletAnchor +// LndRpcWalletAnchor is an implementation of the tapnode.WalletAnchor // interfaced backed by an active remote lnd node. type LndRpcWalletAnchor struct { lnd *lndclient.LndServices @@ -275,7 +275,7 @@ func (l *LndRpcWalletAnchor) MinRelayFee( } // A compile time assertion to ensure LndRpcWalletAnchor meets the -// tapgarden.WalletAnchor interface. -var _ tapgarden.WalletAnchor = (*LndRpcWalletAnchor)(nil) +// tapnode.WalletAnchor interface. +var _ tapnode.WalletAnchor = (*LndRpcWalletAnchor)(nil) var _ tapfreighter.WalletAnchor = (*LndRpcWalletAnchor)(nil) diff --git a/log.go b/log.go index 8c9d4a9651..b155c0ce96 100644 --- a/log.go +++ b/log.go @@ -13,9 +13,11 @@ import ( "github.com/lightninglabs/taproot-assets/rfq" "github.com/lightninglabs/taproot-assets/rpcserver" "github.com/lightninglabs/taproot-assets/tapchannel" + "github.com/lightninglabs/taproot-assets/tapcustody" "github.com/lightninglabs/taproot-assets/tapdb" "github.com/lightninglabs/taproot-assets/tapfreighter" "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapreorg" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightninglabs/taproot-assets/universe" "github.com/lightninglabs/taproot-assets/universe/supplycommit" @@ -109,6 +111,12 @@ func SetupLoggers(root *build.SubLoggerManager, AddSubLogger( root, tapgarden.Subsystem, interceptor, tapgarden.UseLogger, ) + AddSubLogger( + root, tapcustody.Subsystem, interceptor, tapcustody.UseLogger, + ) + AddSubLogger( + root, tapreorg.Subsystem, interceptor, tapreorg.UseLogger, + ) AddSubLogger( root, tapfreighter.Subsystem, interceptor, tapfreighter.UseLogger, diff --git a/monitoring/config.go b/monitoring/config.go index 8137f513d4..c40fdf6d3f 100644 --- a/monitoring/config.go +++ b/monitoring/config.go @@ -41,7 +41,7 @@ type PrometheusConfig struct { // AssetMinter is used to collect any stats that are relevant to the // asset minter. - AssetMinter tapgarden.Planter + AssetMinter *tapgarden.ChainPlanter // CacheStats is a function that can be used to collect cache stats // from the daemon. This is used to export cache hits and misses for diff --git a/proof/meta.go b/proof/meta.go index cb24c918d7..a3e97a33b7 100644 --- a/proof/meta.go +++ b/proof/meta.go @@ -174,6 +174,39 @@ type MetaReveal struct { UnknownOddTypes tlv.TypeMap } +// Copy returns a deep copy of *MetaReveal: data slice, the optional +// canonical-universes slice, and the unknown-types byte map are all +// duplicated. The Option-wrapped DelegationKey contains a value-typed +// PublicKey copied trivially when the Option is assigned. Returns +// nil if m is nil. +func (m *MetaReveal) Copy() *MetaReveal { + if m == nil { + return nil + } + out := &MetaReveal{ + Type: m.Type, + Data: bytes.Clone(m.Data), + DecimalDisplay: m.DecimalDisplay, + UniverseCommitments: m.UniverseCommitments, + DelegationKey: m.DelegationKey, + } + m.CanonicalUniverses.WhenSome(func(urls []url.URL) { + cloned := make([]url.URL, len(urls)) + copy(cloned, urls) + out.CanonicalUniverses = fn.Some(cloned) + }) + // Preserve the empty-vs-nil distinction: an explicitly-empty + // TypeMap is observably different from a nil one under + // reflect.DeepEqual, and AssertCopyEqual catches the collapse. + if m.UnknownOddTypes != nil { + out.UnknownOddTypes = make(tlv.TypeMap, len(m.UnknownOddTypes)) + for k, v := range m.UnknownOddTypes { + out.UnknownOddTypes[k] = bytes.Clone(v) + } + } + return out +} + // Validate validates the meta reveal. func (m *MetaReveal) Validate() error { // A meta reveal is allowed to be nil. diff --git a/rpcserver/rpcserver.go b/rpcserver/rpcserver.go index 6b195ab3a5..dbee70a943 100644 --- a/rpcserver/rpcserver.go +++ b/rpcserver/rpcserver.go @@ -45,9 +45,11 @@ import ( "github.com/lightninglabs/taproot-assets/rpcutils" "github.com/lightninglabs/taproot-assets/tapchannel" "github.com/lightninglabs/taproot-assets/tapconfig" + "github.com/lightninglabs/taproot-assets/tapcustody" "github.com/lightninglabs/taproot-assets/tapdb" "github.com/lightninglabs/taproot-assets/tapfreighter" "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/taprpc" wrpc "github.com/lightninglabs/taproot-assets/taprpc/assetwalletrpc" @@ -851,7 +853,7 @@ func (r *RPCServer) FundBatch(ctx context.Context, return nil, err } - fundBatchResp, err := r.cfg.AssetMinter.FundBatch(tapgarden.FundParams{ + verboseBatch, err := r.cfg.AssetMinter.FundBatch(tapgarden.FundParams{ FeeRate: feeRateOpt, SiblingTapTree: tapTreeOpt, }) @@ -860,12 +862,12 @@ func (r *RPCServer) FundBatch(ctx context.Context, } // If there was no batch to fund, return an empty response. - if fundBatchResp.Batch == nil { + if verboseBatch == nil { return &mintrpc.FundBatchResponse{}, nil } rpcBatch, err := marshalVerboseBatch( - *r.cfg.ChainParams.Params, fundBatchResp.Batch, + *r.cfg.ChainParams.Params, verboseBatch, !req.ShortResponse, req.ShortResponse, ) if err != nil { @@ -1992,22 +1994,22 @@ func (r *RPCServer) NewAddr(ctx context.Context, waitCtx, waitCancel := context.WithTimeout(ctx, addrImportTimeout) defer waitCancel() - status, err := tapgarden.WaitForAddrImport(waitCtx, importSub, addrStr) + status, err := tapcustody.WaitForAddrImport(waitCtx, importSub, addrStr) if err != nil { return nil, fmt.Errorf("waiting for addr import: %w", err) } switch status { // The custodian imported the address successfully; continue. - case tapgarden.AddrImportStatusSuccess: + case tapcustody.AddrImportStatusSuccess: // We timed out or were canceled; the import outcome is unknown. - case tapgarden.AddrImportStatusUndefined: + case tapcustody.AddrImportStatusUndefined: return nil, fmt.Errorf("address import status unknown, " + "check logs") // An error event was seen; it is already wrapped above. - case tapgarden.AddrImportStatusError: + case tapcustody.AddrImportStatusError: return nil, fmt.Errorf("address import failed") } @@ -5341,7 +5343,7 @@ func (r *RPCServer) SubscribeReceiveAssetEventNtfns( case *proof.BackoffWaitEvent: return true, nil - case *tapgarden.AssetReceiveEvent: + case *tapcustody.AssetReceiveEvent: return e.Status == address.StatusCompleted, nil default: @@ -5389,7 +5391,7 @@ func (r *RPCServer) SubscribeReceiveEvents( } marshaler := func(event fn.Event) (*taprpc.ReceiveEvent, error) { - e, ok := event.(*tapgarden.AssetReceiveEvent) + e, ok := event.(*tapcustody.AssetReceiveEvent) if !ok { return nil, fmt.Errorf("invalid event type: %T", event) } @@ -5427,7 +5429,7 @@ func (r *RPCServer) SubscribeReceiveEvents( ) switch e := event.(type) { - case *tapgarden.AssetReceiveEvent: + case *tapcustody.AssetReceiveEvent: eventAddrString, err = e.Address.EncodeAddress() if err != nil { return false, fmt.Errorf("error encoding "+ @@ -5635,7 +5637,7 @@ func (r *RPCServer) SubscribeMintEvents(req *mintrpc.SubscribeMintEventsRequest, return nil, fmt.Errorf("invalid event type: %T", event) } - rpcState, err := marshalBatchState(e.BatchState) + rpcState, err := marshalBatchState(e.Batch.State()) if err != nil { return nil, fmt.Errorf("error marshaling batch state: "+ "%w", err) @@ -5790,7 +5792,7 @@ func marshallReceiveAssetEvent(event fn.Event, }, }, nil - case *tapgarden.AssetReceiveEvent: + case *tapcustody.AssetReceiveEvent: rpcAddr, err := marshalAddr(&e.Address, db) if err != nil { return nil, fmt.Errorf("error marshaling addr: %w", err) @@ -6177,14 +6179,14 @@ func marshalUnsealedSeedling(params chaincfg.Params, verbose bool, if verbose && seedling.PendingAssetGroup != nil { groupVirtualTx, err = rpcutils.MarshalGroupVirtualTx( - &seedling.PendingAssetGroup.GroupVirtualTx, + &seedling.PendingAssetGroup.VirtualTx, ) if err != nil { return nil, err } groupReq, err = rpcutils.MarshalGroupKeyRequest( - &seedling.PendingAssetGroup.GroupKeyRequest, + &seedling.PendingAssetGroup.KeyRequest, ) if err != nil { return nil, err @@ -11349,8 +11351,8 @@ func (r *RPCServer) RegisterTransfer(ctx context.Context, // ProofVerifierCtx returns a proof.VerifierCtx that can be used to verify // proofs in the RPC server. func (r *RPCServer) ProofVerifierCtx(ctx context.Context) proof.VerifierCtx { - headerVerifier := tapgarden.GenHeaderVerifier(ctx, r.cfg.ChainBridge) - groupVerifier := tapgarden.GenGroupVerifier(ctx, r.cfg.MintingStore) + headerVerifier := tapnode.GenHeaderVerifier(ctx, r.cfg.ChainBridge) + groupVerifier := tapnode.GenGroupVerifier(ctx, r.cfg.MintingStore) var ignoreChecker proof.IgnoreChecker = r.cfg.IgnoreChecker return proof.VerifierCtx{ diff --git a/sample-tapd.conf b/sample-tapd.conf index bb3abef038..84132fdc4a 100644 --- a/sample-tapd.conf +++ b/sample-tapd.conf @@ -524,6 +524,16 @@ ; required precision ; experimental.rfq.mockoraclesatsperasset= +[repair] + +; One-shot recovery: when set, tapd cancels all but the most recent +; minting batch in BatchStatePending or BatchStateFrozen and then +; exits. Used to recover a legacy database that violates the +; singleton pre-broadcast batch invariant added in migration 000060 +; (e.g. one with duplicate pending batches that blocks the +; migration). Default value is false. +; repair.cancel-duplicate-batches=false + [healthcheck] ; The number of times we should attempt to check for certificate expiration before diff --git a/tapcfg/config.go b/tapcfg/config.go index 47a688ea73..91bd560810 100644 --- a/tapcfg/config.go +++ b/tapcfg/config.go @@ -359,6 +359,15 @@ type ExperimentalConfig struct { Rfq rfq.CliConfig `group:"rfq" namespace:"rfq"` } +// RepairConfig houses one-shot recovery flags that, when set, cause +// tapd to perform a targeted repair action against the database and +// exit before constructing the full server. These flags are intended +// for operator use after a constraint or invariant failure has +// prevented normal startup. +type RepairConfig struct { + CancelDuplicateBatches bool `long:"cancel-duplicate-batches" description:"If set, tapd cancels all but the most recent minting batch in BatchStatePending or BatchStateFrozen and then exits. Used to recover from a database that violates the singleton pre-broadcast batch invariant added in migration 000060 (e.g. a legacy DB with duplicate pending batches that blocks the migration)."` +} + // CleanAndValidate performs final processing on the ExperimentalConfig, // returning an error if the configuration is invalid. func (c *ExperimentalConfig) CleanAndValidate() error { @@ -419,6 +428,8 @@ type Config struct { Experimental *ExperimentalConfig `group:"experimental" namespace:"experimental"` + Repair *RepairConfig `group:"repair" namespace:"repair"` + HealthChecks *HealthCheckConfig `group:"healthcheck" namespace:"healthcheck"` // LogWriter is the root logger that all of the daemon's subloggers are @@ -539,6 +550,7 @@ func DefaultConfig() Config { AcceptPriceDeviationPpm: rfq.DefaultAcceptPriceDeviationPpm, }, }, + Repair: &RepairConfig{}, HealthChecks: &HealthCheckConfig{ TLSCheck: &CheckConfig{ Interval: defaultTLSInterval, diff --git a/tapcfg/repair.go b/tapcfg/repair.go new file mode 100644 index 0000000000..74f3181f30 --- /dev/null +++ b/tapcfg/repair.go @@ -0,0 +1,132 @@ +package tapcfg + +import ( + "context" + "database/sql" + "fmt" + "sort" + "time" + + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/taproot-assets/tapdb" + "github.com/lightninglabs/taproot-assets/tapgarden" +) + +// RunRepairTool inspects the configured database for batches that +// violate the singleton "≤ 1 in {Pending, Frozen}" invariant added +// in migration 000060, and cancels all but the most recent. The +// preserved batch is the one with the latest CreationTime; cancelled +// batches transition to BatchStateSeedlingCancelled, leaving their +// row and seedlings on disk for later inspection. +// +// The function opens the database with migrations skipped, so it can +// run against a legacy database whose state would otherwise fail the +// migration. After this tool exits cleanly, restarting tapd normally +// will let migration 000060 succeed. +func RunRepairTool(cfg *Config, cfgLogger btclog.Logger) error { + // Open the database with migrations skipped. We want to inspect + // and repair a database whose state would otherwise prevent + // migration 000060 from applying; running migrations as part of + // opening the DB would defeat the purpose. + var ( + db tapdb.DatabaseBackend + err error + ) + switch cfg.DatabaseBackend { + case DatabaseBackendSqlite: + sqliteCfg := *cfg.Sqlite + sqliteCfg.SkipMigrations = true + cfgLogger.Infof("repair: opening sqlite3 database at %v "+ + "(migrations skipped)", + sqliteCfg.DatabaseFileName) + db, err = tapdb.NewSqliteStore(&sqliteCfg) + + case DatabaseBackendPostgres: + pgCfg := *cfg.Postgres + pgCfg.SkipMigrations = true + cfgLogger.Infof("repair: opening postgres database " + + "(migrations skipped)") + db, err = tapdb.NewPostgresStore(&pgCfg) + + default: + return fmt.Errorf("unknown database backend: %s", + cfg.DatabaseBackend) + } + if err != nil { + return fmt.Errorf("repair: unable to open database: %w", err) + } + + mintingExec := tapdb.NewTransactionExecutor( + db, func(tx *sql.Tx) tapdb.PendingAssetStore { + return db.WithTx(tx) + }, + ) + store := tapdb.NewAssetMintingStore(mintingExec) + + ctx := context.Background() + nonFinal, err := store.FetchNonFinalBatches(ctx) + if err != nil { + return fmt.Errorf("repair: unable to fetch non-final "+ + "batches: %w", err) + } + + var preBroadcast []*tapgarden.MintingBatch + for _, batch := range nonFinal { + switch batch.State() { + case tapgarden.BatchStatePending, + tapgarden.BatchStateFrozen: + + preBroadcast = append(preBroadcast, batch) + + default: + // Post-broadcast or terminal states are outside the + // singleton constraint and have nothing to repair. + } + } + + if len(preBroadcast) <= 1 { + cfgLogger.Infof("repair: nothing to do; found %d batches "+ + "in pre-broadcast state", len(preBroadcast)) + return nil + } + + // Sort newest-first by CreationTime; preserve [0], cancel the + // rest. Picking the most recent matches the user's most recent + // intent, which is most likely to be the one they want to + // continue working with. + sort.Slice(preBroadcast, func(i, j int) bool { + return preBroadcast[i].CreationTime.After( + preBroadcast[j].CreationTime, + ) + }) + + preserved := preBroadcast[0] + cfgLogger.Infof("repair: preserving most recent pre-broadcast "+ + "batch %x (state=%v, created=%s)", + preserved.BatchKey.PubKey.SerializeCompressed(), + preserved.State(), + preserved.CreationTime.Format(time.RFC3339)) + + for _, batch := range preBroadcast[1:] { + cfgLogger.Warnf("repair: cancelling pre-broadcast batch "+ + "%x (state=%v, created=%s)", + batch.BatchKey.PubKey.SerializeCompressed(), + batch.State(), + batch.CreationTime.Format(time.RFC3339)) + + err := store.UpdateBatchState( + ctx, batch, tapgarden.BatchStateSeedlingCancelled, + ) + if err != nil { + return fmt.Errorf("repair: unable to cancel batch "+ + "%x: %w", + batch.BatchKey.PubKey.SerializeCompressed(), + err) + } + } + + cfgLogger.Infof("repair: complete; cancelled %d duplicate "+ + "batches, preserved 1. Restart tapd normally to let "+ + "migration 000060 apply.", len(preBroadcast)-1) + return nil +} diff --git a/tapcfg/server.go b/tapcfg/server.go index 2b0aed1932..7b13747c4a 100644 --- a/tapcfg/server.go +++ b/tapcfg/server.go @@ -23,13 +23,17 @@ import ( "github.com/lightninglabs/taproot-assets/rpcserver" "github.com/lightninglabs/taproot-assets/tapchannel" "github.com/lightninglabs/taproot-assets/tapconfig" + "github.com/lightninglabs/taproot-assets/tapcustody" "github.com/lightninglabs/taproot-assets/tapdb" "github.com/lightninglabs/taproot-assets/tapdb/sqlc" "github.com/lightninglabs/taproot-assets/tapfeatures" "github.com/lightninglabs/taproot-assets/tapfreighter" "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" + "github.com/lightninglabs/taproot-assets/tapreorg" "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/universe" + "github.com/lightninglabs/taproot-assets/universe/mintpublish" "github.com/lightninglabs/taproot-assets/universe/supplycommit" "github.com/lightninglabs/taproot-assets/universe/supplyverifier" "github.com/lightningnetwork/lnd/clock" @@ -217,10 +221,10 @@ func genServerConfig(cfg *Config, cfgLogger btclog.Logger, uniStatsDB, defaultClock, statsOpts..., ) - headerVerifier := tapgarden.GenHeaderVerifier( + headerVerifier := tapnode.GenHeaderVerifier( context.Background(), chainBridge, ) - groupVerifier := tapgarden.GenGroupVerifier( + groupVerifier := tapnode.GenGroupVerifier( context.Background(), assetMintingStore, ) @@ -399,7 +403,7 @@ func genServerConfig(cfg *Config, cfgLogger btclog.Logger, } } - reOrgWatcher := tapgarden.NewReOrgWatcher(&tapgarden.ReOrgWatcherConfig{ + reOrgWatcher := tapreorg.NewWatcher(&tapreorg.Config{ ChainBridge: chainBridge, GroupVerifier: groupVerifier, ProofArchive: proofArchive, @@ -805,25 +809,35 @@ func genServerConfig(cfg *Config, cfgLogger btclog.Logger, GardenKit: tapgarden.GardenKit{ Wallet: walletAnchor, ChainBridge: chainBridge, - Log: assetMintingStore, + BatchStore: assetMintingStore, + MintingRefs: assetMintingStore, TreeStore: assetMintingStore, KeyRing: keyRing, GenSigner: virtualTxSigner, GenTxBuilder: &tapscript.GroupTxBuilder{}, TxValidator: &tap.ValidatorV0{}, - ProofFiles: proofFileStore, - Universe: universeFederation, - ProofWatcher: reOrgWatcher, - UniversePushBatchSize: defaultUniverseSyncBatchSize, - IgnoreChecker: ignoreCheckerOpt, - MintSupplyCommitter: supplyCommitManager, - DelegationKeyChecker: addrBook, + ProofFiles: proofFileStore, + MintProofPublisher: mintpublish.NewPublisher( + universeFederation, + defaultUniverseSyncBatchSize, + ), + ProofWatcher: reOrgWatcher, + IgnoreChecker: ignoreCheckerOpt, + GenesisTxAugmenter: supplycommit.NewGenesisAugmenter( + supplycommit.GenesisAugmenterCfg{ + PreCommitStore: tapdb.NewSupplyPreCommitStore(mintingStore), + KeyRing: keyRing, + DelegationKeyChecker: addrBook, + MintEvents: supplyCommitManager, + ChainParams: tapChainParams, + }, + ), }, ChainParams: tapChainParams, ProofUpdates: proofArchive, ErrChan: mainErrChan, }), - AssetCustodian: tapgarden.NewCustodian(&tapgarden.CustodianConfig{ + AssetCustodian: tapcustody.NewCustodian(&tapcustody.Config{ ChainParams: &tapChainParams, WalletAnchor: walletAnchor, ChainBridge: chainBridge, diff --git a/tapchannel/aux_closer.go b/tapchannel/aux_closer.go index 332a0e3371..1bb70db6ca 100644 --- a/tapchannel/aux_closer.go +++ b/tapchannel/aux_closer.go @@ -17,7 +17,7 @@ import ( "github.com/lightninglabs/taproot-assets/tapchannelmsg" "github.com/lightninglabs/taproot-assets/tapfeatures" "github.com/lightninglabs/taproot-assets/tapfreighter" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/tapsend" @@ -63,7 +63,7 @@ type AuxChanCloserCfg struct { GroupVerifier proof.GroupVerifier // ChainBridge is used to fetch blocks from the main chain. - ChainBridge tapgarden.ChainBridge + ChainBridge tapnode.ChainBridge // IgnoreChecker is an optional function that can be used to check if // a proof should be ignored. diff --git a/tapchannel/aux_funding_controller.go b/tapchannel/aux_funding_controller.go index 6ec7ca6fa1..f371f311d4 100644 --- a/tapchannel/aux_funding_controller.go +++ b/tapchannel/aux_funding_controller.go @@ -30,7 +30,7 @@ import ( "github.com/lightninglabs/taproot-assets/tapdb" "github.com/lightninglabs/taproot-assets/tapfeatures" "github.com/lightninglabs/taproot-assets/tapfreighter" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightninglabs/taproot-assets/vm" @@ -671,7 +671,7 @@ func (p *pendingAssetFunding) toAuxFundingDesc(req *bindFundingReq, // unlockInputs unlocks any inputs that were locked during the funding process. func (p *pendingAssetFunding) unlockInputs(ctx context.Context, - wallet tapgarden.WalletAnchor) error { + wallet tapnode.WalletAnchor) error { for _, outpoint := range p.lockedInputs { if err := wallet.UnlockInput(ctx, outpoint); err != nil { diff --git a/tapchannel/aux_sweeper.go b/tapchannel/aux_sweeper.go index 2cd2906aad..b4f32a45f5 100644 --- a/tapchannel/aux_sweeper.go +++ b/tapchannel/aux_sweeper.go @@ -22,7 +22,7 @@ import ( "github.com/lightninglabs/taproot-assets/tapchannelmsg" cmsg "github.com/lightninglabs/taproot-assets/tapchannelmsg" "github.com/lightninglabs/taproot-assets/tapfreighter" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/tapsend" @@ -128,7 +128,7 @@ type AuxSweeperCfg struct { GroupVerifier proof.GroupVerifier // ChainBridge is used to fetch blocks from the main chain. - ChainBridge tapgarden.ChainBridge + ChainBridge tapnode.ChainBridge // IgnoreChecker is an optional function that can be used to check if // a proof should be ignored. @@ -1095,7 +1095,7 @@ func assetOutputToVPacket(fundingInputProofs map[asset.ID]*proof.Proof, // a synthetic commitment template. Once the real commitment transaction is // known, we rewrite the proof's anchor transaction and output indexes. func reanchorAssetOutputs(ctx context.Context, - chainBridge tapgarden.ChainBridge, commitTx wire.MsgTx, + chainBridge tapnode.ChainBridge, commitTx wire.MsgTx, commitTxBlockHeight uint32, outputs []*cmsg.AssetOutput) error { if len(outputs) == 0 { @@ -1402,7 +1402,7 @@ func (a *AuxSweeper) importOutputScriptKeys(desc tapscriptSweepDescs) error { // into our local database. This preps us to be able to detect force closes. func importOutputProofs(ctx context.Context, scid lnwire.ShortChannelID, outputProofs []*proof.Proof, courierAddr *url.URL, - proofDispatch proof.CourierDispatch, chainBridge tapgarden.ChainBridge, + proofDispatch proof.CourierDispatch, chainBridge tapnode.ChainBridge, vCtx proof.VerifierCtx, proofArchive proof.Archiver) error { // TODO(roasbeef): should be part of post confirmation funding validate diff --git a/tapchannel/proof_utils.go b/tapchannel/proof_utils.go index 9e04fb4129..f2a4014c95 100644 --- a/tapchannel/proof_utils.go +++ b/tapchannel/proof_utils.go @@ -6,14 +6,14 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/taproot-assets/proof" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightningnetwork/lnd/lnwire" ) // proofParamsForShortChanID creates proof params using the block referenced by // the given short channel ID. func proofParamsForShortChanID(ctx context.Context, - chainBridge tapgarden.ChainBridge, + chainBridge tapnode.ChainBridge, scid lnwire.ShortChannelID) (proof.BaseProofParams, error) { var zero proof.BaseProofParams @@ -40,7 +40,7 @@ func proofParamsForShortChanID(ctx context.Context, // updateProofsFromShortChanID fills the block-related fields on the provided // proofs using the funding transaction identified by the short channel ID. func updateProofsFromShortChanID(ctx context.Context, - chainBridge tapgarden.ChainBridge, scid lnwire.ShortChannelID, + chainBridge tapnode.ChainBridge, scid lnwire.ShortChannelID, proofs []*proof.Proof) error { if len(proofs) == 0 { @@ -71,7 +71,7 @@ func updateProofsFromShortChanID(ctx context.Context, // commitment transaction. The transaction must be included in the block at that // height. func proofParamsForCommitTx(ctx context.Context, - chainBridge tapgarden.ChainBridge, blockHeight uint32, + chainBridge tapnode.ChainBridge, blockHeight uint32, commitTx wire.MsgTx) (proof.BaseProofParams, error) { var zero proof.BaseProofParams diff --git a/tapconfig/config.go b/tapconfig/config.go index 8b054778ff..5a6ad80093 100644 --- a/tapconfig/config.go +++ b/tapconfig/config.go @@ -16,10 +16,13 @@ import ( "github.com/lightninglabs/taproot-assets/rfq" "github.com/lightninglabs/taproot-assets/rpcperms" "github.com/lightninglabs/taproot-assets/tapchannel" + "github.com/lightninglabs/taproot-assets/tapcustody" "github.com/lightninglabs/taproot-assets/tapdb" "github.com/lightninglabs/taproot-assets/tapfeatures" "github.com/lightninglabs/taproot-assets/tapfreighter" "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" + "github.com/lightninglabs/taproot-assets/tapreorg" "github.com/lightninglabs/taproot-assets/universe" "github.com/lightninglabs/taproot-assets/universe/supplycommit" "github.com/lightninglabs/taproot-assets/universe/supplyverifier" @@ -100,7 +103,7 @@ type RPCConfig struct { type DatabaseConfig struct { RootKeyStore *tapdb.RootKeyStore - MintingStore tapgarden.MintingStore + MintingStore *tapdb.AssetMintingStore AssetStore *tapdb.AssetStore @@ -200,13 +203,13 @@ type Config struct { MboxServerConfig authmailbox.ServerConfig - ReOrgWatcher *tapgarden.ReOrgWatcher + ReOrgWatcher *tapreorg.Watcher - AssetMinter tapgarden.Planter + AssetMinter *tapgarden.ChainPlanter - AssetCustodian *tapgarden.Custodian + AssetCustodian *tapcustody.Custodian - ChainBridge tapgarden.ChainBridge + ChainBridge tapnode.ChainBridge AddrBook *address.Book diff --git a/tapgarden/custodian.go b/tapcustody/custodian.go similarity index 99% rename from tapgarden/custodian.go rename to tapcustody/custodian.go index 63b0497316..6e92f5ba74 100644 --- a/tapgarden/custodian.go +++ b/tapcustody/custodian.go @@ -1,4 +1,4 @@ -package tapgarden +package tapcustody import ( "context" @@ -20,6 +20,7 @@ import ( "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/internal/ecies" "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tapnode" lfn "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnrpc" @@ -37,6 +38,10 @@ const ( // txResubscriptionDelay is the delay we wait before attempting to // resubscribe to the transaction stream after it has ended. txResubscriptionDelay = 10 * time.Second + + // DefaultTimeout is the default timeout we use for RPC and database + // operations. + DefaultTimeout = 30 * time.Second ) // AssetReceiveEvent is an event that is sent to a subscriber once the @@ -162,19 +167,19 @@ func (e *AddrImportCompleteEvent) Timestamp() time.Time { // Ensure that AddrImportCompleteEvent implements the Event interface. var _ fn.Event = (*AddrImportCompleteEvent)(nil) -// CustodianConfig houses all the items that the Custodian needs to carry out -// its duties. -type CustodianConfig struct { +// Config houses all the items that the Custodian needs to carry out its +// duties. +type Config struct { // ChainParams are the Taproot Asset specific chain parameters. ChainParams *address.ChainParams // WalletAnchor is the main interface for interacting with the on-chain // wallet. - WalletAnchor WalletAnchor + WalletAnchor tapnode.WalletAnchor // ChainBridge is the main interface for interacting with the chain // backend. - ChainBridge ChainBridge + ChainBridge tapnode.ChainBridge // GroupVerifier is used to verify the validity of the group key for an // asset. @@ -238,7 +243,7 @@ type Custodian struct { startOnce sync.Once stopOnce sync.Once - cfg *CustodianConfig + cfg *Config // addrSubscription is the subscription queue through which we receive // events about new addresses being created (and we also receive all @@ -277,7 +282,7 @@ type Custodian struct { // NewCustodian creates a new Taproot Asset custodian based on the passed // config. -func NewCustodian(cfg *CustodianConfig) *Custodian { +func NewCustodian(cfg *Config) *Custodian { addrSub := fn.NewEventReceiver[*address.AddrWithKeyInfo]( fn.DefaultQueueSize, ) @@ -1918,7 +1923,7 @@ func EventMatchesProof(event *address.Event, p *proof.Proof) bool { // verifierCtx returns a verifier context that can be used to verify proofs. func (c *Custodian) verifierCtx(ctx context.Context) proof.VerifierCtx { - headerVerifier := GenHeaderVerifier(ctx, c.cfg.ChainBridge) + headerVerifier := tapnode.GenHeaderVerifier(ctx, c.cfg.ChainBridge) merkleVerifier := proof.DefaultMerkleVerifier return proof.VerifierCtx{ diff --git a/tapgarden/custodian_test.go b/tapcustody/custodian_test.go similarity index 96% rename from tapgarden/custodian_test.go rename to tapcustody/custodian_test.go index 3a6a03f276..689dc3d667 100644 --- a/tapgarden/custodian_test.go +++ b/tapcustody/custodian_test.go @@ -1,4 +1,4 @@ -package tapgarden_test +package tapcustody_test import ( "bytes" @@ -25,8 +25,10 @@ import ( "github.com/lightninglabs/taproot-assets/internal/ecies" "github.com/lightninglabs/taproot-assets/internal/test" "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tapcustody" "github.com/lightninglabs/taproot-assets/tapdb" "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode/tapnodemock" "github.com/lightninglabs/taproot-assets/universe" "github.com/lightningnetwork/lnd/clock" "github.com/lightningnetwork/lnd/keychain" @@ -38,14 +40,15 @@ import ( var ( testPollInterval = 20 * time.Millisecond testTimeout = 1 * time.Second + defaultTimeout = time.Second * 30 chainParams = &address.RegressionNetTap txTypeTaproot = lnrpc.OutputScriptType_SCRIPT_TYPE_WITNESS_V1_TAPROOT ) // newAddrBook creates a new instance of the TapAddressBook book. -func newAddrBookForDB(db *tapdb.BaseDB, keyRing *tapgarden.MockKeyRing, - syncer *tapgarden.MockAssetSyncer) (*address.Book, +func newAddrBookForDB(db *tapdb.BaseDB, keyRing *tapnodemock.KeyRing, + syncer *tapcustody.MockAssetSyncer) (*address.Book, *tapdb.TapAddressBook) { txCreator := func(tx *sql.Tx) tapdb.AddrBook { @@ -147,7 +150,7 @@ func newProofArchiveForDB(t *testing.T, db *tapdb.BaseDB) (*proof.MultiArchiver, type mockSigner struct { lndclient.SignerClient - keyRing *tapgarden.MockKeyRing + keyRing *tapnodemock.KeyRing } func (m *mockSigner) DeriveSharedKey(ctx context.Context, @@ -203,16 +206,16 @@ func (m *mockSigner) VerifyMessage(_ context.Context, msg, sig []byte, type custodianHarness struct { t *testing.T - c *tapgarden.Custodian - cfg *tapgarden.CustodianConfig + c *tapcustody.Custodian + cfg *tapcustody.Config errChan chan error - chainBridge *tapgarden.MockChainBridge - walletAnchor *tapgarden.MockWalletAnchor - keyRing *tapgarden.MockKeyRing + chainBridge *tapnodemock.ChainBridge + walletAnchor *tapnodemock.WalletAnchor + keyRing *tapnodemock.KeyRing signer *mockSigner tapdbBook *tapdb.TapAddressBook addrBook *address.Book - syncer *tapgarden.MockAssetSyncer + syncer *tapcustody.MockAssetSyncer assetDB *tapdb.AssetStore multiverse *tapdb.MultiverseStore courier *proof.MockProofCourier @@ -359,13 +362,13 @@ func (h *custodianHarness) addProofFileToMultiverse(p *proof.AnnotatedProof) { func newHarness(t *testing.T, initialAddrs []*address.AddrWithKeyInfo) *custodianHarness { - chainBridge := tapgarden.NewMockChainBridge() - walletAnchor := tapgarden.NewMockWalletAnchor() - keyRing := tapgarden.NewMockKeyRing() + chainBridge := tapnodemock.NewChainBridge() + walletAnchor := tapnodemock.NewWalletAnchor() + keyRing := tapnodemock.NewKeyRing() signer := &mockSigner{ keyRing: keyRing, } - syncer := tapgarden.NewMockAssetSyncer() + syncer := tapcustody.NewMockAssetSyncer() db := tapdb.NewTestDB(t) addrBook, tapdbBook := newAddrBookForDB(db.BaseDB, keyRing, syncer) @@ -389,7 +392,7 @@ func newHarness(t *testing.T, ) errChan := make(chan error, 1) - cfg := &tapgarden.CustodianConfig{ + cfg := &tapcustody.Config{ ChainParams: chainParams, ChainBridge: chainBridge, WalletAnchor: walletAnchor, @@ -404,7 +407,7 @@ func newHarness(t *testing.T, } return &custodianHarness{ t: t, - c: tapgarden.NewCustodian(cfg), + c: tapcustody.NewCustodian(cfg), cfg: cfg, errChan: errChan, chainBridge: chainBridge, @@ -584,7 +587,7 @@ func randProofWithScriptKey(t *testing.T, outputIndex int, tx *wire.MsgTx, // was fetched from the asset syncer, and stores it in the address book. This // simulates asset bootstrapping that would occur during universe sync. func insertAssetInfo(t *testing.T, ctx context.Context, quit <-chan struct{}, - book *tapdb.TapAddressBook, syncer *tapgarden.MockAssetSyncer) { + book *tapdb.TapAddressBook, syncer *tapcustody.MockAssetSyncer) { go func() { for { @@ -993,7 +996,7 @@ func runTransactionConfirmedOnlyTest(t *testing.T, withRestart bool) { if withRestart { require.NoError(t, h.c.Stop()) - h.c = tapgarden.NewCustodian(h.cfg) + h.c = tapcustody.NewCustodian(h.cfg) require.NoError(t, h.c.Start()) h.assertStartup() } @@ -1066,7 +1069,7 @@ func TestProofInMultiverseOnly(t *testing.T) { h.addProofFileToMultiverse(mockProof) // And a new start should import the proof into the local archive. - h.c = tapgarden.NewCustodian(h.cfg) + h.c = tapcustody.NewCustodian(h.cfg) require.NoError(t, h.c.Start()) t.Cleanup(func() { require.NoError(t, h.c.Stop()) @@ -1209,7 +1212,7 @@ func TestAddrMatchesAsset(t *testing.T) { tt.Parallel() require.Equal( - tt, tc.result, tapgarden.AddrMatchesAsset( + tt, tc.result, tapcustody.AddrMatchesAsset( tc.addr, tc.a, ), ) @@ -1267,12 +1270,12 @@ func TestCustodianEventSubscriber(t *testing.T) { // We should receive events on the subscriber channel. The flow is: // StatusTransactionConfirmed -> StatusProofReceived -> StatusCompleted. // We verify we receive at least one AssetReceiveEvent. - var receivedEvents []*tapgarden.AssetReceiveEvent + var receivedEvents []*tapcustody.AssetReceiveEvent require.Eventually(t, func() bool { select { case event := <-subscriber.NewItemCreated.ChanOut(): receiveEvent, ok := - event.(*tapgarden.AssetReceiveEvent) + event.(*tapcustody.AssetReceiveEvent) if ok { receivedEvents = append( receivedEvents, receiveEvent, @@ -1321,12 +1324,12 @@ func TestAssetReceiveErrorEvent(t *testing.T) { testErr := fmt.Errorf("test error: proof fetch failed") // Create a success event. - successEvent := tapgarden.NewAssetReceiveEvent( + successEvent := tapcustody.NewAssetReceiveEvent( *addr.Tap, outpoint, confHeight, status, ) // Create an error event with the same parameters plus an error. - errorEvent := tapgarden.NewAssetReceiveErrorEvent( + errorEvent := tapcustody.NewAssetReceiveErrorEvent( testErr, *addr.Tap, outpoint, confHeight, status, ) @@ -1351,7 +1354,7 @@ func TestAssetReceiveErrorEvent(t *testing.T) { // Test error event with zero/default values (as used in early error // paths where full context isn't available). - earlyErrorEvent := tapgarden.NewAssetReceiveErrorEvent( + earlyErrorEvent := tapcustody.NewAssetReceiveErrorEvent( testErr, *addr.Tap, wire.OutPoint{}, 0, address.StatusTransactionDetected, ) diff --git a/tapcustody/doc.go b/tapcustody/doc.go new file mode 100644 index 0000000000..b047985e2e --- /dev/null +++ b/tapcustody/doc.go @@ -0,0 +1,13 @@ +// Package tapcustody takes custody of assets transferred to this +// node on-chain. The custodian watches the wallet for taproot +// outputs matching known Taproot Asset addresses, retrieves the +// corresponding provenance proofs from courier or auth-mailbox +// services, verifies them, and imports them into the local proof +// archive. +// +// The substance is "receiving assets," distinct from the minting +// substance that lives in tapgarden. The custodian used to live +// alongside the planter because that was the package that first +// needed it; it has been separated so the package name says what +// the package is. +package tapcustody diff --git a/tapcustody/log.go b/tapcustody/log.go new file mode 100644 index 0000000000..bd17193949 --- /dev/null +++ b/tapcustody/log.go @@ -0,0 +1,23 @@ +package tapcustody + +import ( + "github.com/btcsuite/btclog/v2" +) + +// Subsystem defines the logging code for this subsystem. +const Subsystem = "CSTD" + +// log is a logger that is initialized with no output filters. This +// means the package will not perform any logging by default until the +// caller requests it. +var log = btclog.Disabled + +// DisableLog disables all library log output. +func DisableLog() { + UseLogger(btclog.Disabled) +} + +// UseLogger uses a specified Logger to output package logging info. +func UseLogger(logger btclog.Logger) { + log = logger +} diff --git a/tapcustody/mock.go b/tapcustody/mock.go new file mode 100644 index 0000000000..b09e8e368e --- /dev/null +++ b/tapcustody/mock.go @@ -0,0 +1,82 @@ +package tapcustody + +import ( + "context" + "fmt" + "time" + + "github.com/lightninglabs/taproot-assets/asset" +) + +// MockAssetSyncer is a mock implementation of address.AssetSyncer used +// by custodian tests to drive asset discovery flows. +type MockAssetSyncer struct { + Assets map[asset.ID]*asset.AssetGroup + + FetchedAssets chan *asset.AssetGroup + + FetchErrs bool +} + +func NewMockAssetSyncer() *MockAssetSyncer { + return &MockAssetSyncer{ + Assets: make(map[asset.ID]*asset.AssetGroup), + FetchedAssets: make(chan *asset.AssetGroup, 1), + FetchErrs: false, + } +} + +func (m *MockAssetSyncer) AddAsset(newAsset asset.Asset) { + assetGroup := &asset.AssetGroup{ + Genesis: &newAsset.Genesis, + } + + if newAsset.GroupKey != nil { + assetGroup.GroupKey = newAsset.GroupKey + } + + m.Assets[newAsset.ID()] = assetGroup +} + +func (m *MockAssetSyncer) RemoveAsset(id asset.ID) { + delete(m.Assets, id) +} + +func (m *MockAssetSyncer) FetchAsset(id asset.ID) (*asset.AssetGroup, error) { + bookDelay := time.Millisecond * 25 + + assetGroup, ok := m.Assets[id] + switch { + case ok: + // Broadcast the fetched asset so it can be added to the + // address book. + m.FetchedAssets <- assetGroup + + // Wait for the address book to be updated. + time.Sleep(bookDelay) + return assetGroup, nil + + case m.FetchErrs: + return nil, fmt.Errorf("failed to fetch asset info") + + default: + return nil, nil + } +} + +func (m *MockAssetSyncer) SyncAssetInfo(_ context.Context, + s asset.Specifier) error { + + if !s.HasId() { + return fmt.Errorf("no asset ID provided") + } + + _, err := m.FetchAsset(*s.UnwrapIdToPtr()) + return err +} + +func (m *MockAssetSyncer) EnableAssetSync(_ context.Context, + groupInfo *asset.AssetGroup) error { + + return nil +} diff --git a/tapdb/asset_minting.go b/tapdb/asset_minting.go index 83da7fba6c..945e8928e3 100644 --- a/tapdb/asset_minting.go +++ b/tapdb/asset_minting.go @@ -19,6 +19,7 @@ import ( "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapdb/sqlc" "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightningnetwork/lnd/keychain" "golang.org/x/exp/maps" @@ -370,10 +371,16 @@ func upsertDelegationKey(ctx context.Context, q PendingAssetStore, return sqlInt64(keyID), nil } -// insertMintAnchorTx inserts a mint anchor transaction into the database. +// insertMintAnchorTx inserts a mint anchor transaction into the +// database, optionally also persisting the supply-pre-commit row +// supplied by the caller. The pre-commit payload is a typed +// parameter (rather than read off the funded PSBT) so the genesis +// transaction can be persisted without tapdb needing to know what +// substance the extra anchor outputs serve. func insertMintAnchorTx(ctx context.Context, q PendingAssetStore, anchorPackage tapgarden.FundedMintAnchorPsbt, - batchKey btcec.PublicKey, genesisOutpoint wire.OutPoint) error { + batchKey btcec.PublicKey, genesisOutpoint wire.OutPoint, + preCommit fn.Option[tapgarden.PreCommitBindData]) error { // Ensure that the genesis point is in the database. genesisPointDbID, err := upsertGenesisPoint( @@ -389,7 +396,7 @@ func insertMintAnchorTx(ctx context.Context, q PendingAssetStore, } rawBatchKey := batchKey.SerializeCompressed() - enableUniverseCommitments := anchorPackage.PreCommitmentOutput.IsSome() + enableUniverseCommitments := preCommit.IsSome() _, err = q.BindMintingBatchWithTx(ctx, BatchChainUpdate{ RawKey: rawBatchKey, @@ -403,46 +410,57 @@ func insertMintAnchorTx(ctx context.Context, q PendingAssetStore, return fmt.Errorf("%w: %w", ErrBindBatchTx, err) } - // If universe commitments are not enabled for this batch, we can - // return early. - if !enableUniverseCommitments { + // If no supply-pre-commit payload was supplied, there is no + // supply-pre-commit row to write. + if preCommit.IsNone() { 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"), + bind, err := preCommit.UnwrapOrErr( + fmt.Errorf("pre-commitment bind data not set"), ) if err != nil { return err } - // Serialize internal key. - rawInternalKey := preCommitOut.InternalKey.PubKey.SerializeCompressed() + return upsertPreCommitRow( + ctx, q, rawBatchKey, anchorPackage.Pkt.UnsignedTx.TxHash(), + bind, + ) +} + +// upsertPreCommitRow writes a single supply-pre-commit row using the +// typed bind payload. The genesis-tx hash is supplied separately +// because the (tx-hash, output-index) pair forms the outpoint +// column; the bind payload itself carries only the output index. +func upsertPreCommitRow(ctx context.Context, q PendingAssetStore, + rawBatchKey []byte, genesisTxHash chainhash.Hash, + bind tapgarden.PreCommitBindData) error { + + rawInternalKey := bind.InternalKey.PubKey.SerializeCompressed() internalKeyID, err := q.UpsertInternalKey(ctx, InternalKey{ RawKey: rawInternalKey, - KeyFamily: int32(preCommitOut.InternalKey.Family), - KeyIndex: int32(preCommitOut.InternalKey.Index), + KeyFamily: int32(bind.InternalKey.Family), + KeyIndex: int32(bind.InternalKey.Index), }) if err != nil { return fmt.Errorf("faild to upsert delegation key into "+ "internal key table: %w: %w", ErrUpsertInternalKey, err) } - // Serialize the group key if it is defined. The key may be unset when - // there is no existing group and the minting batch is funded but not - // yet sealed. + // Serialize the group key if it is defined. The key may be unset + // when there is no existing group and the minting batch is funded + // but not yet sealed. groupPubKeyBytes := fn.MapOptionZ( - preCommitOut.GroupPubKey, func(pubKey btcec.PublicKey) []byte { + bind.GroupKey, func(pubKey btcec.PublicKey) []byte { return schnorr.SerializePubKey(&pubKey) }, ) outPoint := wire.OutPoint{ - Hash: anchorPackage.Pkt.UnsignedTx.TxHash(), - Index: preCommitOut.OutIdx, + Hash: genesisTxHash, + Index: bind.OutputIndex, } outPointBytes, err := encodeOutpoint(outPoint) if err != nil { @@ -452,27 +470,30 @@ func insertMintAnchorTx(ctx context.Context, q PendingAssetStore, _, err = q.UpsertMintSupplyPreCommit( ctx, UpsertBatchPreCommitParams{ BatchKey: rawBatchKey, - TxOutputIndex: int32(preCommitOut.OutIdx), + TxOutputIndex: int32(bind.OutputIndex), TaprootInternalKeyID: internalKeyID, GroupKey: groupPubKeyBytes, Outpoint: outPointBytes, }, ) if err != nil { - return fmt.Errorf("unable to insert mint anchor uni "+ - "commitment: %w", err) + return fmt.Errorf("unable to upsert pre-commit output: %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 -// will be used as the internal key which will mint all the assets in the -// batch. +// 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 will be used as the internal key which will mint +// all the assets in the batch. If preCommit is set (the batch was +// created already-funded with a pre-commitment output), the +// supply-pre-commit row is persisted in the same transaction. func (a *AssetMintingStore) CommitMintingBatch(ctx context.Context, - newBatch *tapgarden.MintingBatch) error { + newBatch *tapgarden.MintingBatch, + preCommit fn.Option[tapgarden.PreCommitBindData]) error { rawBatchKey := newBatch.BatchKey.PubKey.SerializeCompressed() @@ -527,6 +548,7 @@ func (a *AssetMintingStore) CommitMintingBatch(ctx context.Context, err = insertMintAnchorTx( ctx, q, *genesisPacket, *newBatch.BatchKey.PubKey, genesisOutpoint, + preCommit, ) if err != nil { return fmt.Errorf("unable to insert mint "+ @@ -1319,7 +1341,7 @@ func marshalMintingBatch(ctx context.Context, q PendingAssetStore, return nil, err } - batch.UpdateState(batchState) + batch.SetStateOnDBSuccess(batchState) if len(dbBatch.TapscriptSibling) != 0 { batchSibling, err := chainhash.NewHash(dbBatch.TapscriptSibling) @@ -1344,56 +1366,11 @@ func marshalMintingBatch(ctx context.Context, q PendingAssetStore, } 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 { - fetchRes, err := q.FetchMintSupplyPreCommits( - ctx, FetchMintPreCommitsParams{ - BatchKey: dbBatch.RawKey, - }, - ) - if err != nil { - return nil, fmt.Errorf("unable to fetch mint "+ - "anchor uni commitment: %w", err) - } - - // Expect one pre-commitment output for a given batch. - if len(fetchRes) != 1 { - return nil, fmt.Errorf("expected one "+ - "pre-commitment output, got %d", - len(fetchRes)) - } - - res := fetchRes[0] - - internalKey, err := parseInternalKey(res.InternalKey) - if err != nil { - return nil, fmt.Errorf("error parsing "+ - "pre-commitment internal key: %w", err) - } - - // Parse the group public key from the database. - var groupPubKey fn.Option[btcec.PublicKey] - if res.GroupKey != nil { - gk, err := schnorr.ParsePubKey(res.GroupKey) - if err != nil { - return nil, fmt.Errorf("error parsing "+ - "group public key: %w", err) - } - - groupPubKey = fn.Some(*gk) - } - - preCommitOut = fn.Some( - tapgarden.PreCommitmentOutput{ - OutIdx: uint32(res.TxOutputIndex), - InternalKey: internalKey, - GroupPubKey: groupPubKey, - }, - ) - } - + // The pre-commitment substance is owned by the + // supply-commit augmenter, which reads + // mint_supply_pre_commits directly when it needs the + // data. tapdb no longer attaches a copy of the row to + // the funded PSBT on readback. batch.GenesisPacket = &tapgarden.FundedMintAnchorPsbt{ FundedPsbt: tapsend.FundedPsbt{ Pkt: genesisPkt, @@ -1401,8 +1378,7 @@ func marshalMintingBatch(ctx context.Context, q PendingAssetStore, dbBatch.ChangeOutputIndex, ), }, - AssetAnchorOutIdx: uint32(assetAnchorOutIdx), - PreCommitmentOutput: preCommitOut, + AssetAnchorOutIdx: uint32(assetAnchorOutIdx), } } @@ -1472,15 +1448,22 @@ func marshalMintingBatch(ctx context.Context, q PendingAssetStore, // UpdateBatchState updates the state of a batch based on the batch key. func (a *AssetMintingStore) UpdateBatchState(ctx context.Context, - batchKey *btcec.PublicKey, newState tapgarden.BatchState) error { + batch *tapgarden.MintingBatch, newState tapgarden.BatchState) error { + batchKey := batch.BatchKey.PubKey var writeTxOpts AssetStoreTxOptions - return a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { + err := a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { return q.UpdateMintingBatchState(ctx, BatchStateUpdate{ RawKey: batchKey.SerializeCompressed(), BatchState: int16(newState), }) }) + if err != nil { + return err + } + + batch.SetStateOnDBSuccess(newState) + return nil } // encodeOutpoint encodes the outpoint point in Bitcoin wire format, returning @@ -1495,12 +1478,14 @@ func encodeOutpoint(outPoint wire.OutPoint) ([]byte, error) { return b.Bytes(), nil } -// CommitBatchFunding atomically persists the funded genesis transaction -// and the optional tapscript sibling for a batch in a single database +// CommitBatchFunding atomically persists the funded genesis +// transaction, the optional tapscript sibling, and (when set) the +// supply-pre-commit row for a batch in a single database // transaction. func (a *AssetMintingStore) CommitBatchFunding(ctx context.Context, batchKey *btcec.PublicKey, batchSibling *chainhash.Hash, - genesisPacket tapgarden.FundedMintAnchorPsbt) error { + genesisPacket tapgarden.FundedMintAnchorPsbt, + preCommit fn.Option[tapgarden.PreCommitBindData]) error { genesisOutpoint, err := genesisPacket.GenesisOutpoint().UnwrapOrErr( tapgarden.ErrFundedAnchorPsbtMissingOutpoint, @@ -1528,6 +1513,7 @@ func (a *AssetMintingStore) CommitBatchFunding(ctx context.Context, err := insertMintAnchorTx( ctx, q, genesisPacket, *batchKey, genesisOutpoint, + preCommit, ) if err != nil { return fmt.Errorf("unable to insert mint anchor "+ @@ -1538,138 +1524,14 @@ func (a *AssetMintingStore) CommitBatchFunding(ctx context.Context, }) } -// FetchDelegationKey fetches the delegation key for the given asset group -// public key. -func (a *AssetMintingStore) FetchDelegationKey(ctx context.Context, - groupKey btcec.PublicKey) (fn.Option[tapgarden.DelegationKey], error) { - - var zero fn.Option[tapgarden.DelegationKey] - groupKeyBytes := schnorr.SerializePubKey(&groupKey) - - var delegationKey fn.Option[tapgarden.DelegationKey] - - readOpts := NewAssetStoreReadTx() - dbErr := a.db.ExecTx(ctx, &readOpts, func(q PendingAssetStore) error { - fetchRow, err := q.FetchMintSupplyPreCommits( - ctx, FetchMintPreCommitsParams{ - GroupKey: groupKeyBytes, - }, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil - } - return fmt.Errorf("unable to fetch mint anchor "+ - "uni commitment by group key: %w", err) - } - - // If we didn't find any pre-commitment outputs, then - // we can return early. - if len(fetchRow) == 0 { - return nil - } - - // Select the first pre-commitment entry. We assume that all - // outputs in the group share the same delegation key. - row := fetchRow[0] - - internalKey, err := parseInternalKey(row.InternalKey) - if err != nil { - return fmt.Errorf("error parsing pre-commitment "+ - "internal key: %w", err) - } - - delegationKey = fn.Some(internalKey) - - return nil - }) - if dbErr != nil { - return zero, dbErr - } - - return delegationKey, nil -} - -// upsertPreCommit upserts the pre-commitment output for a batch into the -// database. If the pre-commitment output is unset on the batch, then -// this function is a no-op. -func upsertPreCommit(ctx context.Context, q PendingAssetStore, - batch *tapgarden.MintingBatch) error { - - // Sanity check arguments and unpack target fields. - if batch == nil { - return nil - } - - genesisPkt := batch.GenesisPacket - if genesisPkt == nil { - return nil - } - - if genesisPkt.PreCommitmentOutput.IsNone() { - return nil - } - - preCommit, err := genesisPkt.PreCommitmentOutput.UnwrapOrErr( - fmt.Errorf("pre-commitment output is none"), - ) - if err != nil { - return err - } - - batchKey := batch.BatchKeyBytes() - if len(batchKey) == 0 { - return fmt.Errorf("batch key is empty") - } - - rawInternalKey := preCommit.InternalKey.PubKey.SerializeCompressed() - - internalKeyID, err := q.UpsertInternalKey(ctx, InternalKey{ - RawKey: rawInternalKey, - KeyFamily: int32(preCommit.InternalKey.Family), - KeyIndex: int32(preCommit.InternalKey.Index), - }) - if err != nil { - return fmt.Errorf("unable to upsert pre-commitment "+ - "internal key: %w", err) - } - - groupPubKeyBytes := fn.MapOptionZ( - preCommit.GroupPubKey, func(groupKey btcec.PublicKey) []byte { - return schnorr.SerializePubKey(&groupKey) - }, - ) - - outPoint := wire.OutPoint{ - Hash: genesisPkt.Pkt.UnsignedTx.TxHash(), - Index: preCommit.OutIdx, - } - outPointBytes, err := encodeOutpoint(outPoint) - if err != nil { - return fmt.Errorf("unable to encode outpoint: %w", err) - } - - _, err = q.UpsertMintSupplyPreCommit( - ctx, UpsertBatchPreCommitParams{ - BatchKey: batchKey, - TxOutputIndex: int32(preCommit.OutIdx), - TaprootInternalKeyID: internalKeyID, - GroupKey: groupPubKeyBytes, - Outpoint: outPointBytes, - }, - ) - if err != nil { - return fmt.Errorf("unable to upsert pre-commit output: %w", err) - } - - return nil -} - -// SealBatch seals a batch by assigning and persisting asset groups for -// the seedlings it contains. +// SealBatch seals a batch by assigning and persisting asset groups +// for the seedlings it contains. If preCommit is set, the +// supply-pre-commit row is re-upserted in the same transaction (the +// group-key column typically only becomes known at seal time). func (a *AssetMintingStore) SealBatch(ctx context.Context, batch *tapgarden.MintingBatch, - newAssetGroups []*asset.AssetGroup) error { + newAssetGroups []*asset.AssetGroup, + preCommit fn.Option[tapgarden.PreCommitBindData]) error { // Retrieve genesis outpoint from the batch genesis packet. genesisPkt := batch.GenesisPacket @@ -1715,11 +1577,21 @@ func (a *AssetMintingStore) SealBatch(ctx context.Context, } } - // If the batch has a pre-commitment output, attempt to upsert - // it into the database in case it is stale and needs to be - // updated with a new asset group key. - if genesisPkt.PreCommitmentOutput.IsSome() { - err := upsertPreCommit(ctx, q, batch) + // If a supply-pre-commit payload was supplied, re-upsert + // the row in case the group-key column needs to be filled + // in (typically only known by seal time). + if preCommit.IsSome() { + bind, err := preCommit.UnwrapOrErr( + fmt.Errorf("pre-commit payload is none"), + ) + if err != nil { + return err + } + + err = upsertPreCommitRow( + ctx, q, batch.BatchKeyBytes(), + genesisPkt.Pkt.UnsignedTx.TxHash(), bind, + ) if err != nil { return fmt.Errorf("unable to upsert "+ "pre-commit output: %w", err) @@ -1789,13 +1661,16 @@ func fetchSeedlingGroups(ctx context.Context, q PendingAssetStore, return seedlingGroups, nil } -// AddSproutsToBatch updates a batch with the passed batch transaction and also -// binds the genesis transaction (which will create the set of assets in the -// batch) to the batch itself. +// AddSproutsToBatch updates a batch with the passed batch +// transaction and also binds the genesis transaction (which will +// create the set of assets in the batch) to the batch itself. If +// preCommit is set, the supply-pre-commit row is persisted (or +// refreshed) in the same transaction. func (a *AssetMintingStore) AddSproutsToBatch(ctx context.Context, - batchKey *btcec.PublicKey, + batch *tapgarden.MintingBatch, genesisPacket *tapgarden.FundedMintAnchorPsbt, - assetRoot *commitment.TapCommitment) error { + assetRoot *commitment.TapCommitment, + preCommit fn.Option[tapgarden.PreCommitBindData]) error { // Before we open the DB transaction below, we'll fetch the set of // assets committed to within the root commitment specified. @@ -1807,7 +1682,7 @@ func (a *AssetMintingStore) AddSproutsToBatch(ctx context.Context, // anchor verification depends on inserting group anchors before // reissuances here. We use the raw group anchor verifier since there // is not yet any stored asset group to reference in the verifier. - anchorVerifier := tapgarden.GenRawGroupAnchorVerifier(ctx) + anchorVerifier := tapnode.GenRawGroupAnchorVerifier(ctx) anchorAssets, nonAnchorAssets, err := tapgarden.SortAssets( assets, anchorVerifier, ) @@ -1824,10 +1699,11 @@ func (a *AssetMintingStore) AddSproutsToBatch(ctx context.Context, return err } + batchKey := batch.BatchKey.PubKey rawBatchKey := batchKey.SerializeCompressed() var writeTxOpts AssetStoreTxOptions - return a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { + err = a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { // Upsert the assets with genesis. _, _, err := upsertAssetsWithGenesis( ctx, q, genesisOutpoint, sortedAssets, nil, @@ -1840,6 +1716,7 @@ func (a *AssetMintingStore) AddSproutsToBatch(ctx context.Context, // Insert the batch transaction. err = insertMintAnchorTx( ctx, q, *genesisPacket, *batchKey, genesisOutpoint, + preCommit, ) if err != nil { return fmt.Errorf("unable to insert mint anchor "+ @@ -1852,6 +1729,12 @@ func (a *AssetMintingStore) AddSproutsToBatch(ctx context.Context, BatchState: int16(tapgarden.BatchStateCommitted), }) }) + if err != nil { + return err + } + + batch.SetStateOnDBSuccess(tapgarden.BatchStateCommitted) + return nil } // CommitSignedGenesisTx binds a fully signed genesis transaction to a pending @@ -1863,10 +1746,12 @@ func (a *AssetMintingStore) AddSproutsToBatch(ctx context.Context, // TODO(roasbeef): or could just re-read assets from disk and set the script // root manually? func (a *AssetMintingStore) CommitSignedGenesisTx(ctx context.Context, - batchKey *btcec.PublicKey, genesisPkt *tapsend.FundedPsbt, + batch *tapgarden.MintingBatch, genesisPkt *tapsend.FundedPsbt, anchorOutputIndex uint32, merkleRoot, tapTreeRoot []byte, tapSibling []byte) error { + batchKey := batch.BatchKey.PubKey + // The managed UTXO we'll insert only contains the raw tx of the // genesis packet, so we'll extract that now. // @@ -1902,7 +1787,7 @@ func (a *AssetMintingStore) CommitSignedGenesisTx(ctx context.Context, } var writeTxOpts AssetStoreTxOptions - return a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { + err = a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { // First, we'll update the genesis packet stored as part of the // batch, as this packet is now fully signed. pktBytes, err := fn.Serialize(genesisPkt.Pkt) @@ -1977,19 +1862,26 @@ func (a *AssetMintingStore) CommitSignedGenesisTx(ctx context.Context, BatchState: int16(tapgarden.BatchStateBroadcast), }) }) + if err != nil { + return err + } + + batch.SetStateOnDBSuccess(tapgarden.BatchStateBroadcast) + return nil } // MarkBatchConfirmed stores final confirmation information for a batch on // disk. func (a *AssetMintingStore) MarkBatchConfirmed(ctx context.Context, - batchKey *btcec.PublicKey, blockHash *chainhash.Hash, + batch *tapgarden.MintingBatch, blockHash *chainhash.Hash, blockHeight uint32, txIndex uint32, mintingProofs proof.AssetBlobs) error { + batchKey := batch.BatchKey.PubKey rawBatchKey := batchKey.SerializeCompressed() var writeTxOpts AssetStoreTxOptions - return a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { + err := a.db.ExecTx(ctx, &writeTxOpts, func(q PendingAssetStore) error { // First, we'll update the state of the target batch to reflect // that the batch is fully finalized. err := q.UpdateMintingBatchState(ctx, BatchStateUpdate{ @@ -2044,6 +1936,12 @@ func (a *AssetMintingStore) MarkBatchConfirmed(ctx context.Context, } return nil }) + if err != nil { + return err + } + + batch.SetStateOnDBSuccess(tapgarden.BatchStateConfirmed) + return nil } // FetchGroupByGenesis fetches the asset group created by the genesis referenced @@ -2234,7 +2132,13 @@ func (a *AssetMintingStore) DeleteTapscriptTree(ctx context.Context, }) } -// A compile-time assertion to ensure that AssetMintingStore meets the -// tapgarden.MintingStore interface. -var _ tapgarden.MintingStore = (*AssetMintingStore)(nil) -var _ asset.TapscriptTreeManager = (*AssetMintingStore)(nil) +// Compile-time assertions: AssetMintingStore is the single concrete +// store that satisfies both the BatchStore (batch lifecycle) and the +// MintingRefReader (reference lookups) views the planter and +// caretaker consume separately, as well as the TapscriptTreeManager +// used for batch tap siblings. +var ( + _ tapgarden.BatchStore = (*AssetMintingStore)(nil) + _ tapgarden.MintingRefReader = (*AssetMintingStore)(nil) + _ asset.TapscriptTreeManager = (*AssetMintingStore)(nil) +) diff --git a/tapdb/asset_minting_test.go b/tapdb/asset_minting_test.go index 54a0211c81..2459c2c447 100644 --- a/tapdb/asset_minting_test.go +++ b/tapdb/asset_minting_test.go @@ -26,6 +26,7 @@ import ( "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapdb/sqlc" "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightningnetwork/lnd/clock" @@ -511,7 +512,9 @@ func TestCommitMintingBatchSeedlings(t *testing.T) { t, assetStore, ctx, mintingBatch.Seedlings, ) _, randSiblingHash := addRandSiblingToBatch(t, mintingBatch) - err := assetStore.CommitMintingBatch(ctx, mintingBatch) + err := assetStore.CommitMintingBatch( + ctx, mintingBatch, tapgarden.MockBindDataForBatch(mintingBatch), + ) require.NoError(t, err) batchKey := mintingBatch.BatchKey.PubKey @@ -550,8 +553,9 @@ func TestCommitMintingBatchSeedlings(t *testing.T) { // Finally update the state of the batch, and asset that when we read // it from disk again, it has transitioned to being frozen. require.NoError(t, assetStore.UpdateBatchState( - ctx, batchKey, tapgarden.BatchStateFrozen, + ctx, mintingBatch, tapgarden.BatchStateFrozen, )) + require.Equal(t, tapgarden.BatchStateFrozen, mintingBatch.State()) mintingBatches = noError1(t, assetStore.FetchNonFinalBatches, ctx) assertSeedlingBatchLen(t, mintingBatches, 1, numSeedlings*2) @@ -560,7 +564,7 @@ func TestCommitMintingBatchSeedlings(t *testing.T) { // If we finalize the batch, then the next query to // FetchNonFinalBatches should return zero batches. require.NoError(t, assetStore.UpdateBatchState( - ctx, batchKey, tapgarden.BatchStateFinalized, + ctx, mintingBatch, tapgarden.BatchStateFinalized, )) mintingBatches = noError1(t, assetStore.FetchNonFinalBatches, ctx) assertSeedlingBatchLen(t, mintingBatches, 0, 0) @@ -584,10 +588,14 @@ func TestCommitMintingBatchSeedlings(t *testing.T) { // Adding sprouts updates the batch state to committed, so we'll set it // back to finalized. require.NoError(t, assetStore.AddSproutsToBatch( - ctx, batchKey, genesisPacket, assetRoot, + ctx, mintingBatch, genesisPacket, assetRoot, + tapgarden.MockBindDataForBatch(mintingBatch), )) + require.Equal( + t, tapgarden.BatchStateCommitted, mintingBatch.State(), + ) require.NoError(t, assetStore.UpdateBatchState( - ctx, batchKey, tapgarden.BatchStateFinalized, + ctx, mintingBatch, tapgarden.BatchStateFinalized, )) // We should still be able to fetch the finalized batch from disk. @@ -610,7 +618,9 @@ func TestCommitMintingBatchSeedlings(t *testing.T) { mintingBatch = tapgarden.RandMintingBatch( t, tapgarden.WithTotalSeedlings(numSeedlings), ) - err = assetStore.CommitMintingBatch(ctx, mintingBatch) + err = assetStore.CommitMintingBatch( + ctx, mintingBatch, tapgarden.MockBindDataForBatch(mintingBatch), + ) require.NoError(t, err) mintingBatches = noError1(t, assetStore.FetchNonFinalBatches, ctx) assertSeedlingBatchLen(t, mintingBatches, 1, numSeedlings) @@ -651,7 +661,9 @@ func TestInsertFetchUniCommitBatch(t *testing.T) { require.True(t, seedling.DelegationKey.IsSome()) // Commit the minting batch to the database. - err := assetStore.CommitMintingBatch(ctx, batch) + err := assetStore.CommitMintingBatch( + ctx, batch, tapgarden.MockBindDataForBatch(batch), + ) require.NoError(t, err) // Fetch the same batch from the database. @@ -872,9 +884,9 @@ func TestAddSproutsToBatch(t *testing.T) { } // First, we'll create a new batch, then add some sample seedlings. - require.NoError(t, assetStore.CommitMintingBatch(ctx, mintingBatch)) - - batchKey := mintingBatch.BatchKey.PubKey + require.NoError(t, assetStore.CommitMintingBatch( + ctx, mintingBatch, tapgarden.MockBindDataForBatch(mintingBatch), + )) // Now that the batch is on disk, we'll map those seedlings to an // actual asset commitment, then insert them into the DB as sprouts. @@ -899,8 +911,12 @@ func TestAddSproutsToBatch(t *testing.T) { genesisPacket.Pkt.UnsignedTx.TxOut[anchorOutputIndex].PkScript = script require.NoError(t, assetStore.AddSproutsToBatch( - ctx, batchKey, genesisPacket, assetRoot, + ctx, mintingBatch, genesisPacket, assetRoot, + tapgarden.MockBindDataForBatch(mintingBatch), )) + require.Equal( + t, tapgarden.BatchStateCommitted, mintingBatch.State(), + ) // Now we'll query for that same batch, and assert that the set of // assets we just inserted into the database matches up. @@ -940,7 +956,6 @@ func TestAddSproutsToBatch(t *testing.T) { } type randAssetCtx struct { - batchKey *btcec.PublicKey groupKey *btcec.PublicKey groupGenAmt uint64 genesisPkt *tapsend.FundedPsbt @@ -963,8 +978,9 @@ func addRandAssets(t *testing.T, ctx context.Context, t, assetStore, ctx, mintingBatch.Seedlings, ) randSibling, randSiblingHash := addRandSiblingToBatch(t, mintingBatch) - batchKey := mintingBatch.BatchKey.PubKey - require.NoError(t, assetStore.CommitMintingBatch(ctx, mintingBatch)) + require.NoError(t, assetStore.CommitMintingBatch( + ctx, mintingBatch, tapgarden.MockBindDataForBatch(mintingBatch), + )) genesisPacket := mintingBatch.GenesisPacket assetRoot := seedlingsToAssetRoot( @@ -990,8 +1006,12 @@ func addRandAssets(t *testing.T, ctx context.Context, genesisPacket.Pkt.UnsignedTx.TxOut[anchorOutputIndex].PkScript = script require.NoError(t, assetStore.AddSproutsToBatch( - ctx, batchKey, genesisPacket, assetRoot, + ctx, mintingBatch, genesisPacket, assetRoot, + tapgarden.MockBindDataForBatch(mintingBatch), )) + require.Equal( + t, tapgarden.BatchStateCommitted, mintingBatch.State(), + ) merkleRoot := assetRoot.TapscriptRoot(&randSiblingHash) scriptRoot := assetRoot.TapscriptRoot(nil) @@ -1001,7 +1021,6 @@ func addRandAssets(t *testing.T, ctx context.Context, require.NoError(t, err) return randAssetCtx{ - batchKey: batchKey, groupKey: &group.GroupKey.GroupPubKey, groupGenAmt: genAmt, genesisPkt: &genesisPacket.FundedPsbt, @@ -1042,10 +1061,14 @@ func TestCommitBatchChainActions(t *testing.T) { // to disk, along with the Taproot Asset script root that's stored // alongside any managed UTXOs. require.NoError(t, assetStore.CommitSignedGenesisTx( - ctx, randAssetCtx.batchKey, randAssetCtx.genesisPkt, 0, + ctx, randAssetCtx.mintingBatch, randAssetCtx.genesisPkt, 0, randAssetCtx.merkleRoot, randAssetCtx.scriptRoot, randAssetCtx.tapSiblingBytes, )) + require.Equal( + t, tapgarden.BatchStateBroadcast, + randAssetCtx.mintingBatch.State(), + ) // The batch updated above should be found, with the batch state // updated, and also the genesis transaction updated to match what we @@ -1118,9 +1141,13 @@ func TestCommitBatchChainActions(t *testing.T) { blockHeight := uint32(20) txIndex := uint32(5) require.NoError(t, assetStore.MarkBatchConfirmed( - ctx, randAssetCtx.batchKey, &fakeBlockHash, blockHeight, + ctx, randAssetCtx.mintingBatch, &fakeBlockHash, blockHeight, txIndex, assetProofs, )) + require.Equal( + t, tapgarden.BatchStateConfirmed, + randAssetCtx.mintingBatch.State(), + ) // We'll now fetch the chain transaction again, to confirm that all the // field have been properly updated. @@ -1464,9 +1491,9 @@ func TestGroupAnchors(t *testing.T) { ctx := context.Background() const numSeedlings = 10 assetStore, _, _ := newAssetStore(t) - groupVerifier := tapgarden.GenGroupVerifier(ctx, assetStore) - groupAnchorVerifier := tapgarden.GenGroupAnchorVerifier(ctx, assetStore) - rawGroupAnchorVerifier := tapgarden.GenRawGroupAnchorVerifier(ctx) + groupVerifier := tapnode.GenGroupVerifier(ctx, assetStore) + groupAnchorVerifier := tapnode.GenGroupAnchorVerifier(ctx, assetStore) + rawGroupAnchorVerifier := tapnode.GenRawGroupAnchorVerifier(ctx) // First, we'll write a new minting batch to disk, including an // internal key and a set of seedlings. One random seedling will @@ -1479,7 +1506,9 @@ func TestGroupAnchors(t *testing.T) { t, assetStore, ctx, mintingBatch.Seedlings, ) addMultiAssetGroupToBatch(mintingBatch.Seedlings) - err := assetStore.CommitMintingBatch(ctx, mintingBatch) + err := assetStore.CommitMintingBatch( + ctx, mintingBatch, tapgarden.MockBindDataForBatch(mintingBatch), + ) require.NoError(t, err) batchKey := mintingBatch.BatchKey.PubKey @@ -1557,8 +1586,12 @@ func TestGroupAnchors(t *testing.T) { genesisPacket.Pkt.UnsignedTx.TxOut[anchorOutputIndex].PkScript = script require.NoError(t, assetStore.AddSproutsToBatch( - ctx, batchKey, genesisPacket, assetRoot, + ctx, mintingBatch, genesisPacket, assetRoot, + tapgarden.MockBindDataForBatch(mintingBatch), )) + require.Equal( + t, tapgarden.BatchStateCommitted, mintingBatch.State(), + ) // Now we'll query for that same batch, and assert that the set of // assets we just inserted into the database matches up. @@ -1997,7 +2030,9 @@ func TestUpsertMintSupplyPreCommit(t *testing.T) { storeSeedlingGroupGenesis(t, ctx, assetStore, seedling) // Commit batch. - require.NoError(t, assetStore.CommitMintingBatch(ctx, mintingBatch)) + require.NoError(t, assetStore.CommitMintingBatch( + ctx, mintingBatch, tapgarden.MockBindDataForBatch(mintingBatch), + )) // Retrieve the batch key of the batch we just inserted. var batchKey []byte @@ -2014,12 +2049,15 @@ func TestUpsertMintSupplyPreCommit(t *testing.T) { ) // Define pre-commit outpoint for the batch mint anchor tx. + // The funded PSBT no longer carries a PreCommitmentOutput + // field; the test mock augmenter knows how to derive the + // same persistence payload from the batch's seedlings and + // the funded PSBT. genesisPkt := mintingBatch.GenesisPacket require.NotNil(t, genesisPkt) - preCommitOut, err := genesisPkt.PreCommitmentOutput.UnwrapOrErr( - fmt.Errorf("no pre-commitment output"), - ) + preCommitBind, err := tapgarden.MockBindDataForBatch(mintingBatch). + UnwrapOrErr(fmt.Errorf("no pre-commitment output")) require.NoError(t, err) txidStr := genesisPkt.FundedPsbt.Pkt.UnsignedTx.TxID() @@ -2028,11 +2066,11 @@ func TestUpsertMintSupplyPreCommit(t *testing.T) { preCommitOutpoint := wire.OutPoint{ Hash: *txid, - Index: preCommitOut.OutIdx, + Index: preCommitBind.OutputIndex, } // Serialize keys into bytes for easier handling. - preCommitGroupKey, err := preCommitOut.GroupPubKey.UnwrapOrErr( + preCommitGroupKey, err := preCommitBind.GroupKey.UnwrapOrErr( fmt.Errorf("no group key"), ) require.NoError(t, err) @@ -2040,8 +2078,8 @@ func TestUpsertMintSupplyPreCommit(t *testing.T) { // Retrieve and inspect the mint anchor commitment we just inserted. assertMintSupplyPreCommit( - t, *assetStore, batchKey, preCommitOut.OutIdx, - preCommitOut.InternalKey, groupPubKeyBytes, preCommitOutpoint, + t, *assetStore, batchKey, preCommitBind.OutputIndex, + preCommitBind.InternalKey, groupPubKeyBytes, preCommitOutpoint, ) // Upsert-ing a new taproot internal key for the same pre-commit @@ -2049,13 +2087,13 @@ func TestUpsertMintSupplyPreCommit(t *testing.T) { internalKey2, _ := test.RandKeyDesc(t) storeMintSupplyPreCommit( - t, *assetStore, batchKey, preCommitOut.OutIdx, internalKey2, - groupPubKeyBytes, preCommitOutpoint, + t, *assetStore, batchKey, preCommitBind.OutputIndex, + internalKey2, groupPubKeyBytes, preCommitOutpoint, ) assertMintSupplyPreCommit( - t, *assetStore, batchKey, preCommitOut.OutIdx, internalKey2, - groupPubKeyBytes, preCommitOutpoint, + t, *assetStore, batchKey, preCommitBind.OutputIndex, + internalKey2, groupPubKeyBytes, preCommitOutpoint, ) // Upsert-ing a new group key for the same pre-commit outpoint should @@ -2064,16 +2102,122 @@ func TestUpsertMintSupplyPreCommit(t *testing.T) { groupPubKey2Bytes := schnorr.SerializePubKey(groupPubKey2) storeMintSupplyPreCommit( - t, *assetStore, batchKey, preCommitOut.OutIdx, internalKey2, - groupPubKey2Bytes, preCommitOutpoint, + t, *assetStore, batchKey, preCommitBind.OutputIndex, + internalKey2, groupPubKey2Bytes, preCommitOutpoint, ) assertMintSupplyPreCommit( - t, *assetStore, batchKey, preCommitOut.OutIdx, internalKey2, - groupPubKey2Bytes, preCommitOutpoint, + t, *assetStore, batchKey, preCommitBind.OutputIndex, + internalKey2, groupPubKey2Bytes, preCommitOutpoint, ) } +// TestUpdateBatchStateMemoryCoherence pins the invariant that the +// in-memory batch state never advances unless the on-disk write +// succeeds. When the DB call fails (here, via a pre-cancelled +// context), batch.State() must remain at its prior value and a fresh +// read from disk must agree. +func TestUpdateBatchStateMemoryCoherence(t *testing.T) { + t.Parallel() + + assetStore, _, _ := newAssetStore(t) + ctx := context.Background() + + mintingBatch := tapgarden.RandMintingBatch(t) + require.NoError(t, assetStore.CommitMintingBatch( + ctx, mintingBatch, tapgarden.MockBindDataForBatch(mintingBatch), + )) + require.Equal( + t, tapgarden.BatchStatePending, mintingBatch.State(), + ) + + // A pre-cancelled context forces ExecTx to fail before touching + // the row, exercising the failure path of UpdateBatchState. + cancelledCtx, cancel := context.WithCancel(ctx) + cancel() + + err := assetStore.UpdateBatchState( + cancelledCtx, mintingBatch, tapgarden.BatchStateFrozen, + ) + require.Error(t, err) + + // In-memory state must not have moved. + require.Equal( + t, tapgarden.BatchStatePending, mintingBatch.State(), + ) + + // On-disk state must not have moved either. + fetched, err := assetStore.FetchMintingBatch( + ctx, mintingBatch.BatchKey.PubKey, + ) + require.NoError(t, err) + require.Equal( + t, tapgarden.BatchStatePending, fetched.State(), + ) +} + +// TestSingletonPreBroadcastBatchConstraint exercises the partial +// unique index added in migration 000060. At most one +// asset_minting_batches row may be in BatchStatePending or +// BatchStateFrozen at any time; the second insert into that set +// must fail with a constraint error, and a row in +// BatchStateCommitted (or later) must not count against the +// constraint. +func TestSingletonPreBroadcastBatchConstraint(t *testing.T) { + t.Parallel() + + ctx := context.Background() + assetStore, _, _ := newAssetStore(t) + + // A first Pending batch is fine. + first := tapgarden.RandMintingBatch(t) + require.NoError(t, assetStore.CommitMintingBatch( + ctx, first, tapgarden.MockBindDataForBatch(first), + )) + + // A second Pending batch must be rejected: two rows in + // BatchStatePending violate the partial unique index. + secondPending := tapgarden.RandMintingBatch(t) + err := assetStore.CommitMintingBatch( + ctx, secondPending, + tapgarden.MockBindDataForBatch(secondPending), + ) + require.Error(t, err) + + // Move the first batch to Frozen; it is still in the + // pre-broadcast set, so a new Pending batch must still be + // rejected (Pending ∪ Frozen, not just Pending). + require.NoError(t, assetStore.UpdateBatchState( + ctx, first, tapgarden.BatchStateFrozen, + )) + + pendingWhileFrozen := tapgarden.RandMintingBatch(t) + err = assetStore.CommitMintingBatch( + ctx, pendingWhileFrozen, + tapgarden.MockBindDataForBatch(pendingWhileFrozen), + ) + require.Error(t, err) + + // Move the first batch out of the pre-broadcast set into + // Committed; the constraint no longer applies to it. A new + // Pending batch must now succeed. + require.NoError(t, assetStore.UpdateBatchState( + ctx, first, tapgarden.BatchStateCommitted, + )) + + third := tapgarden.RandMintingBatch(t) + require.NoError(t, assetStore.CommitMintingBatch( + ctx, third, tapgarden.MockBindDataForBatch(third), + )) + + // And finally: two batches both in Committed must be + // permitted -- the constraint targets only the pre-broadcast + // set. + require.NoError(t, assetStore.UpdateBatchState( + ctx, third, tapgarden.BatchStateCommitted, + )) +} + func init() { rand.Seed(time.Now().Unix()) diff --git a/tapdb/migrations.go b/tapdb/migrations.go index b6b1d25e45..69d8323e4b 100644 --- a/tapdb/migrations.go +++ b/tapdb/migrations.go @@ -24,7 +24,7 @@ const ( // daemon. // // NOTE: This MUST be updated when a new migration is added. - LatestMigrationVersion = 59 + LatestMigrationVersion = 62 ) // DatabaseBackend is an interface that contains all methods our different diff --git a/tapdb/migrations_test.go b/tapdb/migrations_test.go index ebf25f4e6f..e8a2205fe4 100644 --- a/tapdb/migrations_test.go +++ b/tapdb/migrations_test.go @@ -1,6 +1,7 @@ package tapdb import ( + "bytes" "context" "database/sql" "encoding/hex" @@ -1399,3 +1400,162 @@ func TestSequenceConsistency(t *testing.T) { ) } } + +// TestMigration62BackfillSupplyUpdateEventKeys verifies that the +// programmatic migration at version 62 fills the event_key column for +// every supply_update_events row created before the column existed. +// Migration 61 added the column nullable; rows inserted at version 61 +// (or earlier) have event_key=NULL, and the dedup invariant only kicks +// in once the backfill has run. +func TestMigration62BackfillSupplyUpdateEventKeys(t *testing.T) { + ctx := context.Background() + + // Start at version 61: the event_key column exists, the unique + // index exists (and tolerates multiple NULLs), but no rows have + // been hashed yet. + db := NewTestDBWithVersion(t, 61) + + // Insert three rows with NULL event_key. update_type_id values + // match the rows seeded by migration 40 (0=mint, 1=burn, + // 2=ignore). Distinct event_data so the backfilled hashes do + // not collide. + groupKey := make([]byte, 32) + for i := range groupKey { + groupKey[i] = byte(i + 1) + } + + type seed struct { + typeID int32 + data []byte + } + seeds := []seed{ + {typeID: 0, data: []byte("event-payload-mint")}, + {typeID: 1, data: []byte("event-payload-burn")}, + {typeID: 2, data: []byte("event-payload-ignore")}, + } + + for _, s := range seeds { + // EventKey is intentionally nil so the row mimics what a + // legacy database holds before migration 62's backfill. + _, err := db.InsertSupplyUpdateEvent( + ctx, sqlc.InsertSupplyUpdateEventParams{ + GroupKey: groupKey, + TransitionID: sql.NullInt64{}, + UpdateTypeID: s.typeID, + EventData: s.data, + EventKey: nil, + }, + ) + require.NoError(t, err) + } + + // Verify event_key is NULL before the backfill. + preRows, err := db.QueryContext(ctx, ` + SELECT update_type_id, event_data, event_key + FROM supply_update_events + ORDER BY update_type_id + `) + require.NoError(t, err) + defer preRows.Close() + for preRows.Next() { + var typeID int32 + var data, key []byte + require.NoError(t, preRows.Scan(&typeID, &data, &key)) + require.Nil(t, key, "event_key must be NULL pre-backfill") + } + require.NoError(t, preRows.Close()) + + // Advance to latest -- the programmatic migration at 62 runs the + // backfill. + err = db.ExecuteMigrations(TargetLatest, WithProgrammaticMigrations( + makeProgrammaticMigrations(db, programmaticMigrations, true), + )) + require.NoError(t, err) + + // After the backfill, each row's event_key must equal the + // expected SHA-256 over (group_key || update_type_id || data). + postRows, err := db.QueryContext(ctx, ` + SELECT update_type_id, event_data, event_key + FROM supply_update_events + ORDER BY update_type_id + `) + require.NoError(t, err) + defer postRows.Close() + + seen := 0 + for postRows.Next() { + var typeID int32 + var data, key []byte + require.NoError(t, postRows.Scan(&typeID, &data, &key)) + + expected := supplyUpdateEventKey(groupKey, typeID, data) + require.Equal(t, expected, key, + "event_key mismatch for type %d", typeID) + require.Len(t, key, 32) + seen++ + } + require.NoError(t, postRows.Close()) + require.Equal(t, len(seeds), seen) +} + +// TestMigration62BackfillDedupesLegacyDuplicates simulates the legacy +// failure mode this PR closes: pre-migration databases could contain +// multiple supply_update_events rows with identical content. The +// migration 62 backfill must drop the duplicates rather than fail on +// the unique index added in migration 61. +func TestMigration62BackfillDedupesLegacyDuplicates(t *testing.T) { + ctx := context.Background() + + db := NewTestDBWithVersion(t, 61) + + groupKey := bytes.Repeat([]byte{0x42}, 32) + payload := []byte("event-payload-duplicate") + + // Insert the same logical event three times. NULL event_key is + // distinct from NULL under both backends, so all three rows + // land without tripping the unique index. + for i := 0; i < 3; i++ { + _, err := db.InsertSupplyUpdateEvent( + ctx, sqlc.InsertSupplyUpdateEventParams{ + GroupKey: groupKey, + TransitionID: sql.NullInt64{}, + UpdateTypeID: 0, + EventData: payload, + EventKey: nil, + }, + ) + require.NoError(t, err) + } + + var preCount int + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM supply_update_events + `).Scan(&preCount)) + require.Equal(t, 3, preCount) + + // Run the backfill. The unique index added in migration 61 + // would reject the naive UPDATE for the second and third + // rows; the backfill must dedupe before writing. + err := db.ExecuteMigrations(TargetLatest, WithProgrammaticMigrations( + makeProgrammaticMigrations(db, programmaticMigrations, true), + )) + require.NoError(t, err) + + // Exactly one row should survive, and its event_key should + // match the hash of the duplicated content. MAX(bytea) is not + // defined in Postgres, so fetch count and key separately. + var postCount int + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM supply_update_events + `).Scan(&postCount)) + require.Equal(t, 1, postCount) + + var survivingKey []byte + require.NoError(t, db.QueryRowContext(ctx, ` + SELECT event_key FROM supply_update_events LIMIT 1 + `).Scan(&survivingKey)) + require.Equal(t, + supplyUpdateEventKey(groupKey, 0, payload), + survivingKey, + ) +} diff --git a/tapdb/programmatic_migrations.go b/tapdb/programmatic_migrations.go index c47e32cd0d..ea5492a43a 100644 --- a/tapdb/programmatic_migrations.go +++ b/tapdb/programmatic_migrations.go @@ -27,6 +27,13 @@ const ( // table by querying all assets and detecting burns from their // witnesses. Migration51InsertAssetBurns = 51 + + // Migration62BackfillEventKeys is the version of the + // programmatic migration that computes the dedup content-hash for + // every supply_update_events row that pre-dates the event_key + // column. SQLite has no native SHA-256, so the work cannot be + // expressed as portable SQL. + Migration62BackfillEventKeys = 62 ) // programmaticMigration is a function type for a function that performs a @@ -39,8 +46,9 @@ var ( // These functions are used to perform additional checks on the // database state that are not fully expressible in SQL. programmaticMigrations = map[uint]programmaticMigration{ - Migration50ScriptKeyType: determineAndAssignScriptKeyType, - Migration51InsertAssetBurns: insertAssetBurns, + Migration50ScriptKeyType: determineAndAssignScriptKeyType, + Migration51InsertAssetBurns: insertAssetBurns, + Migration62BackfillEventKeys: backfillSupplyUpdateEventKeys, } ) @@ -328,3 +336,68 @@ func insertAssetBurns(ctx context.Context, q sqlc.Querier) error { return nil } + +// backfillSupplyUpdateEventKeys computes a content-hash for every +// supply_update_events row that pre-dates the event_key column (added +// in migration 000061) and stores it in the new column. After this +// migration runs every row holds a hash, and the unique index on +// event_key enforces the no-duplicates invariant for new inserts. +// +// Legacy databases may already contain duplicate rows (the bug this +// PR fixes -- restart re-fires of the same logical event). Two rows +// with identical content hash to the same key, so the second +// SetSupplyUpdateEventKey would violate the unique index added in +// migration 000061. We dedupe in-memory by tracking the hashes we've +// already assigned and dropping any row whose hash we've seen. +func backfillSupplyUpdateEventKeys(ctx context.Context, + q sqlc.Querier) error { + + rows, err := q.FetchSupplyUpdateEventsForBackfill(ctx) + if err != nil { + return fmt.Errorf("error fetching supply update events for "+ + "backfill: %w", err) + } + + log.Debugf("Backfilling event_key for %d supply update events", + len(rows)) + + seen := make(map[string]struct{}, len(rows)) + for _, row := range rows { + key := supplyUpdateEventKey( + row.GroupKey, row.UpdateTypeID, row.EventData, + ) + + if _, dup := seen[string(key)]; dup { + // A prior row in this loop already claimed this + // hash, so the current row is a duplicate of an + // earlier logical event. Drop it; the unique + // index in migration 000061 would otherwise + // reject the UPDATE below. + log.Debugf("Dropping duplicate supply update "+ + "event %d during backfill", row.EventID) + + err := q.DeleteSupplyUpdateEvent(ctx, row.EventID) + if err != nil { + return fmt.Errorf("error deleting "+ + "duplicate event %d: %w", + row.EventID, err) + } + + continue + } + seen[string(key)] = struct{}{} + + err := q.SetSupplyUpdateEventKey( + ctx, sqlc.SetSupplyUpdateEventKeyParams{ + EventKey: key, + EventID: row.EventID, + }, + ) + if err != nil { + return fmt.Errorf("error setting event_key for event "+ + "%d: %w", row.EventID, err) + } + } + + return nil +} diff --git a/tapdb/sqlc/migrations/000060_unique_pending_or_frozen_batch.down.sql b/tapdb/sqlc/migrations/000060_unique_pending_or_frozen_batch.down.sql new file mode 100644 index 0000000000..4a26e5ae67 --- /dev/null +++ b/tapdb/sqlc/migrations/000060_unique_pending_or_frozen_batch.down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS asset_minting_batches_unique_pending_or_frozen; diff --git a/tapdb/sqlc/migrations/000060_unique_pending_or_frozen_batch.up.sql b/tapdb/sqlc/migrations/000060_unique_pending_or_frozen_batch.up.sql new file mode 100644 index 0000000000..d6d12beff6 --- /dev/null +++ b/tapdb/sqlc/migrations/000060_unique_pending_or_frozen_batch.up.sql @@ -0,0 +1,21 @@ +-- Enforce that at most one minting batch is in a pre-broadcast state +-- (BatchStatePending = 0 or BatchStateFrozen = 1) at any time. This +-- matches the planter's in-memory model, which assumes a single +-- "current" pre-broadcast batch in flight. Older versions of the +-- planter could in some failure scenarios desync the in-memory slot +-- from disk and leave two pre-broadcast rows behind; this index +-- makes that state unrepresentable going forward. +-- +-- The unique index targets the constant expression `(1)`, which +-- means "at most one row total" among rows matching the WHERE +-- filter. The syntax is dialect-agnostic across SQLite and +-- Postgres. +-- +-- This is a deliberate design choice rather than a fundamental +-- limit. If multi-batch support is ever added to the planter, +-- drop this index and revisit the planter API and recovery loop +-- alongside that change. +CREATE UNIQUE INDEX IF NOT EXISTS + asset_minting_batches_unique_pending_or_frozen + ON asset_minting_batches ((1)) + WHERE batch_state IN (0, 1); diff --git a/tapdb/sqlc/migrations/000061_dedupe_supply_update_events.down.sql b/tapdb/sqlc/migrations/000061_dedupe_supply_update_events.down.sql new file mode 100644 index 0000000000..8f773b82b4 --- /dev/null +++ b/tapdb/sqlc/migrations/000061_dedupe_supply_update_events.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS supply_update_events_event_key_idx; +ALTER TABLE supply_update_events DROP COLUMN event_key; diff --git a/tapdb/sqlc/migrations/000061_dedupe_supply_update_events.up.sql b/tapdb/sqlc/migrations/000061_dedupe_supply_update_events.up.sql new file mode 100644 index 0000000000..27677f95ca --- /dev/null +++ b/tapdb/sqlc/migrations/000061_dedupe_supply_update_events.up.sql @@ -0,0 +1,22 @@ +-- Add a content-hash column that uniquely identifies a supply update +-- event by group, type, and payload bytes. The hash is computed as +-- sha256(group_key || big_endian(update_type_id) || event_data); we +-- hash rather than indexing event_data directly because event_data +-- holds a serialized issuance proof which can exceed the Postgres +-- BTREE indexed-tuple size limit. +-- +-- The column is nullable in this migration so it applies cleanly to +-- existing DBs. Migration 000062 backfills the rows that pre-date +-- this column (SQLite has no native SHA-256 so the backfill is a +-- programmatic step). New inserts must always provide a value. +ALTER TABLE supply_update_events + ADD COLUMN event_key BLOB + CHECK(event_key IS NULL OR length(event_key) = 32); + +-- The unique index covers all rows, but SQLite and Postgres both +-- treat NULL as distinct from every other NULL in a UNIQUE index, +-- so pre-backfill rows do not collide with one another. After +-- migration 000062 every row holds a hash and the index enforces +-- dedup across the whole table. +CREATE UNIQUE INDEX IF NOT EXISTS supply_update_events_event_key_idx + ON supply_update_events(event_key); diff --git a/tapdb/sqlc/migrations/000062_backfill_supply_update_event_keys.down.sql b/tapdb/sqlc/migrations/000062_backfill_supply_update_event_keys.down.sql new file mode 100644 index 0000000000..8b1c6249d9 --- /dev/null +++ b/tapdb/sqlc/migrations/000062_backfill_supply_update_event_keys.down.sql @@ -0,0 +1,3 @@ +-- The backfill cannot be cleanly undone in isolation -- the column +-- itself is dropped by the 000061 down-migration. This file exists +-- only so the migration runner can step back through version 61. diff --git a/tapdb/sqlc/migrations/000062_backfill_supply_update_event_keys.up.sql b/tapdb/sqlc/migrations/000062_backfill_supply_update_event_keys.up.sql new file mode 100644 index 0000000000..7e89437506 --- /dev/null +++ b/tapdb/sqlc/migrations/000062_backfill_supply_update_event_keys.up.sql @@ -0,0 +1,4 @@ +-- The actual backfill runs in Go (see programmatic_migrations.go). +-- This marker file exists so the migration stream picks up the +-- version; SQLite has no native SHA-256, so the work cannot be +-- expressed as portable SQL. diff --git a/tapdb/sqlc/models.go b/tapdb/sqlc/models.go index 815304580d..b72456794c 100644 --- a/tapdb/sqlc/models.go +++ b/tapdb/sqlc/models.go @@ -483,6 +483,7 @@ type SupplyUpdateEvent struct { TransitionID sql.NullInt64 UpdateTypeID int32 EventData []byte + EventKey []byte } type TapscriptEdge struct { diff --git a/tapdb/sqlc/querier.go b/tapdb/sqlc/querier.go index 18cd19a4c4..5b481cd260 100644 --- a/tapdb/sqlc/querier.go +++ b/tapdb/sqlc/querier.go @@ -40,6 +40,10 @@ type Querier interface { DeleteNode(ctx context.Context, arg DeleteNodeParams) (int64, error) DeleteRoot(ctx context.Context, namespace string) (int64, error) DeleteSupplyCommitTransition(ctx context.Context, transitionID int64) error + // Deletes a single supply update event row identified by its + // event_id. Used by the migration 62 backfill to drop duplicate + // rows that hash to the same event_key as an earlier row. + DeleteSupplyUpdateEvent(ctx context.Context, eventID int64) error DeleteSupplyUpdateEvents(ctx context.Context, transitionID sql.NullInt64) error DeleteTapscriptTreeEdges(ctx context.Context, rootHash []byte) error DeleteTapscriptTreeNodes(ctx context.Context) error @@ -116,6 +120,10 @@ type Querier interface { // Fetches all push log entries for a given asset group, ordered by // creation time with the most recent entries first. FetchSupplySyncerPushLogs(ctx context.Context, groupKey []byte) ([]SupplySyncerPushLog, error) + // Returns rows that pre-date the event_key column and still need + // a hash computed. Used by the programmatic migration that runs + // at schema version 61. + FetchSupplyUpdateEventsForBackfill(ctx context.Context) ([]FetchSupplyUpdateEventsForBackfillRow, error) // Sort the nodes by node_index here instead of returning the indices. FetchTapscriptTree(ctx context.Context, rootHash []byte) ([]FetchTapscriptTreeRow, error) FetchTransferInputs(ctx context.Context, transferID int64) ([]FetchTransferInputsRow, error) @@ -159,7 +167,20 @@ type Querier interface { // push to a remote universe server. The commit_txid and output_index are // taken directly from the RootCommitment outpoint. InsertSupplySyncerPushLog(ctx context.Context, arg InsertSupplySyncerPushLogParams) error - InsertSupplyUpdateEvent(ctx context.Context, arg InsertSupplyUpdateEventParams) error + // The event_key column is a deterministic content hash that + // identifies a logical update event. A duplicate insert (e.g. on + // restart re-run of the Confirmed branch in the minting state + // machine) hits the unique index on event_key and is silently + // dropped, leaving the existing row -- and any transition_id it + // already carries -- untouched. + // + // Returning rows-affected (1 on insert, 0 on conflict) lets the + // caller distinguish "new event recorded" from "dedup absorbed an + // old one" -- the latter is the signal InsertPendingUpdate needs + // to avoid creating an empty pending transition when a re-fired + // event matches a row already attached to a prior (finalized) + // transition. + InsertSupplyUpdateEvent(ctx context.Context, arg InsertSupplyUpdateEventParams) (int64, error) InsertTxProof(ctx context.Context, arg InsertTxProofParams) error InsertUniverseServer(ctx context.Context, arg InsertUniverseServerParams) error LinkDanglingSupplyUpdateEvents(ctx context.Context, arg LinkDanglingSupplyUpdateEventsParams) error @@ -233,6 +254,10 @@ type Querier interface { ReAnchorPassiveAssets(ctx context.Context, arg ReAnchorPassiveAssetsParams) error SetAddrManaged(ctx context.Context, arg SetAddrManagedParams) error SetAssetSpent(ctx context.Context, arg SetAssetSpentParams) (int64, error) + // Sets the content-hash key for a single supply update event row. + // Used by the programmatic migration that backfills pre-existing + // rows after column 000061 is added. + SetSupplyUpdateEventKey(ctx context.Context, arg SetSupplyUpdateEventKeyParams) error SetTransferOutputProofDeliveryStatus(ctx context.Context, arg SetTransferOutputProofDeliveryStatusParams) error UniverseLeaves(ctx context.Context) ([]UniverseLeafe, error) UniverseRoots(ctx context.Context, arg UniverseRootsParams) ([]UniverseRootsRow, error) diff --git a/tapdb/sqlc/queries/supply_commit.sql b/tapdb/sqlc/queries/supply_commit.sql index c92d35bd1e..4cc38b4ce4 100644 --- a/tapdb/sqlc/queries/supply_commit.sql +++ b/tapdb/sqlc/queries/supply_commit.sql @@ -65,12 +65,42 @@ UPDATE supply_commit_transitions SET finalized = TRUE WHERE transition_id = @transition_id; --- name: InsertSupplyUpdateEvent :exec +-- name: InsertSupplyUpdateEvent :execrows +-- The event_key column is a deterministic content hash that +-- identifies a logical update event. A duplicate insert (e.g. on +-- restart re-run of the Confirmed branch in the minting state +-- machine) hits the unique index on event_key and is silently +-- dropped, leaving the existing row -- and any transition_id it +-- already carries -- untouched. +-- +-- Returning rows-affected (1 on insert, 0 on conflict) lets the +-- caller distinguish "new event recorded" from "dedup absorbed an +-- old one" -- the latter is the signal InsertPendingUpdate needs +-- to avoid creating an empty pending transition when a re-fired +-- event matches a row already attached to a prior (finalized) +-- transition. INSERT INTO supply_update_events ( - group_key, transition_id, update_type_id, event_data + group_key, transition_id, update_type_id, event_data, event_key ) VALUES ( - $1, $2, $3, $4 -); + $1, $2, $3, $4, $5 +) +ON CONFLICT (event_key) DO NOTHING; + +-- name: FetchSupplyUpdateEventsForBackfill :many +-- Returns rows that pre-date the event_key column and still need +-- a hash computed. Used by the programmatic migration that runs +-- at schema version 61. +SELECT event_id, group_key, update_type_id, event_data +FROM supply_update_events +WHERE event_key IS NULL; + +-- name: SetSupplyUpdateEventKey :exec +-- Sets the content-hash key for a single supply update event row. +-- Used by the programmatic migration that backfills pre-existing +-- rows after column 000061 is added. +UPDATE supply_update_events +SET event_key = @event_key +WHERE event_id = @event_id; -- name: QuerySupplyCommitStateMachine :one SELECT @@ -203,6 +233,13 @@ WHERE transition_id = @transition_id; DELETE FROM supply_update_events WHERE transition_id = @transition_id; +-- name: DeleteSupplyUpdateEvent :exec +-- Deletes a single supply update event row identified by its +-- event_id. Used by the migration 62 backfill to drop duplicate +-- rows that hash to the same event_key as an earlier row. +DELETE FROM supply_update_events +WHERE event_id = @event_id; + -- name: FetchUnspentSupplyPreCommits :many -- Fetch unspent supply pre-commitment outputs. Each pre-commitment output -- comes from a mint anchor transaction and relates to an asset issuance diff --git a/tapdb/sqlc/schemas/generated_schema.sql b/tapdb/sqlc/schemas/generated_schema.sql index 91cae706ff..25f1494a4a 100644 --- a/tapdb/sqlc/schemas/generated_schema.sql +++ b/tapdb/sqlc/schemas/generated_schema.sql @@ -206,6 +206,10 @@ CREATE TABLE asset_minting_batches ( creation_time_unix TIMESTAMP NOT NULL , tapscript_sibling BLOB, assets_output_index INTEGER, universe_commitments BOOLEAN NOT NULL DEFAULT FALSE); +CREATE UNIQUE INDEX asset_minting_batches_unique_pending_or_frozen + ON asset_minting_batches ((1)) + WHERE batch_state IN (0, 1); + CREATE TABLE asset_proofs ( proof_id INTEGER PRIMARY KEY, @@ -1115,7 +1119,11 @@ CREATE TABLE supply_update_events ( -- Opaque blob containing the serialized data for the specific -- SupplyUpdateEvent (NewMintEvent, NewBurnEvent, NewIgnoreEvent). event_data BLOB NOT NULL -); +, event_key BLOB + CHECK(event_key IS NULL OR length(event_key) = 32)); + +CREATE UNIQUE INDEX supply_update_events_event_key_idx + ON supply_update_events(event_key); CREATE INDEX supply_update_events_transition_id_idx ON supply_update_events(transition_id); diff --git a/tapdb/sqlc/supply_commit.sql.go b/tapdb/sqlc/supply_commit.sql.go index 196f1aab74..3120ce3b80 100644 --- a/tapdb/sqlc/supply_commit.sql.go +++ b/tapdb/sqlc/supply_commit.sql.go @@ -20,6 +20,19 @@ func (q *Queries) DeleteSupplyCommitTransition(ctx context.Context, transitionID return err } +const DeleteSupplyUpdateEvent = `-- name: DeleteSupplyUpdateEvent :exec +DELETE FROM supply_update_events +WHERE event_id = $1 +` + +// Deletes a single supply update event row identified by its +// event_id. Used by the migration 62 backfill to drop duplicate +// rows that hash to the same event_key as an earlier row. +func (q *Queries) DeleteSupplyUpdateEvent(ctx context.Context, eventID int64) error { + _, err := q.db.ExecContext(ctx, DeleteSupplyUpdateEvent, eventID) + return err +} + const DeleteSupplyUpdateEvents = `-- name: DeleteSupplyUpdateEvents :exec DELETE FROM supply_update_events WHERE transition_id = $1 @@ -108,6 +121,50 @@ func (q *Queries) FetchSupplyCommit(ctx context.Context, groupKey []byte) (Fetch return i, err } +const FetchSupplyUpdateEventsForBackfill = `-- name: FetchSupplyUpdateEventsForBackfill :many +SELECT event_id, group_key, update_type_id, event_data +FROM supply_update_events +WHERE event_key IS NULL +` + +type FetchSupplyUpdateEventsForBackfillRow struct { + EventID int64 + GroupKey []byte + UpdateTypeID int32 + EventData []byte +} + +// Returns rows that pre-date the event_key column and still need +// a hash computed. Used by the programmatic migration that runs +// at schema version 61. +func (q *Queries) FetchSupplyUpdateEventsForBackfill(ctx context.Context) ([]FetchSupplyUpdateEventsForBackfillRow, error) { + rows, err := q.db.QueryContext(ctx, FetchSupplyUpdateEventsForBackfill) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FetchSupplyUpdateEventsForBackfillRow + for rows.Next() { + var i FetchSupplyUpdateEventsForBackfillRow + if err := rows.Scan( + &i.EventID, + &i.GroupKey, + &i.UpdateTypeID, + &i.EventData, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const FetchUnspentMintSupplyPreCommits = `-- name: FetchUnspentMintSupplyPreCommits :many SELECT mac.tx_output_index, @@ -325,12 +382,13 @@ func (q *Queries) InsertSupplyCommitment(ctx context.Context, arg InsertSupplyCo return commit_id, err } -const InsertSupplyUpdateEvent = `-- name: InsertSupplyUpdateEvent :exec +const InsertSupplyUpdateEvent = `-- name: InsertSupplyUpdateEvent :execrows INSERT INTO supply_update_events ( - group_key, transition_id, update_type_id, event_data + group_key, transition_id, update_type_id, event_data, event_key ) VALUES ( - $1, $2, $3, $4 + $1, $2, $3, $4, $5 ) +ON CONFLICT (event_key) DO NOTHING ` type InsertSupplyUpdateEventParams struct { @@ -338,16 +396,34 @@ type InsertSupplyUpdateEventParams struct { TransitionID sql.NullInt64 UpdateTypeID int32 EventData []byte -} - -func (q *Queries) InsertSupplyUpdateEvent(ctx context.Context, arg InsertSupplyUpdateEventParams) error { - _, err := q.db.ExecContext(ctx, InsertSupplyUpdateEvent, + EventKey []byte +} + +// The event_key column is a deterministic content hash that +// identifies a logical update event. A duplicate insert (e.g. on +// restart re-run of the Confirmed branch in the minting state +// machine) hits the unique index on event_key and is silently +// dropped, leaving the existing row -- and any transition_id it +// already carries -- untouched. +// +// Returning rows-affected (1 on insert, 0 on conflict) lets the +// caller distinguish "new event recorded" from "dedup absorbed an +// old one" -- the latter is the signal InsertPendingUpdate needs +// to avoid creating an empty pending transition when a re-fired +// event matches a row already attached to a prior (finalized) +// transition. +func (q *Queries) InsertSupplyUpdateEvent(ctx context.Context, arg InsertSupplyUpdateEventParams) (int64, error) { + result, err := q.db.ExecContext(ctx, InsertSupplyUpdateEvent, arg.GroupKey, arg.TransitionID, arg.UpdateTypeID, arg.EventData, + arg.EventKey, ) - return err + if err != nil { + return 0, err + } + return result.RowsAffected() } const LinkDanglingSupplyUpdateEvents = `-- name: LinkDanglingSupplyUpdateEvents :exec @@ -805,6 +881,25 @@ func (q *Queries) QuerySupplyUpdateEvents(ctx context.Context, transitionID sql. return items, nil } +const SetSupplyUpdateEventKey = `-- name: SetSupplyUpdateEventKey :exec +UPDATE supply_update_events +SET event_key = $1 +WHERE event_id = $2 +` + +type SetSupplyUpdateEventKeyParams struct { + EventKey []byte + EventID int64 +} + +// Sets the content-hash key for a single supply update event row. +// Used by the programmatic migration that backfills pre-existing +// rows after column 000061 is added. +func (q *Queries) SetSupplyUpdateEventKey(ctx context.Context, arg SetSupplyUpdateEventKeyParams) error { + _, err := q.db.ExecContext(ctx, SetSupplyUpdateEventKey, arg.EventKey, arg.EventID) + return err +} + const UpdateSupplyCommitTransitionCommitment = `-- name: UpdateSupplyCommitTransitionCommitment :exec UPDATE supply_commit_transitions SET new_commitment_id = $1, diff --git a/tapdb/supply_commit.go b/tapdb/supply_commit.go index 9eaa00a599..8dc9a43dc1 100644 --- a/tapdb/supply_commit.go +++ b/tapdb/supply_commit.go @@ -3,7 +3,9 @@ package tapdb import ( "bytes" "context" + "crypto/sha256" "database/sql" + "encoding/binary" "errors" "fmt" "io" @@ -148,9 +150,11 @@ type SupplyCommitStore interface { arg InsertSupplyCommitTransition) (int64, error) // InsertSupplyUpdateEvent inserts a new supply update event associated - // with a transition. + // with a transition. Returns the number of rows affected: 1 on a + // genuine insert and 0 when the row was deduped by the + // event_key UNIQUE index (ON CONFLICT DO NOTHING). InsertSupplyUpdateEvent(ctx context.Context, - arg InsertSupplyUpdateEvent) error + arg InsertSupplyUpdateEvent) (int64, error) // UpsertChainTx upserts a chain transaction. UpsertChainTx(ctx context.Context, @@ -561,6 +565,24 @@ func updateTypeToInt(treeType supplycommit.SupplySubTree) (int32, error) { } } +// supplyUpdateEventKey returns the 32-byte content hash used as the +// dedup key for a supply update event row. The same logical event -- +// same group, same type, same payload bytes -- always hashes to the +// same value, which is what lets the unique index on event_key +// silently absorb re-inserts after a caretaker restart. +func supplyUpdateEventKey(groupKey []byte, updateTypeID int32, + eventData []byte) []byte { + + var typeBuf [4]byte + binary.BigEndian.PutUint32(typeBuf[:], uint32(updateTypeID)) + + h := sha256.New() + h.Write(groupKey) + h.Write(typeBuf[:]) + h.Write(eventData) + return h.Sum(nil) +} + // serializeSupplyUpdateEvent encodes a SupplyUpdateEvent into bytes. func serializeSupplyUpdateEvent(w io.Writer, event supplycommit.SupplyUpdateEvent) error { @@ -615,6 +637,18 @@ func deserializeSupplyUpdateEvent(typeName string, } } +// errInsertPendingUpdateDeduped is an internal sentinel used to roll +// back the enclosing transaction when a re-fired event was deduped +// against a row that already belongs to a prior (now-finalized) +// transition. Returning an error from the ExecTx closure is the only +// way to discard the freshly-created supply_commit_transitions row; +// the outer InsertPendingUpdate wrapper translates this sentinel back +// to a nil error for the caller, since "the event is already in the +// log" is a successful no-op from their perspective. +var errInsertPendingUpdateDeduped = errors.New( + "supply update event was deduped; rolling back empty transition", +) + // InsertPendingUpdate attempts to insert a new pending update into the // update log of the target supply commit state machine. func (s *SupplyCommitMachine) InsertPendingUpdate(ctx context.Context, @@ -627,31 +661,41 @@ func (s *SupplyCommitMachine) InsertPendingUpdate(ctx context.Context, groupKeyBytes := schnorr.SerializePubKey(groupKey) writeTx := WriteTxOption() - return s.db.ExecTx(ctx, writeTx, func(db SupplyCommitStore) error { + err := s.db.ExecTx(ctx, writeTx, func(db SupplyCommitStore) error { // We'll use this helper function to serialize, then insert a - // new supply update event into the database. - insertUpdate := func(transitionID sql.NullInt64) error { + // new supply update event into the database. It returns the + // number of rows affected (1 on insert, 0 on dedup) so the + // caller can decide whether further work is justified. + insertUpdate := func( + transitionID sql.NullInt64) (int64, error) { + var b bytes.Buffer err := serializeSupplyUpdateEvent(&b, event) if err != nil { - return fmt.Errorf("failed to serialize event "+ - "data: %w", err) + return 0, fmt.Errorf("failed to serialize "+ + "event data: %w", err) } updateTypeID, err := updateTypeToInt( event.SupplySubTreeType(), ) if err != nil { - return fmt.Errorf("failed to map update "+ + return 0, fmt.Errorf("failed to map update "+ "type: %w", err) } + eventData := b.Bytes() + eventKey := supplyUpdateEventKey( + groupKeyBytes, updateTypeID, eventData, + ) + return db.InsertSupplyUpdateEvent( ctx, InsertSupplyUpdateEvent{ GroupKey: groupKeyBytes, TransitionID: transitionID, UpdateTypeID: updateTypeID, - EventData: b.Bytes(), + EventData: eventData, + EventKey: eventKey, }, ) } @@ -663,7 +707,9 @@ func (s *SupplyCommitMachine) InsertPendingUpdate(ctx context.Context, ) // If a pending transition exists, insert the update with the - // appropriate transition ID. + // appropriate transition ID. Dedup (rows == 0) is fine here: + // the existing pending transition is untouched and the prior + // row already carries whatever transition link it should. if err == nil { //nolint:lll transition := pendingTransitionRow.SupplyCommitTransition @@ -675,7 +721,8 @@ func (s *SupplyCommitMachine) InsertPendingUpdate(ctx context.Context, transitionID = sqlInt64(transition.TransitionID) } - return insertUpdate(transitionID) + _, err := insertUpdate(transitionID) + return err } // If the error is anything other than "no rows", it's a real @@ -736,11 +783,21 @@ func (s *SupplyCommitMachine) InsertPendingUpdate(ctx context.Context, } // With the transition created, we can now insert the update - // event, linking it to the new transition. - err = insertUpdate(sqlInt64(transitionID)) + // event, linking it to the new transition. If the insert is + // absorbed by the dedup index (rows == 0), the event was + // already recorded against a prior, now-finalized transition; + // committing the freshly-created transition would leave an + // empty pending transition behind and move the state machine + // to UpdatesPendingState with nothing to commit. Return the + // sentinel error to roll the whole tx back -- the outer + // wrapper turns this into a successful no-op for the caller. + rowsAffected, err := insertUpdate(sqlInt64(transitionID)) if err != nil { return err } + if rowsAffected == 0 { + return errInsertPendingUpdateDeduped + } // Finally, we'll explicitly set the state machine to the // UpdatesPendingState. @@ -766,6 +823,15 @@ func (s *SupplyCommitMachine) InsertPendingUpdate(ctx context.Context, return nil }) + + // A dedup against an existing event is a successful no-op from + // the caller's perspective. The tx was rolled back inside ExecTx, + // so no new transition was committed. + if errors.Is(err, errInsertPendingUpdateDeduped) { + return nil + } + + return err } // FreezePendingTransition marks the current pending transition for a group key diff --git a/tapdb/supply_commit_test.go b/tapdb/supply_commit_test.go index 11bae6264e..942e5ecf45 100644 --- a/tapdb/supply_commit_test.go +++ b/tapdb/supply_commit_test.go @@ -19,6 +19,7 @@ import ( "github.com/lightninglabs/taproot-assets/mssmt" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapdb/sqlc" + "github.com/lightninglabs/taproot-assets/tapgarden" "github.com/lightninglabs/taproot-assets/universe" "github.com/lightninglabs/taproot-assets/universe/supplycommit" "github.com/lightninglabs/taproot-assets/universe/supplyverifier" @@ -147,6 +148,23 @@ func (h *supplyCommitTestHarness) addTestMintingBatch() ([]byte, int64, }) require.NoError(h.t, err) + // NewMintingBatch hardcodes state=BatchStatePending. These + // supply-commit tests need to create multiple batches as + // fixtures, which would violate the singleton invariant added + // in migration 000060 (≤ 1 batch in {Pending, Frozen}). + // Immediately advance to a terminal state so each fixture is + // outside the constrained set; the tests do not exercise the + // planter state machine, only the supply-commit logic, so the + // specific state does not matter as long as it is not + // Pending or Frozen. + err = db.UpdateMintingBatchState( + ctx, sqlc.UpdateMintingBatchStateParams{ + RawKey: batchKeyBytes, + BatchState: int16(tapgarden.BatchStateFinalized), + }, + ) + require.NoError(h.t, err) + _, err = db.BindMintingBatchWithTx( ctx, sqlc.BindMintingBatchWithTxParams{ RawKey: batchKeyDesc.PubKey.SerializeCompressed(), @@ -1368,6 +1386,110 @@ func TestSupplyCommitInsertPendingUpdate(t *testing.T) { assertEqualEvents(t, event3, deserializedDangling) } +// TestSupplyCommitInsertPendingUpdateIsIdempotent verifies that inserting +// the same logical supply update event twice produces only a single row. +// This is the dedup invariant relied on by the minting caretaker's +// Confirmed branch: a crash between SendMintEvent and the batch state +// transition causes a restarted caretaker to re-fire the same event, and +// we need the schema -- not the caller -- to be the source of truth for +// "this event has already been recorded." +func TestSupplyCommitInsertPendingUpdateIsIdempotent(t *testing.T) { + t.Parallel() + + h := newSupplyCommitTestHarness(t) + + // Insert a mint event for the first time. + event := h.randMintEvent() + err := h.commitMachine.InsertPendingUpdate(h.ctx, h.assetSpec, event) + require.NoError(t, err) + + transition := h.assertPendingTransitionExists() + h.assertPendingUpdates([]supplycommit.SupplyUpdateEvent{event}) + + // Re-inserting the same event must be a no-op: same transition, same + // single row in the events log. + err = h.commitMachine.InsertPendingUpdate(h.ctx, h.assetSpec, event) + require.NoError(t, err) + + transitionAgain := h.assertPendingTransitionExists() + require.Equal( + t, transition.TransitionID, transitionAgain.TransitionID, + ) + h.assertPendingUpdates([]supplycommit.SupplyUpdateEvent{event}) + + // A separate event with the same group key must still be accepted -- + // the dedup key is per-event content, not per-group. + otherEvent := h.randMintEvent() + err = h.commitMachine.InsertPendingUpdate( + h.ctx, h.assetSpec, otherEvent, + ) + require.NoError(t, err) + h.assertPendingUpdates([]supplycommit.SupplyUpdateEvent{ + event, otherEvent, + }) +} + +// TestSupplyCommitInsertPendingUpdateRefiredAfterFinalize verifies that +// a re-fired event whose duplicate already belongs to a prior, +// now-finalized transition does not orphan a freshly-created pending +// transition. +// +// Without the rows-affected check inside InsertPendingUpdate, the +// no-pending-transition arm would: (a) insert a new +// supply_commit_transitions row, (b) attempt to insert the event and +// have it deduped to zero rows by the event_key UNIQUE index, and (c) +// move the state machine to UpdatesPendingState -- leaving the new +// transition with no events to commit. The fix detects rows-affected +// == 0 and returns an internal sentinel that rolls the whole tx back +// while reporting success to the caller (the event is, in fact, +// already recorded). +// +// This is the exact scenario the minting caretaker exhibits on restart +// after the Confirmed branch has already finalized its supply commit: +// SendMintEvent fires again, and the upstream API treats it as a +// successful no-op rather than a wedged state machine. +func TestSupplyCommitInsertPendingUpdateRefiredAfterFinalize(t *testing.T) { + t.Parallel() + + h := newSupplyCommitTestHarness(t) + + // Drive a mint event all the way through to a finalized + // transition. After this the supply_update_events row holds the + // event content keyed by its content hash, the transition is + // finalized, and the state machine is back in DefaultState. + event := h.randMintEvent() + stateTransition := h.performSingleTransition( + []supplycommit.SupplyUpdateEvent{event}, + []wire.OutPoint{}, 442, + ) + h.assertTransitionApplied(stateTransition) + h.assertNoPendingTransition() + h.assertCurrentStateIs(&supplycommit.DefaultState{}) + + // Re-fire the exact same event. The dedup index absorbs the + // insert; the wrapper must detect rows-affected == 0 and roll + // back so no new pending transition lands. + err := h.commitMachine.InsertPendingUpdate(h.ctx, h.assetSpec, event) + require.NoError(t, err, "re-fired event must be a successful no-op") + + // The crucial invariant: no fresh empty pending transition. + h.assertNoPendingTransition() + + // And the state machine must not have advanced to + // UpdatesPendingState on the strength of a deduped event. + h.assertCurrentStateIs(&supplycommit.DefaultState{}) + + // A genuinely new event must still be accepted, creating a new + // pending transition as usual -- the rollback path must not + // poison subsequent legitimate inserts. + newEvent := h.randMintEvent() + err = h.commitMachine.InsertPendingUpdate(h.ctx, h.assetSpec, newEvent) + require.NoError(t, err) + h.assertPendingTransitionExists() + h.assertPendingUpdates([]supplycommit.SupplyUpdateEvent{newEvent}) + h.assertCurrentStateIs(&supplycommit.UpdatesPendingState{}) +} + // TestBindDanglingUpdatesToTransition tests the logic for binding dangling // updates to a new transition. func TestBindDanglingUpdatesToTransition(t *testing.T) { @@ -1415,15 +1537,23 @@ func TestBindDanglingUpdatesToTransition(t *testing.T) { ) require.NoError(t, err) - err = db.InsertSupplyUpdateEvent( + eventData := b.Bytes() + eventKey := supplyUpdateEventKey( + h.groupKeyBytes, updateTypeID, + eventData, + ) + + rows, err := db.InsertSupplyUpdateEvent( h.ctx, InsertSupplyUpdateEvent{ GroupKey: h.groupKeyBytes, TransitionID: sql.NullInt64{}, UpdateTypeID: updateTypeID, - EventData: b.Bytes(), + EventData: eventData, + EventKey: eventKey, }, ) require.NoError(t, err) + require.Equal(t, int64(1), rows) } return nil diff --git a/tapdb/supply_pre_commit_store.go b/tapdb/supply_pre_commit_store.go new file mode 100644 index 0000000000..fa0d4f17fb --- /dev/null +++ b/tapdb/supply_pre_commit_store.go @@ -0,0 +1,89 @@ +package tapdb + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightningnetwork/lnd/keychain" +) + +// SupplyPreCommitStore is the tapdb-side gateway to the +// mint_supply_pre_commits table. It owns the supply-pre-commit +// reads that supplycommit's augmenter performs during seedling +// intake and the lookups that supplyverifier performs against +// the group-keyed delegation key. The write side is still +// performed by AssetMintingStore inside its binding transactions +// (so the row lands atomically with the batch's chain update); +// the augmenter constructs the payload and tapgarden plumbs it +// through. +type SupplyPreCommitStore struct { + db BatchedPendingAssetStore +} + +// NewSupplyPreCommitStore returns a new SupplyPreCommitStore +// backed by the same db handle as AssetMintingStore. Callers may +// instantiate multiple stores against the same handle without +// coordination; the underlying SQL queries are commutative on +// their own. +func NewSupplyPreCommitStore( + db BatchedPendingAssetStore) *SupplyPreCommitStore { + + return &SupplyPreCommitStore{db: db} +} + +// FetchDelegationKey fetches the delegation key (Taproot internal +// key of the pre-commitment output) for the given asset group +// public key. Returns None if no pre-commit row matches the +// group. +// +// NOTE: When multiple pre-commitment outputs share the same group +// key, the first one is selected. The invariant that all outputs +// in a group share the same delegation key is enforced upstream +// during minting. +func (s *SupplyPreCommitStore) FetchDelegationKey(ctx context.Context, + groupKey btcec.PublicKey) (fn.Option[keychain.KeyDescriptor], error) { + + var zero fn.Option[keychain.KeyDescriptor] + groupKeyBytes := schnorr.SerializePubKey(&groupKey) + + var delegationKey fn.Option[keychain.KeyDescriptor] + + readOpts := NewAssetStoreReadTx() + dbErr := s.db.ExecTx(ctx, &readOpts, func(q PendingAssetStore) error { + fetchRow, err := q.FetchMintSupplyPreCommits( + ctx, FetchMintPreCommitsParams{ + GroupKey: groupKeyBytes, + }, + ) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + return fmt.Errorf("unable to fetch mint anchor "+ + "uni commitment by group key: %w", err) + } + + if len(fetchRow) == 0 { + return nil + } + + internalKey, err := parseInternalKey(fetchRow[0].InternalKey) + if err != nil { + return fmt.Errorf("error parsing pre-commitment "+ + "internal key: %w", err) + } + + delegationKey = fn.Some(internalKey) + return nil + }) + if dbErr != nil { + return zero, dbErr + } + + return delegationKey, nil +} diff --git a/tapfreighter/chain_porter.go b/tapfreighter/chain_porter.go index 0dd2dd221b..3e54ce8ad5 100644 --- a/tapfreighter/chain_porter.go +++ b/tapfreighter/chain_porter.go @@ -21,6 +21,7 @@ import ( "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/tapsend" @@ -481,7 +482,7 @@ func (p *ChainPorter) storeProofs(sendPkg *sendPackage) error { confEvent := sendPkg.TransferTxConfEvent // Use callback to verify that block header exists on chain. - headerVerifier := tapgarden.GenHeaderVerifier(ctx, p.cfg.ChainBridge) + headerVerifier := tapnode.GenHeaderVerifier(ctx, p.cfg.ChainBridge) // Generate updated passive asset proof files. passiveAssetProofFiles := make( @@ -1415,7 +1416,7 @@ func (p *ChainPorter) prelimCheckAddrParcel(addrParcel AddressParcel) error { func (p *ChainPorter) verifyVPacketsPreBroadcast(ctx context.Context, packets []*tappsbt.VPacket) error { - headerVerifier := tapgarden.GenHeaderVerifier(ctx, p.cfg.ChainBridge) + headerVerifier := tapnode.GenHeaderVerifier(ctx, p.cfg.ChainBridge) vCtx := proof.VerifierCtx{ HeaderVerifier: headerVerifier, MerkleVerifier: proof.DefaultMerkleVerifier, @@ -1592,7 +1593,7 @@ func (p *ChainPorter) verifyOutputProofPreBroadcast(ctx context.Context, func (p *ChainPorter) verifyPacketInputProofs(ctx context.Context, vPkt tappsbt.VPacket) error { - headerVerifier := tapgarden.GenHeaderVerifier(ctx, p.cfg.ChainBridge) + headerVerifier := tapnode.GenHeaderVerifier(ctx, p.cfg.ChainBridge) vCtx := proof.VerifierCtx{ HeaderVerifier: headerVerifier, MerkleVerifier: proof.DefaultMerkleVerifier, diff --git a/tapfreighter/fund_test.go b/tapfreighter/fund_test.go index df0e9f5300..43b91f3d78 100644 --- a/tapfreighter/fund_test.go +++ b/tapfreighter/fund_test.go @@ -19,7 +19,7 @@ import ( "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/internal/test" "github.com/lightninglabs/taproot-assets/proof" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode/tapnodemock" "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightningnetwork/lnd/keychain" @@ -326,7 +326,7 @@ func TestFundPacket(t *testing.T) { expectedErr string expectedInputCommitments tappsbt.InputCommitments expectedOutputs func(*testing.T, - *tapgarden.MockKeyRing) [][]*tappsbt.VOutput + *tapnodemock.KeyRing) [][]*tappsbt.VOutput }{ { name: "single input, no change present", @@ -378,7 +378,7 @@ func TestFundPacket(t *testing.T) { inputPrevID: inputCommitment, }, expectedOutputs: func(t *testing.T, - r *tapgarden.MockKeyRing) [][]*tappsbt.VOutput { + r *tapnodemock.KeyRing) [][]*tappsbt.VOutput { pkt0Outputs := []*tappsbt.VOutput{{ Amount: mintAmount - 20, @@ -429,7 +429,7 @@ func TestFundPacket(t *testing.T) { inputPrevID: inputCommitment, }, expectedOutputs: func(t *testing.T, - r *tapgarden.MockKeyRing) [][]*tappsbt.VOutput { + r *tapnodemock.KeyRing) [][]*tappsbt.VOutput { pkt0Outputs := []*tappsbt.VOutput{{ Amount: 0, @@ -486,7 +486,7 @@ func TestFundPacket(t *testing.T) { inputPrevID: inputCommitment, }, expectedOutputs: func(t *testing.T, - r *tapgarden.MockKeyRing) [][]*tappsbt.VOutput { + r *tapnodemock.KeyRing) [][]*tappsbt.VOutput { pkt0Outputs := []*tappsbt.VOutput{{ Amount: 0, @@ -556,7 +556,7 @@ func TestFundPacket(t *testing.T) { // to the same anchor output. And the same anchor output // key should be derived for the same output indexes. expectedOutputs: func(t *testing.T, - r *tapgarden.MockKeyRing) [][]*tappsbt.VOutput { + r *tapnodemock.KeyRing) [][]*tappsbt.VOutput { pkt0Outputs := []*tappsbt.VOutput{{ Amount: 0, @@ -643,7 +643,7 @@ func TestFundPacket(t *testing.T) { // to the same anchor output. And the same anchor output // key should be derived for the same output indexes. expectedOutputs: func(t *testing.T, - r *tapgarden.MockKeyRing) [][]*tappsbt.VOutput { + r *tapnodemock.KeyRing) [][]*tappsbt.VOutput { pkt0Outputs := []*tappsbt.VOutput{{ Amount: mintAmount, @@ -733,7 +733,7 @@ func TestFundPacket(t *testing.T) { // the same anchor output. And the same anchor output // key should be derived for the same output indexes. expectedOutputs: func(t *testing.T, - r *tapgarden.MockKeyRing) [][]*tappsbt.VOutput { + r *tapnodemock.KeyRing) [][]*tappsbt.VOutput { pkt0Outputs := []*tappsbt.VOutput{{ Amount: mintAmount, @@ -783,7 +783,7 @@ func TestFundPacket(t *testing.T) { proofs: tc.inputProofs, } addrBook := &mockAddrBook{} - keyRing := tapgarden.NewMockKeyRing() + keyRing := tapnodemock.NewKeyRing() result, err := createFundedPacketWithInputs( ctx, exporter, keyRing, addrBook, diff --git a/tapfreighter/interface.go b/tapfreighter/interface.go index 6a7b4e9b09..4e55f723b1 100644 --- a/tapfreighter/interface.go +++ b/tapfreighter/interface.go @@ -19,7 +19,7 @@ import ( "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/mssmt" "github.com/lightninglabs/taproot-assets/proof" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightningnetwork/lnd/keychain" @@ -695,20 +695,21 @@ type ExportLog interface { []*OutboundParcel, error) } -// ChainBridge aliases into the ChainBridge of the tapgarden package. -type ChainBridge = tapgarden.ChainBridge +// ChainBridge aliases into the ChainBridge of the tapnode package. +type ChainBridge = tapnode.ChainBridge -// WalletAnchor aliases into the WalletAnchor of the taparden package. +// WalletAnchor extends the WalletAnchor of the tapnode package with the +// PSBT-signing capability that the freighter requires. type WalletAnchor interface { - tapgarden.WalletAnchor + tapnode.WalletAnchor // SignPsbt signs all the inputs it can in the passed-in PSBT packet, // returning a new one with updated signature/witness data. SignPsbt(ctx context.Context, packet *psbt.Packet) (*psbt.Packet, error) } -// KeyRing aliases into the KeyRing of the tapgarden package. -type KeyRing = tapgarden.KeyRing +// KeyRing aliases into the KeyRing of the tapnode package. +type KeyRing = tapnode.KeyRing // Signer aliases into the Signer interface of the tapscript package. type Signer = tapscript.Signer diff --git a/tapfreighter/wallet.go b/tapfreighter/wallet.go index 6fce858207..a9e219ae3f 100644 --- a/tapfreighter/wallet.go +++ b/tapfreighter/wallet.go @@ -20,7 +20,7 @@ import ( "github.com/lightninglabs/taproot-assets/commitment" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/proof" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/tapsend" @@ -930,7 +930,7 @@ func (f *AssetWallet) SignVirtualPacket(ctx context.Context, optFunc(opts) } - headerVerifier := tapgarden.GenHeaderVerifier(ctx, f.cfg.ChainBridge) + headerVerifier := tapnode.GenHeaderVerifier(ctx, f.cfg.ChainBridge) vCtx := proof.VerifierCtx{ HeaderVerifier: headerVerifier, MerkleVerifier: proof.DefaultMerkleVerifier, diff --git a/tapgarden/augmenter.go b/tapgarden/augmenter.go new file mode 100644 index 0000000000..40a6984d0e --- /dev/null +++ b/tapgarden/augmenter.go @@ -0,0 +1,130 @@ +package tapgarden + +import ( + "context" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tapsend" +) + +// GenesisTxAugmenter is the interface that tapgarden invokes at +// well-defined lifecycle moments to let an external substance +// participate in batch minting without tapgarden having to know +// what the substance is doing. The augmenter's outputs ride on +// the genesis tx, its persistence rides on the BatchStore's +// binding tx, and its post-confirmation events fire once the +// batch is durably anchored. +// +// The canonical use case is the supply-commitment subsystem, +// which contributes a pre-commitment output to the genesis tx +// and emits a mint event after confirmation. Other subsystems +// could implement the same interface without tapgarden needing +// to learn anything about them. +// +// A nil GenesisTxAugmenter on GardenKit means no augmenter is +// active. All planter call sites tolerate a nil pointer (via +// the planter's internal helper) so callers may leave it unset +// for tests or for tapd instances that do not run a +// supply-commit substance. +type GenesisTxAugmenter interface { + // PrepareSeedling runs at seedling intake before + // validation. The hook may mutate req to populate + // augmenter-managed fields (e.g. delegation keys derived + // from the batch's existing seedlings or from external + // state). An error here aborts the seedling intake. + PrepareSeedling(ctx context.Context, batch *MintingBatch, + req *Seedling) error + + // ValidateSeedling gates seedling-into-batch admission by + // augmenter-owned invariants (e.g. homogeneity of the + // SupplyCommitments flag within a batch). Called from the + // planter after the batch's own validation has passed. + ValidateSeedling(batch *MintingBatch, req Seedling) error + + // ExtraOutputs returns the extra outputs (if any) that + // should be spliced into the unfunded anchor PSBT for this + // batch. Each output's PkScript must be deterministic from + // the batch's contents so the augmenter can locate the same + // output in the funded PSBT. + ExtraOutputs(ctx context.Context, + batch *MintingBatch) ([]wire.TxOut, error) + + // PostFund is called once the wallet has funded the anchor + // PSBT. The hook locates its own outputs by matching against + // the result of ExtraOutputs and stamps any required + // metadata (e.g. BIP32 derivation paths) on the + // corresponding PSBT outputs. + PostFund(ctx context.Context, batch *MintingBatch, + funded *tapsend.FundedPsbt) error + + // BindData returns the typed persistence payload that the + // BatchStore should write for the augmenter's outputs (if + // any). Called by tapgarden immediately after funding (so + // the row lands atomically with the batch chain update) + // and again at seal time (so the row picks up newly + // available group-key info). The implementation reads from + // the batch's current state. + BindData(ctx context.Context, + batch *MintingBatch) (fn.Option[PreCommitBindData], error) + + // OnBatchConfirmed runs once the batch has confirmed on + // chain and the cultivator has archived its proofs locally. + // The hook may emit downstream events (e.g. supply-commit + // notifications). An error is logged but does not unwind + // the confirmation. + OnBatchConfirmed(ctx context.Context, batch *MintingBatch, + anchorAssets, nonAnchorAssets []*asset.Asset, + mintingProofs proof.AssetProofs) error +} + +// NoOpAugmenter is a GenesisTxAugmenter that does nothing. The +// planter substitutes it whenever GardenKit.GenesisTxAugmenter +// is nil, so internal call sites never need to check. +type NoOpAugmenter struct{} + +// PrepareSeedling is a no-op. +func (NoOpAugmenter) PrepareSeedling(_ context.Context, + _ *MintingBatch, _ *Seedling) error { + + return nil +} + +// ValidateSeedling is a no-op. +func (NoOpAugmenter) ValidateSeedling(_ *MintingBatch, _ Seedling) error { + return nil +} + +// ExtraOutputs returns no extra outputs. +func (NoOpAugmenter) ExtraOutputs(_ context.Context, + _ *MintingBatch) ([]wire.TxOut, error) { + + return nil, nil +} + +// PostFund is a no-op. +func (NoOpAugmenter) PostFund(_ context.Context, _ *MintingBatch, + _ *tapsend.FundedPsbt) error { + + return nil +} + +// BindData returns no persistence payload. +func (NoOpAugmenter) BindData(_ context.Context, + _ *MintingBatch) (fn.Option[PreCommitBindData], error) { + + return fn.None[PreCommitBindData](), nil +} + +// OnBatchConfirmed is a no-op. +func (NoOpAugmenter) OnBatchConfirmed(_ context.Context, _ *MintingBatch, + _, _ []*asset.Asset, _ proof.AssetProofs) error { + + return nil +} + +// A compile-time assertion to ensure NoOpAugmenter satisfies the +// GenesisTxAugmenter interface. +var _ GenesisTxAugmenter = NoOpAugmenter{} diff --git a/tapgarden/batch.go b/tapgarden/batch.go index 4b856f9cf6..9e894c386d 100644 --- a/tapgarden/batch.go +++ b/tapgarden/batch.go @@ -72,17 +72,10 @@ type MintingBatch struct { // ignored, burnt, etc). SupplyCommitments bool - // mintingPubKey is the top-level Taproot output key that will be used - // to commit to the Taproot Asset commitment above. - mintingPubKey *btcec.PublicKey - - // tapSibling is an optional root hash of a tapscript tree that will be - // used with the taprootAssetScriptRoot to construct the mintingPubKey. + // tapSibling is an optional root hash of a tapscript tree that is + // combined with the Taproot Asset commitment to derive the + // MintingOutputKey. tapSibling *chainhash.Hash - - // taprootAssetScriptRoot is the root hash of the Taproot Asset - // commitment. If this is nil, then the mintingPubKey will be as well. - taprootAssetScriptRoot []byte } // VerboseBatch is a MintingBatch that includes seedlings with their pending @@ -102,46 +95,84 @@ func (m *MintingBatch) BatchKeyBytes() []byte { return m.BatchKey.PubKey.SerializeCompressed() } -// Copy creates a deep copy of the batch. +// copyAssetMetas returns a deep copy of an AssetMetas map. Both the map +// and each *MetaReveal value are duplicated. +func copyAssetMetas(am AssetMetas) AssetMetas { + if am == nil { + return nil + } + out := make(AssetMetas, len(am)) + for k, v := range am { + out[k] = v.Copy() + } + return out +} + +// copySeedlings returns a deep copy of a name->seedling map. Each Seedling +// is cloned via Seedling.Copy(); the map itself is freshly allocated. +func copySeedlings(in map[string]*Seedling) map[string]*Seedling { + if in == nil { + return nil + } + out := make(map[string]*Seedling, len(in)) + for k, v := range in { + out[k] = v.Copy() + } + return out +} + +// Copy returns a deep copy of the batch. Every nested pointer, slice, and +// map is duplicated so that mutating the returned batch (or any of its +// substructure) cannot be observed through the source, and vice-versa. +// +// The only intentional sharing is for fields the codebase treats as +// immutable after construction: +// - BatchKey: keychain.KeyDescriptor is rebuilt with a fresh PubKey +// pointer, but its KeyLocator (two uint32 fields) is trivially +// value-copied. +// - tapSibling: a *chainhash.Hash; the underlying 32-byte array is +// value-copied via *m.tapSibling, yielding an independent hash. +// - RootAssetCommitment: cloned via TapCommitment.Copy(), which is +// deep (see commitment.TestTapCommitmentDeepCopy). +// +// The deep-copy contract is exercised by TestMintingBatchCopyIsDeep. func (m *MintingBatch) Copy() *MintingBatch { - batchCopy := &MintingBatch{ - CreationTime: m.CreationTime, - HeightHint: m.HeightHint, - // The following values are expected to not change once they are - // set, so a shallow copy is sufficient. - BatchKey: m.BatchKey, - RootAssetCommitment: m.RootAssetCommitment, - SupplyCommitments: m.SupplyCommitments, - mintingPubKey: m.mintingPubKey, - tapSibling: m.tapSibling, + if m == nil { + return nil } - batchCopy.UpdateState(m.State()) - if m.Seedlings != nil { - batchCopy.Seedlings = make( - map[string]*Seedling, len(m.Seedlings), - ) - for k, v := range m.Seedlings { - seedlingCopy := *v - batchCopy.Seedlings[k] = &seedlingCopy - } + batchCopy := &MintingBatch{ + CreationTime: m.CreationTime, + HeightHint: m.HeightHint, + BatchKey: asset.CopyKeyDescriptor(m.BatchKey), + SupplyCommitments: m.SupplyCommitments, + Seedlings: copySeedlings(m.Seedlings), + AssetMetas: copyAssetMetas(m.AssetMetas), } + batchCopy.setState(m.State()) - if m.GenesisPacket != nil { - batchCopy.GenesisPacket = m.GenesisPacket.Copy() + if m.tapSibling != nil { + siblingCopy := *m.tapSibling + batchCopy.tapSibling = &siblingCopy } - if m.AssetMetas != nil { - batchCopy.AssetMetas = make(AssetMetas, len(m.AssetMetas)) - for k, v := range m.AssetMetas { - batchCopy.AssetMetas[k] = v + if m.RootAssetCommitment != nil { + commitCopy, err := m.RootAssetCommitment.Copy() + if err != nil { + // TapCommitment.Copy only errors on malformed + // internal state; tapgarden builds commitments + // itself via seedlingsToAssetSprouts so this is not + // reachable in practice. If we ever hit it, panic so + // the corruption surfaces immediately rather than + // silently degrading the snapshot contract. + panic(fmt.Errorf("MintingBatch.Copy: deep-copying "+ + "root asset commitment failed: %w", err)) } + batchCopy.RootAssetCommitment = commitCopy } - if m.taprootAssetScriptRoot != nil { - batchCopy.taprootAssetScriptRoot = fn.CopySlice( - m.taprootAssetScriptRoot, - ) + if m.GenesisPacket != nil { + batchCopy.GenesisPacket = m.GenesisPacket.Copy() } return batchCopy @@ -149,7 +180,7 @@ func (m *MintingBatch) Copy() *MintingBatch { // validateGroupAnchor checks if the group anchor for a seedling is valid. // A valid anchor must already be part of the batch and have emission enabled. -func (m *MintingBatch) validateGroupAnchor(s *Seedling) error { +func (m *MintingBatch) ValidateGroupAnchor(s *Seedling) error { if s.GroupAnchor == nil { return fmt.Errorf("group anchor unspecified") } @@ -168,15 +199,22 @@ func (m *MintingBatch) validateGroupAnchor(s *Seedling) error { return validateAnchorMeta(s.Meta, anchor.Meta) } -// MintingOutputKey derives the output key that once mined, will commit to the -// Taproot asset root, thereby creating the set of included assets. +// MintingOutputKey derives the output key that once mined, will commit +// to the Taproot asset root, thereby creating the set of included +// assets. The returned byte slice is the tapscript root that was +// committed to (the Taproot Asset commitment combined with the +// optional sibling). +// +// This function is pure in (m.BatchKey, m.RootAssetCommitment, +// sibling): every call with the same arguments returns the same +// result, and every call with a different sibling returns a +// different result. There is no memoization; the on-curve work is +// trivial and a cache would re-introduce the §IV bug shape where +// the function silently ignored its sibling argument after the +// first call. func (m *MintingBatch) MintingOutputKey(sibling *commitment.TapscriptPreimage) ( *btcec.PublicKey, []byte, error) { - if m.mintingPubKey != nil { - return m.mintingPubKey, m.taprootAssetScriptRoot, nil - } - if m.RootAssetCommitment == nil { return nil, nil, fmt.Errorf("no asset commitment present") } @@ -197,12 +235,11 @@ func (m *MintingBatch) MintingOutputKey(sibling *commitment.TapscriptPreimage) ( siblingHash, ) - m.taprootAssetScriptRoot = taprootAssetScriptRoot[:] - m.mintingPubKey = txscript.ComputeTaprootOutputKey( + mintingPubKey := txscript.ComputeTaprootOutputKey( m.BatchKey.PubKey, taprootAssetScriptRoot[:], ) - return m.mintingPubKey, m.taprootAssetScriptRoot, nil + return mintingPubKey, taprootAssetScriptRoot[:], nil } // VerifyOutputScript recomputes a batch genesis output script from a batch key, @@ -288,12 +325,29 @@ func (m *MintingBatch) State() BatchState { return batchStateCopy } -// UpdateState updates the state of a batch to a value that has been verified to -// be a valid batch state. -func (m *MintingBatch) UpdateState(state BatchState) { +// setState updates the in-memory batch state. This is unexported because +// every authoritative state mutation must flow through a BatchStore call +// that writes to disk first and only then mutates memory. Use this only for +// package-internal cases that are not the result of a DB transition +// (currently: initial Pending state during batch construction and copying +// a batch via Copy()). +func (m *MintingBatch) setState(state BatchState) { m.batchState.Store(uint32(state)) } +// SetStateOnDBSuccess mutates the in-memory batch state. It is intended to +// be called exclusively by BatchStore implementations after a successful +// DB write has committed the same state to disk; this is what guarantees +// that the in-memory mirror cannot get ahead of the on-disk truth. +// +// NOTE: Ordinary callers (planter, caretaker, RPC layer, tests) must never +// invoke this method directly. Use the BatchStore interface, whose +// state-mutating methods take *MintingBatch and update memory only on DB +// success. +func (m *MintingBatch) SetStateOnDBSuccess(state BatchState) { + m.setState(state) +} + // TapSibling returns the optional tapscript sibling for the batch, which is a // root hash of a tapscript tree. func (m *MintingBatch) TapSibling() []byte { @@ -304,7 +358,16 @@ func (m *MintingBatch) TapSibling() []byte { return m.tapSibling.CloneBytes() } -// UpdateTapSibling updates the optional tapscript sibling for the batch. +// UpdateTapSibling mutates the in-memory tapscript sibling for the batch. +// It is intended to be called exclusively by BatchStore implementations +// after a successful DB write has committed the same sibling to disk; +// this is what guarantees that the in-memory mirror cannot get ahead of +// the on-disk truth. +// +// NOTE: Ordinary callers (planter, cultivator, RPC layer, tests) must +// never invoke this method directly. Use the BatchStore interface, whose +// sibling-mutating methods take *MintingBatch and update memory only on +// DB success. func (m *MintingBatch) UpdateTapSibling(sibling *chainhash.Hash) { m.tapSibling = sibling } @@ -320,225 +383,96 @@ 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.SupplyCommitments { - 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"), +// uniqueAnchorSeedling returns the single group anchor seedling in +// the batch -- the seedling whose GroupAnchor is nil and that other +// seedlings may reference by name. If the batch contains zero or +// more than one such seedling, an error is returned. +// +// This invariant ("exactly one anchor per batch") is required by +// callers that derive batch-wide properties (the delegation key, +// the pre-commitment group key) from the anchor seedling: with no +// anchor there is no answer, and with multiple anchors the answer +// is ambiguous. The function computes the answer deterministically +// rather than relying on non-deterministic map iteration to land +// on the unique anchor by luck. +func (m *MintingBatch) uniqueAnchorSeedling() (*Seedling, error) { + var ( + anchor *Seedling + count int ) - 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.SupplyCommitments { - 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, the batch is empty and the current seedling - // will be the first added. Therefore, if the seedling is - // neither creating a new group nor adding to an existing one, - // it violates the constraints of the universe commitment - // feature. - if !newSeedling.EnableEmission && !newSeedling.HasGroupKey() { - return fmt.Errorf("universe commitment enabled: " + - "seedling must either create a new asset " + - "group or issue into an existing one") - } - - // No further checks are required for the first seedling in the - // batch. - return nil - } - - // At this stage, we know that the batch contains seedlings. - // Furthermore, 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.SupplyCommitments != newSeedling.SupplyCommitments { - 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.SupplyCommitments && !newSeedling.SupplyCommitments { - return nil - } - - // Logically, by this point, the following must be true: - // * the universe commitment flag is enabled for both the seedling and - // the batch - // * the batch contains at least one seedling. - // - // For clarity, we will assert these conditions now. - if !m.SupplyCommitments || !newSeedling.SupplyCommitments || - !m.HasSeedlings() { - - return fmt.Errorf("unexpected code path reached") - } - - // At this point, the candidate seedling (with uni commitments enabled) - // 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("non-empty batch with uni commit enabled " + - "but candidate seedling does not have group anchor " + - "specified") - } - - // For clarity, we will assert that the candidate seedling refers to a - // group anchor that is already part of the batch. - if _, ok := m.Seedlings[*newSeedling.GroupAnchor]; !ok { - return fmt.Errorf("group anchor for candidate seedling not " + - "present in batch") - } - - // Next, we will also assert that there is only one group anchor in the - // batch. - var anchorCount int for _, seedling := range m.Seedlings { if seedling.GroupAnchor != nil { - anchorCount++ + continue } - } - if anchorCount > 1 { - return fmt.Errorf("multiple group anchors present in batch " + - "with universe commitments enabled") + + anchor = seedling + count++ } - // Ensure that the group anchor for the candidate seedling is already - // present in the batch. - err := m.validateGroupAnchor(&newSeedling) - if err != nil { - return fmt.Errorf("group anchor validation failed: %w", err) + switch count { + case 0: + return nil, fmt.Errorf("no group anchor seedling in batch") + case 1: + return anchor, nil + default: + return nil, fmt.Errorf("batch has %d group anchor "+ + "seedlings, expected exactly 1", count) } +} +// validateSeedling checks that a candidate seedling is admissible into +// the batch given the batch's current state. It does not mutate the +// batch; this is the read-only half of AddSeedling. +// +// Augmenter-owned invariants (universe commitments, delegation +// keys) are checked separately by the planter via +// GenesisTxAugmenter.ValidateSeedling before this method is +// reached. This method covers only the batch's own invariants. +// +// Callers that need a persistence boundary between validation and +// mutation (e.g. "validate, write to disk, then update in memory") +// should pair this with commitSeedling so an in-memory mutation +// cannot precede the persistence that justifies it. +func (m *MintingBatch) validateSeedling(_ Seedling) error { 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. +// commitSeedling applies the in-memory mutation that adds newSeedling +// to the batch. It assumes the seedling has already been validated by +// validateSeedling; calling it on an invalid seedling is a +// programming error. +// +// The SupplyCommitments mutation must happen before the seedling is +// inserted into the map, because the gate is m.HasSeedlings() which +// flips once the insertion has happened. +func (m *MintingBatch) commitSeedling(newSeedling Seedling) { if !m.HasSeedlings() { m.SupplyCommitments = newSeedling.SupplyCommitments } - // 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. if m.Seedlings == nil { m.Seedlings = make(map[string]*Seedling) } m.Seedlings[newSeedling.AssetName] = &newSeedling +} +// AddSeedling validates the seedling against the batch and, if valid, +// adds it. This is the convenience wrapper for callers that do not +// need a persistence boundary between validation and the in-memory +// mutation (e.g. constructing a fresh batch in memory that will be +// persisted whole, or test helpers building random batches). +// +// Callers that *do* need a persistence boundary -- e.g. adding a +// seedling to an existing on-disk batch where the in-memory mirror +// must not advance unless the DB write succeeds -- should use +// validateSeedling and commitSeedling explicitly around the +// persistence call. +func (m *MintingBatch) AddSeedling(newSeedling Seedling) error { + if err := m.validateSeedling(newSeedling); err != nil { + return err + } + m.commitSeedling(newSeedling) return nil } diff --git a/tapgarden/batch_test.go b/tapgarden/batch_test.go index 9f24af8bb6..f13bbd930a 100644 --- a/tapgarden/batch_test.go +++ b/tapgarden/batch_test.go @@ -1,218 +1,21 @@ package tapgarden import ( + "bytes" + "encoding/hex" "testing" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/commitment" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/internal/test" + "github.com/lightninglabs/taproot-assets/proof" "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/require" ) -// TestValidateUniCommitment tests the mint batch method validateUniCommitment. -func TestValidateUniCommitment(t *testing.T) { - t.Parallel() - - // Define test cases. - type TestCase struct { - name string - candidateSeedling Seedling - batch *MintingBatch - expectErr bool - } - - testCases := []TestCase{ - { - // Verifies that a group anchor candidate seedling with - // universe commitments cannot be added to a batch with - // the universe commitments feature enabled - // if the batch already contains a group anchor - // seedling. - name: "populated batch with universe commitments; " + - "batch already includes group anchor; " + - "candidate seedling is also group anchor; " + - "is not valid", - - candidateSeedling: RandGroupAnchorSeedling( - t, "new-group-anchor", true, - ), - batch: RandMintingBatch( - t, WithTotalGroups([]int{2}), - WithUniverseCommitments(true), - ), - expectErr: true, - }, - { - // Ensures that a group anchor candidate seedling with - // universe commitments cannot be added to a batch that - // already contains seedlings and has the universe - // commitments feature disabled. - name: "populated batch without universe commitments; " + - "batch already includes group anchor; " + - "candidate seedling is also group anchor; " + - "is not valid", - - candidateSeedling: RandGroupAnchorSeedling( - t, "new-group-anchor", true, - ), - batch: RandMintingBatch( - t, WithTotalGroups([]int{2}), - WithUniverseCommitments(false), - ), - expectErr: true, - }, - { - // Ensures that a group candidate seedling (non-anchor) - // with universe commitments cannot be added to a batch - // that already contains seedlings and has the universe - // commitments feature disabled. - name: "populated batch without universe commitments; " + - "candidate seedling is non-anchor with " + - "universe commitments; is not valid", - - candidateSeedling: RandNonAnchorGroupSeedling( - t, asset.V1, asset.Normal, "some-anchor-name", - []byte{}, fn.None[keychain.KeyDescriptor](), - true, - ), - batch: RandMintingBatch( - t, WithTotalGroups([]int{2}), - WithUniverseCommitments(false), - ), - expectErr: true, - }, - { - // Ensures that a group candidate seedling (non-anchor) - // with universe commitments cannot be added to a batch - // that already contains seedlings and has the universe - // commitments feature enabled. - // - // This is because the anchor seedling for the candidate - // seedling is not already present in the batch. - name: "populated batch with universe commitments; " + - "non-anchor candidate seedling with universe " + - "commitments; anchor not in batch; " + - "is not valid", - - candidateSeedling: RandNonAnchorGroupSeedling( - t, asset.V1, asset.Normal, "some-anchor-name", - []byte{}, fn.None[keychain.KeyDescriptor](), - true, - ), - batch: RandMintingBatch( - t, WithTotalGroups([]int{2}), - WithUniverseCommitments(true), - ), - expectErr: true, - }, - { - // Ensures that a group anchor candidate seedling - // with universe commitments can be added to an empty - // batch. - name: "empty unfunded batch; candidate seedling is " + - "group anchor; is valid", - - candidateSeedling: RandGroupAnchorSeedling( - t, "some-anchor-name", true, - ), - batch: RandMintingBatch(t, WithSkipFunding()), - expectErr: false, - }, - } - - // Construct a test case where a mint batch with universe commitments - // is populated with seedlings and a group anchor seedling is present. - // The candidate seedling is a non-anchor group seedling with - // universe commitments which specifies the batch group anchor seedling - // as its anchor. The candidate seedling should be deemed valid. - batch := RandMintingBatch( - t, WithTotalGroups([]int{2}), WithUniverseCommitments(true), - ) - - // Identify the anchor seedling in the batch. - var anchorSeedling *Seedling - for idx := range batch.Seedlings { - seedling := batch.Seedlings[idx] - if seedling.GroupAnchor == nil { - anchorSeedling = seedling - break - } - } - - // Construct a candidate seedling that is a non-anchor group seedling - // with universe commitments and specifies the batch group anchor - // seedling as its anchor. - candidateSeedling := RandNonAnchorGroupSeedling( - t, anchorSeedling.AssetVersion, anchorSeedling.AssetType, - anchorSeedling.AssetName, anchorSeedling.Meta.Data, - anchorSeedling.DelegationKey, - anchorSeedling.SupplyCommitments, - ) - - testCases = append(testCases, TestCase{ - name: "populated batch with universe commitments; " + - "candidate seedling is non-anchor group seedling " + - "with universe commitments; anchor in batch; " + - "is valid", - candidateSeedling: candidateSeedling, - batch: batch, - expectErr: false, - }) - - // Construct a test case where an empty but funded mint batch is - // populated with a group anchor seedling. The candidate seedling has - // universe commitments enabled. We expect the candidate seedling to be - // deemed invalid. This is because the batch is already funded and - // therefore the universe commitments feature cannot be enabled. - fundedEmptyBatch := RandMintingBatch(t) - - // Set the genesis packet of the empty batch to simulate funding. - fundedEmptyBatch.GenesisPacket = &FundedMintAnchorPsbt{} - - testCases = append(testCases, TestCase{ - name: "empty funded batch; candidate seedling is anchor " + - "group seedling with universe commitments; is valid", - candidateSeedling: RandGroupAnchorSeedling( - t, "some-anchor-name", true, - ), - batch: fundedEmptyBatch, - expectErr: true, - }) - - // Add a test case where the batch is funded and empty but universe - // commitments is not enabled for the candidate seedling. The candidate - // seedling should be deemed valid. The universe commitment feature - // restriction does not apply in this case. - testCases = append(testCases, TestCase{ - name: "empty funded batch; candidate seedling is anchor " + - "group seedling with universe commitments; is valid", - candidateSeedling: RandGroupAnchorSeedling( - t, "some-anchor-name", false, - ), - batch: fundedEmptyBatch, - expectErr: false, - }) - - // Execute test cases. - for idx := range testCases { - tc := testCases[idx] - - t.Run(tc.name, func(t *testing.T) { - err := tc.batch.validateUniCommitment( - tc.candidateSeedling, - ) - - if tc.expectErr { - require.Error(t, err) - return - } - - require.NoError(t, err) - }) - } -} - // TestMintingBatchCopy tests that MintingBatch.Copy() works as expected. func TestMintingBatchCopy(t *testing.T) { t.Run("deep copy seedlings", func(t *testing.T) { @@ -227,6 +30,15 @@ func TestMintingBatchCopy(t *testing.T) { p := &MintingBatch{} test.FillFakeData(t, debug, maxDepth, p) + // FillFakeData populates GenesisPacket and RootAssetCommitment + // with reflection-random nonsense that doesn't survive the + // genuine deep-copy paths (psbt serialize/parse, MSSMT clone). + // Clear them so this subtest stays focused on the map-shape + // invariants it was always meant to assert; the realistic- + // fixture coverage lives in TestMintingBatchCopyIsDeep below. + p.GenesisPacket = nil + p.RootAssetCommitment = nil + // Ensure we have deterministic seedlings to probe for aliasing. p.Seedlings = map[string]*Seedling{ "seed-a": { @@ -239,8 +51,8 @@ func TestMintingBatchCopy(t *testing.T) { }, } - // We allow aliasing here deep down (for now). - strict := false + // Copy is now fully deep: assert no aliasing anywhere. + strict := true test.AssertCopyEqual(t, debug, strict, p) copyBatch := p.Copy() @@ -279,3 +91,480 @@ func TestMintingBatchCopy(t *testing.T) { require.Empty(t, emptyCopy.Seedlings) }) } + +// TestMintingBatchCopyIsDeep is the §III deep-copy invariant test. It +// builds a fully-populated batch -- realistic Seedlings (with Meta, +// GroupInfo, ScriptKey, GroupTapscriptRoot), a funded GenesisPacket, +// AssetMetas, a RootAssetCommitment, and a tapSibling -- then mutates +// every reachable substructure on the source and asserts the copy is +// unaffected. +// +// The previous Copy() shared substructure all over the place; under +// the gardener-serialization discipline this happened to be safe but +// the name was a lie. Fixing it eliminates a class of "subscriber +// snapshot mutates underneath them" hazards that would have been +// vanishingly hard to track down if they ever manifested. +func TestMintingBatchCopyIsDeep(t *testing.T) { + t.Parallel() + + src := RandMintingBatch( + t, WithTotalGroups([]int{3}), WithUniverseCommitments(true), + ) + + // Add fields RandMintingBatch doesn't populate: AssetMetas, + // RootAssetCommitment, tapSibling. + src.AssetMetas = AssetMetas{ + asset.SerializedKey{0x01}: { + Type: proof.MetaJson, + Data: []byte(`{"a":1}`), + }, + asset.SerializedKey{0x02}: { + Type: proof.MetaOpaque, + Data: []byte("opaque-bytes"), + }, + } + + randAsset := asset.RandAsset(t, asset.Normal) + tapCommitment, err := commitment.FromAssets( + fn.Ptr(commitment.TapCommitmentV2), randAsset, + ) + require.NoError(t, err) + src.RootAssetCommitment = tapCommitment + + siblingHash := chainhash.Hash{0xDE, 0xAD, 0xBE, 0xEF} + src.tapSibling = &siblingHash + + // Take the snapshot. + dst := src.Copy() + + // The existing TestMintingBatchCopy uses test.AssertCopyEqual on + // a simpler batch (no GenesisPacket, no RootAssetCommitment) to + // catch generic aliasing via reflection. That walker doesn't + // terminate cleanly on the psbt.Packet substructure (spew dumps + // recurse very deep on PSBT), so this test focuses on the + // substantive contract: every reachable mutation in the source + // must NOT propagate into the copy. + + // Pick the group-anchor seedling to probe -- it's the only one + // in the batch with GroupInfo populated (non-anchor seedlings + // only get their GroupInfo filled in during seal, which we + // don't run here). Anchor = the seedling whose GroupAnchor + // field is nil. + var srcKey string + for k, s := range src.Seedlings { + if s.GroupAnchor == nil { + srcKey = k + break + } + } + require.NotEmpty(t, srcKey, "no anchor seedling in batch") + require.Contains(t, dst.Seedlings, srcKey) + + srcSeed := src.Seedlings[srcKey] + dstSeed := dst.Seedlings[srcKey] + require.NotNil(t, srcSeed.GroupInfo) + require.NotNil(t, srcSeed.GroupInfo.GroupKey) + + // Snapshot the bytes we're about to mutate so we can verify the + // copy still holds the original values. + origMetaData := bytes.Clone(srcSeed.Meta.Data) + origGroupTapscriptRoot := bytes.Clone(srcSeed.GroupInfo.GroupKey. + TapscriptRoot) + origAssetMetaData := bytes.Clone( + src.AssetMetas[asset.SerializedKey{0x01}].Data, + ) + + // Mutate the source seedling's Meta.Data. + if len(srcSeed.Meta.Data) > 0 { + srcSeed.Meta.Data[0] ^= 0xFF + } + + // Mutate the source's group key tapscript root. + if len(srcSeed.GroupInfo.GroupKey.TapscriptRoot) > 0 { + srcSeed.GroupInfo.GroupKey.TapscriptRoot[0] ^= 0xFF + } + + // Add a witness element to the source's group key. + srcSeed.GroupInfo.GroupKey.Witness = append( + srcSeed.GroupInfo.GroupKey.Witness, + []byte("injected"), + ) + + // Mutate the source's AssetMetas entry. + src.AssetMetas[asset.SerializedKey{0x01}].Data[0] ^= 0xFF + + // Add a new entry to the source's AssetMetas map. + src.AssetMetas[asset.SerializedKey{0xFF}] = &proof.MetaReveal{ + Data: []byte("new-after-copy"), + } + + // Mutate the source's tap sibling (chainhash.Hash is an array, + // but we hold its address; tweaking via the pointer mutates the + // underlying value). + src.tapSibling[0] ^= 0xFF + + // Mutate a byte in the source's funded GenesisPacket's first + // PSBT input, if there is one. + require.NotNil(t, src.GenesisPacket) + require.NotNil(t, src.GenesisPacket.Pkt) + require.NotNil(t, src.GenesisPacket.Pkt.UnsignedTx) + src.GenesisPacket.Pkt.UnsignedTx.LockTime = 999_999 + + // Now assert: the copy is unmoved by every mutation above. + require.Equal(t, origMetaData, dstSeed.Meta.Data, + "Seedling.Meta.Data leaked into copy") + require.Equal(t, origGroupTapscriptRoot, + dstSeed.GroupInfo.GroupKey.TapscriptRoot, + "GroupKey.TapscriptRoot leaked into copy") + require.Equal(t, origAssetMetaData, + dst.AssetMetas[asset.SerializedKey{0x01}].Data, + "AssetMetas[k].Data leaked into copy") + require.NotContains(t, dst.AssetMetas, + asset.SerializedKey{0xFF}, + "new AssetMetas key leaked into copy") + require.NotEqual(t, src.tapSibling[0], dst.tapSibling[0], + "tapSibling array leaked into copy") + require.NotEqual(t, uint32(999_999), + dst.GenesisPacket.Pkt.UnsignedTx.LockTime, + "GenesisPacket.Pkt.UnsignedTx leaked into copy") + + // The witness append on src should not be visible in dst's + // witness length. + require.NotEqual(t, + len(srcSeed.GroupInfo.GroupKey.Witness), + len(dstSeed.GroupInfo.GroupKey.Witness), + "GroupKey.Witness append leaked into copy") +} + +// TestCheckSingletonInvariant pins the contract of +// checkSingletonInvariant: it returns nil for any slice of batches +// containing at most one batch in {Pending, Frozen}, and returns a +// descriptive error otherwise. Counts both states together +// (Pending ∪ Frozen), not separately, and ignores batches in any +// other state. +func TestCheckSingletonInvariant(t *testing.T) { + t.Parallel() + + mkBatch := func(state BatchState) *MintingBatch { + batchKey, _ := test.RandKeyDesc(t) + b := &MintingBatch{BatchKey: batchKey} + b.setState(state) + return b + } + + t.Run("empty slice is ok", func(t *testing.T) { + require.NoError(t, checkSingletonInvariant(nil)) + }) + + t.Run("single Pending is ok", func(t *testing.T) { + err := checkSingletonInvariant([]*MintingBatch{ + mkBatch(BatchStatePending), + }) + require.NoError(t, err) + }) + + t.Run("single Frozen is ok", func(t *testing.T) { + err := checkSingletonInvariant([]*MintingBatch{ + mkBatch(BatchStateFrozen), + }) + require.NoError(t, err) + }) + + t.Run("Pending plus Committed is ok", func(t *testing.T) { + err := checkSingletonInvariant([]*MintingBatch{ + mkBatch(BatchStatePending), + mkBatch(BatchStateCommitted), + mkBatch(BatchStateBroadcast), + }) + require.NoError(t, err) + }) + + t.Run("two Pending errors", func(t *testing.T) { + err := checkSingletonInvariant([]*MintingBatch{ + mkBatch(BatchStatePending), + mkBatch(BatchStatePending), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "singleton") + require.Contains(t, err.Error(), "found 2 batches") + }) + + t.Run("Pending plus Frozen errors", func(t *testing.T) { + err := checkSingletonInvariant([]*MintingBatch{ + mkBatch(BatchStatePending), + mkBatch(BatchStateFrozen), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "singleton") + }) + + t.Run("error names offending keys and repair tool", + func(t *testing.T) { + a := mkBatch(BatchStatePending) + b := mkBatch(BatchStateFrozen) + + err := checkSingletonInvariant( + []*MintingBatch{a, b}, + ) + require.Error(t, err) + require.Contains(t, err.Error(), + "--repair.cancel-duplicate-batches") + // Both batch keys should appear in the error. + require.Contains(t, err.Error(), + hex.EncodeToString( + a.BatchKey.PubKey. + SerializeCompressed(), + )) + require.Contains(t, err.Error(), + hex.EncodeToString( + b.BatchKey.PubKey. + SerializeCompressed(), + )) + }) +} + +// TestMintingOutputKeyPureInSibling pins the contract that +// MintingOutputKey is a function of (batch, sibling): two calls +// with different siblings must produce different output keys, two +// calls with the same sibling must produce the same key. A +// regression that re-introduced the memoizing cache would silently +// return the first call's value on every subsequent call regardless +// of the sibling argument. +func TestMintingOutputKeyPureInSibling(t *testing.T) { + t.Parallel() + + // Construct a batch with a real RootAssetCommitment so + // MintingOutputKey can compute the tapscript root. + batchKey, _ := test.RandKeyDesc(t) + randAsset := asset.RandAsset(t, asset.Normal) + tapCommitment, err := commitment.FromAssets( + fn.Ptr(commitment.TapCommitmentV2), randAsset, + ) + require.NoError(t, err) + + batch := &MintingBatch{ + BatchKey: batchKey, + RootAssetCommitment: tapCommitment, + } + + // Build two distinct sibling preimages. We use two single-leaf + // trees with different scripts; their TapHashes will differ, + // so a sibling-sensitive MintingOutputKey must return distinct + // output keys. + mkSibling := func(scriptByte byte) *commitment.TapscriptPreimage { + leaf := txscript.NewBaseTapLeaf([]byte{scriptByte}) + nodes, err := asset.TapTreeNodesFromLeaves( + []txscript.TapLeaf{leaf}, + ) + require.NoError(t, err) + + preimage, err := commitment. + NewPreimageFromTapscriptTreeNodes(*nodes) + require.NoError(t, err) + return preimage + } + + siblingA := mkSibling(0x01) + siblingB := mkSibling(0x02) + + keyA, rootA, err := batch.MintingOutputKey(siblingA) + require.NoError(t, err) + keyB, rootB, err := batch.MintingOutputKey(siblingB) + require.NoError(t, err) + + // Different siblings must yield different output keys and + // different tapscript roots. If the cache regressed, keyB + // would equal keyA. + require.False( + t, keyA.IsEqual(keyB), + "MintingOutputKey must depend on its sibling argument", + ) + require.NotEqual(t, rootA, rootB) + + // Same sibling must yield the same key both times: the + // function is deterministic, not stateful. + keyAgain, rootAgain, err := batch.MintingOutputKey(siblingA) + require.NoError(t, err) + require.True(t, keyA.IsEqual(keyAgain)) + require.Equal(t, rootA, rootAgain) + + // Calling with nil sibling produces yet another distinct key + // (it commits to no sibling, equivalent to "the empty tree + // branch"). Important to assert because the caretaker's + // BatchStateCommitted branch used to pass nil and rely on the + // cache for the actual sibling-bearing value. + keyNil, _, err := batch.MintingOutputKey(nil) + require.NoError(t, err) + require.False( + t, keyNil.IsEqual(keyA), + "MintingOutputKey(nil) must not return the same value "+ + "as MintingOutputKey(siblingA)", + ) +} + +// TestUniqueAnchorSeedling pins the contract of +// MintingBatch.uniqueAnchorSeedling: it deterministically returns +// the batch's single group anchor seedling (the one with GroupAnchor +// == nil) and errors loudly when that invariant doesn't hold. The +// callers (fetchDelegationKey, fetchPreCommitGroupKey) used to scan +// non-deterministically and pick whichever seedling Go's map +// iteration handed them first; this test exists to keep them from +// regressing back to that pattern. +func TestUniqueAnchorSeedling(t *testing.T) { + t.Parallel() + + mkAnchor := func(name string) *Seedling { + return &Seedling{ + AssetName: name, + AssetType: asset.Normal, + Amount: 1, + EnableEmission: true, + } + } + + mkChild := func(name, anchorName string) *Seedling { + s := &Seedling{ + AssetName: name, + AssetType: asset.Normal, + Amount: 1, + } + s.GroupAnchor = &anchorName + return s + } + + t.Run("anchor only", func(t *testing.T) { + batch := &MintingBatch{ + Seedlings: map[string]*Seedling{ + "a": mkAnchor("a"), + }, + } + + got, err := batch.uniqueAnchorSeedling() + require.NoError(t, err) + require.Equal(t, "a", got.AssetName) + }) + + t.Run("anchor plus children", func(t *testing.T) { + batch := &MintingBatch{ + Seedlings: map[string]*Seedling{ + "a": mkAnchor("a"), + "child": mkChild("child", "a"), + "other": mkChild("other", "a"), + }, + } + + // Run many times to defeat any incidental ordering: the + // returned anchor must be "a" regardless of how Go + // iterates the map. + for i := 0; i < 32; i++ { + got, err := batch.uniqueAnchorSeedling() + require.NoError(t, err) + require.Equal(t, "a", got.AssetName) + } + }) + + t.Run("multiple anchors errors", func(t *testing.T) { + batch := &MintingBatch{ + Seedlings: map[string]*Seedling{ + "a": mkAnchor("a"), + "b": mkAnchor("b"), + }, + } + + _, err := batch.uniqueAnchorSeedling() + require.Error(t, err) + require.Contains(t, err.Error(), "expected exactly 1") + }) + + t.Run("no anchor errors", func(t *testing.T) { + batch := &MintingBatch{ + Seedlings: map[string]*Seedling{ + "child1": mkChild("child1", "missing"), + "child2": mkChild("child2", "missing"), + }, + } + + _, err := batch.uniqueAnchorSeedling() + require.Error(t, err) + require.Contains(t, err.Error(), "no group anchor") + }) + + t.Run("empty batch errors", func(t *testing.T) { + batch := &MintingBatch{} + + _, err := batch.uniqueAnchorSeedling() + require.Error(t, err) + require.Contains(t, err.Error(), "no group anchor") + }) +} + +// TestSeedlingValidateCommitSplit pins the invariant that +// validateSeedling never mutates the batch -- it is the read-only +// half of AddSeedling, used by callers that need to persist a +// seedling before mirroring it into the in-memory batch. A regression +// here would silently revive the §X bug shape in prepAssetSeedling: +// an in-memory mutation that precedes the DB write that justifies it. +func TestSeedlingValidateCommitSplit(t *testing.T) { + t.Parallel() + + mkCandidate := func(name string, supplyCommitments bool) Seedling { + return Seedling{ + AssetName: name, + AssetType: asset.Normal, + Amount: 1, + SupplyCommitments: supplyCommitments, + DelegationKey: fn.None[keychain.KeyDescriptor](), + } + } + + t.Run("validate on populated batch leaves it unchanged", + func(t *testing.T) { + batch := RandMintingBatch( + t, WithTotalSeedlings(3), + ) + seedlingsBefore := len(batch.Seedlings) + supplyBefore := batch.SupplyCommitments + + candidate := mkCandidate( + "validate-only-candidate", supplyBefore, + ) + + err := batch.validateSeedling(candidate) + require.NoError(t, err) + + require.Equal(t, seedlingsBefore, len(batch.Seedlings)) + require.Equal( + t, supplyBefore, batch.SupplyCommitments, + ) + require.NotContains( + t, batch.Seedlings, candidate.AssetName, + ) + }) + + t.Run("commit on empty batch adopts SupplyCommitments", + func(t *testing.T) { + batch := &MintingBatch{} + + candidate := mkCandidate("first-seedling", false) + + require.NoError(t, batch.validateSeedling(candidate)) + + // validateSeedling must not have set + // SupplyCommitments even though this would be + // "the first seedling" -- only commitSeedling may + // do that. + require.False(t, batch.SupplyCommitments) + require.Empty(t, batch.Seedlings) + + batch.commitSeedling(candidate) + + require.Equal(t, 1, len(batch.Seedlings)) + require.Contains( + t, batch.Seedlings, candidate.AssetName, + ) + require.Equal( + t, candidate.SupplyCommitments, + batch.SupplyCommitments, + ) + }) +} diff --git a/tapgarden/caretaker_rapid_test.go b/tapgarden/caretaker_rapid_test.go new file mode 100644 index 0000000000..81814f7d6c --- /dev/null +++ b/tapgarden/caretaker_rapid_test.go @@ -0,0 +1,189 @@ +package tapgarden_test + +import ( + "context" + "flag" + "fmt" + "testing" + + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightningnetwork/lnd/lntest/wait" + "github.com/stretchr/testify/require" + "pgregory.net/rapid" +) + +// defaultRapidChecks is the number of iterations TestCaretakerRestart +// RecoveryRapid samples by default. The subset space being explored is +// small (4 cases: each restart point on/off), so 30 iterations already +// hits every case multiple times. Operators wanting deeper exploration +// can override via `-rapid.checks=N`. +const defaultRapidChecks = 30 + +// restartPoint enumerates the deterministically-observable disk states +// at which we can simulate a daemon restart in the rapid harness. Each +// point is anchored to a well-defined synchronization signal (either a +// disk-state poll or a mock channel send) so the restart is not racy. +type restartPoint int + +const ( + // rpAfterCommitted: disk state has reached BatchStateCommitted. + // Restart re-enters the Committed branch, which re-signs, re- + // imports, and re-writes the genesis tx. + rpAfterCommitted restartPoint = iota + + // rpAfterPublish: the Broadcast branch has fired PublishReq. + // Restart re-enters the Broadcast branch, which re-publishes and + // re-registers the conf watcher. + rpAfterPublish +) + +var allRestartPoints = []restartPoint{ + rpAfterCommitted, + rpAfterPublish, +} + +// awaitBatchState polls FetchMintingBatch until the batch's state +// reaches target (a successor state also satisfies the predicate, so +// transient passes through target are tolerated). +func awaitBatchState(t *mintingTestHarness, batchKey *btcec.PublicKey, + target tapgarden.BatchState) { + + t.Helper() + err := wait.Predicate(func() bool { + batch, err := t.store.FetchMintingBatch( + context.Background(), batchKey, + ) + require.NoError(t, err) + return batch.State() >= target + }, defaultTimeout) + require.NoError(t, err, "batch never reached state %v", target) +} + +// runMintWithRestarts drives a full mint flow for numSeedlings assets, +// injecting a daemon restart at each restartPoint marked true in +// restartAt. The flow must always end with one batch in the Finalized +// state regardless of the chosen restart subset; that is the §V +// idempotence-under-restart invariant the §I-§X work is meant to +// uphold. +func runMintWithRestarts(t *mintingTestHarness, numSeedlings int, + restartAt map[restartPoint]bool) { + + t.refreshChainPlanter() + _ = t.queueInitialBatch(numSeedlings) + + // Stage 1: Pending -> Frozen -> Committed. + frozenBatch := t.finalizeBatchAssertFrozen(false) + t.assertBatchCommitted(frozenBatch.BatchKey.PubKey) + + if restartAt[rpAfterCommitted] { + t.refreshChainPlanter() + drainErrors(t) + } + + // Stage 2: Committed -> Broadcast (sign + import + commit_signed_tx). + // The signals are consumed from whichever caretaker is currently + // running (post-restart if rpAfterCommitted fired). + t.assertGenesisPsbtFinalized(nil) + + // Stage 3: Broadcast publishes the tx. assertTxPublished is the + // natural sync point for "publish has happened" -- the mock only + // receives once the caretaker has called PublishTransaction. + tx := t.assertTxPublished() + + if restartAt[rpAfterPublish] { + t.refreshChainPlanter() + drainErrors(t) + + // After restart, the Broadcast branch re-runs and + // re-publishes the tx. lnd tolerates re-broadcast of an + // already-known tx, so this is a benign re-fire. + tx = t.assertTxPublished() + } + + // Stage 4: Broadcast -> Confirmed -> Finalized. + merkleTree := blockchain.BuildMerkleTreeStore( + []*btcutil.Tx{btcutil.NewTx(tx)}, false, + ) + merkleRoot := merkleTree[len(merkleTree)-1] + blockHeader := wire.NewBlockHeader( + 0, chaincfg.MainNetParams.GenesisHash, merkleRoot, 0, 0, + ) + block := &wire.MsgBlock{ + Header: *blockHeader, + Transactions: []*wire.MsgTx{tx}, + } + sendConfNtfn := t.assertConfReqSent(tx, block) + sendConfNtfn() + + // Wait for the caretaker goroutine to drive the batch all the way + // through Confirmed -> Finalized and shut itself down. + awaitBatchState(t, frozenBatch.BatchKey.PubKey, + tapgarden.BatchStateFinalized) + t.assertNumCaretakersActive(0) + t.assertNoError() + t.assertLastBatchState(1, tapgarden.BatchStateFinalized) +} + +// drainErrors empties any errors queued on the test harness error +// channel during a restart. The caretaker reports cancellations as +// errors when its context unwinds during planter.Stop(); those are +// expected by-products of the restart, not real failures. +func drainErrors(t *mintingTestHarness) { + select { + case <-t.errChan: + default: + } +} + +// TestCaretakerRestartRecoveryRapid is a property-test capstone for the +// §V idempotence audit. It samples every subset of the two +// well-synchronized restart points and asserts that the mint flow +// still ends with exactly one Finalized batch, regardless of when the +// daemon is restarted along the way. testBasicAssetCreation pins the +// "restart at every observable boundary" case in a fixed order; this +// test fans that out so a failure shrinks to the smallest restart +// subset that reproduces. +// +// Scope: this harness exercises crash recovery at boundaries *between* +// state-machine branches (the §II / §I concerns). The next layer -- +// crashing *within* a branch, e.g. forcing a specific DB call to fail +// on the Nth attempt -- is the natural follow-up that would let this +// same property cover the §V "idempotent re-run of partial branch" +// case explicitly. +func TestCaretakerRestartRecoveryRapid(t *testing.T) { + t.Parallel() + + // Bound iterations to the package default unless the caller + // explicitly passed -rapid.checks. rapid's built-in default is + // 100, which is gratuitous for a 4-element subset space and + // dominates the package's test runtime once batch.Copy() does + // real work (§III). We only override when the flag carries its + // untouched default value. + if cf := flag.Lookup("rapid.checks"); cf != nil { + if cf.Value.String() == cf.DefValue { + _ = cf.Value.Set(fmt.Sprintf("%d", defaultRapidChecks)) + } + } + + rapid.Check(t, func(rt *rapid.T) { + // Fresh DB and fresh mock-wallet/chain stack per iteration + // so iterations don't share state. + store := newMintingStore(t) + h := newMintingTestHarness(t, store) + + restartAt := make(map[restartPoint]bool) + for _, rp := range allRestartPoints { + label := fmt.Sprintf("restart_after_%d", rp) + if rapid.Bool().Draw(rt, label) { + restartAt[rp] = true + } + } + + runMintWithRestarts(h, 5, restartAt) + }) +} diff --git a/tapgarden/caretaker.go b/tapgarden/cultivator.go similarity index 58% rename from tapgarden/caretaker.go rename to tapgarden/cultivator.go index 512af2245c..a572d4d2c1 100644 --- a/tapgarden/caretaker.go +++ b/tapgarden/cultivator.go @@ -10,35 +10,22 @@ import ( "time" "github.com/btcsuite/btcd/blockchain" - "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/btcutil/psbt" - "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/davecgh/go-spew/spew" - "github.com/lightninglabs/neutrino/cache/lru" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/commitment" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/tapsend" - "github.com/lightninglabs/taproot-assets/universe" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "golang.org/x/exp/maps" - "golang.org/x/sync/errgroup" ) var ( - // ErrGroupKeyUnknown is an error returned if an asset has a group key - // attached that has not been previously verified. - ErrGroupKeyUnknown = errors.New("group key not known") - - // ErrGenesisNotGroupAnchor is an error returned if an asset has a group - // key attached, and the asset is not the anchor asset for the group. - // This is true for any asset created via reissuance. - ErrGenesisNotGroupAnchor = errors.New("genesis not group anchor") - // ErrFundedAnchorPsbtMissingOutpoint is an error returned if // the genesis outpoint is missing from a funded anchor PSBT. ErrFundedAnchorPsbtMissingOutpoint = errors.New("genesis outpoint " + @@ -61,17 +48,30 @@ const ( DefaultTimeout = 30 * time.Second ) -// BatchCaretakerConfig houses all the items that the BatchCaretaker needs to +// CultivatorConfig houses all the items that the Cultivator needs to // carry out its duties. -type BatchCaretakerConfig struct { - // Batch is the minting batch that this caretaker is responsible for. +type CultivatorConfig struct { + // Batch is the minting batch that this cultivator is responsible + // for. Ownership invariant: once a cultivator is started, this + // pointer is owned exclusively by the cultivator goroutine. The + // cultivator reads and writes the non-state fields + // (GenesisPacket, RootAssetCommitment, Seedlings, AssetMetas) + // without locking, and any other goroutine that needs to observe + // the batch MUST call Batch.Copy() first to take a deep + // snapshot. State() may be read concurrently because it is + // backed by an atomic. Batch *MintingBatch // BatchFeeRate is an optional manually-set fee rate specified when // finalizing a batch. BatchFeeRate *chainfee.SatPerKWeight - GardenKit + // GardenKit is the planter's shared kit. It is embedded as a + // pointer so the cultivator reads the same kit the planter holds, + // rather than carrying its own copy. The kit is populated once + // when the planter is constructed and is not mutated thereafter, + // so sharing it by reference is safe. + *GardenKit // BroadcastCompleteChan is used to signal back to the caller that the // batch has been broadcast and is now waiting for confirmation. Either @@ -88,16 +88,22 @@ type BatchCaretakerConfig struct { // their batch has been finalized. SignalCompletion func() - // CancelChan is used by the BatchPlanter to signal that the caretaker - // should stop advancing the batch. - CancelReqChan chan struct{} - - // CancelRespChan is used by the BatchCaretaker to report the result of - // attempted batch cancellation to the planter. - CancelRespChan chan CancelResp + // CancelReqChan delivers cancellation requests from the + // BatchPlanter. Each cancelReq carries its own reply channel, so + // the cultivator's response is causally bound to the specific + // request that produced it. The buffer size is 1 because the + // gardener serializes cancel calls today; the per-call binding + // is what makes the protocol correct regardless of that + // discipline. + // + // At any given moment exactly one cultivator goroutine reads this + // channel: either advanceStateUntil (pre-broadcast) or + // assetCultivator's post-broadcast loop. Those two never run + // concurrently, so a single cancelReq has a single receiver. + CancelReqChan chan cancelReq // UpdateMintingProofs is used to update the minting proofs in the - // database in case of a re-org. This cannot be done by the caretaker + // database in case of a re-org. This cannot be done by the cultivator // itself, because its job is already done at the point that a re-org // can happen (the batch is finalized after a single confirmation). UpdateMintingProofs func([]*proof.Proof) error @@ -105,43 +111,47 @@ type BatchCaretakerConfig struct { // PublishMintEvent is used to publish a mint event to all subscribers. PublishMintEvent func(event fn.Event) - // ErrChan is the main error channel the caretaker will report back + // ErrChan is the main error channel the cultivator will report back // critical errors to the main server. ErrChan chan<- error } -// BatchCaretaker is the caretaker for a MintingBatch. It'll handle validating +// Cultivator is the cultivator for a MintingBatch. It'll handle validating // the batch, creating a transaction that mints all items in the batch, and // waiting for enough confirmations for the batch to be considered finalized. -type BatchCaretaker struct { +type Cultivator struct { startOnce sync.Once stopOnce sync.Once batchKey BatchKey - cfg *BatchCaretakerConfig + cfg *CultivatorConfig - // confEvent is used to deliver a confirmation event to the caretaker. + // confEvent is used to deliver a confirmation event to the cultivator. confEvent chan *chainntnfs.TxConfirmation // confInfo is used to store a delivered confirmation event. confInfo *chainntnfs.TxConfirmation - // anchorOutputIndex is the index in the anchor output that commits to - // the Taproot Asset commitment. - anchorOutputIndex uint32 - // ContextGuard provides a wait group and main quit channel that can be // used to create guarded contexts. *fn.ContextGuard } -// NewBatchCaretaker creates a new Taproot Asset caretaker based on the passed +// augmenter returns the GenesisTxAugmenter from the embedded +// GardenKit, or a NoOpAugmenter when none was wired. Call sites +// can invoke augmenter methods without nil-checking. +func (b *Cultivator) augmenter() GenesisTxAugmenter { + if b.cfg.GenesisTxAugmenter == nil { + return NoOpAugmenter{} + } + return b.cfg.GenesisTxAugmenter +} + +// NewCultivator creates a new Taproot Asset cultivator based on the passed // config. -// -// TODO(roasbeef): rename to Cultivator? -func NewBatchCaretaker(cfg *BatchCaretakerConfig) *BatchCaretaker { - return &BatchCaretaker{ +func NewCultivator(cfg *CultivatorConfig) *Cultivator { + return &Cultivator{ batchKey: asset.ToSerialized(cfg.Batch.BatchKey.PubKey), cfg: cfg, confEvent: make(chan *chainntnfs.TxConfirmation, 1), @@ -152,8 +162,8 @@ func NewBatchCaretaker(cfg *BatchCaretakerConfig) *BatchCaretaker { } } -// Start attempts to start a new batch caretaker. -func (b *BatchCaretaker) Start() error { +// Start attempts to start a new batch cultivator. +func (b *Cultivator) Start() error { var startErr error b.startOnce.Do(func() { b.Wg.Add(1) @@ -162,11 +172,11 @@ func (b *BatchCaretaker) Start() error { return startErr } -// Stop signals for a batch caretaker to gracefully exit. -func (b *BatchCaretaker) Stop() error { +// Stop signals for a batch cultivator to gracefully exit. +func (b *Cultivator) Stop() error { var stopErr error b.stopOnce.Do(func() { - log.Infof("BatchCaretaker(%x): Stopping", b.batchKey[:]) + log.Infof("Cultivator(%x): Stopping", b.batchKey[:]) close(b.Quit) b.Wg.Wait() @@ -175,12 +185,13 @@ func (b *BatchCaretaker) Stop() error { return stopErr } -// Cancel signals for a batch caretaker to stop advancing a batch. A batch can +// Cancel signals for a batch cultivator to stop advancing a batch. A batch can // only be cancelled if it has not reached BatchStateBroadcast yet. If // cancellation succeeds, we forward the batch state after cancellation. If the -// batch could not be cancelled, the planter will handle caretaker shutdown and -// batch state. -func (b *BatchCaretaker) Cancel() error { +// batch could not be cancelled, the planter will handle cultivator shutdown and +// batch state. The response is written to respCh, which must be the per-call +// reply channel carried by the originating cancelReq. +func (b *Cultivator) Cancel(respCh chan<- CancelResp) error { ctx, cancel := b.WithCtxQuit() defer cancel() @@ -188,20 +199,20 @@ func (b *BatchCaretaker) Cancel() error { batchState := b.cfg.Batch.State() var cancelResp CancelResp - // This function can only be called before the caretaker state stepping + // This function can only be called before the cultivator state stepping // function, so the batch state read is the next state that has not yet // been executed. Seedlings are converted to asset sprouts in the Frozen // state, and broadcast in the Broadast state. - log.Debugf("BatchCaretaker(%x): Trying to cancel", batchKey[:]) + log.Debugf("Cultivator(%x): Trying to cancel", batchKey[:]) switch batchState { // In the pending state, the batch seedlings have not sprouted yet. case BatchStatePending, BatchStateFrozen: - err := b.cfg.Log.UpdateBatchState( - ctx, b.cfg.Batch.BatchKey.PubKey, + err := b.cfg.BatchStore.UpdateBatchState( + ctx, b.cfg.Batch, BatchStateSeedlingCancelled, ) if err != nil { - err = fmt.Errorf("BatchCaretaker(%x), batch "+ + err = fmt.Errorf("Cultivator(%x), batch "+ "state(%v), "+"cancel failed: %w", batchKey[:], batchState, err) @@ -217,12 +228,12 @@ func (b *BatchCaretaker) Cancel() error { cancelResp = CancelResp{true, err} case BatchStateCommitted: - err := b.cfg.Log.UpdateBatchState( - ctx, b.cfg.Batch.BatchKey.PubKey, + err := b.cfg.BatchStore.UpdateBatchState( + ctx, b.cfg.Batch, BatchStateSproutCancelled, ) if err != nil { - err = fmt.Errorf("BatchCaretaker(%x), batch "+ + err = fmt.Errorf("Cultivator(%x), batch "+ "state(%v), cancel failed: %w", batchKey[:], batchState, err) @@ -238,19 +249,19 @@ func (b *BatchCaretaker) Cancel() error { cancelResp = CancelResp{true, err} default: - err := fmt.Errorf("BatchCaretaker(%x), batch not cancellable", - b.cfg.Batch.BatchKey.PubKey.SerializeCompressed()) + err := fmt.Errorf("Cultivator(%x), batch not cancellable", + batchKey) cancelResp = CancelResp{false, err} } - b.cfg.CancelRespChan <- cancelResp + respCh <- cancelResp - // If the batch was cancellable, the final write of the cancelled batch - // may still have failed. That error will be handled by the planter. At - // this point, the caretaker should shut down gracefully if cancellation - // was attempted. + // If the batch was cancellable, the final write of the cancelled + // batch may still have failed. That error will be handled by the + // planter. At this point, the cultivator should shut down gracefully + // if cancellation was attempted. if cancelResp.cancelAttempted { - log.Infof("BatchCaretaker(%x), attempted batch cancellation, "+ + log.Infof("Cultivator(%x), attempted batch cancellation, "+ "shutting down", b.batchKey[:]) return nil @@ -258,16 +269,16 @@ func (b *BatchCaretaker) Cancel() error { // If the cancellation failed, that error will be handled by the // planter. - return fmt.Errorf("BatchCaretaker(%x) cancellation failed", + return fmt.Errorf("Cultivator(%x) cancellation failed", b.batchKey[:]) } // advanceStateUntil attempts to advance the internal state machine until the // target state has been reached. -func (b *BatchCaretaker) advanceStateUntil(currentState, +func (b *Cultivator) advanceStateUntil(currentState, targetState BatchState) (BatchState, error) { - log.Infof("BatchCaretaker(%x), advancing from state=%v to state=%v", + log.Infof("Cultivator(%x), advancing from state=%v to state=%v", b.batchKey[:], currentState, targetState) var terminalState bool @@ -276,17 +287,17 @@ func (b *BatchCaretaker) advanceStateUntil(currentState, // aren't trying to shut down or cancel the batch. select { case <-b.Quit: - return 0, fmt.Errorf("BatchCaretaker(%x), shutting "+ + return 0, fmt.Errorf("Cultivator(%x), shutting "+ "down", b.batchKey[:]) // If the batch was cancellable, the finalState of the cancel // response will be non-nil. If the cancellation failed, that // error will be handled by the planter. At this point, the - // caretaker should always shut down gracefully. - case <-b.cfg.CancelReqChan: - cancelErr := b.Cancel() + // cultivator should always shut down gracefully. + case req := <-b.cfg.CancelReqChan: + cancelErr := b.Cancel(req.resp) if cancelErr == nil { - return 0, fmt.Errorf("BatchCaretaker(%x), "+ + return 0, fmt.Errorf("Cultivator(%x), "+ "attempted batch cancellation, "+ "shutting down", b.batchKey[:]) } @@ -319,17 +330,22 @@ func (b *BatchCaretaker) advanceStateUntil(currentState, currentState = nextState - b.cfg.Batch.UpdateState(currentState) + // We do not mirror currentState into the in-memory batch + // here. Each branch of stateStep that transitions state does + // so via a BatchStore call, which advances the in-memory + // mirror only after the DB write succeeds. Writing the local + // currentState here would re-introduce the two-truth split + // that the store calls exist to prevent. } return currentState, nil } -// assetCultivator is the main goroutine for the BatchCaretaker struct. This +// assetCultivator is the main goroutine for the Cultivator struct. This // goroutines handles progressing a batch all the way up to the point of // broadcast. Once the batch has been broadcast, we'll register for a // confirmation to progress the batch to the final terminal state. -func (b *BatchCaretaker) assetCultivator() { +func (b *Cultivator) assetCultivator() { defer b.Wg.Done() currentBatchState := b.cfg.Batch.State() @@ -386,12 +402,17 @@ func (b *BatchCaretaker) assetCultivator() { confInfo.BlockHash, confInfo.BlockHeight) b.confInfo = confInfo - b.cfg.Batch.UpdateState(BatchStateConfirmed) - currentBatchState = b.cfg.Batch.State() + // Hand BatchStateConfirmed to advanceStateUntil + // directly rather than mutating the in-memory state + // here: the Confirmed branch of stateStep calls + // MarkBatchConfirmed, which advances both the on-disk + // row and the in-memory mirror as one step. Setting + // memory here would re-create the two-truth window. + // // TODO(roasbeef): use a "trigger" here instead? _, err = b.advanceStateUntil( - currentBatchState, BatchStateFinalized, + BatchStateConfirmed, BatchStateFinalized, ) if err != nil { log.Error(err) @@ -405,8 +426,8 @@ func (b *BatchCaretaker) assetCultivator() { b.cfg.SignalCompletion() return - case <-b.cfg.CancelReqChan: - cancelErr := b.Cancel() + case req := <-b.cfg.CancelReqChan: + cancelErr := b.Cancel(req.resp) if cancelErr == nil { return } @@ -422,11 +443,11 @@ func (b *BatchCaretaker) assetCultivator() { // seedlingsToAssetSprouts maps a set of seedlings in the internal batch into a // set of sprouts: Assets that aren't yet fully linked to broadcast genesis // transaction. -func (b *BatchCaretaker) seedlingsToAssetSprouts(ctx context.Context, +func (b *Cultivator) seedlingsToAssetSprouts(ctx context.Context, genesisPoint wire.OutPoint, assetOutputIndex uint32) (*commitment.TapCommitment, error) { - log.Infof("BatchCaretaker(%x): mapping %v seedlings to asset sprouts, "+ + log.Infof("Cultivator(%x): mapping %v seedlings to asset sprouts, "+ "with genesis_point=%v", b.batchKey[:], len(b.cfg.Batch.Seedlings), genesisPoint) @@ -438,7 +459,7 @@ func (b *BatchCaretaker) seedlingsToAssetSprouts(ctx context.Context, ) groupedSeedlingCount := len(groupedSeedlings) // load seedling asset groups and check for correct group count - seedlingGroups, err := b.cfg.Log.FetchSeedlingGroups( + seedlingGroups, err := b.cfg.BatchStore.FetchSeedlingGroups( ctx, genesisPoint, assetOutputIndex, maps.Values(groupedSeedlings), ) @@ -538,7 +559,7 @@ func (b *BatchCaretaker) seedlingsToAssetSprouts(ctx context.Context, // stateStep attempts to transition the state machine from one state to // another. Two states are terminal: the broadcast state, and the finalized // state. -func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) { +func (b *Cultivator) stateStep(currentState BatchState) (BatchState, error) { // TODO(roasbeef): will also handle finalizing a batch if incomplete // and go done w/ it? switch currentState { @@ -548,12 +569,12 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) // Finalize the batch, then move the batch state to frozen. ctx, cancel := b.WithCtxQuit() defer cancel() - err := freezeMintingBatch(ctx, b.cfg.Log, b.cfg.Batch) + err := freezeMintingBatch(ctx, b.cfg.BatchStore, b.cfg.Batch) if err != nil { return 0, err } - log.Infof("BatchCaretaker(%x): transition states: %v -> %v", + log.Infof("Cultivator(%x): transition states: %v -> %v", b.batchKey[:], BatchStatePending, BatchStateFrozen) return BatchStateFrozen, nil @@ -567,9 +588,9 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) ctx, cancel := b.WithCtxQuitNoTimeout() defer cancel() - // For the caretaker to manage a frozen batch, it must have some - // seedlings and a genesis packet. Check these preconditions - // before modifying the batch. + // For the cultivator to manage a frozen batch, it must + // have some seedlings and a genesis packet. Check these + // preconditions before modifying the batch. if len(b.cfg.Batch.Seedlings) == 0 { return 0, fmt.Errorf("frozen batch has no seedlings") } @@ -600,7 +621,6 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) genesisPkt := b.cfg.Batch.GenesisPacket changeOutputIndex := genesisPkt.ChangeOutputIndex - b.anchorOutputIndex = genesisPkt.AssetAnchorOutIdx genesisPoint, err := genesisPkt.GenesisOutpoint().UnwrapOrErr( ErrFundedAnchorPsbtMissingOutpoint, @@ -611,7 +631,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) // First, we'll turn all the seedlings into actual taproot assets. tapCommitment, err := b.seedlingsToAssetSprouts( - ctx, genesisPoint, b.anchorOutputIndex, + ctx, genesisPoint, genesisPkt.AssetAnchorOutIdx, ) if err != nil { return 0, fmt.Errorf("unable to map seedlings to "+ @@ -647,10 +667,11 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) "script: %w", err) } - genesisTxPkt.UnsignedTx. - TxOut[b.anchorOutputIndex].PkScript = genesisScript + anchorIdx := genesisPkt.AssetAnchorOutIdx + txOut := genesisTxPkt.UnsignedTx.TxOut[anchorIdx] + txOut.PkScript = genesisScript - log.Infof("BatchCaretaker(%x): committing sprouts to disk", + log.Infof("Cultivator(%x): committing sprouts to disk", b.batchKey[:]) fundedGenesisPsbt := FundedMintAnchorPsbt{ @@ -658,16 +679,28 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) Pkt: genesisTxPkt, ChangeOutputIndex: changeOutputIndex, }, - AssetAnchorOutIdx: b.anchorOutputIndex, - PreCommitmentOutput: genesisPkt.PreCommitmentOutput, + AssetAnchorOutIdx: genesisPkt.AssetAnchorOutIdx, + } + + // The augmenter is the source of truth for the + // persistence payload that pairs with the binding tx. + // Stage the freshly-rebuilt genesis packet on a copy + // so the augmenter can read the script-stamped tx + // without us mutating the live batch. + stagingBatch := b.cfg.Batch.Copy() + stagingBatch.GenesisPacket = &fundedGenesisPsbt + preCommit, err := b.augmenter().BindData(ctx, stagingBatch) + if err != nil { + return 0, fmt.Errorf("augmenter BindData: %w", err) } - // With all our commitments created, we'll commit them to disk, - // replacing the existing seedlings we had created for each of - // these assets. - err = b.cfg.Log.AddSproutsToBatch( - ctx, b.cfg.Batch.BatchKey.PubKey, + // With all our commitments created, we'll commit them + // to disk, replacing the existing seedlings we had + // created for each of these assets. + err = b.cfg.BatchStore.AddSproutsToBatch( + ctx, b.cfg.Batch, &fundedGenesisPsbt, b.cfg.Batch.RootAssetCommitment, + preCommit, ) if err != nil { return 0, fmt.Errorf("unable to commit batch: %w", err) @@ -691,7 +724,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) b.cfg.Batch.AssetMetas[scriptKey] = seedling.Meta } - log.Infof("BatchCaretaker(%x): transition states: %v -> %v", + log.Infof("Cultivator(%x): transition states: %v -> %v", b.batchKey[:], BatchStateFrozen, BatchStateCommitted) return BatchStateCommitted, nil @@ -701,7 +734,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) // We'll have the backing wallet sign the transaction, then import the // resulting key into the wallet so it tracks the balance. case BatchStateCommitted: - log.Infof("BatchCaretaker(%x): finalizing GenesisPacket", + log.Infof("Cultivator(%x): finalizing GenesisPacket", b.batchKey[:]) // First, we'll have the wallet sign the PSBT is created, which @@ -739,32 +772,22 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) } b.cfg.Batch.GenesisPacket.ChainFees = int64(chainFees) - log.Infof("BatchCaretaker(%x): GenesisPacket finalized "+ + log.Infof("Cultivator(%x): GenesisPacket finalized "+ "(absolute_fee_sats: %d)", b.batchKey[:], chainFees) log.Tracef("GenesisPacket: %v", spew.Sdump(signedPkt)) // At this point we have a fully signed PSBT packet which'll - // create our set of assets once mined. We'll write this to - // disk, then import the public key into the wallet. The sibling - // here can always be nil as we'll fetch the output key computed - // previously in BatchStateFrozen. - // - // TODO(roasbeef): re-run during the broadcast phase to ensure - // it's fully imported? - mintingOutputKey, merkleRoot, err := b.cfg.Batch. - MintingOutputKey(nil) - if err != nil { - return 0, err - } - - // To spend this output in the future, we must also commit the - // Taproot Asset commitment root and batch tapscript sibling. - tapCommitmentRoot := b.cfg.Batch.RootAssetCommitment. - TapscriptRoot(nil) - - // Fetch the optional Tapscript sibling for this batch, and - // encode it to bytes. - var siblingBytes []byte + // create our set of assets once mined. We import the public + // key into the wallet and then write the genesis tx to disk. + // The minting output key is derived from the batch key, the + // asset commitment root, and the optional tapscript sibling -- + // so we load the sibling preimage first and pass it + // explicitly. MintingOutputKey is pure in its arguments, so we + // cannot rely on a value cached during BatchStateFrozen. + var ( + batchSibling *commitment.TapscriptPreimage + siblingBytes []byte + ) if b.cfg.Batch.tapSibling != nil { tapSibling, err := b.cfg.TreeStore.LoadTapscriptTree( ctx, *b.cfg.Batch.tapSibling, @@ -773,7 +796,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) return 0, err } - batchSibling, err := commitment. + batchSibling, err = commitment. NewPreimageFromTapscriptTreeNodes(*tapSibling) if err != nil { return 0, err @@ -786,23 +809,25 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) } } - err = b.cfg.Log.CommitSignedGenesisTx( - ctx, b.cfg.Batch.BatchKey.PubKey, - &b.cfg.Batch.GenesisPacket.FundedPsbt, - b.anchorOutputIndex, merkleRoot, tapCommitmentRoot[:], - siblingBytes, - ) + mintingOutputKey, merkleRoot, err := b.cfg.Batch. + MintingOutputKey(batchSibling) if err != nil { - return 0, fmt.Errorf("unable to commit genesis "+ - "tx: %w", err) + return 0, err } - // With the genesis transaction committed to disk, we'll also - // import this public key into the backing wallet, so it - // recognizes the de minimis amt sats under out control. - // - // TODO(roasbeef): should be idempotent along w/ all other - // operations above + // To spend this output in the future, we must also commit the + // Taproot Asset commitment root and batch tapscript sibling. + tapCommitmentRoot := b.cfg.Batch.RootAssetCommitment. + TapscriptRoot(nil) + + // Import the minting output key into the backing wallet so it + // recognizes the de minimis amt of sats under our control. + // This MUST happen before the state-transition write below: a + // crash between writing Broadcast and importing the key would + // leave lnd unaware of the output forever, since the Broadcast + // branch on restart never re-runs this step. With the import + // first, a crash anywhere in this branch resumes from Committed + // and the (idempotent) import retries cleanly. ctx, cancel = b.WithCtxQuit() defer cancel() _, err = b.cfg.Wallet.ImportTaprootOutput(ctx, mintingOutputKey) @@ -820,7 +845,18 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) return 0, fmt.Errorf("unable to import key: %w", err) } - log.Infof("BatchCaretaker(%x): transition states: %v -> %v", + err = b.cfg.BatchStore.CommitSignedGenesisTx( + ctx, b.cfg.Batch, + &b.cfg.Batch.GenesisPacket.FundedPsbt, + b.cfg.Batch.GenesisPacket.AssetAnchorOutIdx, + merkleRoot, tapCommitmentRoot[:], siblingBytes, + ) + if err != nil { + return 0, fmt.Errorf("unable to commit genesis "+ + "tx: %w", err) + } + + log.Infof("Cultivator(%x): transition states: %v -> %v", b.batchKey[:], BatchStateCommitted, BatchStateBroadcast) return BatchStateBroadcast, nil @@ -838,7 +874,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) "signed tx: %w", err) } - log.Infof("BatchCaretaker(%x): extracted finalized GenesisTx", + log.Infof("Cultivator(%x): extracted finalized GenesisTx", b.batchKey[:]) log.Tracef("GenesisTx: %v", spew.Sdump(signedTx)) @@ -874,7 +910,12 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) } // Launch a goroutine that'll notify us when the transaction - // confirms. + // confirms. The outer assetCultivator post-broadcast loop is + // the sole reader of b.cfg.CancelReqChan once we get here: a + // cancel request post-broadcast is rejected by Cancel() + // regardless, so adding a second reader inside this goroutine + // would only create a race for which goroutine binds itself + // to that specific request's per-call reply channel. // // TODO(roasbeef): make blocking here? b.Wg.Add(1) @@ -905,16 +946,6 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) "context done") confRecv = true - case <-b.cfg.CancelReqChan: - cancelErr := b.Cancel() - if cancelErr == nil { - return - } - - // Cancellation failed, continue to wait - // for transaction confirmation. - log.Info(cancelErr) - case <-b.Quit: log.Debugf("Skipping TX confirmation, " + "exiting") @@ -946,16 +977,6 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) "context done") return - case <-b.cfg.CancelReqChan: - cancelErr := b.Cancel() - if cancelErr == nil { - return - } - - // Cancellation failed, continue to try - // and send the confirmation event. - log.Info(cancelErr) - case <-b.Quit: log.Debugf("Skipping TX confirmation, " + "exiting") @@ -964,7 +985,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) } }() - log.Infof("BatchCaretaker(%x): transition states: %v -> %v", + log.Infof("Cultivator(%x): transition states: %v -> %v", b.batchKey[:], BatchStateBroadcast, BatchStateBroadcast) return BatchStateBroadcast, nil @@ -1016,7 +1037,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) BlockHeight: confInfo.BlockHeight, Tx: confInfo.Tx, TxIndex: int(confInfo.TxIndex), - OutputIndex: int(b.anchorOutputIndex), + OutputIndex: int(pkt.AssetAnchorOutIdx), InternalKey: b.cfg.Batch.BatchKey.PubKey, TapscriptSibling: batchSibling, TaprootAssetRoot: batchCommitment, @@ -1027,7 +1048,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) &baseProof.BaseProofParams, confInfo.Tx, b.cfg.Batch.GenesisPacket.Pkt.Outputs, func(idx uint32) bool { - return idx == b.anchorOutputIndex + return idx == pkt.AssetAnchorOutIdx }, ) if err != nil { @@ -1050,34 +1071,15 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) committedAssets = batchCommitment.CommittedAssets() numAssets = len(committedAssets) mintingProofBlobs = make(proof.AssetBlobs, numAssets) - universeItems chan *universe.Item mintTxHash = confInfo.Tx.TxHash() proofMutex sync.Mutex - batchSyncEG errgroup.Group ) - // If we have a universe configured, we'll batch stream the - // issuance items to it. We start this as a goroutine/err group - // now, so we can already start streaming while the proofs are - // still being stored to the local proof store. - if b.cfg.Universe != nil { - universeItems = make( - chan *universe.Item, numAssets, - ) - - // We use an error group to simply the error handling of - // a goroutine. - batchSyncEG.Go(func() error { - return b.batchStreamUniverseItems( - ctx, universeItems, numAssets, - ) - }) - } - // Before we write any assets from the batch, we need to sort // the assets so that we insert group anchors before - // reissunces. This is required for any possible reissuances - // to be verified correctly when updating our local Universe. + // reissuances. This is required for any possible reissuances + // to be verified correctly when later updating any local + // universe. anchorAssets, nonAnchorAssets, err := SortAssets( committedAssets, vCtx.GroupAnchorVerifier, ) @@ -1097,8 +1099,8 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) mintingProof := mintingProofs[scriptKey] - proofBlob, uniProof, err := b.storeMintingProof( - ctx, newAsset, mintingProof, mintTxHash, vCtx, + proofBlob, err := b.storeMintingProof( + ctx, newAsset, mintingProof, vCtx, ) if err != nil { return fmt.Errorf("unable to store "+ @@ -1109,10 +1111,6 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) mintingProofBlobs[scriptKey] = proofBlob proofMutex.Unlock() - if uniProof != nil { - universeItems <- uniProof - } - return nil } @@ -1128,32 +1126,53 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) "%w", err) } - // The local proof store inserts are now completed, but we also - // need to wait for the batch sync to complete before we can - // confirm the batch. - if b.cfg.Universe != nil { - close(universeItems) + // Proof archival is done. If a downstream publisher is + // configured, ship the batch out now. Publishing is extrinsic + // to tapgarden's end (a verifiable asset in the local store); + // the publisher owns retry and batching semantics. + if b.cfg.MintProofPublisher != nil { + publishAssets := make( + []*asset.Asset, 0, + len(anchorAssets)+len(nonAnchorAssets), + ) + publishAssets = append( + publishAssets, anchorAssets..., + ) + publishAssets = append( + publishAssets, nonAnchorAssets..., + ) - err = batchSyncEG.Wait() + anchorIdx := b.cfg.Batch.GenesisPacket.AssetAnchorOutIdx + err = b.cfg.MintProofPublisher.PublishMintBatch( + ctx, MintBatchPublishParams{ + Assets: publishAssets, + Proofs: mintingProofs, + MintTxHash: mintTxHash, + AnchorOutIdx: anchorIdx, + }, + ) if err != nil { - return 0, fmt.Errorf("unable to batch sync "+ - "universe: %w", err) + return 0, fmt.Errorf("unable to publish "+ + "minted batch: %w", err) } } - // Send supply commitment events for all minted assets before - // finalizing the batch. This ensures that supply commitments - // are tracked before the batch is considered complete. - err = b.sendSupplyCommitEvents( - ctx, anchorAssets, nonAnchorAssets, mintingProofs, + // Let the augmenter emit any downstream events that + // pair with batch confirmation (e.g. supply-commit + // notifications). Errors are reported but do not roll + // back the confirmation; the augmenter substance is + // expected to be re-runnable. + err = b.augmenter().OnBatchConfirmed( + ctx, b.cfg.Batch, anchorAssets, nonAnchorAssets, + mintingProofs, ) if err != nil { - return 0, fmt.Errorf("unable to send supply commit "+ - "events: %w", err) + return 0, fmt.Errorf("augmenter OnBatchConfirmed: %w", + err) } - err = b.cfg.Log.MarkBatchConfirmed( - ctx, b.cfg.Batch.BatchKey.PubKey, confInfo.BlockHash, + err = b.cfg.BatchStore.MarkBatchConfirmed( + ctx, b.cfg.Batch, confInfo.BlockHash, confInfo.BlockHeight, confInfo.TxIndex, mintingProofBlobs, ) @@ -1169,7 +1188,7 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) return 0, fmt.Errorf("error watching proof: %w", err) } - log.Infof("BatchCaretaker(%x): transition states: %v -> %v", + log.Infof("Cultivator(%x): transition states: %v -> %v", b.batchKey[:], BatchStateConfirmed, BatchStateFinalized) return BatchStateFinalized, nil @@ -1177,14 +1196,14 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) // This is a terminal state, in this state we have nothing left to do, // so we just go back to batch finalized. case BatchStateFinalized: - log.Infof("BatchCaretaker(%x): transition states: %v -> %v", + log.Infof("Cultivator(%x): transition states: %v -> %v", b.batchKey[:], BatchStateFinalized, BatchStateFinalized) // TODO(roasbeef): confirmed should just be the final state? ctx, cancel := b.WithCtxQuit() defer cancel() - err := b.cfg.Log.UpdateBatchState( - ctx, b.cfg.Batch.BatchKey.PubKey, BatchStateFinalized, + err := b.cfg.BatchStore.UpdateBatchState( + ctx, b.cfg.Batch, BatchStateFinalized, ) return BatchStateFinalized, err @@ -1194,17 +1213,15 @@ func (b *BatchCaretaker) stateStep(currentState BatchState) (BatchState, error) } // storeMintingProof stores the minting proof for a new asset in the proof -// store. If a universe is configured, it also returns the issuance item that -// can be used to register the asset with the universe. -func (b *BatchCaretaker) storeMintingProof(ctx context.Context, - a *asset.Asset, mintingProof *proof.Proof, mintTxHash chainhash.Hash, - vCtx proof.VerifierCtx) (proof.Blob, *universe.Item, error) { +// store and returns the encoded proof blob. +func (b *Cultivator) storeMintingProof(ctx context.Context, + a *asset.Asset, mintingProof *proof.Proof, + vCtx proof.VerifierCtx) (proof.Blob, error) { assetID := a.ID() blob, err := proof.EncodeAsProofFile(mintingProof) if err != nil { - return nil, nil, fmt.Errorf("unable to encode proof file: %w", - err) + return nil, fmt.Errorf("unable to encode proof file: %w", err) } fullProof := &proof.AnnotatedProof{ @@ -1218,134 +1235,26 @@ func (b *BatchCaretaker) storeMintingProof(ctx context.Context, err = b.cfg.ProofFiles.ImportProofs(ctx, vCtx, false, fullProof) if err != nil { - return nil, nil, fmt.Errorf("unable to insert proofs: %w", err) - } - - // Before we continue with the next item, we'll also register the - // issuance of the new asset with our local base universe. We skip this - // step if there is no universe configured. - if b.cfg.Universe == nil { - return blob, nil, nil - } - - // The universe ID serves to identifier the universe root we want to add - // this asset to. This is either the assetID or the group key. - uniID := universe.Identifier{ - AssetID: assetID, - } - - groupKey := a.GroupKey - if groupKey != nil { - uniID.GroupKey = &groupKey.GroupPubKey + return nil, fmt.Errorf("unable to insert proofs: %w", err) } - log.Debugf("Preparing asset for registration with universe, key=%v", - spew.Sdump(uniID)) - - // The base key is the set of bytes that keys into the universe, this'll - // be the outpoint where it was created at and the script key for that - // asset. - leafKey := universe.BaseLeafKey{ - OutPoint: wire.OutPoint{ - Hash: mintTxHash, - Index: b.anchorOutputIndex, - }, - ScriptKey: &a.ScriptKey, - } - - mintingProofBytes, err := mintingProof.Bytes() - if err != nil { - return nil, nil, fmt.Errorf("unable to encode proof: %w", err) - } - - // With both of those assembled, we can now register issuance which - // takes the amount and proof of the minting event. - uniGen := universe.GenesisWithGroup{ - Genesis: a.Genesis, - } - if groupKey != nil { - uniGen.GroupKey = groupKey - } - mintingLeaf := &universe.Leaf{ - GenesisWithGroup: uniGen, - - // The universe tree store only the asset state transition and - // not also the proof file checksum (as the root is effectively - // a checksum), so we'll use just the state transition. - RawProof: mintingProofBytes, - Amt: a.Amount, - Asset: a, - } - - return blob, &universe.Item{ - ID: uniID, - Key: leafKey, - Leaf: mintingLeaf, - - // We set this to true to indicate that we would like the syncer - // to log and reattempt (in the event of a failure) to push sync - // this proof leaf. - LogProofSync: true, - }, nil + return blob, nil } -// batchStreamUniverseItems streams the issuance items for a batch to the -// universe. -func (b *BatchCaretaker) batchStreamUniverseItems(ctx context.Context, - universeItems chan *universe.Item, numTotal int) error { - - var ( - numItems int - uni = b.cfg.Universe - ) - err := fn.CollectBatch( - ctx, universeItems, b.cfg.UniversePushBatchSize, - func(ctx context.Context, - batch []*universe.Item) error { - - numItems += len(batch) - log.Infof("Inserting %d new leaves (%d of %d) into "+ - "local universe", len(batch), numItems, - numTotal) - - err := uni.UpsertProofLeafBatch(ctx, batch) - if err != nil { - return fmt.Errorf("unable to register "+ - "proof leaf batch: %w", err) - } - - log.Infof("Inserted %d new leaves (%d of %d) into "+ - "local universe", len(batch), numItems, - numTotal) - - return nil - }, - ) - if err != nil { - return fmt.Errorf("unable to register issuance proofs: %w", err) - } - - return nil -} - -// AssetMintEvent is an event which is sent to the BatchCaretaker's event -// subscribers after a state was executed successfully. +// AssetMintEvent is an event which is sent to the Cultivator's event +// subscribers after a state was executed successfully. The just-executed +// state is read from Batch.State(); the event's constructors mirror the +// state into the copied batch so it cannot lag the executed step. type AssetMintEvent struct { // timestamp is the time the event was created. timestamp time.Time - // BatchState is the last state that was executed before the event is - // received. This field takes precedence over Batch.State() as that - // might not always be updated when the event is created. In case Error - // below is set, the BatchState is the state that was executed that lead - // to the error. - BatchState BatchState - // Error is an optional error, indicating that something went wrong - // during the execution of the BatchState above. + // during the execution of Batch.State(). Error error - // Batch is the batch that is being minted. + // Batch is the batch that is being minted. Batch.State() reports the + // last state that was executed before the event was emitted. Batch *MintingBatch } @@ -1354,12 +1263,15 @@ func (e *AssetMintEvent) Timestamp() time.Time { return e.timestamp } -// newAssetMintEvent creates a new AssetMintEvent from the given batch. +// newAssetMintEvent creates a new AssetMintEvent from the given batch. The +// copied batch's state is set to the just-executed state so consumers can +// trust Batch.State() to reflect the step that produced the event. func newAssetMintEvent(state BatchState, b *MintingBatch) *AssetMintEvent { + batchCopy := b.Copy() + batchCopy.setState(state) return &AssetMintEvent{ - timestamp: time.Now().UTC(), - BatchState: state, - Batch: b.Copy(), + timestamp: time.Now().UTC(), + Batch: batchCopy, } } @@ -1368,11 +1280,12 @@ func newAssetMintEvent(state BatchState, b *MintingBatch) *AssetMintEvent { func newAssetMintErrorEvent(err error, state BatchState, b *MintingBatch) *AssetMintEvent { + batchCopy := b.Copy() + batchCopy.setState(state) return &AssetMintEvent{ - timestamp: time.Now().UTC(), - BatchState: state, - Error: err, - Batch: b.Copy(), + timestamp: time.Now().UTC(), + Error: err, + Batch: batchCopy, } } @@ -1416,8 +1329,8 @@ func SortAssets(fullAssets []*asset.Asset, case err == nil: anchorAssets = append(anchorAssets, fullAsset) - case errors.Is(err, ErrGenesisNotGroupAnchor) || - errors.Is(err, ErrGroupKeyUnknown): + case errors.Is(err, tapnode.ErrGenesisNotGroupAnchor) || + errors.Is(err, tapnode.ErrGroupKeyUnknown): nonAnchorAssets = append( nonAnchorAssets, fullAsset, @@ -1434,306 +1347,14 @@ func SortAssets(fullAssets []*asset.Asset, return anchorAssets, nonAnchorAssets, nil } -// GenHeaderVerifier generates a block header on-chain verification callback -// function given a chain bridge. -func GenHeaderVerifier(ctx context.Context, - chainBridge ChainBridge) func(wire.BlockHeader, uint32) error { - - return func(header wire.BlockHeader, height uint32) error { - err := chainBridge.VerifyBlock(ctx, header, height) - return err - } -} - -// sendSupplyCommitEvents sends supply commitment events for all minted assets -// in the batch to track them in the supply commitment state machine. -func (b *BatchCaretaker) sendSupplyCommitEvents(ctx context.Context, - anchorAssets, nonAnchorAssets []*asset.Asset, - mintingProofs proof.AssetProofs) error { - - // If no supply commit manager is configured, skip this step. - if b.cfg.MintSupplyCommitter == nil { - return nil - } - - // If no delegation key checker is configured, skip this step. As if we - // need this to figure out if we made the asset or not. - if b.cfg.DelegationKeyChecker == nil { - return nil - } - - // We'll combine the anchor and non-anchor assets into a single slice - // that we'll run through below. - allAssets := append(anchorAssets, nonAnchorAssets...) - - delChecker := b.cfg.DelegationKeyChecker - - // We filter the assets to only include those that have a delegation - // key. As only those have a supply commitment maintained. - assetsWithDelegation := fn.Filter(allAssets, func(a *asset.Asset) bool { - hasDelegationKey, err := delChecker.HasDelegationKey( - ctx, a.ID(), - ) - if err != nil { - log.Debugf("Error checking delegation key for "+ - "asset %x: %v", a.ID(), err) - return false - } - if !hasDelegationKey { - log.Debugf("Skipping supply commit event for "+ - "asset %x: delegation key not controlled "+ - "locally", - a.ID()) - } - return hasDelegationKey - }) - - // For each of the assets that we just created with a delegation key, - // we'll create then send a supply commit event so the committer can - // take care of it. - for _, mintedAsset := range assetsWithDelegation { - // First, we'll extract the minting proof based on the srcipt - // key, and extract the very last proof from that. - scriptKey := asset.ToSerialized(mintedAsset.ScriptKey.PubKey) - mintingProof, ok := mintingProofs[scriptKey] - if !ok { - return fmt.Errorf("missing minting proof for asset "+ - "with script key %x", scriptKey[:]) - } - - proofBlob, err := proof.EncodeAsProofFile(mintingProof) - if err != nil { - return fmt.Errorf("unable to encode proof as "+ - "file: %w", err) - } - proofFile, err := proof.DecodeFile(proofBlob) - if err != nil { - return fmt.Errorf("unable to decode proof "+ - "file: %w", err) - } - leafProof, err := proofFile.LastProof() - if err != nil { - return fmt.Errorf("unable to get leaf proof: %w", err) - } - - // Encode just the leaf proof, not the entire file. - var leafProofBuf bytes.Buffer - if err := leafProof.Encode(&leafProofBuf); err != nil { - return fmt.Errorf("unable to encode leaf proof: %w", - err) - } - leafProofBytes := leafProofBuf.Bytes() - - // With the proof extracted, we can now create the universe - // key and leaf. - universeKey := universe.BaseLeafKey{ - OutPoint: leafProof.OutPoint(), - ScriptKey: &mintedAsset.ScriptKey, - } - uniqueLeafKey := universe.AssetLeafKey{ - BaseLeafKey: universeKey, - AssetID: mintedAsset.ID(), - } - universeLeaf := universe.Leaf{ - GenesisWithGroup: universe.GenesisWithGroup{ - Genesis: mintedAsset.Genesis, - GroupKey: mintedAsset.GroupKey, - }, - RawProof: leafProofBytes, - Asset: &leafProof.Asset, - Amt: mintedAsset.Amount, - } - assetSpec := asset.NewSpecifierOptionalGroupKey( - mintedAsset.ID(), mintedAsset.GroupKey, - ) - - // Finally we send all of the above to the supply commiter. - err = b.cfg.MintSupplyCommitter.SendMintEvent( - ctx, assetSpec, uniqueLeafKey, universeLeaf, - leafProof.BlockHeight, - ) - if err != nil { - return fmt.Errorf("unable to send mint event for "+ - "asset %x: %w", mintedAsset.ID(), err) - } - - log.Infof("Sent supply commit mint event for asset %v", - mintedAsset.ID()) - } - - return nil -} - -// assetGroupCacheSize is the size of the cache for group keys. -const assetGroupCacheSize = 10000 - -// emptyVal is a simple type def around struct{} to use as a dummy value in in -// the cache. -type emptyVal struct{} - -// singleCacheValue is a dummy value that can be used to add an element to the -// cache. This should be used when the cache just needs to worry aobut the -// total number of elements, and not also the size (in bytes) of the elements. -type singleCacheValue[T any] struct { - val T -} - -// Size determines how big this entry would be in the cache. -func (s singleCacheValue[T]) Size() (uint64, error) { - return 1, nil -} - -// newSingleValue creates a new single cache value. -func newSingleValue[T any](v T) singleCacheValue[T] { - return singleCacheValue[T]{ - val: v, - } -} - -// emptyCacheVal is a type def for an empty cache value. In this case the cache -// is used more as a set. -type emptyCacheVal = singleCacheValue[emptyVal] - -// GenGroupVerifier generates a group key verification callback function given a -// DB handle. -func GenGroupVerifier(ctx context.Context, - mintingStore GroupFetcher) func(*btcec.PublicKey) error { - - // Cache known group keys that were previously fetched. - assetGroups := lru.NewCache[asset.SerializedKey, emptyCacheVal]( - assetGroupCacheSize, - ) - - return func(groupKey *btcec.PublicKey) error { - if groupKey == nil { - return fmt.Errorf("cannot verify empty group key") - } - - assetGroupKey := asset.ToSerialized(groupKey) - _, err := assetGroups.Get(assetGroupKey) - if err == nil { - return nil - } - - // This query will err if no stored group has a matching - // tweaked group key. - _, err = mintingStore.FetchGroupByGroupKey(ctx, groupKey) - if err != nil { - return fmt.Errorf("%x: group verifier: %s: %w", - assetGroupKey[:], err.Error(), - ErrGroupKeyUnknown) - } - - _, _ = assetGroups.Put(assetGroupKey, emptyCacheVal{}) - - return nil - } -} - -// GenGroupAnchorVerifier generates a caching group anchor verification -// callback function given a DB handle. -func GenGroupAnchorVerifier(ctx context.Context, - mintingStore GroupFetcher) func(*asset.Genesis, *asset.GroupKey) error { - - // Cache anchors for groups that were previously fetched. - groupAnchors := lru.NewCache[ - asset.SerializedKey, singleCacheValue[*asset.Genesis], - ]( - assetGroupCacheSize, - ) - - return func(gen *asset.Genesis, groupKey *asset.GroupKey) error { - assetGroupKey := asset.ToSerialized(&groupKey.GroupPubKey) - groupAnchor, err := groupAnchors.Get(assetGroupKey) - if err != nil { - storedGroup, err := mintingStore.FetchGroupByGroupKey( - ctx, &groupKey.GroupPubKey, - ) - if err != nil { - return fmt.Errorf("%x: group anchor verifier: "+ - "%w", assetGroupKey[:], - ErrGroupKeyUnknown) - } - - isGroupAnchor, err := storedGroup.IsGroupAnchor() - if err != nil { - return fmt.Errorf("%x: group anchor verifier: "+ - "unable to check if genesis is "+ - "group anchor: %w", assetGroupKey[:], - err) - } - - if !isGroupAnchor { - return fmt.Errorf("%x: group anchor verifier: "+ - "genesis is not a group anchor: %w", - assetGroupKey[:], err) - } - - groupAnchor = newSingleValue(storedGroup.Genesis) - - _, _ = groupAnchors.Put(assetGroupKey, groupAnchor) - } - - if gen.ID() != groupAnchor.val.ID() { - return ErrGenesisNotGroupAnchor - } - - return nil - } -} - -// GenRawGroupAnchorVerifier generates a group anchor verification callback -// function. This anchor verifier recomputes the tweaked group key with the -// passed genesis and compares that key to the given group key. This verifier -// is only used in the caretaker, before any asset groups are stored in the DB. -func GenRawGroupAnchorVerifier(ctx context.Context) func(*asset.Genesis, - *asset.GroupKey) error { - - // Cache group anchors we already verified. - groupAnchors := lru.NewCache[ - asset.SerializedKey, singleCacheValue[*asset.Genesis]]( - assetGroupCacheSize, - ) - - return func(gen *asset.Genesis, groupKey *asset.GroupKey) error { - assetGroupKey := asset.ToSerialized(&groupKey.GroupPubKey) - groupAnchor, err := groupAnchors.Get(assetGroupKey) - if err != nil { - singleTweak := gen.ID() - tweakedGroupKey, err := asset.GroupPubKeyV0( - groupKey.RawKey.PubKey, singleTweak[:], - groupKey.TapscriptRoot, - ) - if err != nil { - return err - } - - computedGroupKey := asset.ToSerialized(tweakedGroupKey) - if computedGroupKey != assetGroupKey { - return ErrGenesisNotGroupAnchor - } - - groupAnchor = newSingleValue(gen) - - _, _ = groupAnchors.Put(assetGroupKey, groupAnchor) - - return nil - } - - if gen.ID() != groupAnchor.val.ID() { - return ErrGenesisNotGroupAnchor - } - - return nil - } -} - // verifierCtx returns a verifier context that can be used to verify proofs. -func (b *BatchCaretaker) verifierCtx(ctx context.Context) proof.VerifierCtx { - headerVerifier := GenHeaderVerifier(ctx, b.cfg.ChainBridge) +func (b *Cultivator) verifierCtx(ctx context.Context) proof.VerifierCtx { + headerVerifier := tapnode.GenHeaderVerifier(ctx, b.cfg.ChainBridge) merkleVerifier := proof.DefaultMerkleVerifier - groupVerifier := GenGroupVerifier(ctx, b.cfg.Log) - groupAnchorVerifier := GenGroupAnchorVerifier(ctx, b.cfg.Log) + groupVerifier := tapnode.GenGroupVerifier(ctx, b.cfg.MintingRefs) + groupAnchorVerifier := tapnode.GenGroupAnchorVerifier( + ctx, b.cfg.MintingRefs, + ) return proof.VerifierCtx{ HeaderVerifier: headerVerifier, diff --git a/tapgarden/interface.go b/tapgarden/interface.go index 848ad3c83c..b0268f393b 100644 --- a/tapgarden/interface.go +++ b/tapgarden/interface.go @@ -2,25 +2,18 @@ package tapgarden import ( "context" - "crypto/sha256" "errors" "fmt" "github.com/btcsuite/btcd/btcec/v2" - "github.com/btcsuite/btcd/btcutil" - "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" - "github.com/lightninglabs/lndclient" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/commitment" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/proof" "github.com/lightninglabs/taproot-assets/tapsend" - "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/keychain" - "github.com/lightningnetwork/lnd/lnwallet" - "github.com/lightningnetwork/lnd/lnwallet/chainfee" ) const ( @@ -29,59 +22,6 @@ const ( IssuanceTxLabel = "tapd-asset-issuance" ) -// FundBatchResp is the response returned from the FundBatch method. -type FundBatchResp struct { - // Batch is the batch that was funded. - Batch *VerboseBatch -} - -// Planter is responsible for batching a set of seedlings into a minting batch -// that will eventually be confirmed on chain. -type Planter interface { - // QueueNewSeedling attempts to queue a new seedling request (the - // intent for New asset creation or ongoing issuance) to the Planter. - // A channel is returned where future updates will be sent over. If an - // error is returned no issuance operation was possible. - QueueNewSeedling(req *Seedling) (SeedlingUpdates, error) - - // ListBatches lists the set of batches submitted for minting, or the - // details of a specific batch. - ListBatches(params ListBatchesParams) ([]*VerboseBatch, error) - - // CancelSeedling attempts to cancel the creation of a new asset - // identified by its name. If the seedling has already progressed to a - // point where the genesis PSBT has been broadcasted, an error is - // returned. - CancelSeedling() error - - // FundBatch attempts to provide a genesis point for the current batch, - // or create a new funded batch. - FundBatch(params FundParams) (*FundBatchResp, error) - - // SealBatch attempts to seal the current batch, by providing or - // deriving all witnesses necessary to create the final genesis TX. - SealBatch(params SealParams) (*MintingBatch, error) - - // FinalizeBatch signals that the asset minter should finalize - // the current batch, if one exists. - FinalizeBatch(params FinalizeParams) (*MintingBatch, error) - - // CancelBatch signals that the asset minter should cancel the - // current batch, if one exists. - CancelBatch() (*btcec.PublicKey, error) - - // Start signals that the asset minter should being operations. - Start() error - - // Stop signals that the asset minter should attempt a graceful - // shutdown. - Stop() error - - // EventPublisher is a subscription interface that allows callers to - // subscribe to events that are relevant to the Planter. - fn.EventPublisher[fn.Event, bool] -} - // BatchState an enum that represents the various stages of a minting batch. type BatchState uint8 @@ -188,26 +128,60 @@ func NewBatchState(state uint8) (BatchState, error) { } } -// MintingStore is a log that stores information related to the set of pending -// minting batches. The ChainPlanter and ChainCaretaker use this log to record -// the process of seeding, planting, and finally maturing taproot assets that are -// a part of the batch. -type MintingStore interface { - asset.TapscriptTreeManager - - // CommitMintingBatch commits a new minting batch to disk, identified - // by its batch key. - CommitMintingBatch(ctx context.Context, newBatch *MintingBatch) error +// PreCommitBindData is the typed persistence payload that the +// BatchStore writes for a batch's pre-commitment output when the +// genesis-tx augmenter is active. The augmenter constructs it at +// funding and sealing time; tapgarden plumbs it opaquely through to +// the store. The store treats it as the source of truth for the +// columns of the supply-pre-commit table -- callers must not encode +// the same data through any other route. +type PreCommitBindData struct { + // OutputIndex is the index of the pre-commitment output within + // the batch mint anchor transaction. + OutputIndex uint32 + + // InternalKey is the Taproot internal public key associated + // with the pre-commitment output. + InternalKey keychain.KeyDescriptor + + // GroupKey is the asset-group public key for this + // pre-commitment. Present when known (group reused at funding + // time, or generated by seal time); absent while an unsealed + // batch without a prior group key is still in progress. + GroupKey fn.Option[btcec.PublicKey] +} - // UpdateBatchState updates the batch state on disk identified by the - // batch key. - UpdateBatchState(ctx context.Context, batchKey *btcec.PublicKey, +// BatchStore persists the lifecycle of a minting batch: creation, +// state transitions, and the writes that accompany each transition +// (sprouts, signed genesis tx, confirmation). Both the planter and +// the caretaker use it; the substance stored is exactly "minting +// batches and their state." +type BatchStore interface { + // CommitMintingBatch commits a new minting batch to disk, + // identified by its batch key. If preCommit is set (the + // batch was created already-funded with a pre-commitment + // output), the supply-pre-commit row is persisted in the + // same transaction. + CommitMintingBatch(ctx context.Context, newBatch *MintingBatch, + preCommit fn.Option[PreCommitBindData]) error + + // UpdateBatchState writes the new batch state to disk and, on + // success, mirrors it into the in-memory batch. Either both writes + // succeed and the in-memory state advances, or both stay at the + // prior value. Callers must never mutate batch state by any other + // route. + UpdateBatchState(ctx context.Context, batch *MintingBatch, newState BatchState) error // AddSeedlingsToBatch adds a new seedling to an existing batch. Once // added this batch should remain in the BatchStatePending state. // - // TODO(roasbeef): assumption that only one pending batch at a time? + // The "exactly one batch in BatchStatePending or BatchStateFrozen + // at a time" invariant the planter relies on is enforced at the + // DB layer by migration 000060 (a partial unique index on + // asset_minting_batches). Callers may assume that any successful + // CommitMintingBatch left the singleton slot occupied by exactly + // the batch they just committed. AddSeedlingsToBatch(ctx context.Context, batchKey *btcec.PublicKey, seedlings ...*Seedling) error @@ -223,10 +197,13 @@ type MintingStore interface { FetchMintingBatch(ctx context.Context, batchKey *btcec.PublicKey) (*MintingBatch, error) - // SealBatch seals a batch by assigning and persisting asset groups for - // the seedlings it contains. + // SealBatch seals a batch by assigning and persisting asset + // groups for the seedlings it contains. If preCommit is set, + // the store also re-upserts the supply-pre-commit row with the + // payload (the group key is typically only known by seal time). SealBatch(ctx context.Context, batch *MintingBatch, - newAssetGroups []*asset.AssetGroup) error + newAssetGroups []*asset.AssetGroup, + preCommit fn.Option[PreCommitBindData]) error // FetchSeedlingGroups is used to fetch the asset groups for seedlings // associated with a funded batch. @@ -234,24 +211,29 @@ type MintingStore interface { anchorOutputIndex uint32, seedlings []*Seedling) ([]*asset.AssetGroup, error) - // AddSproutsToBatch adds a new set of sprouts to the batch, along with - // a GenesisPacket, that once signed and broadcast with create the - // set of assets on chain. + // AddSproutsToBatch adds a new set of sprouts to the batch, + // along with a GenesisPacket, that once signed and broadcast + // with create the set of assets on chain. If preCommit is set, + // the supply-pre-commit row is also persisted (or refreshed) + // in the same transaction. // - // NOTE: The BatchState should transition to BatchStateCommitted upon a - // successful call. - AddSproutsToBatch(ctx context.Context, batchKey *btcec.PublicKey, + // NOTE: On success the batch transitions to BatchStateCommitted on + // disk and the in-memory state of the supplied batch is advanced to + // match. On failure neither moves. + AddSproutsToBatch(ctx context.Context, batch *MintingBatch, genesisPacket *FundedMintAnchorPsbt, - assets *commitment.TapCommitment) error + assets *commitment.TapCommitment, + preCommit fn.Option[PreCommitBindData]) error // CommitSignedGenesisTx adds a fully signed genesis transaction to the // batch, along with the Taproot Asset script root, which is the // left/right sibling for the Taproot Asset tapscript commitment in the // transaction. // - // NOTE: The BatchState should transition to the BatchStateBroadcast - // state upon a successful call. - CommitSignedGenesisTx(ctx context.Context, batchKey *btcec.PublicKey, + // NOTE: On success the batch transitions to BatchStateBroadcast on + // disk and the in-memory state of the supplied batch is advanced to + // match. On failure neither moves. + CommitSignedGenesisTx(ctx context.Context, batch *MintingBatch, genesisTx *tapsend.FundedPsbt, anchorOutputIndex uint32, merkleRoot, tapTreeRoot, tapSibling []byte) error @@ -259,183 +241,46 @@ type MintingStore interface { // block location information determines where exactly in the chain the // batch was confirmed. // - // NOTE: The BatchState should transition to the BatchStateConfirmed - // state upon a successful call. - MarkBatchConfirmed(ctx context.Context, batchKey *btcec.PublicKey, + // NOTE: On success the batch transitions to BatchStateConfirmed on + // disk and the in-memory state of the supplied batch is advanced to + // match. On failure neither moves. + MarkBatchConfirmed(ctx context.Context, batch *MintingBatch, blockHash *chainhash.Hash, blockHeight uint32, txIndex uint32, mintingProofs proof.AssetBlobs) error - // FetchGroupByGenesis fetches the asset group created by the genesis - // referenced by the given ID. - FetchGroupByGenesis(ctx context.Context, - genesisID int64) (*asset.AssetGroup, error) - - // FetchGroupByGroupKey fetches the asset group with a matching tweaked - // key, including the genesis information used to create the group. - FetchGroupByGroupKey(ctx context.Context, - groupKey *btcec.PublicKey) (*asset.AssetGroup, error) - - // FetchScriptKeyByTweakedKey fetches the populated script key given the - // tweaked script key. - FetchScriptKeyByTweakedKey(ctx context.Context, - tweakedKey *btcec.PublicKey) (*asset.TweakedScriptKey, error) - - // FetchAssetMeta fetches the meta reveal for an asset genesis. - FetchAssetMeta(ctx context.Context, ID asset.ID) (*proof.MetaReveal, - error) - // CommitBatchFunding atomically persists the funded genesis - // transaction and the optional tapscript sibling root hash for a - // batch in a single transaction. Either both writes succeed or - // neither persists, so a partial-failure cannot leave the batch - // in an inconsistent on-disk state. + // transaction, the optional tapscript sibling root hash, and + // the optional supply-pre-commit row for a batch in a single + // transaction. Either all writes succeed or none persists, so + // a partial failure cannot leave the batch in an inconsistent + // on-disk state. // // NOTE: The tapscript tree referenced by rootHash (if non-nil) // must already be committed to disk. CommitBatchFunding(ctx context.Context, batchKey *btcec.PublicKey, rootHash *chainhash.Hash, - genesisTx FundedMintAnchorPsbt) error - - // FetchDelegationKey fetches the delegation key for the given asset - // group public key. - FetchDelegationKey(ctx context.Context, - groupKey btcec.PublicKey) (fn.Option[DelegationKey], error) + genesisTx FundedMintAnchorPsbt, + preCommit fn.Option[PreCommitBindData]) error } -// GroupFetcher is an interface that allows fetching of asset groups. -type GroupFetcher interface { +// MintingRefReader exposes the read-only lookups the planter performs +// to validate seedlings before they enter a batch and to populate +// listings. These are not batch state; they are pre-existing reference +// data the planter consults but does not own. +type MintingRefReader interface { // FetchGroupByGroupKey fetches the asset group with a matching tweaked // key, including the genesis information used to create the group. FetchGroupByGroupKey(ctx context.Context, groupKey *btcec.PublicKey) (*asset.AssetGroup, error) -} -// ChainBridge is our bridge to the target chain. It's used to get confirmation -// notifications, the current height, publish transactions, and also estimate -// fees. -type ChainBridge interface { - proof.ChainLookupGenerator - - // RegisterConfirmationsNtfn registers an intent to be notified once - // txid reaches numConfs confirmations. - RegisterConfirmationsNtfn(ctx context.Context, txid *chainhash.Hash, - pkScript []byte, numConfs, heightHint uint32, - includeBlock bool, - reOrgChan chan struct{}) (*chainntnfs.ConfirmationEvent, - chan error, error) - - // RegisterBlockEpochNtfn registers an intent to be notified of each - // new block connected to the main chain. - RegisterBlockEpochNtfn(ctx context.Context) (chan int32, chan error, - error) - - // GetBlock returns a chain block given its hash. - GetBlock(context.Context, chainhash.Hash) (*wire.MsgBlock, error) - - // GetBlockByHeight returns a chain block given its height. - GetBlockByHeight(ctx context.Context, - blockHeight int64) (*wire.MsgBlock, error) - - // GetBlockHash returns the hash of the block in the best blockchain at - // the given height. - GetBlockHash(context.Context, int64) (chainhash.Hash, error) - - // VerifyBlock returns an error if a block (with given header and - // height) is not present on-chain. It also checks to ensure that block - // height corresponds to the given block header. - VerifyBlock(ctx context.Context, header wire.BlockHeader, - height uint32) error - - // CurrentHeight return the current height of the main chain. - CurrentHeight(context.Context) (uint32, error) - - // GetBlockTimestamp returns the timestamp of the block at the given - // height. - GetBlockTimestamp(context.Context, uint32) (int64, error) - - // GetBlockHeaderByHeight returns a block header given the block height. - GetBlockHeaderByHeight(ctx context.Context, - blockHeight int64) (*wire.BlockHeader, error) - - // PublishTransaction attempts to publish a new transaction to the - // network. - PublishTransaction(context.Context, *wire.MsgTx, string) error - - // EstimateFee returns a fee estimate for the confirmation target. - EstimateFee(ctx context.Context, - confTarget uint32) (chainfee.SatPerKWeight, error) -} + // FetchScriptKeyByTweakedKey fetches the populated script key given the + // tweaked script key. + FetchScriptKeyByTweakedKey(ctx context.Context, + tweakedKey *btcec.PublicKey) (*asset.TweakedScriptKey, error) -// WalletAnchor is the main wallet interface used to managed PSBT packets, and -// import public keys into the wallet. -type WalletAnchor interface { - // FundPsbt attaches enough inputs to the target PSBT packet for it to - // be valid. - FundPsbt(ctx context.Context, packet *psbt.Packet, minConfs uint32, - feeRate chainfee.SatPerKWeight, - changeIdx int32) (*tapsend.FundedPsbt, error) - - // SignAndFinalizePsbt fully signs and finalizes the target PSBT - // packet. - SignAndFinalizePsbt(context.Context, *psbt.Packet) (*psbt.Packet, error) - - // ImportTaprootOutput imports a new public key into the wallet, as a - // P2TR output. - ImportTaprootOutput(context.Context, *btcec.PublicKey) (btcutil.Address, + // FetchAssetMeta fetches the meta reveal for an asset genesis. + FetchAssetMeta(ctx context.Context, ID asset.ID) (*proof.MetaReveal, error) - - // UnlockInput unlocks the set of target inputs after a batch or send - // transaction is abandoned. - UnlockInput(context.Context, wire.OutPoint) error - - // ListUnspentImportScripts lists all UTXOs of the imported Taproot - // scripts. - ListUnspentImportScripts(ctx context.Context) ([]*lnwallet.Utxo, error) - - // ListTransactions returns all known transactions of the backing lnd - // node. It takes a start and end block height which can be used to - // limit the block range that we query over. These values can be left - // as zero to include all blocks. To include unconfirmed transactions - // in the query, endHeight must be set to -1. - ListTransactions(ctx context.Context, startHeight, endHeight int32, - account string) ([]lndclient.Transaction, error) - - // SubscribeTransactions creates a uni-directional stream from the - // server to the client in which any newly discovered transactions - // relevant to the wallet are sent over. - SubscribeTransactions(context.Context) (<-chan lndclient.Transaction, - <-chan error, error) - - // MinRelayFee returns the current minimum relay fee based on - // our chain backend in sat/kw. - MinRelayFee(ctx context.Context) (chainfee.SatPerKWeight, error) -} - -// KeyRing is a mirror of the keychain.KeyRing interface, with the addition of -// a passed context which allows for cancellation of requests. -type KeyRing interface { - // DeriveNextKey attempts to derive the *next* key within the key - // family (account in BIP-0043) specified. This method should return the - // next external child within this branch. - DeriveNextKey(context.Context, - keychain.KeyFamily) (keychain.KeyDescriptor, error) - - // IsLocalKey returns true if the key is under the control of the wallet - // and can be derived by it. - IsLocalKey(context.Context, keychain.KeyDescriptor) bool - - // DeriveSharedKey returns a shared secret key by performing - // Diffie-Hellman key derivation between the ephemeral public key and - // the key specified by the key locator (or the node's identity private - // key if no key locator is specified): - // - // P_shared = privKeyNode * ephemeralPubkey - // - // The resulting shared public key is serialized in the compressed - // format and hashed with SHA256, resulting in a final key length of 256 - // bits. - DeriveSharedKey(context.Context, *btcec.PublicKey, - *keychain.KeyLocator) ([sha256.Size]byte, error) } var ( diff --git a/tapgarden/mint_publisher.go b/tapgarden/mint_publisher.go new file mode 100644 index 0000000000..2ef05aed59 --- /dev/null +++ b/tapgarden/mint_publisher.go @@ -0,0 +1,72 @@ +package tapgarden + +import ( + "context" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/proof" +) + +// MintProofPublisher ships freshly-minted (or re-organized) assets and +// their proofs to a downstream proof distributor (e.g. a local or remote +// universe). tapgarden owns this interface as the consumer; the +// implementation lives in another package because the act of publishing +// proofs out is extrinsic to tapgarden's end (a verifiable asset in the +// local store). +type MintProofPublisher interface { + // PublishMintBatch publishes the proofs for a confirmed minting + // batch. The publisher is responsible for any batching, ordering, + // or retry semantics it requires. + PublishMintBatch(ctx context.Context, + params MintBatchPublishParams) error + + // PublishMintProofUpdates publishes proof updates emitted after a + // chain re-org affected previously-minted assets. Each proof is the + // updated, fully-encoded minting proof. + PublishMintProofUpdates(ctx context.Context, + proofs []*proof.Proof) error +} + +// MintBatchPublishParams carries the data needed by a MintProofPublisher +// to publish the proofs for a confirmed minting batch. +type MintBatchPublishParams struct { + // Assets is the set of newly-minted assets in the batch, in the + // order they should be inserted (group anchors first). + Assets []*asset.Asset + + // Proofs is the per-asset minting proof, keyed by the asset's + // serialized script key. + Proofs map[asset.SerializedKey]*proof.Proof + + // MintTxHash is the hash of the genesis transaction that anchors + // the batch. + MintTxHash chainhash.Hash + + // AnchorOutIdx is the output index of the asset anchor in the + // genesis transaction. + AnchorOutIdx uint32 +} + +// NoOpMintProofPublisher is a publisher that does nothing. It is +// intended for tests and configurations that have no universe to ship +// proofs to. +type NoOpMintProofPublisher struct{} + +// PublishMintBatch is a no-op. +func (NoOpMintProofPublisher) PublishMintBatch(_ context.Context, + _ MintBatchPublishParams) error { + + return nil +} + +// PublishMintProofUpdates is a no-op. +func (NoOpMintProofPublisher) PublishMintProofUpdates(_ context.Context, + _ []*proof.Proof) error { + + return nil +} + +// Compile-time assertion that NoOpMintProofPublisher implements the +// MintProofPublisher interface. +var _ MintProofPublisher = NoOpMintProofPublisher{} diff --git a/tapgarden/mock.go b/tapgarden/mock.go index f651b5b45f..90839a5e68 100644 --- a/tapgarden/mock.go +++ b/tapgarden/mock.go @@ -3,37 +3,29 @@ package tapgarden import ( "bytes" "context" - "crypto/sha256" "encoding/hex" "fmt" - "math/rand" - "sync" "sync/atomic" "testing" "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" - "github.com/btcsuite/btcd/btcutil" "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/btcsuite/btcwallet/waddrmgr" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/taproot-assets/address" "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" + "github.com/lightninglabs/taproot-assets/tapnode/tapnodemock" + "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapsend" - "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/keychain" - "github.com/lightningnetwork/lnd/lnwallet" "github.com/lightningnetwork/lnd/lnwallet/chainfee" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -278,7 +270,7 @@ func RandMintingBatch(t testing.TB, opts ...MintBatchOption) *MintingBatch { walletFundPsbt := func(ctx context.Context, anchorPkt psbt.Packet) (tapsend.FundedPsbt, error) { - changeOutputIdx := FundGenesisTx( + changeOutputIdx := tapnodemock.FundGenesisTx( &anchorPkt, chainfee.FeePerKwFloor, ) @@ -288,10 +280,17 @@ func RandMintingBatch(t testing.TB, opts ...MintBatchOption) *MintingBatch { }, nil } - // Fund genesis packet. + // Fund genesis packet. Tests that exercise the supply-commit + // path want a fully-formed batch with pre-commit data + // available; the mock augmenter below mirrors what the real + // universe/supplycommit augmenter would do, just locally so + // tapgarden can use it without re-importing supplycommit. ctx := context.Background() + mockAug := mockSupplyCommitAugmenter{ + chainParams: address.TestNet3Tap, + } fundedPsbt, err := fundGenesisPsbt( - ctx, address.TestNet3Tap, batch, walletFundPsbt, + ctx, address.TestNet3Tap, batch, walletFundPsbt, mockAug, ) require.NoError(t, err) batch.GenesisPacket = &fundedPsbt @@ -299,772 +298,220 @@ func RandMintingBatch(t testing.TB, opts ...MintBatchOption) *MintingBatch { return batch } -// RandSeedlings creates a new set of random seedlings for testing. -func RandSeedlings(t testing.TB, numSeedlings int) map[string]*Seedling { - seedlings := make(map[string]*Seedling) - for i := 0; i < numSeedlings; i++ { - metaBlob := test.RandBytes(32) - assetName := hex.EncodeToString(test.RandBytes(32)) - scriptKey, _ := test.RandKeyDesc(t) - seedlings[assetName] = &Seedling{ - // For now, we only test the v0 and v1 versions. - AssetVersion: asset.Version(test.RandIntn(2)), - AssetType: asset.Type(test.RandIntn(2)), - AssetName: assetName, - Meta: &proof.MetaReveal{ - Data: metaBlob, - }, - Amount: uint64(test.RandInt[uint32]()), - ScriptKey: asset.NewScriptKeyBip86(scriptKey), - EnableEmission: test.RandBool(), - } - } - - return seedlings +// mockSupplyCommitAugmenter is the augmenter that RandMintingBatch +// installs on its synthetic batches. It mirrors the behaviour of +// universe/supplycommit.GenesisAugmenter for the few fields the +// tests actually inspect, without taking on the full +// supplycommit dependency (supplycommit imports tapgarden, so we +// cannot import it back here). +type mockSupplyCommitAugmenter struct { + chainParams address.ChainParams } -type MockWalletAnchor struct { - FundPsbtSignal chan *tapsend.FundedPsbt - SignPsbtSignal chan struct{} - ImportPubKeySignal chan *btcec.PublicKey - ListUnspentSignal chan struct{} - SubscribeTxSignal chan struct{} - SubscribeTx chan lndclient.Transaction - ListTxnsSignal chan struct{} - - Transactions []lndclient.Transaction - ImportedUtxos []*lnwallet.Utxo -} +func (mockSupplyCommitAugmenter) PrepareSeedling(_ context.Context, + _ *MintingBatch, _ *Seedling) error { -func NewMockWalletAnchor() *MockWalletAnchor { - return &MockWalletAnchor{ - FundPsbtSignal: make(chan *tapsend.FundedPsbt), - SignPsbtSignal: make(chan struct{}), - ImportPubKeySignal: make(chan *btcec.PublicKey), - ListUnspentSignal: make(chan struct{}), - SubscribeTxSignal: make(chan struct{}), - SubscribeTx: make(chan lndclient.Transaction), - ListTxnsSignal: make(chan struct{}), - } + return nil } -// NewGenesisTx creates a funded genesis PSBT with the given fee rate. -func NewGenesisTx(t testing.TB, feeRate chainfee.SatPerKWeight) psbt.Packet { - txTemplate := wire.NewMsgTx(2) - txTemplate.AddTxOut(tapsend.CreateDummyOutput()) - genesisPkt, err := psbt.NewFromUnsignedTx(txTemplate) - require.NoError(t, err) +func (mockSupplyCommitAugmenter) ValidateSeedling(_ *MintingBatch, + _ Seedling) error { - FundGenesisTx(genesisPkt, feeRate) - return *genesisPkt + return nil } -// FundGenesisTx add a genesis input and change output to a 1-output TX and -// returns the index of the change output. -func FundGenesisTx(packet *psbt.Packet, feeRate chainfee.SatPerKWeight) uint32 { - const anchorBalance = int64(100000) - - // Take the PSBT packet and add an additional input and output to - // simulate the wallet funding the transaction. - packet.UnsignedTx.AddTxIn(&wire.TxIn{ - PreviousOutPoint: wire.OutPoint{ - Index: test.RandInt[uint32](), - }, - }) - - // Use a P2TR input by default. - anchorInput := psbt.PInput{ - WitnessUtxo: &wire.TxOut{ - Value: anchorBalance, - PkScript: bytes.Clone(tapsend.GenesisDummyScript), - }, - SighashType: txscript.SigHashDefault, +// mockDelegationKey reads the delegation key off the batch's +// unique anchor seedling. This is the inlined equivalent of the +// supplycommit augmenter's helper of the same name. +func mockDelegationKey(batch *MintingBatch) fn.Option[keychain.KeyDescriptor] { + var zero fn.Option[keychain.KeyDescriptor] + if batch == nil || !batch.SupplyCommitments { + return zero } - packet.Inputs = append(packet.Inputs, anchorInput) - - // Use a non-P2TR change output by default so we avoid generating - // exclusion proofs. - changeOutput := wire.TxOut{ - Value: anchorBalance - packet.UnsignedTx.TxOut[0].Value, - PkScript: bytes.Clone(tapsend.GenesisDummyScript), + if len(batch.Seedlings) == 0 { + return zero } - changeOutput.PkScript[0] = txscript.OP_0 - packet.UnsignedTx.AddTxOut(&changeOutput) - packet.Outputs = append(packet.Outputs, psbt.POutput{}) - - // Set a realistic change value. - _, fee := tapscript.EstimateFee( - [][]byte{tapsend.GenesisDummyScript}, packet.UnsignedTx.TxOut, - feeRate, - ) - changeOutputIdx := len(packet.UnsignedTx.TxOut) - 1 - packet.UnsignedTx.TxOut[changeOutputIdx].Value -= int64(fee) - - return uint32(changeOutputIdx) + anchor, err := batch.uniqueAnchorSeedling() + if err != nil { + return zero + } + return anchor.DelegationKey } -// FundPsbt funds a PSBT. -func (m *MockWalletAnchor) FundPsbt(_ context.Context, packet *psbt.Packet, - _ uint32, _ chainfee.SatPerKWeight, - changeIdx int32) (*tapsend.FundedPsbt, error) { - - // Take the PSBT packet and add an additional input and output to - // simulate the wallet funding the transaction. - packet.UnsignedTx.AddTxIn(&wire.TxIn{ - PreviousOutPoint: wire.OutPoint{ - Index: rand.Uint32(), - }, - }) - - // Use a P2TR input by default. - anchorInput := psbt.PInput{ - WitnessUtxo: &wire.TxOut{ - Value: 100000, - PkScript: bytes.Clone(tapsend.GenesisDummyScript), - }, - SighashType: txscript.SigHashDefault, +// mockGroupKey reads the group pub key off the batch's anchor +// seedling, when known. +func mockGroupKey(batch *MintingBatch) fn.Option[btcec.PublicKey] { + var zero fn.Option[btcec.PublicKey] + if batch == nil || !batch.SupplyCommitments { + return zero } - packet.Inputs = append(packet.Inputs, anchorInput) - - // Use a non-P2TR change output by default so we avoid generating - // exclusion proofs. - changeOutput := wire.TxOut{ - Value: 50000, - PkScript: bytes.Clone(tapsend.GenesisDummyScript), + if len(batch.Seedlings) == 0 { + return zero } - changeOutput.PkScript[0] = txscript.OP_0 - 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{ - Pkt: packet, - ChangeOutputIndex: changeIdx, + anchor, err := batch.uniqueAnchorSeedling() + if err != nil || anchor.GroupInfo == nil { + return zero } - - m.FundPsbtSignal <- pkt - - return pkt, nil + return fn.Some(anchor.GroupInfo.GroupPubKey) } -func (m *MockWalletAnchor) SignAndFinalizePsbt(ctx context.Context, - pkt *psbt.Packet) (*psbt.Packet, error) { - - select { - case <-ctx.Done(): - return nil, fmt.Errorf("shutting down") - default: - } - - // We'll modify the packet by attaching a "signature" so the PSBT - // appears to actually be finalized. - pkt.Inputs[0].FinalScriptSig = []byte{} - - select { - case <-ctx.Done(): - return nil, fmt.Errorf("shutting down") - case m.SignPsbtSignal <- struct{}{}: +// mockPreCommitTxOut builds the deterministic pay-to-taproot +// output for the given key (the same script the real augmenter +// emits). +func mockPreCommitTxOut(internalKey btcec.PublicKey) (wire.TxOut, error) { + var zero wire.TxOut + taprootOutputKey := txscript.ComputeTaprootKeyNoScript(&internalKey) + pkScript, err := txscript.PayToTaprootScript(taprootOutputKey) + if err != nil { + return zero, fmt.Errorf("unable to create pre-commitment "+ + "output pk script: %w", err) } - - return pkt, nil + return wire.TxOut{ + Value: int64(tapsend.DummyAmtSats), + PkScript: pkScript, + }, nil } -func (m *MockWalletAnchor) ImportTaprootOutput(ctx context.Context, - pub *btcec.PublicKey) (btcutil.Address, error) { +func (m mockSupplyCommitAugmenter) ExtraOutputs(_ context.Context, + batch *MintingBatch) ([]wire.TxOut, error) { - select { - case m.ImportPubKeySignal <- pub: - - case <-ctx.Done(): - return nil, fmt.Errorf("shutting down") + dKey := mockDelegationKey(batch) + if dKey.IsNone() { + return nil, nil } - - return btcutil.NewAddressTaproot( - schnorr.SerializePubKey(pub), &chaincfg.RegressionNetParams, + internalKey, _ := dKey.UnwrapOrErr( + fmt.Errorf("delegation key unexpectedly absent"), ) -} - -// UnlockInput unlocks the set of target inputs after a batch or send -// transaction is abandoned. -func (m *MockWalletAnchor) UnlockInput(context.Context, wire.OutPoint) error { - return nil -} - -// ListUnspentImportScripts lists all UTXOs of the imported Taproot scripts. -func (m *MockWalletAnchor) ListUnspentImportScripts( - ctx context.Context) ([]*lnwallet.Utxo, error) { - - select { - case m.ListUnspentSignal <- struct{}{}: - - case <-ctx.Done(): - return nil, fmt.Errorf("shutting down") - } - - return m.ImportedUtxos, nil -} - -// ImportTapscript imports a Taproot output script into the wallet to track it -// on-chain in a watch-only manner. -func (m *MockWalletAnchor) ImportTapscript(_ context.Context, - tapscript *waddrmgr.Tapscript) (btcutil.Address, error) { - - taprootKey, err := tapscript.TaprootKey() + out, err := mockPreCommitTxOut(*internalKey.PubKey) if err != nil { return nil, err } - - return btcutil.NewAddressTaproot( - schnorr.SerializePubKey(taprootKey), - &chaincfg.RegressionNetParams, - ) -} - -// SubscribeTransactions creates a uni-directional stream from the server to the -// client in which any newly discovered transactions relevant to the wallet are -// sent over. -func (m *MockWalletAnchor) SubscribeTransactions( - ctx context.Context) (<-chan lndclient.Transaction, <-chan error, error) { - - select { - case m.SubscribeTxSignal <- struct{}{}: - - case <-ctx.Done(): - return nil, nil, fmt.Errorf("shutting down") - } - - errChan := make(chan error) - return m.SubscribeTx, errChan, nil -} - -// ListTransactions returns all known transactions of the backing lnd node. It -// takes a start and end block height which can be used to limit the block range -// that we query over. These values can be left as zero to include all blocks. -// To include unconfirmed transactions in the query, endHeight must be set to -// -1. -func (m *MockWalletAnchor) ListTransactions(ctx context.Context, _, _ int32, - _ string) ([]lndclient.Transaction, error) { - - select { - case m.ListTxnsSignal <- struct{}{}: - - case <-ctx.Done(): - return nil, fmt.Errorf("shutting down") - } - - return m.Transactions, nil -} - -// MinRelayFee estimates the minimum fee rate required for a -// transaction. -func (m *MockWalletAnchor) MinRelayFee( - ctx context.Context) (chainfee.SatPerKWeight, error) { - - return chainfee.SatPerKWeight(10), nil -} - -type MockChainBridge struct { - FeeEstimateSignal chan struct{} - PublishReq chan *wire.MsgTx - ConfReqSignal chan int - BlockEpochSignal chan struct{} - - NewBlocks chan int32 - - ReqCount atomic.Int32 - ConfReqs map[int]*chainntnfs.ConfirmationEvent - - Blocks map[chainhash.Hash]*wire.MsgBlock - - failFeeEstimates atomic.Bool - errConf atomic.Int32 - emptyConf atomic.Int32 - confErr chan error -} - -func NewMockChainBridge() *MockChainBridge { - return &MockChainBridge{ - FeeEstimateSignal: make(chan struct{}), - PublishReq: make(chan *wire.MsgTx), - ConfReqs: make(map[int]*chainntnfs.ConfirmationEvent), - ConfReqSignal: make(chan int), - BlockEpochSignal: make(chan struct{}, 1), - NewBlocks: make(chan int32), - Blocks: make(map[chainhash.Hash]*wire.MsgBlock), - } -} - -func (m *MockChainBridge) FailFeeEstimatesOnce() { - m.failFeeEstimates.Store(true) -} - -// FailConfOnce updates the ChainBridge such that the next call to -// RegisterConfirmationNtfn will fail by returning an error on the error channel -// returned from RegisterConfirmationNtfn. -func (m *MockChainBridge) FailConfOnce() { - // Store the incremented request count so we never store 0 as a value. - m.errConf.Store(m.ReqCount.Load() + 1) -} - -// EmptyConfOnce updates the ChainBridge such that the next confirmation event -// sent via SendConfNtfn will have an empty confirmation. -func (m *MockChainBridge) EmptyConfOnce() { - // Store the incremented request count so we never store 0 as a value. - m.emptyConf.Store(m.ReqCount.Load() + 1) -} - -func (m *MockChainBridge) SendConfNtfn(reqNo int, blockHash *chainhash.Hash, - blockHeight, blockIndex int, block *wire.MsgBlock, - tx *wire.MsgTx) { - - // Compare to the incremented request count since we incremented it - // when storing the request number. - req := m.ConfReqs[reqNo] - if m.emptyConf.Load() == int32(reqNo)+1 { - m.emptyConf.Store(0) - req.Confirmed <- nil - return - } - - req.Confirmed <- &chainntnfs.TxConfirmation{ - BlockHash: blockHash, - BlockHeight: uint32(blockHeight), - TxIndex: uint32(blockIndex), - Block: block, - Tx: tx, - } -} - -func (m *MockChainBridge) RegisterConfirmationsNtfn(ctx context.Context, - _ *chainhash.Hash, _ []byte, _, _ uint32, _ bool, - _ chan struct{}) (*chainntnfs.ConfirmationEvent, chan error, error) { - - select { - case <-ctx.Done(): - return nil, nil, fmt.Errorf("shutting down") - default: - } - - defer func() { - m.ReqCount.Add(1) - }() - - req := &chainntnfs.ConfirmationEvent{ - Confirmed: make(chan *chainntnfs.TxConfirmation), - Cancel: func() {}, - } - m.confErr = make(chan error, 1) - - currentReqCount := m.ReqCount.Load() - m.ConfReqs[int(currentReqCount)] = req - - select { - case m.ConfReqSignal <- int(currentReqCount): - case <-ctx.Done(): - } - - // Compare to the incremented request count since we incremented it - // when storing the request number. - if m.errConf.CompareAndSwap(currentReqCount+1, 0) { - m.confErr <- fmt.Errorf("confirmation registration error") - } - - return req, m.confErr, nil -} - -func (m *MockChainBridge) RegisterBlockEpochNtfn( - ctx context.Context) (chan int32, chan error, error) { - - select { - case <-ctx.Done(): - return nil, nil, fmt.Errorf("shutting down") - default: - } - - select { - case m.BlockEpochSignal <- struct{}{}: - case <-ctx.Done(): - } - - return m.NewBlocks, make(chan error), nil -} - -// GetBlock returns a chain block given its hash. -func (m *MockChainBridge) GetBlock(ctx context.Context, - hash chainhash.Hash) (*wire.MsgBlock, error) { - - block, ok := m.Blocks[hash] - if !ok { - return nil, fmt.Errorf("block %s not found", hash.String()) - } - - return block, nil -} - -// GetBlockByHeight returns a block given the block height. -func (m *MockChainBridge) GetBlockByHeight(ctx context.Context, - blockHeight int64) (*wire.MsgBlock, error) { - - return &wire.MsgBlock{}, nil -} - -// GetBlockHeaderByHeight returns a block header given the block height. -func (m *MockChainBridge) GetBlockHeaderByHeight(ctx context.Context, - blockHeight int64) (*wire.BlockHeader, error) { - - return &wire.BlockHeader{}, nil -} - -// GetBlockHash returns the hash of the block in the best blockchain at the -// given height. -func (m *MockChainBridge) GetBlockHash(ctx context.Context, - blockHeight int64) (chainhash.Hash, error) { - - return chainhash.Hash{}, nil -} - -// VerifyBlock returns an error if a block (with given header and height) is not -// present on-chain. It also checks to ensure that block height corresponds to -// the given block header. -func (m *MockChainBridge) VerifyBlock(_ context.Context, - _ wire.BlockHeader, _ uint32) error { - - return nil -} - -func (m *MockChainBridge) CurrentHeight(_ context.Context) (uint32, error) { - return 0, nil -} - -func (m *MockChainBridge) GetBlockTimestamp(_ context.Context, _ uint32) (int64, - error) { - - return 0, nil -} - -func (m *MockChainBridge) PublishTransaction(_ context.Context, - tx *wire.MsgTx, _ string) error { - - m.PublishReq <- tx - return nil -} - -func (m *MockChainBridge) EstimateFee(ctx context.Context, - _ uint32) (chainfee.SatPerKWeight, error) { - - select { - case m.FeeEstimateSignal <- struct{}{}: - - case <-ctx.Done(): - return 0, fmt.Errorf("shutting down") - } - - if m.failFeeEstimates.Load() { - m.failFeeEstimates.Store(false) - return 0, fmt.Errorf("failed to estimate fee") - } - - return chainfee.FeePerKwFloor, nil -} - -// TxBlockHeight returns the block height that the given transaction was -// included in. -func (m *MockChainBridge) TxBlockHeight(context.Context, - chainhash.Hash) (uint32, error) { - - return 123, nil + return []wire.TxOut{out}, nil } -// MeanBlockTimestamp returns the timestamp of the block at the given height as -// a Unix timestamp in seconds, taking into account the mean time elapsed over -// the previous 11 blocks. -func (m *MockChainBridge) MeanBlockTimestamp(context.Context, - uint32) (time.Time, error) { - - return time.Now(), nil -} - -// GenFileChainLookup generates a chain lookup interface for the given -// proof file that can be used to validate proofs. -func (m *MockChainBridge) GenFileChainLookup(*proof.File) asset.ChainLookup { - return m -} +func (m mockSupplyCommitAugmenter) PostFund(_ context.Context, + batch *MintingBatch, funded *tapsend.FundedPsbt) error { -// GenProofChainLookup generates a chain lookup interface for the given -// single proof that can be used to validate proofs. -func (m *MockChainBridge) GenProofChainLookup(*proof.Proof) (asset.ChainLookup, - error) { - - return m, nil -} - -var _ asset.ChainLookup = (*MockChainBridge)(nil) -var _ ChainBridge = (*MockChainBridge)(nil) - -func GenMockGroupVerifier() func(*btcec.PublicKey) error { - return func(groupKey *btcec.PublicKey) error { + dKey := mockDelegationKey(batch) + if dKey.IsNone() { return nil } -} - -type MockAssetSyncer struct { - Assets map[asset.ID]*asset.AssetGroup - - FetchedAssets chan *asset.AssetGroup - - FetchErrs bool -} - -func NewMockAssetSyncer() *MockAssetSyncer { - return &MockAssetSyncer{ - Assets: make(map[asset.ID]*asset.AssetGroup), - FetchedAssets: make(chan *asset.AssetGroup, 1), - FetchErrs: false, - } -} - -func (m *MockAssetSyncer) AddAsset(newAsset asset.Asset) { - assetGroup := &asset.AssetGroup{ - Genesis: &newAsset.Genesis, - } - - if newAsset.GroupKey != nil { - assetGroup.GroupKey = newAsset.GroupKey - } - - m.Assets[newAsset.ID()] = assetGroup -} - -func (m *MockAssetSyncer) RemoveAsset(id asset.ID) { - delete(m.Assets, id) -} - -func (m *MockAssetSyncer) FetchAsset(id asset.ID) (*asset.AssetGroup, error) { - bookDelay := time.Millisecond * 25 - - assetGroup, ok := m.Assets[id] - switch { - case ok: - // Broadcast the fetched asset so it can be added to the address - // book. - m.FetchedAssets <- assetGroup - - // Wait for the address book to be updated. - time.Sleep(bookDelay) - return assetGroup, nil - - case m.FetchErrs: - return nil, fmt.Errorf("failed to fetch asset info") - - default: - return nil, nil + internalKey, _ := dKey.UnwrapOrErr( + fmt.Errorf("delegation key unexpectedly absent"), + ) + expected, err := mockPreCommitTxOut(*internalKey.PubKey) + if err != nil { + return err } -} -func (m *MockAssetSyncer) SyncAssetInfo(_ context.Context, - s asset.Specifier) error { - - if !s.HasId() { - return fmt.Errorf("no asset ID provided") + for i, txOut := range funded.Pkt.UnsignedTx.TxOut { + if int32(i) == funded.ChangeOutputIndex { + continue + } + if !bytes.Equal(txOut.PkScript, expected.PkScript) { + continue + } + bip32, trBip32 := tappsbt.Bip32DerivationFromKeyDesc( + internalKey, m.chainParams.HDCoinType, + ) + pOut := &funded.Pkt.Outputs[i] + pOut.Bip32Derivation = []*psbt.Bip32Derivation{bip32} + pOut.TaprootBip32Derivation = + []*psbt.TaprootBip32Derivation{trBip32} + pOut.TaprootInternalKey = trBip32.XOnlyPubKey + return nil } - - _, err := m.FetchAsset(*s.UnwrapIdToPtr()) - return err -} - -func (m *MockAssetSyncer) EnableAssetSync(_ context.Context, - groupInfo *asset.AssetGroup) error { - return nil } -type MockKeyRing struct { - mock.Mock - - sync.RWMutex - - KeyIndex uint32 - - Keys map[keychain.KeyLocator]*btcec.PrivateKey - - // deriveNextKeyCallCount is used to track the number of calls to - // DeriveNextKey. - deriveNextKeyCallCount atomic.Uint64 -} - -var _ KeyRing = (*MockKeyRing)(nil) +func (m mockSupplyCommitAugmenter) BindData(_ context.Context, + batch *MintingBatch) (fn.Option[PreCommitBindData], error) { -func NewMockKeyRing() *MockKeyRing { - keyRing := &MockKeyRing{ - Keys: make(map[keychain.KeyLocator]*btcec.PrivateKey), + var zero fn.Option[PreCommitBindData] + if batch == nil || batch.GenesisPacket == nil { + return zero, nil } - - keyRing.On( - "DeriveNextKey", mock.Anything, - keychain.KeyFamily(asset.TaprootAssetsKeyFamily), - ).Return(keychain.KeyDescriptor{}, nil) - - keyRing.On( - "DeriveNextTaprootAssetKey", mock.Anything, - ).Return(keychain.KeyDescriptor{}, nil) - - return keyRing -} - -// DeriveNextTaprootAssetKey attempts to derive the *next* key within the -// Taproot Asset key family. -func (m *MockKeyRing) DeriveNextTaprootAssetKey( - ctx context.Context) (keychain.KeyDescriptor, error) { - - // No need to lock mutex here, DeriveNextKey does that for us. - m.Called(ctx) - - return m.DeriveNextKey(ctx, asset.TaprootAssetsKeyFamily) -} - -func (m *MockKeyRing) DeriveNextKey(ctx context.Context, - keyFam keychain.KeyFamily) (keychain.KeyDescriptor, error) { - - m.Lock() - defer func() { - m.KeyIndex++ - m.Unlock() - }() - - m.Called(ctx, keyFam) - m.deriveNextKeyCallCount.Add(1) - - select { - case <-ctx.Done(): - return keychain.KeyDescriptor{}, fmt.Errorf("shutting down") - default: + dKey := mockDelegationKey(batch) + if dKey.IsNone() { + return zero, nil } - - priv, err := btcec.NewPrivateKey() + internalKey, _ := dKey.UnwrapOrErr( + fmt.Errorf("delegation key unexpectedly absent"), + ) + expected, err := mockPreCommitTxOut(*internalKey.PubKey) if err != nil { - return keychain.KeyDescriptor{}, err + return zero, err } - loc := keychain.KeyLocator{ - Index: m.KeyIndex, - Family: keyFam, - } - - m.Keys[loc] = priv - - desc := keychain.KeyDescriptor{ - PubKey: priv.PubKey(), - KeyLocator: loc, - } - - return desc, nil -} - -func (m *MockKeyRing) IsLocalKey(ctx context.Context, - d keychain.KeyDescriptor) bool { - - m.Lock() - defer m.Unlock() - - m.Called(ctx, d) - - priv, ok := m.Keys[d.KeyLocator] - if ok && priv.PubKey().IsEqual(d.PubKey) { - return true - } - - for _, key := range m.Keys { - if key.PubKey().IsEqual(d.PubKey) { - return true + tx := batch.GenesisPacket.Pkt.UnsignedTx + for i, txOut := range tx.TxOut { + if int32(i) == batch.GenesisPacket.ChangeOutputIndex { + continue } + if !bytes.Equal(txOut.PkScript, expected.PkScript) { + continue + } + return fn.Some(PreCommitBindData{ + OutputIndex: uint32(i), + InternalKey: internalKey, + GroupKey: mockGroupKey(batch), + }), nil } - - return false + return zero, nil } -func (m *MockKeyRing) PubKeyAt(t *testing.T, idx uint32) *btcec.PublicKey { - m.Lock() - defer m.Unlock() - - loc := keychain.KeyLocator{ - Index: idx, - Family: asset.TaprootAssetsKeyFamily, - } - - priv, ok := m.Keys[loc] - if !ok { - t.Fatalf("script key not found at index %d", idx) - } +func (mockSupplyCommitAugmenter) OnBatchConfirmed(_ context.Context, + _ *MintingBatch, _, _ []*asset.Asset, _ proof.AssetProofs) error { - return priv.PubKey() + return nil } -func (m *MockKeyRing) ScriptKeyAt(t *testing.T, idx uint32) asset.ScriptKey { - m.Lock() - defer m.Unlock() +var _ GenesisTxAugmenter = mockSupplyCommitAugmenter{} - loc := keychain.KeyLocator{ - Index: idx, - Family: asset.TaprootAssetsKeyFamily, - } +// MockBindDataForBatch is a test helper that returns the +// PreCommitBindData payload the mock supply-commit augmenter +// would produce for the given batch, or fn.None if there is no +// supply-commit pre-commitment to persist. tapdb tests use it +// when they need to plumb a pre-commit payload through the +// BatchStore binding API. +func MockBindDataForBatch( + batch *MintingBatch) fn.Option[PreCommitBindData] { - priv, ok := m.Keys[loc] - if !ok { - t.Fatalf("script key not found at index %d", idx) - } - - return asset.NewScriptKeyBip86(keychain.KeyDescriptor{ - KeyLocator: loc, - PubKey: priv.PubKey(), - }) + aug := mockSupplyCommitAugmenter{chainParams: address.TestNet3Tap} + bind, _ := aug.BindData(context.Background(), batch) + return bind } -func (m *MockKeyRing) DeriveSharedKey(_ context.Context, key *btcec.PublicKey, - locator *keychain.KeyLocator) ([sha256.Size]byte, error) { - - m.Lock() - defer m.Unlock() - - if locator == nil { - return [32]byte{}, fmt.Errorf("locator is nil") - } - - priv, ok := m.Keys[*locator] - if !ok { - return [32]byte{}, fmt.Errorf("script key not found at index "+ - "%d", locator.Index) - } - - ecdh := &keychain.PrivKeyECDH{ - PrivKey: priv, +// RandSeedlings creates a new set of random seedlings for testing. +func RandSeedlings(t testing.TB, numSeedlings int) map[string]*Seedling { + seedlings := make(map[string]*Seedling) + for i := 0; i < numSeedlings; i++ { + metaBlob := test.RandBytes(32) + assetName := hex.EncodeToString(test.RandBytes(32)) + scriptKey, _ := test.RandKeyDesc(t) + seedlings[assetName] = &Seedling{ + // For now, we only test the v0 and v1 versions. + AssetVersion: asset.Version(test.RandIntn(2)), + AssetType: asset.Type(test.RandIntn(2)), + AssetName: assetName, + Meta: &proof.MetaReveal{ + Data: metaBlob, + }, + Amount: uint64(test.RandInt[uint32]()), + ScriptKey: asset.NewScriptKeyBip86(scriptKey), + EnableEmission: test.RandBool(), + } } - return ecdh.ECDH(key) -} -// DeriveNextKeyCallCount returns the number of calls to DeriveNextKey. This is -// useful in tests to assert that the key ring was used as expected in -// concurrent scenarios. -func (m *MockKeyRing) DeriveNextKeyCallCount() int { - return int(m.deriveNextKeyCallCount.Load()) -} - -// ResetDeriveNextKeyCallCount resets the call counter for DeriveNextKey to -// zero. This is useful in tests to ensure a clean state for assertions. -func (m *MockKeyRing) ResetDeriveNextKeyCallCount() { - m.deriveNextKeyCallCount.Store(0) + return seedlings } type MockGenSigner struct { - KeyRing *MockKeyRing + KeyRing *tapnodemock.KeyRing failSigning atomic.Bool } -func NewMockGenSigner(keyRing *MockKeyRing) *MockGenSigner { +func NewMockGenSigner(keyRing *tapnodemock.KeyRing) *MockGenSigner { return &MockGenSigner{ KeyRing: keyRing, } @@ -1145,7 +592,7 @@ func (m *MockProofWatcher) DefaultUpdateCallback() proof.UpdateCallback { } type FallibleTapscriptTreeMgr struct { - store MintingStore + store asset.TapscriptTreeManager FailLoad, FailStore bool } @@ -1175,7 +622,9 @@ func (mgr FallibleTapscriptTreeMgr) StoreTapscriptTree(ctx context.Context, return mgr.store.StoreTapscriptTree(ctx, treeNodes) } -func NewFallibleTapscriptTreeMgr(store MintingStore) FallibleTapscriptTreeMgr { +func NewFallibleTapscriptTreeMgr( + store asset.TapscriptTreeManager) FallibleTapscriptTreeMgr { + return FallibleTapscriptTreeMgr{ store: store, } diff --git a/tapgarden/planter.go b/tapgarden/planter.go index 3a8cee7efa..b063d2ae70 100644 --- a/tapgarden/planter.go +++ b/tapgarden/planter.go @@ -3,6 +3,7 @@ package tapgarden import ( "bytes" "context" + "encoding/hex" "errors" "fmt" "slices" @@ -14,46 +15,42 @@ 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" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/tappsbt" "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/tapsend" - "github.com/lightninglabs/taproot-assets/universe" lfn "github.com/lightningnetwork/lnd/fn/v2" - "github.com/lightningnetwork/lnd/keychain" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "golang.org/x/exp/maps" ) -// MintSupplyCommitter is used during minting to update the on-chain supply -// commitment of a new minted asset. -type MintSupplyCommitter interface { - // SendMintEvent sends a mint event to the supply commitment state - // machine. - SendMintEvent(ctx context.Context, assetSpec asset.Specifier, - leafKey universe.UniqueLeafKey, issuanceProof universe.Leaf, - mintBlockHeight uint32) error -} - // GardenKit holds the set of shared fundamental interfaces all sub-systems of // the tapgarden need to function. type GardenKit struct { // Wallet is an active on chain wallet for the target chain. - Wallet WalletAnchor + Wallet tapnode.WalletAnchor // ChainBridge provides access to the chain for confirmation // notification, and other block related actions. - ChainBridge ChainBridge + ChainBridge tapnode.ChainBridge - // Log stores the current state of any active batch, throughout the - // various states the planter will progress it through. - Log MintingStore + // BatchStore persists the lifecycle of minting batches. Both the + // planter and the cultivators it spawns drive batches through their + // states by writing to this store. + BatchStore BatchStore + + // MintingRefs exposes read-only lookups for the reference data + // (script keys, asset metas, group keys, delegation keys) that + // the planter consults when validating seedlings and that the + // cultivator consults when verifying proofs via GenGroupVerifier + // and GenGroupAnchorVerifier. + MintingRefs MintingRefReader // TreeStore provides access to optional tapscript trees used with // script keys, minting output keys, and group keys. @@ -62,7 +59,7 @@ type GardenKit struct { // KeyRing is used for obtaining internal keys for the anchor // transaction, as well as script keys for each asset and group keys // for assets created that permit ongoing emission. - KeyRing KeyRing + KeyRing tapnode.KeyRing // GenSigner is used to generate signatures for the key group tweaked // by the genesis point when creating assets that permit on going @@ -80,30 +77,27 @@ type GardenKit struct { // ProofFiles stores the set of flat proof files. ProofFiles proof.Archiver - // Universe is used to register new asset issuance with a local/remote - // base universe instance. - Universe universe.BatchRegistrar + // MintProofPublisher ships freshly-minted (or re-organized) proofs + // to a downstream distributor (e.g. a local/remote universe). If + // nil, no proofs are published; the cultivator's local archival + // path is unaffected. + MintProofPublisher MintProofPublisher // ProofWatcher is used to watch new proofs for their anchor transaction // to be confirmed safely with a minimum number of confirmations. ProofWatcher proof.Watcher - // UniversePushBatchSize is the number of minted items to push to the - // local universe in a single batch. - UniversePushBatchSize int - // IgnoreChecker is an optional function that can be used to check if // a proof should be ignored. IgnoreChecker lfn.Option[proof.IgnoreChecker] - // MintSupplyCommitter is used to commit the minting of new assets to - // the supply commitment state machine. - MintSupplyCommitter MintSupplyCommitter - - // DelegationKeyChecker is used to verify that we control the delegation - // key for a given asset, which is required for creating supply - // commitments. - DelegationKeyChecker address.DelegationKeyChecker + // GenesisTxAugmenter is an optional hook that lets an external + // substance (e.g. supply commitment) participate in batch + // minting without tapgarden having to know what that + // substance is doing. When unset, all augmenter call sites + // degrade to NoOpAugmenter and minting proceeds without any + // extra outputs, validation, or post-confirmation events. + GenesisTxAugmenter GenesisTxAugmenter } // PlanterConfig is the main config for the ChainPlanter. @@ -128,38 +122,54 @@ type PlanterConfig struct { // BatchKey is a type alias for a serialized public key. type BatchKey = asset.SerializedKey -// CancelResp is the response from a caretaker attempting to cancel a batch. +// CancelResp is the response from a cultivator attempting to cancel a batch. type CancelResp struct { cancelAttempted bool err error } -type stateRequest interface { - Resolve(any) - Error(error) - Return(any, error) - Type() reqType - Param() any +// cancelReq is a cancellation request sent from the planter to a +// cultivator. Each request carries its own response channel, so the +// cultivator's reply is causally bound to this specific call and cannot +// be confused with the reply to any other in-flight or future +// cancellation. The previous protocol used two shared channels per +// cultivator (CancelReqChan + CancelRespChan), which was only correct +// because the gardener serialized all cancel calls -- a discipline, +// not a property the protocol itself guaranteed. +type cancelReq struct { + // resp is the unique reply channel for this request. The + // cultivator writes the result here exactly once. Buffer size 1 + // so the cultivator never blocks if the planter has already + // given up (e.g. on c.Quit). + resp chan<- CancelResp } -type stateReq[T any] struct { - resp chan T - err chan error - reqType reqType -} +// stateReq is a request executed inside the gardener loop. The +// closure captures its own response channel and any parameters; the +// loop simply invokes it. This replaces the prior stateRequest +// interface + stateReq[T] / stateParamReq[T,S] generics + reqType +// enum + dispatch switch. Closures preserve the per-call binding +// between request and response without any runtime type assertions. +type stateReq func() -func newStateReq[T any](req reqType) *stateReq[T] { - return &stateReq[T]{ - resp: make(chan T, 1), - err: make(chan error, 1), - reqType: req, - } +// stateResult is what a stateReq closure writes back to its caller. +// One buffered channel of stateResult[T] per call replaces the prior +// (resp, err) channel pair. +type stateResult[T any] struct { + val T + err error } -type stateParamReq[T, S any] struct { - stateReq[T] +// stateOk constructs a successful stateResult[T] with the given +// value. +func stateOk[T any](v T) stateResult[T] { + return stateResult[T]{val: v} +} - param S +// stateErr constructs a failing stateResult[T] with the given +// error. +func stateErr[T any](err error) stateResult[T] { + return stateResult[T]{err: err} } // ListBatchesParams are the options available to specify which minting batches @@ -170,10 +180,14 @@ type ListBatchesParams struct { } // PendingAssetGroup is the group key request and virtual TX necessary to -// produce an asset group witness for a seedling. +// produce an asset group witness for a seedling. The joining principle is +// "a request together with the virtual tx that fulfils it." type PendingAssetGroup struct { - asset.GroupKeyRequest - asset.GroupVirtualTx + // KeyRequest is the request to create the asset group. + KeyRequest asset.GroupKeyRequest + + // VirtualTx is the virtual tx that fulfils the KeyRequest. + VirtualTx asset.GroupVirtualTx } // PSBT returns a PSBT packet that can be used to create a group witness for the @@ -182,22 +196,22 @@ func (p *PendingAssetGroup) PSBT( params chaincfg.Params) (*psbt.Packet, error) { // Generate PSBT equivalent of the group virtual tx. - packet, err := psbt.NewFromUnsignedTx(&p.GroupVirtualTx.Tx) + packet, err := psbt.NewFromUnsignedTx(&p.VirtualTx.Tx) if err != nil { return nil, fmt.Errorf("error producing group virtual PSBT "+ "from tx: %w", err) } vIn := &packet.Inputs[0] - vIn.WitnessUtxo = &p.GroupVirtualTx.PrevOut - vIn.TaprootMerkleRoot = p.GroupKeyRequest.TapscriptRoot + vIn.WitnessUtxo = &p.VirtualTx.PrevOut + vIn.TaprootMerkleRoot = p.KeyRequest.TapscriptRoot vIn.TaprootInternalKey = schnorr.SerializePubKey( - p.GroupKeyRequest.RawKey.PubKey, + p.KeyRequest.RawKey.PubKey, ) switch { - case p.GroupKeyRequest.ExternalKey.IsSome(): - externalKey := p.GroupKeyRequest.ExternalKey.UnwrapToPtr() + case p.KeyRequest.ExternalKey.IsSome(): + externalKey := p.KeyRequest.ExternalKey.UnwrapToPtr() pubKey, err := externalKey.PubKey() if err != nil { return nil, fmt.Errorf("error deriving public key "+ @@ -234,7 +248,7 @@ func (p *PendingAssetGroup) PSBT( // TODO(guggero): Make this switch dependent on the non-spend // leaf version, once we allow the user to configure that. if true { - assetID := p.AnchorGen.ID() + assetID := p.KeyRequest.AnchorGen.ID() numsXPub, numsKey, err := asset.TweakedNumsKey(assetID) if err != nil { return nil, fmt.Errorf("error deriving nums "+ @@ -290,7 +304,7 @@ func (p *PendingAssetGroup) PSBT( default: bip32, trBip32 := tappsbt.Bip32DerivationFromKeyDesc( - p.GroupKeyRequest.RawKey, params.HDCoinType, + p.KeyRequest.RawKey, params.HDCoinType, ) vIn.Bip32Derivation = []*psbt.Bip32Derivation{bip32} vIn.TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{ @@ -338,63 +352,9 @@ type SealParams struct { SignedGroupVirtualPsbts []psbt.Packet } -func newStateParamReq[T, S any](req reqType, param S) *stateParamReq[T, S] { - return &stateParamReq[T, S]{ - stateReq: *newStateReq[T](req), - param: param, - } -} - -func (s *stateReq[T]) Resolve(resp any) { - s.resp <- resp.(T) - close(s.err) -} - -func (s *stateReq[T]) Error(err error) { - s.err <- err - close(s.resp) -} - -func (s *stateReq[T]) Return(resp any, err error) { - s.resp <- resp.(T) - s.err <- err -} - -func (s *stateReq[T]) Type() reqType { - return s.reqType -} - -func (s *stateReq[T]) Param() any { - return nil -} - -func (s *stateParamReq[T, S]) Param() any { - return s.param -} - -func typedParam[T any](req stateRequest) (*T, error) { - if param, ok := req.Param().(T); ok { - return ¶m, nil - } - - return nil, fmt.Errorf("invalid type") -} - -type reqType uint8 - -const ( - reqTypePendingBatch = iota - reqTypeNumActiveBatches - reqTypeListBatches - reqTypeFinalizeBatch - reqTypeCancelBatch - reqTypeFundBatch - reqTypeSealBatch -) - // ChainPlanter is responsible for accepting new incoming requests to create // taproot assets. The planter will periodically batch those requests into a new -// minting batch, which is handed off to a caretaker. While batches are +// minting batch, which is handed off to a cultivator. While batches are // progressing through maturity the planter will be responsible for sending // notifications back to the relevant caller. type ChainPlanter struct { @@ -410,19 +370,20 @@ type ChainPlanter struct { // these will exist at any given time. pendingBatch *MintingBatch - // caretakers maps a batch key (which is used as the internal key for - // the transaction that mints the assets) to the caretaker that will + // cultivators maps a batch key (which is used as the internal key for + // the transaction that mints the assets) to the cultivator that will // progress the batch through the final phases. - caretakers map[BatchKey]*BatchCaretaker + cultivators map[BatchKey]*Cultivator - // completionSignals is a channel used to allow the caretakers to + // completionSignals is a channel used to allow the cultivators to // signal that the batch is fully final, allowing garbage collection of // any relevant resources. completionSignals chan BatchKey // stateReqs is the channel that any outside requests for the state of - // the planter will come across. - stateReqs chan stateRequest + // the planter will come across. Each request is a closure that runs + // inside the gardener loop with full access to ChainPlanter state. + stateReqs chan stateReq // subscribers is a map of components that want to be notified on new // events, keyed by their subscription ID. @@ -440,10 +401,10 @@ type ChainPlanter struct { func NewChainPlanter(cfg PlanterConfig) *ChainPlanter { return &ChainPlanter{ cfg: cfg, - caretakers: make(map[BatchKey]*BatchCaretaker), + cultivators: make(map[BatchKey]*Cultivator), completionSignals: make(chan BatchKey), seedlingReqs: make(chan *Seedling), - stateReqs: make(chan stateRequest), + stateReqs: make(chan stateReq), subscribers: make(map[uint64]*fn.EventReceiver[fn.Event]), ContextGuard: &fn.ContextGuard{ DefaultTimeout: DefaultTimeout, @@ -452,22 +413,44 @@ func NewChainPlanter(cfg PlanterConfig) *ChainPlanter { } } -// newCaretakerForBatch creates a new BatchCaretaker for a given batch and -// inserts it into the caretaker map. -func (c *ChainPlanter) newCaretakerForBatch(batch *MintingBatch, - feeRate *chainfee.SatPerKWeight) *BatchCaretaker { +// augmenter returns the GenesisTxAugmenter configured on the +// planter's GardenKit, or a no-op augmenter when none was wired. +// Call sites can invoke augmenter methods without nil-checking. +func (c *ChainPlanter) augmenter() GenesisTxAugmenter { + if c.cfg.GenesisTxAugmenter == nil { + return NoOpAugmenter{} + } + return c.cfg.GenesisTxAugmenter +} + +// newCultivatorForBatch creates a new Cultivator for a given batch and +// inserts it into the cultivator map. +func (c *ChainPlanter) newCultivatorForBatch(batch *MintingBatch, + feeRate *chainfee.SatPerKWeight) *Cultivator { batchKey := asset.ToSerialized(batch.BatchKey.PubKey) - batchConfig := &BatchCaretakerConfig{ + batchConfig := &CultivatorConfig{ Batch: batch, - GardenKit: c.cfg.GardenKit, + GardenKit: &c.cfg.GardenKit, BroadcastCompleteChan: make(chan struct{}, 1), BroadcastErrChan: make(chan error, 1), + // SignalCompletion is invoked from the cultivator goroutine + // just before it returns. The gardener reads + // c.completionSignals from its main select; if Stop has + // already closed c.Quit, the gardener is no longer in that + // select and the unbuffered send would block forever, + // hanging cultivator.Stop's Wg.Wait inside stopCultivators. + // Selecting on c.Quit makes the send abandonable, which is + // safe: on shutdown the planter does not need the + // completion notification (it is stopping the cultivator + // anyway). SignalCompletion: func() { - c.completionSignals <- batchKey + select { + case c.completionSignals <- batchKey: + case <-c.Quit: + } }, - CancelReqChan: make(chan struct{}, 1), - CancelRespChan: make(chan CancelResp, 1), + CancelReqChan: make(chan cancelReq, 1), UpdateMintingProofs: c.updateMintingProofs, PublishMintEvent: c.publishSubscriberEvent, ErrChan: c.cfg.ErrChan, @@ -476,10 +459,10 @@ func (c *ChainPlanter) newCaretakerForBatch(batch *MintingBatch, batchConfig.BatchFeeRate = feeRate } - caretaker := NewBatchCaretaker(batchConfig) - c.caretakers[batchKey] = caretaker + cultivator := NewCultivator(batchConfig) + c.cultivators[batchKey] = cultivator - return caretaker + return cultivator } // Start starts the ChainPlanter and any goroutines it needs to carry out its @@ -493,14 +476,15 @@ func (c *ChainPlanter) Start() error { // fully finalized (minting transaction well confirmed on // chain). This includes batches that were still pending before // our last restart, so were never frozen in the first place. - // The caretaker will handle progressing the batch to the + // The cultivator will handle progressing the batch to the // frozen state, and beyond. // // TODO(roasbeef): instead do RBF here? so only a single // pending batch at a time? but would end up changing assetIDs. ctx, cancel := c.WithCtxQuit() defer cancel() - nonFinalBatches, err := c.cfg.Log.FetchNonFinalBatches(ctx) + nonFinalBatches, err := + c.cfg.BatchStore.FetchNonFinalBatches(ctx) if err != nil { startErr = err return @@ -509,8 +493,21 @@ func (c *ChainPlanter) Start() error { log.Infof("Retrieved %v non-finalized batches from DB", len(nonFinalBatches)) + // Enforce the singleton invariant: at most one batch may + // be in BatchStatePending or BatchStateFrozen at a time. + // The DB constraint added in migration 000060 should + // already make this impossible, but a legacy DB that was + // migrated post-population, or a manually-modified row, + // could still violate it. Surfacing the error here gives + // the operator a human-readable diagnostic instead of an + // opaque SQL one later. + if err := checkSingletonInvariant(nonFinalBatches); err != nil { + startErr = err + return + } + // Now for each of these non-final batches, we'll make a new - // caretaker which'll handle progressing each batch to + // cultivator which'll handle progressing each batch to // completion. We'll skip batches that were cancelled. for _, batch := range nonFinalBatches { batchState := batch.State() @@ -536,8 +533,8 @@ func (c *ChainPlanter) Start() error { cancelBatch := func() { log.Warnf("Marking batch as cancelled (%x)", batchKey) - err := c.cfg.Log.UpdateBatchState( - ctx, batch.BatchKey.PubKey, + err := c.cfg.BatchStore.UpdateBatchState( + ctx, batch, BatchStateSeedlingCancelled, ) @@ -553,10 +550,10 @@ func (c *ChainPlanter) Start() error { // TODO(jhb): Log manual fee rates? // If the batch was still pending, or if batch // finalization was interrupted, it may need to be - // funded or sealed before being assigned a caretaker. + // funded or sealed before being assigned a cultivator. // A batch that was already properly frozen at this // point should not be modified before being assigned a - // caretaker. + // cultivator. if batchState == BatchStatePending || batchState == BatchStateFrozen { @@ -568,7 +565,7 @@ func (c *ChainPlanter) Start() error { if !batch.IsFunded() { log.Infof("Funding non-finalized "+ "batch from DB (%x)", batchKey) - fundErr = c.fundBatch( + fundErr = c.applyFundingToBatch( ctx, FundParams{}, batch, ) } @@ -608,12 +605,12 @@ func (c *ChainPlanter) Start() error { // Any pending batch that was funded and sealed // can now be set as frozen. We are already not - // able to add new seedlings to the batch. - batch.UpdateState(BatchStateFrozen) - - err := c.cfg.Log.UpdateBatchState( - ctx, batch.BatchKey.PubKey, - BatchStateFrozen, + // able to add new seedlings to the batch. The + // store call below moves both the on-disk row + // and the in-memory mirror atomically; if it + // fails, neither has moved. + err := c.cfg.BatchStore.UpdateBatchState( + ctx, batch, BatchStateFrozen, ) if err != nil { log.Warnf("Failed to update batch "+ @@ -624,15 +621,15 @@ func (c *ChainPlanter) Start() error { } } - log.Infof("Launching ChainCaretaker(%x)", batchKey) - caretaker := c.newCaretakerForBatch(batch, nil) - if err := caretaker.Start(); err != nil { + log.Infof("Launching Cultivator(%x)", batchKey) + cultivator := c.newCultivatorForBatch(batch, nil) + if err := cultivator.Start(); err != nil { startErr = err return } } - // With all the caretakers for each minting batch launched, + // With all the cultivators for each minting batch launched, // we'll start up the main gardener goroutine so we can accept // new minting requests. c.Wg.Add(1) @@ -664,15 +661,15 @@ func (c *ChainPlanter) Stop() error { return stopErr } -// stopCaretakers attempts to gracefully stop all the active caretakers. -func (c *ChainPlanter) stopCaretakers() { - for batchKey, caretaker := range c.caretakers { - log.Debugf("Stopping ChainCaretaker(%x)", batchKey[:]) +// stopCultivators attempts to gracefully stop all the active cultivators. +func (c *ChainPlanter) stopCultivators() { + for batchKey, cultivator := range c.cultivators { + log.Debugf("Stopping Cultivator(%x)", batchKey[:]) - if err := caretaker.Stop(); err != nil { + if err := cultivator.Stop(); err != nil { // TODO(roasbeef): continue and stop the rest // of them? - log.Warnf("Unable to stop ChainCaretaker(%x)", + log.Warnf("Unable to stop Cultivator(%x)", batchKey[:]) return } @@ -709,7 +706,11 @@ func (c *ChainPlanter) newBatch() (*MintingBatch, error) { Seedlings: make(map[string]*Seedling), AssetMetas: make(AssetMetas), } - newBatch.UpdateState(BatchStatePending) + // The batch is private to this caller until CommitMintingBatch + // succeeds, so setting the in-memory state directly here does not + // open a two-truth window: the next DB call is the first to publish + // the row, with state=Pending. + newBatch.setState(BatchStatePending) return newBatch, nil } @@ -751,75 +752,35 @@ type AnchorTxOutputIndexes struct { // 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) { +// anchorTxOutputIndexes scans the funded anchor PSBT for the asset +// anchor output and the wallet-provided change output. Any +// additional outputs (e.g. those contributed by the +// GenesisTxAugmenter) are located by the augmenter itself. +func anchorTxOutputIndexes( + fundedPsbt tapsend.FundedPsbt) (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 + break } - - // 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"), ) @@ -827,112 +788,12 @@ func anchorTxOutputIndexes(fundedPsbt tapsend.FundedPsbt, return zero, err } - // 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 } -// DelegationKey is a type alias for a key descriptor used as a supply -// commitment delegation key. -type DelegationKey = keychain.KeyDescriptor - -// fetchDelegationKey retrieves the delegation key from the given batch. -func fetchDelegationKey(pendingBatch *MintingBatch) (fn.Option[DelegationKey], - error) { - - var zero fn.Option[DelegationKey] - - // 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 batch has at least one seedling. - if len(pendingBatch.Seedlings) == 0 { - return zero, fmt.Errorf("failed to derive pre-commitment " + - "delegation key: no seedlings in batch") - } - - // Retrieve batch anchor seedling. - var groupAnchorSeedling fn.Option[Seedling] - for _, seedling := range pendingBatch.Seedlings { - if seedling.GroupAnchor == nil { - groupAnchorSeedling = fn.Some(*seedling) - break - } - - groupAnchorSeedling = - fn.Some(*pendingBatch.Seedlings[*seedling.GroupAnchor]) - break - } - - delegationKeyDesc := fn.MapOptionZ(groupAnchorSeedling, - func(s Seedling) fn.Option[keychain.KeyDescriptor] { - return s.DelegationKey - }, - ) - - return delegationKeyDesc, nil -} - -// fetchPreCommitGroupKey retrieves the group key associated with the -// pre-commitment output from the batch, if the pre-commitment feature is -// enabled and a group key is available. -func fetchPreCommitGroupKey( - pendingBatch *MintingBatch) (fn.Option[btcec.PublicKey], error) { - - var zero fn.Option[btcec.PublicKey] - - // Return None if no pending batch is provided. - if pendingBatch == nil { - return zero, nil - } - - // If universe commitments are disabled, there is no group key available - // from the batch to associate with the pre-commitment. Therefore, we - // return None. - if !pendingBatch.SupplyCommitments { - return zero, nil - } - - // If the batch has no seedlings, we can't derive a group key. - if len(pendingBatch.Seedlings) == 0 { - return zero, nil - } - - // Retrieve batch anchor seedling. - var groupAnchorSeedling Seedling - for _, seedling := range pendingBatch.Seedlings { - // If the seedling has no group anchor, we can use it as the - // group anchor seedling. - if seedling.GroupAnchor == nil { - groupAnchorSeedling = *seedling - break - } - - groupAnchorSeedling = - *pendingBatch.Seedlings[*seedling.GroupAnchor] - break - } - - // If the group info is unset, then there is no pre-commitment group pub - // key defined in the batch. - if groupAnchorSeedling.GroupInfo == nil { - return zero, nil - } - - return fn.Some(groupAnchorSeedling.GroupInfo.GroupPubKey), 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. @@ -1017,42 +878,41 @@ type WalletFundPsbt = func(ctx context.Context, // (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, chainParams address.ChainParams, - pendingBatch *MintingBatch, - walletFundPsbt WalletFundPsbt) (FundedMintAnchorPsbt, error) { +func fundGenesisPsbt(ctx context.Context, _ address.ChainParams, + pendingBatch *MintingBatch, walletFundPsbt WalletFundPsbt, + augmenter GenesisTxAugmenter) (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 delegationKey fn.Option[DelegationKey] - if pendingBatch != nil && pendingBatch.SupplyCommitments { - delegationK, err := fetchDelegationKey(pendingBatch) - if err != nil { - return zero, fmt.Errorf("unable to create "+ - "pre-commitment output: %w", err) - } + if augmenter == nil { + augmenter = NoOpAugmenter{} + } - delegationKey = delegationK + // Ask the augmenter for any extra outputs to splice into + // the unfunded anchor PSBT (e.g. the pre-commitment output + // for the supply-commit substance). The augmenter returns + // nil/empty when it has nothing to contribute, in which + // case the genesis tx carries only the asset anchor output + // (plus a wallet-managed change output). + extraOuts, err := augmenter.ExtraOutputs(ctx, pendingBatch) + if err != nil { + return zero, fmt.Errorf("augmenter ExtraOutputs: %w", err) } - // Derive wire.TxOut from the pre-commitment delegation key, if - // available. The delegation key is used as the output internal key. + // The legacy funding helper accepted a single fn.Option for + // the pre-commitment output. The augmenter generalizes that + // to a list, but in practice only zero or one extra output + // is contributed today; route accordingly. var preCommitmentTxOut fn.Option[wire.TxOut] - if delegationKey.IsSome() { - txOut, err := fn.MapOptionZ( - delegationKey, - func(key DelegationKey) lfn.Result[wire.TxOut] { - return lfn.NewResult( - PreCommitTxOut(*key.PubKey), - ) - }, - ).Unpack() - if err != nil { - return zero, err - } - - preCommitmentTxOut = fn.Some(txOut) + switch len(extraOuts) { + case 0: + // no-op + case 1: + preCommitmentTxOut = fn.Some(extraOuts[0]) + default: + return zero, fmt.Errorf("augmenter returned %d extra "+ + "outputs; only zero or one is supported", + len(extraOuts)) } // Construct an unfunded anchor PSBT which will eventually become a @@ -1077,90 +937,29 @@ func fundGenesisPsbt(ctx context.Context, chainParams address.ChainParams, log.Tracef("GenesisPacket: %v", spew.Sdump(fundedGenesisPkt)) - // Classify anchor transaction output indexes. - anchorOutIndexes, err := anchorTxOutputIndexes( - fundedGenesisPkt, preCommitmentTxOut, - ) + // Classify anchor transaction output indexes. Tapgarden only + // tracks the asset anchor and change output indexes; the + // augmenter (if any) tracks its own outputs internally. + anchorOutIndexes, err := anchorTxOutputIndexes(fundedGenesisPkt) if err != nil { return zero, fmt.Errorf("unable to determine output indexes: "+ "%w", err) } - // The presence of a delegation key indicates that a pre-commitment - // output should exist. Therefore, the index of that output is expected - // to be defined at this point. - if delegationKey.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. - var preCommitOutIdx fn.Option[uint32] - if delegationKey.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 - } - - preCommitOutIdx = fn.Some(outIdx) - } - - // If there is a group pub key to associate with the pre-commitment - // output, fetch it now. - preCommitGroupPubKey, err := fetchPreCommitGroupKey(pendingBatch) - if err != nil { - return zero, fmt.Errorf("unable to fetch pre-commitment "+ - "group key: %w", err) - } - - // Formulate the pre-commitment output descriptor and finalize - // pre-commitment output in fundedGenesisPkt. - var preCommitOut fn.Option[PreCommitmentOutput] - if delegationKey.IsSome() { - dKey, err := delegationKey.UnwrapOrErr( - fmt.Errorf("code error: expected delegation key"), - ) - if err != nil { - return zero, err - } - - outIdx, err := preCommitOutIdx.UnwrapOrErr( - fmt.Errorf("code error: expected pre-commitment " + - "output index"), - ) - if err != nil { - return zero, err - } - - preCommitOut = fn.Some(NewPreCommitmentOutput( - outIdx, dKey, preCommitGroupPubKey, - )) - - // Finalize the pre-commitment output in the fundedGenesisPkt. - // An output is already present in the unsigned transaction, so - // we just need to set the corresponding fields in the PSBT. - bip32Derivation, trBip32Derivation := - tappsbt.Bip32DerivationFromKeyDesc( - dKey, chainParams.HDCoinType, - ) - - pOut := &fundedGenesisPkt.Pkt.Outputs[outIdx] - - pOut.Bip32Derivation = []*psbt.Bip32Derivation{bip32Derivation} - pOut.TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{ - trBip32Derivation, - } - pOut.TaprootInternalKey = trBip32Derivation.XOnlyPubKey + // Let the augmenter locate its own outputs in the funded + // PSBT and stamp any required metadata (BIP32 derivation + // for the pre-commitment output). + if err := augmenter.PostFund( + ctx, pendingBatch, &fundedGenesisPkt, + ); err != nil { + return zero, fmt.Errorf("augmenter PostFund: %w", err) } - // Formulate a funded minting anchor PSBT from the funded PSBT. + // Build the FundedMintAnchorPsbt. The augmenter is the + // source of truth for any extra outputs and their + // persistence payloads via BindData. fundedMintAnchorPsbt, err := NewFundedMintAnchorPsbt( - fundedGenesisPkt, anchorOutIndexes, preCommitOut, + fundedGenesisPkt, anchorOutIndexes, ) if err != nil { return zero, fmt.Errorf("unable to create funded minting "+ @@ -1416,7 +1215,7 @@ func buildGroupReqs(genesisPoint wire.OutPoint, assetOutputIndex uint32, // freezeMintingBatch freezes a target minting batch which means that no new // assets can be added to the batch. -func freezeMintingBatch(ctx context.Context, batchStore MintingStore, +func freezeMintingBatch(ctx context.Context, batchStore BatchStore, batch *MintingBatch) error { batchKey := batch.BatchKey.PubKey @@ -1429,10 +1228,53 @@ func freezeMintingBatch(ctx context.Context, batchStore MintingStore, // // TODO(roasbeef): assert not in some other state first? return batchStore.UpdateBatchState( - ctx, batchKey, BatchStateFrozen, + ctx, batch, BatchStateFrozen, ) } +// checkSingletonInvariant verifies that at most one batch in the +// supplied slice is in a pre-broadcast state (BatchStatePending or +// BatchStateFrozen). The invariant is enforced at the DB layer by +// the partial unique index added in migration 000060; this Go-level +// check exists as defense in depth and to produce a human-readable +// diagnostic naming the offending batch keys, since a raw SQL +// constraint error from a downstream insert is harder to act on. +// +// The check is called from ChainPlanter.Start() after +// FetchNonFinalBatches. If it fails, startup is aborted so the +// operator can investigate rather than letting the daemon run with +// ambiguous "which batch is current?" semantics. +func checkSingletonInvariant(batches []*MintingBatch) error { + var preBroadcastKeys []string + for _, batch := range batches { + switch batch.State() { + case BatchStatePending, BatchStateFrozen: + preBroadcastKeys = append( + preBroadcastKeys, + hex.EncodeToString( + batch.BatchKey.PubKey. + SerializeCompressed(), + ), + ) + + default: + // Post-broadcast or terminal states are outside the + // singleton invariant; they're not counted. + } + } + + if len(preBroadcastKeys) <= 1 { + return nil + } + + return fmt.Errorf("singleton pre-broadcast batch invariant "+ + "violated: found %d batches in BatchStatePending or "+ + "BatchStateFrozen (keys: %v); at most one is permitted. "+ + "Resolve by running `tapd --repair.cancel-duplicate-batches` "+ + "to cancel all but the most recent, then restart", + len(preBroadcastKeys), preBroadcastKeys) +} + // filterFinalizedBatches separates a set of batches into two sets based on // their batch state. func filterFinalizedBatches(batches []*MintingBatch) ([]*MintingBatch, @@ -1455,7 +1297,7 @@ func filterFinalizedBatches(batches []*MintingBatch) ([]*MintingBatch, // fetchFinalizedBatch fetches the assets of a batch in their genesis state, // given a batch populated with seedlings. -func fetchFinalizedBatch(ctx context.Context, batchStore MintingStore, +func fetchFinalizedBatch(ctx context.Context, refs MintingRefReader, archiver proof.Archiver, batch *MintingBatch) (*MintingBatch, error) { genesisPkt := batch.GenesisPacket @@ -1532,7 +1374,7 @@ func fetchFinalizedBatch(ctx context.Context, batchStore MintingStore, "script key") } - tweakedScriptKey, err := batchStore.FetchScriptKeyByTweakedKey( + tweakedScriptKey, err := refs.FetchScriptKeyByTweakedKey( ctx, sproutedAsset.ScriptKey.PubKey, ) if err != nil { @@ -1541,7 +1383,7 @@ func fetchFinalizedBatch(ctx context.Context, batchStore MintingStore, sproutedAsset.ScriptKey.TweakedScriptKey = tweakedScriptKey if sproutedAsset.GroupKey != nil { - assetGroup, err := batchStore.FetchGroupByGroupKey( + assetGroup, err := refs.FetchGroupByGroupKey( ctx, &sproutedAsset.GroupKey.GroupPubKey, ) if err != nil { @@ -1588,8 +1430,9 @@ func fetchFinalizedBatch(ctx context.Context, batchStore MintingStore, // ListBatches returns the single batch specified by the batch key, or the set // of batches not yet finalized on disk. -func listBatches(ctx context.Context, batchStore MintingStore, - archiver proof.Archiver, genBuilder asset.GenesisTxBuilder, +func listBatches(ctx context.Context, batchStore BatchStore, + refs MintingRefReader, archiver proof.Archiver, + genBuilder asset.GenesisTxBuilder, params ListBatchesParams) ([]*VerboseBatch, error) { var ( @@ -1624,7 +1467,7 @@ func listBatches(ctx context.Context, batchStore MintingStore, finalizedBatches := make([]*MintingBatch, 0, len(finalBatches)) for _, batch := range finalBatches { finalizedBatch, err := fetchFinalizedBatch( - ctx, batchStore, archiver, batch, + ctx, refs, archiver, batch, ) if err != nil { return nil, err @@ -1764,8 +1607,8 @@ func newVerboseBatch(currentBatch *MintingBatch, } seedling.PendingAssetGroup = &PendingAssetGroup{ - GroupKeyRequest: groupReqs[i], - GroupVirtualTx: genTXs[i], + KeyRequest: groupReqs[i], + VirtualTx: genTXs[i], } } @@ -1777,14 +1620,14 @@ func newVerboseBatch(currentBatch *MintingBatch, } // canCancelBatch returns a batch key if the planter is in a state where a batch -// can be cancelled. This does not account for the state of a caretaker that +// can be cancelled. This does not account for the state of a cultivator that // may be managing a batch. func (c *ChainPlanter) canCancelBatch() (*btcec.PublicKey, error) { - caretakerCount := len(c.caretakers) + cultivatorCount := len(c.cultivators) - switch caretakerCount { + switch cultivatorCount { case 0: - // If there are no caretakers, the only batch we could cancel + // If there are no cultivators, the only batch we could cancel // would be the current pending batch. if c.pendingBatch == nil { return nil, fmt.Errorf("no pending batch") @@ -1792,53 +1635,71 @@ func (c *ChainPlanter) canCancelBatch() (*btcec.PublicKey, error) { return c.pendingBatch.BatchKey.PubKey, nil case 1: - // TODO(jhb): Update once we support multiple batches. - // If there is exactly one caretaker, our pending batch should - // be empty. Otherwise, the batch to cancel is ambiguous. + // If there is exactly one cultivator, our pending batch + // must be empty for the cancel target to be + // unambiguous. Both can coexist legitimately: the + // cultivator may be handling a post-broadcast batch + // (Committed/Broadcast/Confirmed) while a fresh + // Pending/Frozen batch has begun in c.pendingBatch. The + // singleton constraint added in migration 000060 only + // applies to {Pending, Frozen}, so this case is real, + // not unreachable. if c.pendingBatch != nil { - return nil, fmt.Errorf("multiple batches not supported") + return nil, fmt.Errorf("cancellation ambiguous: " + + "pending batch and an active cultivator " + + "coexist; cancel-by-batch-key not " + + "implemented") } - batchKeys := maps.Keys(c.caretakers) + batchKeys := maps.Keys(c.cultivators) batchKey, err := btcec.ParsePubKey(batchKeys[0][:]) if err != nil { - return nil, fmt.Errorf("bad caretaker key: %w", err) + return nil, fmt.Errorf("bad cultivator key: %w", err) } return batchKey, nil default: } - // TODO(jhb): Update once we support multiple batches. - return nil, fmt.Errorf("multiple caretakers not supported") + // Multiple cultivators can coexist when several post-broadcast + // batches are awaiting confirmation in parallel. The singleton + // constraint added in migration 000060 does not forbid this; it + // only constrains {Pending, Frozen}. + return nil, fmt.Errorf("cancellation ambiguous: %d active "+ + "cultivators; cancel-by-batch-key not implemented", + cultivatorCount) } // cancelMintingBatch attempts to cancel a target minting batch. This can fail -// if the batch is managed by a caretaker and has already been broadcast. +// if the batch is managed by a cultivator and has already been broadcast. func (c *ChainPlanter) cancelMintingBatch(ctx context.Context, batchKey *btcec.PublicKey) error { - // The target batch may have already been assigned a caretaker. If so, - // we need to signal to the caretaker to cancel the batch. + // The target batch may have already been assigned a cultivator. If so, + // we need to signal to the cultivator to cancel the batch. batchKeySerialized := asset.ToSerialized(batchKey) - caretaker, ok := c.caretakers[batchKeySerialized] + cultivator, ok := c.cultivators[batchKeySerialized] if ok { log.Infof("Cancelling MintingBatch(key=%x, num_assets=%v)", - batchKeySerialized, len(caretaker.cfg.Batch.Seedlings)) + batchKeySerialized, len(cultivator.cfg.Batch.Seedlings)) - caretaker.cfg.CancelReqChan <- struct{}{} + // Per-call reply channel: the cultivator writes the result + // of this specific request here. Buffer size 1 so the + // cultivator never blocks if we abandon the wait via c.Quit. + respCh := make(chan CancelResp, 1) + cultivator.cfg.CancelReqChan <- cancelReq{resp: respCh} - // Wait for the caretaker to reply to the cancellation request. - // If the request succeeded, the caretaker will update the + // Wait for the cultivator to reply to the cancellation request. + // If the request succeeded, the cultivator will update the // batch state on disk. select { - case cancelResp := <-caretaker.cfg.CancelRespChan: - // If the caretaker returned a batch state, then batch + case cancelResp := <-respCh: + // If the cultivator returned a batch state, then batch // cancellation was possible and attempted. This means - // that the caretaker is shut down and the planter + // that the cultivator is shut down and the planter // must delete it. if cancelResp.cancelAttempted { - delete(c.caretakers, batchKeySerialized) + delete(c.cultivators, batchKeySerialized) } return cancelResp.err @@ -1851,10 +1712,12 @@ func (c *ChainPlanter) cancelMintingBatch(ctx context.Context, log.Infof("Cancelling MintingBatch(key=%x, num_assets=%v)", batchKeySerialized, len(c.pendingBatch.Seedlings)) - // If the target batch was not assigned a caretaker, we only need to - // update the batch state on disk to cancel it. - err := c.cfg.Log.UpdateBatchState( - ctx, batchKey, BatchStateSeedlingCancelled, + // If the target batch was not assigned a cultivator, the only + // non-cancelled batch in play is c.pendingBatch (canCancelBatch + // guarantees this). Update the batch state on disk and in memory in + // a single atomic call. + err := c.cfg.BatchStore.UpdateBatchState( + ctx, c.pendingBatch, BatchStateSeedlingCancelled, ) if err != nil { return fmt.Errorf("unable to cancel minting batch: %w", err) @@ -1871,8 +1734,8 @@ func (c *ChainPlanter) gardener() { defer c.Wg.Done() // When this exits due to the quit signal, we also want to stop all the - // active caretakers as well. - defer c.stopCaretakers() + // active cultivators as well. + defer c.stopCultivators() log.Infof("Gardener for ChainPlanter now active!") @@ -1918,230 +1781,37 @@ func (c *ChainPlanter) gardener() { } req.updates <- SeedlingUpdate{ PendingBatch: batchCopy, - NewState: MintingStateSeed, } - // A caretaker has finished processing their batch to full + // A cultivator has finished processing their batch to full // Taproot Asset maturity. We'll clean up our local state, and // signal that it can exit. // // TODO(roasbeef): also need a channel to send out additional // notifications? case batchKey := <-c.completionSignals: - caretaker, ok := c.caretakers[batchKey] + cultivator, ok := c.cultivators[batchKey] if !ok { - log.Warnf("Unknown caretaker: %x", batchKey[:]) + log.Warnf("Unknown cultivator: %x", batchKey[:]) continue } - log.Infof("ChainCaretaker(%x) has finished", batchKey[:]) + log.Infof("Cultivator(%x) has finished", batchKey[:]) - if err := caretaker.Stop(); err != nil { - log.Warnf("Unable to stop caretaker: %v", err) + if err := cultivator.Stop(); err != nil { + log.Warnf("Unable to stop cultivator: %v", err) } - delete(c.caretakers, batchKey) + delete(c.cultivators, batchKey) // TODO(roasbeef): send completion signal? - // A new request just came along to query our internal state. + // A new request just came along to query or mutate our + // internal state. Each request is a closure that already + // carries its own response channel and parameters; we + // simply invoke it in this goroutine. case req := <-c.stateReqs: - switch req.Type() { - case reqTypePendingBatch: - // Resolve a copy of the state to prevent - // potential concurrent read/write issues. - if c.pendingBatch == nil { - req.Resolve((*MintingBatch)(nil)) - } else { - req.Resolve(c.pendingBatch.Copy()) - } - - case reqTypeNumActiveBatches: - req.Resolve(len(c.caretakers)) - - case reqTypeListBatches: - listBatchesParams, err := - typedParam[ListBatchesParams](req) - if err != nil { - req.Error(fmt.Errorf("bad list batch "+ - "params: %w", err)) - break - } - - ctx, cancel := c.WithCtxQuit() - batches, err := listBatches( - ctx, c.cfg.Log, c.cfg.ProofFiles, - c.cfg.GenTxBuilder, *listBatchesParams, - ) - cancel() - if err != nil { - req.Error(err) - break - } - - req.Resolve(batches) - - case reqTypeFundBatch: - if c.pendingBatch != nil && - c.pendingBatch.IsFunded() { - - req.Error(fmt.Errorf("batch already " + - "funded")) - break - } - - fundReqParams, err := - typedParam[FundParams](req) - if err != nil { - req.Error(fmt.Errorf("bad fund "+ - "params: %w", err)) - break - } - - ctx, cancel := c.WithCtxQuit() - err = c.fundBatch( - ctx, *fundReqParams, c.pendingBatch, - ) - cancel() - if err != nil { - req.Error(fmt.Errorf("unable to fund "+ - "minting batch: %w", err)) - break - } - - // Formulate a verbose batch to return to the - // caller. - verboseBatch, err := newVerboseBatch( - c.pendingBatch, c.cfg.GenTxBuilder, - ) - if err != nil { - req.Error(err) - break - } - - req.Resolve(&FundBatchResp{ - Batch: verboseBatch, - }) - - case reqTypeSealBatch: - if c.pendingBatch == nil { - req.Error(fmt.Errorf("no pending " + - "batch")) - break - } - - sealReqParams, err := - typedParam[SealParams](req) - if err != nil { - req.Error(fmt.Errorf("bad seal "+ - "params: %w", err)) - break - } - - ctx, cancel := c.WithCtxQuit() - sealedBatch, err := c.sealBatch( - ctx, *sealReqParams, c.pendingBatch, - ) - cancel() - if err != nil { - req.Error(fmt.Errorf("unable to seal "+ - "minting batch: %w", err)) - break - } - - // If seal batch executed successfully, and - // returned a sealed batch, then we can update - // the pending batch. - if err == nil && sealedBatch != nil { - c.pendingBatch = sealedBatch - } - - // Resolve a copy of the state to prevent - // potential concurrent read/write issues. - if c.pendingBatch == nil { - req.Resolve((*MintingBatch)(nil)) - } else { - req.Resolve(c.pendingBatch.Copy()) - } - - case reqTypeFinalizeBatch: - if c.pendingBatch == nil { - req.Error(fmt.Errorf("no pending " + - "batch")) - break - } - - batchKey := c.pendingBatch.BatchKey.PubKey - batchKeySerial := asset.ToSerialized(batchKey) - log.Infof("Finalizing batch %x", batchKeySerial) - - finalizeReqParams, err := - typedParam[FinalizeParams](req) - if err != nil { - req.Error(fmt.Errorf("bad finalize "+ - "params: %w", err)) - break - } - - caretaker, err := c.finalizeBatch( - *finalizeReqParams, - ) - if err != nil { - freezeErr := fmt.Errorf("unable to "+ - "finalize minting batch: %w", - err) - log.Warnf(freezeErr.Error()) - req.Error(freezeErr) - break - } - - // We now wait for the caretaker to either - // broadcast the batch or fail to do so. - select { - case <-caretaker.cfg.BroadcastCompleteChan: - req.Resolve(caretaker.cfg.Batch) - - case err := <-caretaker.cfg.BroadcastErrChan: - req.Error(err) - // Unrecoverable error, stop caretaker - // directly. The pending batch will not - // be saved. - stopErr := caretaker.Stop() - if stopErr != nil { - log.Warnf("Unable to stop "+ - "caretaker "+ - "gracefully: %v", err) - } - - delete(c.caretakers, batchKeySerial) - - case <-c.Quit: - return - } - - // Now that we have a caretaker launched for - // this batch and broadcast its minting - // transaction, we can remove the pending batch. - c.pendingBatch = nil - - case reqTypeCancelBatch: - batchKey, err := c.canCancelBatch() - if err != nil { - req.Error(err) - break - } - - // Attempt to cancel the current batch, and then - // clear the pending batch in the planter. - ctx, cancel := c.WithCtxQuit() - err = c.cancelMintingBatch(ctx, batchKey) - cancel() - c.pendingBatch = nil - - // Always return the key of the batch we tried - // to cancel. - req.Return(batchKey, err) - } + req() case <-c.Quit: return @@ -2149,36 +1819,47 @@ func (c *ChainPlanter) gardener() { } } -// fundBatch attempts to fund a minting batch and create a funded genesis PSBT. -// This PSBT is a template that the caretaker will modify when finalizing the -// batch. If a feerate or tapscript sibling are provided, those will be used -// when funding the batch. If no pending batch exists, a batch will be created -// with the funded genesis PSBT. After funding, the pending batch will be -// saved to disk and updated in memory. -func (c *ChainPlanter) fundBatch(ctx context.Context, params FundParams, - workingBatch *MintingBatch) error { +// fundingPrep stores a tapscript-sibling root hash (already persisted +// to the tree store) and a closure that computes a funded mint anchor +// PSBT for a given batch without mutating it. Both fields are +// populated by prepareFunding and consumed by createFundedBatch / +// applyFundingToBatch. +type fundingPrep struct { + // rootHash is the persisted root hash of the optional tapscript + // sibling supplied via FundParams. nil if no sibling was given. + rootHash *chainhash.Hash + + // computeFunding builds the funded genesis PSBT for a batch + // without mutating it. Callers must apply the result only after + // all persistence has succeeded, so a failure leaves the batch + // unchanged. + computeFunding func(batch *MintingBatch) (*FundedMintAnchorPsbt, + error) +} + +// prepareFunding stores the optional tapscript sibling and constructs +// the funding-computation closure shared by createFundedBatch and +// applyFundingToBatch. +func (c *ChainPlanter) prepareFunding(ctx context.Context, + params FundParams) (fundingPrep, error) { var ( + zero fundingPrep 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. + // If a tapscript tree was specified for this batch, we'll store + // it on disk. The cultivator we start for this batch will use it + // when deriving the final Taproot output key. params.SiblingTapTree.WhenSome(func(tn asset.TapscriptTreeNodes) { rootHash, err = c.cfg.TreeStore.StoreTapscriptTree(ctx, tn) }) - if err != nil { - return fmt.Errorf("unable to store tapscript tree for minting "+ - "batch: %w", err) + return zero, fmt.Errorf("unable to store tapscript tree "+ + "for minting batch: %w", err) } - // computeFunding builds the funded genesis PSBT for a batch - // without mutating it. The caller is responsible for applying - // the result to the batch only after all persistence has - // succeeded, so a failure leaves the batch unchanged. computeFunding := func(batch *MintingBatch) ( *FundedMintAnchorPsbt, error) { @@ -2190,8 +1871,8 @@ func (c *ChainPlanter) fundBatch(ctx context.Context, params FundParams, batchKey := asset.ToSerialized(batch.BatchKey.PubKey) - // walletFundPsbt is a closure that will be used to fund the - // batch with the specified fee rate. + // 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) { @@ -2210,6 +1891,7 @@ func (c *ChainPlanter) fundBatch(ctx context.Context, params FundParams, log.Infof("Attempting to fund batch: %x", batchKey) mintAnchorTx, err := fundGenesisPsbt( ctx, c.cfg.ChainParams, batch, walletFundPsbt, + c.augmenter(), ) if err != nil { return nil, fmt.Errorf("unable to fund minting PSBT "+ @@ -2220,63 +1902,149 @@ func (c *ChainPlanter) fundBatch(ctx context.Context, params FundParams, return &mintAnchorTx, nil } - // If we don't have a batch, we'll create an empty batch before funding - // and writing to disk. - if workingBatch == nil { - newBatch, err := c.newBatch() - if err != nil { - return fmt.Errorf("unable to create new batch: %w", err) - } + return fundingPrep{ + rootHash: rootHash, + computeFunding: computeFunding, + }, nil +} - mintAnchorTx, err := computeFunding(newBatch) - if err != nil { - return err - } +// createFundedBatch derives a fresh minting batch, computes its +// funding, and persists the funded batch to disk as a single new row. +// On any failure no new batch is committed and no in-memory state is +// touched; the caller may try again. The returned batch is ready to +// be installed as c.pendingBatch by the caller. +// +// NOTE: This is the create half of what used to be a single fundBatch +// function with two purposes. The split exists so that "create a new +// funded batch" cannot be silently dispatched into "update an +// existing batch's funding" (or vice-versa) by callers passing a +// stale or wrong reference -- the bug shape behind #2136. +func (c *ChainPlanter) createFundedBatch(ctx context.Context, + params FundParams) (*MintingBatch, error) { + + prep, err := c.prepareFunding(ctx, params) + if err != nil { + return nil, err + } - // Apply the funding to the local batch and commit. If the - // commit fails, newBatch is discarded and the planter's - // pendingBatch is never assigned. - newBatch.GenesisPacket = mintAnchorTx - if rootHash != nil { - newBatch.tapSibling = rootHash - } + newBatch, err := c.newBatch() + if err != nil { + return nil, fmt.Errorf("unable to create new batch: %w", err) + } - err = c.cfg.Log.CommitMintingBatch(ctx, newBatch) - if err != nil { - return err - } + mintAnchorTx, err := prep.computeFunding(newBatch) + if err != nil { + return nil, err + } - c.pendingBatch = newBatch - return nil + // Apply the funding to the local batch and commit. If the + // commit fails, newBatch is discarded and the caller's planter + // state is never assigned. + newBatch.GenesisPacket = mintAnchorTx + if prep.rootHash != nil { + newBatch.tapSibling = prep.rootHash + } + + // The augmenter is the source of truth for the persistence + // payload (formerly read off + // newBatch.GenesisPacket.PreCommitmentOutput); it derives + // the row from the batch's current state. + preCommit, err := c.augmenter().BindData(ctx, newBatch) + if err != nil { + return nil, fmt.Errorf("augmenter BindData: %w", err) + } + err = c.cfg.BatchStore.CommitMintingBatch(ctx, newBatch, preCommit) + if err != nil { + return nil, err + } + + return newBatch, nil +} + +// applyFundingToBatch computes funding for an existing on-disk batch, +// persists the funding atomically (sibling + genesis TX in one DB +// transaction), and only then mirrors the funding into the in-memory +// batch. On any failure neither disk nor memory is mutated. +// +// NOTE: This is the update half of the former fundBatch. It must +// never be called with a batch that has not yet been written to disk +// -- use createFundedBatch for that case. +func (c *ChainPlanter) applyFundingToBatch(ctx context.Context, + params FundParams, batch *MintingBatch) error { + + if batch == nil { + return fmt.Errorf("applyFundingToBatch requires non-nil " + + "batch; use createFundedBatch to create a new one") + } + + prep, err := c.prepareFunding(ctx, params) + if err != nil { + return err } - // Compute the funded genesis packet for the existing batch - // without mutating it yet. - mintAnchorTx, err := computeFunding(workingBatch) + mintAnchorTx, err := prep.computeFunding(batch) if err != nil { return err } - // Persist the sibling and genesis TX atomically. Combining - // both writes in a single transaction ensures a partial - // failure cannot leave the batch with one persisted and the - // other absent. - err = c.cfg.Log.CommitBatchFunding( - ctx, workingBatch.BatchKey.PubKey, rootHash, *mintAnchorTx, + // The augmenter is consulted for the persistence payload -- + // it scans the freshly-funded PSBT for its own output and + // returns the typed row. Currently applyFundingToBatch is + // called before the batch's GenesisPacket has been mirrored + // back into the in-memory batch, so we attach mintAnchorTx + // to a copy so the augmenter can read it without us mutating + // the live batch. + stagingBatch := batch.Copy() + stagingBatch.GenesisPacket = mintAnchorTx + preCommit, err := c.augmenter().BindData(ctx, stagingBatch) + if err != nil { + return fmt.Errorf("augmenter BindData: %w", err) + } + + // Persist the sibling, genesis TX, and (when present) the + // supply-pre-commit row atomically. Combining the writes in a + // single transaction ensures a partial failure cannot leave + // the batch with one persisted and the others absent. + err = c.cfg.BatchStore.CommitBatchFunding( + ctx, batch.BatchKey.PubKey, prep.rootHash, *mintAnchorTx, + preCommit, ) if err != nil { return fmt.Errorf("unable to commit batch funding: %w", err) } - // All persistence succeeded; commit the funding to memory. - workingBatch.GenesisPacket = mintAnchorTx - if rootHash != nil { - workingBatch.tapSibling = rootHash + // All persistence succeeded; mirror the funding into memory. + batch.GenesisPacket = mintAnchorTx + if prep.rootHash != nil { + batch.tapSibling = prep.rootHash } return nil } +// fundPendingBatch funds c.pendingBatch, creating it first if it does +// not yet exist. This is the convenience wrapper used by the +// gardener's fund-batch request handler and by finalizeBatch; both +// have the same "I want the pending batch funded, regardless of +// whether it exists yet" semantics. c.pendingBatch is updated only on +// success of the create path; the update path mutates the existing +// batch in place via applyFundingToBatch. +func (c *ChainPlanter) fundPendingBatch(ctx context.Context, + params FundParams) error { + + if c.pendingBatch == nil { + newBatch, err := c.createFundedBatch(ctx, params) + if err != nil { + return err + } + + c.pendingBatch = newBatch + return nil + } + + return c.applyFundingToBatch(ctx, params, c.pendingBatch) +} + // matchPsbtToGroupReq attempts to match a signed group virtual PSBT to a // corresponding group key request. func matchPsbtToGroupReq(psbt psbt.Packet, @@ -2321,73 +2089,10 @@ func matchPsbtToGroupReq(psbt psbt.Packet, return fn.None[asset.GroupKeyRequest](), nil } -// sealBatchPreCommit injects the group public key obtained during the sealing -// phase into the pre‑commitment output descriptor of the batch's genesis -// packet. -// -// Preconditions: -// - batch.SupplyCommitments must be true – otherwise the function is a NOP. -// - batch.GenesisPacket must not be nil. -// -// Post‑conditions: -// - batch.GenesisPacket.PreCommitmentOutput is populated with the group key. -// -// NOTE: The function mutates the supplied *MintingBatch in place. -func sealBatchPreCommit(batch *MintingBatch) error { - // Fast‑exit if Universe Commitments are disabled – nothing to update. - if !batch.SupplyCommitments { - return nil - } - - // A valid genesis packet is mandatory once Universe Commitments are on. - if batch.GenesisPacket == nil { - return fmt.Errorf("batch genesis packet is unexpectedly " + - "nil, cannot update mint anchor pre-commitment output") - } - - // Retrieve the group public key recorded during sealing. - groupKeyOpt, err := fetchPreCommitGroupKey(batch) - if err != nil { - return fmt.Errorf("unable to fetch pre-commit group key: %w", - err) - } - - groupKey, err := groupKeyOpt.UnwrapOrErr( - fmt.Errorf("pre-commitment output group key is unexpectedly " + - "absent"), - ) - if err != nil { - return err - } - - // Ensure that the group key is set in the genesis packet - // pre-commitment output descriptor. - fundedAnchor := batch.GenesisPacket - if fundedAnchor == nil { - return fmt.Errorf("funded anchor is unexpectedly nil, " + - "cannot update mint anchor pre-commitment output " + - "descriptor") - } - - // Formulate the pre-commitment output descriptor with the group key. - preCommitDesc := fn.MapOptionZ( - fundedAnchor.PreCommitmentOutput, - // nolint: lll - func(preCommit PreCommitmentOutput) fn.Option[PreCommitmentOutput] { - preCommit.GroupPubKey = fn.Some(groupKey) - return fn.Some(preCommit) - }, - ) - - batch.GenesisPacket.PreCommitmentOutput = preCommitDesc - - return nil -} - // sealBatch will verify that each grouped asset in the pending batch has an // asset group witness, and will attempt to create asset group witnesses when // possible if they are not provided. After all asset group witnesses have been -// validated, they are saved to disk to be used by the caretaker during batch +// validated, they are saved to disk to be used by the cultivator during batch // finalization. func (c *ChainPlanter) sealBatch(ctx context.Context, params SealParams, workingBatch *MintingBatch) (*MintingBatch, error) { @@ -2432,7 +2137,7 @@ func (c *ChainPlanter) sealBatch(ctx context.Context, params SealParams, // If the batch was previously sealed, each grouped seedling will have // its asset genesis already stored on disk. - existingGroups, err := c.cfg.Log.FetchSeedlingGroups( + existingGroups, err := c.cfg.BatchStore.FetchSeedlingGroups( ctx, genesisPoint, anchorOutputIndex, singleSeedling, ) @@ -2623,19 +2328,25 @@ func (c *ChainPlanter) sealBatch(ctx context.Context, params SealParams, batchWithGroupInfo.Seedlings[assetName].GroupInfo = group } - // Persist the newly generated group-key metadata in the batch’s - // pre-commitment output—needed only when Universe Commitments are on— - // before passing the batch to the minting store. - if batchWithGroupInfo.SupplyCommitments { - err := sealBatchPreCommit(batchWithGroupInfo) - if err != nil { - return nil, err - } - } + // The supply-commit augmenter rediscovers the group-key + // metadata directly from the (now group-keyed) seedlings + // when it constructs the persistence payload below; no + // separate "stamp the group key onto PreCommitmentOutput" + // step is needed. - // With all the asset group witnesses validated, we can now save them - // to disk effectively sealing the batch. - err = c.cfg.Log.SealBatch(ctx, batchWithGroupInfo, newAssetGroups) + // With all the asset group witnesses validated, we can now + // save them to disk effectively sealing the batch. The + // augmenter recomputes its persistence payload off the + // batch's current state -- by seal time the group key has + // typically been derived, so the row will be refreshed + // with it. + sealPreCommit, err := c.augmenter().BindData(ctx, batchWithGroupInfo) + if err != nil { + return nil, fmt.Errorf("augmenter BindData: %w", err) + } + err = c.cfg.BatchStore.SealBatch( + ctx, batchWithGroupInfo, newAssetGroups, sealPreCommit, + ) if err != nil { return nil, fmt.Errorf("unable to write seedling groups: "+ "%w", err) @@ -2644,8 +2355,8 @@ func (c *ChainPlanter) sealBatch(ctx context.Context, params SealParams, return batchWithGroupInfo, nil } -// finalizeBatch creates a new caretaker for the batch and starts it. -func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, +// finalizeBatch creates a new cultivator for the batch and starts it. +func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*Cultivator, error) { var ( @@ -2678,10 +2389,15 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, } // Fund the batch if it hasn't been funded yet. If funding // fails, the batch stays pending so the user can retry. + // + // finalizeBatch is only reached when c.pendingBatch is + // non-nil (the gardener short-circuits with an error + // otherwise), so the "create" path of fundPendingBatch is + // not exercised here; calling fundPendingBatch keeps the + // dispatch in one place rather than re-checking pending-ness + // here. if !c.pendingBatch.IsFunded() { - err = c.fundBatch( - ctx, FundParams(params), c.pendingBatch, - ) + err = c.fundPendingBatch(ctx, FundParams(params)) if err != nil { return nil, err } @@ -2708,42 +2424,68 @@ func (c *ChainPlanter) finalizeBatch(params FinalizeParams) (*BatchCaretaker, // Now that funding and sealing have succeeded, freeze the // batch on disk and in memory. This means no further - // seedlings can be added to this batch. - err = freezeMintingBatch(ctx, c.cfg.Log, c.pendingBatch) + // seedlings can be added to this batch. freezeMintingBatch + // updates both the on-disk row and the in-memory state in a + // single atomic step via the BatchStore. + err = freezeMintingBatch(ctx, c.cfg.BatchStore, c.pendingBatch) if err != nil { return nil, err } - c.pendingBatch.UpdateState(BatchStateFrozen) - caretaker := c.newCaretakerForBatch(c.pendingBatch, feeRate) - if err := caretaker.Start(); err != nil { - return nil, fmt.Errorf("unable to start new caretaker: %w", err) + cultivator := c.newCultivatorForBatch(c.pendingBatch, feeRate) + if err := cultivator.Start(); err != nil { + return nil, fmt.Errorf("unable to start new "+ + "cultivator: %w", err) } - return caretaker, nil + return cultivator, nil } -// PendingBatch returns the current pending batch, or nil if no batch is -// pending. -func (c *ChainPlanter) PendingBatch() (*MintingBatch, error) { - req := newStateReq[*MintingBatch](reqTypePendingBatch) +// dispatchStateReq sends a closure to the gardener loop and waits +// for its typed result. The closure runs inside the loop's +// goroutine with full access to ChainPlanter state. Returns a +// shutdown error if the planter quits before the request can be +// sent or its response received. +func dispatchStateReq[T any](c *ChainPlanter, + handler func(out chan<- stateResult[T])) (T, error) { + + var zero T + out := make(chan stateResult[T], 1) + req := stateReq(func() { handler(out) }) + + if !fn.SendOrQuit(c.stateReqs, req, c.Quit) { + return zero, fmt.Errorf("chain planter shutting down") + } - if !fn.SendOrQuit[stateRequest](c.stateReqs, req, c.Quit) { - return nil, fmt.Errorf("chain planter shutting down") + select { + case r := <-out: + return r.val, r.err + case <-c.Quit: + return zero, fmt.Errorf("chain planter shutting down") } +} - return <-req.resp, nil +// PendingBatch returns the current pending batch, or nil if no batch is +// pending. +func (c *ChainPlanter) PendingBatch() (*MintingBatch, error) { + return dispatchStateReq( + c, func(out chan<- stateResult[*MintingBatch]) { + // Resolve a copy of the state to prevent potential + // concurrent read/write issues. + if c.pendingBatch == nil { + out <- stateOk[*MintingBatch](nil) + return + } + out <- stateOk(c.pendingBatch.Copy()) + }, + ) } // NumActiveBatches returns the total number of active batches that have an -// outstanding caretaker assigned. +// outstanding cultivator assigned. func (c *ChainPlanter) NumActiveBatches() (int, error) { - req := newStateReq[int](reqTypeNumActiveBatches) - - if !fn.SendOrQuit[stateRequest](c.stateReqs, req, c.Quit) { - return 0, fmt.Errorf("chain planter shutting down") - } - - return <-req.resp, nil + return dispatchStateReq(c, func(out chan<- stateResult[int]) { + out <- stateOk(len(c.cultivators)) + }) } // ListBatches returns the single batch specified by the batch key, or the set @@ -2751,143 +2493,221 @@ func (c *ChainPlanter) NumActiveBatches() (int, error) { func (c *ChainPlanter) ListBatches(params ListBatchesParams) ([]*VerboseBatch, error) { - req := newStateParamReq[[]*VerboseBatch](reqTypeListBatches, params) - - if !fn.SendOrQuit[stateRequest](c.stateReqs, req, c.Quit) { - return nil, fmt.Errorf("chain planter shutting down") - } - - return <-req.resp, <-req.err + return dispatchStateReq( + c, func(out chan<- stateResult[[]*VerboseBatch]) { + ctx, cancel := c.WithCtxQuit() + batches, err := listBatches( + ctx, c.cfg.BatchStore, c.cfg.MintingRefs, + c.cfg.ProofFiles, c.cfg.GenTxBuilder, params, + ) + cancel() + if err != nil { + out <- stateErr[[]*VerboseBatch](err) + return + } + out <- stateOk(batches) + }, + ) } // FundBatch sends a signal to the planter to fund the current batch, or create // a funded batch. -func (c *ChainPlanter) FundBatch(params FundParams) (*FundBatchResp, error) { - req := newStateParamReq[*FundBatchResp](reqTypeFundBatch, params) +func (c *ChainPlanter) FundBatch(params FundParams) (*VerboseBatch, error) { + return dispatchStateReq( + c, func(out chan<- stateResult[*VerboseBatch]) { + if c.pendingBatch != nil && + c.pendingBatch.IsFunded() { + + out <- stateErr[*VerboseBatch](fmt.Errorf( + "batch already funded", + )) + return + } - if !fn.SendOrQuit[stateRequest](c.stateReqs, req, c.Quit) { - return nil, fmt.Errorf("chain planter shutting down") - } + ctx, cancel := c.WithCtxQuit() + err := c.fundPendingBatch(ctx, params) + cancel() + if err != nil { + out <- stateErr[*VerboseBatch](fmt.Errorf( + "unable to fund minting batch: %w", + err, + )) + return + } + + verboseBatch, err := newVerboseBatch( + c.pendingBatch, c.cfg.GenTxBuilder, + ) + if err != nil { + out <- stateErr[*VerboseBatch](err) + return + } - return <-req.resp, <-req.err + out <- stateOk(verboseBatch) + }, + ) } // SealBatch attempts to seal the current batch, by providing or deriving all // witnesses necessary to create the final genesis TX. func (c *ChainPlanter) SealBatch(params SealParams) (*MintingBatch, error) { - req := newStateParamReq[*MintingBatch](reqTypeSealBatch, params) + return dispatchStateReq( + c, func(out chan<- stateResult[*MintingBatch]) { + if c.pendingBatch == nil { + out <- stateErr[*MintingBatch](fmt.Errorf( + "no pending batch", + )) + return + } - if !fn.SendOrQuit[stateRequest](c.stateReqs, req, c.Quit) { - return nil, fmt.Errorf("chain planter shutting down") - } + ctx, cancel := c.WithCtxQuit() + sealedBatch, err := c.sealBatch( + ctx, params, c.pendingBatch, + ) + cancel() + if err != nil { + out <- stateErr[*MintingBatch](fmt.Errorf( + "unable to seal minting batch: %w", + err, + )) + return + } + + if sealedBatch != nil { + c.pendingBatch = sealedBatch + } - return <-req.resp, <-req.err + // Resolve a copy of the state to prevent potential + // concurrent read/write issues. + if c.pendingBatch == nil { + out <- stateOk[*MintingBatch](nil) + return + } + out <- stateOk(c.pendingBatch.Copy()) + }, + ) } // FinalizeBatch sends a signal to the planter to finalize the current batch. func (c *ChainPlanter) FinalizeBatch(params FinalizeParams) (*MintingBatch, error) { - req := newStateParamReq[*MintingBatch](reqTypeFinalizeBatch, params) - - if !fn.SendOrQuit[stateRequest](c.stateReqs, req, c.Quit) { - return nil, fmt.Errorf("chain planter shutting down") - } - - return <-req.resp, <-req.err -} - -// CancelBatch sends a signal to the planter to cancel the current batch. -func (c *ChainPlanter) CancelBatch() (*btcec.PublicKey, error) { - req := newStateReq[*btcec.PublicKey](reqTypeCancelBatch) - - if !fn.SendOrQuit[stateRequest](c.stateReqs, req, c.Quit) { - return nil, fmt.Errorf("chain planter shutting down") - } - - 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.SupplyCommitments { - return nil - } - - // If the delegation key is already set, we can skip any further - // delegation key considerations. - if req.DelegationKey.IsSome() { - return nil - } + return dispatchStateReq( + c, func(out chan<- stateResult[*MintingBatch]) { + if c.pendingBatch == nil { + out <- stateErr[*MintingBatch](fmt.Errorf( + "no pending batch", + )) + return + } - // 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 + batchKey := c.pendingBatch.BatchKey.PubKey + batchKeySerial := asset.ToSerialized(batchKey) + log.Infof("Finalizing batch %x", batchKeySerial) - 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) - } + cultivator, err := c.finalizeBatch(params) + if err != nil { + freezeErr := fmt.Errorf("unable to finalize "+ + "minting batch: %w", err) + log.Warnf(freezeErr.Error()) + out <- stateErr[*MintingBatch](freezeErr) + return + } - if anchor.DelegationKey.IsNone() { - return fmt.Errorf("group anchor seedling has no "+ - "delegation key (anchor_seedling_name=%s)", - anchorSeedlingName) - } + // Wait for the cultivator to either broadcast the + // batch or fail to do so. + select { + case <-cultivator.cfg.BroadcastCompleteChan: + // Snapshot the cultivator's live batch before + // handing it to the caller. The cultivator + // goroutine continues to mutate + // Batch.GenesisPacket and + // Batch.RootAssetCommitment after this point + // (Broadcast -> Confirmed -> Finalized); + // returning the live pointer would race + // those writes against any read the caller + // does. + out <- stateOk(cultivator.cfg.Batch.Copy()) + + case err := <-cultivator.cfg.BroadcastErrChan: + out <- stateErr[*MintingBatch](err) + + // Unrecoverable error, stop cultivator + // directly. The pending batch will not be + // saved. + stopErr := cultivator.Stop() + if stopErr != nil { + log.Warnf("Unable to stop cultivator "+ + "gracefully: %v", err) + } - // Set the delegation key for the seedling to the delegation key - // of the group anchor seedling. - req.DelegationKey = anchor.DelegationKey + delete(c.cultivators, batchKeySerial) + + // Cancel the failed batch on disk so it does + // not stay wedged in a pre-broadcast state, + // where the singleton invariant added in + // migration 000060 would block any + // subsequent batch from being created. We + // use the same cancel-state rule as + // cultivator.Cancel(): Pending or Frozen → + // SeedlingCancelled (no sprouts yet); + // Committed → SproutCancelled (sprouts + // already on disk). + cancelState := BatchStateSeedlingCancelled + if c.pendingBatch.State() == + BatchStateCommitted { + + cancelState = BatchStateSproutCancelled + } - // Return early, no further seedling prep required for universe - // commitments feature. - return nil - } + cancelCtx, cancelCtxCancel := c.WithCtxQuit() + cancelErr := c.cfg.BatchStore.UpdateBatchState( + cancelCtx, c.pendingBatch, cancelState, + ) + cancelCtxCancel() + if cancelErr != nil { + log.Warnf("Unable to cancel failed "+ + "batch (%x): %v", + batchKeySerial[:], cancelErr) + } - // If an existing group key is set, we can use that to look up the - // delegation key. - if req.GroupInfo != nil && req.GroupInfo.GroupKey != nil { - dKeyOpt, err := c.cfg.Log.FetchDelegationKey( - ctx, req.GroupInfo.GroupKey.GroupPubKey, - ) - if err != nil { - return fmt.Errorf("unable to fetch delegation key "+ - "for group key: %w", err) - } + case <-c.Quit: + return + } - // Return early if a corresponding delegation key is found. - if dKeyOpt.IsSome() { - req.DelegationKey = dKeyOpt - return nil - } - } + // Now that we have a cultivator launched for this + // batch and broadcast its minting transaction, we + // can remove the pending batch. + c.pendingBatch = nil + }, + ) +} - // On the other hand, if we're handling the group anchor seedling, - // 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) - } +// CancelBatch sends a signal to the planter to cancel the current batch. +func (c *ChainPlanter) CancelBatch() (*btcec.PublicKey, error) { + return dispatchStateReq( + c, func(out chan<- stateResult[*btcec.PublicKey]) { + batchKey, err := c.canCancelBatch() + if err != nil { + out <- stateErr[*btcec.PublicKey](err) + return + } - req.DelegationKey = fn.Some(newKey) - return nil - } + // Attempt to cancel the current batch, and then + // clear the pending batch in the planter. + ctx, cancel := c.WithCtxQuit() + err = c.cancelMintingBatch(ctx, batchKey) + cancel() + c.pendingBatch = nil - return fmt.Errorf("failed to finalize delegation key for "+ - "seedling %s", req.AssetName) + // Always return the key of the batch we tried to + // cancel. + out <- stateResult[*btcec.PublicKey]{ + val: batchKey, + err: err, + } + }, + ) } // prepAssetSeedling performs some basic validation for the Seedling, then @@ -2895,13 +2715,14 @@ func (c *ChainPlanter) prepSeedlingDelegationKey(ctx context.Context, func (c *ChainPlanter) prepAssetSeedling(ctx context.Context, req *Seedling) error { - // If the seedling has the universe/supply commitment feature enabled, - // finalize the delegation key. - if req.SupplyCommitments { - err := c.prepSeedlingDelegationKey(ctx, req) - if err != nil { - return err - } + // Let the configured augmenter populate any augmenter-managed + // fields on the seedling (e.g. a delegation key for + // supply-commit-flagged seedlings). When no augmenter is + // active the call is a no-op. + if err := c.augmenter().PrepareSeedling( + ctx, c.pendingBatch, req, + ); err != nil { + return err } // Set seedling asset metadata fields. @@ -2942,7 +2763,7 @@ func (c *ChainPlanter) prepAssetSeedling(ctx context.Context, if req.HasGroupKey() { groupKeyBytes := req.GroupInfo.GroupPubKey. SerializeCompressed() - groupInfo, err := c.cfg.Log.FetchGroupByGroupKey( + groupInfo, err := c.cfg.MintingRefs.FetchGroupByGroupKey( ctx, &req.GroupInfo.GroupPubKey, ) if err != nil { @@ -2951,7 +2772,7 @@ func (c *ChainPlanter) prepAssetSeedling(ctx context.Context, ) } - anchorMeta, err := c.cfg.Log.FetchAssetMeta( + anchorMeta, err := c.cfg.MintingRefs.FetchAssetMeta( ctx, groupInfo.Genesis.ID(), ) if err != nil { @@ -2976,7 +2797,7 @@ func (c *ChainPlanter) prepAssetSeedling(ctx context.Context, "invalid", *req.GroupAnchor) } - err := c.pendingBatch.validateGroupAnchor(req) + err := c.pendingBatch.ValidateGroupAnchor(req) if err != nil { return err } @@ -3042,12 +2863,26 @@ func (c *ChainPlanter) prepAssetSeedling(ctx context.Context, return err } - c.pendingBatch = newBatch - log.Infof("Attempting to add a seedling to a new batch "+ "(seedling=%v)", req) - err = c.pendingBatch.AddSeedling(*req) + // Let the augmenter run its intake gate against the + // fresh (empty) batch. It enforces invariants like + // "first seedling sets the SupplyCommitments flag" and + // "delegation key must be set if SupplyCommitments + // is on." + err = c.augmenter().ValidateSeedling(newBatch, *req) + if err != nil { + return fmt.Errorf("failed to add seedling to batch: %w", + err) + } + + // Stage the seedling on the local newBatch and persist + // the whole batch atomically via CommitMintingBatch. The + // planter's pendingBatch is assigned only after the DB + // write succeeds; on any failure newBatch is discarded + // and the planter state is unchanged. + err = newBatch.AddSeedling(*req) if err != nil { return fmt.Errorf("failed to add seedling to batch: %w", err) @@ -3055,32 +2890,53 @@ func (c *ChainPlanter) prepAssetSeedling(ctx context.Context, ctx, cancel := c.WithCtxQuit() defer cancel() - err = c.cfg.Log.CommitMintingBatch(ctx, c.pendingBatch) + err = c.cfg.BatchStore.CommitMintingBatch( + ctx, newBatch, fn.None[PreCommitBindData](), + ) 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("Attempting to add a seedling to batch (seedling=%v)", req) - err := c.pendingBatch.AddSeedling(*req) + // Let the augmenter run its intake gate before the + // batch's own validation. Splitting validation in two + // keeps augmenter-owned invariants in the augmenter and + // batch-owned invariants on MintingBatch. + err := c.augmenter().ValidateSeedling(c.pendingBatch, *req) + if err != nil { + return fmt.Errorf("failed to add seedling to batch: %w", + err) + } + + // Validate first without mutating the in-memory batch, + // then persist, then mirror the seedling into memory. + // This ordering ensures the in-memory batch never + // advances unless the DB write succeeded: a failed + // AddSeedlingsToBatch leaves both disk and memory at + // their prior state. + err = c.pendingBatch.validateSeedling(*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.BatchStore.AddSeedlingsToBatch( ctx, c.pendingBatch.BatchKey.PubKey, req, ) if err != nil { return err } + + c.pendingBatch.commitSeedling(*req) } // Now that we have the batch committed to disk, we'll return back to @@ -3090,7 +2946,7 @@ func (c *ChainPlanter) prepAssetSeedling(ctx context.Context, } // updateMintingProofs is called by the re-org watcher when it detects a re-org -// and has updated the minting proofs. This cannot be done by the caretaker +// and has updated the minting proofs. This cannot be done by the cultivator // itself, because its job is already done at the point that a re-org can happen // (the batch is finalized after a single confirmation). func (c *ChainPlanter) updateMintingProofs(proofs []*proof.Proof) error { @@ -3136,58 +2992,17 @@ func (c *ChainPlanter) updateMintingProofs(proofs []*proof.Proof) error { "minted proofs: %w", err) } } + } - // The universe ID serves to identify the universe root we want - // to update this asset in. This is either the assetID or the - // group key. - uniID := universe.Identifier{ - AssetID: p.Asset.ID(), - } - if p.Asset.GroupKey != nil { - uniID.GroupKey = &p.Asset.GroupKey.GroupPubKey - } - - log.Debugf("Updating issuance proof for asset with universe, "+ - "key=%v", spew.Sdump(uniID)) - - // The base key is the set of bytes that keys into the universe, - // this'll be the outpoint where it was created at and the - // script key for that asset. - leafKey := universe.BaseLeafKey{ - OutPoint: wire.OutPoint{ - Hash: p.AnchorTx.TxHash(), - Index: p.InclusionProof.OutputIndex, - }, - ScriptKey: &p.Asset.ScriptKey, - } - - // The universe leaf stores the raw proof, so we'll encode it - // here now. - proofBytes, err := p.Bytes() - if err != nil { - return fmt.Errorf("unable to encode proof: %w", err) - } + if c.cfg.MintProofPublisher == nil { + return nil + } - // With both of those assembled, we can now update issuance - // which takes the amount and proof of the minting event. - uniGen := universe.GenesisWithGroup{ - Genesis: p.Asset.Genesis, - } - if p.Asset.GroupKey != nil { - uniGen.GroupKey = p.Asset.GroupKey - } - mintingLeaf := &universe.Leaf{ - GenesisWithGroup: uniGen, - RawProof: proofBytes, - Amt: p.Asset.Amount, - Asset: &p.Asset, - } - _, err = c.cfg.Universe.UpsertProofLeaf( - ctx, uniID, leafKey, mintingLeaf, - ) - if err != nil { - return fmt.Errorf("unable to update issuance: %w", err) - } + if err := c.cfg.MintProofPublisher.PublishMintProofUpdates( + ctx, proofs, + ); err != nil { + return fmt.Errorf("unable to publish minting proof "+ + "updates: %w", err) } return nil @@ -3197,8 +3012,6 @@ func (c *ChainPlanter) updateMintingProofs(proofs []*proof.Proof) error { // New asset creation or ongoing issuance) to the ChainPlanter. A channel is // returned where future updates will be sent over. If an error is returned no // issuance operation was possible. -// -// NOTE: This is part of the Planter interface. func (c *ChainPlanter) QueueNewSeedling(req *Seedling) (SeedlingUpdates, error) { req.updates = make(SeedlingUpdates, 1) @@ -3214,8 +3027,6 @@ func (c *ChainPlanter) QueueNewSeedling(req *Seedling) (SeedlingUpdates, error) // CancelSeedling attempts to cancel the creation of a new asset identified by // its name. If the seedling has already progressed to a point where the // genesis PSBT has been broadcasted, an error is returned. -// -// NOTE: This is part of the Planter interface. func (c *ChainPlanter) CancelSeedling() error { // TODO(roasbeef): actually needed? return nil @@ -3268,9 +3079,9 @@ func (c *ChainPlanter) publishSubscriberEvent(event fn.Event) { // verifierCtx returns a verifier context that can be used to verify proofs. func (c *ChainPlanter) verifierCtx(ctx context.Context) proof.VerifierCtx { - headerVerifier := GenHeaderVerifier(ctx, c.cfg.ChainBridge) + headerVerifier := tapnode.GenHeaderVerifier(ctx, c.cfg.ChainBridge) merkleVerifier := proof.DefaultMerkleVerifier - groupVerifier := GenGroupVerifier(ctx, c.cfg.Log) + groupVerifier := tapnode.GenGroupVerifier(ctx, c.cfg.MintingRefs) return proof.VerifierCtx{ HeaderVerifier: headerVerifier, @@ -3281,69 +3092,10 @@ func (c *ChainPlanter) verifierCtx(ctx context.Context) proof.VerifierCtx { } } -// A compile-time assertion to make sure that ChainPlanter implements the -// tapgarden.Planter interface. -var _ Planter = (*ChainPlanter)(nil) - -// A compile-time assertion to make sure BatchCaretaker satisfies the +// A compile-time assertion to make sure ChainPlanter 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 DelegationKey - - // GroupPubKey is the asset-group public key for this pre-commitment. - // - // Optional: - // - Present when the group key is already known—either reused from an - // existing group at funding time or generated once the batch is - // sealed. - // - Absent while an unsealed batch without a prior group key is still - // in progress. - GroupPubKey fn.Option[btcec.PublicKey] -} - -// NewPreCommitmentOutput creates a new PreCommitmentOutput instance. -func NewPreCommitmentOutput(outIdx uint32, internalKey DelegationKey, - groupPubKey fn.Option[btcec.PublicKey]) PreCommitmentOutput { - - return PreCommitmentOutput{ - OutIdx: outIdx, - InternalKey: internalKey, - GroupPubKey: groupPubKey, - } -} - -// PreCommitTxOut returns the pre-commitment output as a wire.TxOut instance. -func PreCommitTxOut(internalKey btcec.PublicKey) (wire.TxOut, error) { - var zero wire.TxOut - - // Formulate a taproot output key from the taproot internal key. - taprootOutputKey := txscript.ComputeTaprootKeyNoScript(&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 { @@ -3353,35 +3105,16 @@ type FundedMintAnchorPsbt struct { // 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") - } +func NewFundedMintAnchorPsbt(fundedPsbt tapsend.FundedPsbt, + anchorOutIndexes AnchorTxOutputIndexes) (FundedMintAnchorPsbt, error) { return FundedMintAnchorPsbt{ - FundedPsbt: fundedPsbt, - AssetAnchorOutIdx: anchorOutIndexes.AssetAnchorOutIdx, - PreCommitmentOutput: preCommitOut, + FundedPsbt: fundedPsbt, + AssetAnchorOutIdx: anchorOutIndexes.AssetAnchorOutIdx, }, nil } @@ -3405,7 +3138,15 @@ func (f *FundedMintAnchorPsbt) GenesisOutpoint() fn.Option[wire.OutPoint] { return fn.Some(f.Pkt.UnsignedTx.TxIn[0].PreviousOutPoint) } -// Copy creates a deep copy of FundedMintAnchorPsbt. +// Copy returns a deep copy of FundedMintAnchorPsbt. The contained +// psbt.Packet is cloned via a serialize/parse round-trip so every nested +// PInput/POutput/Unknown -- each of which carries its own slice and map +// substructure -- is duplicated. LockedUTXOs holds wire.OutPoint values +// (no pointer reachability) so fn.CopySlice is a true deep copy there. +// +// If the round-trip fails (the underlying packet is malformed) we panic, +// since tapgarden only ever holds packets it constructed itself via the +// wallet's funding flow. func (f *FundedMintAnchorPsbt) Copy() *FundedMintAnchorPsbt { newMintAnchorPsbt := &FundedMintAnchorPsbt{ FundedPsbt: tapsend.FundedPsbt{ @@ -3413,22 +3154,33 @@ func (f *FundedMintAnchorPsbt) Copy() *FundedMintAnchorPsbt { ChainFees: f.ChainFees, LockedUTXOs: fn.CopySlice(f.LockedUTXOs), }, - AssetAnchorOutIdx: f.AssetAnchorOutIdx, - PreCommitmentOutput: f.PreCommitmentOutput, + AssetAnchorOutIdx: f.AssetAnchorOutIdx, } if f.Pkt != nil { - var unsignedTx *wire.MsgTx - if f.Pkt.UnsignedTx != nil { - unsignedTx = f.Pkt.UnsignedTx.Copy() + // Real-world packets always carry an UnsignedTx (the psbt + // package's Serialize requires it). Surface the impossible + // case explicitly rather than letting Serialize panic with + // a less-actionable nil-pointer dereference. + if f.Pkt.UnsignedTx == nil { + panic("FundedMintAnchorPsbt.Copy: Pkt has nil " + + "UnsignedTx; not a valid psbt") } - newMintAnchorPsbt.Pkt = &psbt.Packet{ - UnsignedTx: unsignedTx, - Inputs: fn.CopySlice(f.Pkt.Inputs), - Outputs: fn.CopySlice(f.Pkt.Outputs), - Unknowns: fn.CopySlice(f.Pkt.Unknowns), + var buf bytes.Buffer + if err := f.Pkt.Serialize(&buf); err != nil { + panic(fmt.Errorf("FundedMintAnchorPsbt.Copy: "+ + "serializing packet failed: %w", err)) + } + + pktCopy, err := psbt.NewFromRawBytes( + bytes.NewReader(buf.Bytes()), false, + ) + if err != nil { + panic(fmt.Errorf("FundedMintAnchorPsbt.Copy: parsing "+ + "round-tripped packet failed: %w", err)) } + newMintAnchorPsbt.Pkt = pktCopy } return newMintAnchorPsbt diff --git a/tapgarden/planter_test.go b/tapgarden/planter_test.go index 6328586010..56708c4aa1 100644 --- a/tapgarden/planter_test.go +++ b/tapgarden/planter_test.go @@ -26,6 +26,7 @@ import ( "github.com/davecgh/go-spew/spew" "github.com/lightninglabs/lndclient" tap "github.com/lightninglabs/taproot-assets" + "github.com/lightninglabs/taproot-assets/address" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/commitment" "github.com/lightninglabs/taproot-assets/fn" @@ -34,6 +35,7 @@ import ( "github.com/lightninglabs/taproot-assets/tapdb" _ "github.com/lightninglabs/taproot-assets/tapdb" // Register relevant drivers. "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode/tapnodemock" "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightningnetwork/lnd/input" @@ -47,6 +49,7 @@ import ( // Default to a large interval so the planter never actually ticks and only // rely on our manual ticks. var ( + chainParams = &address.RegressionNetTap defaultTimeout = time.Second * 30 noCaretakerStates = fn.NewSet( tapgarden.BatchStatePending, @@ -68,8 +71,11 @@ var ( ) ) -// newMintingStore creates a new instance of the TapAddressBook book. -func newMintingStore(t *testing.T) tapgarden.MintingStore { +// newMintingStore creates a new in-memory asset minting store backed +// by tapdb. The concrete type is returned because the harness needs +// both BatchStore and MintingRefReader views of the same underlying +// store as well as TapscriptTreeManager for the fallible tree mock. +func newMintingStore(t *testing.T) *tapdb.AssetMintingStore { db := tapdb.NewTestDB(t) txCreator := func(tx *sql.Tx) tapdb.PendingAssetStore { @@ -84,15 +90,15 @@ func newMintingStore(t *testing.T) tapgarden.MintingStore { // create succinct and fully featured unit/systems tests for the batched asset // minting process. type mintingTestHarness struct { - wallet *tapgarden.MockWalletAnchor + wallet *tapnodemock.WalletAnchor - chain *tapgarden.MockChainBridge + chain *tapnodemock.ChainBridge - store tapgarden.MintingStore + store *tapdb.AssetMintingStore treeStore *tapgarden.FallibleTapscriptTreeMgr - keyRing *tapgarden.MockKeyRing + keyRing *tapnodemock.KeyRing genSigner *tapgarden.MockGenSigner @@ -114,9 +120,9 @@ type mintingTestHarness struct { // newMintingTestHarness creates a new test harness from an active minting // store and an existing testing context. func newMintingTestHarness(t *testing.T, - store tapgarden.MintingStore) *mintingTestHarness { + store *tapdb.AssetMintingStore) *mintingTestHarness { - keyRing := tapgarden.NewMockKeyRing() + keyRing := tapnodemock.NewKeyRing() genSigner := tapgarden.NewMockGenSigner(keyRing) treeMgr := tapgarden.NewFallibleTapscriptTreeMgr(store) archiver := proof.NewMockProofArchive() @@ -125,8 +131,8 @@ func newMintingTestHarness(t *testing.T, T: t, store: store, treeStore: &treeMgr, - wallet: tapgarden.NewMockWalletAnchor(), - chain: tapgarden.NewMockChainBridge(), + wallet: tapnodemock.NewWalletAnchor(), + chain: tapnodemock.NewChainBridge(), proofFiles: archiver, proofWatcher: &tapgarden.MockProofWatcher{}, keyRing: keyRing, @@ -149,7 +155,8 @@ func (t *mintingTestHarness) refreshChainPlanter() { GardenKit: tapgarden.GardenKit{ Wallet: t.wallet, ChainBridge: t.chain, - Log: t.store, + BatchStore: t.store, + MintingRefs: t.store, TreeStore: t.treeStore, KeyRing: t.keyRing, GenSigner: t.genSigner, @@ -219,51 +226,6 @@ func (t *mintingTestHarness) assertBatchResumedBackground(wg *sync.WaitGroup, }() } -// createExternalBatch creates a new pending batch outside the planter, which -// can then be stored on disk. -func (t *mintingTestHarness) createExternalBatch( - numSeedlings int) *tapgarden.MintingBatch { - - t.Helper() - - seedlings := t.newRandSeedlings(numSeedlings) - seedlingsWithKeys := make(map[string]*tapgarden.Seedling) - for _, seedling := range seedlings { - scriptKeyInternalDesc, _ := test.RandKeyDesc(t) - scriptKey := asset.NewScriptKeyBip86(scriptKeyInternalDesc) - seedling.ScriptKey = scriptKey - - // The group internal key should be from the key ring since we - // expect the caretaker to sign with it later. - if seedling.EnableEmission { - groupKey, err := t.keyRing.DeriveNextKey( - context.Background(), - asset.TaprootAssetsKeyFamily, - ) - require.NoError(t, err) - - seedling.GroupInternalKey = &groupKey - } - - seedlingsWithKeys[seedling.AssetName] = seedling - } - - batchInternalKey, err := t.keyRing.DeriveNextKey( - context.Background(), asset.TaprootAssetsKeyFamily, - ) - require.NoError(t, err) - - newBatch := &tapgarden.MintingBatch{ - CreationTime: time.Now(), - HeightHint: 0, - BatchKey: batchInternalKey, - Seedlings: seedlingsWithKeys, - AssetMetas: make(tapgarden.AssetMetas), - } - newBatch.UpdateState(tapgarden.BatchStatePending) - - return newBatch -} // queueSeedlingsInBatch adds the series of seedlings to the batch, an error is // raised if any of the seedlings aren't accepted. @@ -308,9 +270,6 @@ func (t *mintingTestHarness) queueSeedlingsInBatch(isFunded bool, // Make sure the seedling was planted without error. require.NoError(t, update.Error) - // The received update should be a state of MintingStateSeed. - require.Equal(t, tapgarden.MintingStateSeed, update.NewState) - err = wait.NoError(func() error { // Assert that the key ring method DeriveNextKey was // called the expected number of times. @@ -485,7 +444,7 @@ func (t *mintingTestHarness) fundBatch(wg *sync.WaitGroup, fundParams = *params } - fundBatchResp, fundErr := t.planter.FundBatch(fundParams) + verboseBatch, fundErr := t.planter.FundBatch(fundParams) if fundErr != nil { respChan <- &FundBatchResp{ Err: fundErr, @@ -495,7 +454,7 @@ func (t *mintingTestHarness) fundBatch(wg *sync.WaitGroup, } respChan <- &FundBatchResp{ - Batch: fundBatchResp.Batch.MintingBatch, + Batch: verboseBatch.MintingBatch, } }() } @@ -968,19 +927,29 @@ func (t *mintingTestHarness) assertBatchGenesisTx( } // assertMintOutputKey asserts that the genesis output key for the batch was -// computed correctly during minting and includes a tapscript sibling. +// computed correctly during minting and includes a tapscript sibling. The +// sibling preimage is passed through to MintingOutputKey explicitly -- +// the helper must not rely on any previously cached value, since +// MintingOutputKey is pure in its sibling argument. func (t *mintingTestHarness) assertMintOutputKey(batch *tapgarden.MintingBatch, - siblingHash *chainhash.Hash) { + siblingPreimage *commitment.TapscriptPreimage) { rootCommitment := batch.RootAssetCommitment require.NotNil(t, rootCommitment) + var siblingHash *chainhash.Hash + if siblingPreimage != nil { + h, err := siblingPreimage.TapHash() + require.NoError(t, err) + siblingHash = h + } + scriptRoot := rootCommitment.TapscriptRoot(siblingHash) expectedOutputKey := txscript.ComputeTaprootOutputKey( batch.BatchKey.PubKey, scriptRoot[:], ) - outputKey, _, err := batch.MintingOutputKey(nil) + outputKey, _, err := batch.MintingOutputKey(siblingPreimage) require.NoError(t, err) require.True(t, expectedOutputKey.IsEqual(outputKey)) } @@ -1664,9 +1633,14 @@ func testFinalizeWithTapscriptTree(t *mintingTestHarness) { batchCount++ // The caretaker should fail when computing the Taproot output key. + // The gardener cancels the failed batch on disk so it does not + // block subsequent pending batches via the singleton invariant + // added in migration 000060. _ = t.assertGenesisTxFunded(nil) t.assertFinalizeBatch(&wg, respChan, "failed to load tapscript tree") - t.assertLastBatchState(batchCount, tapgarden.BatchStateFrozen) + t.assertLastBatchState( + batchCount, tapgarden.BatchStateSeedlingCancelled, + ) t.assertNoPendingBatch() // Reset the tapscript tree store to not force load or store failures. @@ -1729,9 +1703,7 @@ func testFinalizeWithTapscriptTree(t *mintingTestHarness) { // Verify that the final minting output key matches what we would derive // manually. - siblingHash, err := siblingPreimage.TapHash() - require.NoError(t, err) - t.assertMintOutputKey(batchWithSibling, siblingHash) + t.assertMintOutputKey(batchWithSibling, &siblingPreimage) } // testFundFailSiblingNotLeaked verifies that when a finalize attempt @@ -2033,7 +2005,8 @@ func testFundSealBeforeFinalize(t *mintingTestHarness) { // seedling. First we need the seedling asset ID and group internal key. seedlingWithGroupTapscriptRoot := fundedBatch. UnsealedSeedlings[secondSeedling] - seedlingAssetID := seedlingWithGroupTapscriptRoot.NewAsset.ID() + seedlingAssetID := + seedlingWithGroupTapscriptRoot.KeyRequest.NewAsset.ID() derivedInternalKey := seedlingWithGroupTapscriptRoot.GroupInternalKey // Now we can build the control block for using the hash lock script. @@ -2110,7 +2083,7 @@ func testFundSealBeforeFinalize(t *mintingTestHarness) { t.assertNumCaretakersActive(0) t.assertLastBatchState(1, tapgarden.BatchStateFinalized) - t.assertMintOutputKey(mintedBatch, &defaultTapHash) + t.assertMintOutputKey(mintedBatch, &defaultPreimage) } func testFundSealOnRestart(t *mintingTestHarness) { @@ -2197,46 +2170,14 @@ func testFundSealOnRestart(t *mintingTestHarness) { t.assertNumCaretakersActive(0) t.assertLastBatchState(batchCount, tapgarden.BatchStateFinalized) - // Submit another batch, which we'll leave as pending. - secondSeedlings := t.newRandSeedlings(numSeedlings) - t.queueSeedlingsInBatch(false, secondSeedlings...) - batchCount++ - - t.assertLastBatchState(batchCount, tapgarden.BatchStatePending) - require.NoError(t, t.planter.Stop()) - t.planter = nil - - // We should also be able to resume one batch even when resuming another - // batch fails. Since we can only queue one batch at a time, we'll - // insert another pending batch on disk while the planter is shut down. - dbBatch := t.createExternalBatch(numSeedlings) - batchCount++ - err := t.store.CommitMintingBatch(context.Background(), dbBatch) - require.NoError(t, err) - - // With two pending batches on disk, we want resume for the first batch - // to fail. Resume for the second batch should succeed. - t.chain.FailFeeEstimatesOnce() - failedBatchCount++ - - t.assertBatchResumedBackground(&wg, true, false) - t.assertBatchResumedBackground(&wg, true, true) - t.refreshChainPlanter() - wg.Wait() - - t.assertNumCaretakersActive(1) - t.assertNoPendingBatch() - - sendConfNtfn = t.progressCaretaker(true, nil, nil) - t.assertLastBatchState(batchCount, tapgarden.BatchStateBroadcast) - - sendConfNtfn() - t.assertNoError() - t.assertNumCaretakersActive(0) - t.assertNumBatchesWithState( - failedBatchCount, tapgarden.BatchStateSeedlingCancelled, - ) - t.assertLastBatchState(batchCount, tapgarden.BatchStateFinalized) + // The original test continued by inserting a second Pending + // batch directly on disk to exercise recovery when multiple + // pending batches were present. That scenario is now forbidden + // by the singleton invariant added in migration 000060, so the + // section has been removed. The single-pending-batch recovery + // paths exercised above are the surviving useful coverage; the + // "multiple pre-broadcast batches" case is covered by + // TestSingletonPreBroadcastBatchConstraint in tapdb. } // mintingStoreTestCase is used to programmatically run a series of test cases @@ -2308,7 +2249,7 @@ func TestBatchedAssetIssuance(t *testing.T) { func TestGroupKeyRevealV1WitnessWithCustomRoot(t *testing.T) { var ( ctx = context.Background() - mockKeyRing = tapgarden.NewMockKeyRing() + mockKeyRing = tapnodemock.NewKeyRing() mockSigner = tapgarden.NewMockGenSigner(mockKeyRing) txBuilder = &tapscript.GroupTxBuilder{} txValidator = &tap.ValidatorV0{} @@ -2471,7 +2412,7 @@ func TestGroupKeyRevealV1WitnessWithCustomRoot(t *testing.T) { func TestGroupKeyRevealV1WitnessNoScripts(t *testing.T) { var ( ctx = context.Background() - mockKeyRing = tapgarden.NewMockKeyRing() + mockKeyRing = tapnodemock.NewKeyRing() mockSigner = tapgarden.NewMockGenSigner(mockKeyRing) txBuilder = &tapscript.GroupTxBuilder{} txValidator = &tap.ValidatorV0{} diff --git a/tapgarden/seedling.go b/tapgarden/seedling.go index ffc9676b67..deb5e293bd 100644 --- a/tapgarden/seedling.go +++ b/tapgarden/seedling.go @@ -1,6 +1,7 @@ package tapgarden import ( + "bytes" "crypto/sha256" "fmt" @@ -23,36 +24,9 @@ var ( ErrInvalidAssetAmt = fmt.Errorf("asset amt cannot be zero") ) -// MintingState is an enum that tracks an asset through the various minting -// stages. -type MintingState uint8 - -const ( - // MintingStateNone is the default state, no actions have been taken. - MintingStateNone MintingState = iota - - // MintingStateSeed denotes the seedling as been added to a batch. - MintingStateSeed - - // MintingStateSeedling denotes that a seedling has been finalized in a - // batch and now has a corresponding asset associated with it. - MintingStateSeedling - - // MintingStateSprout denotes that a seedling has been paired with a - // genesis transaction and broadcast for confirmation. - MintingStateSprout - - // MintingStateAdult denotes that a seedling has been confirmed on - // chain and reached full adulthood. - MintingStateAdult -) - // SeedlingUpdate is a struct used to send notifications w.r.t the state of a // seedling back to the caller. type SeedlingUpdate struct { - // NewState is the new state a seedling has transitioned to. - NewState MintingState - // PendingBatch is the current pending batch that the seedling has been // added to. PendingBatch *MintingBatch @@ -139,6 +113,54 @@ type Seedling struct { ExternalKey fn.Option[asset.ExternalKey] } +// Copy returns a deep copy of the seedling. Every pointer and slice field +// is duplicated so the caller can mutate the result without affecting the +// source. The updates channel is shared by design: it is the per-seedling +// completion bus, and a snapshot consumer that watched a private copy +// would never observe completion. +// +// See TestMintingBatchCopyIsDeep for the invariant pinned by tests. +func (s *Seedling) Copy() *Seedling { + if s == nil { + return nil + } + out := &Seedling{ + AssetVersion: s.AssetVersion, + AssetType: s.AssetType, + AssetName: s.AssetName, + Meta: s.Meta.Copy(), + Amount: s.Amount, + GroupInfo: s.GroupInfo.Copy(), + EnableEmission: s.EnableEmission, + SupplyCommitments: s.SupplyCommitments, + updates: s.updates, + ScriptKey: s.ScriptKey.Copy(), + GroupTapscriptRoot: bytes.Clone( + s.GroupTapscriptRoot, + ), + } + + s.DelegationKey.WhenSome(func(kd keychain.KeyDescriptor) { + out.DelegationKey = fn.Some(asset.CopyKeyDescriptor(kd)) + }) + + if s.GroupAnchor != nil { + ga := *s.GroupAnchor + out.GroupAnchor = &ga + } + + if s.GroupInternalKey != nil { + gik := asset.CopyKeyDescriptor(*s.GroupInternalKey) + out.GroupInternalKey = &gik + } + + s.ExternalKey.WhenSome(func(ek asset.ExternalKey) { + out.ExternalKey = fn.Some(ek.Copy()) + }) + + return out +} + // validateFields attempts to validate the set of input fields for the passed // seedling, an error is returned if any of the fields are out of spec. // diff --git a/tapgarden/supply_commit_test.go b/tapgarden/supply_commit_test.go deleted file mode 100644 index 893bbbebe4..0000000000 --- a/tapgarden/supply_commit_test.go +++ /dev/null @@ -1,195 +0,0 @@ -package tapgarden - -import ( - "context" - "testing" - "time" - - "github.com/btcsuite/btcd/wire" - "github.com/lightninglabs/taproot-assets/asset" - "github.com/lightninglabs/taproot-assets/proof" - "github.com/lightninglabs/taproot-assets/universe" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" -) - -// MockSupplyCommitManager is a mock implementation of the SupplyCommitManager -// interface for testing. -type MockSupplyCommitManager struct { - mock.Mock -} - -// MockDelegationKeyChecker is a mock implementation of the DelegationKeyChecker -// interface for testing. -type MockDelegationKeyChecker struct { - mock.Mock -} - -// HasDelegationKey implements the DelegationKeyChecker interface. -func (m *MockDelegationKeyChecker) HasDelegationKey(ctx context.Context, - assetID asset.ID) (bool, error) { - - args := m.Called(ctx, assetID) - return args.Bool(0), args.Error(1) -} - -// SendEvent implements the SupplyCommitManager interface. -func (m *MockSupplyCommitManager) SendEvent(ctx context.Context, - assetSpec asset.Specifier, event interface{}) error { - - args := m.Called(ctx, assetSpec, event) - return args.Error(0) -} - -// SendMintEvent implements the SupplyCommitManager interface. -func (m *MockSupplyCommitManager) SendMintEvent(ctx context.Context, - assetSpec asset.Specifier, leafKey universe.UniqueLeafKey, - issuanceProof universe.Leaf, mintBlockHeight uint32) error { - - args := m.Called( - ctx, assetSpec, leafKey, issuanceProof, mintBlockHeight, - ) - return args.Error(0) -} - -// SendBurnEvent implements the SupplyCommitManager interface. -func (m *MockSupplyCommitManager) SendBurnEvent(ctx context.Context, - assetSpec asset.Specifier, burnLeaf universe.BurnLeaf) error { - - args := m.Called(ctx, assetSpec, burnLeaf) - return args.Error(0) -} - -// TestSupplyCommitDelegationKeyFiltering tests that supply commit events -// are only sent for assets where we control the delegation key. -func TestSupplyCommitDelegationKeyFiltering(t *testing.T) { - t.Parallel() - - ctx := context.Background() - - asset1 := asset.RandAsset(t, asset.Normal) - asset2 := asset.RandAsset(t, asset.Normal) - asset3 := asset.RandAsset(t, asset.Normal) - - tests := []struct { - name string - assets []*asset.Asset - delegationKeyResponses map[asset.ID]bool - expectedCallCount int - }{ - { - name: "all assets have delegation key", - assets: []*asset.Asset{asset1, asset2}, - delegationKeyResponses: map[asset.ID]bool{ - asset1.ID(): true, - asset2.ID(): true, - }, - expectedCallCount: 2, - }, - { - name: "only one asset has delegation key", - assets: []*asset.Asset{asset1, asset2, asset3}, - delegationKeyResponses: map[asset.ID]bool{ - asset1.ID(): true, - asset2.ID(): false, - asset3.ID(): true, - }, - expectedCallCount: 2, - }, - { - name: "no assets have delegation key", - assets: []*asset.Asset{asset1, asset2}, - delegationKeyResponses: map[asset.ID]bool{ - asset1.ID(): false, - asset2.ID(): false, - }, - expectedCallCount: 0, - }, - } - - for _, tc := range tests { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - // First, we'll set up our series of mocks, and then - // record the intended responses for each of them. - mockCommitter := &MockSupplyCommitManager{} - mockDelegationChecker := &MockDelegationKeyChecker{} - - // Set up delegation key checker responses - for assetID, hasKey := range tc.delegationKeyResponses { - mockDelegationChecker.On( - "HasDelegationKey", ctx, assetID, - ).Return(hasKey, nil) - } - - // If we're expecting any calls, then we'll make sure to - // register that here. - if tc.expectedCallCount > 0 { - //nolint:lll - mockCommitter.On("SendMintEvent", - ctx, - mock.AnythingOfType("asset.Specifier"), - mock.AnythingOfType( - "universe.AssetLeafKey", - ), - mock.AnythingOfType("universe.Leaf"), - mock.AnythingOfType("uint32")). - Return(nil). - Times(tc.expectedCallCount) - } - - // With the mocks registered above, we'll create a new - // care taker instance that uses them. - //nolint:lll - caretaker := &BatchCaretaker{ - cfg: &BatchCaretakerConfig{ - GardenKit: GardenKit{ - MintSupplyCommitter: mockCommitter, - DelegationKeyChecker: mockDelegationChecker, - }, - }, - } - - // Next, we'll create a series of proofs for each of the - // assets. - proofs := make(proof.AssetProofs) - dummyTx := &wire.MsgTx{ - Version: 2, - TxIn: []*wire.TxIn{{ - PreviousOutPoint: wire.OutPoint{}, - SignatureScript: []byte{}, - Sequence: 0xffffffff, - }}, - } - block := wire.MsgBlock{ - Header: wire.BlockHeader{ - Version: 1, - Timestamp: time.Now(), - Bits: 0x207fffff, - }, - Transactions: []*wire.MsgTx{dummyTx}, - } - for i, a := range tc.assets { - scriptKey := asset.ToSerialized( - a.ScriptKey.PubKey, - ) - testProof := proof.RandProof( - t, a.Genesis, a.ScriptKey.PubKey, block, - 0, uint32(i), - ) - proofs[scriptKey] = &testProof - } - - // Call the internal method, then verify the expected - // calls were made. - err := caretaker.sendSupplyCommitEvents( - ctx, tc.assets, nil, proofs, - ) - require.NoError(t, err) - mockCommitter.AssertExpectations(t) - mockDelegationChecker.AssertExpectations(t) - }) - } -} diff --git a/tapnode/chain_bridge.go b/tapnode/chain_bridge.go new file mode 100644 index 0000000000..fb1e8c42c6 --- /dev/null +++ b/tapnode/chain_bridge.go @@ -0,0 +1,79 @@ +package tapnode + +import ( + "context" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + +// ChainBridge is our bridge to the target chain. It's used to get +// confirmation notifications, the current height, publish +// transactions, and also estimate fees. +type ChainBridge interface { + proof.ChainLookupGenerator + + // RegisterConfirmationsNtfn registers an intent to be notified + // once txid reaches numConfs confirmations. + RegisterConfirmationsNtfn(ctx context.Context, txid *chainhash.Hash, + pkScript []byte, numConfs, heightHint uint32, + includeBlock bool, + reOrgChan chan struct{}) (*chainntnfs.ConfirmationEvent, + chan error, error) + + // RegisterBlockEpochNtfn registers an intent to be notified of + // each new block connected to the main chain. + RegisterBlockEpochNtfn(ctx context.Context) (chan int32, chan error, + error) + + // GetBlock returns a chain block given its hash. + GetBlock(context.Context, chainhash.Hash) (*wire.MsgBlock, error) + + // GetBlockByHeight returns a chain block given its height. + GetBlockByHeight(ctx context.Context, + blockHeight int64) (*wire.MsgBlock, error) + + // GetBlockHash returns the hash of the block in the best + // blockchain at the given height. + GetBlockHash(context.Context, int64) (chainhash.Hash, error) + + // VerifyBlock returns an error if a block (with given header and + // height) is not present on-chain. It also checks to ensure that + // block height corresponds to the given block header. + VerifyBlock(ctx context.Context, header wire.BlockHeader, + height uint32) error + + // CurrentHeight return the current height of the main chain. + CurrentHeight(context.Context) (uint32, error) + + // GetBlockTimestamp returns the timestamp of the block at the + // given height. + GetBlockTimestamp(context.Context, uint32) (int64, error) + + // GetBlockHeaderByHeight returns a block header given the block + // height. + GetBlockHeaderByHeight(ctx context.Context, + blockHeight int64) (*wire.BlockHeader, error) + + // PublishTransaction attempts to publish a new transaction to + // the network. + PublishTransaction(context.Context, *wire.MsgTx, string) error + + // EstimateFee returns a fee estimate for the confirmation + // target. + EstimateFee(ctx context.Context, + confTarget uint32) (chainfee.SatPerKWeight, error) +} + +// GenHeaderVerifier returns a proof header verifier backed by the +// given chain bridge. +func GenHeaderVerifier(ctx context.Context, + chainBridge ChainBridge) func(wire.BlockHeader, uint32) error { + + return func(header wire.BlockHeader, height uint32) error { + return chainBridge.VerifyBlock(ctx, header, height) + } +} diff --git a/tapnode/doc.go b/tapnode/doc.go new file mode 100644 index 0000000000..15fd500928 --- /dev/null +++ b/tapnode/doc.go @@ -0,0 +1,16 @@ +// Package tapnode declares the abstractions over node-side services +// that tapd depends on: the chain backend, the on-chain wallet, the +// key ring, and small read-only accessors that are likewise satisfied +// by upstream services rather than tapd's own logic. +// +// Concrete implementations live in lndservices. Consumers +// (tapgarden, tapfreighter, tapchannel, universe/supplycommit, +// universe/supplyverifier, ...) import this package to obtain the +// interfaces alone; they need not depend on the lnd-backed +// implementations to compile. +// +// Historically these interfaces lived in tapgarden/interface.go, +// which conflated tapd's minting substance with the substrate it +// depends on. They were hoisted here so that the substrate is named +// for what it is. +package tapnode diff --git a/tapnode/group_fetcher.go b/tapnode/group_fetcher.go new file mode 100644 index 0000000000..0d5d03803b --- /dev/null +++ b/tapnode/group_fetcher.go @@ -0,0 +1,17 @@ +package tapnode + +import ( + "context" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/taproot-assets/asset" +) + +// GroupFetcher is an interface that allows fetching of asset groups. +type GroupFetcher interface { + // FetchGroupByGroupKey fetches the asset group with a matching + // tweaked key, including the genesis information used to create + // the group. + FetchGroupByGroupKey(ctx context.Context, + groupKey *btcec.PublicKey) (*asset.AssetGroup, error) +} diff --git a/tapnode/group_verifier.go b/tapnode/group_verifier.go new file mode 100644 index 0000000000..bf330c7286 --- /dev/null +++ b/tapnode/group_verifier.go @@ -0,0 +1,185 @@ +package tapnode + +import ( + "context" + "errors" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/neutrino/cache/lru" + "github.com/lightninglabs/taproot-assets/asset" +) + +var ( + // ErrGroupKeyUnknown is an error returned if an asset has a group key + // that is not known to the local node. + ErrGroupKeyUnknown = errors.New("group key not known") + + // ErrGenesisNotGroupAnchor is an error returned if an asset has a group + // key but is not the anchor of the group. + ErrGenesisNotGroupAnchor = errors.New("genesis not group anchor") +) + +// assetGroupCacheSize is the size of the cache for group keys. +const assetGroupCacheSize = 10000 + +// emptyVal is a simple type def around struct{} to use as a dummy value in +// caches that only need set semantics. +type emptyVal struct{} + +// singleCacheValue is a single-element cache value wrapper that also implements +// the lru.Value sizing interface. +type singleCacheValue[T any] struct { + val T +} + +// Size returns the constant size of a singleCacheValue. +func (s singleCacheValue[T]) Size() (uint64, error) { + return 1, nil +} + +// newSingleValue constructs a singleCacheValue carrying v. +func newSingleValue[T any](v T) singleCacheValue[T] { + return singleCacheValue[T]{ + val: v, + } +} + +// emptyCacheVal is a type def for an empty cache value. In this case the cache +// is used more as a set. +type emptyCacheVal = singleCacheValue[emptyVal] + +// GenGroupVerifier generates a group key verification callback function given a +// GroupFetcher. +func GenGroupVerifier(ctx context.Context, + groupFetcher GroupFetcher) func(*btcec.PublicKey) error { + + // Cache known group keys that were previously fetched. + assetGroups := lru.NewCache[asset.SerializedKey, emptyCacheVal]( + assetGroupCacheSize, + ) + + return func(groupKey *btcec.PublicKey) error { + if groupKey == nil { + return fmt.Errorf("cannot verify empty group key") + } + + assetGroupKey := asset.ToSerialized(groupKey) + _, err := assetGroups.Get(assetGroupKey) + if err == nil { + return nil + } + + // This query will err if no stored group has a matching + // tweaked group key. + _, err = groupFetcher.FetchGroupByGroupKey(ctx, groupKey) + if err != nil { + return fmt.Errorf("%x: group verifier: %s: %w", + assetGroupKey[:], err.Error(), + ErrGroupKeyUnknown) + } + + _, _ = assetGroups.Put(assetGroupKey, emptyCacheVal{}) + + return nil + } +} + +// GenGroupAnchorVerifier generates a caching group anchor verification +// callback function given a GroupFetcher. +func GenGroupAnchorVerifier(ctx context.Context, + groupFetcher GroupFetcher) func(*asset.Genesis, + *asset.GroupKey) error { + + // Cache anchors for groups that were previously fetched. + groupAnchors := lru.NewCache[ + asset.SerializedKey, singleCacheValue[*asset.Genesis], + ]( + assetGroupCacheSize, + ) + + return func(gen *asset.Genesis, groupKey *asset.GroupKey) error { + assetGroupKey := asset.ToSerialized(&groupKey.GroupPubKey) + groupAnchor, err := groupAnchors.Get(assetGroupKey) + if err != nil { + storedGroup, err := groupFetcher.FetchGroupByGroupKey( + ctx, &groupKey.GroupPubKey, + ) + if err != nil { + return fmt.Errorf("%x: group anchor verifier: "+ + "%w", assetGroupKey[:], + ErrGroupKeyUnknown) + } + + isGroupAnchor, err := storedGroup.IsGroupAnchor() + if err != nil { + return fmt.Errorf("%x: group anchor verifier: "+ + "unable to check if genesis is "+ + "group anchor: %w", assetGroupKey[:], + err) + } + + if !isGroupAnchor { + return fmt.Errorf("%x: group anchor verifier: "+ + "genesis is not a group anchor: %w", + assetGroupKey[:], err) + } + + groupAnchor = newSingleValue(storedGroup.Genesis) + + _, _ = groupAnchors.Put(assetGroupKey, groupAnchor) + } + + if gen.ID() != groupAnchor.val.ID() { + return ErrGenesisNotGroupAnchor + } + + return nil + } +} + +// GenRawGroupAnchorVerifier generates a group anchor verification callback +// function. This anchor verifier recomputes the tweaked group key with the +// passed genesis and compares that key to the given group key. This verifier +// is used before any asset groups are stored in the DB. +func GenRawGroupAnchorVerifier(ctx context.Context) func(*asset.Genesis, + *asset.GroupKey) error { + + // Cache group anchors we already verified. + groupAnchors := lru.NewCache[ + asset.SerializedKey, singleCacheValue[*asset.Genesis]]( + assetGroupCacheSize, + ) + + return func(gen *asset.Genesis, groupKey *asset.GroupKey) error { + assetGroupKey := asset.ToSerialized(&groupKey.GroupPubKey) + groupAnchor, err := groupAnchors.Get(assetGroupKey) + if err != nil { + singleTweak := gen.ID() + tweakedGroupKey, err := asset.GroupPubKeyV0( + groupKey.RawKey.PubKey, singleTweak[:], + groupKey.TapscriptRoot, + ) + if err != nil { + return err + } + + computedGroupKey := asset.ToSerialized(tweakedGroupKey) + if computedGroupKey != assetGroupKey { + return ErrGenesisNotGroupAnchor + } + + groupAnchor = newSingleValue(gen) + + _, _ = groupAnchors.Put(assetGroupKey, groupAnchor) + + return nil + } + + if gen.ID() != groupAnchor.val.ID() { + return ErrGenesisNotGroupAnchor + } + + return nil + } +} diff --git a/tapnode/key_ring.go b/tapnode/key_ring.go new file mode 100644 index 0000000000..83e49000da --- /dev/null +++ b/tapnode/key_ring.go @@ -0,0 +1,37 @@ +package tapnode + +import ( + "context" + "crypto/sha256" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightningnetwork/lnd/keychain" +) + +// KeyRing is a mirror of the keychain.KeyRing interface, with the +// addition of a passed context which allows for cancellation of +// requests. +type KeyRing interface { + // DeriveNextKey attempts to derive the *next* key within the key + // family (account in BIP-0043) specified. This method should + // return the next external child within this branch. + DeriveNextKey(context.Context, + keychain.KeyFamily) (keychain.KeyDescriptor, error) + + // IsLocalKey returns true if the key is under the control of the + // wallet and can be derived by it. + IsLocalKey(context.Context, keychain.KeyDescriptor) bool + + // DeriveSharedKey returns a shared secret key by performing + // Diffie-Hellman key derivation between the ephemeral public key + // and the key specified by the key locator (or the node's + // identity private key if no key locator is specified): + // + // P_shared = privKeyNode * ephemeralPubkey + // + // The resulting shared public key is serialized in the + // compressed format and hashed with SHA256, resulting in a final + // key length of 256 bits. + DeriveSharedKey(context.Context, *btcec.PublicKey, + *keychain.KeyLocator) ([sha256.Size]byte, error) +} diff --git a/tapnode/tapnodemock/chain_bridge.go b/tapnode/tapnodemock/chain_bridge.go new file mode 100644 index 0000000000..d1cff5b566 --- /dev/null +++ b/tapnode/tapnodemock/chain_bridge.go @@ -0,0 +1,277 @@ +package tapnodemock + +import ( + "context" + "fmt" + "sync/atomic" + "time" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tapnode" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + +// ChainBridge is an in-memory mock implementation of tapnode.ChainBridge. +type ChainBridge struct { + FeeEstimateSignal chan struct{} + PublishReq chan *wire.MsgTx + ConfReqSignal chan int + BlockEpochSignal chan struct{} + + NewBlocks chan int32 + + ReqCount atomic.Int32 + ConfReqs map[int]*chainntnfs.ConfirmationEvent + + Blocks map[chainhash.Hash]*wire.MsgBlock + + failFeeEstimates atomic.Bool + errConf atomic.Int32 + emptyConf atomic.Int32 + confErr chan error +} + +// NewChainBridge returns a freshly-initialised mock ChainBridge. +func NewChainBridge() *ChainBridge { + return &ChainBridge{ + FeeEstimateSignal: make(chan struct{}), + PublishReq: make(chan *wire.MsgTx), + ConfReqs: make(map[int]*chainntnfs.ConfirmationEvent), + ConfReqSignal: make(chan int), + BlockEpochSignal: make(chan struct{}, 1), + NewBlocks: make(chan int32), + Blocks: make(map[chainhash.Hash]*wire.MsgBlock), + } +} + +// FailFeeEstimatesOnce arms the next call to EstimateFee to return an error. +func (m *ChainBridge) FailFeeEstimatesOnce() { + m.failFeeEstimates.Store(true) +} + +// FailConfOnce updates the ChainBridge such that the next call to +// RegisterConfirmationNtfn will fail by returning an error on the error channel +// returned from RegisterConfirmationNtfn. +func (m *ChainBridge) FailConfOnce() { + // Store the incremented request count so we never store 0 as a value. + m.errConf.Store(m.ReqCount.Load() + 1) +} + +// EmptyConfOnce updates the ChainBridge such that the next confirmation event +// sent via SendConfNtfn will have an empty confirmation. +func (m *ChainBridge) EmptyConfOnce() { + // Store the incremented request count so we never store 0 as a value. + m.emptyConf.Store(m.ReqCount.Load() + 1) +} + +// SendConfNtfn dispatches a synthetic confirmation event to the watcher +// registered as request reqNo. +func (m *ChainBridge) SendConfNtfn(reqNo int, blockHash *chainhash.Hash, + blockHeight, blockIndex int, block *wire.MsgBlock, + tx *wire.MsgTx) { + + // Compare to the incremented request count since we incremented it + // when storing the request number. + req := m.ConfReqs[reqNo] + if m.emptyConf.Load() == int32(reqNo)+1 { + m.emptyConf.Store(0) + req.Confirmed <- nil + return + } + + req.Confirmed <- &chainntnfs.TxConfirmation{ + BlockHash: blockHash, + BlockHeight: uint32(blockHeight), + TxIndex: uint32(blockIndex), + Block: block, + Tx: tx, + } +} + +// RegisterConfirmationsNtfn records a confirmation subscription and signals +// the caller via ConfReqSignal. +func (m *ChainBridge) RegisterConfirmationsNtfn(ctx context.Context, + _ *chainhash.Hash, _ []byte, _, _ uint32, _ bool, + _ chan struct{}) (*chainntnfs.ConfirmationEvent, chan error, error) { + + select { + case <-ctx.Done(): + return nil, nil, fmt.Errorf("shutting down") + default: + } + + defer func() { + m.ReqCount.Add(1) + }() + + req := &chainntnfs.ConfirmationEvent{ + Confirmed: make(chan *chainntnfs.TxConfirmation), + Cancel: func() {}, + } + m.confErr = make(chan error, 1) + + currentReqCount := m.ReqCount.Load() + m.ConfReqs[int(currentReqCount)] = req + + select { + case m.ConfReqSignal <- int(currentReqCount): + case <-ctx.Done(): + } + + // Compare to the incremented request count since we incremented it + // when storing the request number. + if m.errConf.CompareAndSwap(currentReqCount+1, 0) { + m.confErr <- fmt.Errorf("confirmation registration error") + } + + return req, m.confErr, nil +} + +// RegisterBlockEpochNtfn returns the mock's NewBlocks channel and signals +// startup via BlockEpochSignal. +func (m *ChainBridge) RegisterBlockEpochNtfn( + ctx context.Context) (chan int32, chan error, error) { + + select { + case <-ctx.Done(): + return nil, nil, fmt.Errorf("shutting down") + default: + } + + select { + case m.BlockEpochSignal <- struct{}{}: + case <-ctx.Done(): + } + + return m.NewBlocks, make(chan error), nil +} + +// GetBlock returns a chain block given its hash. +func (m *ChainBridge) GetBlock(ctx context.Context, + hash chainhash.Hash) (*wire.MsgBlock, error) { + + block, ok := m.Blocks[hash] + if !ok { + return nil, fmt.Errorf("block %s not found", hash.String()) + } + + return block, nil +} + +// GetBlockByHeight returns a block given the block height. +func (m *ChainBridge) GetBlockByHeight(ctx context.Context, + blockHeight int64) (*wire.MsgBlock, error) { + + return &wire.MsgBlock{}, nil +} + +// GetBlockHeaderByHeight returns a block header given the block height. +func (m *ChainBridge) GetBlockHeaderByHeight(ctx context.Context, + blockHeight int64) (*wire.BlockHeader, error) { + + return &wire.BlockHeader{}, nil +} + +// GetBlockHash returns the hash of the block in the best blockchain at the +// given height. +func (m *ChainBridge) GetBlockHash(ctx context.Context, + blockHeight int64) (chainhash.Hash, error) { + + return chainhash.Hash{}, nil +} + +// VerifyBlock returns an error if a block (with given header and height) is not +// present on-chain. It also checks to ensure that block height corresponds to +// the given block header. +func (m *ChainBridge) VerifyBlock(_ context.Context, + _ wire.BlockHeader, _ uint32) error { + + return nil +} + +// CurrentHeight returns the mock's current chain height (always 0). +func (m *ChainBridge) CurrentHeight(_ context.Context) (uint32, error) { + return 0, nil +} + +// GetBlockTimestamp returns the timestamp of the block at the given height. +func (m *ChainBridge) GetBlockTimestamp(_ context.Context, _ uint32) (int64, + error) { + + return 0, nil +} + +// PublishTransaction records the transaction to PublishReq. +func (m *ChainBridge) PublishTransaction(_ context.Context, + tx *wire.MsgTx, _ string) error { + + m.PublishReq <- tx + return nil +} + +// EstimateFee returns chainfee.FeePerKwFloor unless FailFeeEstimatesOnce was +// armed, in which case it returns an error once. +func (m *ChainBridge) EstimateFee(ctx context.Context, + _ uint32) (chainfee.SatPerKWeight, error) { + + select { + case m.FeeEstimateSignal <- struct{}{}: + + case <-ctx.Done(): + return 0, fmt.Errorf("shutting down") + } + + if m.failFeeEstimates.Load() { + m.failFeeEstimates.Store(false) + return 0, fmt.Errorf("failed to estimate fee") + } + + return chainfee.FeePerKwFloor, nil +} + +// TxBlockHeight returns the block height that the given transaction was +// included in. +func (m *ChainBridge) TxBlockHeight(context.Context, + chainhash.Hash) (uint32, error) { + + return 123, nil +} + +// MeanBlockTimestamp returns the timestamp of the block at the given height as +// a Unix timestamp in seconds, taking into account the mean time elapsed over +// the previous 11 blocks. +func (m *ChainBridge) MeanBlockTimestamp(context.Context, + uint32) (time.Time, error) { + + return time.Now(), nil +} + +// GenFileChainLookup generates a chain lookup interface for the given +// proof file that can be used to validate proofs. +func (m *ChainBridge) GenFileChainLookup(*proof.File) asset.ChainLookup { + return m +} + +// GenProofChainLookup generates a chain lookup interface for the given +// single proof that can be used to validate proofs. +func (m *ChainBridge) GenProofChainLookup(*proof.Proof) (asset.ChainLookup, + error) { + + return m, nil +} + +var _ asset.ChainLookup = (*ChainBridge)(nil) +var _ tapnode.ChainBridge = (*ChainBridge)(nil) + +// GenGroupVerifier returns a no-op group verifier suitable for tests that +// don't care about group-key authenticity. +func GenGroupVerifier() func(*btcec.PublicKey) error { + return func(groupKey *btcec.PublicKey) error { + return nil + } +} diff --git a/tapnode/tapnodemock/doc.go b/tapnode/tapnodemock/doc.go new file mode 100644 index 0000000000..6335227ca4 --- /dev/null +++ b/tapnode/tapnodemock/doc.go @@ -0,0 +1,9 @@ +// Package tapnodemock provides in-memory mock implementations of the +// node-side interfaces declared in tapnode. They are intended for use +// in tests across the repo, where the lnd-backed implementations in +// lndservices are unavailable. +// +// The mocks live in their own subpackage rather than alongside the +// interfaces so that production code paths cannot depend on them +// transitively. +package tapnodemock diff --git a/tapnode/tapnodemock/key_ring.go b/tapnode/tapnodemock/key_ring.go new file mode 100644 index 0000000000..051d263538 --- /dev/null +++ b/tapnode/tapnodemock/key_ring.go @@ -0,0 +1,201 @@ +package tapnodemock + +import ( + "context" + "crypto/sha256" + "fmt" + "sync" + "sync/atomic" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/tapnode" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/mock" +) + +// KeyRing is an in-memory mock implementation of tapnode.KeyRing. +type KeyRing struct { + mock.Mock + + sync.RWMutex + + KeyIndex uint32 + + Keys map[keychain.KeyLocator]*btcec.PrivateKey + + deriveNextKeyCallCount atomic.Uint64 +} + +// NewKeyRing returns a freshly-initialised mock KeyRing with the default +// DeriveNextKey / DeriveNextTaprootAssetKey expectations registered. +func NewKeyRing() *KeyRing { + keyRing := &KeyRing{ + Keys: make(map[keychain.KeyLocator]*btcec.PrivateKey), + } + + keyRing.On( + "DeriveNextKey", mock.Anything, + keychain.KeyFamily(asset.TaprootAssetsKeyFamily), + ).Return(keychain.KeyDescriptor{}, nil) + + keyRing.On( + "DeriveNextTaprootAssetKey", mock.Anything, + ).Return(keychain.KeyDescriptor{}, nil) + + return keyRing +} + +// DeriveNextTaprootAssetKey attempts to derive the *next* key within the +// Taproot Asset key family. +func (m *KeyRing) DeriveNextTaprootAssetKey( + ctx context.Context) (keychain.KeyDescriptor, error) { + + // No need to lock mutex here, DeriveNextKey does that for us. + m.Called(ctx) + + return m.DeriveNextKey(ctx, asset.TaprootAssetsKeyFamily) +} + +// DeriveNextKey returns a fresh keychain descriptor backed by a newly- +// generated private key, recorded in m.Keys. +func (m *KeyRing) DeriveNextKey(ctx context.Context, + keyFam keychain.KeyFamily) (keychain.KeyDescriptor, error) { + + m.Lock() + defer func() { + m.KeyIndex++ + m.Unlock() + }() + + m.Called(ctx, keyFam) + m.deriveNextKeyCallCount.Add(1) + + select { + case <-ctx.Done(): + return keychain.KeyDescriptor{}, fmt.Errorf("shutting down") + default: + } + + priv, err := btcec.NewPrivateKey() + if err != nil { + return keychain.KeyDescriptor{}, err + } + + loc := keychain.KeyLocator{ + Index: m.KeyIndex, + Family: keyFam, + } + + m.Keys[loc] = priv + + desc := keychain.KeyDescriptor{ + PubKey: priv.PubKey(), + KeyLocator: loc, + } + + return desc, nil +} + +// IsLocalKey reports whether the given descriptor is for a key the mock +// previously derived. +func (m *KeyRing) IsLocalKey(ctx context.Context, + d keychain.KeyDescriptor) bool { + + m.Lock() + defer m.Unlock() + + m.Called(ctx, d) + + priv, ok := m.Keys[d.KeyLocator] + if ok && priv.PubKey().IsEqual(d.PubKey) { + return true + } + + for _, key := range m.Keys { + if key.PubKey().IsEqual(d.PubKey) { + return true + } + } + + return false +} + +// PubKeyAt returns the public key at the given index within the Taproot Assets +// key family, failing the test if no key has been derived at that index. +func (m *KeyRing) PubKeyAt(t *testing.T, idx uint32) *btcec.PublicKey { + m.Lock() + defer m.Unlock() + + loc := keychain.KeyLocator{ + Index: idx, + Family: asset.TaprootAssetsKeyFamily, + } + + priv, ok := m.Keys[loc] + if !ok { + t.Fatalf("script key not found at index %d", idx) + } + + return priv.PubKey() +} + +// ScriptKeyAt returns the BIP-86 script key at the given index within the +// Taproot Assets key family. +func (m *KeyRing) ScriptKeyAt(t *testing.T, idx uint32) asset.ScriptKey { + m.Lock() + defer m.Unlock() + + loc := keychain.KeyLocator{ + Index: idx, + Family: asset.TaprootAssetsKeyFamily, + } + + priv, ok := m.Keys[loc] + if !ok { + t.Fatalf("script key not found at index %d", idx) + } + + return asset.NewScriptKeyBip86(keychain.KeyDescriptor{ + KeyLocator: loc, + PubKey: priv.PubKey(), + }) +} + +// DeriveSharedKey performs DH between the given public key and the +// locator-identified private key in the mock's ring. +func (m *KeyRing) DeriveSharedKey(_ context.Context, key *btcec.PublicKey, + locator *keychain.KeyLocator) ([sha256.Size]byte, error) { + + m.Lock() + defer m.Unlock() + + if locator == nil { + return [32]byte{}, fmt.Errorf("locator is nil") + } + + priv, ok := m.Keys[*locator] + if !ok { + return [32]byte{}, fmt.Errorf("script key not found at index "+ + "%d", locator.Index) + } + + ecdh := &keychain.PrivKeyECDH{ + PrivKey: priv, + } + return ecdh.ECDH(key) +} + +// DeriveNextKeyCallCount returns the number of calls to DeriveNextKey. +func (m *KeyRing) DeriveNextKeyCallCount() int { + return int(m.deriveNextKeyCallCount.Load()) +} + +// ResetDeriveNextKeyCallCount resets the call counter for DeriveNextKey to +// zero. +func (m *KeyRing) ResetDeriveNextKeyCallCount() { + m.deriveNextKeyCallCount.Store(0) +} + +var _ tapnode.KeyRing = (*KeyRing)(nil) diff --git a/tapnode/tapnodemock/wallet_anchor.go b/tapnode/tapnodemock/wallet_anchor.go new file mode 100644 index 0000000000..2487b356c4 --- /dev/null +++ b/tapnode/tapnodemock/wallet_anchor.go @@ -0,0 +1,261 @@ +package tapnodemock + +import ( + "bytes" + "context" + "fmt" + "math/rand/v2" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/taproot-assets/internal/test" + "github.com/lightninglabs/taproot-assets/tapnode" + "github.com/lightninglabs/taproot-assets/tapscript" + "github.com/lightninglabs/taproot-assets/tapsend" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/require" +) + +// WalletAnchor is an in-memory mock implementation of tapnode.WalletAnchor. +type WalletAnchor struct { + FundPsbtSignal chan *tapsend.FundedPsbt + SignPsbtSignal chan struct{} + ImportPubKeySignal chan *btcec.PublicKey + ListUnspentSignal chan struct{} + SubscribeTxSignal chan struct{} + SubscribeTx chan lndclient.Transaction + ListTxnsSignal chan struct{} + + Transactions []lndclient.Transaction + ImportedUtxos []*lnwallet.Utxo +} + +// NewWalletAnchor returns a freshly-initialised mock WalletAnchor. +func NewWalletAnchor() *WalletAnchor { + return &WalletAnchor{ + FundPsbtSignal: make(chan *tapsend.FundedPsbt), + SignPsbtSignal: make(chan struct{}), + ImportPubKeySignal: make(chan *btcec.PublicKey), + ListUnspentSignal: make(chan struct{}), + SubscribeTxSignal: make(chan struct{}), + SubscribeTx: make(chan lndclient.Transaction), + ListTxnsSignal: make(chan struct{}), + } +} + +// NewGenesisTx creates a funded genesis PSBT with the given fee rate. +func NewGenesisTx(t testing.TB, feeRate chainfee.SatPerKWeight) psbt.Packet { + txTemplate := wire.NewMsgTx(2) + txTemplate.AddTxOut(tapsend.CreateDummyOutput()) + genesisPkt, err := psbt.NewFromUnsignedTx(txTemplate) + require.NoError(t, err) + + FundGenesisTx(genesisPkt, feeRate) + return *genesisPkt +} + +// FundGenesisTx add a genesis input and change output to a 1-output TX and +// returns the index of the change output. +func FundGenesisTx(packet *psbt.Packet, feeRate chainfee.SatPerKWeight) uint32 { + const anchorBalance = int64(100000) + + packet.UnsignedTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Index: test.RandInt[uint32](), + }, + }) + + anchorInput := psbt.PInput{ + WitnessUtxo: &wire.TxOut{ + Value: anchorBalance, + PkScript: bytes.Clone(tapsend.GenesisDummyScript), + }, + SighashType: txscript.SigHashDefault, + } + packet.Inputs = append(packet.Inputs, anchorInput) + + changeOutput := wire.TxOut{ + Value: anchorBalance - packet.UnsignedTx.TxOut[0].Value, + PkScript: bytes.Clone(tapsend.GenesisDummyScript), + } + changeOutput.PkScript[0] = txscript.OP_0 + packet.UnsignedTx.AddTxOut(&changeOutput) + packet.Outputs = append(packet.Outputs, psbt.POutput{}) + + _, fee := tapscript.EstimateFee( + [][]byte{tapsend.GenesisDummyScript}, packet.UnsignedTx.TxOut, + feeRate, + ) + changeOutputIdx := len(packet.UnsignedTx.TxOut) - 1 + packet.UnsignedTx.TxOut[changeOutputIdx].Value -= int64(fee) + + return uint32(changeOutputIdx) +} + +// FundPsbt funds a PSBT. +func (m *WalletAnchor) FundPsbt(_ context.Context, packet *psbt.Packet, + _ uint32, _ chainfee.SatPerKWeight, + changeIdx int32) (*tapsend.FundedPsbt, error) { + + // Mock outpoint index; not security-sensitive. + // nolint:gosec + packet.UnsignedTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Index: rand.Uint32(), + }, + }) + + anchorInput := psbt.PInput{ + WitnessUtxo: &wire.TxOut{ + Value: 100000, + PkScript: bytes.Clone(tapsend.GenesisDummyScript), + }, + SighashType: txscript.SigHashDefault, + } + packet.Inputs = append(packet.Inputs, anchorInput) + + changeOutput := wire.TxOut{ + Value: 50000, + PkScript: bytes.Clone(tapsend.GenesisDummyScript), + } + changeOutput.PkScript[0] = txscript.OP_0 + packet.UnsignedTx.AddTxOut(&changeOutput) + packet.Outputs = append(packet.Outputs, psbt.POutput{}) + + changeIdx = int32(len(packet.Outputs) - 1) + + pkt := &tapsend.FundedPsbt{ + Pkt: packet, + ChangeOutputIndex: changeIdx, + } + + m.FundPsbtSignal <- pkt + + return pkt, nil +} + +// SignAndFinalizePsbt fully signs and finalizes the target PSBT packet. +func (m *WalletAnchor) SignAndFinalizePsbt(ctx context.Context, + pkt *psbt.Packet) (*psbt.Packet, error) { + + select { + case <-ctx.Done(): + return nil, fmt.Errorf("shutting down") + default: + } + + // We'll modify the packet by attaching a "signature" so the PSBT + // appears to actually be finalized. + pkt.Inputs[0].FinalScriptSig = []byte{} + + select { + case <-ctx.Done(): + return nil, fmt.Errorf("shutting down") + case m.SignPsbtSignal <- struct{}{}: + } + + return pkt, nil +} + +// ImportTaprootOutput imports a new public key into the wallet, as a P2TR +// output. +func (m *WalletAnchor) ImportTaprootOutput(ctx context.Context, + pub *btcec.PublicKey) (btcutil.Address, error) { + + select { + case m.ImportPubKeySignal <- pub: + + case <-ctx.Done(): + return nil, fmt.Errorf("shutting down") + } + + return btcutil.NewAddressTaproot( + schnorr.SerializePubKey(pub), &chaincfg.RegressionNetParams, + ) +} + +// UnlockInput unlocks the set of target inputs after a batch or send +// transaction is abandoned. +func (m *WalletAnchor) UnlockInput(context.Context, wire.OutPoint) error { + return nil +} + +// ListUnspentImportScripts lists all UTXOs of the imported Taproot scripts. +func (m *WalletAnchor) ListUnspentImportScripts( + ctx context.Context) ([]*lnwallet.Utxo, error) { + + select { + case m.ListUnspentSignal <- struct{}{}: + + case <-ctx.Done(): + return nil, fmt.Errorf("shutting down") + } + + return m.ImportedUtxos, nil +} + +// ImportTapscript imports a Taproot output script into the wallet to track it +// on-chain in a watch-only manner. (Not part of tapnode.WalletAnchor; provided +// here for tests that exercise the broader wallet-anchor surface.) +func (m *WalletAnchor) ImportTapscript(_ context.Context, + ts *waddrmgr.Tapscript) (btcutil.Address, error) { + + taprootKey, err := ts.TaprootKey() + if err != nil { + return nil, err + } + + return btcutil.NewAddressTaproot( + schnorr.SerializePubKey(taprootKey), + &chaincfg.RegressionNetParams, + ) +} + +// SubscribeTransactions creates a uni-directional stream from the server to the +// client in which any newly discovered transactions relevant to the wallet are +// sent over. +func (m *WalletAnchor) SubscribeTransactions(ctx context.Context) ( + <-chan lndclient.Transaction, <-chan error, error) { + + select { + case m.SubscribeTxSignal <- struct{}{}: + + case <-ctx.Done(): + return nil, nil, fmt.Errorf("shutting down") + } + + errChan := make(chan error) + return m.SubscribeTx, errChan, nil +} + +// ListTransactions returns all known transactions of the backing lnd node. +func (m *WalletAnchor) ListTransactions(ctx context.Context, _, _ int32, + _ string) ([]lndclient.Transaction, error) { + + select { + case m.ListTxnsSignal <- struct{}{}: + + case <-ctx.Done(): + return nil, fmt.Errorf("shutting down") + } + + return m.Transactions, nil +} + +// MinRelayFee returns a fixed mock minimum relay fee. +func (m *WalletAnchor) MinRelayFee( + _ context.Context) (chainfee.SatPerKWeight, error) { + + return chainfee.SatPerKWeight(10), nil +} + +var _ tapnode.WalletAnchor = (*WalletAnchor)(nil) diff --git a/tapnode/wallet_anchor.go b/tapnode/wallet_anchor.go new file mode 100644 index 0000000000..fea88f2846 --- /dev/null +++ b/tapnode/wallet_anchor.go @@ -0,0 +1,60 @@ +package tapnode + +import ( + "context" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/taproot-assets/tapsend" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + +// WalletAnchor is the main wallet interface used to manage PSBT +// packets, and import public keys into the wallet. +type WalletAnchor interface { + // FundPsbt attaches enough inputs to the target PSBT packet for + // it to be valid. + FundPsbt(ctx context.Context, packet *psbt.Packet, minConfs uint32, + feeRate chainfee.SatPerKWeight, + changeIdx int32) (*tapsend.FundedPsbt, error) + + // SignAndFinalizePsbt fully signs and finalizes the target PSBT + // packet. + SignAndFinalizePsbt(context.Context, *psbt.Packet) (*psbt.Packet, error) + + // ImportTaprootOutput imports a new public key into the wallet, + // as a P2TR output. + ImportTaprootOutput(context.Context, *btcec.PublicKey) (btcutil.Address, + error) + + // UnlockInput unlocks the set of target inputs after a batch or + // send transaction is abandoned. + UnlockInput(context.Context, wire.OutPoint) error + + // ListUnspentImportScripts lists all UTXOs of the imported + // Taproot scripts. + ListUnspentImportScripts(ctx context.Context) ([]*lnwallet.Utxo, error) + + // ListTransactions returns all known transactions of the backing + // lnd node. It takes a start and end block height which can be + // used to limit the block range that we query over. These values + // can be left as zero to include all blocks. To include + // unconfirmed transactions in the query, endHeight must be set + // to -1. + ListTransactions(ctx context.Context, startHeight, endHeight int32, + account string) ([]lndclient.Transaction, error) + + // SubscribeTransactions creates a uni-directional stream from + // the server to the client in which any newly discovered + // transactions relevant to the wallet are sent over. + SubscribeTransactions(context.Context) (<-chan lndclient.Transaction, + <-chan error, error) + + // MinRelayFee returns the current minimum relay fee based on our + // chain backend in sat/kw. + MinRelayFee(ctx context.Context) (chainfee.SatPerKWeight, error) +} diff --git a/tapreorg/doc.go b/tapreorg/doc.go new file mode 100644 index 0000000000..99f00bb0f4 --- /dev/null +++ b/tapreorg/doc.go @@ -0,0 +1,11 @@ +// Package tapreorg watches initially-confirmed anchor transactions +// until they reach a safe confirmation depth. If a re-org reverts an +// anchor, the watcher updates the affected proof(s) with the new +// block context and stores them in the proof archive. +// +// The substance is "guarding proof integrity in the face of chain +// re-orgs," distinct from the minting substance that lives in +// tapgarden. The watcher used to live alongside the planter and +// caretaker because that was the package that first needed it; it +// has been separated so the package name says what the package is. +package tapreorg diff --git a/tapreorg/log.go b/tapreorg/log.go new file mode 100644 index 0000000000..50b63dad9d --- /dev/null +++ b/tapreorg/log.go @@ -0,0 +1,23 @@ +package tapreorg + +import ( + "github.com/btcsuite/btclog/v2" +) + +// Subsystem defines the logging code for this subsystem. +const Subsystem = "RORG" + +// log is a logger that is initialized with no output filters. This +// means the package will not perform any logging by default until the +// caller requests it. +var log = btclog.Disabled + +// DisableLog disables all library log output. +func DisableLog() { + UseLogger(btclog.Disabled) +} + +// UseLogger uses a specified Logger to output package logging info. +func UseLogger(logger btclog.Logger) { + log = logger +} diff --git a/tapgarden/re-org_watcher.go b/tapreorg/watcher.go similarity index 91% rename from tapgarden/re-org_watcher.go rename to tapreorg/watcher.go index a1128abbe6..b18292420f 100644 --- a/tapgarden/re-org_watcher.go +++ b/tapreorg/watcher.go @@ -1,4 +1,4 @@ -package tapgarden +package tapreorg import ( "bytes" @@ -6,16 +6,22 @@ import ( "fmt" "sync" "sync/atomic" + "time" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightningnetwork/lnd/chainntnfs" lfn "github.com/lightningnetwork/lnd/fn/v2" ) +// DefaultTimeout is the default timeout used for RPC and database +// operations issued by the watcher. +const DefaultTimeout = 30 * time.Second + // proofRegistration is a struct that holds all proofs that need to be watched // for re-orgs and a callback that will be called if a re-org happened. type proofRegistration struct { @@ -56,12 +62,12 @@ func (a *anchorTxNotification) firstRegistration() *proofRegistration { return a.proofsRegistrations[0] } -// ReOrgWatcherConfig houses all the items that the re-org watcher needs to -// carry out its duties. -type ReOrgWatcherConfig struct { +// Config houses all the items that the watcher needs to carry out its +// duties. +type Config struct { // ChainBridge is the main interface for interacting with the chain // backend. - ChainBridge ChainBridge + ChainBridge tapnode.ChainBridge // GroupVerifier is used to verify the validity of the group key for an // asset. @@ -89,14 +95,14 @@ type ReOrgWatcherConfig struct { ErrChan chan<- error } -// ReOrgWatcher is responsible for watching initially confirmed transactions +// Watcher is responsible for watching initially confirmed transactions // until they reach a safe confirmation depth. If a re-org happens, it will // update the proof and store it in the proof archive. -type ReOrgWatcher struct { +type Watcher struct { startOnce sync.Once stopOnce sync.Once - cfg *ReOrgWatcherConfig + cfg *Config bestHeight atomic.Int32 @@ -112,9 +118,9 @@ type ReOrgWatcher struct { *fn.ContextGuard } -// NewReOrgWatcher creates a new re-org watcher based on the passed config. -func NewReOrgWatcher(cfg *ReOrgWatcherConfig) *ReOrgWatcher { - return &ReOrgWatcher{ +// NewWatcher creates a new re-org watcher based on the passed config. +func NewWatcher(cfg *Config) *Watcher { + return &Watcher{ cfg: cfg, incomingProofs: make(chan *proofRegistration), incomingConfs: make(chan *chainntnfs.TxConfirmation), @@ -127,7 +133,7 @@ func NewReOrgWatcher(cfg *ReOrgWatcherConfig) *ReOrgWatcher { } // Start attempts to start a new re-org watcher. -func (w *ReOrgWatcher) Start() error { +func (w *Watcher) Start() error { var startErr error w.startOnce.Do(func() { log.Info("Starting re-org watcher") @@ -197,7 +203,7 @@ func (w *ReOrgWatcher) Start() error { } // Stop signals for a re-org watcher to gracefully exit. -func (w *ReOrgWatcher) Stop() error { +func (w *Watcher) Stop() error { var stopErr error w.stopOnce.Do(func() { log.Info("Stopping re-org watcher") @@ -211,7 +217,7 @@ func (w *ReOrgWatcher) Stop() error { // waitForConf waits for the anchor transaction of the given proofs to reach a // safe confirmation depth. -func (w *ReOrgWatcher) waitForConf(ctx context.Context, txHash chainhash.Hash, +func (w *Watcher) waitForConf(ctx context.Context, txHash chainhash.Hash, newProofs *proofRegistration) error { // Do we already have a confirmation watcher for this transaction? Then @@ -316,7 +322,7 @@ func (w *ReOrgWatcher) waitForConf(ctx context.Context, txHash chainhash.Hash, // updateProofs updates the given proofs with the new block and merkle proof and // then informs the caller about the update. -func (w *ReOrgWatcher) updateProofs(proofNtfn *anchorTxNotification, +func (w *Watcher) updateProofs(proofNtfn *anchorTxNotification, conf *chainntnfs.TxConfirmation) error { // All proofs in the registration should have the same anchor tx, so we @@ -352,7 +358,7 @@ func (w *ReOrgWatcher) updateProofs(proofNtfn *anchorTxNotification, // watchTransactions processes new proofs given to the watcher and watches their // anchor transactions until they reach a safe confirmation depth. -func (w *ReOrgWatcher) watchTransactions() { +func (w *Watcher) watchTransactions() { defer w.Wg.Done() runCtx, cancel := w.WithCtxQuitNoTimeout() @@ -492,7 +498,7 @@ func (w *ReOrgWatcher) watchTransactions() { // WatchProofs adds new proofs to the re-org watcher for their anchor // transaction to be watched until it reaches a safe confirmation depth. -func (w *ReOrgWatcher) WatchProofs(newProofs []*proof.Proof, +func (w *Watcher) WatchProofs(newProofs []*proof.Proof, onProofUpdate proof.UpdateCallback) error { if len(newProofs) == 0 { @@ -536,7 +542,7 @@ func (w *ReOrgWatcher) WatchProofs(newProofs []*proof.Proof, // MaybeWatch inspects the given proof file for any proofs that are not // yet buried sufficiently deep and adds them to the re-org watcher. -func (w *ReOrgWatcher) MaybeWatch(file *proof.File, +func (w *Watcher) MaybeWatch(file *proof.File, onProofUpdate proof.UpdateCallback) error { // We walk backward through the file and start watching all proofs that @@ -572,41 +578,37 @@ func (w *ReOrgWatcher) MaybeWatch(file *proof.File, // ShouldWatch returns true if the proof is for a block that is not yet // sufficiently deep to be considered safe. -func (w *ReOrgWatcher) ShouldWatch(p *proof.Proof) bool { +func (w *Watcher) ShouldWatch(p *proof.Proof) bool { return (w.bestHeight.Load() - int32(p.BlockHeight)) < w.cfg.SafeDepth } // DefaultUpdateCallback is the default implementation for the update callback // that is called when a proof is updated. This implementation will replace the // old proof in the proof archiver (multi-archive) with the new one. -func (w *ReOrgWatcher) DefaultUpdateCallback() proof.UpdateCallback { +func (w *Watcher) DefaultUpdateCallback() proof.UpdateCallback { return func(proofs []*proof.Proof) error { // Let's not be interrupted by a shutdown. ctxt, cancel := w.CtxBlocking() defer cancel() + // We deliberately use a no-op header verifier here: if we + // have a chain of transactions that were re-organized, we + // can't verify the whole chain until all of the transactions + // were confirmed and all proofs were updated with the new + // blocks and merkle roots. So we skip verification since we + // don't know whether the whole chain has been updated yet + // (the confirmations might come in out of order). + // TODO(guggero): Find a better way to do this. vCtx := proof.VerifierCtx{ - HeaderVerifier: GenHeaderVerifier( - ctxt, w.cfg.ChainBridge, - ), + HeaderVerifier: func(wire.BlockHeader, uint32) error { + return nil + }, MerkleVerifier: proof.DefaultMerkleVerifier, GroupVerifier: w.cfg.GroupVerifier, ChainLookupGen: w.cfg.ChainBridge, IgnoreChecker: w.cfg.IgnoreChecker, } - // This is a bit of a hacky part. If we have a chain of - // transactions that were re-organized, we can't verify the - // whole chain until all of the transactions were confirmed and - // all proofs were updated with the new blocks and merkle roots. - // So we'll skip the verification here since we don't know if - // the whole chain has been updated yet (the confirmations might - // come in out of order). - // TODO(guggero): Find a better way to do this. - vCtx.HeaderVerifier = func(wire.BlockHeader, uint32) error { - return nil - } - for idx := range proofs { proofToUpdate := proofs[idx] @@ -644,7 +646,7 @@ func (w *ReOrgWatcher) DefaultUpdateCallback() proof.UpdateCallback { } // reportErr reports an error to the main server. -func (w *ReOrgWatcher) reportErr(err error) { +func (w *Watcher) reportErr(err error) { select { case w.cfg.ErrChan <- err: case <-w.Quit: diff --git a/tapgarden/re-org_watcher_test.go b/tapreorg/watcher_test.go similarity index 89% rename from tapgarden/re-org_watcher_test.go rename to tapreorg/watcher_test.go index 29150acbd7..a3a7be777a 100644 --- a/tapgarden/re-org_watcher_test.go +++ b/tapreorg/watcher_test.go @@ -1,4 +1,4 @@ -package tapgarden +package tapreorg import ( "context" @@ -12,6 +12,7 @@ import ( "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/internal/test" "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tapnode/tapnodemock" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/stretchr/testify/require" ) @@ -25,15 +26,15 @@ const ( testReOrgBlockHeight = 123_654 ) -type reOrgWatcherHarness struct { +type watcherHarness struct { t *testing.T - w *ReOrgWatcher - cfg *ReOrgWatcherConfig - chainBridge *MockChainBridge + w *Watcher + cfg *Config + chainBridge *tapnodemock.ChainBridge } -// assertStartup makes sure the custodian was started correctly. -func (h *reOrgWatcherHarness) assertStartup() { +// assertStartup makes sure the watcher was started correctly. +func (h *watcherHarness) assertStartup() { // Make sure RegisterBlockEpochNtfn is called on startup. _, err := fn.RecvOrTimeout( h.chainBridge.BlockEpochSignal, testTimeout, @@ -43,15 +44,15 @@ func (h *reOrgWatcherHarness) assertStartup() { // eventually is a shortcut for require.Eventually with the timeout and poll // interval pre-set. -func (h *reOrgWatcherHarness) eventually(fn func() bool) { +func (h *watcherHarness) eventually(fn func() bool) { require.Eventually(h.t, fn, testTimeout, testPollInterval) } -func newReOrgWatcherHarness(t *testing.T) *reOrgWatcherHarness { - chainBridge := NewMockChainBridge() - cfg := &ReOrgWatcherConfig{ +func newWatcherHarness(t *testing.T) *watcherHarness { + chainBridge := tapnodemock.NewChainBridge() + cfg := &Config{ ChainBridge: chainBridge, - GroupVerifier: GenMockGroupVerifier(), + GroupVerifier: tapnodemock.GenGroupVerifier(), NonBuriedAssetFetcher: func(ctx context.Context, minHeight int32) ([]*asset.ChainAsset, error) { @@ -60,9 +61,9 @@ func newReOrgWatcherHarness(t *testing.T) *reOrgWatcherHarness { SafeDepth: testSafeDepth, ErrChan: make(chan error, 1), } - return &reOrgWatcherHarness{ + return &watcherHarness{ t: t, - w: NewReOrgWatcher(cfg), + w: NewWatcher(cfg), cfg: cfg, chainBridge: chainBridge, } @@ -110,7 +111,7 @@ func makeBlock(secondTransaction *wire.MsgTx) *wire.MsgBlock { func TestWatchProofs(t *testing.T) { t.Parallel() - h := newReOrgWatcherHarness(t) + h := newWatcherHarness(t) require.NoError(t, h.w.Start()) h.assertStartup() diff --git a/universe/mintpublish/log.go b/universe/mintpublish/log.go new file mode 100644 index 0000000000..504d8a6759 --- /dev/null +++ b/universe/mintpublish/log.go @@ -0,0 +1,24 @@ +package mintpublish + +import ( + "github.com/btcsuite/btclog/v2" +) + +// Subsystem defines the logging code for this subsystem. +const Subsystem = "MPUB" + +// log is a logger that is initialized with no output filters. This +// means the package will not perform any logging by default until the +// caller requests it. +var log = btclog.Disabled + +// DisableLog disables all library log output. Logging output is +// disabled by default until UseLogger is called. +func DisableLog() { + UseLogger(btclog.Disabled) +} + +// UseLogger uses a specified Logger to output package logging info. +func UseLogger(logger btclog.Logger) { + log = logger +} diff --git a/universe/mintpublish/publisher.go b/universe/mintpublish/publisher.go new file mode 100644 index 0000000000..47d4f93f65 --- /dev/null +++ b/universe/mintpublish/publisher.go @@ -0,0 +1,197 @@ +// Package mintpublish provides the implementation of +// tapgarden.MintProofPublisher: it converts minted assets and their proofs +// into universe leaves and ships them through a universe.BatchRegistrar. +package mintpublish + +import ( + "context" + "fmt" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/davecgh/go-spew/spew" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/universe" +) + +// Publisher ships minted assets and their proofs to a universe via a +// BatchRegistrar. +type Publisher struct { + reg universe.BatchRegistrar + batchSize uint +} + +// NewPublisher constructs a Publisher that ships items to reg. batchSize +// controls the number of items per UpsertProofLeafBatch call and must be +// non-zero; the type rules out negative values at the boundary. +func NewPublisher(reg universe.BatchRegistrar, batchSize uint) *Publisher { + return &Publisher{ + reg: reg, + batchSize: batchSize, + } +} + +// PublishMintBatch ships the proofs for a confirmed minting batch. +func (p *Publisher) PublishMintBatch(ctx context.Context, + params tapgarden.MintBatchPublishParams) error { + + items := make([]*universe.Item, 0, len(params.Assets)) + for _, a := range params.Assets { + scriptKey := asset.ToSerialized(a.ScriptKey.PubKey) + + mintingProof, ok := params.Proofs[scriptKey] + if !ok { + return fmt.Errorf("no minting proof for asset %x", + scriptKey[:]) + } + + item, err := buildItem( + a, mintingProof, params.MintTxHash, + params.AnchorOutIdx, + ) + if err != nil { + return fmt.Errorf("unable to build universe item: %w", + err) + } + + items = append(items, item) + } + + numTotal := uint(len(items)) + var sent uint + for start := uint(0); start < numTotal; start += p.batchSize { + end := start + p.batchSize + if end > numTotal { + end = numTotal + } + + chunk := items[start:end] + sent += uint(len(chunk)) + + log.Infof("Inserting %d new leaves (%d of %d) into local "+ + "universe", len(chunk), sent, numTotal) + + if err := p.reg.UpsertProofLeafBatch(ctx, chunk); err != nil { + return fmt.Errorf("unable to register proof leaf "+ + "batch: %w", err) + } + + log.Infof("Inserted %d new leaves (%d of %d) into local "+ + "universe", len(chunk), sent, numTotal) + } + + return nil +} + +// PublishMintProofUpdates ships post-reorg proof updates to the universe. +func (p *Publisher) PublishMintProofUpdates(ctx context.Context, + proofs []*proof.Proof) error { + + for idx := range proofs { + pr := proofs[idx] + + uniID := universe.Identifier{ + AssetID: pr.Asset.ID(), + } + if pr.Asset.GroupKey != nil { + uniID.GroupKey = &pr.Asset.GroupKey.GroupPubKey + } + + log.Debugf("Updating issuance proof for asset with "+ + "universe, key=%v", spew.Sdump(uniID)) + + leafKey := universe.BaseLeafKey{ + OutPoint: wire.OutPoint{ + Hash: pr.AnchorTx.TxHash(), + Index: pr.InclusionProof.OutputIndex, + }, + ScriptKey: &pr.Asset.ScriptKey, + } + + proofBytes, err := pr.Bytes() + if err != nil { + return fmt.Errorf("unable to encode proof: %w", err) + } + + uniGen := universe.GenesisWithGroup{ + Genesis: pr.Asset.Genesis, + } + if pr.Asset.GroupKey != nil { + uniGen.GroupKey = pr.Asset.GroupKey + } + + mintingLeaf := &universe.Leaf{ + GenesisWithGroup: uniGen, + RawProof: proofBytes, + Amt: pr.Asset.Amount, + Asset: &pr.Asset, + } + + _, err = p.reg.UpsertProofLeaf( + ctx, uniID, leafKey, mintingLeaf, + ) + if err != nil { + return fmt.Errorf("unable to update issuance: %w", err) + } + } + + return nil +} + +// buildItem assembles the universe item for a single newly-minted asset. +func buildItem(a *asset.Asset, mintingProof *proof.Proof, + mintTxHash chainhash.Hash, anchorOutIdx uint32) (*universe.Item, + error) { + + assetID := a.ID() + + uniID := universe.Identifier{ + AssetID: assetID, + } + if a.GroupKey != nil { + uniID.GroupKey = &a.GroupKey.GroupPubKey + } + + log.Debugf("Preparing asset for registration with universe, key=%v", + spew.Sdump(uniID)) + + leafKey := universe.BaseLeafKey{ + OutPoint: wire.OutPoint{ + Hash: mintTxHash, + Index: anchorOutIdx, + }, + ScriptKey: &a.ScriptKey, + } + + mintingProofBytes, err := mintingProof.Bytes() + if err != nil { + return nil, fmt.Errorf("unable to encode proof: %w", err) + } + + uniGen := universe.GenesisWithGroup{ + Genesis: a.Genesis, + } + if a.GroupKey != nil { + uniGen.GroupKey = a.GroupKey + } + + mintingLeaf := &universe.Leaf{ + GenesisWithGroup: uniGen, + RawProof: mintingProofBytes, + Amt: a.Amount, + Asset: a, + } + + return &universe.Item{ + ID: uniID, + Key: leafKey, + Leaf: mintingLeaf, + LogProofSync: true, + }, nil +} + +// Compile-time assertion that *Publisher satisfies the consumer interface +// declared by tapgarden. +var _ tapgarden.MintProofPublisher = (*Publisher)(nil) diff --git a/universe/supplycommit/env.go b/universe/supplycommit/env.go index de772d5dc0..5f418ab1a4 100644 --- a/universe/supplycommit/env.go +++ b/universe/supplycommit/env.go @@ -19,7 +19,7 @@ import ( "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/mssmt" "github.com/lightninglabs/taproot-assets/proof" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightninglabs/taproot-assets/universe" lfn "github.com/lightningnetwork/lnd/fn/v2" @@ -376,7 +376,7 @@ func NewPreCommitFromProof(issuanceProof proof.Proof, // supply pre-commitment output. // // Construct the expected pre-commit tx out. - expectedTxOut, err := tapgarden.PreCommitTxOut(delegationKey) + expectedTxOut, err := PreCommitTxOut(delegationKey) if err != nil { return zero, fmt.Errorf("unable to derive expected pre-commit "+ "txout: %w", err) @@ -916,7 +916,7 @@ type Environment struct { // Chain is our access to the current main chain. // // TODO(roasbeef): can make a slimmer version of - Chain tapgarden.ChainBridge + Chain tapnode.ChainBridge // SupplySyncer is used to insert supply commitments into the remote // universe server. diff --git a/universe/supplycommit/genesis_augmenter.go b/universe/supplycommit/genesis_augmenter.go new file mode 100644 index 0000000000..f1674ded8f --- /dev/null +++ b/universe/supplycommit/genesis_augmenter.go @@ -0,0 +1,706 @@ +package supplycommit + +import ( + "bytes" + "context" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/taproot-assets/address" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/proof" + "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" + "github.com/lightninglabs/taproot-assets/tappsbt" + "github.com/lightninglabs/taproot-assets/tapsend" + "github.com/lightninglabs/taproot-assets/universe" + "github.com/lightningnetwork/lnd/keychain" +) + +// SupplyPreCommitReader is the subset of the tapdb supply-pre-commit +// store that the augmenter consumes. It is defined here (not +// imported from tapdb) so supplycommit can express its dependency +// on the lookup without taking on tapdb as a dependency. +type SupplyPreCommitReader interface { + // FetchDelegationKey returns the delegation key associated + // with the given asset-group public key, when known. + FetchDelegationKey(ctx context.Context, + groupKey btcec.PublicKey) ( + fn.Option[keychain.KeyDescriptor], error) +} + +// MintEventEmitter is the subset of supplycommit.Manager that the +// augmenter needs at batch-confirmation time. Defining it as an +// interface lets tests substitute the manager without instantiating +// one. +type MintEventEmitter interface { + // SendMintEvent forwards a mint event to the appropriate + // supply-commitment state machine. + SendMintEvent(ctx context.Context, assetSpec asset.Specifier, + leafKey universe.UniqueLeafKey, issuanceProof universe.Leaf, + mintBlockHeight uint32) error +} + +// GenesisAugmenterCfg bundles the dependencies that the augmenter +// needs at minting time. All fields are required; passing a +// fully-constructed GenesisAugmenter through to tapgarden's +// GardenKit lets the planter remain free of any supply-commit +// knowledge. +type GenesisAugmenterCfg struct { + // PreCommitStore is consulted during PrepareSeedling when + // looking up the delegation key for an existing asset + // group. + PreCommitStore SupplyPreCommitReader + + // KeyRing derives a new delegation key when the planter is + // minting a fresh group with the supply-commit flag set. + KeyRing tapnode.KeyRing + + // DelegationKeyChecker decides, at batch-confirmation time, + // which newly-minted assets the local node owns the + // delegation key for. Only those become mint events. + DelegationKeyChecker address.DelegationKeyChecker + + // MintEvents forwards mint events to the supply-commit + // state machine after batch confirmation. + MintEvents MintEventEmitter + + // ChainParams supplies the BIP32 coin type used when + // stamping derivation metadata onto the pre-commitment + // output's PSBT entry. + ChainParams address.ChainParams +} + +// GenesisAugmenter implements tapgarden.GenesisTxAugmenter for +// the supply-commitment substance. It contributes the +// pre-commitment output to the genesis transaction, persists +// the supply-pre-commit row through tapgarden's binding API, +// and emits mint events once the batch confirms. +type GenesisAugmenter struct { + cfg GenesisAugmenterCfg +} + +// NewGenesisAugmenter returns a new augmenter wired with the +// supplied dependencies. +func NewGenesisAugmenter(cfg GenesisAugmenterCfg) *GenesisAugmenter { + return &GenesisAugmenter{cfg: cfg} +} + +// PrepareSeedling finalizes the seedling's delegation key. If the +// seedling does not enable supply commitments, this is a no-op. +// Otherwise the augmenter: +// +// - reuses the delegation key from the batch's group-anchor +// seedling when one is referenced; +// - reuses the delegation key already persisted on disk when an +// existing group key is being reissued into; +// - derives a fresh key from the key ring when a brand-new +// group is being minted. +// +// NOTE: This implements tapgarden.GenesisTxAugmenter.PrepareSeedling. +func (a *GenesisAugmenter) PrepareSeedling(ctx context.Context, + batch *tapgarden.MintingBatch, req *tapgarden.Seedling) error { + + if !req.SupplyCommitments { + return nil + } + + if req.DelegationKey.IsSome() { + return nil + } + + // Reuse the delegation key from a referenced group-anchor + // seedling in the same batch. + if req.GroupAnchor != nil { + if batch == nil { + return fmt.Errorf("group anchor seedling " + + "referenced but batch is nil") + } + + anchorName := *req.GroupAnchor + anchor, ok := batch.Seedlings[anchorName] + if !ok || anchor == nil { + return fmt.Errorf("group anchor seedling not "+ + "present in batch (anchor_seedling_name=%s)", + anchorName) + } + + if anchor.DelegationKey.IsNone() { + return fmt.Errorf("group anchor seedling has no "+ + "delegation key (anchor_seedling_name=%s)", + anchorName) + } + + req.DelegationKey = anchor.DelegationKey + return nil + } + + // Reuse the delegation key previously persisted on disk for + // an existing group being reissued into. + if req.GroupInfo != nil && req.GroupInfo.GroupKey != nil { + dKey, err := a.cfg.PreCommitStore.FetchDelegationKey( + ctx, req.GroupInfo.GroupKey.GroupPubKey, + ) + if err != nil { + return fmt.Errorf("unable to fetch delegation key "+ + "for group key: %w", err) + } + + if dKey.IsSome() { + req.DelegationKey = dKey + return nil + } + } + + // Derive a fresh delegation key for a brand-new group. + if req.EnableEmission && req.GroupAnchor == nil { + newKey, err := a.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 + } + + return fmt.Errorf("failed to finalize delegation key for "+ + "seedling %s", req.AssetName) +} + +// ValidateSeedling enforces the supply-commit invariants on a +// candidate seedling. The batch is either entirely on the +// supply-commit path or entirely off it; the first seedling +// sets the flag and subsequent seedlings must match. +// +// NOTE: This implements tapgarden.GenesisTxAugmenter.ValidateSeedling. +func (a *GenesisAugmenter) ValidateSeedling(batch *tapgarden.MintingBatch, + req tapgarden.Seedling) error { + + if err := a.validateUniCommitment(batch, req); err != nil { + return err + } + + return a.validateDelegationKey(batch, req) +} + +// validateUniCommitment is the augmenter half of the universe- +// commitment intake gate. It is moved verbatim from the planter +// (formerly MintingBatch.validateUniCommitment) so the +// invariants it captures remain unchanged. +func (a *GenesisAugmenter) validateUniCommitment(batch *tapgarden.MintingBatch, + req tapgarden.Seedling) error { + + // First-seedling-into-empty-batch path: the seedling sets + // the batch's SupplyCommitments flag. + if !batch.HasSeedlings() { + if !req.SupplyCommitments { + return nil + } + + // 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. + if batch.IsFunded() { + return fmt.Errorf("attempting to add first " + + "seedling with universe commitment flag " + + "enabled to funded batch") + } + + // The first uni-committed seedling must either create + // a new asset group or issue into an existing one. + if !req.EnableEmission && !req.HasGroupKey() { + return fmt.Errorf("universe commitment " + + "enabled: seedling must either create a " + + "new asset group or issue into an " + + "existing one") + } + + return nil + } + + // Subsequent-seedling path: must match the batch's flag. + if batch.SupplyCommitments != req.SupplyCommitments { + return fmt.Errorf("seedling universe commitment flag " + + "does not match batch") + } + + if !batch.SupplyCommitments && !req.SupplyCommitments { + return nil + } + + // At this point both the seedling and the batch have uni + // commitments enabled. The candidate must reference a + // group-anchor seedling that is already part of the batch. + if req.GroupAnchor == nil { + return fmt.Errorf("non-empty batch with uni commit " + + "enabled but candidate seedling does not have " + + "group anchor specified") + } + + if _, ok := batch.Seedlings[*req.GroupAnchor]; !ok { + return fmt.Errorf("group anchor for candidate seedling " + + "not present in batch") + } + + // Assert single-group-anchor invariant. The original + // invariant (preserved verbatim) counts seedlings that + // reference an anchor; multiple referencers across distinct + // anchors would violate uniqueness. + var anchorCount int + for _, s := range batch.Seedlings { + if s.GroupAnchor != nil { + anchorCount++ + } + } + if anchorCount > 1 { + return fmt.Errorf("multiple group anchors present in " + + "batch with universe commitments enabled") + } + + // Run the batch's own group-anchor compatibility check + // (anchor exists, has EnableEmission, meta is compatible). + return batch.ValidateGroupAnchor(&req) +} + +// validateDelegationKey is the augmenter half of the delegation- +// key intake gate. It is moved verbatim from the planter +// (formerly MintingBatch.validateDelegationKey). +func (a *GenesisAugmenter) validateDelegationKey(batch *tapgarden.MintingBatch, + req tapgarden.Seedling) error { + + if !req.SupplyCommitments { + if req.DelegationKey.IsSome() { + return fmt.Errorf("delegation key must not be " + + "set for seedling without universe " + + "commitments") + } + return nil + } + + delegationKey, err := req.DelegationKey.UnwrapOrErr( + fmt.Errorf("delegation key must be set for seedling " + + "with universe commitments"), + ) + if err != nil { + return err + } + + if delegationKey.PubKey == nil { + return fmt.Errorf("candidate seedling delegation key " + + "validation failed: pubkey is nil") + } + if !delegationKey.PubKey.IsOnCurve() { + return fmt.Errorf("candidate seedling delegation key " + + "validation failed: pubkey is not on curve") + } + + // All seedlings in the batch must share the same + // delegation key. + for _, s := range batch.Seedlings { + other, err := s.DelegationKey.UnwrapOrErr( + fmt.Errorf("delegation key must be set for " + + "seedling with universe commitments"), + ) + if err != nil { + return err + } + + if !delegationKey.PubKey.IsEqual(other.PubKey) { + return fmt.Errorf("delegation key mismatch") + } + } + + return nil +} + +// ExtraOutputs returns the pre-commitment output for this batch, +// when supply commitments are enabled. The output's PkScript is +// deterministic from the batch's anchor seedling's delegation +// key, so the augmenter can locate the same output in the funded +// PSBT later. +// +// NOTE: This implements tapgarden.GenesisTxAugmenter.ExtraOutputs. +func (a *GenesisAugmenter) ExtraOutputs(_ context.Context, + batch *tapgarden.MintingBatch) ([]wire.TxOut, error) { + + dKey, err := delegationKeyFromBatch(batch) + if err != nil { + return nil, err + } + if dKey.IsNone() { + return nil, nil + } + + internalKey, err := dKey.UnwrapOrErr( + fmt.Errorf("delegation key unexpectedly absent"), + ) + if err != nil { + return nil, err + } + + out, err := PreCommitTxOut(*internalKey.PubKey) + if err != nil { + return nil, err + } + + return []wire.TxOut{out}, nil +} + +// PostFund stamps BIP32 derivation metadata onto the +// pre-commitment output in the funded PSBT. It locates the +// output by matching its PkScript against the deterministic +// pre-commitment script. +// +// NOTE: This implements tapgarden.GenesisTxAugmenter.PostFund. +func (a *GenesisAugmenter) PostFund(_ context.Context, + batch *tapgarden.MintingBatch, funded *tapsend.FundedPsbt) error { + + dKey, err := delegationKeyFromBatch(batch) + if err != nil { + return err + } + if dKey.IsNone() { + return nil + } + + internalKey, err := dKey.UnwrapOrErr( + fmt.Errorf("delegation key unexpectedly absent"), + ) + if err != nil { + return err + } + + outIdx, err := findPreCommitOutputIdx(funded, *internalKey.PubKey) + if err != nil { + return err + } + if outIdx.IsNone() { + return fmt.Errorf("pre-commit output not found in " + + "funded psbt") + } + + idx, _ := outIdx.UnwrapOrErr( + fmt.Errorf("pre-commit output index unexpectedly absent"), + ) + + bip32, trBip32 := tappsbt.Bip32DerivationFromKeyDesc( + internalKey, a.cfg.ChainParams.HDCoinType, + ) + pOut := &funded.Pkt.Outputs[idx] + pOut.Bip32Derivation = []*psbt.Bip32Derivation{bip32} + pOut.TaprootBip32Derivation = []*psbt.TaprootBip32Derivation{trBip32} + pOut.TaprootInternalKey = trBip32.XOnlyPubKey + + return nil +} + +// BindData returns the PreCommitBindData for the batch's +// pre-commitment output. The output index is resolved by +// scanning the batch's funded PSBT (post-funding it is in +// batch.GenesisPacket); the group key is read from the anchor +// seedling's GroupInfo, which becomes known no later than seal +// time. +// +// NOTE: This implements tapgarden.GenesisTxAugmenter.BindData. +func (a *GenesisAugmenter) BindData(_ context.Context, + batch *tapgarden.MintingBatch) ( + fn.Option[tapgarden.PreCommitBindData], error) { + + var zero fn.Option[tapgarden.PreCommitBindData] + + if batch == nil || !batch.SupplyCommitments { + return zero, nil + } + if batch.GenesisPacket == nil { + return zero, nil + } + + dKey, err := delegationKeyFromBatch(batch) + if err != nil { + return zero, err + } + if dKey.IsNone() { + return zero, nil + } + + internalKey, err := dKey.UnwrapOrErr( + fmt.Errorf("delegation key unexpectedly absent"), + ) + if err != nil { + return zero, err + } + + outIdx, err := findPreCommitOutputIdx( + &batch.GenesisPacket.FundedPsbt, *internalKey.PubKey, + ) + if err != nil { + return zero, err + } + if outIdx.IsNone() { + return zero, nil + } + + idx, _ := outIdx.UnwrapOrErr( + fmt.Errorf("pre-commit output index unexpectedly absent"), + ) + + groupKey, err := groupKeyFromBatch(batch) + if err != nil { + return zero, err + } + + return fn.Some(tapgarden.PreCommitBindData{ + OutputIndex: idx, + InternalKey: internalKey, + GroupKey: groupKey, + }), nil +} + +// OnBatchConfirmed emits a mint event for each newly-confirmed +// asset whose delegation key the local node controls. +// +// NOTE: This implements tapgarden.GenesisTxAugmenter.OnBatchConfirmed. +func (a *GenesisAugmenter) OnBatchConfirmed(ctx context.Context, + _ *tapgarden.MintingBatch, anchorAssets, + nonAnchorAssets []*asset.Asset, + mintingProofs proof.AssetProofs) error { + + if a.cfg.MintEvents == nil { + return nil + } + + allAssets := append( + make([]*asset.Asset, 0, len(anchorAssets)+len(nonAnchorAssets)), + anchorAssets..., + ) + allAssets = append(allAssets, nonAnchorAssets...) + + withDelegation := fn.Filter(allAssets, func(m *asset.Asset) bool { + has, err := a.cfg.DelegationKeyChecker.HasDelegationKey( + ctx, m.ID(), + ) + if err != nil { + return false + } + return has + }) + + for _, m := range withDelegation { + scriptKey := asset.ToSerialized(m.ScriptKey.PubKey) + mintingProof, ok := mintingProofs[scriptKey] + if !ok { + return fmt.Errorf("missing minting proof for "+ + "asset with script key %x", scriptKey[:]) + } + + proofBlob, err := proof.EncodeAsProofFile(mintingProof) + if err != nil { + return fmt.Errorf("unable to encode proof as "+ + "file: %w", err) + } + proofFile, err := proof.DecodeFile(proofBlob) + if err != nil { + return fmt.Errorf("unable to decode proof file: "+ + "%w", err) + } + leafProof, err := proofFile.LastProof() + if err != nil { + return fmt.Errorf("unable to get leaf proof: %w", + err) + } + + var leafBuf bytes.Buffer + if err := leafProof.Encode(&leafBuf); err != nil { + return fmt.Errorf("unable to encode leaf proof: "+ + "%w", err) + } + + uniqueLeafKey := universe.AssetLeafKey{ + BaseLeafKey: universe.BaseLeafKey{ + OutPoint: leafProof.OutPoint(), + ScriptKey: &m.ScriptKey, + }, + AssetID: m.ID(), + } + universeLeaf := universe.Leaf{ + GenesisWithGroup: universe.GenesisWithGroup{ + Genesis: m.Genesis, + GroupKey: m.GroupKey, + }, + RawProof: leafBuf.Bytes(), + Asset: &leafProof.Asset, + Amt: m.Amount, + } + assetSpec := asset.NewSpecifierOptionalGroupKey( + m.ID(), m.GroupKey, + ) + + err = a.cfg.MintEvents.SendMintEvent( + ctx, assetSpec, uniqueLeafKey, universeLeaf, + leafProof.BlockHeight, + ) + if err != nil { + return fmt.Errorf("unable to send mint event for "+ + "asset %x: %w", m.ID(), err) + } + } + + return nil +} + +// PreCommitTxOut returns the wire.TxOut for the pre-commitment +// output corresponding to the given internal key. The output's +// PkScript is the BIP-341 key-only P2TR script, so it is +// deterministic from the key and can be matched against funded +// PSBT outputs. +// +// This is exported because the supply-commit verifier (env.go) +// uses the same script construction to identify pre-commitment +// outputs in mint anchor transactions, independently of the +// minting flow. +func PreCommitTxOut(internalKey btcec.PublicKey) (wire.TxOut, error) { + var zero wire.TxOut + taprootOutputKey := txscript.ComputeTaprootKeyNoScript(&internalKey) + pkScript, err := txscript.PayToTaprootScript(taprootOutputKey) + if err != nil { + return zero, fmt.Errorf("unable to create "+ + "pre-commitment output pk script: %w", err) + } + return wire.TxOut{ + Value: int64(tapsend.DummyAmtSats), + PkScript: pkScript, + }, nil +} + +// delegationKeyFromBatch returns the delegation key from the +// batch's unique group-anchor seedling, when present. When the +// batch has no seedlings or supply commitments are disabled the +// result is None. +func delegationKeyFromBatch(batch *tapgarden.MintingBatch) ( + fn.Option[keychain.KeyDescriptor], error) { + + var zero fn.Option[keychain.KeyDescriptor] + + if batch == nil || !batch.SupplyCommitments { + return zero, nil + } + if len(batch.Seedlings) == 0 { + return zero, nil + } + + anchor, err := uniqueAnchorSeedling(batch) + if err != nil { + return zero, fmt.Errorf("unable to identify group "+ + "anchor seedling: %w", err) + } + + return anchor.DelegationKey, nil +} + +// groupKeyFromBatch returns the group key for the batch's +// pre-commitment payload, when the batch's anchor seedling has +// a populated GroupInfo. Before seal time the group is typically +// not yet derived; after seal time it is. +func groupKeyFromBatch(batch *tapgarden.MintingBatch) ( + fn.Option[btcec.PublicKey], error) { + + var zero fn.Option[btcec.PublicKey] + + if batch == nil || !batch.SupplyCommitments { + return zero, nil + } + if len(batch.Seedlings) == 0 { + return zero, nil + } + + anchor, err := uniqueAnchorSeedling(batch) + if err != nil { + return zero, fmt.Errorf("unable to identify group "+ + "anchor seedling: %w", err) + } + + if anchor.GroupInfo == nil { + return zero, nil + } + + return fn.Some(anchor.GroupInfo.GroupPubKey), nil +} + +// uniqueAnchorSeedling returns the single group-anchor seedling +// in the batch -- the seedling whose GroupAnchor is nil and that +// other seedlings may reference by name. Returns an error if the +// batch contains zero or more than one such seedling. +func uniqueAnchorSeedling( + batch *tapgarden.MintingBatch) (*tapgarden.Seedling, error) { + + var ( + anchor *tapgarden.Seedling + count int + ) + for _, s := range batch.Seedlings { + if s.GroupAnchor != nil { + continue + } + anchor = s + count++ + } + + switch count { + case 0: + return nil, fmt.Errorf("no group anchor seedling in batch") + case 1: + return anchor, nil + default: + return nil, fmt.Errorf("batch has %d group anchor "+ + "seedlings, expected exactly 1", count) + } +} + +// findPreCommitOutputIdx scans the funded PSBT for the +// pre-commitment output associated with the given internal +// key. Returns None when the output is absent. +func findPreCommitOutputIdx(funded *tapsend.FundedPsbt, + internalKey btcec.PublicKey) (fn.Option[uint32], error) { + + var zero fn.Option[uint32] + if funded == nil || funded.Pkt == nil { + return zero, nil + } + + expectedOut, err := PreCommitTxOut(internalKey) + if err != nil { + return zero, err + } + + tx := funded.Pkt.UnsignedTx + if tx == nil { + return zero, nil + } + + for i, txOut := range tx.TxOut { + if int32(i) == funded.ChangeOutputIndex { + continue + } + if bytes.Equal(txOut.PkScript, expectedOut.PkScript) { + return fn.Some(uint32(i)), nil + } + } + + return zero, nil +} + +// A compile-time assertion to ensure GenesisAugmenter implements +// tapgarden.GenesisTxAugmenter. +var _ tapgarden.GenesisTxAugmenter = (*GenesisAugmenter)(nil) diff --git a/universe/supplycommit/genesis_augmenter_test.go b/universe/supplycommit/genesis_augmenter_test.go new file mode 100644 index 0000000000..a8b34148e6 --- /dev/null +++ b/universe/supplycommit/genesis_augmenter_test.go @@ -0,0 +1,168 @@ +package supplycommit_test + +import ( + "testing" + + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/universe/supplycommit" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// TestAugmenterValidateSeedling exercises the supply-commit +// invariants that the GenesisAugmenter enforces at seedling +// intake. These tests previously lived on +// MintingBatch.validateUniCommitment in tapgarden; they moved +// with the invariant. +func TestAugmenterValidateSeedling(t *testing.T) { + t.Parallel() + + aug := supplycommit.NewGenesisAugmenter( + supplycommit.GenesisAugmenterCfg{}, + ) + + type tc struct { + name string + candidate tapgarden.Seedling + batch *tapgarden.MintingBatch + expectErr bool + } + + cases := []tc{ + { + // Multiple group anchors in a uni-commit batch + // is not allowed. + name: "populated batch with universe commitments; " + + "candidate is a second group anchor; invalid", + candidate: tapgarden.RandGroupAnchorSeedling( + t, "new-group-anchor", true, + ), + batch: tapgarden.RandMintingBatch( + t, tapgarden.WithTotalGroups([]int{2}), + tapgarden.WithUniverseCommitments(true), + ), + expectErr: true, + }, + { + // A uni-commit candidate cannot enter a + // non-uni-commit batch. + name: "populated batch without universe commitments; " + + "uni-commit candidate; invalid", + candidate: tapgarden.RandGroupAnchorSeedling( + t, "new-group-anchor", true, + ), + batch: tapgarden.RandMintingBatch( + t, tapgarden.WithTotalGroups([]int{2}), + tapgarden.WithUniverseCommitments(false), + ), + expectErr: true, + }, + { + // A non-anchor uni-commit candidate referencing + // an absent anchor must be rejected. + name: "populated batch without universe commitments; " + + "non-anchor uni-commit candidate; invalid", + candidate: tapgarden.RandNonAnchorGroupSeedling( + t, asset.V1, asset.Normal, "some-anchor-name", + []byte{}, fn.None[keychain.KeyDescriptor](), + true, + ), + batch: tapgarden.RandMintingBatch( + t, tapgarden.WithTotalGroups([]int{2}), + tapgarden.WithUniverseCommitments(false), + ), + expectErr: true, + }, + { + // A non-anchor uni-commit candidate referencing + // an absent anchor in a uni-commit batch. + name: "populated uni-commit batch; anchor absent; " + + "invalid", + candidate: tapgarden.RandNonAnchorGroupSeedling( + t, asset.V1, asset.Normal, "some-anchor-name", + []byte{}, fn.None[keychain.KeyDescriptor](), + true, + ), + batch: tapgarden.RandMintingBatch( + t, tapgarden.WithTotalGroups([]int{2}), + tapgarden.WithUniverseCommitments(true), + ), + expectErr: true, + }, + { + // Group anchor candidate into an empty unfunded + // batch is fine. + name: "empty unfunded batch; group anchor candidate; " + + "valid", + candidate: tapgarden.RandGroupAnchorSeedling( + t, "some-anchor-name", true, + ), + batch: tapgarden.RandMintingBatch( + t, tapgarden.WithSkipFunding(), + ), + expectErr: false, + }, + } + + // Construct a positive case: a uni-commit batch with a + // group anchor and a non-anchor candidate that correctly + // references it. + batch := tapgarden.RandMintingBatch( + t, tapgarden.WithTotalGroups([]int{2}), + tapgarden.WithUniverseCommitments(true), + ) + var anchor *tapgarden.Seedling + for _, s := range batch.Seedlings { + if s.GroupAnchor == nil { + anchor = s + break + } + } + cases = append(cases, tc{ + name: "populated uni-commit batch; non-anchor " + + "candidate references existing anchor; valid", + candidate: tapgarden.RandNonAnchorGroupSeedling( + t, anchor.AssetVersion, anchor.AssetType, + anchor.AssetName, anchor.Meta.Data, + anchor.DelegationKey, anchor.SupplyCommitments, + ), + batch: batch, + expectErr: false, + }) + + // Funded-but-empty uni-commit batch must reject a + // uni-commit candidate. + fundedEmptyBatch := tapgarden.RandMintingBatch(t) + fundedEmptyBatch.GenesisPacket = &tapgarden.FundedMintAnchorPsbt{} + cases = append(cases, tc{ + name: "empty funded batch; uni-commit candidate; invalid", + candidate: tapgarden.RandGroupAnchorSeedling( + t, "some-anchor-name", true, + ), + batch: fundedEmptyBatch, + expectErr: true, + }) + cases = append(cases, tc{ + name: "empty funded batch; non-uni-commit candidate; valid", + candidate: tapgarden.RandGroupAnchorSeedling( + t, "some-anchor-name", false, + ), + batch: fundedEmptyBatch, + expectErr: false, + }) + + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + err := aug.ValidateSeedling(c.batch, c.candidate) + if c.expectErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} diff --git a/universe/supplycommit/manager.go b/universe/supplycommit/manager.go index a8ed0a9741..f7f0bac012 100644 --- a/universe/supplycommit/manager.go +++ b/universe/supplycommit/manager.go @@ -14,7 +14,7 @@ import ( "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/mssmt" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/universe" "github.com/lightningnetwork/lnd/msgmux" "github.com/lightningnetwork/lnd/protofsm" @@ -67,7 +67,7 @@ type ManagerCfg struct { // Chain is our access to the current main chain. // // TODO(roasbeef): can make a slimmer version of - Chain tapgarden.ChainBridge + Chain tapnode.ChainBridge // SupplySyncer is used to insert supply commitments into the remote // universe server. @@ -441,7 +441,8 @@ func (m *Manager) SendEventSync(ctx context.Context, assetSpec asset.Specifier, // SendMintEvent sends a mint event to the supply commitment state machine. // -// NOTE: This implements the tapgarden.MintSupplyCommitter interface. +// NOTE: This is consumed by the GenesisAugmenter at batch confirmation +// time via the MintEventEmitter interface. func (m *Manager) SendMintEvent(ctx context.Context, assetSpec asset.Specifier, leafKey universe.UniqueLeafKey, issuanceProof universe.Leaf, mintBlockHeight uint32) error { diff --git a/universe/supplycommit/mock.go b/universe/supplycommit/mock.go index c8b8c5c879..b4f347b830 100644 --- a/universe/supplycommit/mock.go +++ b/universe/supplycommit/mock.go @@ -14,7 +14,7 @@ import ( "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/mssmt" "github.com/lightninglabs/taproot-assets/proof" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/tapsend" "github.com/lightningnetwork/lnd/chainntnfs" lfn "github.com/lightningnetwork/lnd/fn/v2" @@ -167,7 +167,7 @@ func (m *mockKeyRing) DeriveNextTaprootAssetKey( return args.Get(0).(keychain.KeyDescriptor), args.Error(1) } -// mockChainBridge is a mock implementation of the tapgarden.ChainBridge +// mockChainBridge is a mock implementation of the tapnode.ChainBridge // interface. type mockChainBridge struct { mock.Mock @@ -301,8 +301,8 @@ func (m *mockChainBridge) GenProofChainLookup( return args.Get(0).(asset.ChainLookup), args.Error(1) } -// Ensure mockChainBridge implements the tapgarden.ChainBridge interface. -var _ tapgarden.ChainBridge = (*mockChainBridge)(nil) +// Ensure mockChainBridge implements the tapnode.ChainBridge interface. +var _ tapnode.ChainBridge = (*mockChainBridge)(nil) // mockStateMachineStore is a mock implementation of the StateMachineStore // interface. diff --git a/universe/supplycommit/util.go b/universe/supplycommit/util.go index 65ce7dd4ca..b67b1233aa 100644 --- a/universe/supplycommit/util.go +++ b/universe/supplycommit/util.go @@ -10,7 +10,7 @@ import ( "github.com/lightninglabs/taproot-assets/address" "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/proof" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightningnetwork/lnd/fn/v2" ) @@ -171,7 +171,7 @@ func IsSupplySupported(ctx context.Context, assetLookup AssetLookup, // ExtractSupplyLeavesBlockHeaders is a helper method which extracts the block // headers from the supply leaves. The returned map is keyed by block height. func ExtractSupplyLeavesBlockHeaders(ctx context.Context, - chain tapgarden.ChainBridge, + chain tapnode.ChainBridge, supplyLeaves SupplyLeaves) (map[uint32]wire.BlockHeader, error) { blockHeaders := make(map[uint32]wire.BlockHeader) diff --git a/universe/supplyverifier/env.go b/universe/supplyverifier/env.go index fde12ac29d..57b7fac302 100644 --- a/universe/supplyverifier/env.go +++ b/universe/supplyverifier/env.go @@ -11,7 +11,7 @@ import ( "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/mssmt" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/universe/supplycommit" lfn "github.com/lightningnetwork/lnd/fn/v2" ) @@ -102,7 +102,7 @@ type Environment struct { AssetLog btclog.Logger // Chain is our access to the current main chain. - Chain tapgarden.ChainBridge + Chain tapnode.ChainBridge // SupplyCommitView allows us to look up supply commitments and // pre-commitments. @@ -119,7 +119,7 @@ type Environment struct { Lnd *lndclient.LndServices // GroupFetcher is used to fetch asset groups. - GroupFetcher tapgarden.GroupFetcher + GroupFetcher tapnode.GroupFetcher // SupplySyncer is used to retrieve supply commitments from a universe // server. diff --git a/universe/supplyverifier/manager.go b/universe/supplyverifier/manager.go index 3cafb543c7..00093d685d 100644 --- a/universe/supplyverifier/manager.go +++ b/universe/supplyverifier/manager.go @@ -12,7 +12,7 @@ import ( "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/mssmt" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/universe" "github.com/lightninglabs/taproot-assets/universe/supplycommit" "github.com/lightningnetwork/lnd/msgmux" @@ -61,7 +61,7 @@ type IssuanceSubscriptions interface { // manage multiple supply verifier state machines, one for each asset group. type ManagerCfg struct { // Chain is our access to the current main chain. - Chain tapgarden.ChainBridge + Chain tapnode.ChainBridge // AssetLookup is used to look up asset information such as asset groups // and asset metadata. @@ -82,7 +82,7 @@ type ManagerCfg struct { SupplySyncer SupplySyncer // GroupFetcher is used to fetch asset group information. - GroupFetcher tapgarden.GroupFetcher + GroupFetcher tapnode.GroupFetcher // IssuanceSubscriptions registers verifier state machines to receive // new asset group issuance event notifications. diff --git a/universe/supplyverifier/mock.go b/universe/supplyverifier/mock.go index 503513ee71..c8dbba8860 100644 --- a/universe/supplyverifier/mock.go +++ b/universe/supplyverifier/mock.go @@ -111,7 +111,7 @@ func (m *MockSupplyTreeView) FetchSupplyLeavesByHeight(ctx context.Context, return args.Get(0).(lfn.Result[supplycommit.SupplyLeaves]) } -// MockGroupFetcher is a mock implementation of tapgarden.GroupFetcher. +// MockGroupFetcher is a mock implementation of tapnode.GroupFetcher. type MockGroupFetcher struct { mock.Mock } diff --git a/universe/supplyverifier/util_test.go b/universe/supplyverifier/util_test.go index ccde539be7..3fd1413c7d 100644 --- a/universe/supplyverifier/util_test.go +++ b/universe/supplyverifier/util_test.go @@ -17,7 +17,6 @@ import ( "github.com/lightninglabs/taproot-assets/internal/test" "github.com/lightninglabs/taproot-assets/mssmt" "github.com/lightninglabs/taproot-assets/proof" - "github.com/lightninglabs/taproot-assets/tapgarden" "github.com/lightninglabs/taproot-assets/tapscript" "github.com/lightninglabs/taproot-assets/universe" "github.com/lightninglabs/taproot-assets/universe/supplycommit" @@ -264,8 +263,8 @@ func createTestValidMintEvent(t *testing.T, blockHeight uint32, } // Create the pre-commitment output that matches what - // tapgarden.PreCommitTxOut would create. - preCommitTxOut, err := tapgarden.PreCommitTxOut(*delegationKey) + // supplycommit.PreCommitTxOut would create. + preCommitTxOut, err := supplycommit.PreCommitTxOut(*delegationKey) if err != nil { t.Fatalf("failed to create pre-commit tx out: %v", err) } @@ -868,7 +867,7 @@ func randProofWithGroupKey(t *testing.T, pkScript, err := txscript.PayToTaprootScript(taprootOutputKey) require.NoError(t, err) - preCommitTxOut, err := tapgarden.PreCommitTxOut(*delegationKey) + preCommitTxOut, err := supplycommit.PreCommitTxOut(*delegationKey) require.NoError(t, err) // Anchor tx: pre-commit output at index 0, @@ -1090,7 +1089,7 @@ func randBurnProofWithGroupKey(t *testing.T, // createVerifiableCommitment builds a RootCommitment whose chain anchor // passes VerifyChainAnchor without a live chain. It uses a single-tx block -// so the merkle proof is empty (merkle root == tx hash), and MockChainBridge +// so the merkle proof is empty (merkle root == tx hash), and tapnodemock // returns nil from VerifyBlock unconditionally. The TxOut is derived from // RootCommitTxOut using an empty supply tree root, so the output script // check passes. If txIns is nil, a single default input with a zero outpoint diff --git a/universe/supplyverifier/verifier.go b/universe/supplyverifier/verifier.go index 1a5bebce3d..23063d5ad2 100644 --- a/universe/supplyverifier/verifier.go +++ b/universe/supplyverifier/verifier.go @@ -14,7 +14,7 @@ import ( "github.com/lightninglabs/taproot-assets/asset" "github.com/lightninglabs/taproot-assets/mssmt" "github.com/lightninglabs/taproot-assets/proof" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode" "github.com/lightninglabs/taproot-assets/universe/supplycommit" ) @@ -24,7 +24,7 @@ type VerifierCfg struct { AssetSpec asset.Specifier // Chain is our access to the chain. - ChainBridge tapgarden.ChainBridge + ChainBridge tapnode.ChainBridge // AssetLookup is used to look up asset information such as asset groups // and asset metadata. @@ -34,7 +34,7 @@ type VerifierCfg struct { Lnd *lndclient.LndServices // GroupFetcher is used to fetch asset groups. - GroupFetcher tapgarden.GroupFetcher + GroupFetcher tapnode.GroupFetcher // SupplyCommitView allows us to look up supply commitments and // pre-commitments. @@ -403,10 +403,10 @@ func (v *Verifier) verifyIncrementalCommit(ctx context.Context, // proofVerifierCtx returns a verifier context that can be used to verify // proofs. func (v *Verifier) proofVerifierCtx(ctx context.Context) proof.VerifierCtx { - headerVerifier := tapgarden.GenHeaderVerifier(ctx, v.cfg.ChainBridge) + headerVerifier := tapnode.GenHeaderVerifier(ctx, v.cfg.ChainBridge) merkleVerifier := proof.DefaultMerkleVerifier - groupVerifier := tapgarden.GenGroupVerifier(ctx, v.cfg.GroupFetcher) - groupAnchorVerifier := tapgarden.GenGroupAnchorVerifier( + groupVerifier := tapnode.GenGroupVerifier(ctx, v.cfg.GroupFetcher) + groupAnchorVerifier := tapnode.GenGroupAnchorVerifier( ctx, v.cfg.GroupFetcher, ) @@ -748,7 +748,7 @@ func (v *Verifier) VerifyCommit(ctx context.Context, // anchoring block header. This provides a basic proof-of-work guarantee // that gates further verification steps. v.assetLog.Debugf("Verifying chain anchor for commitment") - headerVerifier := tapgarden.GenHeaderVerifier(ctx, v.cfg.ChainBridge) + headerVerifier := tapnode.GenHeaderVerifier(ctx, v.cfg.ChainBridge) err := commitment.VerifyChainAnchor( proof.DefaultMerkleVerifier, headerVerifier, ) diff --git a/universe/supplyverifier/verifier_methods_test.go b/universe/supplyverifier/verifier_methods_test.go index a9420d4726..60fe990196 100644 --- a/universe/supplyverifier/verifier_methods_test.go +++ b/universe/supplyverifier/verifier_methods_test.go @@ -14,7 +14,7 @@ import ( "github.com/lightninglabs/taproot-assets/fn" internaltest "github.com/lightninglabs/taproot-assets/internal/test" "github.com/lightninglabs/taproot-assets/mssmt" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode/tapnodemock" "github.com/lightninglabs/taproot-assets/universe" "github.com/lightninglabs/taproot-assets/universe/supplycommit" "github.com/stretchr/testify/mock" @@ -350,7 +350,7 @@ func TestVerifyIssuanceLeaf(t *testing.T) { } cfg := VerifierCfg{ - ChainBridge: tapgarden.NewMockChainBridge(), + ChainBridge: tapnodemock.NewChainBridge(), GroupFetcher: &MockGroupFetcher{}, } v := Verifier{assetLog: log, cfg: cfg} @@ -756,7 +756,7 @@ func TestVerifyCommit(t *testing.T) { commitment := createTestRootCommitment(t, 200) cfg := VerifierCfg{ - ChainBridge: tapgarden.NewMockChainBridge(), + ChainBridge: tapnodemock.NewChainBridge(), SupplyCommitView: &MockSupplyCommitView{}, } v := Verifier{assetLog: log, cfg: cfg} @@ -789,7 +789,7 @@ func TestVerifyCommit(t *testing.T) { ).Return(&commitment, nil).Once() cfg := VerifierCfg{ - ChainBridge: tapgarden.NewMockChainBridge(), + ChainBridge: tapnodemock.NewChainBridge(), SupplyCommitView: mockView, } v := Verifier{assetLog: log, cfg: cfg} @@ -833,7 +833,7 @@ func TestVerifyCommit(t *testing.T) { t, mockLookup, groupPrivKey.PubKey(), &delegKey, ) cfg := VerifierCfg{ - ChainBridge: tapgarden.NewMockChainBridge(), + ChainBridge: tapnodemock.NewChainBridge(), SupplyCommitView: mockView, AssetLookup: mockLookup, } @@ -902,7 +902,7 @@ func TestVerifyCommit(t *testing.T) { ).Return(nil, ErrCommitmentNotFound).Once() cfg := VerifierCfg{ - ChainBridge: tapgarden.NewMockChainBridge(), + ChainBridge: tapnodemock.NewChainBridge(), SupplyCommitView: mockView, AssetLookup: mockLookup, GroupFetcher: &MockGroupFetcher{}, @@ -976,7 +976,7 @@ func TestVerifyCommit(t *testing.T) { ).Return(emptyTree, emptySupplyTrees, nil).Once() cfg := VerifierCfg{ - ChainBridge: tapgarden.NewMockChainBridge(), + ChainBridge: tapnodemock.NewChainBridge(), SupplyCommitView: mockView, AssetLookup: mockLookup, GroupFetcher: &MockGroupFetcher{}, @@ -1067,7 +1067,7 @@ func TestVerifyBurnLeaf(t *testing.T) { v := Verifier{ assetLog: log, cfg: VerifierCfg{ - ChainBridge: tapgarden.NewMockChainBridge(), + ChainBridge: tapnodemock.NewChainBridge(), GroupFetcher: mockGroupFetcher, }, } diff --git a/universe/supplyverifier/verifier_test.go b/universe/supplyverifier/verifier_test.go index 1ddc0e0550..bdbe76e6d8 100644 --- a/universe/supplyverifier/verifier_test.go +++ b/universe/supplyverifier/verifier_test.go @@ -7,7 +7,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/taproot-assets/fn" - "github.com/lightninglabs/taproot-assets/tapgarden" + "github.com/lightninglabs/taproot-assets/tapnode/tapnodemock" "github.com/lightninglabs/taproot-assets/universe/supplycommit" "github.com/stretchr/testify/require" ) @@ -19,7 +19,7 @@ func newTestVerifierCfg(t *testing.T) VerifierCfg { return VerifierCfg{ AssetSpec: createTestAssetSpec(t), - ChainBridge: tapgarden.NewMockChainBridge(), + ChainBridge: tapnodemock.NewChainBridge(), AssetLookup: &supplycommit.MockAssetLookup{}, Lnd: &lndclient.LndServices{}, GroupFetcher: &MockGroupFetcher{},