diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d0cbc..7c40783 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) ## Unreleased +- [#111](https://github.com/babylonlabs-io/btc-staker/pull/111) Add CLI command +to create phase-1/phase-2 PoP payload + +## v0.14.0 + * [#108](https://github.com/babylonlabs-io/btc-staker/pull/108) Bump babylon to v1.0.0-rc.1 ## v0.13.0 diff --git a/babylonclient/keyringcontroller/codec.go b/babylonclient/keyringcontroller/codec.go new file mode 100644 index 0000000..f4c142c --- /dev/null +++ b/babylonclient/keyringcontroller/codec.go @@ -0,0 +1,16 @@ +package keyringcontroller + +import ( + "github.com/cosmos/cosmos-sdk/codec" + codectypes "github.com/cosmos/cosmos-sdk/codec/types" + cryptocodec "github.com/cosmos/cosmos-sdk/crypto/codec" +) + +func MakeCodec() *codec.ProtoCodec { + ir := codectypes.NewInterfaceRegistry() + cdc := codec.NewProtoCodec(ir) + + cryptocodec.RegisterInterfaces(ir) + + return cdc +} diff --git a/babylonclient/keyringcontroller/keyring.go b/babylonclient/keyringcontroller/keyring.go new file mode 100644 index 0000000..720e280 --- /dev/null +++ b/babylonclient/keyringcontroller/keyring.go @@ -0,0 +1,43 @@ +package keyringcontroller + +import ( + "fmt" + "strings" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/crypto/keyring" +) + +func CreateKeyring(keyringDir string, chainID string, backend string, input *strings.Reader) (keyring.Keyring, error) { + ctx, err := CreateClientCtx(keyringDir, chainID) + if err != nil { + return nil, err + } + + if backend == "" { + return nil, fmt.Errorf("the keyring backend should not be empty") + } + + kr, err := keyring.New( + ctx.ChainID, + backend, + ctx.KeyringDir, + input, + ctx.Codec, + ctx.KeyringOptions...) + if err != nil { + return nil, fmt.Errorf("failed to create keyring: %w", err) + } + + return kr, nil +} + +func CreateClientCtx(keyringDir string, chainID string) (client.Context, error) { + if keyringDir == "" { + return client.Context{}, fmt.Errorf("the keyring directory should not be empty") + } + return client.Context{}. + WithChainID(chainID). + WithCodec(MakeCodec()). + WithKeyringDir(keyringDir), nil +} diff --git a/babylonclient/keyringcontroller/keyringcontroller.go b/babylonclient/keyringcontroller/keyringcontroller.go new file mode 100644 index 0000000..d6c8d6d --- /dev/null +++ b/babylonclient/keyringcontroller/keyringcontroller.go @@ -0,0 +1,136 @@ +package keyringcontroller + +import ( + "fmt" + "strings" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/crypto/keyring" + sdksecp256k1 "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + "github.com/cosmos/go-bip39" +) + +const ( + secp256k1Type = "secp256k1" + mnemonicEntropySize = 256 +) + +type ChainKeyInfo struct { + Name string + Mnemonic string + PublicKey *btcec.PublicKey + PrivateKey *btcec.PrivateKey +} + +type ChainKeyringController struct { + kr keyring.Keyring + keyName string + // input is to send passphrase to kr + input *strings.Reader +} + +func NewChainKeyringController(ctx client.Context, name, keyringBackend string) (*ChainKeyringController, error) { + if name == "" { + return nil, fmt.Errorf("the key name should not be empty") + } + + if keyringBackend == "" { + return nil, fmt.Errorf("the keyring backend should not be empty") + } + + inputReader := strings.NewReader("") + kr, err := keyring.New( + ctx.ChainID, + keyringBackend, + ctx.KeyringDir, + inputReader, + ctx.Codec, + ctx.KeyringOptions...) + if err != nil { + return nil, fmt.Errorf("failed to create keyring: %w", err) + } + + return &ChainKeyringController{ + keyName: name, + kr: kr, + input: inputReader, + }, nil +} + +func NewChainKeyringControllerWithKeyring(kr keyring.Keyring, name string, input *strings.Reader) (*ChainKeyringController, error) { + if name == "" { + return nil, fmt.Errorf("the key name should not be empty") + } + + return &ChainKeyringController{ + kr: kr, + keyName: name, + input: input, + }, nil +} + +func (kc *ChainKeyringController) GetKeyring() keyring.Keyring { + return kc.kr +} + +func (kc *ChainKeyringController) CreateChainKey(passphrase, hdPath string) (*ChainKeyInfo, error) { + keyringAlgos, _ := kc.kr.SupportedAlgorithms() + algo, err := keyring.NewSigningAlgoFromString(secp256k1Type, keyringAlgos) + if err != nil { + return nil, err + } + // read entropy seed straight from tmcrypto.Rand and convert to mnemonic + entropySeed, err := bip39.NewEntropy(mnemonicEntropySize) + if err != nil { + return nil, err + } + + mnemonic, err := bip39.NewMnemonic(entropySeed) + if err != nil { + return nil, err + } + + // we need to repeat the passphrase to mock the reentry + kc.input.Reset(passphrase + "\n" + passphrase) + record, err := kc.kr.NewAccount(kc.keyName, mnemonic, passphrase, hdPath, algo) + if err != nil { + return nil, err + } + + privKey := record.GetLocal().PrivKey.GetCachedValue() + + switch v := privKey.(type) { + case *sdksecp256k1.PrivKey: + sk, pk := btcec.PrivKeyFromBytes(v.Key) + return &ChainKeyInfo{ + Name: kc.keyName, + PublicKey: pk, + PrivateKey: sk, + Mnemonic: mnemonic, + }, nil + default: + return nil, fmt.Errorf("unsupported key type in keyring") + } +} + +func (kc *ChainKeyringController) GetChainPrivKey(passphrase string) (*sdksecp256k1.PrivKey, error) { + kc.input.Reset(passphrase) + k, err := kc.kr.Key(kc.keyName) + if err != nil { + return nil, fmt.Errorf("failed to get private key: %w", err) + } + + privKeyCached := k.GetLocal().PrivKey.GetCachedValue() + + switch v := privKeyCached.(type) { + case *sdksecp256k1.PrivKey: + return v, nil + default: + return nil, fmt.Errorf("unsupported key type in keyring") + } +} + +func (kc *ChainKeyringController) KeyRecord() (*keyring.Record, error) { + return kc.GetKeyring().Key(kc.keyName) +} diff --git a/cmd/stakercli/main.go b/cmd/stakercli/main.go index fbb4358..0a5ed05 100644 --- a/cmd/stakercli/main.go +++ b/cmd/stakercli/main.go @@ -6,6 +6,7 @@ import ( cmdadmin "github.com/babylonlabs-io/btc-staker/cmd/stakercli/admin" cmddaemon "github.com/babylonlabs-io/btc-staker/cmd/stakercli/daemon" + cmdpop "github.com/babylonlabs-io/btc-staker/cmd/stakercli/pop" cmdtx "github.com/babylonlabs-io/btc-staker/cmd/stakercli/transaction" "github.com/urfave/cli" ) @@ -21,7 +22,6 @@ const ( btcWalletRPCUserFlag = "btc-wallet-rpc-user" btcWalletRPCPassFlag = "btc-wallet-rpc-pass" btcWalletPassphraseFlag = "btc-wallet-passphrase" - btcWalletBackendFlag = "btc-wallet-backend" ) func main() { @@ -53,16 +53,12 @@ func main() { Name: btcWalletPassphraseFlag, Usage: "Bitcoin wallet passphrase", }, - cli.StringFlag{ - Name: btcWalletBackendFlag, - Usage: "Bitcoin backend (btcwallet|bitcoind)", - Value: "btcd", - }, } app.Commands = append(app.Commands, cmddaemon.DaemonCommands...) app.Commands = append(app.Commands, cmdadmin.AdminCommands...) app.Commands = append(app.Commands, cmdtx.TransactionCommands...) + app.Commands = append(app.Commands, cmdpop.PopCommands...) if err := app.Run(os.Args); err != nil { fatal(err) diff --git a/cmd/stakercli/pop/pop.go b/cmd/stakercli/pop/pop.go new file mode 100644 index 0000000..6eea16b --- /dev/null +++ b/cmd/stakercli/pop/pop.go @@ -0,0 +1,165 @@ +package pop + +import ( + "fmt" + + "github.com/babylonlabs-io/btc-staker/babylonclient/keyringcontroller" + "github.com/babylonlabs-io/btc-staker/cmd/stakercli/helpers" + "github.com/babylonlabs-io/btc-staker/staker" + "github.com/babylonlabs-io/btc-staker/types" + ut "github.com/babylonlabs-io/btc-staker/utils" + "github.com/babylonlabs-io/btc-staker/walletcontroller" + "github.com/btcsuite/btcd/btcutil" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/urfave/cli" +) + +const ( + msgFlag = "msg" + btcNetworkFlag = "btc-network" + btcWalletHostFlag = "btc-wallet-host" + btcWalletRPCUserFlag = "btc-wallet-rpc-user" + btcWalletRPCPassFlag = "btc-wallet-rpc-pass" + btcWalletNameFlag = "btc-wallet-name" + btcWalletPassphraseFlag = "btc-wallet-passphrase" + btcAddressFlag = "btc-address" + babyAddressFlag = "baby-address" + babyAddressPrefixFlag = "baby-address-prefix" + keyringDirFlag = "keyring-dir" + keyringBackendFlag = "keyring-backend" +) + +var PopCommands = []cli.Command{ + { + Name: "pop", + Usage: "Commands realted to generation and verification of the Proof of Possession", + Category: "PoP commands", + Subcommands: []cli.Command{ + generatePopCmd, + }, + }, +} + +var generatePopCmd = cli.Command{ + Name: "generate-pop", + ShortName: "gp", + Usage: "stakercli pop generate-pop", + Flags: []cli.Flag{ + cli.StringFlag{ + Name: btcAddressFlag, + Usage: "Bitcoin address to generate proof of possession for", + Required: true, + }, + cli.StringFlag{ + Name: babyAddressFlag, + Usage: "Baby address to generate proof of possession for", + Required: true, + }, + cli.StringFlag{ + Name: babyAddressPrefixFlag, + Usage: "Baby address prefix", + Value: "bbn", + }, + cli.StringFlag{ + Name: btcNetworkFlag, + Usage: "Bitcoin network on which staking should take place (testnet3, mainnet, regtest, simnet, signet)", + Value: "testnet3", + }, + cli.StringFlag{ + Name: btcWalletHostFlag, + Usage: "Bitcoin wallet rpc host", + Value: "127.0.0.1:18554", + }, + cli.StringFlag{ + Name: btcWalletRPCUserFlag, + Usage: "Bitcoin wallet rpc user", + Value: "user", + }, + cli.StringFlag{ + Name: btcWalletRPCPassFlag, + Usage: "Bitcoin wallet rpc password", + Value: "pass", + }, + cli.StringFlag{ + Name: btcWalletNameFlag, + Usage: "Bitcoin wallet name", + Value: "", + }, + cli.StringFlag{ + Name: btcWalletPassphraseFlag, + Usage: "Bitcoin wallet passphrase", + Value: "passphrase", + }, + cli.StringFlag{ + Name: keyringDirFlag, + Usage: "Keyring directory", + Value: "", + }, + cli.StringFlag{ + Name: keyringBackendFlag, + Usage: "Keyring backend", + Value: "test", + }, + }, + Action: generatePop, +} + +func generatePop(c *cli.Context) error { + network := c.String(btcNetworkFlag) + + networkParams, err := ut.GetBtcNetworkParams(network) + if err != nil { + return err + } + + rpcWalletController, err := walletcontroller.NewRPCWalletControllerFromArgs( + c.String(btcWalletHostFlag), + c.String(btcWalletRPCUserFlag), + c.String(btcWalletRPCPassFlag), + network, + c.String(btcWalletNameFlag), + c.String(btcWalletPassphraseFlag), + types.BitcoindWalletBackend, + networkParams, + true, + "", + "", + ) + if err != nil { + return fmt.Errorf("failed to create rpc wallet controller: %w", err) + } + + btcAddress, err := btcutil.DecodeAddress(c.String(btcAddressFlag), networkParams) + if err != nil { + return fmt.Errorf("failed to decode bitcoin address: %w", err) + } + + babylonAddress := c.String(babyAddressFlag) + babyAddressPrefix := c.String(babyAddressPrefixFlag) + + sdkAddressBytes, err := sdk.GetFromBech32(babylonAddress, babyAddressPrefix) + if err != nil { + return fmt.Errorf("failed to decode baby address: %w", err) + } + + sdkAddress := sdk.AccAddress(sdkAddressBytes) + + keyringDir := c.String(keyringDirFlag) + keyringBackend := c.String(keyringBackendFlag) + + keyring, err := keyringcontroller.CreateKeyring(keyringDir, "babylon", keyringBackend, nil) + if err != nil { + return err + } + + popCreator := staker.NewPopCreator(rpcWalletController, keyring) + + popResponse, err := popCreator.CreatePop(btcAddress, babyAddressPrefix, sdkAddress) + if err != nil { + return err + } + + helpers.PrintRespJSON(popResponse) + + return nil +} diff --git a/itest/e2e_test.go b/itest/e2e_test.go index 7241cdb..f523c5f 100644 --- a/itest/e2e_test.go +++ b/itest/e2e_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/babylonlabs-io/btc-staker/babylonclient/keyringcontroller" "github.com/babylonlabs-io/btc-staker/itest/containers" "github.com/babylonlabs-io/btc-staker/itest/testutil" "github.com/babylonlabs-io/networks/parameters/parser" @@ -27,6 +28,7 @@ import ( "github.com/babylonlabs-io/btc-staker/stakercfg" "github.com/babylonlabs-io/btc-staker/types" "github.com/babylonlabs-io/btc-staker/walletcontroller" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" @@ -983,3 +985,55 @@ func TestStakeFromPhase1(t *testing.T) { require.NoError(t, err) require.True(t, delInfo.Active) } + +func TestPopCreation(t *testing.T) { + t.Parallel() + manager, err := containers.NewManager(t) + require.NoError(t, err) + h := NewBitcoindHandler(t, manager) + bitcoind := h.Start() + passphrase := "pass" + walletName := "test-wallet" + _ = h.CreateWallet(walletName, passphrase) + + rpcHost := fmt.Sprintf("127.0.0.1:%s", bitcoind.GetPort("18443/tcp")) + cfg, c := defaultStakerConfigAndBtc(t, walletName, passphrase, rpcHost) + + segwitAddress, err := c.GetNewAddress("") + require.NoError(t, err) + + controller, err := walletcontroller.NewRPCWalletController(cfg) + require.NoError(t, err) + + keyring, err := keyringcontroller.CreateKeyring( + // does not matter for memory keyring + "/", + "babylon", + "memory", + nil, + ) + require.NoError(t, err) + + randomKey, _ := btcec.NewPrivateKey() + require.NoError(t, err) + + keyName := "test" + err = keyring.ImportPrivKeyHex(keyName, hex.EncodeToString(randomKey.Serialize()), "secp256k1") + require.NoError(t, err) + + record, err := keyring.Key(keyName) + require.NoError(t, err) + + address, err := record.GetAddress() + require.NoError(t, err) + + popCreator := staker.NewPopCreator(controller, keyring) + require.NotNil(t, popCreator) + + err = controller.UnlockWallet(30) + require.NoError(t, err) + + popResponse, err := popCreator.CreatePop(segwitAddress, "bbn", address) + require.NoError(t, err) + require.NotNil(t, popResponse) +} diff --git a/staker/pop_creator.go b/staker/pop_creator.go new file mode 100644 index 0000000..d12f0b3 --- /dev/null +++ b/staker/pop_creator.go @@ -0,0 +1,199 @@ +package staker + +import ( + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + + "github.com/babylonlabs-io/babylon/crypto/bip322" + "github.com/babylonlabs-io/btc-staker/walletcontroller" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/btcutil" + "github.com/cosmos/cosmos-sdk/crypto/keyring" + "github.com/cosmos/cosmos-sdk/crypto/keys/secp256k1" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/types/tx/signing" +) + +type Response struct { + BabyAddress string `json:"babyAddress"` + BTCAddress string `json:"btcAddress"` + BTCPublicKey string `json:"btcPublicKey"` + BTCSignBaby string `json:"btcSignBaby"` + BabySignBTC string `json:"babySignBtc"` + BabyPublicKey string `json:"babyPublicKey"` +} + +type BabyPublicKey struct { + Type string `json:"type"` + Value string `json:"value"` +} + +type PopCreator struct { + BitcoinWalletController walletcontroller.WalletController + KeyRing keyring.Keyring +} + +func NewPopCreator(bitcoinWalletController *walletcontroller.RPCWalletController, keyring keyring.Keyring) *PopCreator { + return &PopCreator{ + BitcoinWalletController: bitcoinWalletController, + KeyRing: keyring, + } +} + +func (pc *PopCreator) getBabyPubKey(babylonAddress sdk.AccAddress) (*keyring.Record, *secp256k1.PubKey, error) { + record, err := pc.KeyRing.KeyByAddress(babylonAddress) + + if err != nil { + return nil, nil, err + } + + pubKey, err := record.GetPubKey() + + if err != nil { + return nil, nil, err + } + + switch v := pubKey.(type) { + case *secp256k1.PubKey: + return record, v, nil + default: + return nil, nil, fmt.Errorf("unsupported key type in keyring") + } +} + +func (pc *PopCreator) signCosmosAdr36( + keyName string, + cosmosBech32Address string, + bytesToSign []byte, +) ([]byte, error) { + base64Bytes := base64.StdEncoding.EncodeToString(bytesToSign) + + signDoc := NewCosmosSignDoc( + cosmosBech32Address, + base64Bytes, + ) + + marshaled, err := json.Marshal(signDoc) + if err != nil { + return nil, fmt.Errorf("failed to marshal sign doc: %w", err) + } + + bz := sdk.MustSortJSON(marshaled) + + babySignBTCAddress, _, err := pc.KeyRing.Sign( + keyName, + bz, + signing.SignMode_SIGN_MODE_DIRECT, + ) + + if err != nil { + return nil, fmt.Errorf("failed to sign btc address bytes: %w", err) + } + + return babySignBTCAddress, nil +} + +func (pc *PopCreator) CreatePop( + btcAddress btcutil.Address, + babyAddressPrefix string, + babyAddress sdk.AccAddress, +) (*Response, error) { + bech32cosmosAddressString, err := sdk.Bech32ifyAddressBytes(babyAddressPrefix, babyAddress.Bytes()) + if err != nil { + return nil, err + } + + signature, err := pc.BitcoinWalletController.SignBip322NativeSegwit( + []byte(bech32cosmosAddressString), + btcAddress, + ) + + if err != nil { + return nil, err + } + + btcPubKey, err := pc.BitcoinWalletController.AddressPublicKey(btcAddress) + if err != nil { + return nil, err + } + + signatureBytes, err := bip322.SerializeWitness(signature) + if err != nil { + return nil, err + } + + record, babyPubKey, err := pc.getBabyPubKey(babyAddress) + if err != nil { + return nil, err + } + + babySignBTCAddress, err := pc.signCosmosAdr36( + record.Name, + bech32cosmosAddressString, + []byte(btcAddress.String()), + ) + + if err != nil { + return nil, fmt.Errorf("failed to sign btc address: %w", err) + } + + return &Response{ + BabyAddress: bech32cosmosAddressString, + BTCAddress: btcAddress.String(), + BTCPublicKey: hex.EncodeToString(schnorr.SerializePubKey(btcPubKey)), + BTCSignBaby: base64.StdEncoding.EncodeToString(signatureBytes), + BabySignBTC: base64.StdEncoding.EncodeToString(babySignBTCAddress), + BabyPublicKey: base64.StdEncoding.EncodeToString(babyPubKey.Bytes()), + }, nil +} + +type Fee struct { + Gas string `json:"gas"` + Amount []string `json:"amount"` +} + +type MsgValue struct { + Signer string `json:"signer"` + Data string `json:"data"` +} + +type Msg struct { + Type string `json:"type"` + Value MsgValue `json:"value"` +} + +type SignDoc struct { + ChainID string `json:"chain_id"` + AccountNumber string `json:"account_number"` + Sequence string `json:"sequence"` + Fee Fee `json:"fee"` + Msgs []Msg `json:"msgs"` + Memo string `json:"memo"` +} + +func NewCosmosSignDoc( + signer string, + data string, +) *SignDoc { + return &SignDoc{ + ChainID: "", + AccountNumber: "0", + Sequence: "0", + Fee: Fee{ + Gas: "0", + Amount: []string{}, + }, + Msgs: []Msg{ + { + Type: "sign/MsgSignData", + Value: MsgValue{ + Signer: signer, + Data: data, + }, + }, + }, + Memo: "", + } +}