diff --git a/client/comms/wsconn.go b/client/comms/wsconn.go
index 04d8071b65..5b4984730a 100644
--- a/client/comms/wsconn.go
+++ b/client/comms/wsconn.go
@@ -41,6 +41,15 @@ const (
DefaultResponseTimeout = 30 * time.Second
)
+// ConnectionStatus represents the current status of the websocket connection.
+type ConnectionStatus uint32
+
+const (
+ Disconnected ConnectionStatus = iota
+ Connected
+ InvalidCert
+)
+
// ErrInvalidCert is the error returned when attempting to use an invalid cert
// to set up a ws connection.
var ErrInvalidCert = fmt.Errorf("invalid certificate")
@@ -88,7 +97,7 @@ type WsCfg struct {
//
// NOTE: Disconnect event notifications may lag behind actual
// disconnections.
- ConnectEventFunc func(bool)
+ ConnectEventFunc func(ConnectionStatus)
// Logger is the logger for the WsConn.
Logger dex.Logger
@@ -112,8 +121,7 @@ type wsConn struct {
wsMtx sync.Mutex
ws *websocket.Conn
- connectedMtx sync.RWMutex
- connected bool
+ connectionStatus uint32 // atomic
reqMtx sync.RWMutex
respHandlers map[uint64]*responseHandler
@@ -163,20 +171,16 @@ func NewWsConn(cfg *WsCfg) (WsConn, error) {
// IsDown indicates if the connection is known to be down.
func (conn *wsConn) IsDown() bool {
- conn.connectedMtx.RLock()
- defer conn.connectedMtx.RUnlock()
- return !conn.connected
+ return atomic.LoadUint32(&conn.connectionStatus) != uint32(Connected)
}
-// setConnected updates the connection's connected state and runs the
+// setConnectionStatus updates the connection's status and runs the
// ConnectEventFunc in case of a change.
-func (conn *wsConn) setConnected(connected bool) {
- conn.connectedMtx.Lock()
- statusChange := conn.connected != connected
- conn.connected = connected
- conn.connectedMtx.Unlock()
+func (conn *wsConn) setConnectionStatus(status ConnectionStatus) {
+ oldStatus := atomic.SwapUint32(&conn.connectionStatus, uint32(status))
+ statusChange := oldStatus != uint32(status)
if statusChange && conn.cfg.ConnectEventFunc != nil {
- conn.cfg.ConnectEventFunc(connected)
+ conn.cfg.ConnectEventFunc(status)
}
}
@@ -195,11 +199,13 @@ func (conn *wsConn) connect(ctx context.Context) error {
if err != nil {
var e x509.UnknownAuthorityError
if errors.As(err, &e) {
+ conn.setConnectionStatus(InvalidCert)
if conn.tlsCfg == nil {
return ErrCertRequired
}
return ErrInvalidCert
}
+ conn.setConnectionStatus(Disconnected)
return err
}
@@ -241,7 +247,7 @@ func (conn *wsConn) connect(ctx context.Context) error {
conn.ws = ws
conn.wsMtx.Unlock()
- conn.setConnected(true)
+ conn.setConnectionStatus(Connected)
conn.wg.Add(1)
go func() {
defer conn.wg.Done()
@@ -264,7 +270,7 @@ func (conn *wsConn) close() {
// run as a goroutine. Increment the wg before calling read.
func (conn *wsConn) read(ctx context.Context) {
reconnect := func() {
- conn.setConnected(false)
+ conn.setConnectionStatus(Disconnected)
conn.reconnectCh <- struct{}{}
}
@@ -406,17 +412,34 @@ func (conn *wsConn) Connect(ctx context.Context) (*sync.WaitGroup, error) {
var ctxInternal context.Context
ctxInternal, conn.cancel = context.WithCancel(ctx)
- conn.wg.Add(1)
+ err := conn.connect(ctxInternal)
+ if err != nil {
+ // If the certificate is invalid or missing, do not start the reconnect
+ // loop, and return an error with no WaitGroup.
+ if errors.Is(err, ErrInvalidCert) || errors.Is(err, ErrCertRequired) {
+ conn.cancel()
+ conn.wg.Wait() // probably a no-op
+ close(conn.readCh)
+ return nil, err
+ }
+
+ // The read loop would normally trigger keepAlive, but it wasn't started
+ // on account of a connect error.
+ conn.log.Errorf("Initial connection failed, starting reconnect loop: %v", err)
+ time.AfterFunc(5*time.Second, func() {
+ conn.reconnectCh <- struct{}{}
+ })
+ }
+
+ conn.wg.Add(2)
go func() {
defer conn.wg.Done()
conn.keepAlive(ctxInternal)
}()
-
- conn.wg.Add(1)
go func() {
defer conn.wg.Done()
<-ctxInternal.Done()
- conn.setConnected(false)
+ conn.setConnectionStatus(Disconnected)
conn.wsMtx.Lock()
if conn.ws != nil {
conn.log.Debug("Sending close 1000 (normal) message.")
@@ -427,16 +450,6 @@ func (conn *wsConn) Connect(ctx context.Context) (*sync.WaitGroup, error) {
close(conn.readCh) // signal to MessageSource receivers that the wsConn is dead
}()
- err := conn.connect(ctxInternal)
- if err != nil {
- // The read loop would normally trigger keepAlive, but it wasn't started
- // on account of a connect error.
- conn.log.Errorf("Initial connection failed, starting reconnect loop: %v", err)
- time.AfterFunc(5*time.Second, func() {
- conn.reconnectCh <- struct{}{}
- })
- }
-
return &conn.wg, err
}
diff --git a/client/core/account.go b/client/core/account.go
index 0d76e31233..3bf25050c6 100644
--- a/client/core/account.go
+++ b/client/core/account.go
@@ -2,6 +2,7 @@ package core
import (
"encoding/hex"
+ "errors"
"fmt"
"decred.org/dcrdex/client/db"
@@ -190,3 +191,26 @@ func (c *Core) AccountImport(pw []byte, acct Account) error {
return nil
}
+
+// UpdateCert attempts to connect to a server using a new TLS certificate. If
+// the connection is successful, then the cert in the database is updated.
+func (c *Core) UpdateCert(host string, cert []byte) error {
+ accountInfo, err := c.db.Account(host)
+ if err != nil {
+ return err
+ }
+
+ accountInfo.Cert = cert
+
+ _, connected := c.connectAccount(accountInfo)
+ if !connected {
+ return errors.New("failed to connect using new cert")
+ }
+
+ err = c.db.UpdateAccountInfo(accountInfo)
+ if err != nil {
+ return fmt.Errorf("failed to update account info: %w", err)
+ }
+
+ return nil
+}
diff --git a/client/core/account_test.go b/client/core/account_test.go
index 9e36985ac1..affe0143d9 100644
--- a/client/core/account_test.go
+++ b/client/core/account_test.go
@@ -9,6 +9,7 @@ import (
"testing"
"decred.org/dcrdex/client/db"
+ "decred.org/dcrdex/dex/encode"
"decred.org/dcrdex/dex/order"
"decred.org/dcrdex/server/account"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
@@ -179,6 +180,82 @@ func TestAccountDisable(t *testing.T) {
}
}
+func TestUpdateCert(t *testing.T) {
+ rig := newTestRig()
+ tCore := rig.core
+ rig.db.acct.Paid = true
+ rig.db.acct.FeeCoin = encode.RandomBytes(32)
+
+ tests := []struct {
+ name string
+ host string
+ acctErr bool
+ updateAccountInfoErr bool
+ queueConfig bool
+ expectError bool
+ }{
+ {
+ name: "ok",
+ host: rig.db.acct.Host,
+ queueConfig: true,
+ },
+ {
+ name: "connect error",
+ host: rig.db.acct.Host,
+ queueConfig: false,
+ expectError: true,
+ },
+ {
+ name: "db get account error",
+ host: rig.db.acct.Host,
+ queueConfig: true,
+ acctErr: true,
+ expectError: true,
+ },
+ {
+ name: "db update account err",
+ host: rig.db.acct.Host,
+ queueConfig: true,
+ updateAccountInfoErr: true,
+ expectError: true,
+ },
+ }
+
+ for _, test := range tests {
+ rig.db.verifyUpdateAccountInfo = false
+ if test.updateAccountInfoErr {
+ rig.db.updateAccountInfoErr = errors.New("")
+ } else {
+ rig.db.updateAccountInfoErr = nil
+ }
+ if test.acctErr {
+ rig.db.acctErr = errors.New("")
+ } else {
+ rig.db.acctErr = nil
+ }
+ randomCert := encode.RandomBytes(32)
+ if test.queueConfig {
+ rig.queueConfig()
+ }
+ err := tCore.UpdateCert(test.host, randomCert)
+ if test.expectError {
+ if err == nil {
+ t.Fatalf("%s: expected error but did not get", test.name)
+ }
+ continue
+ }
+ if err != nil {
+ t.Fatalf("%s: unexpected error: %v", test.name, err)
+ }
+ if !rig.db.verifyUpdateAccountInfo {
+ t.Fatalf("%s: expected update account to be called but it was not", test.name)
+ }
+ if !bytes.Equal(randomCert, rig.db.acct.Cert) {
+ t.Fatalf("%s: expected account to be updated with cert but it was not", test.name)
+ }
+ }
+}
+
func TestAccountExportPasswordError(t *testing.T) {
rig := newTestRig()
tCore := rig.core
diff --git a/client/core/core.go b/client/core/core.go
index 529b9cd06c..e9f1c1235d 100644
--- a/client/core/core.go
+++ b/client/core/core.go
@@ -134,8 +134,9 @@ type dexConnection struct {
epochMtx sync.RWMutex
epoch map[string]uint64
- // connected is a best guess on the ws connection status.
- connected uint32
+
+ // connectionStatus is a best guess on the ws connection status.
+ connectionStatus uint32
pendingFeeMtx sync.RWMutex
pendingFee *pendingFeeState
@@ -164,6 +165,11 @@ func (dc *dexConnection) running(mkt string) bool {
return mktCfg.Running()
}
+// status returns the status of the connection to the dex.
+func (dc *dexConnection) status() comms.ConnectionStatus {
+ return comms.ConnectionStatus(atomic.LoadUint32(&dc.connectionStatus))
+}
+
func (dc *dexConnection) feeAsset(assetID uint32) *msgjson.FeeAsset {
dc.cfgMtx.RLock()
defer dc.cfgMtx.RUnlock()
@@ -296,10 +302,10 @@ func (dc *dexConnection) exchangeInfo() *Exchange {
dc.cfgMtx.RUnlock()
if cfg == nil { // no config, assets, or markets data
return &Exchange{
- Host: dc.acct.host,
- AcctID: acctID,
- Connected: atomic.LoadUint32(&dc.connected) == 1,
- PendingFee: dc.getPendingFee(),
+ Host: dc.acct.host,
+ AcctID: acctID,
+ ConnectionStatus: dc.status(),
+ PendingFee: dc.getPendingFee(),
}
}
@@ -329,15 +335,15 @@ func (dc *dexConnection) exchangeInfo() *Exchange {
}
return &Exchange{
- Host: dc.acct.host,
- AcctID: acctID,
- Markets: dc.marketMap(),
- Assets: assets,
- Connected: atomic.LoadUint32(&dc.connected) == 1,
- Fee: dcrAsset,
- RegFees: feeAssets,
- PendingFee: dc.getPendingFee(),
- CandleDurs: cfg.BinSizes,
+ Host: dc.acct.host,
+ AcctID: acctID,
+ Markets: dc.marketMap(),
+ Assets: assets,
+ ConnectionStatus: dc.status(),
+ Fee: dcrAsset,
+ RegFees: feeAssets,
+ PendingFee: dc.getPendingFee(),
+ CandleDurs: cfg.BinSizes,
}
}
@@ -947,11 +953,10 @@ func (c *Core) dex(addr string) (*dexConnection, bool, error) {
c.connMtx.RLock()
dc, found := c.conns[host]
c.connMtx.RUnlock()
- connected := found && atomic.LoadUint32(&dc.connected) == 1
if !found {
return nil, false, fmt.Errorf("unknown DEX %s", addr)
}
- return dc, connected, nil
+ return dc, dc.status() == comms.Connected, nil
}
// Get the *dexConnection for the the host. Return an error if the DEX is not
@@ -1395,7 +1400,22 @@ func (c *Core) Network() dex.Network {
// Exchanges creates a map of *Exchange keyed by host, including markets and
// orders.
func (c *Core) Exchanges() map[string]*Exchange {
- return c.exchangeMap()
+ dcs := c.dexConnections()
+ infos := make(map[string]*Exchange, len(dcs))
+ for _, dc := range dcs {
+ infos[dc.acct.host] = dc.exchangeInfo()
+ }
+ return infos
+}
+
+// Exchange returns an exchange with a certain host. It returns an error if
+// no exchange exists at that host.
+func (c *Core) Exchange(host string) (*Exchange, error) {
+ dc, _, err := c.dex(host)
+ if err != nil {
+ return nil, err
+ }
+ return dc.exchangeInfo(), nil
}
// dexConnections creates a slice of the *dexConnection in c.conns.
@@ -1409,17 +1429,6 @@ func (c *Core) dexConnections() []*dexConnection {
return conns
}
-// exchangeMap creates a map of *Exchange keyed by host, including markets and
-// orders.
-func (c *Core) exchangeMap() map[string]*Exchange {
- dcs := c.dexConnections()
- infos := make(map[string]*Exchange, len(dcs))
- for _, dc := range dcs {
- infos[dc.acct.host] = dc.exchangeInfo()
- }
- return infos
-}
-
// wallet gets the wallet for the specified asset ID in a thread-safe way.
func (c *Core) wallet(assetID uint32) (*xcWallet, bool) {
c.walletMtx.RLock()
@@ -1693,7 +1702,7 @@ func (c *Core) assetMap() map[uint32]*SupportedAsset {
func (c *Core) User() *User {
return &User{
Assets: c.assetMap(),
- Exchanges: c.exchangeMap(),
+ Exchanges: c.Exchanges(),
Initialized: c.IsInitialized(),
SeedGenerationTime: c.seedGenerationTime,
}
@@ -4943,7 +4952,8 @@ func (c *Core) initialize() {
// connectAccount makes a connection to the DEX for the given account. If a
// non-nil dexConnection is returned, it was inserted into the conns map even if
// the initial connection attempt failed (connected == false), and the connect
-// retry / keepalive loop is active.
+// retry / keepalive loop is active. If there was already a dexConnection, it is
+// first stopped.
func (c *Core) connectAccount(acct *db.AccountInfo) (dc *dexConnection, connected bool) {
if !acct.Paid && len(acct.FeeCoin) == 0 {
// Register should have set this when creating the account that was
@@ -4958,6 +4968,23 @@ func (c *Core) connectAccount(acct *db.AccountInfo) (dc *dexConnection, connecte
c.log.Errorf("skipping loading of %s due to address parse error: %v", host, err)
return
}
+
+ c.connMtx.RLock()
+ if dc := c.conns[host]; dc != nil {
+ dc.connMaster.Disconnect()
+ dc.acct.lock()
+ dc.booksMtx.Lock()
+ for m, b := range dc.books {
+ b.closeFeeds()
+ if b.closeTimer != nil {
+ b.closeTimer.Stop()
+ }
+ delete(dc.books, m)
+ }
+ dc.booksMtx.Unlock()
+ } // leave it in the map so it remains listed if connectDEX fails
+ c.connMtx.RUnlock()
+
dc, err = c.connectDEX(acct)
if dc == nil {
c.log.Errorf("Cannot connect to DEX %s: %v", host, err)
@@ -5697,6 +5724,7 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo, temporary ...bool) (*dexConn
apiVer: -1,
reportingConnects: reporting,
spots: make(map[string]*msgjson.Spot),
+ connectionStatus: uint32(comms.Disconnected),
// On connect, must set: cfg, epoch, and assets.
}
@@ -5726,8 +5754,8 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo, temporary ...bool) (*dexConn
return nil, errors.New("a TLS connection is required when not using a hidden service")
}
- wsCfg.ConnectEventFunc = func(connected bool) {
- c.handleConnectEvent(dc, connected)
+ wsCfg.ConnectEventFunc = func(status comms.ConnectionStatus) {
+ c.handleConnectEvent(dc, status)
}
wsCfg.ReconnectSync = func() {
go c.handleReconnect(host)
@@ -5742,6 +5770,14 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo, temporary ...bool) (*dexConn
dc.WsConn = conn
dc.connMaster = dex.NewConnectionMaster(conn)
+ // At this point, we have a valid dexConnection object whether or not we can
+ // actually connect. In any return below, we return the dexConnection so it
+ // may be tracked in the c.conns map (and listed as a known DEX). TODO:
+ // split the above code into a dexConnection constructor, and the below into
+ // a startDexConnection function so we don't have the anti-pattern of
+ // returning a non-nil object with a non-nil error and requiring the caller
+ // to check both!
+
// Start listening for messages. The listener stops when core shuts down or
// the dexConnection's ConnectionMaster is shut down. This goroutine should
// be started as long as the reconnect loop is running. It only returns when
@@ -5881,11 +5917,9 @@ func (dc *dexConnection) broadcastingConnect() bool {
// lost or established.
//
// NOTE: Disconnect event notifications may lag behind actual disconnections.
-func (c *Core) handleConnectEvent(dc *dexConnection, connected bool) {
- var v uint32
+func (c *Core) handleConnectEvent(dc *dexConnection, status comms.ConnectionStatus) {
topic := TopicDEXDisconnected
- if connected {
- v = 1
+ if status == comms.Connected {
topic = TopicDEXConnected
} else {
for _, tracker := range dc.trackedTrades() {
@@ -5901,10 +5935,10 @@ func (c *Core) handleConnectEvent(dc *dexConnection, connected bool) {
tracker.mtx.Unlock()
}
}
- atomic.StoreUint32(&dc.connected, v)
+ atomic.StoreUint32(&dc.connectionStatus, uint32(status))
if dc.broadcastingConnect() {
subject, details := c.formatDetails(topic, dc.acct.host)
- dc.notify(newConnEventNote(topic, subject, dc.acct.host, connected, details, db.Poke))
+ dc.notify(newConnEventNote(topic, subject, dc.acct.host, status, details, db.Poke))
}
}
diff --git a/client/core/core_test.go b/client/core/core_test.go
index 632ca7b7ef..698231da46 100644
--- a/client/core/core_test.go
+++ b/client/core/core_test.go
@@ -269,7 +269,7 @@ func testDexConnection(ctx context.Context, crypter *tCrypter) (*dexConnection,
trades: make(map[order.OrderID]*trackedTrade),
epoch: map[string]uint64{tDcrBtcMktName: 0},
apiVer: serverdex.PreAPIVersion,
- connected: 1,
+ connectionStatus: uint32(comms.Connected),
reportingConnects: 1,
spots: make(map[string]*msgjson.Spot),
}, conn, acct
@@ -360,7 +360,7 @@ type TDB struct {
accountProofErr error
verifyAccountPaid bool
verifyCreateAccount bool
- verifyUpdateAccount bool
+ verifyUpdateAccountInfo bool
accountProofPersisted *db.AccountProof
disabledAcct *db.AccountInfo
disableAccountErr error
@@ -370,6 +370,7 @@ type TDB struct {
recryptErr error
deleteInactiveOrdersErr error
deleteInactiveMatchesErr error
+ updateAccountInfoErr error
}
func (tdb *TDB) Run(context.Context) {}
@@ -398,10 +399,10 @@ func (tdb *TDB) DisableAccount(url string) error {
return tdb.disableAccountErr
}
-func (tdb *TDB) UpdateAccount(ai *db.AccountInfo) error {
- tdb.verifyUpdateAccount = true
+func (tdb *TDB) UpdateAccountInfo(ai *db.AccountInfo) error {
+ tdb.verifyUpdateAccountInfo = true
tdb.acct = ai
- return nil
+ return tdb.updateAccountInfoErr
}
func (tdb *TDB) UpdateOrder(m *db.MetaOrder) error {
@@ -2753,12 +2754,12 @@ wait:
rig.dc.acct.unlock(rig.crypter)
// DEX not connected
- atomic.StoreUint32(&rig.dc.connected, 0)
+ atomic.StoreUint32(&rig.dc.connectionStatus, uint32(comms.Disconnected))
_, err = tCore.Trade(tPW, form)
if err == nil {
t.Fatalf("no error for disconnected dex")
}
- atomic.StoreUint32(&rig.dc.connected, 1)
+ atomic.StoreUint32(&rig.dc.connectionStatus, uint32(comms.Connected))
// No base asset
form.Base = 12345
diff --git a/client/core/notification.go b/client/core/notification.go
index 9b6be2e0f2..62936fd9e4 100644
--- a/client/core/notification.go
+++ b/client/core/notification.go
@@ -6,6 +6,7 @@ package core
import (
"fmt"
+ "decred.org/dcrdex/client/comms"
"decred.org/dcrdex/client/db"
"decred.org/dcrdex/dex"
"decred.org/dcrdex/dex/msgjson"
@@ -335,8 +336,8 @@ func (on *EpochNotification) String() string {
// ConnEventNote is a notification regarding individual DEX connection status.
type ConnEventNote struct {
db.Notification
- Host string `json:"host"`
- Connected bool `json:"connected"`
+ Host string `json:"host"`
+ ConnectionStatus comms.ConnectionStatus `json:"connectionStatus"`
}
const (
@@ -344,11 +345,11 @@ const (
TopicDEXDisconnected Topic = "DEXDisconnected"
)
-func newConnEventNote(topic Topic, subject, host string, connected bool, details string, severity db.Severity) *ConnEventNote {
+func newConnEventNote(topic Topic, subject, host string, status comms.ConnectionStatus, details string, severity db.Severity) *ConnEventNote {
return &ConnEventNote{
- Notification: db.NewNotification(NoteTypeConnEvent, topic, subject, details, severity),
- Host: host,
- Connected: connected,
+ Notification: db.NewNotification(NoteTypeConnEvent, topic, subject, details, severity),
+ Host: host,
+ ConnectionStatus: status,
}
}
diff --git a/client/core/trade_simnet_test.go b/client/core/trade_simnet_test.go
index d124444a87..59c484b955 100644
--- a/client/core/trade_simnet_test.go
+++ b/client/core/trade_simnet_test.go
@@ -45,6 +45,7 @@ import (
"decred.org/dcrdex/client/asset/doge"
"decred.org/dcrdex/client/asset/eth"
"decred.org/dcrdex/client/asset/ltc"
+ "decred.org/dcrdex/client/comms"
"decred.org/dcrdex/client/db"
"decred.org/dcrdex/dex"
"decred.org/dcrdex/dex/calc"
@@ -727,7 +728,7 @@ func TestOrderStatusReconciliation(t *testing.T) {
disconnectTimeout := 10 * sleepFactor * time.Second
disconnected := client2.notes.find(context.Background(), disconnectTimeout, func(n Notification) bool {
connNote, ok := n.(*ConnEventNote)
- return ok && connNote.Host == dexHost && !connNote.Connected
+ return ok && connNote.Host == dexHost && connNote.ConnectionStatus != comms.Connected
})
if !disconnected {
t.Fatalf("client 2 dex not disconnected after %v", disconnectTimeout)
diff --git a/client/core/types.go b/client/core/types.go
index a8d6c6c3da..20910f624c 100644
--- a/client/core/types.go
+++ b/client/core/types.go
@@ -11,6 +11,7 @@ import (
"sync"
"decred.org/dcrdex/client/asset"
+ "decred.org/dcrdex/client/comms"
"decred.org/dcrdex/client/db"
"decred.org/dcrdex/dex"
"decred.org/dcrdex/dex/calc"
@@ -498,15 +499,15 @@ type PendingFeeState struct {
// Exchange represents a single DEX with any number of markets.
type Exchange struct {
- Host string `json:"host"`
- AcctID string `json:"acctID"`
- Markets map[string]*Market `json:"markets"`
- Assets map[uint32]*dex.Asset `json:"assets"`
- Connected bool `json:"connected"`
- Fee *FeeAsset `json:"feeAsset"` // DEPRECATED. DCR.
- RegFees map[string]*FeeAsset `json:"regFees"`
- PendingFee *PendingFeeState `json:"pendingFee,omitempty"`
- CandleDurs []string `json:"candleDurs"`
+ Host string `json:"host"`
+ AcctID string `json:"acctID"`
+ Markets map[string]*Market `json:"markets"`
+ Assets map[uint32]*dex.Asset `json:"assets"`
+ ConnectionStatus comms.ConnectionStatus `json:"connectionStatus"`
+ Fee *FeeAsset `json:"feeAsset"` // DEPRECATED. DCR.
+ RegFees map[string]*FeeAsset `json:"regFees"`
+ PendingFee *PendingFeeState `json:"pendingFee,omitempty"`
+ CandleDurs []string `json:"candleDurs"`
}
// newDisplayID creates a display-friendly market ID for a base/quote ID pair.
diff --git a/client/db/bolt/db.go b/client/db/bolt/db.go
index cfe5a44534..258578fd4f 100644
--- a/client/db/bolt/db.go
+++ b/client/db/bolt/db.go
@@ -522,6 +522,19 @@ func (db *BoltDB) CreateAccount(ai *dexdb.AccountInfo) error {
})
}
+// UpdateAccountInfo updates the account info for an existing account with
+// the same Host as the parameter. If no account exists with this host,
+// an error is returned.
+func (db *BoltDB) UpdateAccountInfo(ai *dexdb.AccountInfo) error {
+ return db.acctsUpdate(func(accts *bbolt.Bucket) error {
+ acct := accts.Bucket([]byte(ai.Host))
+ if acct == nil {
+ return fmt.Errorf("account not found for %s", ai.Host)
+ }
+ return acct.Put(accountKey, ai.Encode())
+ })
+}
+
// deleteAccount removes the account by host.
func (db *BoltDB) deleteAccount(host string) error {
acctKey := []byte(host)
diff --git a/client/db/interface.go b/client/db/interface.go
index 05de76c931..c02dc989fb 100644
--- a/client/db/interface.go
+++ b/client/db/interface.go
@@ -34,6 +34,10 @@ type DB interface {
Account(host string) (*AccountInfo, error)
// CreateAccount saves the AccountInfo.
CreateAccount(ai *AccountInfo) error
+ // UpdateAccountInfo updates the account info for an existing account with
+ // the same Host as the parameter. If no account exists with this host,
+ // an error is returned.
+ UpdateAccountInfo(ai *AccountInfo) error
// DisableAccount sets the AccountInfo disabled status to true.
DisableAccount(host string) error
// AccountProof retrieves the AccountPoof value specified by url.
diff --git a/client/rpcserver/rpcserver_test.go b/client/rpcserver/rpcserver_test.go
index d393895e14..a8c8f745a8 100644
--- a/client/rpcserver/rpcserver_test.go
+++ b/client/rpcserver/rpcserver_test.go
@@ -84,6 +84,13 @@ func (c *TCore) CloseWallet(assetID uint32) error {
return c.closeWalletErr
}
func (c *TCore) Exchanges() (exchanges map[string]*core.Exchange) { return c.exchanges }
+func (c *TCore) Exchange(host string) (*core.Exchange, error) {
+ exchange, ok := c.exchanges[host]
+ if !ok {
+ return nil, fmt.Errorf("no exchange at %v", host)
+ }
+ return exchange, nil
+}
func (c *TCore) InitializeClient(pw, seed []byte) error {
return c.initializeClientErr
}
diff --git a/client/webserver/api.go b/client/webserver/api.go
index 44e04df521..a830bccd1a 100644
--- a/client/webserver/api.go
+++ b/client/webserver/api.go
@@ -400,6 +400,24 @@ func (s *WebServer) apiAccountImport(w http.ResponseWriter, r *http.Request) {
writeJSON(w, simpleAck(), s.indent)
}
+func (s *WebServer) apiUpdateCert(w http.ResponseWriter, r *http.Request) {
+ form := &struct {
+ Host string `json:"host"`
+ Cert string `json:"cert"`
+ }{}
+ if !readPost(w, r, form) {
+ return
+ }
+
+ err := s.core.UpdateCert(form.Host, []byte(form.Cert))
+ if err != nil {
+ s.writeAPIError(w, fmt.Errorf("error updating cert: %w", err))
+ return
+ }
+
+ writeJSON(w, simpleAck(), s.indent)
+}
+
// apiAccountDisable is the handler for the '/disableaccount' API request.
func (s *WebServer) apiAccountDisable(w http.ResponseWriter, r *http.Request) {
form := new(accountDisableForm)
diff --git a/client/webserver/http.go b/client/webserver/http.go
index 7db5045930..6a9cd4a5e9 100644
--- a/client/webserver/http.go
+++ b/client/webserver/http.go
@@ -244,6 +244,33 @@ func (s *WebServer) handleSettings(w http.ResponseWriter, r *http.Request) {
s.sendTemplate(w, "settings", data)
}
+// handleDexSettings is the handler for the '/dexsettings' page request.
+func (s *WebServer) handleDexSettings(w http.ResponseWriter, r *http.Request) {
+ host, err := getHostCtx(r)
+ if err != nil {
+ log.Errorf("error getting host ctx: %v", err)
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ exchange, err := s.core.Exchange(host)
+ if err != nil {
+ log.Errorf("error getting exchange: %v", err)
+ http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
+ return
+ }
+
+ data := &struct {
+ CommonArguments
+ Exchange *core.Exchange
+ }{
+ CommonArguments: *commonArgs(r, fmt.Sprintf("%v Settings | Decred DEX", host)),
+ Exchange: exchange,
+ }
+
+ s.sendTemplate(w, "dexsettings", data)
+}
+
type ordersTmplData struct {
CommonArguments
Assets map[uint32]*core.SupportedAsset
diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go
index a48a17763b..5208c5e982 100644
--- a/client/webserver/live_test.go
+++ b/client/webserver/live_test.go
@@ -25,6 +25,7 @@ import (
"decred.org/dcrdex/client/asset/btc"
"decred.org/dcrdex/client/asset/dcr"
"decred.org/dcrdex/client/asset/ltc"
+ "decred.org/dcrdex/client/comms"
"decred.org/dcrdex/client/core"
"decred.org/dcrdex/client/db"
"decred.org/dcrdex/dex"
@@ -303,7 +304,7 @@ var tExchanges = map[string]*core.Exchange{
mkid(22, 42): mkMrkt("mona", "dcr"),
mkid(28, 0): mkMrkt("vtc", "btc"),
},
- Connected: true,
+ ConnectionStatus: comms.Connected,
RegFees: map[string]*core.FeeAsset{
"dcr": {
ID: 42,
@@ -352,7 +353,7 @@ var tExchanges = map[string]*core.Exchange{
mkid(0, 2): mkMrkt("btc", "ltc"),
mkid(22, 141): mkMrkt("mona", "kmd"),
},
- Connected: true,
+ ConnectionStatus: comms.Connected,
RegFees: map[string]*core.FeeAsset{
"dcr": {
ID: 42,
@@ -494,6 +495,14 @@ func (c *TCore) Network() dex.Network { return dex.Mainnet }
func (c *TCore) Exchanges() map[string]*core.Exchange { return tExchanges }
+func (c *TCore) Exchange(host string) (*core.Exchange, error) {
+ exchange, ok := tExchanges[host]
+ if !ok {
+ return nil, fmt.Errorf("no exchange at %v", host)
+ }
+ return exchange, nil
+}
+
func (c *TCore) InitializeClient(pw, seed []byte) error {
randomDelay()
c.inited = true
@@ -1597,6 +1606,9 @@ func (c *TCore) WalletLogFilePath(uint32) (string, error) {
func (c *TCore) RecoverWallet(uint32, []byte, bool) error {
return nil
}
+func (c *TCore) UpdateCert(string, []byte) error {
+ return nil
+}
func TestServer(t *testing.T) {
// Register dummy drivers for unimplemented assets.
diff --git a/client/webserver/locales/en-us.go b/client/webserver/locales/en-us.go
index 90b9a09ba3..9316c90d8a 100644
--- a/client/webserver/locales/en-us.go
+++ b/client/webserver/locales/en-us.go
@@ -233,4 +233,7 @@ var EnUS = map[string]string{
"confirm_force_message": "This wallet is actively managing orders. After taking this action, it will take a long time to resync your wallet, potentially causing orders to fail. Only take this action if absolutely necessary!",
"confirm": "Confirm",
"cancel": "Cancel",
+ "Update TLS Certificate": "Update TLS Certificate",
+ "registered dexes": "Registered Dexes:",
+ "successful_cert_update": "Successfully updated certificate!",
}
diff --git a/client/webserver/middleware.go b/client/webserver/middleware.go
index f3e39fa494..43065ebbfe 100644
--- a/client/webserver/middleware.go
+++ b/client/webserver/middleware.go
@@ -19,6 +19,7 @@ type ctxID int
const (
ctxOID ctxID = iota
+ ctxHost
)
// securityMiddleware adds security headers to the server responses.
@@ -133,7 +134,29 @@ func (s *WebServer) requireDEXConnection(next http.Handler) http.Handler {
})
}
-// orderIDCtx embeds order ID into the request context
+// dexHostCtx embeds the host into the request context.
+func dexHostCtx(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ host := chi.URLParam(r, "host")
+ ctx := context.WithValue(r.Context(), ctxHost, host)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// getHostCtx interprets the context value at ctxHost as a string host.
+func getHostCtx(r *http.Request) (string, error) {
+ untypedHost := r.Context().Value(ctxHost)
+ if untypedHost == nil {
+ return "", errors.New("host not set in request")
+ }
+ host, ok := untypedHost.(string)
+ if !ok {
+ return "", errors.New("type assertion failed")
+ }
+ return host, nil
+}
+
+// orderIDCtx embeds order ID into the request context.
func orderIDCtx(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
oid := chi.URLParam(r, "oid")
diff --git a/client/webserver/site/src/css/application.scss b/client/webserver/site/src/css/application.scss
index 0f2265371e..48f3cabf6a 100644
--- a/client/webserver/site/src/css/application.scss
+++ b/client/webserver/site/src/css/application.scss
@@ -78,3 +78,4 @@ $grid-breakpoints: (
@import "./settings.scss";
@import "./forms.scss";
@import "./forms_dark.scss";
+@import "./dex_settings.scss";
diff --git a/client/webserver/site/src/css/dex_settings.scss b/client/webserver/site/src/css/dex_settings.scss
new file mode 100644
index 0000000000..40c211d904
--- /dev/null
+++ b/client/webserver/site/src/css/dex_settings.scss
@@ -0,0 +1,11 @@
+.connection {
+ color: green;
+}
+
+.update-cert-msg {
+ color: green;
+}
+
+.disconnected {
+ color: red;
+}
diff --git a/client/webserver/site/src/css/icons.scss b/client/webserver/site/src/css/icons.scss
index 65d7e86436..28105059ee 100644
--- a/client/webserver/site/src/css/icons.scss
+++ b/client/webserver/site/src/css/icons.scss
@@ -56,10 +56,6 @@
content: "\e902";
}
-.ico-rocket::before {
- content: "\e9a5";
-}
-
.ico-profile::before {
content: "\e903";
}
@@ -115,3 +111,11 @@
.ico-checkbox::before {
content: "\e90d";
}
+
+.ico-connection::before {
+ content: "\e90e";
+}
+
+.ico-rocket::before {
+ content: "\e90f";
+}
diff --git a/client/webserver/site/src/css/settings.scss b/client/webserver/site/src/css/settings.scss
index ea962ae73f..0ed3a2df3a 100644
--- a/client/webserver/site/src/css/settings.scss
+++ b/client/webserver/site/src/css/settings.scss
@@ -49,3 +49,8 @@ div.settings-exchange {
word-break: break-all;
user-select: all;
}
+
+.dex-settings-icon {
+ font-size: 15;
+ margin-left: 0.5rem;
+}
diff --git a/client/webserver/site/src/font/icomoon.svg b/client/webserver/site/src/font/icomoon.svg
index 2f280e1ee9..3c666bae38 100644
--- a/client/webserver/site/src/font/icomoon.svg
+++ b/client/webserver/site/src/font/icomoon.svg
@@ -25,8 +25,9 @@
[[[simultaneous_servers_msg]]]
@@ -74,11 +59,6 @@ {{template "confirmRegistrationForm"}} - {{- /* AUTHORIZE EXPORT ACCOUNT */ -}} - - {{- /* AUTHORIZE IMPORT ACCOUNT */ -}} - {{- /* DISABLE ACCOUNT */ -}} - - {{- /* CHANGE APP PASSWORD */ -}}