Skip to content

Commit

Permalink
Merge pull request #176 from lightninglabs/splits-collectibles
Browse files Browse the repository at this point in the history
multi: support non-interactive collectible sends
  • Loading branch information
Roasbeef authored Nov 9, 2022
2 parents 19af433 + 929981f commit 77b6919
Show file tree
Hide file tree
Showing 10 changed files with 432 additions and 76 deletions.
46 changes: 42 additions & 4 deletions commitment/commitment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down
25 changes: 18 additions & 7 deletions commitment/split.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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 &&
Expand Down
91 changes: 91 additions & 0 deletions itest/collectible_split_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
4 changes: 4 additions & 0 deletions itest/test_list_on_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,8 @@ var testCases = []*testCase{
name: "full value send",
test: testFullValueSend,
},
{
name: "collectible send",
test: testCollectibleSend,
},
}
48 changes: 17 additions & 31 deletions tarofreighter/chain_porter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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 &currentPkg, nil

Expand All @@ -704,21 +705,6 @@ func (p *ChainPorter) stateStep(currentPkg sendPackage) (*sendPackage, error) {

return &currentPkg, 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 &currentPkg, nil

// At this point, we have everything we need to sign our _virtual_
// transaction on the Taro layer.
case SendStateSigned:
Expand Down
31 changes: 18 additions & 13 deletions taroscript/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,19 +234,19 @@ 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
// specified in the address.
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)
}
Expand All @@ -258,30 +258,35 @@ 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)
}

// 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
Expand Down Expand Up @@ -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
Expand Down
Loading

0 comments on commit 77b6919

Please sign in to comment.