Skip to content

Commit 7356ae4

Browse files
committed
tapchannel+tapscript: derive unique funding script keys
To avoid a proof collision in the universe, we need to make sure we derive unique funding output script keys for multiple asset IDs within the same channel funding outpoint. We do that by adding a second OP_RETURN leaf into the tapscript tree of the funding script key. That should ensure uniqueness of the top-level Taproot output key and only requires us to slightly alter the control block when creating the witness (it will now include an inclusion proof element for the OP_RETURN leaf).
1 parent cdadcab commit 7356ae4

File tree

4 files changed

+229
-59
lines changed

4 files changed

+229
-59
lines changed

tapchannel/aux_closer.go

+16-29
Original file line numberDiff line numberDiff line change
@@ -161,16 +161,23 @@ func createCloseAlloc(isLocal bool, outputSum uint64,
161161
func signCommitVirtualPackets(ctx context.Context,
162162
vPackets []*tappsbt.VPacket) error {
163163

164-
// Now that we've added all the relevant vPackets, we'll prepare the
165-
// funding witness which includes the OP_TRUE ctrl block.
166-
fundingWitness, err := fundingSpendWitness().Unpack()
167-
if err != nil {
168-
return fmt.Errorf("unable to make funding witness: %w", err)
169-
}
170-
171-
// With all the vPackets created, we'll create output commitments from
172-
// them, as we'll need them to ship the transaction off to the porter.
164+
useUniqueScriptKey := len(vPackets) > 1
173165
for idx := range vPackets {
166+
assetID, err := vPackets[idx].AssetID()
167+
if err != nil {
168+
return fmt.Errorf("unable to get asset ID: %w", err)
169+
}
170+
171+
// First, we'll prepare the funding witness which includes the
172+
// OP_TRUE ctrl block.
173+
fundingWitness, err := tapscript.ChannelFundingSpendWitness(
174+
useUniqueScriptKey, assetID,
175+
)
176+
if err != nil {
177+
return fmt.Errorf("unable to make funding witness: %w",
178+
err)
179+
}
180+
174181
err = tapsend.PrepareOutputAssets(ctx, vPackets[idx])
175182
if err != nil {
176183
return fmt.Errorf("unable to prepare output "+
@@ -203,26 +210,6 @@ func signCommitVirtualPackets(ctx context.Context,
203210
return nil
204211
}
205212

206-
// fundingSpendWitness creates a complete witness to spend the OP_TRUE funding
207-
// script of an asset funding output.
208-
func fundingSpendWitness() lfn.Result[wire.TxWitness] {
209-
fundingScriptTree := tapscript.NewChannelFundingScriptTree()
210-
211-
tapscriptTree := fundingScriptTree.TapscriptTree
212-
ctrlBlock := tapscriptTree.LeafMerkleProofs[0].ToControlBlock(
213-
&input.TaprootNUMSKey,
214-
)
215-
ctrlBlockBytes, err := ctrlBlock.ToBytes()
216-
if err != nil {
217-
return lfn.Errf[wire.TxWitness]("unable to serialize control "+
218-
"block: %w", err)
219-
}
220-
221-
return lfn.Ok(wire.TxWitness{
222-
tapscript.AnyoneCanSpendScript(), ctrlBlockBytes,
223-
})
224-
}
225-
226213
// AuxCloseOutputs returns the set of close outputs to use for this co-op close
227214
// attempt. We'll add some extra outputs to the co-op close transaction, and
228215
// also give the caller a custom sorting routine.

tapchannel/aux_funding_controller.go

+75-30
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import (
3131
"github.com/lightninglabs/taproot-assets/tapfreighter"
3232
"github.com/lightninglabs/taproot-assets/tapgarden"
3333
"github.com/lightninglabs/taproot-assets/tappsbt"
34-
"github.com/lightninglabs/taproot-assets/tapscript"
3534
"github.com/lightninglabs/taproot-assets/tapsend"
3635
"github.com/lightninglabs/taproot-assets/vm"
3736
lfn "github.com/lightningnetwork/lnd/fn/v2"
@@ -758,27 +757,18 @@ func (f *FundingController) fundVirtualPacket(ctx context.Context,
758757
amt)
759758

760759
// Our funding script key will be the OP_TRUE addr that we'll use as
761-
// the funding script on the asset level.
762-
fundingScriptTree := tapscript.NewChannelFundingScriptTree()
763-
fundingTaprootKey, _ := schnorr.ParsePubKey(
764-
schnorr.SerializePubKey(fundingScriptTree.TaprootKey),
760+
// the funding script on the asset level. We start with this one in case
761+
// there is only a single asset ID in the channel. This is to remain
762+
// backward compatible with previous versions of the channel funding
763+
// process, so updated users can still fund channels with a single asset
764+
// ID with older clients. Also, we don't know what asset IDs we're going
765+
// to be using, so we couldn't derive a unique funding script key for
766+
// each asset ID yet anyway.
767+
fundingScriptKey, err := deriveFundingScriptKey(
768+
ctx, f.cfg.AddrBook, nil,
765769
)
766-
fundingScriptKey := asset.ScriptKey{
767-
PubKey: fundingTaprootKey,
768-
TweakedScriptKey: &asset.TweakedScriptKey{
769-
RawKey: keychain.KeyDescriptor{
770-
PubKey: fundingScriptTree.InternalKey,
771-
},
772-
Tweak: fundingScriptTree.TapscriptRoot,
773-
},
774-
}
775-
776-
// We'll also need to import the funding script key into the wallet so
777-
// the asset will be materialized in the asset table and show up in the
778-
// balance correctly.
779-
err := f.cfg.AddrBook.InsertScriptKey(ctx, fundingScriptKey, true)
780770
if err != nil {
781-
return nil, fmt.Errorf("unable to insert script key: %w", err)
771+
return nil, fmt.Errorf("unable to derive script key: %w", err)
782772
}
783773

784774
// Next, we'll use the asset wallet to fund a new vPSBT which'll be
@@ -805,7 +795,62 @@ func (f *FundingController) fundVirtualPacket(ctx context.Context,
805795

806796
// Fund the packet. This will derive an anchor internal key for us, but
807797
// we'll overwrite that later on.
808-
return f.cfg.AssetWallet.FundPacket(ctx, fundDesc, pktTemplate)
798+
fundedPkt, err := f.cfg.AssetWallet.FundPacket(
799+
ctx, fundDesc, pktTemplate,
800+
)
801+
if err != nil {
802+
return nil, fmt.Errorf("unable to fund vPacket: %w", err)
803+
}
804+
805+
// If there was just a single virtual packet created, it means we only
806+
// have a single asset ID in the channel, and we can proceed without any
807+
// workarounds.
808+
if len(fundedPkt.VPackets) == 1 {
809+
return fundedPkt, nil
810+
}
811+
812+
// For channels with multiple asset IDs, we'll need to create unique
813+
// funding script keys for each asset ID. Otherwise, the proofs for the
814+
// assets will collide in the universe because of group key, script key
815+
// and outpoint all being equal.
816+
for _, vPkt := range fundedPkt.VPackets {
817+
assetID, err := vPkt.AssetID()
818+
if err != nil {
819+
return nil, fmt.Errorf("unable to get asset ID: %w",
820+
err)
821+
}
822+
823+
// If there's change from the funding output, it'll be in the
824+
// split root output. If there's no change, there will be no
825+
// split root output, since the virtual transfer is interactive.
826+
// So in either case we just need to get the first non-root
827+
// output.
828+
fundingOut, err := vPkt.FirstNonSplitRootOutput()
829+
if err != nil {
830+
return nil, fmt.Errorf("unable to get first non split "+
831+
"root output: %w", err)
832+
}
833+
834+
fundingScriptKey, err := deriveFundingScriptKey(
835+
ctx, f.cfg.AddrBook, &assetID,
836+
)
837+
if err != nil {
838+
return nil, fmt.Errorf("unable to derive script key: "+
839+
"%w", err)
840+
}
841+
842+
// We now set the unique script key. This requires us to
843+
// re-calculate the split commitments, so we'll do that right
844+
// afterward.
845+
fundingOut.ScriptKey = fundingScriptKey
846+
847+
if err := tapsend.PrepareOutputAssets(ctx, vPkt); err != nil {
848+
return nil, fmt.Errorf("unable to prepare output "+
849+
"assets after funding key update: %w", err)
850+
}
851+
}
852+
853+
return fundedPkt, nil
809854
}
810855

811856
// sendInputOwnershipProofs sends the input ownership proofs to the remote
@@ -1014,8 +1059,7 @@ func (f *FundingController) signAllVPackets(ctx context.Context,
10141059
// complete, but unsigned PSBT packet that can be used to create out asset
10151060
// channel.
10161061
func (f *FundingController) anchorVPackets(fundedPkt *tapsend.FundedPsbt,
1017-
allPackets []*tappsbt.VPacket,
1018-
fundingScriptKey asset.ScriptKey) ([]*proof.Proof, error) {
1062+
allPackets []*tappsbt.VPacket) ([]*proof.Proof, error) {
10191063

10201064
log.Infof("Anchoring funding vPackets to funding PSBT")
10211065

@@ -1062,10 +1106,13 @@ func (f *FundingController) anchorVPackets(fundedPkt *tapsend.FundedPsbt,
10621106

10631107
vPkt.Outputs[vOutIdx].ProofSuffix = proofSuffix
10641108

1065-
if proofSuffix.Asset.ScriptKey.PubKey.IsEqual(
1066-
fundingScriptKey.PubKey,
1067-
) {
1068-
1109+
// Any output that isn't a split root output is a
1110+
// channel funding output, so we'll store the proofs
1111+
// for those outputs. If there is change, that will be
1112+
// the split root output. And if there is no change,
1113+
// there is no split root output, as it's an interactive
1114+
// transfer.
1115+
if vPkt.Outputs[vOutIdx].Type == tappsbt.TypeSimple {
10691116
fundingProofs = append(
10701117
fundingProofs, proofSuffix,
10711118
)
@@ -1252,10 +1299,8 @@ func (f *FundingController) completeChannelFunding(ctx context.Context,
12521299
// With all the vPackets signed, we'll now anchor them to the funding
12531300
// PSBT. This'll update all the pkScripts for our funding output and
12541301
// change.
1255-
fundingScriptTree := tapscript.NewChannelFundingScriptTree()
1256-
fundingScriptKey := asset.NewScriptKey(fundingScriptTree.TaprootKey)
12571302
fundingOutputProofs, err := f.anchorVPackets(
1258-
finalFundedPsbt, signedPkts, fundingScriptKey,
1303+
finalFundedPsbt, signedPkts,
12591304
)
12601305
if err != nil {
12611306
return nil, fmt.Errorf("unable to anchor vPackets: %w", err)

tapchannel/commitment.go

+50
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/lightninglabs/taproot-assets/rfqmsg"
1919
cmsg "github.com/lightninglabs/taproot-assets/tapchannelmsg"
2020
"github.com/lightninglabs/taproot-assets/tappsbt"
21+
"github.com/lightninglabs/taproot-assets/tapscript"
2122
"github.com/lightninglabs/taproot-assets/tapsend"
2223
"github.com/lightningnetwork/lnd/channeldb"
2324
lfn "github.com/lightningnetwork/lnd/fn/v2"
@@ -1452,6 +1453,55 @@ func FakeCommitTx(fundingOutpoint wire.OutPoint,
14521453
return fakeCommitTx, nil
14531454
}
14541455

1456+
// deriveFundingScriptKey derives the funding script key that'll be used to
1457+
// fund the channel. If an asset ID is provided, then a unique funding script
1458+
// key will be derived for that asset ID.
1459+
func deriveFundingScriptKey(ctx context.Context, addrBook address.Storage,
1460+
assetID *asset.ID) (asset.ScriptKey, error) {
1461+
1462+
fundingScriptTree := tapscript.NewChannelFundingScriptTree()
1463+
1464+
// If we're using more than one asset ID in a channel, then this will be
1465+
// called with the actual asset ID set. So we need to use a different
1466+
// function to generate the funding script key.
1467+
if assetID != nil {
1468+
scriptKey, err := tapscript.NewChannelFundingScriptTreeUniqueID(
1469+
*assetID,
1470+
)
1471+
if err != nil {
1472+
return asset.ScriptKey{}, fmt.Errorf("error deriving "+
1473+
"funding script key for asset ID %s: %w",
1474+
assetID.String(), err)
1475+
}
1476+
1477+
fundingScriptTree = scriptKey
1478+
}
1479+
1480+
fundingTaprootKey, _ := schnorr.ParsePubKey(
1481+
schnorr.SerializePubKey(fundingScriptTree.TaprootKey),
1482+
)
1483+
fundingScriptKey := asset.ScriptKey{
1484+
PubKey: fundingTaprootKey,
1485+
TweakedScriptKey: &asset.TweakedScriptKey{
1486+
RawKey: keychain.KeyDescriptor{
1487+
PubKey: fundingScriptTree.InternalKey,
1488+
},
1489+
Tweak: fundingScriptTree.TapscriptRoot,
1490+
},
1491+
}
1492+
1493+
// We'll also need to import the funding script key into the wallet so
1494+
// the asset will be materialized in the asset table and show up in the
1495+
// balance correctly.
1496+
err := addrBook.InsertScriptKey(ctx, fundingScriptKey, true)
1497+
if err != nil {
1498+
return asset.ScriptKey{}, fmt.Errorf("unable to insert script "+
1499+
"key: %w", err)
1500+
}
1501+
1502+
return fundingScriptKey, nil
1503+
}
1504+
14551505
// InPlaceCustomCommitSort performs an in-place sort of a transaction, given a
14561506
// list of allocations. The sort is applied to the transaction outputs, using
14571507
// the allocation's OutputIndex. The transaction inputs are sorted by the

tapscript/script.go

+88
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package tapscript
22

33
import (
4+
"fmt"
5+
46
"github.com/btcsuite/btcd/txscript"
7+
"github.com/btcsuite/btcd/wire"
8+
"github.com/lightninglabs/taproot-assets/asset"
59
"github.com/lightningnetwork/lnd/input"
610
)
711

@@ -48,3 +52,87 @@ func NewChannelFundingScriptTree() *FundingScriptTree {
4852
},
4953
}
5054
}
55+
56+
// NewChannelFundingScriptTreeUniqueID creates a new funding script tree for a
57+
// custom channel asset-level script key. The script tree is constructed with a
58+
// simple OP_TRUE script that allows anyone to spend the output and another
59+
// OP_RETURN script that makes the resulting script key unique. This simplifies
60+
// the funding process as no signatures for the asset-level witnesses need to be
61+
// exchanged. This is still safe because the BTC level multi-sig output is still
62+
// protected by a 2-of-2 MuSig2 output.
63+
func NewChannelFundingScriptTreeUniqueID(id asset.ID) (*FundingScriptTree,
64+
error) {
65+
66+
// First, we'll generate our OP_TRUE script.
67+
fundingScript := AnyoneCanSpendScript()
68+
fundingTapLeaf := txscript.NewBaseTapLeaf(fundingScript)
69+
70+
// Then we'll create the OP_RETURN leaf with the asset ID to make the
71+
// resulting script key unique.
72+
opReturnLeaf, err := asset.NewNonSpendableScriptLeaf(
73+
asset.OpReturnVersion, id[:],
74+
)
75+
if err != nil {
76+
return nil, fmt.Errorf("error deriving op return leaf: %w", err)
77+
}
78+
79+
// With the both scripts derived, we'll now create the tapscript tree
80+
// from them.
81+
tapscriptTree := txscript.AssembleTaprootScriptTree(
82+
fundingTapLeaf, opReturnLeaf,
83+
)
84+
tapScriptRoot := tapscriptTree.RootNode.TapHash()
85+
86+
// Finally, we'll make the funding output script which actually uses a
87+
// NUMS key to force a script path only.
88+
fundingOutputKey := txscript.ComputeTaprootOutputKey(
89+
&input.TaprootNUMSKey, tapScriptRoot[:],
90+
)
91+
92+
return &FundingScriptTree{
93+
ScriptTree: input.ScriptTree{
94+
InternalKey: &input.TaprootNUMSKey,
95+
TaprootKey: fundingOutputKey,
96+
TapscriptTree: tapscriptTree,
97+
TapscriptRoot: tapScriptRoot[:],
98+
},
99+
}, nil
100+
}
101+
102+
// ChannelFundingSpendWitness creates a complete witness to spend the OP_TRUE
103+
// funding script of an asset funding output.
104+
func ChannelFundingSpendWitness(uniqueScriptKeys bool,
105+
assetID asset.ID) (wire.TxWitness, error) {
106+
107+
fundingScriptTree := NewChannelFundingScriptTree()
108+
109+
// If we're using unique script keys for multiple virtual packets with
110+
// different asset IDs, we need to derive a specific script tree that
111+
// includes the asset ID. Everything else should still work the same
112+
// way (the OP_TRUE leaf we spend is still at index zero.
113+
if uniqueScriptKeys {
114+
var err error
115+
fundingScriptTree, err = NewChannelFundingScriptTreeUniqueID(
116+
assetID,
117+
)
118+
if err != nil {
119+
return nil, fmt.Errorf("unable to create unique "+
120+
"script key: %w", err)
121+
}
122+
}
123+
124+
const opTrueIndex = 0
125+
tapscriptTree := fundingScriptTree.TapscriptTree
126+
ctrlBlock := tapscriptTree.LeafMerkleProofs[opTrueIndex].ToControlBlock(
127+
&input.TaprootNUMSKey,
128+
)
129+
ctrlBlockBytes, err := ctrlBlock.ToBytes()
130+
if err != nil {
131+
return nil, fmt.Errorf("unable to serialize control "+
132+
"block: %w", err)
133+
}
134+
135+
return wire.TxWitness{
136+
AnyoneCanSpendScript(), ctrlBlockBytes,
137+
}, nil
138+
}

0 commit comments

Comments
 (0)