diff --git a/cmd/chantools/recoverloopin.go b/cmd/chantools/recoverloopin.go new file mode 100644 index 0000000..beebb27 --- /dev/null +++ b/cmd/chantools/recoverloopin.go @@ -0,0 +1,336 @@ +package main + +import ( + "bytes" + "encoding/hex" + "fmt" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/guggero/chantools/btc" + "github.com/guggero/chantools/lnd" + "github.com/lightninglabs/loop" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/swap" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/spf13/cobra" +) + +type recoverLoopInCommand struct { + TxID string + Vout uint32 + SwapHash string + SweepAddr string + FeeRate uint16 + StartKeyIndex int + NumTries int + + APIURL string + Publish bool + + LoopDbPath string + + rootKey *rootKey + cmd *cobra.Command +} + +func newRecoverLoopInCommand() *cobra.Command { + cc := &recoverLoopInCommand{} + cc.cmd = &cobra.Command{ + Use: "recoverloopin", + Short: "Recover a loop in swap that the loop daemon " + + "is not able to sweep", + Example: `chantools recoverloopin \ + --txid abcdef01234... \ + --vout 0 \ + --swap_hash abcdef01234... \ + --loop_db_path /path/to/loop.db \ + --sweep_addr bc1pxxxxxxx \ + --feerate 10`, + RunE: cc.Execute, + } + cc.cmd.Flags().StringVar( + &cc.TxID, "txid", "", "transaction id of the on-chain "+ + "transaction that created the HTLC", + ) + cc.cmd.Flags().Uint32Var( + &cc.Vout, "vout", 0, "output index of the on-chain "+ + "transaction that created the HTLC", + ) + cc.cmd.Flags().StringVar( + &cc.SwapHash, "swap_hash", "", "swap hash of the loop in "+ + "swap", + ) + cc.cmd.Flags().StringVar( + &cc.LoopDbPath, "loop_db_path", "", "path to the loop "+ + "database file", + ) + cc.cmd.Flags().StringVar( + &cc.SweepAddr, "sweep_addr", "", "address to recover "+ + "the funds to", + ) + cc.cmd.Flags().Uint16Var( + &cc.FeeRate, "feerate", 0, "fee rate to "+ + "use for the sweep transaction in sat/vByte", + ) + cc.cmd.Flags().IntVar( + &cc.NumTries, "num_tries", 1000, "number of tries to "+ + "try to find the correct key index", + ) + cc.cmd.Flags().IntVar( + &cc.StartKeyIndex, "start_key_index", 0, "start key index "+ + "to try to find the correct key index", + ) + cc.cmd.Flags().StringVar( + &cc.APIURL, "apiurl", defaultAPIURL, "API URL to use (must "+ + "be esplora compatible)", + ) + cc.cmd.Flags().BoolVar( + &cc.Publish, "publish", false, "publish sweep TX to the chain "+ + "API instead of just printing the TX", + ) + + cc.rootKey = newRootKey(cc.cmd, "deriving starting key") + + return cc.cmd +} + +func (c *recoverLoopInCommand) Execute(_ *cobra.Command, _ []string) error { + extendedKey, err := c.rootKey.read() + if err != nil { + return fmt.Errorf("error reading root key: %w", err) + } + + api := &btc.ExplorerAPI{BaseURL: c.APIURL} + + signer := &lnd.Signer{ + ExtendedKey: extendedKey, + ChainParams: chainParams, + } + + // Try to fetch the swap from the database. + store, err := loopdb.NewBoltSwapStore(c.LoopDbPath, chainParams) + if err != nil { + return err + } + defer store.Close() + + swaps, err := store.FetchLoopInSwaps() + if err != nil { + return err + } + + var loopIn *loopdb.LoopIn + for _, s := range swaps { + if s.Hash.String() == c.SwapHash { + loopIn = s + break + } + } + if loopIn == nil { + return fmt.Errorf("swap not found") + } + + fmt.Println("Loop expires at block height", loopIn.Contract.CltvExpiry) + + // Get the swaps htlc. + htlc, err := loop.GetHtlc( + loopIn.Hash, &loopIn.Contract.SwapContract, chainParams, + ) + if err != nil { + return err + } + + // Get the destination address. + sweepAddr, err := btcutil.DecodeAddress(c.SweepAddr, chainParams) + if err != nil { + return err + } + + // Calculate the sweep fee. + estimator := &input.TxWeightEstimator{} + err = htlc.AddTimeoutToEstimator(estimator) + if err != nil { + return err + } + + switch sweepAddr.(type) { + case *btcutil.AddressWitnessPubKeyHash: + estimator.AddP2WKHOutput() + + case *btcutil.AddressTaproot: + estimator.AddP2TROutput() + + default: + return fmt.Errorf("unsupported address type") + } + + feeRateKWeight := chainfee.SatPerKVByte(1000 * c.FeeRate).FeePerKWeight() + fee := feeRateKWeight.FeeForWeight(int64(estimator.Weight())) + + txID, err := chainhash.NewHashFromStr(c.TxID) + if err != nil { + return err + } + + // Get the htlc outpoint. + htlcOutpoint := wire.OutPoint{ + Hash: *txID, + Index: c.Vout, + } + + // Compose tx. + sweepTx := wire.NewMsgTx(2) + + sweepTx.LockTime = uint32(loopIn.Contract.CltvExpiry) + + // Add HTLC input. + sweepTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: htlcOutpoint, + Sequence: 0, + }) + + // Add output for the destination address. + sweepPkScript, err := txscript.PayToAddrScript(sweepAddr) + if err != nil { + return err + } + + sweepTx.AddTxOut(&wire.TxOut{ + PkScript: sweepPkScript, + Value: int64(loopIn.Contract.AmountRequested) - int64(fee), + }) + + // If the htlc is version 2, we need to brute force the key locator, as + // it is not stored in the database. + var rawTx []byte + if htlc.Version == swap.HtlcV2 { + fmt.Println("Brute forcing key index...") + for i := c.StartKeyIndex; i < c.StartKeyIndex+c.NumTries; i++ { + rawTx, err = getSignedTx( + signer, loopIn, sweepTx, htlc, + keychain.KeyFamily(swap.KeyFamily), uint32(i), + ) + if err == nil { + break + } + } + if rawTx == nil { + return fmt.Errorf("failed to brute force key index, " + + "please try again with a higher start key index") + } + } else { + rawTx, err = getSignedTx( + signer, loopIn, sweepTx, htlc, + loopIn.Contract.HtlcKeys.ClientScriptKeyLocator.Family, + loopIn.Contract.HtlcKeys.ClientScriptKeyLocator.Index, + ) + if err != nil { + return err + } + } + + // Publish TX. + if c.Publish { + response, err := api.PublishTx( + hex.EncodeToString(rawTx), + ) + if err != nil { + return err + } + log.Infof("Published TX %s, response: %s", + sweepTx.TxHash().String(), response) + } else { + fmt.Printf("Success, we successfully created the sweep transaction. "+ + "Please publish this using any bitcoin node:\n\n%x\n\n", + rawTx) + } + + return nil +} + +func getSignedTx(signer *lnd.Signer, loopIn *loopdb.LoopIn, sweepTx *wire.MsgTx, + htlc *swap.Htlc, keyFamily keychain.KeyFamily, + keyIndex uint32) ([]byte, error) { + + // Create the sign descriptor. + prevoutFetcher := txscript.NewCannedPrevOutputFetcher( + htlc.PkScript, int64(loopIn.Contract.AmountRequested), + ) + + signDesc := &input.SignDescriptor{ + KeyDesc: keychain.KeyDescriptor{ + KeyLocator: keychain.KeyLocator{ + Family: keyFamily, + Index: keyIndex, + }, + }, + WitnessScript: htlc.TimeoutScript(), + HashType: htlc.SigHash(), + InputIndex: 0, + PrevOutputFetcher: prevoutFetcher, + Output: &wire.TxOut{ + PkScript: htlc.PkScript, + Value: int64(loopIn.Contract.AmountRequested), + }, + } + switch htlc.Version { + case swap.HtlcV2: + signDesc.SignMethod = input.WitnessV0SignMethod + + case swap.HtlcV3: + signDesc.SignMethod = input.TaprootScriptSpendSignMethod + } + + sig, err := signer.SignOutputRaw(sweepTx, signDesc) + if err != nil { + return nil, err + } + + witness, err := htlc.GenTimeoutWitness(sig.Serialize()) + if err != nil { + return nil, err + } + + sweepTx.TxIn[0].Witness = witness + + rawTx, err := encodeTx(sweepTx) + if err != nil { + return nil, err + } + + sighashes := txscript.NewTxSigHashes(sweepTx, prevoutFetcher) + + // Verify the signature. This will throw an error if the signature is + // invalid and allows us to bruteforce the key index. + vm, err := txscript.NewEngine( + htlc.PkScript, sweepTx, 0, txscript.StandardVerifyFlags, nil, + sighashes, int64(loopIn.Contract.AmountRequested), prevoutFetcher, + ) + if err != nil { + return nil, err + } + + err = vm.Execute() + if err != nil { + return nil, err + } + + return rawTx, nil +} + +// encodeTx encodes a tx to raw bytes. +func encodeTx(tx *wire.MsgTx) ([]byte, error) { + var buffer bytes.Buffer + err := tx.BtcEncode(&buffer, 0, wire.WitnessEncoding) + if err != nil { + return nil, err + } + rawTx := buffer.Bytes() + + return rawTx, nil +} diff --git a/cmd/chantools/root.go b/cmd/chantools/root.go index a52e0ff..c04f156 100644 --- a/cmd/chantools/root.go +++ b/cmd/chantools/root.go @@ -95,6 +95,7 @@ func main() { newForceCloseCommand(), newGenImportScriptCommand(), newMigrateDBCommand(), + newRecoverLoopInCommand(), newRemoveChannelCommand(), newRescueClosedCommand(), newRescueFundingCommand(), diff --git a/go.mod b/go.mod index 3bd02ed..8b98594 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 github.com/gogo/protobuf v1.3.2 github.com/hasura/go-graphql-client v0.9.1 + github.com/lightninglabs/loop v0.23.0-beta github.com/lightninglabs/pool v0.6.2-beta.0.20230329135228-c3bffb52df3a github.com/lightningnetwork/lnd v0.16.0-beta github.com/lightningnetwork/lnd/kvdb v1.4.1 @@ -93,8 +94,10 @@ require ( github.com/klauspost/compress v1.16.0 // indirect github.com/klauspost/pgzip v1.2.5 // indirect github.com/lib/pq v1.10.3 // indirect + github.com/lightninglabs/aperture v0.1.20-beta // indirect github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf // indirect github.com/lightninglabs/lndclient v0.16.0-10 // indirect + github.com/lightninglabs/loop/swapserverrpc v1.0.4 // indirect github.com/lightninglabs/neutrino v0.15.0 // indirect github.com/lightninglabs/neutrino/cache v1.1.1 // indirect github.com/lightninglabs/pool/auctioneerrpc v1.0.7 // indirect diff --git a/go.sum b/go.sum index ba7ea09..1729569 100644 --- a/go.sum +++ b/go.sum @@ -177,6 +177,7 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fergusstrange/embedded-postgres v1.10.0 h1:YnwF6xAQYmKLAXXrrRx4rHDLih47YJwVPvg8jeKfdNg= github.com/fergusstrange/embedded-postgres v1.10.0/go.mod h1:a008U8/Rws5FtIOTGYDYa7beVWsT3qVKyqExqYYjL+c= +github.com/fortytw2/leaktest v1.3.0 h1:u8491cBMTQ8ft8aeV+adlcytMZylmA5nnwwkRZjI8vw= github.com/frankban/quicktest v1.0.0/go.mod h1:R98jIehRai+d1/3Hv2//jOVCTJhW1VBavT6B6CuGq2k= github.com/frankban/quicktest v1.2.2/go.mod h1:Qh/WofXFeiAFII1aEBu529AtJo6Zg2VHscnEsbBnJ20= 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= github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.3 h1:v9QZf2Sn6AmjXtQeFpdoq/eaNtYP6IN+7lcrygsIAtg= github.com/lib/pq v1.10.3/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lightninglabs/aperture v0.1.20-beta h1:zMcYtzhaC+LsGpkS4Xkt6Qv2YeMHSL6wXQA0cydER1U= +github.com/lightninglabs/aperture v0.1.20-beta/go.mod h1:81OL9AHa8Wjm1HzRqTa6jkcafyaxJAsHZDIG5jj6RlU= github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf h1:HZKvJUHlcXI/f/O0Avg7t8sqkPo78HFzjmeYFl6DPnc= github.com/lightninglabs/gozmq v0.0.0-20191113021534-d20a764486bf/go.mod h1:vxmQPeIQxPf6Jf9rM8R+B4rKBqLA2AjttNxkFBL2Plk= github.com/lightninglabs/lndclient v0.16.0-10 h1:cMBJNfssBQtpgYIu23QLP/qw0ijiT5SBZffnXz8zjJk= github.com/lightninglabs/lndclient v0.16.0-10/go.mod h1:mqY0znSNa+M40HZowwKfno29RyZnmxoqo++BlYP82EY= +github.com/lightninglabs/loop v0.23.0-beta h1:me3g9erjnvoJq5udl7XLOC0e3Pp7+6YGupTNRIbl64E= +github.com/lightninglabs/loop v0.23.0-beta/go.mod h1:rh5c7KZMNV/GOJ79n3x5qrO9h6FZT7ZZ54b6/FPIhQI= +github.com/lightninglabs/loop/swapserverrpc v1.0.4 h1:cEX+mt7xmQlEbmuQ52vOBT7l+a471v94ofdJbB6MmXs= +github.com/lightninglabs/loop/swapserverrpc v1.0.4/go.mod h1:imy1/sqnb70EEyBKMo4pHwwLBPW8uYahWZ8s+1Xcq1o= github.com/lightninglabs/neutrino v0.15.0 h1:yr3uz36fLAq8hyM0TRUVlef1TRNoWAqpmmNlVtKUDtI= github.com/lightninglabs/neutrino v0.15.0/go.mod h1:pmjwElN/091TErtSE9Vd5W4hpxoG2/+xlb+HoPm9Gug= 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 github.com/ulikunitz/xz v0.5.7/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.10 h1:t92gobL9l3HE202wg3rlk19F6X+JOxl9BBrCCMYEYd8= github.com/ulikunitz/xz v0.5.10/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= -github.com/xdg-go/stringprep v1.0.2 h1:6iq84/ryjjeRmMJwxutI51F2GIPlP5BfTvXHeYjyhBc= github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8=