Skip to content

Commit 62c0f3a

Browse files
authored
Merge pull request #66 from sputn1ck/recover_loop_in
recoverloopin: add new command recoverloopin
2 parents aa767d3 + 0e51bc6 commit 62c0f3a

File tree

5 files changed

+353
-4
lines changed

5 files changed

+353
-4
lines changed

btc/bitcoind.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const (
1919
FormatImportwallet = "bitcoin-importwallet"
2020
FormatDescriptors = "bitcoin-descriptors"
2121
FormatElectrum = "electrum"
22+
23+
PasteString = "# Paste the following lines into a command line window."
2224
)
2325

2426
type KeyExporter interface {
@@ -130,7 +132,7 @@ func SeedBirthdayToBlock(params *chaincfg.Params,
130132
type Cli struct{}
131133

132134
func (c *Cli) Header() string {
133-
return "# Paste the following lines into a command line window."
135+
return PasteString
134136
}
135137

136138
func (c *Cli) Format(hdKey *hdkeychain.ExtendedKey, params *chaincfg.Params,
@@ -159,7 +161,7 @@ func (c *Cli) Trailer(birthdayBlock uint32) string {
159161
type CliWatchOnly struct{}
160162

161163
func (c *CliWatchOnly) Header() string {
162-
return "# Paste the following lines into a command line window."
164+
return PasteString
163165
}
164166

165167
func (c *CliWatchOnly) Format(hdKey *hdkeychain.ExtendedKey,
@@ -279,7 +281,7 @@ func (p *Electrum) Trailer(_ uint32) string {
279281
type Descriptors struct{}
280282

281283
func (d *Descriptors) Header() string {
282-
return "# Paste the following lines into a command line window."
284+
return PasteString
283285
}
284286

285287
func (d *Descriptors) Format(hdKey *hdkeychain.ExtendedKey,

cmd/chantools/recoverloopin.go

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/hex"
6+
"fmt"
7+
8+
"github.com/btcsuite/btcd/btcutil"
9+
"github.com/btcsuite/btcd/chaincfg/chainhash"
10+
"github.com/btcsuite/btcd/txscript"
11+
"github.com/btcsuite/btcd/wire"
12+
"github.com/guggero/chantools/btc"
13+
"github.com/guggero/chantools/lnd"
14+
"github.com/lightninglabs/loop"
15+
"github.com/lightninglabs/loop/loopdb"
16+
"github.com/lightninglabs/loop/swap"
17+
"github.com/lightningnetwork/lnd/input"
18+
"github.com/lightningnetwork/lnd/keychain"
19+
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
20+
"github.com/spf13/cobra"
21+
)
22+
23+
type recoverLoopInCommand struct {
24+
TxID string
25+
Vout uint32
26+
SwapHash string
27+
SweepAddr string
28+
FeeRate uint16
29+
StartKeyIndex int
30+
NumTries int
31+
32+
APIURL string
33+
Publish bool
34+
35+
LoopDbPath string
36+
37+
rootKey *rootKey
38+
cmd *cobra.Command
39+
}
40+
41+
func newRecoverLoopInCommand() *cobra.Command {
42+
cc := &recoverLoopInCommand{}
43+
cc.cmd = &cobra.Command{
44+
Use: "recoverloopin",
45+
Short: "Recover a loop in swap that the loop daemon " +
46+
"is not able to sweep",
47+
Example: `chantools recoverloopin \
48+
--txid abcdef01234... \
49+
--vout 0 \
50+
--swap_hash abcdef01234... \
51+
--loop_db_path /path/to/loop.db \
52+
--sweep_addr bc1pxxxxxxx \
53+
--feerate 10`,
54+
RunE: cc.Execute,
55+
}
56+
cc.cmd.Flags().StringVar(
57+
&cc.TxID, "txid", "", "transaction id of the on-chain "+
58+
"transaction that created the HTLC",
59+
)
60+
cc.cmd.Flags().Uint32Var(
61+
&cc.Vout, "vout", 0, "output index of the on-chain "+
62+
"transaction that created the HTLC",
63+
)
64+
cc.cmd.Flags().StringVar(
65+
&cc.SwapHash, "swap_hash", "", "swap hash of the loop in "+
66+
"swap",
67+
)
68+
cc.cmd.Flags().StringVar(
69+
&cc.LoopDbPath, "loop_db_path", "", "path to the loop "+
70+
"database file",
71+
)
72+
cc.cmd.Flags().StringVar(
73+
&cc.SweepAddr, "sweep_addr", "", "address to recover "+
74+
"the funds to",
75+
)
76+
cc.cmd.Flags().Uint16Var(
77+
&cc.FeeRate, "feerate", 0, "fee rate to "+
78+
"use for the sweep transaction in sat/vByte",
79+
)
80+
cc.cmd.Flags().IntVar(
81+
&cc.NumTries, "num_tries", 1000, "number of tries to "+
82+
"try to find the correct key index",
83+
)
84+
cc.cmd.Flags().IntVar(
85+
&cc.StartKeyIndex, "start_key_index", 0, "start key index "+
86+
"to try to find the correct key index",
87+
)
88+
cc.cmd.Flags().StringVar(
89+
&cc.APIURL, "apiurl", defaultAPIURL, "API URL to use (must "+
90+
"be esplora compatible)",
91+
)
92+
cc.cmd.Flags().BoolVar(
93+
&cc.Publish, "publish", false, "publish sweep TX to the chain "+
94+
"API instead of just printing the TX",
95+
)
96+
97+
cc.rootKey = newRootKey(cc.cmd, "deriving starting key")
98+
99+
return cc.cmd
100+
}
101+
102+
func (c *recoverLoopInCommand) Execute(_ *cobra.Command, _ []string) error {
103+
extendedKey, err := c.rootKey.read()
104+
if err != nil {
105+
return fmt.Errorf("error reading root key: %w", err)
106+
}
107+
108+
api := &btc.ExplorerAPI{BaseURL: c.APIURL}
109+
110+
signer := &lnd.Signer{
111+
ExtendedKey: extendedKey,
112+
ChainParams: chainParams,
113+
}
114+
115+
// Try to fetch the swap from the database.
116+
store, err := loopdb.NewBoltSwapStore(c.LoopDbPath, chainParams)
117+
if err != nil {
118+
return err
119+
}
120+
defer store.Close()
121+
122+
swaps, err := store.FetchLoopInSwaps()
123+
if err != nil {
124+
return err
125+
}
126+
127+
var loopIn *loopdb.LoopIn
128+
for _, s := range swaps {
129+
if s.Hash.String() == c.SwapHash {
130+
loopIn = s
131+
break
132+
}
133+
}
134+
if loopIn == nil {
135+
return fmt.Errorf("swap not found")
136+
}
137+
138+
fmt.Println("Loop expires at block height", loopIn.Contract.CltvExpiry)
139+
140+
// Get the swaps htlc.
141+
htlc, err := loop.GetHtlc(
142+
loopIn.Hash, &loopIn.Contract.SwapContract, chainParams,
143+
)
144+
if err != nil {
145+
return err
146+
}
147+
148+
// Get the destination address.
149+
sweepAddr, err := btcutil.DecodeAddress(c.SweepAddr, chainParams)
150+
if err != nil {
151+
return err
152+
}
153+
154+
// Calculate the sweep fee.
155+
estimator := &input.TxWeightEstimator{}
156+
err = htlc.AddTimeoutToEstimator(estimator)
157+
if err != nil {
158+
return err
159+
}
160+
161+
switch sweepAddr.(type) {
162+
case *btcutil.AddressWitnessPubKeyHash:
163+
estimator.AddP2WKHOutput()
164+
165+
case *btcutil.AddressTaproot:
166+
estimator.AddP2TROutput()
167+
168+
default:
169+
return fmt.Errorf("unsupported address type")
170+
}
171+
172+
feeRateKWeight := chainfee.SatPerKVByte(1000 * c.FeeRate).FeePerKWeight()
173+
fee := feeRateKWeight.FeeForWeight(int64(estimator.Weight()))
174+
175+
txID, err := chainhash.NewHashFromStr(c.TxID)
176+
if err != nil {
177+
return err
178+
}
179+
180+
// Get the htlc outpoint.
181+
htlcOutpoint := wire.OutPoint{
182+
Hash: *txID,
183+
Index: c.Vout,
184+
}
185+
186+
// Compose tx.
187+
sweepTx := wire.NewMsgTx(2)
188+
189+
sweepTx.LockTime = uint32(loopIn.Contract.CltvExpiry)
190+
191+
// Add HTLC input.
192+
sweepTx.AddTxIn(&wire.TxIn{
193+
PreviousOutPoint: htlcOutpoint,
194+
Sequence: 0,
195+
})
196+
197+
// Add output for the destination address.
198+
sweepPkScript, err := txscript.PayToAddrScript(sweepAddr)
199+
if err != nil {
200+
return err
201+
}
202+
203+
sweepTx.AddTxOut(&wire.TxOut{
204+
PkScript: sweepPkScript,
205+
Value: int64(loopIn.Contract.AmountRequested) - int64(fee),
206+
})
207+
208+
// If the htlc is version 2, we need to brute force the key locator, as
209+
// it is not stored in the database.
210+
var rawTx []byte
211+
if htlc.Version == swap.HtlcV2 {
212+
fmt.Println("Brute forcing key index...")
213+
for i := c.StartKeyIndex; i < c.StartKeyIndex+c.NumTries; i++ {
214+
rawTx, err = getSignedTx(
215+
signer, loopIn, sweepTx, htlc,
216+
keychain.KeyFamily(swap.KeyFamily), uint32(i),
217+
)
218+
if err == nil {
219+
break
220+
}
221+
}
222+
if rawTx == nil {
223+
return fmt.Errorf("failed to brute force key index, " +
224+
"please try again with a higher start key index")
225+
}
226+
} else {
227+
rawTx, err = getSignedTx(
228+
signer, loopIn, sweepTx, htlc,
229+
loopIn.Contract.HtlcKeys.ClientScriptKeyLocator.Family,
230+
loopIn.Contract.HtlcKeys.ClientScriptKeyLocator.Index,
231+
)
232+
if err != nil {
233+
return err
234+
}
235+
}
236+
237+
// Publish TX.
238+
if c.Publish {
239+
response, err := api.PublishTx(
240+
hex.EncodeToString(rawTx),
241+
)
242+
if err != nil {
243+
return err
244+
}
245+
log.Infof("Published TX %s, response: %s",
246+
sweepTx.TxHash().String(), response)
247+
} else {
248+
fmt.Printf("Success, we successfully created the sweep transaction. "+
249+
"Please publish this using any bitcoin node:\n\n%x\n\n",
250+
rawTx)
251+
}
252+
253+
return nil
254+
}
255+
256+
func getSignedTx(signer *lnd.Signer, loopIn *loopdb.LoopIn, sweepTx *wire.MsgTx,
257+
htlc *swap.Htlc, keyFamily keychain.KeyFamily,
258+
keyIndex uint32) ([]byte, error) {
259+
260+
// Create the sign descriptor.
261+
prevoutFetcher := txscript.NewCannedPrevOutputFetcher(
262+
htlc.PkScript, int64(loopIn.Contract.AmountRequested),
263+
)
264+
265+
signDesc := &input.SignDescriptor{
266+
KeyDesc: keychain.KeyDescriptor{
267+
KeyLocator: keychain.KeyLocator{
268+
Family: keyFamily,
269+
Index: keyIndex,
270+
},
271+
},
272+
WitnessScript: htlc.TimeoutScript(),
273+
HashType: htlc.SigHash(),
274+
InputIndex: 0,
275+
PrevOutputFetcher: prevoutFetcher,
276+
Output: &wire.TxOut{
277+
PkScript: htlc.PkScript,
278+
Value: int64(loopIn.Contract.AmountRequested),
279+
},
280+
}
281+
switch htlc.Version {
282+
case swap.HtlcV2:
283+
signDesc.SignMethod = input.WitnessV0SignMethod
284+
285+
case swap.HtlcV3:
286+
signDesc.SignMethod = input.TaprootScriptSpendSignMethod
287+
}
288+
289+
sig, err := signer.SignOutputRaw(sweepTx, signDesc)
290+
if err != nil {
291+
return nil, err
292+
}
293+
294+
witness, err := htlc.GenTimeoutWitness(sig.Serialize())
295+
if err != nil {
296+
return nil, err
297+
}
298+
299+
sweepTx.TxIn[0].Witness = witness
300+
301+
rawTx, err := encodeTx(sweepTx)
302+
if err != nil {
303+
return nil, err
304+
}
305+
306+
sighashes := txscript.NewTxSigHashes(sweepTx, prevoutFetcher)
307+
308+
// Verify the signature. This will throw an error if the signature is
309+
// invalid and allows us to bruteforce the key index.
310+
vm, err := txscript.NewEngine(
311+
htlc.PkScript, sweepTx, 0, txscript.StandardVerifyFlags, nil,
312+
sighashes, int64(loopIn.Contract.AmountRequested), prevoutFetcher,
313+
)
314+
if err != nil {
315+
return nil, err
316+
}
317+
318+
err = vm.Execute()
319+
if err != nil {
320+
return nil, err
321+
}
322+
323+
return rawTx, nil
324+
}
325+
326+
// encodeTx encodes a tx to raw bytes.
327+
func encodeTx(tx *wire.MsgTx) ([]byte, error) {
328+
var buffer bytes.Buffer
329+
err := tx.BtcEncode(&buffer, 0, wire.WitnessEncoding)
330+
if err != nil {
331+
return nil, err
332+
}
333+
rawTx := buffer.Bytes()
334+
335+
return rawTx, nil
336+
}

cmd/chantools/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ func main() {
9595
newForceCloseCommand(),
9696
newGenImportScriptCommand(),
9797
newMigrateDBCommand(),
98+
newRecoverLoopInCommand(),
9899
newRemoveChannelCommand(),
99100
newRescueClosedCommand(),
100101
newRescueFundingCommand(),

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ require (
1717
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1
1818
github.com/gogo/protobuf v1.3.2
1919
github.com/hasura/go-graphql-client v0.9.1
20+
github.com/lightninglabs/loop v0.23.0-beta
2021
github.com/lightninglabs/pool v0.6.2-beta.0.20230329135228-c3bffb52df3a
2122
github.com/lightningnetwork/lnd v0.16.0-beta
2223
github.com/lightningnetwork/lnd/kvdb v1.4.1
@@ -93,8 +94,10 @@ require (
9394
github.com/klauspost/compress v1.16.0 // indirect
9495
github.com/klauspost/pgzip v1.2.5 // indirect
9596
github.com/lib/pq v1.10.3 // indirect
97+
github.com/lightninglabs/aperture v0.1.20-beta // indirect
9698
github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect
9799
github.com/lightninglabs/lndclient v0.16.0-10 // indirect
100+
github.com/lightninglabs/loop/swapserverrpc v1.0.4 // indirect
98101
github.com/lightninglabs/neutrino v0.15.0 // indirect
99102
github.com/lightninglabs/neutrino/cache v1.1.1 // indirect
100103
github.com/lightninglabs/pool/auctioneerrpc v1.0.7 // indirect

0 commit comments

Comments
 (0)