Skip to content

Commit ab0743a

Browse files
committed
recoverloopin: add new command recoverloopin
This commit adds a new command recoverloopin which allows the user to recover stuck loop in funds.
1 parent aa767d3 commit ab0743a

File tree

4 files changed

+348
-1
lines changed

4 files changed

+348
-1
lines changed

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

go.sum

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
177177
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
178178
github.com/fergusstrange/embedded-postgres v1.10.0 h1:YnwF6xAQYmKLAXXrrRx4rHDLih47YJwVPvg8jeKfdNg=
179179
github.com/fergusstrange/embedded-postgres v1.10.0/go.mod h1:a008U8/Rws5FtIOTGYDYa7beVWsT3qVKyqExqYYjL+c=
180+
github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw=
180181
github.com/frankban/quicktest v1.0.0/go.mod h1:R98jIehRai+d1/3Hv2//jOVCTJhW1VBavT6B6CuGq2k=
181182
github.com/frankban/quicktest v1.2.2/go.mod h1:Qh/WofXFeiAFII1aEBu529AtJo6Zg2VHscnEsbBnJ20=
182183
github.com/frankban/quicktest v1.11.2 h1:mjwHjStlXWibxOohM7HYieIViKyh56mmt3+6viyhDDI=
@@ -463,10 +464,16 @@ github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
463464
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
464465
github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg=
465466
github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
467+
github.com/lightninglabs/aperture v0.1.20-beta h1:zMcYtzhaC+LsGpkS4Xkt6Qv2YeMHSL6wXQA0cydER1U=
468+
github.com/lightninglabs/aperture v0.1.20-beta/go.mod h1:81OL9AHa8Wjm1HzRqTa6jkcafyaxJAsHZDIG5jj6RlU=
466469
github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf h1:HZKvJUHlcXI/f/O0Avg7t8sqkPo78HFzjmeYFl6DPnc=
467470
github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf/go.mod h1:vxmQPeIQxPf6Jf9rM8R+B4rKBqLA2AjttNxkFBL2Plk=
468471
github.com/lightninglabs/lndclient v0.16.0-10 h1:cMBJNfssBQtpgYIu23QLP/qw0ijiT5SBZffnXz8zjJk=
469472
github.com/lightninglabs/lndclient v0.16.0-10/go.mod h1:mqY0znSNa+M40HZowwKfno29RyZnmxoqo++BlYP82EY=
473+
github.com/lightninglabs/loop v0.23.0-beta h1:me3g9erjnvoJq5udl7XLOC0e3Pp7+6YGupTNRIbl64E=
474+
github.com/lightninglabs/loop v0.23.0-beta/go.mod h1:rh5c7KZMNV/GOJ79n3x5qrO9h6FZT7ZZ54b6/FPIhQI=
475+
github.com/lightninglabs/loop/swapserverrpc v1.0.4 h1:cEX+mt7xmQlEbmuQ52vOBT7l+a471v94ofdJbB6MmXs=
476+
github.com/lightninglabs/loop/swapserverrpc v1.0.4/go.mod h1:imy1/sqnb70EEyBKMo4pHwwLBPW8uYahWZ8s+1Xcq1o=
470477
github.com/lightninglabs/neutrino v0.15.0 h1:yr3uz36fLAq8hyM0TRUVlef1TRNoWAqpmmNlVtKUDtI=
471478
github.com/lightninglabs/neutrino v0.15.0/go.mod h1:pmjwElN/091TErtSE9Vd5W4hpxoG2/+xlb+HoPm9Gug=
472479
github.com/lightninglabs/neutrino/cache v1.1.1 h1:TllWOSlkABhpgbWJfzsrdUaDH2fBy/54VSIB4vVqV8M=
@@ -660,8 +667,8 @@ github.com/ulikunitz/xz v0.5.6/go.mod h1:2bypXElzHzzJZwzH67Y6wb67pO62Rzfn7BSiF4A
660667
github.com/ulikunitz/xz v0.5.7/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
661668
github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8=
662669
github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
663-
github.com/xdg-go/stringprep v1.0.2 h1:6iq84/ryjjeRmMJwxutI51F2GIPlP5BfTvXHeYjyhBc=
664670
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
671+
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
665672
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
666673
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
667674
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8=

0 commit comments

Comments
 (0)