Skip to content

Commit e1c15a3

Browse files
committed
assets: add support for co-signing a zero-fee deposit HTLC spend
This commit extends the deposit manager and sweeper to support generating a zero-fee HTLC transaction that spends a selected deposit. Once the HTLC is prepared, it is partially signed by the client and can be handed to the server as a safety measure before using the deposit in a trustless swap. This typically occurs after the client has accepted the swap payment. If the client becomes unresponsive during the swap process, the server can use the zero-fee HTLC as part of a recovery package.
1 parent c035613 commit e1c15a3

File tree

6 files changed

+345
-3
lines changed

6 files changed

+345
-3
lines changed

assets/deposit/manager.go

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
package deposit
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"fmt"
78
"strings"
89
"time"
910

1011
"github.com/btcsuite/btcd/btcec/v2"
12+
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
1113
"github.com/btcsuite/btcd/chaincfg"
1214
"github.com/btcsuite/btcd/wire"
13-
"github.com/decred/dcrd/dcrec/secp256k1/v4"
15+
"github.com/btcsuite/btcwallet/wallet"
1416
"github.com/lightninglabs/lndclient"
1517
"github.com/lightninglabs/loop/assets"
1618
"github.com/lightninglabs/loop/assets/htlc"
@@ -19,8 +21,7 @@ import (
1921
"github.com/lightninglabs/taproot-assets/asset"
2022
"github.com/lightninglabs/taproot-assets/rpcutils"
2123
"github.com/lightninglabs/taproot-assets/taprpc"
22-
"github.com/lightninglabs/taproot-assets/taprpc/assetwalletrpc"
23-
"google.golang.org/protobuf/proto"
24+
"github.com/lightningnetwork/lnd/lntypes"
2425
)
2526

2627
var (
@@ -1240,3 +1241,68 @@ func (m *Manager) RevealDepositKeys(ctx context.Context,
12401241

12411242
return err
12421243
}
1244+
1245+
// CoSignHTLC will partially a deposit spending zero-fee HTLC and send the
1246+
// resulting signature to the swap server.
1247+
func (m *Manager) CoSignHTLC(ctx context.Context, depositID string,
1248+
serverNonce [musig2.PubNonceSize]byte, hash lntypes.Hash,
1249+
csvExpiry uint32) error {
1250+
1251+
done, err := m.scheduleNextCall()
1252+
if err != nil {
1253+
return err
1254+
}
1255+
defer done()
1256+
1257+
deposit, ok := m.deposits[depositID]
1258+
if !ok {
1259+
return fmt.Errorf("deposit %v not available", depositID)
1260+
}
1261+
1262+
_, htlcPkt, _, _, _, err := m.sweeper.GetHTLC(
1263+
ctx, deposit.Kit, deposit.Proof, deposit.Amount, hash,
1264+
csvExpiry,
1265+
)
1266+
if err != nil {
1267+
log.Errorf("Unable to get HTLC packet: %v", err)
1268+
1269+
return err
1270+
}
1271+
1272+
prevOutFetcher := wallet.PsbtPrevOutputFetcher(htlcPkt)
1273+
sigHash, err := getSigHash(htlcPkt.UnsignedTx, 0, prevOutFetcher)
1274+
if err != nil {
1275+
return err
1276+
}
1277+
1278+
nonce, partialSig, err := m.sweeper.PartialSignMuSig2(
1279+
ctx, deposit.Kit, deposit.AnchorRootHash, serverNonce, sigHash,
1280+
)
1281+
if err != nil {
1282+
log.Errorf("Unable to partial sign HTLC: %v", err)
1283+
1284+
return err
1285+
}
1286+
1287+
var pktBuf bytes.Buffer
1288+
err = htlcPkt.Serialize(&pktBuf)
1289+
if err != nil {
1290+
log.Errorf("Unable to write HTLC packet: %v", err)
1291+
return err
1292+
}
1293+
1294+
_, err = m.depositServiceClient.PushAssetDepositHtlcSigs(
1295+
ctx, &swapserverrpc.PushAssetDepositHtlcSigsReq{
1296+
PartialSigs: []*swapserverrpc.AssetDepositPartialSig{
1297+
{
1298+
DepositId: depositID,
1299+
Nonce: nonce[:],
1300+
PartialSig: partialSig,
1301+
},
1302+
},
1303+
HtlcPsbt: pktBuf.Bytes(),
1304+
},
1305+
)
1306+
1307+
return err
1308+
}

assets/deposit/server.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import (
55
"encoding/hex"
66
"fmt"
77

8+
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
89
"github.com/lightninglabs/loop/looprpc"
910
"github.com/lightninglabs/taproot-assets/asset"
11+
"github.com/lightningnetwork/lnd/lntypes"
1012
"google.golang.org/grpc/codes"
1113
"google.golang.org/grpc/status"
1214
)
@@ -176,3 +178,39 @@ func (s *Server) WithdrawAssetDeposits(ctx context.Context,
176178

177179
return &looprpc.WithdrawAssetDepositsResponse{}, nil
178180
}
181+
182+
// PushAssetDepositHtlcSig is the rpc endpoint for loop clients to push partial
183+
// signatures for asset deposit spending zero fee HTLCs to the server.
184+
func (s *Server) PushAssetDepositHtlcSig(ctx context.Context,
185+
in *looprpc.PushAssetDepositHtlcSigRequest) (
186+
*looprpc.PushAssetDepositHtlcSigResponse, error) {
187+
188+
if len(in.Nonce) != musig2.PubNonceSize {
189+
return nil, status.Error(codes.InvalidArgument,
190+
fmt.Sprintf("invalid nonce length: expected %d bytes, "+
191+
"got %d", musig2.PubNonceSize, len(in.Nonce)))
192+
}
193+
194+
if len(in.PreimageHash) != lntypes.HashSize {
195+
return nil, status.Error(codes.InvalidArgument,
196+
fmt.Sprintf("invalid preimage hash length: expected "+
197+
"%d bytes, got %d", lntypes.HashSize,
198+
len(in.PreimageHash)))
199+
}
200+
201+
var (
202+
nonce [musig2.PubNonceSize]byte
203+
preimageHash lntypes.Hash
204+
)
205+
copy(nonce[:], in.Nonce)
206+
copy(preimageHash[:], in.PreimageHash)
207+
208+
err := s.manager.PushHtlcSig(
209+
ctx, in.DepositId, nonce, preimageHash, in.CsvExpiry,
210+
)
211+
if err != nil {
212+
return nil, status.Error(codes.Internal, err.Error())
213+
}
214+
215+
return &looprpc.PushAssetDepositHtlcSigResponse{}, nil
216+
}

assets/deposit/signer.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package deposit
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
8+
"github.com/btcsuite/btcd/btcec/v2"
9+
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
10+
"github.com/lightninglabs/lndclient"
11+
"github.com/lightningnetwork/lnd/input"
12+
)
13+
14+
type MuSig2Signer struct {
15+
signer lndclient.SignerClient
16+
17+
scriptKey *btcec.PublicKey
18+
otherPubKey *btcec.PublicKey
19+
anchorRootHash []byte
20+
21+
session input.MuSig2Session
22+
}
23+
24+
func NewMuSig2Signer(signer lndclient.SignerClient, deposit *Kit, funder bool,
25+
anchorRootHash []byte) *MuSig2Signer {
26+
27+
scriptKey := deposit.FunderScriptKey
28+
otherPubKey := deposit.CoSignerInternalKey
29+
if !funder {
30+
scriptKey = deposit.CoSignerScriptKey
31+
otherPubKey = deposit.FunderInternalKey
32+
}
33+
34+
return &MuSig2Signer{
35+
signer: signer,
36+
scriptKey: scriptKey,
37+
otherPubKey: otherPubKey,
38+
anchorRootHash: anchorRootHash,
39+
}
40+
}
41+
42+
func (s *MuSig2Signer) NewSession(ctx context.Context) error {
43+
// Derive the internal key that will be used to sign the message.
44+
signingPubKey, signingPrivKey, err := DeriveSharedDepositKey(
45+
ctx, s.signer, s.scriptKey,
46+
)
47+
48+
pubKeys := []*btcec.PublicKey{
49+
signingPubKey, s.otherPubKey,
50+
}
51+
52+
tweaks := &input.MuSig2Tweaks{
53+
TaprootTweak: s.anchorRootHash[:],
54+
}
55+
56+
_, session, err := input.MuSig2CreateContext(
57+
input.MuSig2Version100RC2, signingPrivKey, pubKeys, tweaks, nil,
58+
)
59+
if err != nil {
60+
return fmt.Errorf("error creating signing context: %w", err)
61+
}
62+
63+
s.session = session
64+
65+
return nil
66+
}
67+
68+
func (s *MuSig2Signer) Session() input.MuSig2Session {
69+
return s.session
70+
}
71+
72+
func (s *MuSig2Signer) PubNonce() ([musig2.PubNonceSize]byte, error) {
73+
// If we don't have a session, we can't return a public nonce.
74+
if s.session == nil {
75+
return [musig2.PubNonceSize]byte{}, fmt.Errorf("no session " +
76+
"available")
77+
}
78+
79+
// Return the public nonce of the current session.
80+
return s.session.PublicNonce(), nil
81+
}
82+
83+
// PartialSignMuSig2 is used to partially sign a message hash with the deposit's
84+
// keys.
85+
func (s *MuSig2Signer) PartialSignMuSig2(otherNonce [musig2.PubNonceSize]byte,
86+
message [32]byte) ([]byte, error) {
87+
88+
if s.session == nil {
89+
return nil, fmt.Errorf("no session available")
90+
}
91+
92+
_, err := s.session.RegisterPubNonce(otherNonce)
93+
if err != nil {
94+
return nil, err
95+
}
96+
97+
partialSig, err := input.MuSig2Sign(s.session, message, true)
98+
if err != nil {
99+
return nil, err
100+
}
101+
102+
var buf bytes.Buffer
103+
err = partialSig.Encode(&buf)
104+
if err != nil {
105+
return nil, fmt.Errorf("error encoding partial sig: %w", err)
106+
}
107+
108+
return buf.Bytes(), nil
109+
}

assets/deposit/sweeper.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,25 @@ import (
99

1010
"github.com/btcsuite/btcd/btcec/v2"
1111
"github.com/btcsuite/btcd/btcec/v2/schnorr"
12+
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
1213
"github.com/btcsuite/btcd/btcutil/psbt"
1314
"github.com/btcsuite/btcd/txscript"
1415
"github.com/btcsuite/btcd/wire"
1516
"github.com/btcsuite/btcwallet/wallet"
1617
"github.com/btcsuite/btcwallet/wtxmgr"
1718
"github.com/lightninglabs/lndclient"
1819
"github.com/lightninglabs/loop/assets"
20+
"github.com/lightninglabs/loop/assets/htlc"
1921
"github.com/lightninglabs/loop/utils"
2022
"github.com/lightninglabs/taproot-assets/address"
2123
"github.com/lightninglabs/taproot-assets/asset"
2224
"github.com/lightninglabs/taproot-assets/proof"
25+
"github.com/lightninglabs/taproot-assets/tappsbt"
2326
"github.com/lightninglabs/taproot-assets/taprpc"
27+
"github.com/lightninglabs/taproot-assets/taprpc/assetwalletrpc"
2428
"github.com/lightningnetwork/lnd/input"
29+
"github.com/lightningnetwork/lnd/keychain"
30+
"github.com/lightningnetwork/lnd/lntypes"
2531
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
2632
)
2733

@@ -343,3 +349,65 @@ func getSigHash(tx *wire.MsgTx, idx int,
343349

344350
return sigHash, nil
345351
}
352+
353+
// GetHTLC creates a new zero-fee HTLC packet to be able to partially sign it
354+
// and send it to the server for further processing.
355+
//
356+
// TODO(bhandras): add support for spending multiple deposits into the HTLC.
357+
func (s *Sweeper) GetHTLC(ctx context.Context, deposit *Kit,
358+
depositProof *proof.Proof, amount uint64, hash lntypes.Hash,
359+
csvExpiry uint32) (*htlc.SwapKit, *psbt.Packet, []*tappsbt.VPacket,
360+
[]*tappsbt.VPacket, *assetwalletrpc.CommitVirtualPsbtsResponse, error) {
361+
362+
// Genearate the HTLC address that will be used to sweep the deposit to
363+
// in case the client is uncooperative.
364+
rpcHtlcAddr, swapKit, err := deposit.NewHtlcAddr(
365+
ctx, s.tapdClient, amount, hash, csvExpiry,
366+
)
367+
if err != nil {
368+
return nil, nil, nil, nil, nil, fmt.Errorf("unable to create "+
369+
"htlc addr: %v", err)
370+
}
371+
372+
htlcAddr, err := address.DecodeAddress(
373+
rpcHtlcAddr.Encoded, &s.addressParams,
374+
)
375+
if err != nil {
376+
return nil, nil, nil, nil, nil, err
377+
}
378+
379+
// Now we can create the sweep vpacket that'd sweep the deposited
380+
// assets to the HTLC output.
381+
depositSpendVpkt, err := assets.CreateOpTrueSweepVpkt(
382+
ctx, []*proof.Proof{depositProof}, htlcAddr, &s.addressParams,
383+
)
384+
if err != nil {
385+
return nil, nil, nil, nil, nil, fmt.Errorf("unable to create "+
386+
"deposit spend vpacket: %v", err)
387+
}
388+
389+
// By committing the virtual transaction to the BTC template we
390+
// created, our lnd node will fund the BTC level transaction with an
391+
// input to pay for the fees. We'll further add a change output to the
392+
// transaction that will be generated using the above key descriptor.
393+
feeRate := chainfee.SatPerVByte(0)
394+
395+
// Use an empty lock ID, as we don't need to lock any UTXOs for this
396+
// operation.
397+
lockID := wtxmgr.LockID{}
398+
399+
htlcBtcPkt, activeAssets, passiveAssets, commitResp, err :=
400+
s.tapdClient.PrepareAndCommitVirtualPsbts(
401+
ctx, depositSpendVpkt, feeRate, nil,
402+
s.addressParams.Params, nil, &lockID,
403+
time.Duration(0),
404+
)
405+
if err != nil {
406+
return nil, nil, nil, nil, nil, fmt.Errorf("deposit spend "+
407+
"HTLC prepare and commit failed: %v", err)
408+
}
409+
410+
htlcBtcPkt.UnsignedTx.Version = 3
411+
412+
return swapKit, htlcBtcPkt, activeAssets, passiveAssets, commitResp, nil
413+
}

0 commit comments

Comments
 (0)