diff --git a/server/admin/api.go b/server/admin/api.go index b7beedbdc1..9efd36d2fe 100644 --- a/server/admin/api.go +++ b/server/admin/api.go @@ -4,6 +4,7 @@ package admin import ( + "encoding/hex" "encoding/json" "fmt" "net/http" @@ -12,6 +13,7 @@ import ( "time" "decred.org/dcrdex/dex/encode" + "decred.org/dcrdex/server/account" "github.com/go-chi/chi" ) @@ -156,3 +158,21 @@ func (s *Server) apiAccounts(w http.ResponseWriter, _ *http.Request) { } writeJSON(w, accts) } + +// apiAccountInfo is the handler for the '/account/{account id}' API request. +func (s *Server) apiAccountInfo(w http.ResponseWriter, r *http.Request) { + acct := strings.ToLower(chi.URLParam(r, accountNameKey)) + acctIDSlice, err := hex.DecodeString(acct) + if err != nil { + http.Error(w, fmt.Sprintf("could not decode accout id: %v", err), http.StatusBadRequest) + return + } + var acctID account.AccountID + copy(acctID[:], acctIDSlice) + acctInfo, err := s.core.AccountInfo(acctID) + if err != nil { + http.Error(w, fmt.Sprintf("failed to retrieve account: %v", err), http.StatusInternalServerError) + return + } + writeJSON(w, acctInfo) +} diff --git a/server/admin/server.go b/server/admin/server.go index 1b28ce8dea..f48418dc34 100644 --- a/server/admin/server.go +++ b/server/admin/server.go @@ -17,6 +17,7 @@ import ( "sync" "time" + "decred.org/dcrdex/server/account" "decred.org/dcrdex/server/db" "decred.org/dcrdex/server/market" "github.com/decred/slog" @@ -30,7 +31,8 @@ const ( // is closed. rpcTimeoutSeconds = 10 - marketNameKey = "market" + marketNameKey = "market" + accountNameKey = "account" ) var ( @@ -40,6 +42,7 @@ var ( // SvrCore is satisfied by server/dex.DEX. type SvrCore interface { Accounts() ([]*db.Account, error) + AccountInfo(account.AccountID) (*db.Account, error) ConfigMsg() json.RawMessage MarketRunning(mktName string) (found, running bool) MarketStatus(mktName string) *market.Status @@ -121,6 +124,9 @@ func NewServer(cfg *SrvConfig) (*Server, error) { r.Get("/ping", s.apiPing) r.Get("/config", s.apiConfig) r.Get("/accounts", s.apiAccounts) + r.Route("/account/{"+accountNameKey+"}", func(rm chi.Router) { + rm.Get("/", s.apiAccountInfo) + }) r.Get("/markets", s.apiMarkets) r.Route("/market/{"+marketNameKey+"}", func(rm chi.Router) { diff --git a/server/admin/server_test.go b/server/admin/server_test.go index ad272154f0..c21097f03d 100644 --- a/server/admin/server_test.go +++ b/server/admin/server_test.go @@ -49,6 +49,8 @@ type TCore struct { markets map[string]*TMarket accounts []*db.Account accountsErr error + account *db.Account + accountErr error } func (c *TCore) ConfigMsg() json.RawMessage { return nil } @@ -138,6 +140,9 @@ func (w *tResponseWriter) WriteHeader(statusCode int) { } func (c *TCore) Accounts() ([]*db.Account, error) { return c.accounts, c.accountsErr } +func (c *TCore) AccountInfo(_ account.AccountID) (*db.Account, error) { + return c.account, c.accountErr +} // genCertPair generates a key/cert pair to the paths provided. func genCertPair(certFile, keyFile string) error { @@ -794,3 +799,102 @@ func TestAccounts(t *testing.T) { t.Fatalf("apiAccounts returned code %d, expected %d", w.Code, http.StatusInternalServerError) } } + +func TestAccountInfo(t *testing.T) { + core := new(TCore) + srv := &Server{ + core: core, + } + + acctIDStr := "0a9912205b2cbab0c25c2de30bda9074de0ae23b065489a99199bad763f102cc" + + mux := chi.NewRouter() + mux.Route("/account/{"+accountNameKey+"}", func(rm chi.Router) { + rm.Get("/", srv.apiAccountInfo) + }) + + // No account. + w := httptest.NewRecorder() + r, _ := http.NewRequest("GET", "https://localhost/account/"+acctIDStr, nil) + r.RemoteAddr = "localhost" + + mux.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("apiAccounts returned code %d, expected %d", w.Code, http.StatusOK) + } + respBody := w.Body.String() + if respBody != "null\n" { + t.Errorf("incorrect response body: %q", respBody) + } + + accountIDSlice, err := hex.DecodeString(acctIDStr) + if err != nil { + t.Fatal(err) + } + var accountID account.AccountID + copy(accountID[:], accountIDSlice) + pubkey, err := hex.DecodeString("0204988a498d5d19514b217e872b4dbd1cf071d365c4879e64ed5919881c97eb19") + if err != nil { + t.Fatal(err) + } + feeCoin, err := hex.DecodeString("6e515ff861f2016fd0da2f3eccdf8290c03a9d116bfba2f6729e648bdc6e5aed00000005") + if err != nil { + t.Fatal(err) + } + + // An account. + core.account = &db.Account{ + AccountID: accountID, + Pubkey: dex.Bytes(pubkey), + FeeAddress: "DsdQFmH3azyoGKJHt2ArJNxi35LCEgMqi8k", + FeeCoin: dex.Bytes(feeCoin), + BrokenRule: account.Rule(byte(255)), + } + + w = httptest.NewRecorder() + r, _ = http.NewRequest("GET", "https://localhost/account/"+acctIDStr, nil) + r.RemoteAddr = "localhost" + + mux.ServeHTTP(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("apiAccounts returned code %d, expected %d", w.Code, http.StatusOK) + } + + exp := `{ + "accountid": "0a9912205b2cbab0c25c2de30bda9074de0ae23b065489a99199bad763f102cc", + "pubkey": "0204988a498d5d19514b217e872b4dbd1cf071d365c4879e64ed5919881c97eb19", + "feeaddress": "DsdQFmH3azyoGKJHt2ArJNxi35LCEgMqi8k", + "feecoin": "6e515ff861f2016fd0da2f3eccdf8290c03a9d116bfba2f6729e648bdc6e5aed00000005", + "brokenrule": 255 +} +` + if exp != w.Body.String() { + t.Errorf("unexpected response %q, wanted %q", w.Body.String(), exp) + } + + // acct id is not hex + w = httptest.NewRecorder() + r, _ = http.NewRequest("GET", "https://localhost/account/nothex", nil) + r.RemoteAddr = "localhost" + + mux.ServeHTTP(w, r) + + if w.Code != http.StatusBadRequest { + t.Fatalf("apiAccounts returned code %d, expected %d", w.Code, http.StatusBadRequest) + } + + // core.Account error + core.accountErr = errors.New("error") + + w = httptest.NewRecorder() + r, _ = http.NewRequest("GET", "https://localhost/account/"+acctIDStr, nil) + r.RemoteAddr = "localhost" + + mux.ServeHTTP(w, r) + + if w.Code != http.StatusInternalServerError { + t.Fatalf("apiAccounts returned code %d, expected %d", w.Code, http.StatusInternalServerError) + } +} diff --git a/server/db/driver/pg/accounts.go b/server/db/driver/pg/accounts.go index d1b9fe6a98..a5610e7244 100644 --- a/server/db/driver/pg/accounts.go +++ b/server/db/driver/pg/accounts.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" + "decred.org/dcrdex/dex" "decred.org/dcrdex/server/account" "decred.org/dcrdex/server/db" "decred.org/dcrdex/server/db/driver/pg/internal" @@ -53,12 +54,18 @@ func (a *Archiver) Accounts() ([]*db.Account, error) { } defer rows.Close() var accts []*db.Account + var accountID, pubkey, feeCoin []byte + var brokenRule byte for rows.Next() { a := new(db.Account) - err = rows.Scan(&a.AccountID, &a.Pubkey, &a.FeeAddress, &a.FeeCoin, &a.BrokenRule) + err = rows.Scan(&accountID, &pubkey, &a.FeeAddress, &feeCoin, &brokenRule) if err != nil { return nil, err } + copy(a.AccountID[:], accountID) + a.Pubkey = dex.Bytes(pubkey) + a.FeeCoin = dex.Bytes(feeCoin) + a.BrokenRule = account.Rule(brokenRule) accts = append(accts, a) } if err = rows.Err(); err != nil { @@ -67,6 +74,22 @@ func (a *Archiver) Accounts() ([]*db.Account, error) { return accts, nil } +// AccountInfo returns data for an account. +func (a *Archiver) AccountInfo(aid account.AccountID) (*db.Account, error) { + stmt := fmt.Sprintf(internal.SelectAccountInfo, a.tables.accounts) + acct := new(db.Account) + var accountID, pubkey, feeCoin []byte + var brokenRule byte + if err := a.db.QueryRow(stmt, aid).Scan(&accountID, &pubkey, &acct.FeeAddress, &feeCoin, &brokenRule); err != nil { + return nil, err + } + copy(acct.AccountID[:], accountID) + acct.Pubkey = dex.Bytes(pubkey) + acct.FeeCoin = dex.Bytes(feeCoin) + acct.BrokenRule = account.Rule(brokenRule) + return acct, nil +} + // CreateAccount creates an entry for a new account in the accounts table. A // DCR registration fee address is created and returned. func (a *Archiver) CreateAccount(acct *account.Account) (string, error) { diff --git a/server/db/driver/pg/accounts_online_test.go b/server/db/driver/pg/accounts_online_test.go index f466ee3a58..eb76b18b84 100644 --- a/server/db/driver/pg/accounts_online_test.go +++ b/server/db/driver/pg/accounts_online_test.go @@ -4,6 +4,7 @@ package pg import ( "encoding/hex" + "reflect" "testing" "decred.org/dcrdex/server/account" @@ -89,6 +90,14 @@ func TestAccounts(t *testing.T) { t.Fatal("accounts has unexpected data") } + anAcct, err := archie.AccountInfo(accts[0].AccountID) + if err != nil { + t.Fatalf("error getting account info: %v", err) + } + if !reflect.DeepEqual(accts[0], anAcct) { + t.Fatal("error getting account info: actual does not equal expected") + } + // Close the account for failure to complete a swap. archie.CloseAccount(tAcctID, account.FailureToAct) _, _, open = archie.Account(tAcctID) diff --git a/server/db/driver/pg/internal/accounts.go b/server/db/driver/pg/internal/accounts.go index 1167b4daed..4e8b47f78d 100644 --- a/server/db/driver/pg/internal/accounts.go +++ b/server/db/driver/pg/internal/accounts.go @@ -43,6 +43,10 @@ const ( // SelectAllAccounts retrieves all accounts. SelectAllAccounts = `SELECT * FROM %s;` + // SelectAccountInfo retrieves all fields for an account. + SelectAccountInfo = `SELECT * FROM %s + WHERE account_id = $1;` + // CreateAccount creates an entry for a new account. CreateAccount = `INSERT INTO %s (account_id, pubkey, fee_address) VALUES ($1, $2, $3);` diff --git a/server/db/interface.go b/server/db/interface.go index ebe2651c8f..e5319b94b6 100644 --- a/server/db/interface.go +++ b/server/db/interface.go @@ -182,6 +182,9 @@ type AccountArchiver interface { // Accounts returns data for all accounts. Accounts() ([]*Account, error) + + // AccountInfo returns data for an account. + AccountInfo(account.AccountID) (*Account, error) } // MatchData represents an order pair match, but with just the order IDs instead diff --git a/server/dex/dex.go b/server/dex/dex.go index f3fc256241..0be615c948 100644 --- a/server/dex/dex.go +++ b/server/dex/dex.go @@ -12,6 +12,7 @@ import ( "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/msgjson" + "decred.org/dcrdex/server/account" "decred.org/dcrdex/server/asset" dcrasset "decred.org/dcrdex/server/asset/dcr" "decred.org/dcrdex/server/auth" @@ -552,3 +553,8 @@ func (dm *DEX) SuspendMarket(name string, tSusp time.Time, persistBooks bool) *m func (dm *DEX) Accounts() ([]*db.Account, error) { return dm.storage.Accounts() } + +// AccountInfo returns data for an account. +func (dm *DEX) AccountInfo(aid account.AccountID) (*db.Account, error) { + return dm.storage.AccountInfo(aid) +} diff --git a/server/market/market_test.go b/server/market/market_test.go index 6c172e7a1d..46d478afa3 100644 --- a/server/market/market_test.go +++ b/server/market/market_test.go @@ -169,11 +169,12 @@ func (ta *TArchivist) CloseAccount(account.AccountID, account.Rule) {} func (ta *TArchivist) Account(account.AccountID) (acct *account.Account, paid, open bool) { return nil, false, false } -func (ta *TArchivist) CreateAccount(*account.Account) (string, error) { return "", nil } -func (ta *TArchivist) AccountRegAddr(account.AccountID) (string, error) { return "", nil } -func (ta *TArchivist) PayAccount(account.AccountID, []byte) error { return nil } -func (ta *TArchivist) Accounts() ([]*db.Account, error) { return nil, nil } -func (ta *TArchivist) Close() error { return nil } +func (ta *TArchivist) CreateAccount(*account.Account) (string, error) { return "", nil } +func (ta *TArchivist) AccountRegAddr(account.AccountID) (string, error) { return "", nil } +func (ta *TArchivist) PayAccount(account.AccountID, []byte) error { return nil } +func (ta *TArchivist) Accounts() ([]*db.Account, error) { return nil, nil } +func (ta *TArchivist) AccountInfo(account.AccountID) (*db.Account, error) { return nil, nil } +func (ta *TArchivist) Close() error { return nil } func randomOrderID() order.OrderID { pk := randomBytes(order.OrderIDSize)