From eac1b9caa5bba231dcc93ba70ec33cefe85eb624 Mon Sep 17 00:00:00 2001 From: JoeGruffins <34998433+JoeGruffins@users.noreply.github.com> Date: Tue, 16 Jun 2020 04:43:02 +0900 Subject: [PATCH] client/rpcserver: Add withdraw route --- client/cmd/dexcctl/main.go | 1 + client/rpcserver/handlers.go | 46 ++++++++++++++++--- client/rpcserver/handlers_test.go | 71 ++++++++++++++++++++++++++++++ client/rpcserver/rpcserver.go | 2 + client/rpcserver/rpcserver_test.go | 6 +++ client/rpcserver/types.go | 29 ++++++++++++ client/rpcserver/types_test.go | 47 ++++++++++++++++++++ dex/msgjson/types.go | 57 ++++++++++++------------ 8 files changed, 226 insertions(+), 33 deletions(-) diff --git a/client/cmd/dexcctl/main.go b/client/cmd/dexcctl/main.go index e118a39613..03a1a3274f 100644 --- a/client/cmd/dexcctl/main.go +++ b/client/cmd/dexcctl/main.go @@ -59,6 +59,7 @@ var promptPasswords = map[string][]string{ "openwallet": {"App password:"}, "register": {"App password:"}, "trade": {"App password:"}, + "withdraw": {"App password:"}, } // optionalTextFiles is a map of routes to arg index for routes that should read diff --git a/client/rpcserver/handlers.go b/client/rpcserver/handlers.go index 98b8c69eb4..0d28d2b079 100644 --- a/client/rpcserver/handlers.go +++ b/client/rpcserver/handlers.go @@ -29,6 +29,7 @@ const ( tradeRoute = "trade" versionRoute = "version" walletsRoute = "wallets" + withdrawRoute = "withdraw" ) const ( @@ -73,6 +74,7 @@ var routes = map[string]func(s *RPCServer, params *RawParams) *msgjson.ResponseP tradeRoute: handleTrade, versionRoute: handleVersion, walletsRoute: handleWallets, + withdrawRoute: handleWithdraw, } // handleHelp handles requests for help. Returns general help for all commands @@ -143,8 +145,7 @@ func handleNewWallet(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { form.WalletPass.Clear() }() - exists := s.core.WalletState(form.AssetID) != nil - if exists { + if s.core.WalletState(form.AssetID) != nil { errMsg := fmt.Sprintf("error creating %s wallet: wallet already exists", dex.BipIDSymbol(form.AssetID)) resErr := msgjson.NewError(msgjson.RPCWalletExistsError, errMsg) @@ -385,11 +386,30 @@ func handleCancel(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { 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) + return createResponse(cancelRoute, nil, resErr) + } + res := fmt.Sprintf(canceledOrderStr, form.OrderID) + + return createResponse(cancelRoute, &res, nil) +} + +// handleWithdraw handles requests for withdraw. *msgjson.ResponsePayload.Error +// is empty if successful. +func handleWithdraw(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { + form, err := parseWithdrawArgs(params) + if err != nil { + return usage(withdrawRoute, err) + } + defer form.AppPass.Clear() + coin, err := s.core.Withdraw(form.AppPass, form.AssetID, form.Value, form.Address) + if err != nil { + errMsg := fmt.Sprintf("unable to withdraw: %v", err) + resErr := msgjson.NewError(msgjson.RPCWithdrawError, errMsg) + return createResponse(withdrawRoute, nil, resErr) } - resp := fmt.Sprintf(canceledOrderStr, form.OrderID) + res := coin.String() - return createResponse(cancelRoute, &resp, nil) + return createResponse(withdrawRoute, &res, nil) } // format concatenates thing and tail. If thing is empty, returns an empty @@ -739,4 +759,20 @@ Registration is complete after the fee transaction has been confirmed.`, returns: `Returns: string: The message "` + fmt.Sprintf(canceledOrderStr, "[order ID]") + `"`, }, + withdrawRoute: { + pwArgsShort: `"appPass"`, + argsShort: `assetID value "address"`, + cmdSummary: `Withdraw value from an exchange wallet to address.`, + pwArgsLong: `Password Args: + appPass (string): The DEX client password.`, + argsLong: `Args: + assetID (int): The asset's BIP-44 registered coin index. Used to identify + which wallet to withdraw from. e.g. 42 for DCR. See + https://github.com/satoshilabs/slips/blob/master/slip-0044.md + value (int): The amount to withdraw in units of the asset's smallest + denomination (e.g. satoshis, atoms, etc.)" + address (string): The address to which withdrawn funds are sent.`, + returns: `Returns: + string: "[coin ID]"`, + }, } diff --git a/client/rpcserver/handlers_test.go b/client/rpcserver/handlers_test.go index 15330ebbf1..f1d5f4a26e 100644 --- a/client/rpcserver/handlers_test.go +++ b/client/rpcserver/handlers_test.go @@ -10,7 +10,9 @@ import ( "reflect" "testing" + "decred.org/dcrdex/client/asset" "decred.org/dcrdex/client/core" + "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/msgjson" ) @@ -717,3 +719,72 @@ func TestHandleCancel(t *testing.T) { } } } + +// tCoin satifies the asset.Coin interface. +type tCoin struct{} + +func (tCoin) ID() dex.Bytes { + return nil +} +func (tCoin) String() string { + return "" +} +func (tCoin) Value() uint64 { + return 0 +} +func (tCoin) Confirmations() (uint32, error) { + return 0, nil +} +func (tCoin) Redeem() dex.Bytes { + return nil +} + +func TestHandleWithdraw(t *testing.T) { + pw := encode.PassBytes("password123") + params := &RawParams{ + PWArgs: []encode.PassBytes{pw}, + Args: []string{ + "42", + "1000", + "abc", + }, + } + tests := []struct { + name string + params *RawParams + walletState *core.WalletState + coin asset.Coin + withdrawErr error + wantErrCode int + }{{ + name: "ok", + params: params, + walletState: &core.WalletState{}, + coin: tCoin{}, + wantErrCode: -1, + }, { + name: "core.Withdraw error", + params: params, + walletState: &core.WalletState{}, + coin: tCoin{}, + withdrawErr: errors.New("error"), + wantErrCode: msgjson.RPCWithdrawError, + }, { + name: "bad params", + params: &RawParams{}, + wantErrCode: msgjson.RPCArgumentsError, + }} + for _, test := range tests { + tc := &TCore{ + walletState: test.walletState, + coin: test.coin, + withdrawErr: test.withdrawErr, + } + r := &RPCServer{core: tc} + payload := handleWithdraw(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 763c994550..30a33457dc 100644 --- a/client/rpcserver/rpcserver.go +++ b/client/rpcserver/rpcserver.go @@ -20,6 +20,7 @@ import ( "sync" "time" + "decred.org/dcrdex/client/asset" "decred.org/dcrdex/client/core" "decred.org/dcrdex/client/db" "decred.org/dcrdex/dex" @@ -67,6 +68,7 @@ type ClientCore interface { Trade(appPass []byte, form *core.TradeForm) (order *core.Order, err error) WalletState(assetID uint32) (walletState *core.WalletState) Wallets() (walletsStates []*core.WalletState) + Withdraw(appPass []byte, assetID uint32, value uint64, addr string) (asset.Coin, error) } // marketSyncer is used to synchronize market subscriptions. The marketSyncer diff --git a/client/rpcserver/rpcserver_test.go b/client/rpcserver/rpcserver_test.go index 81e4e49ec3..f65f8d6d60 100644 --- a/client/rpcserver/rpcserver_test.go +++ b/client/rpcserver/rpcserver_test.go @@ -18,6 +18,7 @@ import ( "testing" "time" + "decred.org/dcrdex/client/asset" "decred.org/dcrdex/client/core" "decred.org/dcrdex/client/db" "decred.org/dcrdex/dex" @@ -57,6 +58,8 @@ type TCore struct { order *core.Order tradeErr error cancelErr error + coin asset.Coin + withdrawErr error } func (c *TCore) Balance(uint32) (uint64, error) { @@ -105,6 +108,9 @@ func (c *TCore) Wallets() []*core.WalletState { func (c *TCore) WalletState(assetID uint32) *core.WalletState { return c.walletState } +func (c *TCore) Withdraw(pw []byte, assetID uint32, value uint64, addr string) (asset.Coin, error) { + return c.coin, c.withdrawErr +} type TWriter struct { b []byte diff --git a/client/rpcserver/types.go b/client/rpcserver/types.go index aaaf519a69..b85ea874ed 100644 --- a/client/rpcserver/types.go +++ b/client/rpcserver/types.go @@ -85,6 +85,14 @@ type cancelForm struct { OrderID string `json:"orderID"` } +// withdrawForm is information necessary to withdraw funds. +type withdrawForm struct { + AppPass encode.PassBytes `json:"appPass"` + AssetID uint32 `json:"assetID"` + Value uint64 `json:"value"` + Address string `json:"address"` +} + // 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. @@ -296,3 +304,24 @@ func parseCancelArgs(params *RawParams) (*cancelForm, error) { } return &cancelForm{AppPass: params.PWArgs[0], OrderID: id}, nil } + +func parseWithdrawArgs(params *RawParams) (*withdrawForm, error) { + if err := checkNArgs(params, []int{1}, []int{3}); err != nil { + return nil, err + } + assetID, err := checkUIntArg(params.Args[0], "assetID", 32) + if err != nil { + return nil, err + } + value, err := checkUIntArg(params.Args[1], "value", 64) + if err != nil { + return nil, err + } + req := &withdrawForm{ + AppPass: params.PWArgs[0], + AssetID: uint32(assetID), + Value: value, + Address: params.Args[2], + } + return req, nil +} diff --git a/client/rpcserver/types_test.go b/client/rpcserver/types_test.go index 09ddcedb87..bdb0225fa8 100644 --- a/client/rpcserver/types_test.go +++ b/client/rpcserver/types_test.go @@ -465,3 +465,50 @@ func TestParseCancelArgs(t *testing.T) { } } } + +func TestParseWithdrawArgs(t *testing.T) { + paramsWithArgs := func(id, value string) *RawParams { + pw := encode.PassBytes("password123") + pwArgs := []encode.PassBytes{pw} + args := []string{ + id, + value, + "abc", + } + return &RawParams{PWArgs: pwArgs, Args: args} + } + tests := []struct { + name string + params *RawParams + wantErr error + }{{ + name: "ok", + params: paramsWithArgs("42", "5000"), + }, { + name: "assetID is not int", + params: paramsWithArgs("42.1", "5000"), + wantErr: errArgs, + }} + for _, test := range tests { + res, err := parseWithdrawArgs(test.params) + if err != nil { + if !errors.Is(err, test.wantErr) { + t.Fatalf("unexpected error %v for test %s", + err, test.name) + } + continue + } + if !bytes.Equal(res.AppPass, test.params.PWArgs[0]) { + t.Fatalf("appPass doesn't match") + } + if fmt.Sprint(res.AssetID) != test.params.Args[0] { + t.Fatalf("assetID doesn't match") + } + if fmt.Sprint(res.Value) != test.params.Args[1] { + t.Fatalf("value doesn't match") + } + if res.Address != test.params.Args[2] { + t.Fatalf("address doesn't match") + } + } +} diff --git a/dex/msgjson/types.go b/dex/msgjson/types.go index ba27ab1a96..133b4620b7 100644 --- a/dex/msgjson/types.go +++ b/dex/msgjson/types.go @@ -32,34 +32,35 @@ const ( RPCArgumentsError // 16 RPCTradeError // 17 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 - TryAgainLaterError // 46 + RPCWithdrawError // 19 + SignatureError // 20 + SerializationError // 21 + TransactionUndiscovered // 22 + ContractError // 23 + SettlementSequenceError // 24 + ResultLengthError // 25 + IDMismatchError // 26 + RedemptionError // 27 + IDTypeError // 28 + AckCountError // 29 + UnknownResponseID // 30 + OrderParameterError // 31 + UnknownMarketError // 32 + ClockRangeError // 33 + FundingError // 34 + CoinAuthError // 35 + UnknownMarket // 36 + NotSubscribedError // 37 + UnauthorizedConnection // 38 + AuthenticationError // 39 + PubKeyParseError // 40 + FeeError // 41 + InvalidPreimage // 42 + PreimageCommitmentMismatch // 43 + UnknownMessageType // 44 + AccountClosedError // 45 + MarketNotRunningError // 46 + TryAgainLaterError // 47 ) // Routes are destinations for a "payload" of data. The type of data being