diff --git a/server/account/account.go b/server/account/account.go index 8bb7b8e9d9..1a1b16b50e 100644 --- a/server/account/account.go +++ b/server/account/account.go @@ -3,6 +3,7 @@ package account import ( "database/sql/driver" "encoding/hex" + "encoding/json" "fmt" "decred.org/dcrdex/server/account/pki" @@ -31,6 +32,12 @@ func (aid AccountID) String() string { return hex.EncodeToString(aid[:]) } +// MarshalJSON satisfies the json.Marshaller interface, and will marshal the +// id to a hex string. +func (aid AccountID) MarshalJSON() ([]byte, error) { + return json.Marshal(aid.String()) +} + // Value implements the sql/driver.Valuer interface. func (aid AccountID) Value() (driver.Value, error) { return aid[:], nil // []byte diff --git a/server/admin/api.go b/server/admin/api.go index d35b71bcae..b7beedbdc1 100644 --- a/server/admin/api.go +++ b/server/admin/api.go @@ -146,3 +146,13 @@ func (s *Server) apiSuspend(w http.ResponseWriter, r *http.Request) { SuspendTime: APITime{suspEpoch.End}, }) } + +// apiAccounts is the handler for the '/accounts' API request. +func (s *Server) apiAccounts(w http.ResponseWriter, _ *http.Request) { + accts, err := s.core.Accounts() + if err != nil { + http.Error(w, fmt.Sprintf("failed to retrieve accounts: %v", err), http.StatusInternalServerError) + return + } + writeJSON(w, accts) +} diff --git a/server/admin/server.go b/server/admin/server.go index d4b47c3a0e..1b28ce8dea 100644 --- a/server/admin/server.go +++ b/server/admin/server.go @@ -17,6 +17,7 @@ import ( "sync" "time" + "decred.org/dcrdex/server/db" "decred.org/dcrdex/server/market" "github.com/decred/slog" "github.com/go-chi/chi" @@ -38,6 +39,7 @@ var ( // SvrCore is satisfied by server/dex.DEX. type SvrCore interface { + Accounts() ([]*db.Account, error) ConfigMsg() json.RawMessage MarketRunning(mktName string) (found, running bool) MarketStatus(mktName string) *market.Status @@ -118,6 +120,7 @@ func NewServer(cfg *SrvConfig) (*Server, error) { r.Use(middleware.AllowContentType("application/json")) r.Get("/ping", s.apiPing) r.Get("/config", s.apiConfig) + r.Get("/accounts", s.apiAccounts) 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 2e164e84de..ad272154f0 100644 --- a/server/admin/server_test.go +++ b/server/admin/server_test.go @@ -7,7 +7,9 @@ import ( "context" "crypto/elliptic" "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "fmt" "io/ioutil" "net/http" @@ -20,7 +22,10 @@ import ( "testing" "time" + "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/encode" + "decred.org/dcrdex/server/account" + "decred.org/dcrdex/server/db" "decred.org/dcrdex/server/market" "github.com/decred/dcrd/certgen" "github.com/decred/slog" @@ -41,7 +46,9 @@ type TMarket struct { } type TCore struct { - markets map[string]*TMarket + markets map[string]*TMarket + accounts []*db.Account + accountsErr error } func (c *TCore) ConfigMsg() json.RawMessage { return nil } @@ -130,6 +137,8 @@ func (w *tResponseWriter) WriteHeader(statusCode int) { w.code = statusCode } +func (c *TCore) Accounts() ([]*db.Account, error) { return c.accounts, c.accountsErr } + // genCertPair generates a key/cert pair to the paths provided. func genCertPair(certFile, keyFile string) error { log.Infof("Generating TLS certificates...") @@ -696,3 +705,92 @@ func TestAuthMiddleware(t *testing.T) { wantAuthError(test.name, test.wantErr) } } + +func TestAccounts(t *testing.T) { + core := &TCore{ + accounts: []*db.Account{}, + } + srv := &Server{ + core: core, + } + + mux := chi.NewRouter() + mux.Get("/accounts", srv.apiAccounts) + + // No accounts. + w := httptest.NewRecorder() + r, _ := http.NewRequest("GET", "https://localhost/accounts", 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 != "[]\n" { + t.Errorf("incorrect response body: %q", respBody) + } + + accountIDSlice, err := hex.DecodeString("0a9912205b2cbab0c25c2de30bda9074de0ae23b065489a99199bad763f102cc") + 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. + acct := &db.Account{ + AccountID: accountID, + Pubkey: dex.Bytes(pubkey), + FeeAddress: "DsdQFmH3azyoGKJHt2ArJNxi35LCEgMqi8k", + FeeCoin: dex.Bytes(feeCoin), + BrokenRule: account.Rule(byte(255)), + } + core.accounts = append(core.accounts, acct) + + w = httptest.NewRecorder() + r, _ = http.NewRequest("GET", "https://localhost/accounts", 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) + } + + // core.Accounts error + core.accountsErr = errors.New("error") + + w = httptest.NewRecorder() + r, _ = http.NewRequest("GET", "https://localhost/accounts", 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 0130bb270f..d1b9fe6a98 100644 --- a/server/db/driver/pg/accounts.go +++ b/server/db/driver/pg/accounts.go @@ -9,6 +9,7 @@ import ( "fmt" "decred.org/dcrdex/server/account" + "decred.org/dcrdex/server/db" "decred.org/dcrdex/server/db/driver/pg/internal" "github.com/decred/dcrd/chaincfg/chainhash" "github.com/decred/dcrd/dcrutil/v2" @@ -43,6 +44,29 @@ func (a *Archiver) Account(aid account.AccountID) (*account.Account, bool, bool) return acct, isPaid, isOpen } +// Accounts returns data for all accounts. +func (a *Archiver) Accounts() ([]*db.Account, error) { + stmt := fmt.Sprintf(internal.SelectAllAccounts, a.tables.accounts) + rows, err := a.db.Query(stmt) + if err != nil { + return nil, err + } + defer rows.Close() + var accts []*db.Account + for rows.Next() { + a := new(db.Account) + err = rows.Scan(&a.AccountID, &a.Pubkey, &a.FeeAddress, &a.FeeCoin, &a.BrokenRule) + if err != nil { + return nil, err + } + accts = append(accts, a) + } + if err = rows.Err(); err != nil { + return nil, err + } + return accts, 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 2d6d3821f6..f466ee3a58 100644 --- a/server/db/driver/pg/accounts_online_test.go +++ b/server/db/driver/pg/accounts_online_test.go @@ -77,6 +77,18 @@ func TestAccounts(t *testing.T) { t.Fatalf("newly paid account marked as closed") } + accts, err := archie.Accounts() + if err != nil { + t.Fatalf("error getting accounts: %v", err) + } + if accts[0].AccountID.String() != "0a9912205b2cbab0c25c2de30bda9074de0ae23b065489a99199bad763f102cc" || + accts[0].Pubkey.String() != "0204988a498d5d19514b217e872b4dbd1cf071d365c4879e64ed5919881c97eb19" || + accts[0].FeeAddress != "DsdQFmH3azyoGKJHt2ArJNxi35LCEgMqi8k" || + accts[0].FeeCoin.String() != "6e515ff861f2016fd0da2f3eccdf8290c03a9d116bfba2f6729e648bdc6e5aed00000005" || + byte(accts[0].BrokenRule) != byte(0) { + t.Fatal("accounts has unexpected data") + } + // 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 867fc599fa..1167b4daed 100644 --- a/server/db/driver/pg/internal/accounts.go +++ b/server/db/driver/pg/internal/accounts.go @@ -40,6 +40,9 @@ const ( FROM %s WHERE account_id = $1;` + // SelectAllAccounts retrieves all accounts. + SelectAllAccounts = `SELECT * FROM %s;` + // 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 08ef84d1fa..ebe2651c8f 100644 --- a/server/db/interface.go +++ b/server/db/interface.go @@ -179,6 +179,9 @@ type AccountArchiver interface { // PayAccount sets the registration fee payment transaction details for the // account, completing the registration process. PayAccount(account.AccountID, []byte) error + + // Accounts returns data for all accounts. + Accounts() ([]*Account, error) } // MatchData represents an order pair match, but with just the order IDs instead diff --git a/server/db/types.go b/server/db/types.go new file mode 100644 index 0000000000..f358b47d3f --- /dev/null +++ b/server/db/types.go @@ -0,0 +1,18 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package db + +import ( + "decred.org/dcrdex/dex" + "decred.org/dcrdex/server/account" +) + +// Account holds data returned by Accounts. +type Account struct { + AccountID account.AccountID `json:"accountid"` + Pubkey dex.Bytes `json:"pubkey"` + FeeAddress string `json:"feeaddress"` + FeeCoin dex.Bytes `json:"feecoin"` + BrokenRule account.Rule `json:"brokenrule"` +} diff --git a/server/dex/dex.go b/server/dex/dex.go index 14257c2cbc..f3fc256241 100644 --- a/server/dex/dex.go +++ b/server/dex/dex.go @@ -547,3 +547,8 @@ func (dm *DEX) SuspendMarket(name string, tSusp time.Time, persistBooks bool) *m // TODO: resume by relaunching the market subsystems (Run) // Resume / ResumeMarket + +// Accounts returns data for all accounts. +func (dm *DEX) Accounts() ([]*db.Account, error) { + return dm.storage.Accounts() +} diff --git a/server/market/market_test.go b/server/market/market_test.go index e50044ef75..6c172e7a1d 100644 --- a/server/market/market_test.go +++ b/server/market/market_test.go @@ -172,6 +172,7 @@ func (ta *TArchivist) Account(account.AccountID) (acct *account.Account, paid, o 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 randomOrderID() order.OrderID {