diff --git a/client/cmd/dexcctl/main.go b/client/cmd/dexcctl/main.go index 0d093806b6..e118a39613 100644 --- a/client/cmd/dexcctl/main.go +++ b/client/cmd/dexcctl/main.go @@ -52,6 +52,7 @@ func main() { // promptPasswords is a map of routes to password prompts. Passwords are // prompted in the order given. var promptPasswords = map[string][]string{ + "cancel": {"App password:"}, "init": {"Set new app password:"}, "login": {"App password:"}, "newwallet": {"App password:", "Wallet password:"}, diff --git a/client/rpcserver/handlers.go b/client/rpcserver/handlers.go index 1975d82ee1..55b0cb400d 100644 --- a/client/rpcserver/handlers.go +++ b/client/rpcserver/handlers.go @@ -16,6 +16,7 @@ import ( // routes const ( + cancelRoute = "cancel" closeWalletRoute = "closewallet" exchangesRoute = "exchanges" helpRoute = "help" @@ -36,6 +37,7 @@ const ( walletCreatedStr = "%s wallet created and unlocked" walletLockedStr = "%s wallet locked" walletUnlockedStr = "%s wallet unlocked" + canceledOrderStr = "canceled order %s" ) // createResponse creates a msgjson response payload. @@ -59,6 +61,7 @@ func usage(route string, err error) *msgjson.ResponsePayload { // routes maps routes to a handler function. var routes = map[string]func(s *RPCServer, params *RawParams) *msgjson.ResponsePayload{ + cancelRoute: handleCancel, closeWalletRoute: handleCloseWallet, exchangesRoute: handleExchanges, helpRoute: handleHelp, @@ -374,6 +377,24 @@ func handleTrade(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { return createResponse(tradeRoute, &tradeRes, nil) } +// handleCancel handles requests for cancel. *msgjson.ResponsePayload.Error is +// empty if successful. +func handleCancel(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { + form, err := parseCancelArgs(params) + if err != nil { + return usage(cancelRoute, err) + } + defer form.AppPass.Clear() + if err := s.core.Cancel(form.AppPass, form.OrderID); err != nil { + errMsg := fmt.Sprintf("unable to cancel order %q: %v", form.OrderID, err) + resErr := msgjson.NewError(msgjson.RPCCancelError, errMsg) + return createResponse(tradeRoute, nil, resErr) + } + resp := fmt.Sprintf(canceledOrderStr, form.OrderID) + + return createResponse(cancelRoute, &resp, nil) +} + // format concatenates thing and tail. If thing is empty, returns an empty // string. func format(thing, tail string) string { @@ -707,4 +728,15 @@ Registration is complete after the fee transaction has been confirmed.`, Jan 1 1970. }`, }, + cancelRoute: { + pwArgsShort: `"appPass"`, + argsShort: `"orderID"`, + cmdSummary: `Cancel an order.`, + pwArgsLong: `Password Args: + appPass (string): The DEX client password.`, + argsLong: `Args: + orderID (string): The hex ID of the order to cancel`, + returns: `Returns: + string: The message "` + fmt.Sprintf(canceledOrderStr, "[order ID]") + `"`, + }, } diff --git a/client/rpcserver/handlers_test.go b/client/rpcserver/handlers_test.go index 13a5f9b922..46024889ec 100644 --- a/client/rpcserver/handlers_test.go +++ b/client/rpcserver/handlers_test.go @@ -682,3 +682,38 @@ func TestHandleTrade(t *testing.T) { } } } + +func TestHandleCancel(t *testing.T) { + params := &RawParams{ + PWArgs: []encode.PassBytes{encode.PassBytes("123")}, + Args: []string{"fb94fe99e4e32200a341f0f1cb33f34a08ac23eedab636e8adb991fa76343e1e"}, + } + tests := []struct { + name string + params *RawParams + cancelErr error + wantErrCode int + }{{ + name: "ok", + params: params, + wantErrCode: -1, + }, { + name: "core.Cancel error", + params: params, + cancelErr: errors.New("error"), + wantErrCode: msgjson.RPCCancelError, + }, { + name: "bad params", + params: &RawParams{}, + wantErrCode: msgjson.RPCArgumentsError, + }} + for _, test := range tests { + tc := &TCore{cancelErr: test.cancelErr} + r := &RPCServer{core: tc} + payload := handleCancel(r, test.params) + res := "" + if err := verifyResponse(payload, &res, test.wantErrCode); err != nil { + t.Fatal(err) + } + } +} diff --git a/client/rpcserver/rpcserver.go b/client/rpcserver/rpcserver.go index f9f8e216cc..e899aec7f0 100644 --- a/client/rpcserver/rpcserver.go +++ b/client/rpcserver/rpcserver.go @@ -52,16 +52,17 @@ var ( // ClientCore is satisfied by core.Core. type ClientCore interface { AssetBalances(assetID uint32) (*core.BalanceSet, error) - Book(dex string, base, quote uint32) (orderBook *core.OrderBook, err error) + Book(host string, base, quote uint32) (orderBook *core.OrderBook, err error) + Cancel(appPass []byte, orderID string) error CloseWallet(assetID uint32) error CreateWallet(appPass, walletPass []byte, form *core.WalletForm) error Exchanges() (exchanges map[string]*core.Exchange) InitializeClient(appPass []byte) error Login(appPass []byte) (*core.LoginResult, error) - OpenWallet(assetID uint32, pw []byte) error + OpenWallet(assetID uint32, appPass []byte) error GetFee(addr, cert string) (fee uint64, err error) Register(form *core.RegisterForm) error - Sync(dex string, base, quote uint32) (*core.OrderBook, *core.BookFeed, error) + Sync(host string, base, quote uint32) (*core.OrderBook, *core.BookFeed, error) Trade(appPass []byte, form *core.TradeForm) (order *core.Order, err error) WalletState(assetID uint32) (walletState *core.WalletState) Wallets() (walletsStates []*core.WalletState) diff --git a/client/rpcserver/rpcserver_test.go b/client/rpcserver/rpcserver_test.go index eada67cd31..86d923cccf 100644 --- a/client/rpcserver/rpcserver_test.go +++ b/client/rpcserver/rpcserver_test.go @@ -54,14 +54,21 @@ type TCore struct { loginResult *core.LoginResult order *core.Order tradeErr error + cancelErr error } +func (c *TCore) Balance(uint32) (uint64, error) { + return 0, c.balanceErr +} func (c *TCore) Book(dex string, base, quote uint32) (*core.OrderBook, error) { return nil, nil } func (c *TCore) AssetBalances(uint32) (*core.BalanceSet, error) { return nil, c.balanceErr } +func (c *TCore) Cancel(pw []byte, sid string) error { + return c.cancelErr +} func (c *TCore) CreateWallet(appPW, walletPW []byte, form *core.WalletForm) error { return c.createWalletErr } diff --git a/client/rpcserver/types.go b/client/rpcserver/types.go index 637418e105..aaaf519a69 100644 --- a/client/rpcserver/types.go +++ b/client/rpcserver/types.go @@ -4,14 +4,19 @@ package rpcserver import ( + "encoding/hex" "errors" "fmt" "strconv" "decred.org/dcrdex/client/core" "decred.org/dcrdex/dex/encode" + "decred.org/dcrdex/dex/order" ) +// An orderID is a 256 bit number encoded as a hex string. +const orderIdLen = 2 * order.OrderIDSize // 2 * 32 + var ( // errArgs is wrapped when arguments to the known command cannot be parsed. errArgs = errors.New("unable to parse arguments") @@ -42,7 +47,7 @@ type getFeeResponse struct { // tradeResponse is used when responding to the trade route. type tradeResponse struct { - OrderID string `json:"orderid"` + OrderID string `json:"orderID"` Sig string `json:"sig"` Stamp uint64 `json:"stamp"` } @@ -68,11 +73,18 @@ type helpForm struct { IncludePasswords bool `json:"includepasswords"` } +// tradeForm combines the application password and the user's trade details. type tradeForm struct { AppPass encode.PassBytes SrvForm *core.TradeForm } +// cancelForm is information necessary to cancel a trade. +type cancelForm struct { + AppPass encode.PassBytes `json:"appPass"` + OrderID string `json:"orderID"` +} + // checkNArgs checks that args and pwArgs are the correct length. func checkNArgs(params *RawParams, nPWArgs, nArgs []int) error { // For want, one integer indicates an exact match, two are the min and max. @@ -270,3 +282,17 @@ func parseTradeArgs(params *RawParams) (*tradeForm, error) { } return req, nil } + +func parseCancelArgs(params *RawParams) (*cancelForm, error) { + if err := checkNArgs(params, []int{1}, []int{1}); err != nil { + return nil, err + } + id := params.Args[0] + if len(id) != orderIdLen { + return nil, fmt.Errorf("%w: orderID has incorrect length", errArgs) + } + if _, err := hex.DecodeString(id); err != nil { + return nil, fmt.Errorf("%w: invalid order id hex", errArgs) + } + return &cancelForm{AppPass: params.PWArgs[0], OrderID: id}, nil +} diff --git a/client/rpcserver/types_test.go b/client/rpcserver/types_test.go index e4e766d8ab..09ddcedb87 100644 --- a/client/rpcserver/types_test.go +++ b/client/rpcserver/types_test.go @@ -60,16 +60,14 @@ func TestCheckNArgs(t *testing.T) { pwArgs[i] = encode.PassBytes(testValue) } err := checkNArgs(&RawParams{PWArgs: pwArgs, Args: test.have}, test.wantNArgs, test.wantNArgs) - if test.wantErr { - if err == nil { - t.Fatalf("expected error for test %s", - test.name) + if err != nil { + if test.wantErr { + continue } - continue + t.Fatalf("unexpected error for test %s: %v", test.name, err) } - if err != nil { - t.Fatalf("unexpected error for test %s: %v", - test.name, err) + if test.wantErr { + t.Fatalf("expected error for test %s", test.name) } } } @@ -99,7 +97,7 @@ func TestParseNewWalletArgs(t *testing.T) { }} for _, test := range tests { nwf, err := parseNewWalletArgs(test.params) - if test.wantErr != nil { + if err != nil { if !errors.Is(err, test.wantErr) { t.Fatalf("unexpected error %v for test %s", err, test.name) @@ -145,7 +143,7 @@ func TestParseOpenWalletArgs(t *testing.T) { }} for _, test := range tests { owf, err := parseOpenWalletArgs(test.params) - if test.wantErr != nil { + if err != nil { if !errors.Is(err, test.wantErr) { t.Fatalf("unexpected error %v for test %s", err, test.name) @@ -165,11 +163,13 @@ func TestCheckUIntArg(t *testing.T) { tests := []struct { name string arg string + want uint64 bitSize int wantErr error }{{ name: "ok", arg: "4294967295", + want: 4294967295, bitSize: 32, }, { name: "too big", @@ -196,8 +196,8 @@ func TestCheckUIntArg(t *testing.T) { } continue } - if fmt.Sprint(res) != test.arg { - t.Fatalf("strings don't match for test %s", test.name) + if res != test.want { + t.Fatalf("expected %d but got %d for test %q", test.want, res, test.name) } } } @@ -425,3 +425,43 @@ func TestTradeArgs(t *testing.T) { } } } + +func TestParseCancelArgs(t *testing.T) { + paramsWithOrderID := func(orderID string) *RawParams { + pw := encode.PassBytes("password123") + pwArgs := []encode.PassBytes{pw} + return &RawParams{PWArgs: pwArgs, Args: []string{orderID}} + } + tests := []struct { + name string + params *RawParams + wantErr error + }{{ + name: "ok", + params: paramsWithOrderID("fb94fe99e4e32200a341f0f1cb33f34a08ac23eedab636e8adb991fa76343e1e"), + }, { + name: "order ID incorrect length", + params: paramsWithOrderID("94fe99e4e32200a341f0f1cb33f34a08ac23eedab636e8adb991fa76343e1e"), + wantErr: errArgs, + }, { + name: "order ID not hex", + params: paramsWithOrderID("zb94fe99e4e32200a341f0f1cb33f34a08ac23eedab636e8adb991fa76343e1e"), + wantErr: errArgs, + }} + for _, test := range tests { + reg, err := parseCancelArgs(test.params) + if err != nil { + if !errors.Is(err, test.wantErr) { + t.Fatalf("unexpected error %v for test %q", + err, test.name) + } + continue + } + if !bytes.Equal(reg.AppPass, test.params.PWArgs[0]) { + t.Fatalf("appPass doesn't match") + } + if fmt.Sprint(reg.OrderID) != test.params.Args[0] { + t.Fatalf("order ID doesn't match") + } + } +} diff --git a/dex/msgjson/types.go b/dex/msgjson/types.go index 70a7ff9a5f..1e3d9c24b4 100644 --- a/dex/msgjson/types.go +++ b/dex/msgjson/types.go @@ -31,33 +31,34 @@ const ( RPCRegisterError // 15 RPCArgumentsError // 16 RPCTradeError // 17 - SignatureError // 18 - SerializationError // 19 - TransactionUndiscovered // 20 - ContractError // 21 - SettlementSequenceError // 22 - ResultLengthError // 23 - IDMismatchError // 24 - RedemptionError // 25 - IDTypeError // 26 - AckCountError // 27 - UnknownResponseID // 28 - OrderParameterError // 29 - UnknownMarketError // 30 - ClockRangeError // 31 - FundingError // 32 - CoinAuthError // 33 - UnknownMarket // 34 - NotSubscribedError // 35 - UnauthorizedConnection // 36 - AuthenticationError // 37 - PubKeyParseError // 38 - FeeError // 39 - InvalidPreimage // 40 - PreimageCommitmentMismatch // 41 - UnknownMessageType // 42 - AccountClosedError // 43 - MarketNotRunningError // 44 + RPCCancelError // 18 + SignatureError // 19 + SerializationError // 20 + TransactionUndiscovered // 21 + ContractError // 22 + SettlementSequenceError // 23 + ResultLengthError // 24 + IDMismatchError // 25 + RedemptionError // 26 + IDTypeError // 27 + AckCountError // 28 + UnknownResponseID // 29 + OrderParameterError // 30 + UnknownMarketError // 31 + ClockRangeError // 32 + FundingError // 33 + CoinAuthError // 34 + UnknownMarket // 35 + NotSubscribedError // 36 + UnauthorizedConnection // 37 + AuthenticationError // 38 + PubKeyParseError // 39 + FeeError // 40 + InvalidPreimage // 41 + PreimageCommitmentMismatch // 42 + UnknownMessageType // 43 + AccountClosedError // 44 + MarketNotRunningError // 45 ) // Routes are destinations for a "payload" of data. The type of data being