diff --git a/app/appmessage/message.go b/app/appmessage/message.go index e01f7674a..a2fb9bd6f 100644 --- a/app/appmessage/message.go +++ b/app/appmessage/message.go @@ -165,6 +165,8 @@ const ( CmdGetCoinSupplyResponseMessage CmdGetFeeEstimateRequestMessage CmdGetFeeEstimateResponseMessage + CmdSubmitTransactionReplacementRequestMessage + CmdSubmitTransactionReplacementResponseMessage ) // ProtocolMessageCommandToString maps all MessageCommands to their string representation @@ -304,6 +306,8 @@ var RPCMessageCommandToString = map[MessageCommand]string{ CmdGetCoinSupplyResponseMessage: "GetCoinSupplyResponse", CmdGetFeeEstimateRequestMessage: "GetFeeEstimateRequest", CmdGetFeeEstimateResponseMessage: "GetFeeEstimateResponse", + CmdSubmitTransactionReplacementRequestMessage: "SubmitTransactionReplacementRequest", + CmdSubmitTransactionReplacementResponseMessage: "SubmitTransactionReplacementResponse", } // Message is an interface that describes a kaspa message. A type that diff --git a/app/appmessage/rpc_submit_transaction_replacement.go b/app/appmessage/rpc_submit_transaction_replacement.go new file mode 100644 index 000000000..e2bc4585d --- /dev/null +++ b/app/appmessage/rpc_submit_transaction_replacement.go @@ -0,0 +1,41 @@ +package appmessage + +// SubmitTransactionReplacementRequestMessage is an appmessage corresponding to +// its respective RPC message +type SubmitTransactionReplacementRequestMessage struct { + baseMessage + Transaction *RPCTransaction +} + +// Command returns the protocol command string for the message +func (msg *SubmitTransactionReplacementRequestMessage) Command() MessageCommand { + return CmdSubmitTransactionReplacementRequestMessage +} + +// NewSubmitTransactionReplacementRequestMessage returns a instance of the message +func NewSubmitTransactionReplacementRequestMessage(transaction *RPCTransaction) *SubmitTransactionReplacementRequestMessage { + return &SubmitTransactionReplacementRequestMessage{ + Transaction: transaction, + } +} + +// SubmitTransactionReplacementResponseMessage is an appmessage corresponding to +// its respective RPC message +type SubmitTransactionReplacementResponseMessage struct { + baseMessage + TransactionID string + + Error *RPCError +} + +// Command returns the protocol command string for the message +func (msg *SubmitTransactionReplacementResponseMessage) Command() MessageCommand { + return CmdSubmitTransactionReplacementResponseMessage +} + +// NewSubmitTransactionReplacementResponseMessage returns a instance of the message +func NewSubmitTransactionReplacementResponseMessage(transactionID string) *SubmitTransactionReplacementResponseMessage { + return &SubmitTransactionReplacementResponseMessage{ + TransactionID: transactionID, + } +} diff --git a/cmd/kaspawallet/broadcast_replacement.go b/cmd/kaspawallet/broadcast_replacement.go new file mode 100644 index 000000000..abfaa058c --- /dev/null +++ b/cmd/kaspawallet/broadcast_replacement.go @@ -0,0 +1,56 @@ +package main + +import ( + "context" + "fmt" + "io/ioutil" + "strings" + + "github.com/kaspanet/kaspad/cmd/kaspawallet/daemon/client" + "github.com/kaspanet/kaspad/cmd/kaspawallet/daemon/pb" + "github.com/pkg/errors" +) + +func broadcastReplacement(conf *broadcastConfig) error { + daemonClient, tearDown, err := client.Connect(conf.DaemonAddress) + if err != nil { + return err + } + defer tearDown() + + ctx, cancel := context.WithTimeout(context.Background(), daemonTimeout) + defer cancel() + + if conf.Transactions == "" && conf.TransactionsFile == "" { + return errors.Errorf("Either --transaction or --transaction-file is required") + } + if conf.Transactions != "" && conf.TransactionsFile != "" { + return errors.Errorf("Both --transaction and --transaction-file cannot be passed at the same time") + } + + transactionsHex := conf.Transactions + if conf.TransactionsFile != "" { + transactionHexBytes, err := ioutil.ReadFile(conf.TransactionsFile) + if err != nil { + return errors.Wrapf(err, "Could not read hex from %s", conf.TransactionsFile) + } + transactionsHex = strings.TrimSpace(string(transactionHexBytes)) + } + + transactions, err := decodeTransactionsFromHex(transactionsHex) + if err != nil { + return err + } + + response, err := daemonClient.BroadcastRBF(ctx, &pb.BroadcastRequest{Transactions: transactions}) + if err != nil { + return err + } + fmt.Println("Transactions were sent successfully") + fmt.Println("Transaction ID(s): ") + for _, txID := range response.TxIDs { + fmt.Printf("\t%s\n", txID) + } + + return nil +} diff --git a/cmd/kaspawallet/bump_fee.go b/cmd/kaspawallet/bump_fee.go new file mode 100644 index 000000000..6f72fea47 --- /dev/null +++ b/cmd/kaspawallet/bump_fee.go @@ -0,0 +1,110 @@ +package main + +import ( + "context" + "fmt" + "math" + "os" + "strings" + + "github.com/kaspanet/kaspad/cmd/kaspawallet/daemon/client" + "github.com/kaspanet/kaspad/cmd/kaspawallet/daemon/pb" + "github.com/kaspanet/kaspad/cmd/kaspawallet/keys" + "github.com/kaspanet/kaspad/cmd/kaspawallet/libkaspawallet" + "github.com/pkg/errors" +) + +func bumpFee(conf *bumpFeeConfig) error { + keysFile, err := keys.ReadKeysFile(conf.NetParams(), conf.KeysFile) + if err != nil { + return err + } + + if len(keysFile.ExtendedPublicKeys) > len(keysFile.EncryptedMnemonics) { + return errors.Errorf("Cannot use 'send' command for multisig wallet without all of the keys") + } + + daemonClient, tearDown, err := client.Connect(conf.DaemonAddress) + if err != nil { + return err + } + defer tearDown() + + ctx, cancel := context.WithTimeout(context.Background(), daemonTimeout) + defer cancel() + + feeRate := &pb.FeeRate{ + FeeRate: &pb.FeeRate_Max{Max: math.MaxFloat64}, + } + if conf.FeeRate > 0 { + feeRate.FeeRate = &pb.FeeRate_Exact{Exact: conf.FeeRate} + } else if conf.MaxFeeRate > 0 { + feeRate.FeeRate = &pb.FeeRate_Max{Max: conf.MaxFeeRate} + } + + createUnsignedTransactionsResponse, err := + daemonClient.BumpFee(ctx, &pb.BumpFeeRequest{ + TxID: conf.TxID, + From: conf.FromAddresses, + UseExistingChangeAddress: conf.UseExistingChangeAddress, + FeeRate: feeRate, + }) + if err != nil { + return err + } + + if len(conf.Password) == 0 { + conf.Password = keys.GetPassword("Password:") + } + mnemonics, err := keysFile.DecryptMnemonics(conf.Password) + if err != nil { + if strings.Contains(err.Error(), "message authentication failed") { + fmt.Fprintf(os.Stderr, "Password decryption failed. Sometimes this is a result of not "+ + "specifying the same keys file used by the wallet daemon process.\n") + } + return err + } + + signedTransactions := make([][]byte, len(createUnsignedTransactionsResponse.Transactions)) + for i, unsignedTransaction := range createUnsignedTransactionsResponse.Transactions { + signedTransaction, err := libkaspawallet.Sign(conf.NetParams(), mnemonics, unsignedTransaction, keysFile.ECDSA) + if err != nil { + return err + } + signedTransactions[i] = signedTransaction + } + + fmt.Printf("Broadcasting %d transaction(s)\n", len(signedTransactions)) + // Since we waited for user input when getting the password, which could take unbound amount of time - + // create a new context for broadcast, to reset the timeout. + broadcastCtx, broadcastCancel := context.WithTimeout(context.Background(), daemonTimeout) + defer broadcastCancel() + + const chunkSize = 100 // To avoid sending a message bigger than the gRPC max message size, we split it to chunks + for offset := 0; offset < len(signedTransactions); offset += chunkSize { + end := len(signedTransactions) + if offset+chunkSize <= len(signedTransactions) { + end = offset + chunkSize + } + + chunk := signedTransactions[offset:end] + response, err := daemonClient.BroadcastRBF(broadcastCtx, &pb.BroadcastRequest{Transactions: chunk}) + if err != nil { + return err + } + fmt.Printf("Broadcasted %d transaction(s) (broadcasted %.2f%% of the transactions so far)\n", len(chunk), 100*float64(end)/float64(len(signedTransactions))) + fmt.Println("Broadcasted Transaction ID(s): ") + for _, txID := range response.TxIDs { + fmt.Printf("\t%s\n", txID) + } + } + + if conf.Verbose { + fmt.Println("Serialized Transaction(s) (can be parsed via the `parse` command or resent via `broadcast`): ") + for _, signedTx := range signedTransactions { + fmt.Printf("\t%x\n\n", signedTx) + } + } + + return nil +} diff --git a/cmd/kaspawallet/bump_fee_unsigned.go b/cmd/kaspawallet/bump_fee_unsigned.go new file mode 100644 index 000000000..e9875385e --- /dev/null +++ b/cmd/kaspawallet/bump_fee_unsigned.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "fmt" + "math" + "os" + + "github.com/kaspanet/kaspad/cmd/kaspawallet/daemon/client" + "github.com/kaspanet/kaspad/cmd/kaspawallet/daemon/pb" +) + +func bumpFeeUnsigned(conf *bumpFeeUnsignedConfig) error { + daemonClient, tearDown, err := client.Connect(conf.DaemonAddress) + if err != nil { + return err + } + defer tearDown() + + ctx, cancel := context.WithTimeout(context.Background(), daemonTimeout) + defer cancel() + + if err != nil { + return err + } + + feeRate := &pb.FeeRate{ + FeeRate: &pb.FeeRate_Max{Max: math.MaxFloat64}, + } + if conf.FeeRate > 0 { + feeRate.FeeRate = &pb.FeeRate_Exact{Exact: conf.FeeRate} + } else if conf.MaxFeeRate > 0 { + feeRate.FeeRate = &pb.FeeRate_Max{Max: conf.MaxFeeRate} + } + + response, err := daemonClient.BumpFee(ctx, &pb.BumpFeeRequest{ + TxID: conf.TxID, + From: conf.FromAddresses, + UseExistingChangeAddress: conf.UseExistingChangeAddress, + FeeRate: feeRate, + }) + if err != nil { + return err + } + + fmt.Fprintln(os.Stderr, "Created unsigned transaction") + fmt.Println(encodeTransactionsToHex(response.Transactions)) + + return nil +} diff --git a/cmd/kaspawallet/config.go b/cmd/kaspawallet/config.go index c88bc5f39..51b098228 100644 --- a/cmd/kaspawallet/config.go +++ b/cmd/kaspawallet/config.go @@ -24,6 +24,9 @@ const ( startDaemonSubCmd = "start-daemon" versionSubCmd = "version" getDaemonVersionSubCmd = "get-daemon-version" + bumpFeeSubCmd = "bump-fee" + bumpFeeUnsignedSubCmd = "bump-fee-unsigned" + broadcastReplacementSubCmd = "broadcast-replacement" ) const ( @@ -137,6 +140,29 @@ type dumpUnencryptedDataConfig struct { config.NetworkFlags } +type bumpFeeUnsignedConfig struct { + TxID string `long:"txid" short:"i" description:"The transaction ID to bump the fee for"` + DaemonAddress string `long:"daemonaddress" short:"d" description:"Wallet daemon server to connect to"` + FromAddresses []string `long:"from-address" short:"a" description:"Specific public address to send Kaspa from. Use multiple times to accept several addresses" required:"false"` + UseExistingChangeAddress bool `long:"use-existing-change-address" short:"u" description:"Will use an existing change address (in case no change address was ever used, it will use a new one)"` + MaxFeeRate float64 `long:"max-fee-rate" short:"m" description:"Maximum fee rate in Sompi/gram to use for the transaction. The wallet will take the maximum between the fee estimate from the connected node and this value."` + FeeRate float64 `long:"fee-rate" short:"r" description:"Fee rate in Sompi/gram to use for the transaction. This option will override any fee estimate from the connected node."` + config.NetworkFlags +} + +type bumpFeeConfig struct { + TxID string `long:"txid" short:"i" description:"The transaction ID to bump the fee for"` + KeysFile string `long:"keys-file" short:"f" description:"Keys file location (default: ~/.kaspawallet/keys.json (*nix), %USERPROFILE%\\AppData\\Local\\Kaspawallet\\key.json (Windows))"` + Password string `long:"password" short:"p" description:"Wallet password"` + DaemonAddress string `long:"daemonaddress" short:"d" description:"Wallet daemon server to connect to"` + FromAddresses []string `long:"from-address" short:"a" description:"Specific public address to send Kaspa from. Repeat multiple times (adding -a before each) to accept several addresses" required:"false"` + UseExistingChangeAddress bool `long:"use-existing-change-address" short:"u" description:"Will use an existing change address (in case no change address was ever used, it will use a new one)"` + MaxFeeRate float64 `long:"max-fee-rate" short:"m" description:"Maximum fee rate in Sompi/gram to use for the transaction. The wallet will take the maximum between the fee estimate from the connected node and this value."` + FeeRate float64 `long:"fee-rate" short:"r" description:"Fee rate in Sompi/gram to use for the transaction. This option will override any fee estimate from the connected node."` + Verbose bool `long:"show-serialized" short:"s" description:"Show a list of hex encoded sent transactions"` + config.NetworkFlags +} + type versionConfig struct { } @@ -203,6 +229,12 @@ func parseCommandLine() (subCommand string, config interface{}) { parser.AddCommand(versionSubCmd, "Get the wallet version", "Get the wallet version", &versionConfig{}) getDaemonVersionConf := &getDaemonVersionConfig{DaemonAddress: defaultListen} parser.AddCommand(getDaemonVersionSubCmd, "Get the wallet daemon version", "Get the wallet daemon version", getDaemonVersionConf) + bumpFeeConf := &bumpFeeConfig{DaemonAddress: defaultListen} + parser.AddCommand(bumpFeeSubCmd, "Bump transaction fee (with signing and broadcast)", "Bump transaction fee (with signing and broadcast)", bumpFeeConf) + bumpFeeUnsignedConf := &bumpFeeUnsignedConfig{DaemonAddress: defaultListen} + parser.AddCommand(bumpFeeUnsignedSubCmd, "Bump transaction fee (without signing)", "Bump transaction fee (without signing)", bumpFeeUnsignedConf) + parser.AddCommand(broadcastReplacementSubCmd, "Broadcast the given transaction replacement", + "Broadcast the given transaction replacement", broadcastConf) _, err := parser.Parse() if err != nil { @@ -273,6 +305,13 @@ func parseCommandLine() (subCommand string, config interface{}) { printErrorAndExit(err) } config = broadcastConf + case broadcastReplacementSubCmd: + combineNetworkFlags(&broadcastConf.NetworkFlags, &cfg.NetworkFlags) + err := broadcastConf.ResolveNetwork(parser) + if err != nil { + printErrorAndExit(err) + } + config = broadcastConf case parseSubCmd: combineNetworkFlags(&parseConf.NetworkFlags, &cfg.NetworkFlags) err := parseConf.ResolveNetwork(parser) @@ -311,6 +350,20 @@ func parseCommandLine() (subCommand string, config interface{}) { case versionSubCmd: case getDaemonVersionSubCmd: config = getDaemonVersionConf + case bumpFeeSubCmd: + combineNetworkFlags(&bumpFeeConf.NetworkFlags, &cfg.NetworkFlags) + err := bumpFeeConf.ResolveNetwork(parser) + if err != nil { + printErrorAndExit(err) + } + config = bumpFeeConf + case bumpFeeUnsignedSubCmd: + combineNetworkFlags(&bumpFeeConf.NetworkFlags, &cfg.NetworkFlags) + err := bumpFeeConf.ResolveNetwork(parser) + if err != nil { + printErrorAndExit(err) + } + config = bumpFeeUnsignedConf } return parser.Command.Active.Name, config diff --git a/cmd/kaspawallet/daemon/pb/kaspawalletd.pb.go b/cmd/kaspawallet/daemon/pb/kaspawalletd.pb.go index 5b94e3fcc..bcf069758 100644 --- a/cmd/kaspawallet/daemon/pb/kaspawalletd.pb.go +++ b/cmd/kaspawallet/daemon/pb/kaspawalletd.pb.go @@ -1425,6 +1425,140 @@ func (x *GetVersionResponse) GetVersion() string { return "" } +type BumpFeeRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Password string `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` + From []string `protobuf:"bytes,2,rep,name=from,proto3" json:"from,omitempty"` + UseExistingChangeAddress bool `protobuf:"varint,3,opt,name=useExistingChangeAddress,proto3" json:"useExistingChangeAddress,omitempty"` + FeeRate *FeeRate `protobuf:"bytes,4,opt,name=feeRate,proto3" json:"feeRate,omitempty"` + TxID string `protobuf:"bytes,5,opt,name=txID,proto3" json:"txID,omitempty"` +} + +func (x *BumpFeeRequest) Reset() { + *x = BumpFeeRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_kaspawalletd_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *BumpFeeRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BumpFeeRequest) ProtoMessage() {} + +func (x *BumpFeeRequest) ProtoReflect() protoreflect.Message { + mi := &file_kaspawalletd_proto_msgTypes[26] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BumpFeeRequest.ProtoReflect.Descriptor instead. +func (*BumpFeeRequest) Descriptor() ([]byte, []int) { + return file_kaspawalletd_proto_rawDescGZIP(), []int{26} +} + +func (x *BumpFeeRequest) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *BumpFeeRequest) GetFrom() []string { + if x != nil { + return x.From + } + return nil +} + +func (x *BumpFeeRequest) GetUseExistingChangeAddress() bool { + if x != nil { + return x.UseExistingChangeAddress + } + return false +} + +func (x *BumpFeeRequest) GetFeeRate() *FeeRate { + if x != nil { + return x.FeeRate + } + return nil +} + +func (x *BumpFeeRequest) GetTxID() string { + if x != nil { + return x.TxID + } + return "" +} + +type BumpFeeResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Transactions [][]byte `protobuf:"bytes,1,rep,name=transactions,proto3" json:"transactions,omitempty"` + TxIDs []string `protobuf:"bytes,2,rep,name=txIDs,proto3" json:"txIDs,omitempty"` +} + +func (x *BumpFeeResponse) Reset() { + *x = BumpFeeResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_kaspawalletd_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *BumpFeeResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BumpFeeResponse) ProtoMessage() {} + +func (x *BumpFeeResponse) ProtoReflect() protoreflect.Message { + mi := &file_kaspawalletd_proto_msgTypes[27] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BumpFeeResponse.ProtoReflect.Descriptor instead. +func (*BumpFeeResponse) Descriptor() ([]byte, []int) { + return file_kaspawalletd_proto_rawDescGZIP(), []int{27} +} + +func (x *BumpFeeResponse) GetTransactions() [][]byte { + if x != nil { + return x.Transactions + } + return nil +} + +func (x *BumpFeeResponse) GetTxIDs() []string { + if x != nil { + return x.TxIDs + } + return nil +} + var File_kaspawalletd_proto protoreflect.FileDescriptor var file_kaspawalletd_proto_rawDesc = []byte{ @@ -1568,68 +1702,95 @@ var file_kaspawalletd_proto_rawDesc = []byte{ 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x2e, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x32, 0x86, 0x07, - 0x0a, 0x0c, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x12, 0x51, - 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x1f, 0x2e, 0x6b, - 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x47, 0x65, 0x74, 0x42, - 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0xc1, 0x01, + 0x0a, 0x0e, 0x42, 0x75, 0x6d, 0x70, 0x46, 0x65, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x12, 0x0a, 0x04, + 0x66, 0x72, 0x6f, 0x6d, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x04, 0x66, 0x72, 0x6f, 0x6d, + 0x12, 0x3a, 0x0a, 0x18, 0x75, 0x73, 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x43, + 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x18, 0x75, 0x73, 0x65, 0x45, 0x78, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x43, + 0x68, 0x61, 0x6e, 0x67, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x2f, 0x0a, 0x07, + 0x66, 0x65, 0x65, 0x52, 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, + 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x46, 0x65, 0x65, + 0x52, 0x61, 0x74, 0x65, 0x52, 0x07, 0x66, 0x65, 0x65, 0x52, 0x61, 0x74, 0x65, 0x12, 0x12, 0x0a, + 0x04, 0x74, 0x78, 0x49, 0x44, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x78, 0x49, + 0x44, 0x22, 0x4b, 0x0a, 0x0f, 0x42, 0x75, 0x6d, 0x70, 0x46, 0x65, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x22, 0x0a, 0x0c, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0c, 0x52, 0x0c, 0x74, 0x72, 0x61, 0x6e, + 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x78, 0x49, 0x44, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x74, 0x78, 0x49, 0x44, 0x73, 0x32, 0xa3, + 0x08, 0x0a, 0x0c, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x12, + 0x51, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x1f, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x47, 0x65, 0x74, - 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0x00, 0x12, 0x7e, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, - 0x53, 0x70, 0x65, 0x6e, 0x64, 0x61, 0x62, 0x6c, 0x65, 0x55, 0x54, 0x58, 0x4f, 0x73, 0x12, 0x2e, + 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x47, 0x65, - 0x74, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x53, 0x70, 0x65, 0x6e, 0x64, 0x61, 0x62, - 0x6c, 0x65, 0x55, 0x54, 0x58, 0x4f, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2f, - 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x47, 0x65, - 0x74, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x53, 0x70, 0x65, 0x6e, 0x64, 0x61, 0x62, - 0x6c, 0x65, 0x55, 0x54, 0x58, 0x4f, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0x00, 0x12, 0x81, 0x01, 0x0a, 0x1a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x6e, 0x73, 0x69, - 0x67, 0x6e, 0x65, 0x64, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, - 0x12, 0x2f, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, - 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x6e, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x54, 0x72, - 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x30, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, + 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x00, 0x12, 0x7e, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, + 0x6c, 0x53, 0x70, 0x65, 0x6e, 0x64, 0x61, 0x62, 0x6c, 0x65, 0x55, 0x54, 0x58, 0x4f, 0x73, 0x12, + 0x2e, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x47, + 0x65, 0x74, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x53, 0x70, 0x65, 0x6e, 0x64, 0x61, + 0x62, 0x6c, 0x65, 0x55, 0x54, 0x58, 0x4f, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x2f, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x47, + 0x65, 0x74, 0x45, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x53, 0x70, 0x65, 0x6e, 0x64, 0x61, + 0x62, 0x6c, 0x65, 0x55, 0x54, 0x58, 0x4f, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x00, 0x12, 0x81, 0x01, 0x0a, 0x1a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x6e, 0x73, + 0x69, 0x67, 0x6e, 0x65, 0x64, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x73, 0x12, 0x2f, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x6e, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, 0x54, - 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x0d, 0x53, 0x68, 0x6f, 0x77, 0x41, 0x64, 0x64, - 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x12, 0x22, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, - 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x53, 0x68, 0x6f, 0x77, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, - 0x73, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6b, 0x61, 0x73, - 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x53, 0x68, 0x6f, 0x77, 0x41, 0x64, - 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0x00, 0x12, 0x51, 0x0a, 0x0a, 0x4e, 0x65, 0x77, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, - 0x1f, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x4e, - 0x65, 0x77, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x1a, 0x20, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, - 0x4e, 0x65, 0x77, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x08, 0x53, 0x68, 0x75, 0x74, 0x64, 0x6f, 0x77, 0x6e, - 0x12, 0x1d, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, - 0x53, 0x68, 0x75, 0x74, 0x64, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x1e, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x53, - 0x68, 0x75, 0x74, 0x64, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0x00, 0x12, 0x4e, 0x0a, 0x09, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x12, 0x1e, - 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x42, 0x72, - 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, - 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x42, 0x72, - 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, - 0x00, 0x12, 0x3f, 0x0a, 0x04, 0x53, 0x65, 0x6e, 0x64, 0x12, 0x19, 0x2e, 0x6b, 0x61, 0x73, 0x70, - 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, - 0x65, 0x74, 0x64, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x22, 0x00, 0x12, 0x3f, 0x0a, 0x04, 0x53, 0x69, 0x67, 0x6e, 0x12, 0x19, 0x2e, 0x6b, 0x61, 0x73, - 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, - 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x22, 0x00, 0x12, 0x51, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, - 0x6e, 0x12, 0x1f, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, - 0x2e, 0x47, 0x65, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, - 0x64, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, - 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x6e, 0x65, 0x74, 0x2f, 0x6b, 0x61, - 0x73, 0x70, 0x61, 0x64, 0x2f, 0x63, 0x6d, 0x64, 0x2f, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, - 0x6c, 0x6c, 0x65, 0x74, 0x2f, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2f, 0x70, 0x62, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, + 0x64, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x6e, 0x73, 0x69, 0x67, 0x6e, 0x65, 0x64, + 0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x5a, 0x0a, 0x0d, 0x53, 0x68, 0x6f, 0x77, 0x41, 0x64, + 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x12, 0x22, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, + 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x53, 0x68, 0x6f, 0x77, 0x41, 0x64, 0x64, 0x72, 0x65, + 0x73, 0x73, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6b, 0x61, + 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x53, 0x68, 0x6f, 0x77, 0x41, + 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x00, 0x12, 0x51, 0x0a, 0x0a, 0x4e, 0x65, 0x77, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, + 0x12, 0x1f, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, + 0x4e, 0x65, 0x77, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x20, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, + 0x2e, 0x4e, 0x65, 0x77, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x08, 0x53, 0x68, 0x75, 0x74, 0x64, 0x6f, 0x77, + 0x6e, 0x12, 0x1d, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, + 0x2e, 0x53, 0x68, 0x75, 0x74, 0x64, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x1e, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, + 0x53, 0x68, 0x75, 0x74, 0x64, 0x6f, 0x77, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x00, 0x12, 0x4e, 0x0a, 0x09, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x12, + 0x1e, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x42, + 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x1f, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x42, + 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x00, 0x12, 0x51, 0x0a, 0x0c, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x52, + 0x42, 0x46, 0x12, 0x1e, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, + 0x64, 0x2e, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, + 0x64, 0x2e, 0x42, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x3f, 0x0a, 0x04, 0x53, 0x65, 0x6e, 0x64, 0x12, 0x19, 0x2e, + 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x53, 0x65, 0x6e, + 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, + 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x3f, 0x0a, 0x04, 0x53, 0x69, 0x67, 0x6e, 0x12, 0x19, + 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x53, 0x69, + 0x67, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x6b, 0x61, 0x73, 0x70, + 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x53, 0x69, 0x67, 0x6e, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x51, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x56, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, + 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, + 0x6c, 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x47, 0x65, 0x74, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x48, 0x0a, 0x07, 0x42, 0x75, + 0x6d, 0x70, 0x46, 0x65, 0x65, 0x12, 0x1c, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, + 0x6c, 0x65, 0x74, 0x64, 0x2e, 0x42, 0x75, 0x6d, 0x70, 0x46, 0x65, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, 0x65, + 0x74, 0x64, 0x2e, 0x42, 0x75, 0x6d, 0x70, 0x46, 0x65, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x22, 0x00, 0x42, 0x36, 0x5a, 0x34, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, + 0x6f, 0x6d, 0x2f, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x6e, 0x65, 0x74, 0x2f, 0x6b, 0x61, 0x73, 0x70, + 0x61, 0x64, 0x2f, 0x63, 0x6d, 0x64, 0x2f, 0x6b, 0x61, 0x73, 0x70, 0x61, 0x77, 0x61, 0x6c, 0x6c, + 0x65, 0x74, 0x2f, 0x64, 0x61, 0x65, 0x6d, 0x6f, 0x6e, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -1644,7 +1805,7 @@ func file_kaspawalletd_proto_rawDescGZIP() []byte { return file_kaspawalletd_proto_rawDescData } -var file_kaspawalletd_proto_msgTypes = make([]protoimpl.MessageInfo, 26) +var file_kaspawalletd_proto_msgTypes = make([]protoimpl.MessageInfo, 28) var file_kaspawalletd_proto_goTypes = []interface{}{ (*GetBalanceRequest)(nil), // 0: kaspawalletd.GetBalanceRequest (*GetBalanceResponse)(nil), // 1: kaspawalletd.GetBalanceResponse @@ -1672,6 +1833,8 @@ var file_kaspawalletd_proto_goTypes = []interface{}{ (*SignResponse)(nil), // 23: kaspawalletd.SignResponse (*GetVersionRequest)(nil), // 24: kaspawalletd.GetVersionRequest (*GetVersionResponse)(nil), // 25: kaspawalletd.GetVersionResponse + (*BumpFeeRequest)(nil), // 26: kaspawalletd.BumpFeeRequest + (*BumpFeeResponse)(nil), // 27: kaspawalletd.BumpFeeResponse } var file_kaspawalletd_proto_depIdxs = []int32{ 2, // 0: kaspawalletd.GetBalanceResponse.addressBalances:type_name -> kaspawalletd.AddressBalances @@ -1681,31 +1844,36 @@ var file_kaspawalletd_proto_depIdxs = []int32{ 16, // 4: kaspawalletd.UtxoEntry.scriptPublicKey:type_name -> kaspawalletd.ScriptPublicKey 15, // 5: kaspawalletd.GetExternalSpendableUTXOsResponse.Entries:type_name -> kaspawalletd.UtxosByAddressesEntry 3, // 6: kaspawalletd.SendRequest.feeRate:type_name -> kaspawalletd.FeeRate - 0, // 7: kaspawalletd.kaspawalletd.GetBalance:input_type -> kaspawalletd.GetBalanceRequest - 18, // 8: kaspawalletd.kaspawalletd.GetExternalSpendableUTXOs:input_type -> kaspawalletd.GetExternalSpendableUTXOsRequest - 4, // 9: kaspawalletd.kaspawalletd.CreateUnsignedTransactions:input_type -> kaspawalletd.CreateUnsignedTransactionsRequest - 6, // 10: kaspawalletd.kaspawalletd.ShowAddresses:input_type -> kaspawalletd.ShowAddressesRequest - 8, // 11: kaspawalletd.kaspawalletd.NewAddress:input_type -> kaspawalletd.NewAddressRequest - 12, // 12: kaspawalletd.kaspawalletd.Shutdown:input_type -> kaspawalletd.ShutdownRequest - 10, // 13: kaspawalletd.kaspawalletd.Broadcast:input_type -> kaspawalletd.BroadcastRequest - 20, // 14: kaspawalletd.kaspawalletd.Send:input_type -> kaspawalletd.SendRequest - 22, // 15: kaspawalletd.kaspawalletd.Sign:input_type -> kaspawalletd.SignRequest - 24, // 16: kaspawalletd.kaspawalletd.GetVersion:input_type -> kaspawalletd.GetVersionRequest - 1, // 17: kaspawalletd.kaspawalletd.GetBalance:output_type -> kaspawalletd.GetBalanceResponse - 19, // 18: kaspawalletd.kaspawalletd.GetExternalSpendableUTXOs:output_type -> kaspawalletd.GetExternalSpendableUTXOsResponse - 5, // 19: kaspawalletd.kaspawalletd.CreateUnsignedTransactions:output_type -> kaspawalletd.CreateUnsignedTransactionsResponse - 7, // 20: kaspawalletd.kaspawalletd.ShowAddresses:output_type -> kaspawalletd.ShowAddressesResponse - 9, // 21: kaspawalletd.kaspawalletd.NewAddress:output_type -> kaspawalletd.NewAddressResponse - 13, // 22: kaspawalletd.kaspawalletd.Shutdown:output_type -> kaspawalletd.ShutdownResponse - 11, // 23: kaspawalletd.kaspawalletd.Broadcast:output_type -> kaspawalletd.BroadcastResponse - 21, // 24: kaspawalletd.kaspawalletd.Send:output_type -> kaspawalletd.SendResponse - 23, // 25: kaspawalletd.kaspawalletd.Sign:output_type -> kaspawalletd.SignResponse - 25, // 26: kaspawalletd.kaspawalletd.GetVersion:output_type -> kaspawalletd.GetVersionResponse - 17, // [17:27] is the sub-list for method output_type - 7, // [7:17] is the sub-list for method input_type - 7, // [7:7] is the sub-list for extension type_name - 7, // [7:7] is the sub-list for extension extendee - 0, // [0:7] is the sub-list for field type_name + 3, // 7: kaspawalletd.BumpFeeRequest.feeRate:type_name -> kaspawalletd.FeeRate + 0, // 8: kaspawalletd.kaspawalletd.GetBalance:input_type -> kaspawalletd.GetBalanceRequest + 18, // 9: kaspawalletd.kaspawalletd.GetExternalSpendableUTXOs:input_type -> kaspawalletd.GetExternalSpendableUTXOsRequest + 4, // 10: kaspawalletd.kaspawalletd.CreateUnsignedTransactions:input_type -> kaspawalletd.CreateUnsignedTransactionsRequest + 6, // 11: kaspawalletd.kaspawalletd.ShowAddresses:input_type -> kaspawalletd.ShowAddressesRequest + 8, // 12: kaspawalletd.kaspawalletd.NewAddress:input_type -> kaspawalletd.NewAddressRequest + 12, // 13: kaspawalletd.kaspawalletd.Shutdown:input_type -> kaspawalletd.ShutdownRequest + 10, // 14: kaspawalletd.kaspawalletd.Broadcast:input_type -> kaspawalletd.BroadcastRequest + 10, // 15: kaspawalletd.kaspawalletd.BroadcastRBF:input_type -> kaspawalletd.BroadcastRequest + 20, // 16: kaspawalletd.kaspawalletd.Send:input_type -> kaspawalletd.SendRequest + 22, // 17: kaspawalletd.kaspawalletd.Sign:input_type -> kaspawalletd.SignRequest + 24, // 18: kaspawalletd.kaspawalletd.GetVersion:input_type -> kaspawalletd.GetVersionRequest + 26, // 19: kaspawalletd.kaspawalletd.BumpFee:input_type -> kaspawalletd.BumpFeeRequest + 1, // 20: kaspawalletd.kaspawalletd.GetBalance:output_type -> kaspawalletd.GetBalanceResponse + 19, // 21: kaspawalletd.kaspawalletd.GetExternalSpendableUTXOs:output_type -> kaspawalletd.GetExternalSpendableUTXOsResponse + 5, // 22: kaspawalletd.kaspawalletd.CreateUnsignedTransactions:output_type -> kaspawalletd.CreateUnsignedTransactionsResponse + 7, // 23: kaspawalletd.kaspawalletd.ShowAddresses:output_type -> kaspawalletd.ShowAddressesResponse + 9, // 24: kaspawalletd.kaspawalletd.NewAddress:output_type -> kaspawalletd.NewAddressResponse + 13, // 25: kaspawalletd.kaspawalletd.Shutdown:output_type -> kaspawalletd.ShutdownResponse + 11, // 26: kaspawalletd.kaspawalletd.Broadcast:output_type -> kaspawalletd.BroadcastResponse + 11, // 27: kaspawalletd.kaspawalletd.BroadcastRBF:output_type -> kaspawalletd.BroadcastResponse + 21, // 28: kaspawalletd.kaspawalletd.Send:output_type -> kaspawalletd.SendResponse + 23, // 29: kaspawalletd.kaspawalletd.Sign:output_type -> kaspawalletd.SignResponse + 25, // 30: kaspawalletd.kaspawalletd.GetVersion:output_type -> kaspawalletd.GetVersionResponse + 27, // 31: kaspawalletd.kaspawalletd.BumpFee:output_type -> kaspawalletd.BumpFeeResponse + 20, // [20:32] is the sub-list for method output_type + 8, // [8:20] is the sub-list for method input_type + 8, // [8:8] is the sub-list for extension type_name + 8, // [8:8] is the sub-list for extension extendee + 0, // [0:8] is the sub-list for field type_name } func init() { file_kaspawalletd_proto_init() } @@ -2026,6 +2194,30 @@ func file_kaspawalletd_proto_init() { return nil } } + file_kaspawalletd_proto_msgTypes[26].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*BumpFeeRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_kaspawalletd_proto_msgTypes[27].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*BumpFeeResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } file_kaspawalletd_proto_msgTypes[3].OneofWrappers = []interface{}{ (*FeeRate_Max)(nil), @@ -2037,7 +2229,7 @@ func file_kaspawalletd_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_kaspawalletd_proto_rawDesc, NumEnums: 0, - NumMessages: 26, + NumMessages: 28, NumExtensions: 0, NumServices: 1, }, diff --git a/cmd/kaspawallet/daemon/pb/kaspawalletd.proto b/cmd/kaspawallet/daemon/pb/kaspawalletd.proto index 5ddd349e7..309c674b4 100644 --- a/cmd/kaspawallet/daemon/pb/kaspawalletd.proto +++ b/cmd/kaspawallet/daemon/pb/kaspawalletd.proto @@ -13,6 +13,7 @@ service kaspawalletd { rpc NewAddress(NewAddressRequest) returns (NewAddressResponse) {} rpc Shutdown(ShutdownRequest) returns (ShutdownResponse) {} rpc Broadcast(BroadcastRequest) returns (BroadcastResponse) {} + rpc BroadcastRBF(BroadcastRequest) returns (BroadcastResponse) {} // Since SendRequest contains a password - this command should only be used on // a trusted or secure connection rpc Send(SendRequest) returns (SendResponse) {} @@ -20,6 +21,7 @@ service kaspawalletd { // a trusted or secure connection rpc Sign(SignRequest) returns (SignResponse) {} rpc GetVersion(GetVersionRequest) returns (GetVersionResponse) {} + rpc BumpFee(BumpFeeRequest) returns (BumpFeeResponse) {} } message GetBalanceRequest {} @@ -131,4 +133,17 @@ message SignResponse { repeated bytes signedTransactions = 1; } message GetVersionRequest {} -message GetVersionResponse { string version = 1; } \ No newline at end of file +message GetVersionResponse { string version = 1; } + +message BumpFeeRequest { + string password = 1; + repeated string from = 2; + bool useExistingChangeAddress = 3; + FeeRate feeRate = 4; + string txID = 5; +} + +message BumpFeeResponse { + repeated bytes transactions = 1; + repeated string txIDs = 2; +} diff --git a/cmd/kaspawallet/daemon/pb/kaspawalletd_grpc.pb.go b/cmd/kaspawallet/daemon/pb/kaspawalletd_grpc.pb.go index ee25118a9..8248e0ff1 100644 --- a/cmd/kaspawallet/daemon/pb/kaspawalletd_grpc.pb.go +++ b/cmd/kaspawallet/daemon/pb/kaspawalletd_grpc.pb.go @@ -29,6 +29,7 @@ type KaspawalletdClient interface { NewAddress(ctx context.Context, in *NewAddressRequest, opts ...grpc.CallOption) (*NewAddressResponse, error) Shutdown(ctx context.Context, in *ShutdownRequest, opts ...grpc.CallOption) (*ShutdownResponse, error) Broadcast(ctx context.Context, in *BroadcastRequest, opts ...grpc.CallOption) (*BroadcastResponse, error) + BroadcastRBF(ctx context.Context, in *BroadcastRequest, opts ...grpc.CallOption) (*BroadcastResponse, error) // Since SendRequest contains a password - this command should only be used on // a trusted or secure connection Send(ctx context.Context, in *SendRequest, opts ...grpc.CallOption) (*SendResponse, error) @@ -36,6 +37,7 @@ type KaspawalletdClient interface { // a trusted or secure connection Sign(ctx context.Context, in *SignRequest, opts ...grpc.CallOption) (*SignResponse, error) GetVersion(ctx context.Context, in *GetVersionRequest, opts ...grpc.CallOption) (*GetVersionResponse, error) + BumpFee(ctx context.Context, in *BumpFeeRequest, opts ...grpc.CallOption) (*BumpFeeResponse, error) } type kaspawalletdClient struct { @@ -109,6 +111,15 @@ func (c *kaspawalletdClient) Broadcast(ctx context.Context, in *BroadcastRequest return out, nil } +func (c *kaspawalletdClient) BroadcastRBF(ctx context.Context, in *BroadcastRequest, opts ...grpc.CallOption) (*BroadcastResponse, error) { + out := new(BroadcastResponse) + err := c.cc.Invoke(ctx, "/kaspawalletd.kaspawalletd/BroadcastRBF", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *kaspawalletdClient) Send(ctx context.Context, in *SendRequest, opts ...grpc.CallOption) (*SendResponse, error) { out := new(SendResponse) err := c.cc.Invoke(ctx, "/kaspawalletd.kaspawalletd/Send", in, out, opts...) @@ -136,6 +147,15 @@ func (c *kaspawalletdClient) GetVersion(ctx context.Context, in *GetVersionReque return out, nil } +func (c *kaspawalletdClient) BumpFee(ctx context.Context, in *BumpFeeRequest, opts ...grpc.CallOption) (*BumpFeeResponse, error) { + out := new(BumpFeeResponse) + err := c.cc.Invoke(ctx, "/kaspawalletd.kaspawalletd/BumpFee", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + // KaspawalletdServer is the server API for Kaspawalletd service. // All implementations must embed UnimplementedKaspawalletdServer // for forward compatibility @@ -147,6 +167,7 @@ type KaspawalletdServer interface { NewAddress(context.Context, *NewAddressRequest) (*NewAddressResponse, error) Shutdown(context.Context, *ShutdownRequest) (*ShutdownResponse, error) Broadcast(context.Context, *BroadcastRequest) (*BroadcastResponse, error) + BroadcastRBF(context.Context, *BroadcastRequest) (*BroadcastResponse, error) // Since SendRequest contains a password - this command should only be used on // a trusted or secure connection Send(context.Context, *SendRequest) (*SendResponse, error) @@ -154,6 +175,7 @@ type KaspawalletdServer interface { // a trusted or secure connection Sign(context.Context, *SignRequest) (*SignResponse, error) GetVersion(context.Context, *GetVersionRequest) (*GetVersionResponse, error) + BumpFee(context.Context, *BumpFeeRequest) (*BumpFeeResponse, error) mustEmbedUnimplementedKaspawalletdServer() } @@ -182,6 +204,9 @@ func (UnimplementedKaspawalletdServer) Shutdown(context.Context, *ShutdownReques func (UnimplementedKaspawalletdServer) Broadcast(context.Context, *BroadcastRequest) (*BroadcastResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Broadcast not implemented") } +func (UnimplementedKaspawalletdServer) BroadcastRBF(context.Context, *BroadcastRequest) (*BroadcastResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method BroadcastRBF not implemented") +} func (UnimplementedKaspawalletdServer) Send(context.Context, *SendRequest) (*SendResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Send not implemented") } @@ -191,6 +216,9 @@ func (UnimplementedKaspawalletdServer) Sign(context.Context, *SignRequest) (*Sig func (UnimplementedKaspawalletdServer) GetVersion(context.Context, *GetVersionRequest) (*GetVersionResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetVersion not implemented") } +func (UnimplementedKaspawalletdServer) BumpFee(context.Context, *BumpFeeRequest) (*BumpFeeResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method BumpFee not implemented") +} func (UnimplementedKaspawalletdServer) mustEmbedUnimplementedKaspawalletdServer() {} // UnsafeKaspawalletdServer may be embedded to opt out of forward compatibility for this service. @@ -330,6 +358,24 @@ func _Kaspawalletd_Broadcast_Handler(srv interface{}, ctx context.Context, dec f return interceptor(ctx, in, info, handler) } +func _Kaspawalletd_BroadcastRBF_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(BroadcastRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KaspawalletdServer).BroadcastRBF(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/kaspawalletd.kaspawalletd/BroadcastRBF", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KaspawalletdServer).BroadcastRBF(ctx, req.(*BroadcastRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _Kaspawalletd_Send_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(SendRequest) if err := dec(in); err != nil { @@ -384,6 +430,24 @@ func _Kaspawalletd_GetVersion_Handler(srv interface{}, ctx context.Context, dec return interceptor(ctx, in, info, handler) } +func _Kaspawalletd_BumpFee_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(BumpFeeRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(KaspawalletdServer).BumpFee(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/kaspawalletd.kaspawalletd/BumpFee", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(KaspawalletdServer).BumpFee(ctx, req.(*BumpFeeRequest)) + } + return interceptor(ctx, in, info, handler) +} + // Kaspawalletd_ServiceDesc is the grpc.ServiceDesc for Kaspawalletd service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -419,6 +483,10 @@ var Kaspawalletd_ServiceDesc = grpc.ServiceDesc{ MethodName: "Broadcast", Handler: _Kaspawalletd_Broadcast_Handler, }, + { + MethodName: "BroadcastRBF", + Handler: _Kaspawalletd_BroadcastRBF_Handler, + }, { MethodName: "Send", Handler: _Kaspawalletd_Send_Handler, @@ -431,6 +499,10 @@ var Kaspawalletd_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetVersion", Handler: _Kaspawalletd_GetVersion_Handler, }, + { + MethodName: "BumpFee", + Handler: _Kaspawalletd_BumpFee_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "kaspawalletd.proto", diff --git a/cmd/kaspawallet/daemon/server/broadcast_rbf.go b/cmd/kaspawallet/daemon/server/broadcast_rbf.go new file mode 100644 index 000000000..e7e81a389 --- /dev/null +++ b/cmd/kaspawallet/daemon/server/broadcast_rbf.go @@ -0,0 +1,69 @@ +package server + +import ( + "context" + "time" + + "github.com/kaspanet/kaspad/app/appmessage" + "github.com/kaspanet/kaspad/cmd/kaspawallet/daemon/pb" + "github.com/kaspanet/kaspad/cmd/kaspawallet/libkaspawallet" + "github.com/kaspanet/kaspad/cmd/kaspawallet/libkaspawallet/serialization" + "github.com/kaspanet/kaspad/domain/consensus/model/externalapi" + "github.com/kaspanet/kaspad/domain/consensus/utils/consensushashing" + "github.com/kaspanet/kaspad/infrastructure/network/rpcclient" + "github.com/pkg/errors" +) + +func (s *server) BroadcastRBF(_ context.Context, request *pb.BroadcastRequest) (*pb.BroadcastResponse, error) { + s.lock.Lock() + defer s.lock.Unlock() + + txIDs, err := s.broadcastRBF(request.Transactions, request.IsDomain) + if err != nil { + return nil, err + } + + return &pb.BroadcastResponse{TxIDs: txIDs}, nil +} + +func (s *server) broadcastRBF(transactions [][]byte, isDomain bool) ([]string, error) { + + txIDs := make([]string, len(transactions)) + var tx *externalapi.DomainTransaction + var err error + + for i, transaction := range transactions { + + if isDomain { + tx, err = serialization.DeserializeDomainTransaction(transaction) + if err != nil { + return nil, err + } + } else if !isDomain { //default in proto3 is false + tx, err = libkaspawallet.ExtractTransaction(transaction, s.keysFile.ECDSA) + if err != nil { + return nil, err + } + } + + txIDs[i], err = sendTransactionRBF(s.rpcClient, tx) + if err != nil { + return nil, err + } + + for _, input := range tx.Inputs { + s.usedOutpoints[input.PreviousOutpoint] = time.Now() + } + } + + s.forceSync() + return txIDs, nil +} + +func sendTransactionRBF(client *rpcclient.RPCClient, tx *externalapi.DomainTransaction) (string, error) { + submitTransactionResponse, err := client.SubmitTransactionReplacement(appmessage.DomainTransactionToRPCTransaction(tx), consensushashing.TransactionID(tx).String()) + if err != nil { + return "", errors.Wrapf(err, "error submitting transaction replacement") + } + return submitTransactionResponse.TransactionID, nil +} diff --git a/cmd/kaspawallet/daemon/server/bump_fee.go b/cmd/kaspawallet/daemon/server/bump_fee.go new file mode 100644 index 000000000..576b2e0fa --- /dev/null +++ b/cmd/kaspawallet/daemon/server/bump_fee.go @@ -0,0 +1,138 @@ +package server + +import ( + "context" + + "github.com/kaspanet/kaspad/app/appmessage" + "github.com/kaspanet/kaspad/cmd/kaspawallet/daemon/pb" + "github.com/kaspanet/kaspad/cmd/kaspawallet/libkaspawallet" + "github.com/kaspanet/kaspad/domain/consensus/model/externalapi" + "github.com/kaspanet/kaspad/domain/consensus/utils/txscript" + "github.com/pkg/errors" +) + +func (s *server) BumpFee(_ context.Context, request *pb.BumpFeeRequest) (*pb.BumpFeeResponse, error) { + s.lock.Lock() + defer s.lock.Unlock() + + entry, err := s.rpcClient.GetMempoolEntry(request.TxID, false, false) + if err != nil { + return nil, err + } + + domainTx, err := appmessage.RPCTransactionToDomainTransaction(entry.Entry.Transaction) + if err != nil { + return nil, err + } + + mass := s.txMassCalculator.CalculateTransactionMass(domainTx) // TODO: Does GetMempoolEntry already provide the mass? + feeRate := float64(entry.Entry.Fee) / float64(mass) + newFeeRate, err := s.calculateFeeRate(request.FeeRate) + if err != nil { + return nil, err + } + + if feeRate >= newFeeRate { + return nil, errors.Errorf("new fee rate (%f) is not higher than the current fee rate (%f)", newFeeRate, feeRate) + } + + outpointsSet := make(map[externalapi.DomainOutpoint]struct{}) + for _, input := range domainTx.Inputs { + outpointsSet[input.PreviousOutpoint] = struct{}{} + } + + var maxUTXO *walletUTXO + for _, utxo := range s.utxosSortedByAmount { + if _, ok := outpointsSet[*utxo.Outpoint]; !ok { + continue + } + + if maxUTXO == nil || utxo.UTXOEntry.Amount() > maxUTXO.UTXOEntry.Amount() { + maxUTXO = utxo + } + } + + if maxUTXO == nil { + return nil, errors.Errorf("no UTXOs were found for transaction %s. This probably means the transaction is already accepted", request.TxID) + } + + if len(domainTx.Outputs) == 0 || len(domainTx.Outputs) > 2 { + return nil, errors.Errorf("kaspawallet supports only transactions with 1 or 2 outputs in transaction %s, but this transaction got %d", request.TxID, len(domainTx.Outputs)) + } + + var fromAddresses []*walletAddress + for _, from := range request.From { + fromAddress, exists := s.addressSet[from] + if !exists { + return nil, errors.Errorf("specified from address %s does not exists", from) + } + fromAddresses = append(fromAddresses, fromAddress) + } + + selectedUTXOs, spendValue, changeSompi, err := s.selectUTXOsWithPreselected([]*walletUTXO{maxUTXO}, outpointsSet, domainTx.Outputs[0].Value, false, newFeeRate, fromAddresses) + if err != nil { + return nil, err + } + + _, toAddress, err := txscript.ExtractScriptPubKeyAddress(domainTx.Outputs[0].ScriptPublicKey, s.params) + if err != nil { + return nil, err + } + + changeAddress, changeWalletAddress, err := s.changeAddress(request.UseExistingChangeAddress, fromAddresses) + if err != nil { + return nil, err + } + + if len(selectedUTXOs) == 0 { + return nil, errors.Errorf("couldn't find funds to spend") + } + + payments := []*libkaspawallet.Payment{{ + Address: toAddress, + Amount: spendValue, + }} + if changeSompi > 0 { + _, changeAddress, err := txscript.ExtractScriptPubKeyAddress(domainTx.Outputs[0].ScriptPublicKey, s.params) + if err != nil { + return nil, err + } + + payments = append(payments, &libkaspawallet.Payment{ + Address: changeAddress, + Amount: changeSompi, + }) + } + unsignedTransaction, err := libkaspawallet.CreateUnsignedTransaction(s.keysFile.ExtendedPublicKeys, + s.keysFile.MinimumSignatures, + payments, selectedUTXOs) + if err != nil { + return nil, err + } + + unsignedTransactions, err := s.maybeAutoCompoundTransaction(unsignedTransaction, toAddress, changeAddress, changeWalletAddress, feeRate) + if err != nil { + return nil, err + } + + if request.Password == "" { + return &pb.BumpFeeResponse{ + Transactions: unsignedTransactions, + }, nil + } + + signedTransactions, err := s.signTransactions(unsignedTransactions, request.Password) + if err != nil { + return nil, err + } + + txIDs, err := s.broadcastRBF(signedTransactions, false) + if err != nil { + return nil, err + } + + return &pb.BumpFeeResponse{ + TxIDs: txIDs, + Transactions: signedTransactions, + }, nil +} diff --git a/cmd/kaspawallet/daemon/server/create_unsigned_transaction.go b/cmd/kaspawallet/daemon/server/create_unsigned_transaction.go index a80fbd5c8..4d369c105 100644 --- a/cmd/kaspawallet/daemon/server/create_unsigned_transaction.go +++ b/cmd/kaspawallet/daemon/server/create_unsigned_transaction.go @@ -34,23 +34,32 @@ func (s *server) CreateUnsignedTransactions(_ context.Context, request *pb.Creat return &pb.CreateUnsignedTransactionsResponse{UnsignedTransactions: unsignedTransactions}, nil } -func (s *server) createUnsignedTransactions(address string, amount uint64, isSendAll bool, fromAddressesString []string, useExistingChangeAddress bool, feeRateOneOf *pb.FeeRate) ([][]byte, error) { - if !s.isSynced() { - return nil, errors.Errorf("wallet daemon is not synced yet, %s", s.formatSyncStateReport()) - } - +func (s *server) calculateFeeRate(requestFeeRate *pb.FeeRate) (float64, error) { var feeRate float64 - switch requestFeeRate := feeRateOneOf.FeeRate.(type) { + switch requestFeeRate := requestFeeRate.FeeRate.(type) { case *pb.FeeRate_Exact: feeRate = requestFeeRate.Exact case *pb.FeeRate_Max: estimate, err := s.rpcClient.GetFeeEstimate() if err != nil { - return nil, err + return 0, err } feeRate = math.Min(estimate.Estimate.NormalBuckets[0].Feerate, requestFeeRate.Max) } + return feeRate, nil +} + +func (s *server) createUnsignedTransactions(address string, amount uint64, isSendAll bool, fromAddressesString []string, useExistingChangeAddress bool, requestFeeRate *pb.FeeRate) ([][]byte, error) { + if !s.isSynced() { + return nil, errors.Errorf("wallet daemon is not synced yet, %s", s.formatSyncStateReport()) + } + + feeRate, err := s.calculateFeeRate(requestFeeRate) + if err != nil { + return nil, err + } + // make sure address string is correct before proceeding to a // potentially long UTXO refreshment operation toAddress, err := util.DecodeAddress(address, s.params.Prefix) @@ -107,8 +116,16 @@ func (s *server) createUnsignedTransactions(address string, amount uint64, isSen func (s *server) selectUTXOs(spendAmount uint64, isSendAll bool, feeRate float64, fromAddresses []*walletAddress) ( selectedUTXOs []*libkaspawallet.UTXO, totalReceived uint64, changeSompi uint64, err error) { + return s.selectUTXOsWithPreselected(nil, map[externalapi.DomainOutpoint]struct{}{}, spendAmount, isSendAll, feeRate, fromAddresses) +} - selectedUTXOs = []*libkaspawallet.UTXO{} +func (s *server) selectUTXOsWithPreselected(preSelectedUTXOs []*walletUTXO, allowUsed map[externalapi.DomainOutpoint]struct{}, spendAmount uint64, isSendAll bool, feeRate float64, fromAddresses []*walletAddress) ( + selectedUTXOs []*libkaspawallet.UTXO, totalReceived uint64, changeSompi uint64, err error) { + + preSelectedSet := make(map[externalapi.DomainOutpoint]struct{}) + for _, utxo := range preSelectedUTXOs { + preSelectedSet[*utxo.Outpoint] = struct{}{} + } totalValue := uint64(0) dagInfo, err := s.rpcClient.GetBlockDAGInfo() @@ -117,17 +134,25 @@ func (s *server) selectUTXOs(spendAmount uint64, isSendAll bool, feeRate float64 } var fee uint64 - for _, utxo := range s.utxosSortedByAmount { + iteration := func(utxo *walletUTXO, avoidPreselected bool) (bool, error) { if (fromAddresses != nil && !walletAddressesContain(fromAddresses, utxo.address)) || !s.isUTXOSpendable(utxo, dagInfo.VirtualDAAScore) { - continue + return true, nil } if broadcastTime, ok := s.usedOutpoints[*utxo.Outpoint]; ok { - if s.usedOutpointHasExpired(broadcastTime) { - delete(s.usedOutpoints, *utxo.Outpoint) - } else { - continue + if _, ok := allowUsed[*utxo.Outpoint]; !ok { + if s.usedOutpointHasExpired(broadcastTime) { + delete(s.usedOutpoints, *utxo.Outpoint) + } else { + return true, nil + } + } + } + + if avoidPreselected { + if _, ok := preSelectedSet[*utxo.Outpoint]; ok { + return true, nil } } @@ -141,7 +166,7 @@ func (s *server) selectUTXOs(spendAmount uint64, isSendAll bool, feeRate float64 fee, err = s.estimateFee(selectedUTXOs, feeRate) if err != nil { - return nil, 0, 0, err + return false, err } totalSpend := spendAmount + fee @@ -151,10 +176,37 @@ func (s *server) selectUTXOs(spendAmount uint64, isSendAll bool, feeRate float64 // 2.1 go-nodes dust patch we try and find at least 2 inputs (even though the next one is not necessary in terms of spend value) // 2.2 KIP9 we try and make sure that the change amount is not too small if !isSendAll && (totalValue == totalSpend || (totalValue >= totalSpend+minChangeTarget && len(selectedUTXOs) > 1)) { + return false, nil + } + + return true, nil + } + + shouldContinue := true + for _, utxo := range preSelectedUTXOs { + shouldContinue, err = iteration(utxo, false) + if err != nil { + return nil, 0, 0, err + } + + if !shouldContinue { break } } + if shouldContinue { + for _, utxo := range s.utxosSortedByAmount { + shouldContinue, err := iteration(utxo, true) + if err != nil { + return nil, 0, 0, err + } + + if !shouldContinue { + break + } + } + } + var totalSpend uint64 if isSendAll { totalSpend = totalValue diff --git a/cmd/kaspawallet/main.go b/cmd/kaspawallet/main.go index afe44cadc..1a51cbb24 100644 --- a/cmd/kaspawallet/main.go +++ b/cmd/kaspawallet/main.go @@ -20,6 +20,8 @@ func main() { err = sign(config.(*signConfig)) case broadcastSubCmd: err = broadcast(config.(*broadcastConfig)) + case broadcastReplacementSubCmd: + err = broadcastReplacement(config.(*broadcastConfig)) case parseSubCmd: err = parse(config.(*parseConfig)) case showAddressesSubCmd: @@ -36,6 +38,10 @@ func main() { showVersion() case getDaemonVersionSubCmd: err = getDaemonVersion(config.(*getDaemonVersionConfig)) + case bumpFeeSubCmd: + err = bumpFee(config.(*bumpFeeConfig)) + case bumpFeeUnsignedSubCmd: + err = bumpFeeUnsigned(config.(*bumpFeeUnsignedConfig)) default: err = errors.Errorf("Unknown sub-command '%s'\n", subCmd) } diff --git a/infrastructure/network/netadapter/server/grpcserver/protowire/rpc_submit_transaction_replacement.go b/infrastructure/network/netadapter/server/grpcserver/protowire/rpc_submit_transaction_replacement.go new file mode 100644 index 000000000..dc621ac75 --- /dev/null +++ b/infrastructure/network/netadapter/server/grpcserver/protowire/rpc_submit_transaction_replacement.go @@ -0,0 +1,68 @@ +package protowire + +import ( + "github.com/kaspanet/kaspad/app/appmessage" + "github.com/pkg/errors" +) + +func (x *KaspadMessage_SubmitTransactionReplacementRequest) toAppMessage() (appmessage.Message, error) { + if x == nil { + return nil, errors.Wrapf(errorNil, "KaspadMessage_SubmitTransactionReplacementRequest is nil") + } + return x.SubmitTransactionReplacementRequest.toAppMessage() +} + +func (x *KaspadMessage_SubmitTransactionReplacementRequest) fromAppMessage(message *appmessage.SubmitTransactionReplacementRequestMessage) error { + x.SubmitTransactionReplacementRequest = &SubmitTransactionReplacementRequestMessage{ + Transaction: &RpcTransaction{}, + } + x.SubmitTransactionReplacementRequest.Transaction.fromAppMessage(message.Transaction) + return nil +} + +func (x *SubmitTransactionReplacementRequestMessage) toAppMessage() (appmessage.Message, error) { + if x == nil { + return nil, errors.Wrapf(errorNil, "SubmitBlockRequestMessage is nil") + } + rpcTransaction, err := x.Transaction.toAppMessage() + if err != nil { + return nil, err + } + return &appmessage.SubmitTransactionReplacementRequestMessage{ + Transaction: rpcTransaction, + }, nil +} + +func (x *KaspadMessage_SubmitTransactionReplacementResponse) toAppMessage() (appmessage.Message, error) { + if x == nil { + return nil, errors.Wrapf(errorNil, "KaspadMessage_SubmitTransactionReplacementResponse is nil") + } + return x.SubmitTransactionReplacementResponse.toAppMessage() +} + +func (x *KaspadMessage_SubmitTransactionReplacementResponse) fromAppMessage(message *appmessage.SubmitTransactionReplacementResponseMessage) error { + var err *RPCError + if message.Error != nil { + err = &RPCError{Message: message.Error.Message} + } + x.SubmitTransactionReplacementResponse = &SubmitTransactionReplacementResponseMessage{ + TransactionId: message.TransactionID, + Error: err, + } + return nil +} + +func (x *SubmitTransactionReplacementResponseMessage) toAppMessage() (appmessage.Message, error) { + if x == nil { + return nil, errors.Wrapf(errorNil, "SubmitTransactionReplacementResponseMessage is nil") + } + rpcErr, err := x.Error.toAppMessage() + // Error is an optional field + if err != nil && !errors.Is(err, errorNil) { + return nil, err + } + return &appmessage.SubmitTransactionReplacementResponseMessage{ + TransactionID: x.TransactionId, + Error: rpcErr, + }, nil +} diff --git a/infrastructure/network/netadapter/server/grpcserver/protowire/wire.go b/infrastructure/network/netadapter/server/grpcserver/protowire/wire.go index cd5d12ebb..2fe626960 100644 --- a/infrastructure/network/netadapter/server/grpcserver/protowire/wire.go +++ b/infrastructure/network/netadapter/server/grpcserver/protowire/wire.go @@ -975,6 +975,20 @@ func toRPCPayload(message appmessage.Message) (isKaspadMessage_Payload, error) { return nil, err } return payload, nil + case *appmessage.SubmitTransactionReplacementRequestMessage: + payload := new(KaspadMessage_SubmitTransactionReplacementRequest) + err := payload.fromAppMessage(message) + if err != nil { + return nil, err + } + return payload, nil + case *appmessage.SubmitTransactionReplacementResponseMessage: + payload := new(KaspadMessage_SubmitTransactionReplacementResponse) + err := payload.fromAppMessage(message) + if err != nil { + return nil, err + } + return payload, nil default: return nil, nil } diff --git a/infrastructure/network/rpcclient/rpc_submit_transaction_replacement.go b/infrastructure/network/rpcclient/rpc_submit_transaction_replacement.go new file mode 100644 index 000000000..1bb3cb7c9 --- /dev/null +++ b/infrastructure/network/rpcclient/rpc_submit_transaction_replacement.go @@ -0,0 +1,44 @@ +package rpcclient + +import ( + "strings" + + "github.com/kaspanet/kaspad/app/appmessage" +) + +// SubmitTransactionReplacement sends an RPC request respective to the function's name and returns the RPC server's response +func (c *RPCClient) SubmitTransactionReplacement(transaction *appmessage.RPCTransaction, transactionID string) (*appmessage.SubmitTransactionReplacementResponseMessage, error) { + err := c.rpcRouter.outgoingRoute().Enqueue(appmessage.NewSubmitTransactionReplacementRequestMessage(transaction)) + if err != nil { + return nil, err + } + for { + response, err := c.route(appmessage.CmdSubmitTransactionReplacementResponseMessage).DequeueWithTimeout(c.timeout) + if err != nil { + return nil, err + } + SubmitTransactionReplacementResponse := response.(*appmessage.SubmitTransactionReplacementResponseMessage) + // Match the response to the expected ID. If they are different it means we got an old response which we + // previously timed-out on, so we log and continue waiting for the correct current response. + if SubmitTransactionReplacementResponse.TransactionID != transactionID { + if SubmitTransactionReplacementResponse.Error != nil { + // A non-updated Kaspad might return an empty ID in the case of error, so in + // such a case we fallback to checking if the error contains the expected ID + if SubmitTransactionReplacementResponse.TransactionID != "" || !strings.Contains(SubmitTransactionReplacementResponse.Error.Message, transactionID) { + log.Warnf("SubmitTransactionReplacement: received an error response for previous request: %s", SubmitTransactionReplacementResponse.Error) + continue + } + + } else { + log.Warnf("SubmitTransactionReplacement: received a successful response for previous request with ID %s", + SubmitTransactionReplacementResponse.TransactionID) + continue + } + } + if SubmitTransactionReplacementResponse.Error != nil { + return nil, c.convertRPCError(SubmitTransactionReplacementResponse.Error) + } + + return SubmitTransactionReplacementResponse, nil + } +}