diff --git a/commitment/commitment_test.go b/commitment/commitment_test.go index 04602efb7..15f9624ea 100644 --- a/commitment/commitment_test.go +++ b/commitment/commitment_test.go @@ -464,7 +464,7 @@ func TestSplitCommitment(t *testing.T) { err error }{ { - name: "invalid asset input type", + name: "collectible split with excess external locators", f: func() (*asset.Asset, *SplitLocator, []*SplitLocator) { input := randAsset( t, genesisCollectible, @@ -473,14 +473,52 @@ func TestSplitCommitment(t *testing.T) { root := &SplitLocator{ OutputIndex: 0, AssetID: genesisCollectible.ID(), + ScriptKey: asset.NUMSCompressedKey, + Amount: 0, + } + external := []*SplitLocator{{ + OutputIndex: 1, + AssetID: genesisCollectible.ID(), ScriptKey: asset.ToSerialized( - input.ScriptKey.PubKey, + randKey(t).PubKey(), ), Amount: input.Amount, + }, { + OutputIndex: 1, + AssetID: genesisCollectible.ID(), + ScriptKey: asset.ToSerialized( + randKey(t).PubKey(), + ), + Amount: input.Amount, + }} + return input, root, external + }, + err: ErrInvalidSplitLocatorCount, + }, + { + name: "collectible split commitment", + f: func() (*asset.Asset, *SplitLocator, []*SplitLocator) { + input := randAsset( + t, genesisCollectible, + familyKeyCollectible, + ) + root := &SplitLocator{ + OutputIndex: 0, + AssetID: genesisCollectible.ID(), + ScriptKey: asset.NUMSCompressedKey, + Amount: 0, } - return input, root, nil + external := []*SplitLocator{{ + OutputIndex: 1, + AssetID: genesisCollectible.ID(), + ScriptKey: asset.ToSerialized( + randKey(t).PubKey(), + ), + Amount: input.Amount, + }} + return input, root, external }, - err: ErrInvalidInputType, + err: nil, }, { name: "locator duplicate output index", diff --git a/commitment/split.go b/commitment/split.go index f289edd46..bf0acf12c 100644 --- a/commitment/split.go +++ b/commitment/split.go @@ -13,10 +13,6 @@ import ( ) var ( - // ErrInvalidInputType is an error returned when an input to be split is - // not of type asset.Normal. - ErrInvalidInputType = errors.New("invalid asset input type") - // ErrDuplicateSplitOutputIndex is an error returned when duplicate // split output indices are detected. ErrDuplicateSplitOutputIndex = errors.New( @@ -35,6 +31,12 @@ var ( "at least one locator should be specified", ) + // ErrInvalidSplitLocatorCount is returned if a collectible split is + // attempted with a count of external split locators not equal to one. + ErrInvalidSplitLocatorCount = errors.New( + "exactly one locator should be specified", + ) + // ErrInvalidScriptKey is an error returned when a root locator has zero // value but does not use the correct unspendable script key. ErrInvalidScriptKey = errors.New( @@ -140,9 +142,6 @@ func NewSplitCommitment(input *asset.Asset, outPoint wire.OutPoint, ID: input.Genesis.ID(), ScriptKey: asset.ToSerialized(input.ScriptKey.PubKey), } - if input.Type != asset.Normal { - return nil, ErrInvalidInputType - } // The assets need to go somewhere, they can be fully spent, but we // still require this external locator to denote where the new value @@ -151,6 +150,18 @@ func NewSplitCommitment(input *asset.Asset, outPoint wire.OutPoint, return nil, ErrInvalidSplitLocator } + // To transfer a collectible with a split, the split root must be + // unspendable, and there can only be only one external locator. + if input.Type == asset.Collectible { + if rootLocator.Amount != 0 { + return nil, ErrNonZeroSplitAmount + } + + if len(externalLocators) != 1 { + return nil, ErrInvalidSplitLocatorCount + } + } + // The only valid unspendable root locator uses the correct unspendable // script key and has zero value. if rootLocator.Amount == 0 && diff --git a/itest/collectible_split_test.go b/itest/collectible_split_test.go new file mode 100644 index 000000000..5a12334ec --- /dev/null +++ b/itest/collectible_split_test.go @@ -0,0 +1,91 @@ +package itest + +import ( + "context" + + "github.com/lightninglabs/taro/tarorpc" + "github.com/stretchr/testify/require" +) + +// testCollectibleSend tests that we can properly send a collectible asset +// with split commitments. +func testCollectibleSend(t *harnessTest) { + // First, we'll make a collectible with emission enabled. + rpcAssets := mintAssetsConfirmBatch( + t, t.tarod, []*tarorpc.MintAssetRequest{issuableAssets[1]}, + ) + + familyKey := rpcAssets[0].AssetFamily.TweakedFamilyKey + genInfo := rpcAssets[0].AssetGenesis + genBootstrap := rpcAssets[0].AssetGenesis.GenesisBootstrapInfo + + ctxb := context.Background() + + // Now that we have the asset created, we'll make a new node that'll + // serve as the node which'll receive the assets. + secondTarod := setupTarodHarness( + t.t, t, t.lndHarness.BackendCfg, t.lndHarness.Bob, t.universeServer, + ) + defer func() { + require.NoError(t.t, secondTarod.stop(true)) + }() + + // Next, we'll attempt to complete three transfers of the full value of + // the asset between our main node and Bob. + var ( + numSends = 3 + fullAmount = rpcAssets[0].Amount + receiverAddr *tarorpc.Addr + err error + ) + + for i := 0; i < numSends; i++ { + // Create an address for the receiver and send the asset. We + // start with Bob receiving the asset, then sending it back + // to the main node, and so on. + if i%2 == 0 { + receiverAddr, err = secondTarod.NewAddr( + ctxb, &tarorpc.NewAddrRequest{ + GenesisBootstrapInfo: genBootstrap, + FamKey: familyKey, + Amt: fullAmount, + }, + ) + require.NoError(t.t, err) + + assertAddrCreated( + t.t, secondTarod, rpcAssets[0], receiverAddr, + ) + _ = sendAssetsToAddr(t, t.tarod, receiverAddr) + confirmSend( + t, t.tarod, secondTarod, receiverAddr, genInfo, + ) + } else { + receiverAddr, err = t.tarod.NewAddr( + ctxb, &tarorpc.NewAddrRequest{ + GenesisBootstrapInfo: genBootstrap, + FamKey: familyKey, + Amt: fullAmount, + }, + ) + require.NoError(t.t, err) + + assertAddrCreated( + t.t, t.tarod, rpcAssets[0], receiverAddr, + ) + _ = sendAssetsToAddr(t, secondTarod, receiverAddr) + confirmSend( + t, secondTarod, t.tarod, receiverAddr, genInfo, + ) + } + } + + // Check the final state of both nodes. The main node should list 2 + // zero-value transfers. and Bob should have 1. The main node should + // show a balance of zero, and Bob should hold the total asset supply. + assertTransfers(t.t, t.tarod, []int64{0, 0}) + assertBalance(t.t, t.tarod, genInfo.AssetId, int64(0)) + + assertTransfers(t.t, secondTarod, []int64{0}) + assertBalance(t.t, secondTarod, genInfo.AssetId, int64(fullAmount)) +} diff --git a/itest/test_list_on_test.go b/itest/test_list_on_test.go index fe47a776d..6b9cd0f92 100644 --- a/itest/test_list_on_test.go +++ b/itest/test_list_on_test.go @@ -24,4 +24,8 @@ var testCases = []*testCase{ name: "full value send", test: testFullValueSend, }, + { + name: "collectible send", + test: testCollectibleSend, + }, } diff --git a/tarofreighter/chain_porter.go b/tarofreighter/chain_porter.go index 6ab3bd975..de76e3101 100644 --- a/tarofreighter/chain_porter.go +++ b/tarofreighter/chain_porter.go @@ -600,6 +600,16 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) { // to complete the send w/o merging inputs. assetInput := elgigibleCommitments[0] + // If the key found for the input UTXO is not from the Taro + // keyfamily, something has gone wrong with the DB. + if assetInput.InternalKey.Family != tarogarden.TaroKeyFamily { + return nil, fmt.Errorf("invalid internal key family "+ + "for selected input: %v %v", + assetInput.InternalKey.Family, + assetInput.InternalKey.Index, + ) + } + // At this point, we have a valid "coin" to spend in the // commitment, so we'll update the relevant information in the // send package. @@ -626,8 +636,8 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) { // We'll validate the selected input and commitment. From this // we'll gain the asset that we'll use as an input and info - // w.r.t if we need to split or not. - inputAsset, needsSplit, err := taroscript.IsValidInput( + // w.r.t if we need to use an unspendable zero-value root. + inputAsset, fullValue, err := taroscript.IsValidInput( currentPkg.InputAsset.Commitment, *currentPkg.ReceiverAddr, *currentPkg.InputAsset.Asset.ScriptKey.PubKey, *p.cfg.ChainParams, @@ -652,9 +662,10 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) { return nil, err } - // If we are sending the full value of the input asset, we will - // need to create a split with unspendable change. - if inputAsset.Type == asset.Normal && !needsSplit { + // If we are sending the full value of the input asset, or + // sending a collectible, we will need to create a split with + // unspendable change. + if fullValue { currentPkg.SenderScriptKey = asset.NUMSScriptKey } else { senderScriptKey, err := p.cfg.KeyRing.DeriveNextKey( @@ -671,17 +682,7 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) { ) } - // If we need to split (addr amount < input amount), then we'll - // transition to prepare the set of splits. If not,then we can - // assume the splits are unnecessary. - // - // TODO(roasbeef): always need to split anyway see: - // https://github.com/lightninglabs/taro/issues/121 - if inputAsset.Type == asset.Normal { - currentPkg.SendState = SendStatePreparedSplit - } else { - currentPkg.SendState = SendStatePreparedComplete - } + currentPkg.SendState = SendStatePreparedSplit return ¤tPkg, nil @@ -704,21 +705,6 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) { return ¤tPkg, nil - // Alternatively, we'll enter this state when we know we don't actually - // need a split at all. In this case, we fully consume an input asset, - // so the asset created is the same asset w/ the new script key in - // place. - case SendStatePreparedComplete: - preparedSpend := taroscript.PrepareAssetCompleteSpend( - *currentPkg.ReceiverAddr, currentPkg.InputAssetPrevID, - *currentPkg.SendDelta, - ) - currentPkg.SendDelta = preparedSpend - - currentPkg.SendState = SendStateSigned - - return ¤tPkg, nil - // At this point, we have everything we need to sign our _virtual_ // transaction on the Taro layer. case SendStateSigned: diff --git a/taroscript/send.go b/taroscript/send.go index 8061e9109..9e4264dd7 100644 --- a/taroscript/send.go +++ b/taroscript/send.go @@ -234,11 +234,11 @@ func IsValidInput(input *commitment.TaroCommitment, addr address.Taro, inputScriptKey btcec.PublicKey, net address.ChainParams) (*asset.Asset, bool, error) { - needsSplit := false + fullValue := false // The input and address networks must match. if !address.IsForNet(addr.ChainParams.TaroHRP, &net) { - return nil, needsSplit, address.ErrMismatchedHRP + return nil, fullValue, address.ErrMismatchedHRP } // The top-level Taro tree must have a non-empty asset tree at the leaf @@ -246,7 +246,7 @@ func IsValidInput(input *commitment.TaroCommitment, inputCommitments := input.Commitments() assetCommitment, ok := inputCommitments[addr.TaroCommitmentKey()] if !ok { - return nil, needsSplit, fmt.Errorf("input commitment does "+ + return nil, fullValue, fmt.Errorf("input commitment does "+ "not contain asset_id=%x: %w", addr.TaroCommitmentKey(), ErrMissingInputAsset) } @@ -258,11 +258,11 @@ func IsValidInput(input *commitment.TaroCommitment, ) inputAsset, _, err := assetCommitment.AssetProof(assetCommitmentKey) if err != nil { - return nil, needsSplit, err + return nil, fullValue, err } if inputAsset == nil { - return nil, needsSplit, fmt.Errorf("input commitment does not "+ + return nil, fullValue, fmt.Errorf("input commitment does not "+ "contain leaf with script_key=%x: %w", inputScriptKey.SerializeCompressed(), ErrMissingInputAsset) @@ -270,18 +270,23 @@ func IsValidInput(input *commitment.TaroCommitment, // For Normal assets, we also check that the input asset amount is // at least as large as the amount specified in the address. - // If the input amount exceeds the amount specified in the address, - // the spend will require an asset split. + // If the input amount is exactly the amount specified in the address, + // the spend must use an unspendable zero-value root split. if inputAsset.Type == asset.Normal { if inputAsset.Amount < addr.Amount { - return nil, needsSplit, ErrInsufficientInputAsset + return nil, fullValue, ErrInsufficientInputAsset } - if inputAsset.Amount > addr.Amount { - needsSplit = true + + if inputAsset.Amount == addr.Amount { + fullValue = true } + } else { + // Collectible assets always require the spending split to use an + // unspendable zero-value root split. + fullValue = true } - return inputAsset, needsSplit, nil + return inputAsset, fullValue, nil } // PrepareAssetSplitSpend computes a split commitment with the given input and @@ -328,8 +333,8 @@ func PrepareAssetSplitSpend(addr address.Taro, prevInput asset.PrevID, updatedDelta.Locators[receiverStateKey] = receiverLocator // Enforce an unspendable root split if the split sends the full value - // of the input asset. - if senderLocator.Amount == 0 && + // of the input asset or if the split sends a collectible. + if (senderLocator.Amount == 0 || inputAsset.Type == asset.Collectible) && senderLocator.ScriptKey != asset.NUMSCompressedKey { return nil, commitment.ErrInvalidScriptKey diff --git a/taroscript/send_test.go b/taroscript/send_test.go index 8446bc540..91d5501d5 100644 --- a/taroscript/send_test.go +++ b/taroscript/send_test.go @@ -840,6 +840,30 @@ var prepareAssetSplitSpendTestCases = []prepareAssetSplitSpendTestCase{ }, err: nil, }, + { + name: "asset split with collectible", + f: func(t *testing.T) error { + state := initSpendScenario(t) + spend := taroscript.SpendDelta{ + InputAssets: state.asset1CollectFamilyInputAssets, + } + state.spenderScriptKey = *asset.NUMSPubKey + spendPrepared, err := taroscript.PrepareAssetSplitSpend( + state.address1CollectFamily, + state.asset1CollectFamilyPrevID, + state.spenderScriptKey, spend, + ) + require.NoError(t, err) + + checkPreparedSplitSpend( + t, spendPrepared, state.address1CollectFamily, + state.asset1CollectFamilyPrevID, + state.spenderScriptKey, + ) + return nil + }, + err: nil, + }, { name: "asset split with incorrect script key", f: func(t *testing.T) error { @@ -1113,6 +1137,38 @@ var completeAssetSpendTestCases = []completeAssetSpendTestCase{ return nil }, }, + { + name: "validate split collectible with family key", + f: func(t *testing.T) error { + state := initSpendScenario(t) + spend := taroscript.SpendDelta{ + InputAssets: state. + asset1CollectFamilyInputAssets, + } + state.spenderScriptKey = *asset.NUMSPubKey + spendPrepared, err := taroscript.PrepareAssetSplitSpend( + state.address1CollectFamily, + state.asset1CollectFamilyPrevID, + state.spenderScriptKey, spend, + ) + require.NoError(t, err) + + unvalidatedAsset := spendPrepared.NewAsset + spendCompleted, err := taroscript.CompleteAssetSpend( + state.spenderPubKey, + state.asset1CollectFamilyPrevID, + *spendPrepared, state.signer, state.validator, + ) + require.NoError(t, err) + + checkValidateSpend( + t, &unvalidatedAsset, + &spendCompleted.NewAsset, false, + ) + return nil + }, + err: nil, + }, } // TestCreateSpendCommitments tests edge cases around creating TaroCommitments @@ -1412,6 +1468,52 @@ var createSpendCommitmentsTestCases = []createSpendCommitmentsTestCase{ }, err: nil, }, + { + name: "split collectible with family key", + f: func(t *testing.T) error { + state := initSpendScenario(t) + spend := taroscript.SpendDelta{ + InputAssets: state. + asset1CollectFamilyInputAssets, + } + state.spenderScriptKey = *asset.NUMSPubKey + spendPrepared, err := taroscript.PrepareAssetSplitSpend( + state.address1CollectFamily, + state.asset1CollectFamilyPrevID, + state.spenderScriptKey, spend, + ) + require.NoError(t, err) + + spendCompleted, err := taroscript.CompleteAssetSpend( + state.spenderPubKey, + state.asset1CollectFamilyPrevID, + *spendPrepared, state.signer, state.validator, + ) + require.NoError(t, err) + + spendCommitments, err := taroscript.CreateSpendCommitments( + &state.asset1CollectFamilyTaroTree, + state.asset1CollectFamilyPrevID, + *spendCompleted, state.address1CollectFamily, + state.spenderScriptKey, + ) + require.NoError(t, err) + + senderStateKey := asset.AssetCommitmentKey( + state.address1CollectFamily.ID(), + &state.spenderScriptKey, + false, + ) + receiverStateKey := state.address1CollectFamilyStateKey + checkSpendCommitments( + t, senderStateKey, receiverStateKey, + state.asset1CollectFamilyPrevID, + spendCompleted, spendCommitments, true, + ) + return nil + }, + err: nil, + }, } // TestCreateSpendOutputs tests edge cases around creating Bitcoin outputs @@ -1552,7 +1654,7 @@ var createSpendOutputsTestCases = []createSpendOutputsTestCase{ require.NoError(t, err) senderStateKey := asset.AssetCommitmentKey( - state.address1.ID(), + state.address1CollectFamily.ID(), &state.spenderScriptKey, false, ) receiverStateKey := state.address1CollectFamilyStateKey @@ -1746,6 +1848,67 @@ var createSpendOutputsTestCases = []createSpendOutputsTestCase{ }, err: nil, }, + { + name: "split collectible with family key", + f: func(t *testing.T) error { + state := initSpendScenario(t) + spend := taroscript.SpendDelta{ + InputAssets: state. + asset1CollectFamilyInputAssets, + } + state.spenderScriptKey = *asset.NUMSPubKey + spendPrepared, err := taroscript.PrepareAssetSplitSpend( + state.address1CollectFamily, + state.asset1CollectFamilyPrevID, + state.spenderScriptKey, spend, + ) + require.NoError(t, err) + + spendCompleted, err := taroscript.CompleteAssetSpend( + state.spenderPubKey, + state.asset1CollectFamilyPrevID, + *spendPrepared, state.signer, state.validator, + ) + require.NoError(t, err) + + spendCommitments, err := taroscript.CreateSpendCommitments( + &state.asset1CollectFamilyTaroTree, + state.asset1CollectFamilyPrevID, + *spendCompleted, + state.address1CollectFamily, + state.spenderScriptKey, + ) + require.NoError(t, err) + + receiverStateKey := state.address1CollectFamilyStateKey + receiverLocator := spendCompleted. + Locators[receiverStateKey] + receiverAsset := spendCompleted.SplitCommitment. + SplitAssets[receiverLocator].Asset + spendPsbt, err := taroscript.CreateTemplatePsbt( + spendCompleted.Locators, + ) + require.NoError(t, err) + + err = taroscript.CreateSpendOutputs( + state.address1CollectFamily, + spendCompleted.Locators, + state.spenderPubKey, state.spenderScriptKey, + spendCommitments, spendPsbt, + ) + require.NoError(t, err) + + checkSpendOutputs( + t, state.address1CollectFamily, + state.spenderPubKey, state.spenderScriptKey, + &spendCompleted.NewAsset, &receiverAsset, + spendCommitments, spendCompleted.Locators, + spendPsbt, true, + ) + return nil + }, + err: nil, + }, } // TestProofVerify tests that a split spend can be used to append to a @@ -1973,11 +2136,11 @@ var addressValidInputTestCases = []addressValidInputTestCase{ name: "valid normal", f: func(t *testing.T) (*asset.Asset, *asset.Asset, error) { state := initSpendScenario(t) - inputAsset, needsSplit, err := taroscript.IsValidInput( + inputAsset, fullValue, err := taroscript.IsValidInput( &state.asset1TaroTree, state.address1, state.spenderScriptKey, address.MainNetTaro, ) - require.False(t, needsSplit) + require.True(t, fullValue) return &state.asset1, inputAsset, err }, err: nil, @@ -1986,12 +2149,12 @@ var addressValidInputTestCases = []addressValidInputTestCase{ name: "valid collectible with family key", f: func(t *testing.T) (*asset.Asset, *asset.Asset, error) { state := initSpendScenario(t) - inputAsset, needsSplit, err := taroscript.IsValidInput( + inputAsset, fullValue, err := taroscript.IsValidInput( &state.asset1CollectFamilyTaroTree, state.address1CollectFamily, state.spenderScriptKey, address.TestNet3Taro, ) - require.False(t, needsSplit) + require.True(t, fullValue) return &state.asset1CollectFamily, inputAsset, err }, err: nil, @@ -2000,11 +2163,11 @@ var addressValidInputTestCases = []addressValidInputTestCase{ name: "valid asset split", f: func(t *testing.T) (*asset.Asset, *asset.Asset, error) { state := initSpendScenario(t) - inputAsset, needsSplit, err := taroscript.IsValidInput( + inputAsset, fullValue, err := taroscript.IsValidInput( &state.asset2TaroTree, state.address1, state.spenderScriptKey, address.MainNetTaro, ) - require.True(t, needsSplit) + require.False(t, fullValue) return &state.asset2, inputAsset, err }, err: nil, @@ -2013,11 +2176,11 @@ var addressValidInputTestCases = []addressValidInputTestCase{ name: "normal with insufficient amount", f: func(t *testing.T) (*asset.Asset, *asset.Asset, error) { state := initSpendScenario(t) - inputAsset, needsSplit, err := taroscript.IsValidInput( + inputAsset, fullValue, err := taroscript.IsValidInput( &state.asset1TaroTree, state.address2, state.spenderScriptKey, address.MainNetTaro, ) - require.False(t, needsSplit) + require.False(t, fullValue) return &state.asset1, inputAsset, err }, err: taroscript.ErrInsufficientInputAsset, @@ -2026,12 +2189,12 @@ var addressValidInputTestCases = []addressValidInputTestCase{ name: "collectible with missing input asset", f: func(t *testing.T) (*asset.Asset, *asset.Asset, error) { state := initSpendScenario(t) - inputAsset, needsSplit, err := taroscript.IsValidInput( + inputAsset, fullValue, err := taroscript.IsValidInput( &state.asset1TaroTree, state.address1CollectFamily, state.spenderScriptKey, address.TestNet3Taro, ) - require.False(t, needsSplit) + require.False(t, fullValue) return &state.asset1, inputAsset, err }, err: taroscript.ErrMissingInputAsset, @@ -2046,11 +2209,11 @@ var addressValidInputTestCases = []addressValidInputTestCase{ &address.TestNet3Taro, ) require.NoError(t, err) - inputAsset, needsSplit, err := taroscript.IsValidInput( + inputAsset, fullValue, err := taroscript.IsValidInput( &state.asset1TaroTree, *address1testnet, state.receiverPubKey, address.TestNet3Taro, ) - require.False(t, needsSplit) + require.False(t, fullValue) return &state.asset1, inputAsset, err }, err: taroscript.ErrMissingInputAsset, @@ -2065,11 +2228,11 @@ var addressValidInputTestCases = []addressValidInputTestCase{ &address.TestNet3Taro, ) require.NoError(t, err) - inputAsset, needsSplit, err := taroscript.IsValidInput( + inputAsset, fullValue, err := taroscript.IsValidInput( &state.asset1TaroTree, *address1testnet, state.receiverPubKey, address.MainNetTaro, ) - require.False(t, needsSplit) + require.False(t, fullValue) return &state.asset1, inputAsset, err }, err: address.ErrMismatchedHRP, diff --git a/vm/error.go b/vm/error.go index ecf128fec..b93717363 100644 --- a/vm/error.go +++ b/vm/error.go @@ -47,7 +47,7 @@ const ( ErrInvalidTransferWitness // ErrInvalidSplitAssetType represents an error case where an asset - // split type is not `asset.Normal`. + // split type does not match the root asset. ErrInvalidSplitAssetType // ErrInvalidSplitCommitmentWitness represents an error case where an diff --git a/vm/vm.go b/vm/vm.go index 02493d3d8..a2f877e74 100644 --- a/vm/vm.go +++ b/vm/vm.go @@ -105,12 +105,10 @@ func matchesAssetParams(newAsset, prevAsset *asset.Asset, // input. This is done by verifying the asset split is committed to within the // new asset's split commitment root through its split commitment proof. func (vm *Engine) validateSplit() error { - // Only `Normal` assets can be split, and the change asset should have - // a split commitment root. + // The asset type must match for all parts of a split, and the change + // asset should have a split commitment root. switch { - case vm.newAsset.Type != asset.Normal || - vm.splitAsset.Type != asset.Normal: - + case vm.newAsset.Type != vm.splitAsset.Type: return newErrKind(ErrInvalidSplitAssetType) case vm.newAsset.SplitCommitmentRoot == nil: diff --git a/vm/vm_test.go b/vm/vm_test.go index f80f93459..5b62345e1 100644 --- a/vm/vm_test.go +++ b/vm/vm_test.go @@ -362,6 +362,56 @@ func splitFullValueStateTransition(t *testing.T, validRootLocator, } } +func splitCollectibleStateTransition(t *testing.T, + validRoot bool) stateTransitionFunc { + + return func(t *testing.T) (*asset.Asset, commitment.SplitSet, + commitment.InputSet) { + + privKey := randKey(t) + scriptKey := txscript.ComputeTaprootKeyNoScript(privKey.PubKey()) + + genesisOutPoint := wire.OutPoint{} + genesisAsset := randAsset(t, asset.Collectible, *scriptKey) + + assetID := genesisAsset.Genesis.ID() + rootLocator := &commitment.SplitLocator{ + OutputIndex: 0, + AssetID: assetID, + ScriptKey: asset.NUMSCompressedKey, + Amount: 0, + } + externalLocators := []*commitment.SplitLocator{{ + OutputIndex: 1, + AssetID: assetID, + ScriptKey: asset.ToSerialized(randKey(t).PubKey()), + Amount: genesisAsset.Amount, + }} + splitCommitment, err := commitment.NewSplitCommitment( + genesisAsset, genesisOutPoint, rootLocator, + externalLocators..., + ) + require.NoError(t, err) + + virtualTx, _, err := taroscript.VirtualTx( + splitCommitment.RootAsset, splitCommitment.PrevAssets, + ) + require.NoError(t, err) + newWitness := genTaprootKeySpend( + t, *privKey, virtualTx, genesisAsset, 0, + ) + require.NoError(t, err) + splitCommitment.RootAsset.PrevWitnesses[0].TxWitness = newWitness + + if !validRoot { + splitCommitment.RootAsset.Type = asset.Normal + } + + return splitCommitment.RootAsset, splitCommitment.SplitAssets, + splitCommitment.PrevAssets + } +} + func TestVM(t *testing.T) { t.Parallel() @@ -380,6 +430,11 @@ func TestVM(t *testing.T) { f: genesisStateTransition(t, asset.Collectible, false), err: newErrKind(ErrInvalidGenesisStateTransition), }, + { + name: "invalid split collectible input", + f: splitCollectibleStateTransition(t, false), + err: newErrKind(ErrInvalidSplitAssetType), + }, { name: "normal genesis", f: genesisStateTransition(t, asset.Normal, true), @@ -420,6 +475,11 @@ func TestVM(t *testing.T) { f: splitFullValueStateTransition(t, false, true), err: newErrKind(ErrInvalidRootAsset), }, + { + name: "split collectible state transition", + f: splitCollectibleStateTransition(t, true), + err: nil, + }, } for _, testCase := range testCases {