mirror of https://github.com/guggero/chantools
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
415 lines
11 KiB
Go
415 lines
11 KiB
Go
3 years ago
|
package main
|
||
|
|
||
|
import (
|
||
|
"bytes"
|
||
|
"encoding/hex"
|
||
|
"fmt"
|
||
|
|
||
|
"github.com/btcsuite/btcd/btcec"
|
||
|
"github.com/btcsuite/btcd/txscript"
|
||
|
"github.com/btcsuite/btcd/wire"
|
||
|
"github.com/btcsuite/btcutil/hdkeychain"
|
||
|
"github.com/guggero/chantools/btc"
|
||
|
"github.com/guggero/chantools/lnd"
|
||
|
"github.com/lightninglabs/pool/poolscript"
|
||
|
"github.com/lightningnetwork/lnd/input"
|
||
|
"github.com/lightningnetwork/lnd/keychain"
|
||
|
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
|
||
|
"github.com/spf13/cobra"
|
||
|
)
|
||
|
|
||
|
const (
|
||
|
poolMainnetFirstBatchBlock = 648168
|
||
|
defaultMaxNumBlocks = 200000
|
||
|
defaultMaxNumAccounts = 20
|
||
|
defaultMaxNumBatchKeys = 500
|
||
|
)
|
||
|
|
||
|
var (
|
||
|
initialBatchKeyBytes, _ = hex.DecodeString(
|
||
|
"02824d0cbac65e01712124c50ff2cc74ce22851d7b444c1bf2ae66afefb8" +
|
||
|
"eaf27f",
|
||
|
)
|
||
|
initialBatchKey, _ = btcec.ParsePubKey(
|
||
|
initialBatchKeyBytes, btcec.S256(),
|
||
|
)
|
||
|
|
||
|
mainnetAuctioneerKeyHex = "028e87bdd134238f8347f845d9ecc827b843d0d1e2" +
|
||
|
"7cdcb46da704d916613f4fce"
|
||
|
)
|
||
|
|
||
|
type closePoolAccountCommand struct {
|
||
|
APIURL string
|
||
|
Outpoint string
|
||
|
AuctioneerKey string
|
||
|
Publish bool
|
||
|
SweepAddr string
|
||
|
FeeRate uint16
|
||
|
|
||
|
MinExpiry uint32
|
||
|
MaxNumBlocks uint32
|
||
|
MaxNumAccounts uint32
|
||
|
MaxNumBatchKeys uint32
|
||
|
|
||
|
rootKey *rootKey
|
||
|
cmd *cobra.Command
|
||
|
}
|
||
|
|
||
|
func newClosePoolAccountCommand() *cobra.Command {
|
||
|
cc := &closePoolAccountCommand{}
|
||
|
cc.cmd = &cobra.Command{
|
||
|
Use: "closepoolaccount",
|
||
|
Short: "Tries to close a Pool account that has expired",
|
||
|
Long: `In case a Pool account cannot be closed normally with the
|
||
|
poold daemon it can be closed with this command. The account **MUST** have
|
||
|
expired already, otherwise this command doesn't work since a signature from the
|
||
|
auctioneer is necessary.
|
||
|
|
||
|
You need to know the account's last unspent outpoint. That can either be
|
||
|
obtained by running 'pool accounts list' `,
|
||
|
Example: `chantools closepoolaccount \
|
||
|
--outpoint xxxxxxxxx:y \
|
||
|
--sweepaddr bc1q..... \
|
||
|
--feerate 10 \
|
||
|
--publish`,
|
||
|
RunE: cc.Execute,
|
||
|
}
|
||
|
cc.cmd.Flags().StringVar(
|
||
|
&cc.APIURL, "apiurl", defaultAPIURL, "API URL to use (must "+
|
||
|
"be esplora compatible)",
|
||
|
)
|
||
|
cc.cmd.Flags().StringVar(
|
||
|
&cc.Outpoint, "outpoint", "", "last account outpoint of the "+
|
||
|
"account to close (<txid>:<txindex>)",
|
||
|
)
|
||
|
cc.cmd.Flags().StringVar(
|
||
|
&cc.AuctioneerKey, "auctioneerkey", mainnetAuctioneerKeyHex,
|
||
|
"the auctioneer's static public key",
|
||
|
)
|
||
|
cc.cmd.Flags().BoolVar(
|
||
|
&cc.Publish, "publish", false, "publish sweep TX to the chain "+
|
||
|
"API instead of just printing the TX",
|
||
|
)
|
||
|
cc.cmd.Flags().StringVar(
|
||
|
&cc.SweepAddr, "sweepaddr", "", "address to sweep the funds to",
|
||
|
)
|
||
|
cc.cmd.Flags().Uint16Var(
|
||
|
&cc.FeeRate, "feerate", defaultFeeSatPerVByte, "fee rate to "+
|
||
|
"use for the sweep transaction in sat/vByte",
|
||
|
)
|
||
|
cc.cmd.Flags().Uint32Var(
|
||
|
&cc.MinExpiry, "minexpiry", poolMainnetFirstBatchBlock,
|
||
|
"the block to start brute forcing the expiry from",
|
||
|
)
|
||
|
cc.cmd.Flags().Uint32Var(
|
||
|
&cc.MaxNumBlocks, "maxnumblocks", defaultMaxNumBlocks, "the "+
|
||
|
"maximum number of blocks to try when brute forcing "+
|
||
|
"the expiry",
|
||
|
)
|
||
|
cc.cmd.Flags().Uint32Var(
|
||
|
&cc.MaxNumAccounts, "maxnumaccounts", defaultMaxNumAccounts,
|
||
|
"the number of account indices to try at most",
|
||
|
)
|
||
|
cc.cmd.Flags().Uint32Var(
|
||
|
&cc.MaxNumBatchKeys, "maxnumbatchkeys", defaultMaxNumBatchKeys,
|
||
|
"the number of batch keys to try at most",
|
||
|
)
|
||
|
|
||
|
cc.rootKey = newRootKey(cc.cmd, "deriving keys")
|
||
|
|
||
|
return cc.cmd
|
||
|
}
|
||
|
|
||
|
func (c *closePoolAccountCommand) Execute(_ *cobra.Command, _ []string) error {
|
||
|
extendedKey, err := c.rootKey.read()
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("error reading root key: %v", err)
|
||
|
}
|
||
|
|
||
|
// Make sure sweep addr is set.
|
||
|
if c.SweepAddr == "" {
|
||
|
return fmt.Errorf("sweep addr is required")
|
||
|
}
|
||
|
|
||
|
// Parse account outpoint and auctioneer key.
|
||
|
outpoint, err := lnd.ParseOutpoint(c.Outpoint)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("error parsing account outpoint: %v", err)
|
||
|
}
|
||
|
|
||
|
auctioneerKeyBytes, err := hex.DecodeString(c.AuctioneerKey)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("error decoding auctioneer key: %v", err)
|
||
|
}
|
||
|
|
||
|
auctioneerKey, err := btcec.ParsePubKey(
|
||
|
auctioneerKeyBytes, btcec.S256(),
|
||
|
)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("error parsing auctioneer key: %v", err)
|
||
|
}
|
||
|
|
||
|
// Set default values.
|
||
|
if c.FeeRate == 0 {
|
||
|
c.FeeRate = defaultFeeSatPerVByte
|
||
|
}
|
||
|
return closePoolAccount(
|
||
|
extendedKey, c.APIURL, outpoint, auctioneerKey, c.SweepAddr,
|
||
|
c.Publish, c.FeeRate, c.MinExpiry, c.MinExpiry+c.MaxNumBlocks,
|
||
|
c.MaxNumAccounts, c.MaxNumBatchKeys,
|
||
|
)
|
||
|
}
|
||
|
|
||
|
func closePoolAccount(extendedKey *hdkeychain.ExtendedKey, apiURL string,
|
||
|
outpoint *wire.OutPoint, auctioneerKey *btcec.PublicKey,
|
||
|
sweepAddr string, publish bool, feeRate uint16, minExpiry,
|
||
|
maxNumBlocks, maxNumAccounts, maxNumBatchKeys uint32) error {
|
||
|
|
||
|
signer := &lnd.Signer{
|
||
|
ExtendedKey: extendedKey,
|
||
|
ChainParams: chainParams,
|
||
|
}
|
||
|
api := &btc.ExplorerAPI{BaseURL: apiURL}
|
||
|
|
||
|
tx, err := api.Transaction(outpoint.Hash.String())
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("error looking up TX %s: %v",
|
||
|
outpoint.Hash.String(), err)
|
||
|
}
|
||
|
|
||
|
txOut := tx.Vout[outpoint.Index]
|
||
|
if txOut.Outspend.Spent {
|
||
|
return fmt.Errorf("outpoint %v is already spent", outpoint)
|
||
|
}
|
||
|
|
||
|
pkScript, err := hex.DecodeString(txOut.ScriptPubkey)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("error decoding pk script %s: %v",
|
||
|
txOut.ScriptPubkey, err)
|
||
|
}
|
||
|
log.Debugf("Brute forcing pk script %x for outpoint %v", pkScript,
|
||
|
outpoint)
|
||
|
|
||
|
// Let's derive the account key family's extended key first.
|
||
|
path := []uint32{
|
||
|
lnd.HardenedKeyStart + uint32(keychain.BIP0043Purpose),
|
||
|
lnd.HardenedKeyStart + chainParams.HDCoinType,
|
||
|
lnd.HardenedKeyStart + uint32(poolscript.AccountKeyFamily),
|
||
|
0,
|
||
|
}
|
||
|
accountBaseKey, err := lnd.DeriveChildren(extendedKey, path)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("error deriving account base key: %v", err)
|
||
|
}
|
||
|
|
||
|
// Try our luck.
|
||
|
acct, err := bruteForceAccountScript(
|
||
|
accountBaseKey, auctioneerKey, minExpiry, maxNumBlocks,
|
||
|
maxNumAccounts, maxNumBatchKeys, pkScript,
|
||
|
)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("error brute forcing account script: %v", err)
|
||
|
}
|
||
|
|
||
|
log.Debugf("Found pool account with expiry %d!", acct.expiry)
|
||
|
|
||
|
sweepTx := wire.NewMsgTx(2)
|
||
|
sweepTx.LockTime = acct.expiry
|
||
|
sweepValue := int64(txOut.Value)
|
||
|
|
||
|
// Create the transaction input.
|
||
|
sweepTx.TxIn = []*wire.TxIn{{
|
||
|
PreviousOutPoint: *outpoint,
|
||
|
}}
|
||
|
|
||
|
// Calculate the fee based on the given fee rate and our weight
|
||
|
// estimation.
|
||
|
var estimator input.TxWeightEstimator
|
||
|
estimator.AddWitnessInput(input.ToLocalTimeoutWitnessSize)
|
||
|
estimator.AddP2WKHOutput()
|
||
|
feeRateKWeight := chainfee.SatPerKVByte(1000 * feeRate).FeePerKWeight()
|
||
|
totalFee := feeRateKWeight.FeeForWeight(int64(estimator.Weight()))
|
||
|
|
||
|
// Add our sweep destination output.
|
||
|
sweepScript, err := lnd.GetP2WPKHScript(sweepAddr, chainParams)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
sweepTx.TxOut = []*wire.TxOut{{
|
||
|
Value: sweepValue - int64(totalFee),
|
||
|
PkScript: sweepScript,
|
||
|
}}
|
||
|
|
||
|
log.Infof("Fee %d sats of %d total amount (estimated weight %d)",
|
||
|
totalFee, sweepValue, estimator.Weight())
|
||
|
|
||
|
// Create the sign descriptor for the input then sign the transaction.
|
||
|
sigHashes := txscript.NewTxSigHashes(sweepTx)
|
||
|
signDesc := &input.SignDescriptor{
|
||
|
KeyDesc: keychain.KeyDescriptor{
|
||
|
KeyLocator: keychain.KeyLocator{
|
||
|
Family: poolscript.AccountKeyFamily,
|
||
|
Index: acct.keyIndex,
|
||
|
},
|
||
|
},
|
||
|
SingleTweak: acct.keyTweak,
|
||
|
WitnessScript: acct.witnessScript,
|
||
|
Output: &wire.TxOut{
|
||
|
PkScript: pkScript,
|
||
|
Value: sweepValue,
|
||
|
},
|
||
|
InputIndex: 0,
|
||
|
SigHashes: sigHashes,
|
||
|
HashType: txscript.SigHashAll,
|
||
|
}
|
||
|
sig, err := signer.SignOutputRaw(sweepTx, signDesc)
|
||
|
if err != nil {
|
||
|
return fmt.Errorf("error signing sweep tx: %v", err)
|
||
|
}
|
||
|
ourSig := append(sig.Serialize(), byte(signDesc.HashType))
|
||
|
sweepTx.TxIn[0].Witness = poolscript.SpendExpiry(
|
||
|
acct.witnessScript, ourSig,
|
||
|
)
|
||
|
|
||
|
var buf bytes.Buffer
|
||
|
err = sweepTx.Serialize(&buf)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
|
||
|
// Publish TX.
|
||
|
if publish {
|
||
|
response, err := api.PublishTx(
|
||
|
hex.EncodeToString(buf.Bytes()),
|
||
|
)
|
||
|
if err != nil {
|
||
|
return err
|
||
|
}
|
||
|
log.Infof("Published TX %s, response: %s",
|
||
|
sweepTx.TxHash().String(), response)
|
||
|
}
|
||
|
|
||
|
log.Infof("Transaction: %x", buf.Bytes())
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
type poolAccount struct {
|
||
|
keyIndex uint32
|
||
|
expiry uint32
|
||
|
keyTweak []byte
|
||
|
witnessScript []byte
|
||
|
}
|
||
|
|
||
|
func bruteForceAccountScript(accountBaseKey *hdkeychain.ExtendedKey,
|
||
|
auctioneerKey *btcec.PublicKey, minExpiry, maxNumBlocks, maxNumAccounts,
|
||
|
maxNumBatchKeys uint32, targetScript []byte) (*poolAccount, error) {
|
||
|
|
||
|
// The outer-most loop is over the possible accounts.
|
||
|
for i := uint32(0); i < maxNumAccounts; i++ {
|
||
|
accountExtendedKey, err := accountBaseKey.DeriveNonStandard(i)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("error deriving account key: "+
|
||
|
"%v", err)
|
||
|
}
|
||
|
|
||
|
accountPrivKey, err := accountExtendedKey.ECPrivKey()
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("error deriving private key: "+
|
||
|
"%v", err)
|
||
|
}
|
||
|
log.Debugf("Trying trader key %x...",
|
||
|
accountPrivKey.PubKey().SerializeCompressed())
|
||
|
|
||
|
sharedKey, err := lnd.ECDH(accountPrivKey, auctioneerKey)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("error deriving shared key: "+
|
||
|
"%v", err)
|
||
|
}
|
||
|
|
||
|
// The next loop is over the batch keys.
|
||
|
batchKeyIndex := uint32(0)
|
||
|
currentBatchKey := initialBatchKey
|
||
|
for batchKeyIndex < maxNumBatchKeys {
|
||
|
// And then finally the loop over the actual account
|
||
|
// expiry in blocks.
|
||
|
block, err := fastScript(
|
||
|
minExpiry, maxNumBlocks,
|
||
|
accountPrivKey.PubKey(), auctioneerKey,
|
||
|
currentBatchKey, sharedKey, targetScript,
|
||
|
)
|
||
|
if err == nil {
|
||
|
witnessScript, err := poolscript.AccountWitnessScript(
|
||
|
block, accountPrivKey.PubKey(),
|
||
|
auctioneerKey, currentBatchKey,
|
||
|
sharedKey,
|
||
|
)
|
||
|
if err != nil {
|
||
|
return nil, fmt.Errorf("error "+
|
||
|
"deriving script: %v", err)
|
||
|
}
|
||
|
|
||
|
traderKeyTweak := poolscript.TraderKeyTweak(
|
||
|
currentBatchKey, sharedKey,
|
||
|
accountPrivKey.PubKey(),
|
||
|
)
|
||
|
|
||
|
return &poolAccount{
|
||
|
keyIndex: i,
|
||
|
expiry: block,
|
||
|
keyTweak: traderKeyTweak,
|
||
|
witnessScript: witnessScript,
|
||
|
}, nil
|
||
|
}
|
||
|
|
||
|
currentBatchKey = poolscript.IncrementKey(
|
||
|
currentBatchKey,
|
||
|
)
|
||
|
batchKeyIndex++
|
||
|
}
|
||
|
|
||
|
log.Debugf("Tried account index %d of %d", i, maxNumAccounts)
|
||
|
}
|
||
|
|
||
|
return nil, fmt.Errorf("account script not derived")
|
||
|
}
|
||
|
|
||
|
func fastScript(expiryFrom, expiryTo uint32, traderKey, auctioneerKey,
|
||
|
batchKey *btcec.PublicKey, secret [32]byte,
|
||
|
targetScript []byte) (uint32, error) {
|
||
|
|
||
|
traderKeyTweak := poolscript.TraderKeyTweak(batchKey, secret, traderKey)
|
||
|
tweakedTraderKey := input.TweakPubKeyWithTweak(traderKey, traderKeyTweak)
|
||
|
tweakedAuctioneerKey := input.TweakPubKey(auctioneerKey, tweakedTraderKey)
|
||
|
|
||
|
for block := expiryFrom; block <= expiryTo; block++ {
|
||
|
builder := txscript.NewScriptBuilder()
|
||
|
|
||
|
builder.AddData(tweakedTraderKey.SerializeCompressed())
|
||
|
builder.AddOp(txscript.OP_CHECKSIGVERIFY)
|
||
|
|
||
|
builder.AddData(tweakedAuctioneerKey.SerializeCompressed())
|
||
|
builder.AddOp(txscript.OP_CHECKSIG)
|
||
|
|
||
|
builder.AddOp(txscript.OP_IFDUP)
|
||
|
builder.AddOp(txscript.OP_NOTIF)
|
||
|
builder.AddInt64(int64(block))
|
||
|
builder.AddOp(txscript.OP_CHECKLOCKTIMEVERIFY)
|
||
|
builder.AddOp(txscript.OP_ENDIF)
|
||
|
|
||
|
currentScript, err := builder.Script()
|
||
|
if err != nil {
|
||
|
return 0, fmt.Errorf("error building script: %v", err)
|
||
|
}
|
||
|
|
||
|
currentPkScript, err := input.WitnessScriptHash(currentScript)
|
||
|
if err != nil {
|
||
|
return 0, fmt.Errorf("error hashing script: %v", err)
|
||
|
}
|
||
|
if bytes.Equal(currentPkScript, targetScript) {
|
||
|
return block, nil
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return 0, fmt.Errorf("account script not derived")
|
||
|
}
|