Repository: decred/dcrwallet
Pull request #2351 (2024-05-15 17:26:06)

Replace client-server with peer-to-peer mixing
This commit converts dcrwallet from the CoinShuffle++ mixing server to perform a similar protocol over peer-to-peer messages that propagate the Decred network.  Besides the swap between the different client libraries, this commit also adds support for sending and receiving mixing messages over RPC and SPV modes.

The existing --csppserver option will no longer enable mixing using the server.  Instead, the option has been deprecated in favor of a --mixing option.  However, until the option is removed, a configured --csppserver will imply using the new mixing protocol.

It is recommend, but not required, to build csppsolver and either install it to PATH, or provide the path to the executable with dcrwallet's --csppsolver option.  The solver is a necessary component to complete a mix, but only one
participant in the mix is required to provide it.  The solver requires the C library libflint (including its development headers, if your distribution creates separate -dev packages this way), and once these dependencies are met, csppsolver can be installed with:

```
  go install decred.org/cspp/v2/cmd/csppsolver@latest
```


Patch:
diff --git a/chain/backend.go b/chain/backend.go
index fceddaeb..3a56e1b5 100644
--- a/chain/backend.go
+++ b/chain/backend.go
@@ -10,6 +10,7 @@ import (
 	"github.com/decred/dcrd/chaincfg/chainhash"
 	"github.com/decred/dcrd/dcrutil/v4"
 	"github.com/decred/dcrd/gcs/v4"
+	"github.com/decred/dcrd/mixing"
 	dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v4"
 	"github.com/decred/dcrd/txscript/v4/stdaddr"
 	"github.com/decred/dcrd/wire"
@@ -37,6 +38,12 @@ func (s *Syncer) PublishTransactions(ctx context.Context, txs ...*wire.MsgTx) er
 	return s.rpc.PublishTransactions(ctx, txs...)
 }
 
+// PublishMixMessages submits each mixing message to the dcrd mixpool for acceptance.
+// If accepted, the messages are published to other peers.
+func (s *Syncer) PublishMixMessages(ctx context.Context, msgs ...mixing.Message) error {
+	return s.rpc.PublishMixMessages(ctx, msgs...)
+}
+
 // LoadTxFilter is part of the wallet.NetworkBackend interface.
 func (s *Syncer) LoadTxFilter(ctx context.Context, reload bool, addrs []stdaddr.Address, outpoints []wire.OutPoint) error {
 	return s.rpc.LoadTxFilter(ctx, reload, addrs, outpoints)
diff --git a/chain/sync.go b/chain/sync.go
index dc2fad74..120e0e32 100644
--- a/chain/sync.go
+++ b/chain/sync.go
@@ -10,6 +10,7 @@ import (
 	"crypto/x509"
 	"encoding/json"
 	"fmt"
+	"hash"
 	"net"
 	"runtime/trace"
 	"sync"
@@ -17,17 +18,19 @@ import (
 	"time"
 
 	"decred.org/dcrwallet/v4/errors"
+	"decred.org/dcrwallet/v4/internal/loggers"
 	"decred.org/dcrwallet/v4/rpc/client/dcrd"
 	"decred.org/dcrwallet/v4/validate"
 	"decred.org/dcrwallet/v4/wallet"
 	"github.com/decred/dcrd/blockchain/stake/v5"
 	"github.com/decred/dcrd/chaincfg/chainhash"
+	"github.com/decred/dcrd/crypto/blake256"
 	"github.com/decred/dcrd/wire"
 	"github.com/jrick/wsrpc/v2"
 	"golang.org/x/sync/errgroup"
 )
 
-var requiredAPIVersion = semver{Major: 8, Minor: 0, Patch: 0}
+var requiredAPIVersion = semver{Major: 8, Minor: 2, Patch: 0}
 
 // Syncer implements wallet synchronization services by processing
 // notifications from a dcrd JSON-RPC server.
@@ -40,6 +43,9 @@ type Syncer struct {
 	rpc      *dcrd.RPC
 	notifier *notifier
 
+	blake256Hasher   hash.Hash
+	blake256HasherMu sync.Mutex
+
 	discoverAccts bool
 	mu            sync.Mutex
 
@@ -68,10 +74,11 @@ type RPCOptions struct {
 // NewSyncer creates a Syncer that will sync the wallet using dcrd JSON-RPC.
 func NewSyncer(w *wallet.Wallet, r *RPCOptions) *Syncer {
 	return &Syncer{
-		wallet:        w,
-		opts:          r,
-		discoverAccts: !w.Locked(),
-		relevantTxs:   make(map[chainhash.Hash][]*wire.MsgTx),
+		wallet:         w,
+		opts:           r,
+		blake256Hasher: blake256.New(),
+		discoverAccts:  !w.Locked(),
+		relevantTxs:    make(map[chainhash.Hash][]*wire.MsgTx),
 	}
 }
 
@@ -625,6 +632,13 @@ func (s *Syncer) Run(ctx context.Context) (err error) {
 		return err
 	}
 
+	g, ctx := errgroup.WithContext(ctx)
+	g.Go(func() error {
+		// Run wallet background goroutines (currently, this just runs
+		// mixclient).
+		return s.wallet.Run(ctx)
+	})
+
 	if s.wallet.VotingEnabled() {
 		err = s.rpc.Call(ctx, "notifywinningtickets", nil)
 		if err != nil {
@@ -692,6 +706,29 @@ func (s *Syncer) Run(ctx context.Context) (err error) {
 		return err
 	}
 
+	// Request notifications for mixing messages.
+	if s.wallet.MixingEnabled() {
+		err = s.rpc.Call(ctx, "notifymixmessages", nil)
+		if err != nil {
+			return err
+		}
+
+		// Populate existing mix pair requests.
+		mixPRs, err := s.rpc.MixPairRequests(ctx)
+		if err != nil {
+			return err
+		}
+		s.blake256HasherMu.Lock()
+		for _, pr := range mixPRs {
+			pr.WriteHash(s.blake256Hasher)
+		}
+		s.blake256HasherMu.Unlock()
+		for _, pr := range mixPRs {
+			loggers.MixcLog.Debugf("accepting PR hash %s at initial sync", pr.Hash())
+			s.wallet.AcceptMixMessage(pr)
+		}
+	}
+
 	log.Infof("Blockchain sync completed, wallet ready for general usage.")
 
 	// Wait for notifications to finish before returning
@@ -699,13 +736,16 @@ func (s *Syncer) Run(ctx context.Context) (err error) {
 		<-s.notifier.closed
 	}()
 
-	select {
-	case <-ctx.Done():
-		client.Close()
-		return ctx.Err()
-	case <-client.Done():
-		return client.Err()
-	}
+	g.Go(func() error {
+		select {
+		case <-ctx.Done():
+			client.Close()
+			return ctx.Err()
+		case <-client.Done():
+			return client.Err()
+		}
+	})
+	return g.Wait()
 }
 
 type notifier struct {
@@ -749,6 +789,11 @@ func (n *notifier) Notify(method string, params json.RawMessage) error {
 		if err != nil {
 			log.Error(errors.E(op, err))
 		}
+	case "mixmessage":
+		err := s.mixMessage(ctx, params)
+		if err != nil {
+			log.Error(errors.E(op, err))
+		}
 	}
 	return nil
 }
@@ -872,3 +917,28 @@ func (s *Syncer) storeTSpend(ctx context.Context, params json.RawMessage) error
 	}
 	return s.wallet.AddTSpend(*tx)
 }
+
+func (s *Syncer) mixMessage(ctx context.Context, params json.RawMessage) error {
+	if !s.wallet.MixingEnabled() {
+		log.Debugf("Ignoring mixmessage notification: mixing disabled")
+		return nil
+	}
+
+	msg, err := dcrd.MixMessage(params)
+	if err != nil {
+		return err
+	}
+	s.blake256HasherMu.Lock()
+	msg.WriteHash(s.blake256Hasher)
+	s.blake256HasherMu.Unlock()
+	msgHash := msg.Hash()
+	err = s.wallet.AcceptMixMessage(msg)
+	if err == nil {
+		loggers.MixpLog.Debugf("Accepted mix message %T %s by %x",
+			msg, &msgHash, msg.Pub())
+	} else {
+		loggers.MixpLog.Debugf("Rejected mix message %T %s by %x",
+			msg, &msgHash, msg.Pub())
+	}
+	return err
+}
diff --git a/config.go b/config.go
index 45d968db..96d12ef8 100644
--- a/config.go
+++ b/config.go
@@ -7,8 +7,6 @@ package main
 
 import (
 	"context"
-	"crypto/tls"
-	"crypto/x509"
 	"fmt"
 	"net"
 	"os"
@@ -19,6 +17,7 @@ import (
 	"strconv"
 	"strings"
 
+	"decred.org/cspp/v2/solverrpc"
 	"decred.org/dcrwallet/v4/errors"
 	"decred.org/dcrwallet/v4/internal/cfgutil"
 	"decred.org/dcrwallet/v4/internal/loggers"
@@ -175,10 +174,11 @@ type config struct {
 	IssueClientCert   bool  `long:"issueclientcert" description:"Notify a client cert and key over the TX pipe for RPC authentication"`
 
 	// CSPP
-	CSPPServer         string `long:"csppserver" description:"Network address of CoinShuffle++ server"`
-	CSPPServerCA       string `long:"csppserver.ca" description:"CoinShuffle++ Certificate Authority"`
-	dialCSPPServer     func(ctx context.Context, network, addr string) (net.Conn, error)
-	MixedAccount       string `long:"mixedaccount" description:"Account/branch used to derive CoinShuffle++ mixed outputs and voting rewards"`
+	CSPPServer         string                  `long:"csppserver" description:"(deprecated) Network address of CoinShuffle++ server"`
+	CSPPServerCA       string                  `long:"csppserver.ca" description:"(deprecated) CoinShuffle++ TLS Certificate Authority"`
+	Mixing             bool                    `long:"mixing" description:"Enable mixing support"`
+	CSPPSolver         *cfgutil.ExplicitString `long:"csppsolver" description:"Path to CSPP solver executable (if not in PATH)"`
+	MixedAccount       string                  `long:"mixedaccount" description:"Account/branch used to derive CoinShuffle++ mixed outputs and voting rewards"`
 	mixedAccount       string
 	mixedBranch        uint32
 	TicketSplitAccount string `long:"ticketsplitaccount" description:"Account to derive fresh addresses from for mixed ticket splits; uses mixedaccount if unset"`
@@ -388,6 +388,7 @@ func loadConfig(ctx context.Context) (*config, []string, error) {
 		DisableCoinTypeUpgrades: defaultDisableCoinTypeUpgrades,
 		CircuitLimit:            defaultCircuitLimit,
 		MixSplitLimit:           defaultMixSplitLimit,
+		CSPPSolver:              cfgutil.NewExplicitString(solverrpc.SolverProcess),
 
 		// Ticket Buyer Options
 		TBOpts: ticketBuyerOptions{
@@ -774,38 +775,30 @@ func loadConfig(ctx context.Context) (*config, []string, error) {
 		}
 	}
 
-	// Create CoinShuffle++ TLS dialer based on server name and certificate
-	// authority settings.
-	csppTLSConfig := new(tls.Config)
 	if cfg.CSPPServer != "" {
-		csppTLSConfig.ServerName, _, err = net.SplitHostPort(cfg.CSPPServer)
-		if err != nil {
-			err := errors.Errorf("Cannot parse CoinShuffle++ "+
-				"server name %q: %v", cfg.CSPPServer, err)
-			fmt.Fprintln(os.Stderr, err.Error())
-			return loadConfigError(err)
+		log.Warnf("--csppserver option is deprecated; set --mixing instead")
+		if !cfg.Mixing {
+			log.Warnf("Enabling --mixing option due to --csppserver being configured")
+			cfg.Mixing = true
 		}
 	}
-	if cfg.CSPPServerCA != "" {
-		cfg.CSPPServerCA = cleanAndExpandPath(cfg.CSPPServerCA)
-		ca, err := os.ReadFile(cfg.CSPPServerCA)
-		if err != nil {
-			err := errors.Errorf("Cannot read CoinShuffle++ "+
-				"Certificate Authority file: %v", err)
-			fmt.Fprintln(os.Stderr, err.Error())
-			return loadConfigError(err)
+
+	var solverMustWork bool
+	if cfg.Mixing {
+		if cfg.CSPPSolver.ExplicitlySet() {
+			solverrpc.SolverProcess = cfg.CSPPSolver.Value
+			solverMustWork = true
+		} else if err := solverrpc.StartSolver(); err == nil {
+			solverMustWork = true
+		} else {
+			log.Warnf("Unable to start csppsolver; must rely on " +
+				"other peers publishing results")
 		}
-		pool := x509.NewCertPool()
-		pool.AppendCertsFromPEM(ca)
-		csppTLSConfig.RootCAs = pool
 	}
-	cfg.dialCSPPServer = func(ctx context.Context, network, addr string) (net.Conn, error) {
-		conn, err := cfg.dial(ctx, network, addr)
-		if err != nil {
-			return nil, err
-		}
-		conn = tls.Client(conn, csppTLSConfig)
-		return conn, nil
+	if solverMustWork && !testStartedSolverWorks() {
+		err := errors.Errorf("csppsolver process is not operating properly")
+		fmt.Fprintln(os.Stderr, err)
+		return loadConfigError(err)
 	}
 
 	// Parse mixedaccount account/branch
diff --git a/dcrwallet.go b/dcrwallet.go
index e3cf64a7..e8eed039 100644
--- a/dcrwallet.go
+++ b/dcrwallet.go
@@ -175,9 +175,8 @@ func run(ctx context.Context) error {
 	}
 	loader := ldr.NewLoader(activeNet.Params, dbDir, stakeOptions,
 		cfg.GapLimit, cfg.WatchLast, cfg.AllowHighFees, cfg.RelayFee.Amount,
-		cfg.AccountGapLimit, cfg.DisableCoinTypeUpgrades, cfg.ManualTickets,
-		cfg.MixSplitLimit)
-	loader.DialCSPPServer = cfg.dialCSPPServer
+		cfg.AccountGapLimit, cfg.DisableCoinTypeUpgrades, !cfg.Mixing,
+		cfg.ManualTickets, cfg.MixSplitLimit)
 
 	// Stop any services started by the loader after the shutdown procedure is
 	// initialized and this function returns.
@@ -259,7 +258,7 @@ func run(ctx context.Context) error {
 
 		if cfg.VSPOpts.URL != "" {
 			changeAccountName := cfg.ChangeAccount
-			if changeAccountName == "" && cfg.CSPPServer == "" {
+			if changeAccountName == "" && !cfg.Mixing {
 				log.Warnf("Change account not set, using "+
 					"purchase account %q", cfg.PurchaseAccount)
 				changeAccountName = cfg.PurchaseAccount
@@ -314,19 +313,19 @@ func run(ctx context.Context) error {
 		var (
 			purchaseAccount    uint32 // enableticketbuyer
 			votingAccount      uint32 // enableticketbuyer
-			mixedAccount       uint32 // (enableticketbuyer && csppserver) || mixchange
-			changeAccount      uint32 // (enableticketbuyer && csppserver) || mixchange
-			ticketSplitAccount uint32 // enableticketbuyer && csppserver
+			mixedAccount       uint32 // (enableticketbuyer && mixing) || mixchange
+			changeAccount      uint32 // (enableticketbuyer && mixing) || mixchange
+			ticketSplitAccount uint32 // enableticketbuyer && mixing
 
 			votingAddr  = cfg.TBOpts.votingAddress
 			poolFeeAddr = cfg.poolAddress
 		)
 		if cfg.EnableTicketBuyer {
 			purchaseAccount = lookup("purchaseaccount", cfg.PurchaseAccount)
-			if cfg.CSPPServer != "" {
+			if cfg.Mixing {
 				poolFeeAddr = nil
 			}
-			if cfg.CSPPServer != "" && cfg.TBOpts.VotingAccount == "" {
+			if cfg.Mixing && cfg.TBOpts.VotingAccount == "" {
 				err := errors.New("cannot run mixed ticketbuyer without --votingaccount")
 				log.Error(err)
 				return err
@@ -336,11 +335,11 @@ func run(ctx context.Context) error {
 				votingAddr = nil
 			}
 		}
-		if (cfg.EnableTicketBuyer && cfg.CSPPServer != "") || cfg.MixChange {
+		if (cfg.EnableTicketBuyer && cfg.Mixing) || cfg.MixChange {
 			mixedAccount = lookup("mixedaccount", cfg.mixedAccount)
 			changeAccount = lookup("changeaccount", cfg.ChangeAccount)
 		}
-		if cfg.EnableTicketBuyer && cfg.CSPPServer != "" {
+		if cfg.EnableTicketBuyer && cfg.Mixing {
 			ticketSplitAccount = lookup("ticketsplitaccount", cfg.TicketSplitAccount)
 		}
 		if err != nil {
@@ -358,8 +357,7 @@ func run(ctx context.Context) error {
 				c.PoolFeeAddr = poolFeeAddr
 				c.Limit = int(cfg.TBOpts.Limit)
 				c.VotingAccount = votingAccount
-				c.CSPPServer = cfg.CSPPServer
-				c.DialCSPPServer = cfg.dialCSPPServer
+				c.Mixing = cfg.Mixing
 				c.MixChange = cfg.MixChange
 				c.MixedAccount = mixedAccount
 				c.MixedAccountBranch = cfg.mixedBranch
@@ -400,7 +398,7 @@ func run(ctx context.Context) error {
 		// Start wallet, voting and network gRPC services after a
 		// wallet is loaded.
 		loader.RunAfterLoad(func(w *wallet.Wallet) {
-			rpcserver.StartWalletService(gRPCServer, w, cfg.dialCSPPServer)
+			rpcserver.StartWalletService(gRPCServer, w)
 			rpcserver.StartNetworkService(gRPCServer, w)
 			rpcserver.StartVotingService(gRPCServer, w)
 		})
diff --git a/go.mod b/go.mod
index fd37f5b5..5ffbe9bd 100644
--- a/go.mod
+++ b/go.mod
@@ -20,6 +20,7 @@ require (
 	github.com/decred/dcrd/dcrutil/v4 v4.0.2
 	github.com/decred/dcrd/gcs/v4 v4.1.0
 	github.com/decred/dcrd/hdkeychain/v3 v3.1.2
+	github.com/decred/dcrd/mixing v0.1.0
 	github.com/decred/dcrd/rpc/jsonrpc/types/v4 v4.2.0
 	github.com/decred/dcrd/rpcclient/v8 v8.0.1
 	github.com/decred/dcrd/txscript/v4 v4.1.1
@@ -34,9 +35,9 @@ require (
 	github.com/jrick/logrotate v1.0.0
 	github.com/jrick/wsrpc/v2 v2.3.5
 	go.etcd.io/bbolt v1.3.8
-	golang.org/x/crypto v0.14.0
-	golang.org/x/sync v0.3.0
-	golang.org/x/term v0.13.0
+	golang.org/x/crypto v0.23.0
+	golang.org/x/sync v0.7.0
+	golang.org/x/term v0.20.0
 	google.golang.org/grpc v1.45.0
 	google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.2.0
 	google.golang.org/protobuf v1.27.1
@@ -51,9 +52,9 @@ require (
 	github.com/decred/dcrd/dcrec/edwards/v2 v2.0.3 // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/klauspost/cpuid/v2 v2.2.5 // indirect
-	golang.org/x/net v0.17.0 // indirect
+	golang.org/x/net v0.25.0 // indirect
 	golang.org/x/sys v0.20.0 // indirect
-	golang.org/x/text v0.13.0 // indirect
+	golang.org/x/text v0.15.0 // indirect
 	google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 // indirect
 	lukechampine.com/blake3 v1.3.0 // indirect
 )
diff --git a/go.sum b/go.sum
index 44acea47..f3aee2e8 100644
--- a/go.sum
+++ b/go.sum
@@ -59,6 +59,8 @@ github.com/decred/dcrd/gcs/v4 v4.1.0 h1:tpW7JW53yJZlgNwl/n2NL1b8NxHaIPRUyNuLMkB/
 github.com/decred/dcrd/gcs/v4 v4.1.0/go.mod h1:nPTbGM/I3Ihe5KFvUmxZEqQP/jDZQjQ63+WEi/f4lqU=
 github.com/decred/dcrd/hdkeychain/v3 v3.1.2 h1:x25WuuE7zM/20EynuVMyOhL0K8BwGBBsexGq8xTiHFA=
 github.com/decred/dcrd/hdkeychain/v3 v3.1.2/go.mod h1:FnNJmZ7jqUDeAo6/c/xkQi5cuxh3EWtJeMmW6/Z8lcc=
+github.com/decred/dcrd/mixing v0.1.0 h1:XmRdBipQE7dSpY/5VAvI2EOqGu9bzAh2KzS0JSZ1CIs=
+github.com/decred/dcrd/mixing v0.1.0/go.mod h1:W3K7yJKmoI03G2U5Yw+HSRNe6lLBegi63ZR6fFLnM9c=
 github.com/decred/dcrd/rpc/jsonrpc/types/v4 v4.2.0 h1:BmxdaVwddO5UeYkWFO1JIZiQe8kE1DdNkohlLx9aDec=
 github.com/decred/dcrd/rpc/jsonrpc/types/v4 v4.2.0/go.mod h1:dDHO7ivrPAhZjFD3LoOJN/kdq5gi0sxie6zCsWHAiUo=
 github.com/decred/dcrd/rpcclient/v8 v8.0.1 h1:hd81e4w1KSqvPcozJlnz6XJfWKDNuahgooH/N5E8vOU=
@@ -134,8 +136,8 @@ go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
 go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
-golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
+golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -147,16 +149,18 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
-golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
-golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
+golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
-golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -165,11 +169,11 @@ golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
 golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
-golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
+golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
-golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
diff --git a/internal/loader/loader.go b/internal/loader/loader.go
index 96f94216..351ec9b8 100644
--- a/internal/loader/loader.go
+++ b/internal/loader/loader.go
@@ -7,7 +7,6 @@ package loader
 
 import (
 	"context"
-	"net"
 	"os"
 	"path/filepath"
 	"sync"
@@ -44,14 +43,13 @@ type Loader struct {
 	watchLast               uint32
 	accountGapLimit         int
 	disableCoinTypeUpgrades bool
+	disableMixing           bool
 	allowHighFees           bool
 	manualTickets           bool
 	relayFee                dcrutil.Amount
 	mixSplitLimit           int
 
 	mu sync.Mutex
-
-	DialCSPPServer DialFunc
 }
 
 // StakeOptions contains the various options necessary for stake mining.
@@ -63,15 +61,10 @@ type StakeOptions struct {
 	StakePoolColdExtKey string
 }
 
-// DialFunc provides a method to dial a network connection.
-// If the dialed network connection is secured by TLS, TLS
-// configuration is provided by the method, not the caller.
-type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
-
 // NewLoader constructs a Loader.
 func NewLoader(chainParams *chaincfg.Params, dbDirPath string, stakeOptions *StakeOptions, gapLimit uint32,
 	watchLast uint32, allowHighFees bool, relayFee dcrutil.Amount, accountGapLimit int,
-	disableCoinTypeUpgrades bool, manualTickets bool, mixSplitLimit int) *Loader {
+	disableCoinTypeUpgrades bool, disableMixing bool, manualTickets bool, mixSplitLimit int) *Loader {
 
 	return &Loader{
 		chainParams:             chainParams,
@@ -81,6 +74,7 @@ func NewLoader(chainParams *chaincfg.Params, dbDirPath string, stakeOptions *Sta
 		watchLast:               watchLast,
 		accountGapLimit:         accountGapLimit,
 		disableCoinTypeUpgrades: disableCoinTypeUpgrades,
+		disableMixing:           disableMixing,
 		allowHighFees:           allowHighFees,
 		manualTickets:           manualTickets,
 		relayFee:                relayFee,
@@ -190,6 +184,7 @@ func (l *Loader) CreateWatchingOnlyWallet(ctx context.Context, extendedPubKey st
 		WatchLast:               l.watchLast,
 		AccountGapLimit:         l.accountGapLimit,
 		DisableCoinTypeUpgrades: l.disableCoinTypeUpgrades,
+		DisableMixing:           l.disableMixing,
 		StakePoolColdExtKey:     so.StakePoolColdExtKey,
 		ManualTickets:           l.manualTickets,
 		AllowHighFees:           l.allowHighFees,
diff --git a/internal/loggers/loggers.go b/internal/loggers/loggers.go
index 42010f75..cc3a6a0d 100644
--- a/internal/loggers/loggers.go
+++ b/internal/loggers/loggers.go
@@ -52,6 +52,8 @@ var (
 	GrpcLog    = backendLog.Logger("GRPC")
 	JsonrpcLog = backendLog.Logger("RPCS")
 	CmgrLog    = backendLog.Logger("CMGR")
+	MixcLog    = backendLog.Logger("MIXC")
+	MixpLog    = backendLog.Logger("MIXP")
 	VspcLog    = backendLog.Logger("VSPC")
 )
 
diff --git a/internal/rpc/jsonrpc/config.go b/internal/rpc/jsonrpc/config.go
index de04bac4..51b3f822 100644
--- a/internal/rpc/jsonrpc/config.go
+++ b/internal/rpc/jsonrpc/config.go
@@ -19,8 +19,7 @@ type Options struct {
 	MaxPOSTClients      int64
 	MaxWebsocketClients int64
 
-	CSPPServer         string
-	DialCSPPServer     func(ctx context.Context, network, addr string) (net.Conn, error)
+	Mixing             bool
 	MixAccount         string
 	MixBranch          uint32
 	MixChangeAccount   string
diff --git a/internal/rpc/jsonrpc/methods.go b/internal/rpc/jsonrpc/methods.go
index 8e2c5813..d57cbc37 100644
--- a/internal/rpc/jsonrpc/methods.go
+++ b/internal/rpc/jsonrpc/methods.go
@@ -3349,14 +3349,14 @@ func (s *Server) purchaseTicket(ctx context.Context, icmd any) (any, error) {
 		dontSignTx = *cmd.DontSignTx
 	}
 
-	var csppServer string
+	var mixing bool
 	var mixedAccount uint32
 	var mixedAccountBranch uint32
 	var mixedSplitAccount uint32
 	var changeAccount = account
 
-	if s.cfg.CSPPServer != "" {
-		csppServer = s.cfg.CSPPServer
+	if s.cfg.Mixing {
+		mixing = true
 		mixedAccount, err = w.AccountNumber(ctx, s.cfg.MixAccount)
 		if err != nil {
 			return nil, rpcErrorf(dcrjson.ErrRPCInvalidParameter,
@@ -3411,8 +3411,7 @@ func (s *Server) purchaseTicket(ctx context.Context, icmd any) (any, error) {
 		VSPFees:       poolFee,
 
 		// CSPP
-		CSPPServer:         csppServer,
-		DialCSPPServer:     s.cfg.DialCSPPServer,
+		Mixing:             mixing,
 		MixedAccount:       mixedAccount,
 		MixedAccountBranch: mixedAccountBranch,
 		MixedSplitAccount:  mixedSplitAccount,
@@ -3541,7 +3540,7 @@ func makeOutputs(pairs map[string]dcrutil.Amount, chainParams *chaincfg.Params)
 // All errors are returned in dcrjson.RPCError format
 func (s *Server) sendPairs(ctx context.Context, w *wallet.Wallet, amounts map[string]dcrutil.Amount, account uint32, minconf int32) (string, error) {
 	changeAccount := account
-	if s.cfg.CSPPServer != "" && s.cfg.MixAccount != "" && s.cfg.MixChangeAccount != "" {
+	if s.cfg.Mixing && s.cfg.MixAccount != "" && s.cfg.MixChangeAccount != "" {
 		mixAccount, err := w.AccountNumber(ctx, s.cfg.MixAccount)
 		if err != nil {
 			return "", err
@@ -3577,7 +3576,7 @@ func (s *Server) sendPairs(ctx context.Context, w *wallet.Wallet, amounts map[st
 // returned in dcrjson.RPCError format
 func (s *Server) sendAmountToTreasury(ctx context.Context, w *wallet.Wallet, amount dcrutil.Amount, account uint32, minconf int32) (string, error) {
 	changeAccount := account
-	if s.cfg.CSPPServer != "" {
+	if s.cfg.Mixing {
 		mixAccount, err := w.AccountNumber(ctx, s.cfg.MixAccount)
 		if err != nil {
 			return "", err
@@ -5616,9 +5615,6 @@ func (s *Server) walletPassphraseChange(ctx context.Context, icmd any) (any, err
 
 func (s *Server) mixOutput(ctx context.Context, icmd any) (any, error) {
 	cmd := icmd.(*types.MixOutputCmd)
-	if s.cfg.CSPPServer == "" {
-		return nil, errors.E("CoinShuffle++ server is not configured")
-	}
 	w, ok := s.walletLoader.LoadedWallet()
 	if !ok {
 		return nil, errUnloadedWallet
@@ -5644,17 +5640,15 @@ func (s *Server) mixOutput(ctx context.Context, icmd any) (any, error) {
 		return nil, err
 	}
 
-	dial := s.cfg.DialCSPPServer
-	server := s.cfg.CSPPServer
 	mixBranch := s.cfg.MixBranch
 
-	err = w.MixOutput(ctx, dial, server, outpoint, changeAccount, mixAccount, mixBranch)
+	err = w.MixOutput(ctx, outpoint, changeAccount, mixAccount, mixBranch)
 	return nil, err
 }
 
 func (s *Server) mixAccount(ctx context.Context, icmd any) (any, error) {
-	if s.cfg.CSPPServer == "" {
-		return nil, errors.E("CoinShuffle++ server is not configured")
+	if s.cfg.Mixing {
+		return nil, errors.E("Mixing is not configured")
 	}
 	w, ok := s.walletLoader.LoadedWallet()
 	if !ok {
@@ -5676,11 +5670,9 @@ func (s *Server) mixAccount(ctx context.Context, icmd any) (any, error) {
 		return nil, err
 	}
 
-	dial := s.cfg.DialCSPPServer
-	server := s.cfg.CSPPServer
 	mixBranch := s.cfg.MixBranch
 
-	err = w.MixAccount(ctx, dial, server, changeAccount, mixAccount, mixBranch)
+	err = w.MixAccount(ctx, changeAccount, mixAccount, mixBranch)
 	return nil, err
 }
 
diff --git a/internal/rpc/rpcserver/server.go b/internal/rpc/rpcserver/server.go
index b9a42695..557b35e7 100644
--- a/internal/rpc/rpcserver/server.go
+++ b/internal/rpc/rpcserver/server.go
@@ -178,9 +178,8 @@ type versionServer struct {
 
 // walletServer provides wallet services for RPC clients.
 type walletServer struct {
-	ready          atomic.Uint32
-	wallet         *wallet.Wallet
-	dialCSPPServer func(ctx context.Context, network, addr string) (net.Conn, error)
+	ready  atomic.Uint32
+	wallet *wallet.Wallet
 	pb.UnimplementedWalletServiceServer
 }
 
@@ -326,12 +325,11 @@ func (*versionServer) Version(ctx context.Context, req *pb.VersionRequest) (*pb.
 type dialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
 
 // StartWalletService starts the WalletService.
-func StartWalletService(server *grpc.Server, wallet *wallet.Wallet, dialCSPP dialFunc) {
+func StartWalletService(server *grpc.Server, wallet *wallet.Wallet) {
 	if walletService.ready.Swap(1) != 0 {
 		panic("service already started")
 	}
 	walletService.wallet = wallet
-	walletService.dialCSPPServer = dialCSPP
 }
 
 func (s *walletServer) checkReady() bool {
@@ -1935,8 +1933,7 @@ func (s *walletServer) PurchaseTickets(ctx context.Context,
 		VotingAccount:    req.VotingAccount,
 
 		// CSPP
-		CSPPServer:         csppServer,
-		DialCSPPServer:     s.dialCSPPServer,
+		Mixing:             csppServer != "",
 		MixedAccount:       mixedAccount,
 		MixedAccountBranch: mixedAccountBranch,
 		MixedSplitAccount:  mixedSplitAccount,
@@ -2610,11 +2607,10 @@ func (t *accountMixerServer) RunAccountMixer(req *pb.RunAccountMixerRequest, svr
 
 	// Set ticketbuyerV2 config
 	tb.AccessConfig(func(c *ticketbuyer.Config) {
+		c.Mixing = req.CsppServer != ""
 		c.MixedAccountBranch = req.MixedAccountBranch
 		c.MixedAccount = req.MixedAccount
 		c.ChangeAccount = req.ChangeAccount
-		c.CSPPServer = req.CsppServer
-		c.DialCSPPServer = t.loader.DialCSPPServer
 		c.BuyTickets = false
 		c.MixChange = true
 	})
@@ -2772,10 +2768,9 @@ func (t *ticketbuyerV2Server) RunTicketBuyer(req *pb.RunTicketBuyerRequest, svr
 		c.PoolFeeAddr = poolAddress
 		c.PoolFees = req.PoolFees
 		c.VSP = vspClient
+		c.Mixing = csppServer != ""
 		c.MixedAccount = mixedAccount
 		c.MixChange = mixedChange
-		c.CSPPServer = csppServer
-		c.DialCSPPServer = t.loader.DialCSPPServer
 		c.ChangeAccount = changeAccount
 		c.MixedAccountBranch = mixedAccountBranch
 		c.TicketSplitAccount = mixedSplitAccount
diff --git a/log.go b/log.go
index db2e3799..c29f789f 100644
--- a/log.go
+++ b/log.go
@@ -19,6 +19,7 @@ import (
 	"decred.org/dcrwallet/v4/wallet"
 	"decred.org/dcrwallet/v4/wallet/udb"
 	"github.com/decred/dcrd/connmgr/v3"
+	"github.com/decred/dcrd/mixing/mixpool"
 	"github.com/decred/slog"
 )
 
@@ -36,6 +37,8 @@ func init() {
 	rpcserver.UseLogger(loggers.GrpcLog)
 	jsonrpc.UseLogger(loggers.JsonrpcLog)
 	connmgr.UseLogger(loggers.CmgrLog)
+	// XXX mixclient.UseLogger(loggers.MixcLog)
+	mixpool.UseLogger(loggers.MixpLog)
 }
 
 // subsystemLoggers maps each subsystem identifier to its associated logger.
@@ -49,6 +52,8 @@ var subsystemLoggers = map[string]slog.Logger{
 	"GRPC": loggers.GrpcLog,
 	"RPCS": loggers.JsonrpcLog,
 	"CMGR": loggers.CmgrLog,
+	"MIXC": loggers.MixcLog,
+	"MIXP": loggers.MixpLog,
 	"VSPC": loggers.VspcLog,
 }
 
diff --git a/p2p/peering.go b/p2p/peering.go
index c21ccac1..2bc9c8d2 100644
--- a/p2p/peering.go
+++ b/p2p/peering.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2018-2019 The Decred developers
+// Copyright (c) 2018-2023 The Decred developers
 // Use of this source code is governed by an ISC
 // license that can be found in the LICENSE file.
 
@@ -24,8 +24,10 @@ import (
 	"github.com/decred/dcrd/chaincfg/chainhash"
 	"github.com/decred/dcrd/chaincfg/v3"
 	"github.com/decred/dcrd/connmgr/v3"
+	"github.com/decred/dcrd/crypto/blake256"
 	"github.com/decred/dcrd/gcs/v4"
 	blockcf "github.com/decred/dcrd/gcs/v4/blockcf2"
+	"github.com/decred/dcrd/mixing"
 	"github.com/decred/dcrd/wire"
 	"github.com/decred/go-socks/socks"
 	"golang.org/x/sync/errgroup"
@@ -42,7 +44,7 @@ var uaVersion = version.String()
 const minPver = wire.RemoveRejectVersion
 
 // Pver is the maximum protocol version implemented by the LocalPeer.
-const Pver = wire.RemoveRejectVersion
+const Pver = wire.MixVersion
 
 // stallTimeout is the amount of time allowed before a request to receive data
 // that is known to exist at the RemotePeer times out with no matching reply.
@@ -93,8 +95,12 @@ type RemotePeer struct {
 	requestedBlocks   map[chainhash.Hash]*blockRequest
 
 	requestedCFiltersV2 sync.Map // k=chainhash.Hash v=chan<- *wire.MsgCFilterV2
-	requestedTxs        map[chainhash.Hash]chan<- *wire.MsgTx
-	requestedTxsMu      sync.Mutex
+
+	requestedTxs   map[chainhash.Hash]chan<- *wire.MsgTx
+	requestedTxsMu sync.Mutex
+
+	requestedMixMsgs   map[chainhash.Hash]chan<- mixing.Message
+	requestedMixMsgsMu sync.Mutex
 
 	// headers message management.  Headers can either be fetched synchronously
 	// or used to push block notifications with sendheaders.
@@ -132,6 +138,7 @@ type LocalPeer struct {
 	receivedInv       chan *inMsg
 	announcedHeaders  chan *inMsg
 	receivedInitState chan *inMsg
+	receivedMixMsg    chan *inMsg
 
 	extaddr        net.Addr
 	amgr           *addrmgr.AddrManager
@@ -153,6 +160,7 @@ func NewLocalPeer(params *chaincfg.Params, extaddr *net.TCPAddr, amgr *addrmgr.A
 		receivedInv:       make(chan *inMsg),
 		announcedHeaders:  make(chan *inMsg),
 		receivedInitState: make(chan *inMsg),
+		receivedMixMsg:    make(chan *inMsg),
 		extaddr:           extaddr,
 		amgr:              amgr,
 		chainParams:       params,
@@ -492,23 +500,24 @@ func handshake(ctx context.Context, lp *LocalPeer, id uint64, na *addrmgr.NetAdd
 	const op errors.Op = "p2p.handshake"
 
 	rp := &RemotePeer{
-		id:              id,
-		lp:              lp,
-		ua:              "",
-		services:        0,
-		pver:            Pver,
-		raddr:           c.RemoteAddr(),
-		na:              na,
-		c:               c,
-		mr:              msgReader{r: c, net: lp.chainParams.Net},
-		out:             nil,
-		outPrio:         nil,
-		pongs:           make(chan *wire.MsgPong, 1),
-		requestedBlocks: make(map[chainhash.Hash]*blockRequest),
-		requestedTxs:    make(map[chainhash.Hash]chan<- *wire.MsgTx),
-		invsSent:        lru.NewCache[chainhash.Hash](invLRUSize),
-		invsRecv:        lru.NewCache[chainhash.Hash](invLRUSize),
-		errc:            make(chan struct{}),
+		id:               id,
+		lp:               lp,
+		ua:               "",
+		services:         0,
+		pver:             Pver,
+		raddr:            c.RemoteAddr(),
+		na:               na,
+		c:                c,
+		mr:               msgReader{r: c, net: lp.chainParams.Net},
+		out:              nil,
+		outPrio:          nil,
+		pongs:            make(chan *wire.MsgPong, 1),
+		requestedBlocks:  make(map[chainhash.Hash]*blockRequest),
+		requestedTxs:     make(map[chainhash.Hash]chan<- *wire.MsgTx),
+		requestedMixMsgs: make(map[chainhash.Hash]chan<- mixing.Message),
+		invsSent:         lru.NewCache[chainhash.Hash](invLRUSize),
+		invsRecv:         lru.NewCache[chainhash.Hash](invLRUSize),
+		errc:             make(chan struct{}),
 	}
 
 	mw := msgWriter{c, lp.chainParams.Net}
@@ -758,6 +767,23 @@ func (rp *RemotePeer) readMessages(ctx context.Context) error {
 				pong(ctx, m, rp)
 			case *wire.MsgPong:
 				rp.receivedPong(ctx, m)
+
+			case *wire.MsgMixPairReq:
+				rp.receivedMixMsg(ctx, m)
+			case *wire.MsgMixKeyExchange:
+				rp.receivedMixMsg(ctx, m)
+			case *wire.MsgMixCiphertexts:
+				rp.receivedMixMsg(ctx, m)
+			case *wire.MsgMixSlotReserve:
+				rp.receivedMixMsg(ctx, m)
+			case *wire.MsgMixDCNet:
+				rp.receivedMixMsg(ctx, m)
+			case *wire.MsgMixConfirm:
+				rp.receivedMixMsg(ctx, m)
+			case *wire.MsgMixFactoredPoly:
+				rp.receivedMixMsg(ctx, m)
+			case *wire.MsgMixSecrets:
+				rp.receivedMixMsg(ctx, m)
 			}
 		}()
 	}
@@ -854,6 +880,19 @@ func (lp *LocalPeer) ReceiveHeadersAnnouncement(ctx context.Context) (*RemotePee
 	}
 }
 
+// ReceiveMixMessage waits for a mixing message from a remote peer, returning
+// the peer that sent the message, and the message itself.
+func (lp *LocalPeer) ReceiveMixMessage(ctx context.Context) (*RemotePeer, mixing.Message, error) {
+	select {
+	case <-ctx.Done():
+		return nil, nil, ctx.Err()
+	case r := <-lp.receivedMixMsg:
+		rp, msg := r.rp, r.msg.(mixing.Message)
+		recycleInMsg(r)
+		return rp, msg, nil
+	}
+}
+
 func (rp *RemotePeer) pingPong(ctx context.Context) {
 	nonce, err := wire.RandomUint64()
 	if err != nil {
@@ -933,6 +972,7 @@ func (rp *RemotePeer) deleteRequestedCFilterV2(hash *chainhash.Hash) {
 }
 
 func (rp *RemotePeer) receivedCFilterV2(ctx context.Context, msg *wire.MsgCFilterV2) {
+	log.Debugf("received cfilter for block %v", &msg.BlockHash)
 	const opf = "remotepeer(%v).receivedCFilterV2(%v)"
 	var k any = msg.BlockHash
 	v, ok := rp.requestedCFiltersV2.Load(k)
@@ -1085,6 +1125,54 @@ func (rp *RemotePeer) receivedTx(ctx context.Context, msg *wire.MsgTx) {
 	}
 }
 
+var (
+	blake256Hasher = blake256.New()
+	blake256Mu     sync.Mutex
+)
+
+func writeMixMsgHash(msg mixing.Message) chainhash.Hash {
+	blake256Mu.Lock()
+	defer blake256Mu.Unlock()
+
+	msg.WriteHash(blake256Hasher)
+	return msg.Hash()
+}
+
+func (rp *RemotePeer) addRequestedMixMsg(hash *chainhash.Hash, c chan<- mixing.Message) (newRequest bool) {
+	rp.requestedMixMsgsMu.Lock()
+	_, ok := rp.requestedMixMsgs[*hash]
+	if !ok {
+		rp.requestedMixMsgs[*hash] = c
+	}
+	rp.requestedMixMsgsMu.Unlock()
+	return !ok
+}
+
+func (rp *RemotePeer) deleteRequestedMixMsg(hash *chainhash.Hash) {
+	rp.requestedMixMsgsMu.Lock()
+	delete(rp.requestedMixMsgs, *hash)
+	rp.requestedMixMsgsMu.Unlock()
+}
+
+func (rp *RemotePeer) receivedMixMsg(ctx context.Context, msg mixing.Message) {
+	const opf = "remotepeer(%v).receivedMixMsg(%v)"
+	mixHash := writeMixMsgHash(msg)
+	rp.requestedMixMsgsMu.Lock()
+	c, ok := rp.requestedMixMsgs[mixHash]
+	delete(rp.requestedMixMsgs, mixHash)
+	rp.requestedMixMsgsMu.Unlock()
+	if !ok {
+		op := errors.Opf(opf, rp.raddr, &mixHash)
+		err := errors.E(op, errors.Protocol, "received unrequested mix msg")
+		rp.Disconnect(err)
+		return
+	}
+	select {
+	case <-ctx.Done():
+	case c <- msg:
+	}
+}
+
 func (rp *RemotePeer) receivedGetData(ctx context.Context, msg *wire.MsgGetData) {
 	if rp.banScore.Increase(0, uint32(len(msg.InvList))*banThreshold/wire.MaxInvPerMsg) > banThreshold {
 		log.Warnf("%v: ban score reached threshold", rp.RemoteAddr())
@@ -1410,6 +1498,76 @@ func (rp *RemotePeer) Transactions(ctx context.Context, hashes []*chainhash.Hash
 	return txs, nil
 }
 
+// MixMessages requests multiple mixing messages at a time from a RemotePeer
+// using a single getdata message.  It returns when all of the messages
+// and/or notfound messages have been received.  The same message may not be
+// requested multiple times concurrently from the same peer.  Returns
+// ErrNotFound with a slice of one or more nil messages if any notfound
+// messages are received for requested mix messages.
+func (rp *RemotePeer) MixMessages(ctx context.Context, hashes []*chainhash.Hash) ([]mixing.Message, error) {
+	const opf = "remotepeer(%v).MixMessages"
+
+	m := wire.NewMsgGetDataSizeHint(uint(len(hashes)))
+	cs := make([]chan mixing.Message, len(hashes))
+	for i, h := range hashes {
+		err := m.AddInvVect(wire.NewInvVect(wire.InvTypeMix, h))
+		if err != nil {
+			op := errors.Opf(opf, rp.raddr)
+			return nil, errors.E(op, err)
+		}
+		cs[i] = make(chan mixing.Message, 1)
+		if !rp.addRequestedMixMsg(h, cs[i]) {
+			for _, h := range hashes[:i] {
+				rp.deleteRequestedMixMsg(h)
+			}
+			op := errors.Opf(opf, rp.raddr)
+			return nil, errors.E(op, errors.Errorf("mix msg %v is already being requested from this peer", h))
+		}
+	}
+	select {
+	case <-ctx.Done():
+		for _, h := range hashes {
+			rp.deleteRequestedMixMsg(h)
+		}
+		return nil, ctx.Err()
+	case rp.out <- &msgAck{m, nil}:
+	}
+	msgs := make([]mixing.Message, len(hashes))
+	var notfound bool
+	stalled := time.NewTimer(stallTimeout)
+	for i := 0; i < len(hashes); i++ {
+		select {
+		case <-ctx.Done():
+			go func() {
+				<-stalled.C
+				for _, h := range hashes[i:] {
+					rp.deleteRequestedMixMsg(h)
+				}
+			}()
+			return nil, ctx.Err()
+		case <-stalled.C:
+			for _, h := range hashes[i:] {
+				rp.deleteRequestedMixMsg(h)
+			}
+			op := errors.Opf(opf, rp.raddr)
+			err := errors.E(op, errors.IO, "peer appears stalled")
+			rp.Disconnect(err)
+			return nil, err
+		case <-rp.errc:
+			stalled.Stop()
+			return nil, rp.err
+		case m, ok := <-cs[i]:
+			msgs[i] = m
+			notfound = notfound || !ok
+		}
+	}
+	stalled.Stop()
+	if notfound {
+		return msgs, ErrNotFound
+	}
+	return msgs, nil
+}
+
 // CFilterV2 requests a version 2 regular compact filter from a RemotePeer
 // using getcfilterv2.  The same block can not be requested concurrently from
 // the same peer.
@@ -1748,17 +1906,48 @@ func (rp *RemotePeer) HeadersAsync(ctx context.Context, blockLocators []*chainha
 // hashes of txs.
 func (rp *RemotePeer) PublishTransactions(ctx context.Context, txs ...*wire.MsgTx) error {
 	const opf = "remotepeer(%v).PublishTransactions"
-	msg := wire.NewMsgInvSizeHint(uint(len(txs)))
+	inv := wire.NewMsgInvSizeHint(uint(len(txs)))
 	for i := range txs {
 		txHash := txs[i].TxHash()
 		rp.invsSent.Add(txHash)
-		err := msg.AddInvVect(wire.NewInvVect(wire.InvTypeTx, &txHash))
+		err := inv.AddInvVect(wire.NewInvVect(wire.InvTypeTx, &txHash))
+		if err != nil {
+			op := errors.Opf(opf, rp.raddr)
+			return errors.E(op, errors.Protocol, err)
+		}
+	}
+	err := rp.SendMessage(ctx, inv)
+	if err != nil {
+		op := errors.Opf(opf, rp.raddr)
+		return errors.E(op, err)
+	}
+	return nil
+}
+
+// PublishTransactions pushes an inventory message advertising transaction
+// hashes of txs.
+func (rp *RemotePeer) PublishMixMessages(ctx context.Context, msgs ...mixing.Message) error {
+	const opf = "remotepeer(%v).PublishMixMessages"
+
+	if rp.pver < wire.MixVersion {
+		op := errors.Opf(opf, rp.raddr)
+		err := errors.Errorf("protocol version %v is too low to publish mix messages",
+			rp.pver)
+		return errors.E(op, errors.Protocol, err)
+	}
+
+	inv := wire.NewMsgInvSizeHint(uint(len(msgs)))
+	for _, msg := range msgs {
+		msgHash := msg.Hash() // Must be type chainhash.Hash
+		log.Debugf("Publishing inv for mix message %v", msgHash)
+		rp.invsSent.Add(msgHash)
+		err := inv.AddInvVect(wire.NewInvVect(wire.InvTypeMix, &msgHash))
 		if err != nil {
 			op := errors.Opf(opf, rp.raddr)
 			return errors.E(op, errors.Protocol, err)
 		}
 	}
-	err := rp.SendMessage(ctx, msg)
+	err := rp.SendMessage(ctx, inv)
 	if err != nil {
 		op := errors.Opf(opf, rp.raddr)
 		return errors.E(op, err)
diff --git a/rpc/client/dcrd/calls.go b/rpc/client/dcrd/calls.go
index fc6b515b..ef71cc08 100644
--- a/rpc/client/dcrd/calls.go
+++ b/rpc/client/dcrd/calls.go
@@ -17,6 +17,7 @@ import (
 	"github.com/decred/dcrd/chaincfg/chainhash"
 	"github.com/decred/dcrd/dcrutil/v4"
 	"github.com/decred/dcrd/gcs/v4"
+	"github.com/decred/dcrd/mixing"
 	dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v4"
 	"github.com/decred/dcrd/txscript/v4/stdaddr"
 	"github.com/decred/dcrd/wire"
@@ -221,6 +222,46 @@ func (r *RPC) PublishTransactions(ctx context.Context, txs ...*wire.MsgTx) error
 	return nil
 }
 
+func (r *RPC) publishMixMessage(ctx context.Context, op errors.Op, msg mixing.Message) error {
+	var b strings.Builder
+	err := msg.BtcEncode(hex.NewEncoder(&b), wire.MixVersion)
+	if err != nil {
+		return errors.E(op, errors.Encoding, err)
+	}
+	return r.Call(ctx, "sendrawmixmessage", nil, msg.Command(), b.String())
+}
+
+// PublishMixMessages submits each mixing message to the dcrd mixpool for acceptance.
+// If accepted, the messages are published to other peers.
+func (r *RPC) PublishMixMessages(ctx context.Context, msgs ...mixing.Message) error {
+	const op errors.Op = "dcrd.PublishMixMessages"
+
+	var firstErr error
+	for _, msg := range msgs {
+		err := r.publishMixMessage(ctx, op, msg)
+		if err != nil && firstErr == nil {
+			firstErr = err
+		}
+	}
+	if firstErr != nil {
+		return errors.E(op, firstErr)
+	}
+	return nil
+}
+
+// MixPairRequests returns all mixing pair request messages currently held by
+// the dcrd mixpool.
+func (r *RPC) MixPairRequests(ctx context.Context) ([]*wire.MsgMixPairReq, error) {
+	const op errors.Op = "dcrd.MixPairRequests"
+
+	mixPRs := makeMessageArray[wire.MsgMixPairReq](wire.MixVersion)
+	err := r.Call(ctx, "getmixpairrequests", &mixPRs)
+	if err != nil {
+		return nil, errors.E(op, err)
+	}
+	return mixPRs.Messages, nil
+}
+
 // Blocks returns the blocks for each block hash.
 func (r *RPC) Blocks(ctx context.Context, blockHashes []*chainhash.Hash) ([]*wire.MsgBlock, error) {
 	const op errors.Op = "dcrd.Blocks"
diff --git a/rpc/client/dcrd/json.go b/rpc/client/dcrd/json.go
index e2d1086d..ada22330 100644
--- a/rpc/client/dcrd/json.go
+++ b/rpc/client/dcrd/json.go
@@ -279,3 +279,52 @@ func (t *transactions) UnmarshalJSON(j []byte) error {
 	}
 	return nil
 }
+
+type msgPtr[T any] interface {
+	*T
+	wire.Message
+}
+
+type messageArray[T any, P msgPtr[T]] struct {
+	pver     uint32
+	Messages []P
+}
+
+func makeMessageArray[T any, P msgPtr[T]](pver uint32) messageArray[T, P] {
+	var msgArray messageArray[T, P]
+	msgArray.pver = pver
+	return msgArray
+}
+
+func (m *messageArray[T, P]) UnmarshalJSON(j []byte) error {
+	if bytes.Equal(j, []byte("null")) {
+		m.Messages = nil
+		return nil
+	}
+	var array []json.RawMessage
+	err := json.Unmarshal(j, &array)
+	if err != nil {
+		return err
+	}
+	m.Messages = make([]P, len(array))
+	for i, j := range array {
+		if len(j) < 2 || j[0] != '"' || j[len(j)-1] != '"' {
+			return errors.E(errors.Encoding, "not a string")
+		}
+		m.Messages[i] = new(T)
+		err = m.Messages[i].BtcDecode(hex.NewDecoder(bytes.NewReader(j[1:len(j)-1])), m.pver)
+		if err != nil {
+			return errors.E(errors.Encoding, err)
+		}
+	}
+	return nil
+}
+
+type buffer struct {
+	Buffer bytes.Buffer
+}
+
+func (b *buffer) Deserialize(r io.Reader) error {
+	_, err := b.Buffer.ReadFrom(r)
+	return err
+}
diff --git a/rpc/client/dcrd/notifications.go b/rpc/client/dcrd/notifications.go
index f899d376..57d166e0 100644
--- a/rpc/client/dcrd/notifications.go
+++ b/rpc/client/dcrd/notifications.go
@@ -9,6 +9,7 @@ import (
 
 	"decred.org/dcrwallet/v4/errors"
 	"github.com/decred/dcrd/chaincfg/chainhash"
+	"github.com/decred/dcrd/mixing"
 	"github.com/decred/dcrd/wire"
 )
 
@@ -73,3 +74,42 @@ func TSpend(params json.RawMessage) (tx *wire.MsgTx, err error) {
 	err = unmarshalArray(params, unhex(tx))
 	return
 }
+
+// MixMessage extracts the mixing message from a mixmessage JSON-RPC
+// notification.
+func MixMessage(params json.RawMessage) (msg mixing.Message, err error) {
+	// Parameters (array):
+	// 0: wire command string
+	// 1: hex-encoded serialized message
+	var command string
+	var messageBytes buffer
+	err = unmarshalArray(params, &command, unhex(&messageBytes))
+	if err != nil {
+		return nil, err
+	}
+
+	switch command {
+	case wire.CmdMixPairReq:
+		msg = new(wire.MsgMixPairReq)
+	case wire.CmdMixKeyExchange:
+		msg = new(wire.MsgMixKeyExchange)
+	case wire.CmdMixCiphertexts:
+		msg = new(wire.MsgMixCiphertexts)
+	case wire.CmdMixSlotReserve:
+		msg = new(wire.MsgMixSlotReserve)
+	case wire.CmdMixDCNet:
+		msg = new(wire.MsgMixDCNet)
+	case wire.CmdMixConfirm:
+		msg = new(wire.MsgMixConfirm)
+	case wire.CmdMixFactoredPoly:
+		msg = new(wire.MsgMixFactoredPoly)
+	case wire.CmdMixSecrets:
+		msg = new(wire.MsgMixSecrets)
+	default:
+		err = errors.E("unrecognized mixing message command string")
+		return nil, err
+	}
+
+	err = msg.BtcDecode(&messageBytes.Buffer, wire.MixVersion)
+	return msg, err
+}
diff --git a/rpcserver.go b/rpcserver.go
index cebc8867..009dcd12 100644
--- a/rpcserver.go
+++ b/rpcserver.go
@@ -366,8 +366,7 @@ func startRPCServers(walletLoader *loader.Loader) (*grpc.Server, *jsonrpc.Server
 			Password:            pass,
 			MaxPOSTClients:      cfg.LegacyRPCMaxClients,
 			MaxWebsocketClients: cfg.LegacyRPCMaxWebsockets,
-			CSPPServer:          cfg.CSPPServer,
-			DialCSPPServer:      cfg.dialCSPPServer,
+			Mixing:              cfg.Mixing,
 			MixAccount:          cfg.mixedAccount,
 			MixBranch:           cfg.mixedBranch,
 			MixChangeAccount:    cfg.ChangeAccount,
diff --git a/sample-dcrwallet.conf b/sample-dcrwallet.conf
index f40a563e..88d680cd 100644
--- a/sample-dcrwallet.conf
+++ b/sample-dcrwallet.conf
@@ -86,13 +86,8 @@
 ; CoinShuffle++ settings
 ; ------------------------------------------------------------------------------
 
-; Network address of CoinShuffle++ server. When connecting through Tor via a
-; SOCKS5 proxy, an .onion address can be specified.
-; csppserver=
-
-; File containing the CA certificate of the CoinShuffle++ server.  If unset,
-; the default trusted roots will be used.
-; csppserver.ca=
+; Enable the mixing client (required for any mixing).
+; mixing=0
 
 ; Account/branch used to derive CoinShuffle++ mixed outputs and voting rewards.
 ; mixedaccount=
diff --git a/spv/backend.go b/spv/backend.go
index da20e6c4..4ccfeafb 100644
--- a/spv/backend.go
+++ b/spv/backend.go
@@ -18,6 +18,7 @@ import (
 	"github.com/decred/dcrd/chaincfg/chainhash"
 	"github.com/decred/dcrd/dcrutil/v4"
 	"github.com/decred/dcrd/gcs/v4"
+	"github.com/decred/dcrd/mixing"
 	"github.com/decred/dcrd/txscript/v4/stdaddr"
 	"github.com/decred/dcrd/wire"
 	"golang.org/x/sync/errgroup"
@@ -426,6 +427,51 @@ func (s *Syncer) PublishTransactions(ctx context.Context, txs ...*wire.MsgTx) er
 	})
 }
 
+// PublishMixMessages implements the PublishMixMessages method of the
+// wallet.NetworkBackend interface.
+func (s *Syncer) PublishMixMessages(ctx context.Context, msgs ...mixing.Message) error {
+	const op errors.Op = "spv.PublishMixMessages"
+
+	// When we inventory a KE, also add our own PR hash from our KE
+	// message to recently-inventoried message LRU, so they can be queried
+	// by nodes that learn of them through the KE.
+	var ownPRs []*chainhash.Hash
+
+	msg := wire.NewMsgInvSizeHint(uint(len(msgs)))
+	for _, mixMsg := range msgs {
+		mixMsgHash := mixMsg.Hash()
+		err := msg.AddInvVect(wire.NewInvVect(wire.InvTypeMix, &mixMsgHash))
+		if err != nil {
+			return errors.E(op, errors.Protocol, err)
+		}
+		if ke, ok := mixMsg.(*wire.MsgMixKeyExchange); ok {
+			ownPRs = append(ownPRs, &ke.SeenPRs[ke.Pos])
+		}
+	}
+	var mixingPeers int
+	err := s.forRemotes(func(rp *p2p.RemotePeer) error {
+		if rp.Pver() < wire.MixVersion {
+			return nil
+		}
+		mixingPeers++
+		for _, inv := range msg.InvList {
+			rp.InvsSent().Add(inv.Hash)
+		}
+		for _, prHash := range ownPRs {
+			rp.InvsSent().Add(*prHash)
+		}
+		return rp.SendMessage(ctx, msg)
+	})
+	if err != nil {
+		return errors.E(op, err)
+	}
+	if mixingPeers == 0 {
+		s := "no connected peers support the mixing protocol version"
+		return errors.E(op, errors.Protocol, s)
+	}
+	return nil
+}
+
 // Rescan implements the Rescan method of the wallet.NetworkBackend interface.
 func (s *Syncer) Rescan(ctx context.Context, blockHashes []chainhash.Hash, save func(*chainhash.Hash, []*wire.MsgTx) error) error {
 	const op errors.Op = "spv.Rescan"
diff --git a/spv/sync.go b/spv/sync.go
index 5a148182..2abf19ea 100644
--- a/spv/sync.go
+++ b/spv/sync.go
@@ -22,6 +22,8 @@ import (
 	"github.com/decred/dcrd/chaincfg/chainhash"
 	"github.com/decred/dcrd/dcrec/secp256k1/v4/schnorr"
 	"github.com/decred/dcrd/gcs/v4/blockcf2"
+	"github.com/decred/dcrd/mixing"
+	"github.com/decred/dcrd/mixing/mixpool"
 	"github.com/decred/dcrd/txscript/v4"
 	"github.com/decred/dcrd/wire"
 	"golang.org/x/sync/errgroup"
@@ -63,11 +65,12 @@ type Syncer struct {
 	filterData   blockcf2.Entries
 	filterMu     sync.Mutex
 
-	// seenTxs records hashes of received inventoried transactions.  Once a
-	// transaction is fetched and processed from one peer, the hash is added to
-	// this cache to avoid fetching it again from other peers that announce the
-	// transaction.
-	seenTxs lru.Cache[chainhash.Hash]
+	// These LRU caches record hashes of received inventoried messages.
+	// Once a message is fetched and processed from one peer, the hash is
+	// added to the cache to avoid fetching it again from other peers that
+	// also announce it.
+	seenTxs     lru.Cache[chainhash.Hash]
+	seenMixMsgs lru.Cache[chainhash.Hash]
 
 	// Sidechain management
 	sidechains  wallet.SidechainForest
@@ -122,6 +125,7 @@ func NewSyncer(w *wallet.Wallet, lp *p2p.LocalPeer) *Syncer {
 		remotes:           make(map[string]*p2p.RemotePeer),
 		rescanFilter:      wallet.NewRescanFilter(nil, nil),
 		seenTxs:           lru.NewCache[chainhash.Hash](2000),
+		seenMixMsgs:       lru.NewCache[chainhash.Hash](2000),
 		lp:                lp,
 		mempoolAdds:       make(chan *chainhash.Hash),
 		initialSyncDone:   make(chan struct{}),
@@ -343,6 +347,7 @@ func (s *Syncer) Run(ctx context.Context) error {
 	g.Go(func() error { return s.receiveGetData(ctx) })
 	g.Go(func() error { return s.receiveInv(ctx) })
 	g.Go(func() error { return s.receiveHeadersAnnouncements(ctx) })
+	g.Go(func() error { return s.receiveMixMsgs(ctx) })
 	s.lp.AddHandledMessages(p2p.MaskGetData | p2p.MaskInv)
 
 	if len(s.persistentPeers) != 0 {
@@ -396,6 +401,12 @@ func (s *Syncer) Run(ctx context.Context) error {
 		return nil
 	})
 
+	// Run wallet background goroutines (currently, this just runs
+	// mixclient).
+	g.Go(func() error {
+		return s.wallet.Run(ctx)
+	})
+
 	// Wait until cancellation or a handler errors.
 	return g.Wait()
 }
@@ -627,6 +638,7 @@ func (s *Syncer) receiveGetData(ctx context.Context) error {
 			defer wg.Done()
 			// Ensure that the data was (recently) announced using an inv.
 			var txHashes []*chainhash.Hash
+			var mixHashes []*chainhash.Hash
 			var notFound []*wire.InvVect
 			for _, inv := range msg.InvList {
 				if !rp.InvsSent().Contains(inv.Hash) {
@@ -636,6 +648,12 @@ func (s *Syncer) receiveGetData(ctx context.Context) error {
 				switch inv.Type {
 				case wire.InvTypeTx:
 					txHashes = append(txHashes, &inv.Hash)
+				case wire.InvTypeMix:
+					if !s.wallet.MixingEnabled() {
+						notFound = append(notFound, inv)
+						continue
+					}
+					mixHashes = append(mixHashes, &inv.Hash)
 				default:
 					notFound = append(notFound, inv)
 				}
@@ -665,6 +683,20 @@ func (s *Syncer) receiveGetData(ctx context.Context) error {
 				}
 			}
 
+			// Search for requested mix messages
+			var foundMixMsgs []mixing.Message
+			if len(mixHashes) != 0 {
+				for _, hash := range mixHashes {
+					msg, err := s.wallet.MixMessage(hash)
+					if err != nil {
+						invvect := wire.NewInvVect(wire.InvTypeMix, hash)
+						notFound = append(notFound, invvect)
+						continue
+					}
+					foundMixMsgs = append(foundMixMsgs, msg)
+				}
+			}
+
 			// Send all found transactions
 			for _, tx := range foundTxs {
 				err := rp.SendMessage(ctx, tx)
@@ -677,6 +709,18 @@ func (s *Syncer) receiveGetData(ctx context.Context) error {
 				}
 			}
 
+			// Send all found mix messages
+			for _, msg := range foundMixMsgs {
+				err := rp.SendMessage(ctx, msg)
+				if ctx.Err() != nil {
+					return
+				}
+				if err != nil {
+					log.Warnf("Failed to send getdata reply to peer %v: %v",
+						rp.RemoteAddr(), err)
+				}
+			}
+
 			// Send notfound message for all missing or unannounced data.
 			if len(notFound) != 0 {
 				err := rp.SendMessage(ctx, &wire.MsgNotFound{InvList: notFound})
@@ -705,12 +749,17 @@ func (s *Syncer) receiveInv(ctx context.Context) error {
 
 		var blocks []*chainhash.Hash
 		var txs []*chainhash.Hash
+		var mixMsgs []*chainhash.Hash
 		for _, inv := range msg.InvList {
 			switch inv.Type {
 			case wire.InvTypeBlock:
 				blocks = append(blocks, &inv.Hash)
 			case wire.InvTypeTx:
 				txs = append(txs, &inv.Hash)
+			case wire.InvTypeMix:
+				if s.wallet.MixingEnabled() {
+					mixMsgs = append(mixMsgs, &inv.Hash)
+				}
 			}
 		}
 
@@ -740,6 +789,13 @@ func (s *Syncer) receiveInv(ctx context.Context) error {
 				wg.Done()
 			}()
 		}
+		if len(mixMsgs) != 0 {
+			wg.Add(1)
+			go func() {
+				s.handleMixInvs(ctx, rp, mixMsgs, nil)
+				wg.Done()
+			}()
+		}
 	}
 }
 
@@ -959,6 +1015,90 @@ func (s *Syncer) handleTxInvs(ctx context.Context, rp *p2p.RemotePeer, hashes []
 	s.mempoolTxs(relevant)
 }
 
+// handleMixInvs responds to the inv message created by rp by fetching all
+// unseen mix messages announced by the peer.  Any messages not known to the
+// wallet's mixpool are requested from the announcing peer.
+func (s *Syncer) handleMixInvs(ctx context.Context, rp *p2p.RemotePeer, hashes []*chainhash.Hash,
+	onlyByID map[[33]byte]struct{}) {
+
+	const opf = "spv.handleMixInvs(%v)"
+
+	// Ignore already-processed messages
+	unseen := hashes[:0]
+	for _, h := range hashes {
+		if !s.seenMixMsgs.Contains(*h) {
+			unseen = append(unseen, h)
+		}
+	}
+	if len(unseen) == 0 {
+		return
+	}
+
+	msgs, err := rp.MixMessages(ctx, unseen)
+	if errors.Is(err, errors.NotExist) {
+		err = nil
+		// Remove notfound txs.
+		prevMsgs, prevUnseen := msgs, unseen
+		msgs, unseen = msgs[:0], unseen[:0]
+		for i, msg := range prevMsgs {
+			if msg != nil {
+				msgs = append(msgs, msg)
+				unseen = append(unseen, prevUnseen[i])
+			}
+		}
+	}
+	if err != nil {
+		if ctx.Err() == nil {
+			op := errors.Opf(opf, rp.RemoteAddr())
+			err := errors.E(op, err)
+			log.Warn(err)
+		}
+		return
+	}
+
+	// Mark messages as processed so they are not queried from other nodes
+	// who announce them in the future.
+	for _, h := range unseen {
+		s.seenMixMsgs.Add(*h)
+	}
+
+	requestUnknownPRs := make(map[chainhash.Hash]struct{})
+	unknownPRIDs := make(map[[33]byte]struct{})
+
+	// Accept mix messages to the wallet's mixpool.  If any KE was an
+	// orphan and does not reference its own PR, request the previous
+	// messages as well.
+	for _, msg := range msgs {
+		if len(onlyByID) != 0 {
+			if _, ok := onlyByID[[33]byte(msg.Pub())]; !ok {
+				continue
+			}
+		}
+
+		err := s.wallet.AcceptMixMessage(msg)
+		var missingPRErr *mixpool.MissingOwnPRError
+		if errors.As(err, &missingPRErr) {
+			ke := msg.(*wire.MsgMixKeyExchange)
+			log.Debugf("will request unknown PR from %x", ke.Identity[:])
+			requestUnknownPRs[missingPRErr.MissingPR] = struct{}{}
+			unknownPRIDs[ke.Identity] = struct{}{}
+		} else if err != nil {
+			op := errors.Opf(opf, rp.RemoteAddr())
+			log.Warn(errors.E(op, err))
+		}
+	}
+
+	if len(requestUnknownPRs) > 0 {
+		requestUnknownPRs := make(map[chainhash.Hash]struct{})
+		unknownPRs := make([]*chainhash.Hash, 0, len(requestUnknownPRs))
+		for hash := range requestUnknownPRs {
+			hash := hash
+			unknownPRs = append(unknownPRs, &hash)
+		}
+		s.handleMixInvs(ctx, rp, unknownPRs, unknownPRIDs)
+	}
+}
+
 // receiveHeaderAnnouncements receives all block announcements through pushed
 // headers messages messages from peers and starts goroutines to handle the
 // announced header.
@@ -988,6 +1128,34 @@ func (s *Syncer) receiveHeadersAnnouncements(ctx context.Context) error {
 	}
 }
 
+// receiveMixMsgs receives all mixing messages from peers and starts goroutines
+// to handle the message acceptance.
+func (s *Syncer) receiveMixMsgs(ctx context.Context) error {
+	for {
+		rp, msg, err := s.lp.ReceiveMixMessage(ctx)
+		if err != nil {
+			return err
+		}
+
+		go func() {
+			err := s.handleMixMessage(ctx, rp, msg)
+			if err != nil {
+				if ctx.Err() != nil {
+					return
+				}
+
+				if errors.Is(err, errors.Protocol) || errors.Is(err, errors.Consensus) {
+					log.Warnf("Disconnecting peer %v: %v", rp, err)
+					rp.Disconnect(err)
+					return
+				}
+
+				log.Warnf("Failed to handle mix message announced by %v: %v", rp, err)
+			}
+		}()
+	}
+}
+
 // scanChain checks for matching filters of chain and returns a map of
 // relevant wallet transactions keyed by block hash.  bmap is queried
 // for the block first with fallback to querying rp using getdata.
@@ -1632,7 +1800,7 @@ func (s *Syncer) peerStartup(ctx context.Context, rp *p2p.RemotePeer) error {
 	if rp.Pver() >= wire.InitStateVersion {
 		err := s.GetInitState(ctx, rp)
 		if err != nil {
-			log.Errorf("Failed to get init state", err)
+			log.Errorf("Failed to get init state: %v", err)
 		}
 	}
 
@@ -1689,3 +1857,15 @@ func (s *Syncer) handleMempool(ctx context.Context) error {
 		}
 	}
 }
+
+// handleMixMessage handles mixing messages announced by peers.  It adds the
+// messages to the wallet's mixing pool, thereby allowing the wallet to perform
+// the mixing protocol.
+func (s *Syncer) handleMixMessage(ctx context.Context, rp *p2p.RemotePeer, msg mixing.Message) (err error) {
+	err = s.wallet.AcceptMixMessage(msg)
+	if err != nil {
+		return err
+	}
+
+	return
+}
diff --git a/ticketbuyer/tb.go b/ticketbuyer/tb.go
index 1e9aa3ab..7d9c6581 100644
--- a/ticketbuyer/tb.go
+++ b/ticketbuyer/tb.go
@@ -6,7 +6,6 @@ package ticketbuyer
 
 import (
 	"context"
-	"net"
 	"runtime/trace"
 	"sync"
 
@@ -46,8 +45,7 @@ type Config struct {
 	Limit int
 
 	// CSPP-related options
-	CSPPServer         string
-	DialCSPPServer     func(ctx context.Context, network, addr string) (net.Conn, error)
+	Mixing             bool
 	MixedAccount       uint32
 	MixedAccountBranch uint32
 	TicketSplitAccount uint32
@@ -170,7 +168,7 @@ func (tb *TB) Run(ctx context.Context, passphrase []byte) error {
 			tb.mu.Unlock()
 
 			multiple := 1
-			if cfg.CSPPServer != "" {
+			if cfg.Mixing {
 				multiple = cfg.Limit
 				cfg.Limit = 1
 			}
@@ -246,14 +244,18 @@ func (tb *TB) buy(ctx context.Context, passphrase []byte, tip *wire.BlockHeader,
 	poolFeeAddr := cfg.PoolFeeAddr
 	poolFees := cfg.PoolFees
 	limit := cfg.Limit
-	csppServer := cfg.CSPPServer
-	dialCSPPServer := cfg.DialCSPPServer
+	mixing := cfg.Mixing
 	votingAccount := cfg.VotingAccount
 	mixedAccount := cfg.MixedAccount
 	mixedBranch := cfg.MixedAccountBranch
 	splitAccount := cfg.TicketSplitAccount
 	changeAccount := cfg.ChangeAccount
 
+	minconf := int32(minconf)
+	if mixing {
+		minconf = 2
+	}
+
 	sdiff, err := w.NextStakeDifficultyAfterHeader(ctx, tip)
 	if err != nil {
 		return err
@@ -284,7 +286,7 @@ func (tb *TB) buy(ctx context.Context, passphrase []byte, tip *wire.BlockHeader,
 	} else {
 		buy = int(w.ChainParams().MaxFreshStakePerBlock)
 	}
-	if limit == 0 && csppServer != "" {
+	if limit == 0 && mixing {
 		buy = 1
 	} else if limit > 0 && buy > limit {
 		buy = limit
@@ -298,8 +300,7 @@ func (tb *TB) buy(ctx context.Context, passphrase []byte, tip *wire.BlockHeader,
 		Expiry:        expiry,
 
 		// CSPP
-		CSPPServer:         csppServer,
-		DialCSPPServer:     dialCSPPServer,
+		Mixing:             mixing,
 		VotingAccount:      votingAccount,
 		MixedAccount:       mixedAccount,
 		MixedAccountBranch: mixedBranch,
@@ -336,19 +337,18 @@ func (tb *TB) AccessConfig(f func(cfg *Config)) {
 
 func (tb *TB) mixChange(ctx context.Context, cfg *Config) error {
 	// Read config
-	dial := cfg.DialCSPPServer
-	csppServer := cfg.CSPPServer
+	mixing := cfg.Mixing
 	mixedAccount := cfg.MixedAccount
 	mixedBranch := cfg.MixedAccountBranch
 	changeAccount := cfg.ChangeAccount
 	mixChange := cfg.MixChange
 
-	if !mixChange || csppServer == "" {
+	if !mixChange || !mixing {
 		return nil
 	}
 
 	ctx, task := trace.NewTask(ctx, "ticketbuyer.mixChange")
 	defer task.End()
 
-	return tb.wallet.MixAccount(ctx, dial, csppServer, changeAccount, mixedAccount, mixedBranch)
+	return tb.wallet.MixAccount(ctx, changeAccount, mixedAccount, mixedBranch)
 }
diff --git a/wallet/chainntfns.go b/wallet/chainntfns.go
index 7e950727..361c96d0 100644
--- a/wallet/chainntfns.go
+++ b/wallet/chainntfns.go
@@ -276,6 +276,8 @@ func (w *Wallet) ChainSwitch(ctx context.Context, forest *SidechainForest, chain
 	forest.PruneChain(chain)
 	forest.Prune(int32(chain[len(chain)-1].Header.Height), w.chainParams)
 
+	w.mixClient.ExpireMessages(chain[len(chain)-1].Header.Height)
+
 	w.NtfnServer.notifyMainChainTipChanged(chainTipChanges)
 	w.NtfnServer.sendAttachedBlockNotification(ctx)
 
diff --git a/wallet/coinjoin.go b/wallet/coinjoin.go
deleted file mode 100644
index 0fea8288..00000000
--- a/wallet/coinjoin.go
+++ /dev/null
@@ -1,246 +0,0 @@
-package wallet
-
-import (
-	"bytes"
-	"context"
-	"crypto/subtle"
-
-	"decred.org/dcrwallet/v4/errors"
-	"decred.org/dcrwallet/v4/wallet/walletdb"
-	"github.com/decred/dcrd/dcrec"
-	"github.com/decred/dcrd/dcrutil/v4"
-	"github.com/decred/dcrd/txscript/v4"
-	"github.com/decred/dcrd/txscript/v4/sign"
-	"github.com/decred/dcrd/txscript/v4/stdaddr"
-	"github.com/decred/dcrd/txscript/v4/stdscript"
-	"github.com/decred/dcrd/wire"
-)
-
-type missingGenError struct{}
-
-var errMissingGen missingGenError
-
-func (missingGenError) Error() string   { return "coinjoin is missing gen output" }
-func (missingGenError) MissingMessage() {}
-
-type csppJoin struct {
-	tx            *wire.MsgTx
-	txInputs      map[wire.OutPoint]int
-	myPrevScripts [][]byte
-	myIns         []*wire.TxIn
-	change        *wire.TxOut
-	mcount        int
-	genScripts    [][]byte
-	genIndex      []int
-	amount        int64
-	wallet        *Wallet
-	mixAccount    uint32
-	mixBranch     uint32
-
-	ctx context.Context
-}
-
-func (w *Wallet) newCsppJoin(ctx context.Context, change *wire.TxOut, amount dcrutil.Amount, mixAccount, mixBranch uint32, mcount int) *csppJoin {
-	cj := &csppJoin{
-		tx:         &wire.MsgTx{Version: 1},
-		change:     change,
-		mcount:     mcount,
-		amount:     int64(amount),
-		wallet:     w,
-		mixAccount: mixAccount,
-		mixBranch:  mixBranch,
-		ctx:        ctx,
-	}
-	if change != nil {
-		cj.tx.TxOut = append(cj.tx.TxOut, change)
-	}
-	return cj
-}
-
-func (c *csppJoin) addTxIn(prevScript []byte, in *wire.TxIn) {
-	c.tx.TxIn = append(c.tx.TxIn, in)
-	c.myPrevScripts = append(c.myPrevScripts, prevScript)
-	c.myIns = append(c.myIns, in)
-}
-
-func (c *csppJoin) Gen() ([][]byte, error) {
-	const op errors.Op = "cspp.Gen"
-	gen := make([][]byte, c.mcount)
-	c.genScripts = make([][]byte, c.mcount)
-	var updates []func(walletdb.ReadWriteTx) error
-	for i := 0; i < c.mcount; i++ {
-		persist := c.wallet.deferPersistReturnedChild(c.ctx, &updates)
-		const accountName = "" // not used, so can be faked.
-		mixAddr, err := c.wallet.nextAddress(c.ctx, op, persist,
-			accountName, c.mixAccount, c.mixBranch, WithGapPolicyIgnore())
-		if err != nil {
-			return nil, err
-		}
-		version, script := mixAddr.PaymentScript()
-		if version != 0 {
-			return nil, errors.E("expected script version 0")
-		}
-		hash160er, ok := mixAddr.(stdaddr.Hash160er)
-		if !ok {
-			return nil, errors.E("address does not have Hash160 method")
-		}
-		c.genScripts[i] = script
-		gen[i] = hash160er.Hash160()[:]
-	}
-	err := walletdb.Update(c.ctx, c.wallet.db, func(dbtx walletdb.ReadWriteTx) error {
-		for _, f := range updates {
-			if err := f(dbtx); err != nil {
-				return err
-			}
-		}
-		return nil
-	})
-	if err != nil {
-		return nil, errors.E(op, err)
-	}
-	return gen, nil
-}
-
-func (c *csppJoin) Confirm() error {
-	const op errors.Op = "cspp.Confirm"
-	err := walletdb.View(c.ctx, c.wallet.db, func(dbtx walletdb.ReadTx) error {
-		addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey)
-		for outx, in := range c.myIns {
-			outScript := c.myPrevScripts[outx]
-			index, ok := c.txInputs[in.PreviousOutPoint]
-			if !ok {
-				return errors.E("coinjoin is missing inputs")
-			}
-			in = c.tx.TxIn[index]
-
-			const scriptVersion = 0
-			_, addrs := stdscript.ExtractAddrs(scriptVersion, outScript, c.wallet.chainParams)
-			if len(addrs) != 1 {
-				continue
-			}
-			apkh, ok := addrs[0].(*stdaddr.AddressPubKeyHashEcdsaSecp256k1V0)
-			if !ok {
-				return errors.E(errors.Bug, "previous output is not P2PKH")
-			}
-			privKey, done, err := c.wallet.manager.PrivateKey(addrmgrNs, apkh)
-			if err != nil {
-				return err
-			}
-			defer done()
-			sigscript, err := sign.SignatureScript(c.tx, index, outScript,
-				txscript.SigHashAll, privKey.Serialize(), dcrec.STEcdsaSecp256k1, true)
-			if err != nil {
-				return errors.E(errors.Op("txscript.SignatureScript"), err)
-			}
-			in.SignatureScript = sigscript
-		}
-		return nil
-	})
-	if err != nil {
-		return errors.E(op, err)
-	}
-	return nil
-}
-
-func (c *csppJoin) mixOutputIndexes() []int {
-	return c.genIndex
-}
-
-func (c *csppJoin) MarshalBinary() ([]byte, error) {
-	buf := new(bytes.Buffer)
-	buf.Grow(c.tx.SerializeSize())
-	err := c.tx.Serialize(buf)
-	return buf.Bytes(), err
-}
-
-func (c *csppJoin) UnmarshalBinary(b []byte) error {
-	tx := new(wire.MsgTx)
-	err := tx.Deserialize(bytes.NewReader(b))
-	if err != nil {
-		return err
-	}
-
-	// Ensure all unmixed inputs, unmixed outputs, and mixed outputs exist.
-	// Mixed outputs must be searched in constant time to avoid sidechannel leakage.
-	txInputs := make(map[wire.OutPoint]int, len(tx.TxIn))
-	for i, in := range tx.TxIn {
-		txInputs[in.PreviousOutPoint] = i
-	}
-	var n int
-	for _, in := range c.myIns {
-		if index, ok := txInputs[in.PreviousOutPoint]; ok {
-			other := tx.TxIn[index]
-			if in.Sequence != other.Sequence || in.ValueIn != other.ValueIn {
-				break
-			}
-			n++
-		}
-	}
-	if n != len(c.myIns) {
-		return errors.E("coinjoin is missing inputs")
-	}
-	if c.change != nil {
-		var hasChange bool
-		for _, out := range tx.TxOut {
-			if out.Value != c.change.Value {
-				continue
-			}
-			if out.Version != c.change.Version {
-				continue
-			}
-			if !bytes.Equal(out.PkScript, c.change.PkScript) {
-				continue
-			}
-			hasChange = true
-			break
-		}
-		if !hasChange {
-			return errors.E("coinjoin is missing change")
-		}
-	}
-	indexes, err := constantTimeOutputSearch(tx, c.amount, 0, c.genScripts)
-	if err != nil {
-		return err
-	}
-
-	c.tx = tx
-	c.txInputs = txInputs
-	c.genIndex = indexes
-	return nil
-}
-
-// constantTimeOutputSearch searches for the output indexes of mixed outputs to
-// verify inclusion in a coinjoin.  It is constant time such that, for each
-// searched script, all outputs with equal value, script versions, and script
-// lengths matching the searched output are checked in constant time.
-func constantTimeOutputSearch(tx *wire.MsgTx, value int64, scriptVer uint16, scripts [][]byte) ([]int, error) {
-	var scan []int
-	for i, out := range tx.TxOut {
-		if out.Value != value {
-			continue
-		}
-		if out.Version != scriptVer {
-			continue
-		}
-		if len(out.PkScript) != len(scripts[0]) {
-			continue
-		}
-		scan = append(scan, i)
-	}
-	indexes := make([]int, 0, len(scan))
-	var missing int
-	for _, s := range scripts {
-		idx := -1
-		for _, i := range scan {
-			eq := subtle.ConstantTimeCompare(tx.TxOut[i].PkScript, s)
-			idx = subtle.ConstantTimeSelect(eq, i, idx)
-		}
-		indexes = append(indexes, idx)
-		eq := subtle.ConstantTimeEq(int32(idx), -1)
-		missing = subtle.ConstantTimeSelect(eq, 1, missing)
-	}
-	if missing == 1 {
-		return nil, errMissingGen
-	}
-	return indexes, nil
-}
diff --git a/wallet/createtx.go b/wallet/createtx.go
index f3d7eee8..dc74c470 100644
--- a/wallet/createtx.go
+++ b/wallet/createtx.go
@@ -8,14 +8,10 @@ package wallet
 import (
 	"context"
 	"crypto/rand"
-	"crypto/tls"
 	"encoding/binary"
-	"net"
 	"sort"
 	"time"
 
-	"decred.org/cspp/v2"
-	"decred.org/cspp/v2/coinjoin"
 	"decred.org/dcrwallet/v4/deployments"
 	"decred.org/dcrwallet/v4/errors"
 	"decred.org/dcrwallet/v4/internal/uniformprng"
@@ -30,6 +26,7 @@ import (
 	"github.com/decred/dcrd/chaincfg/v3"
 	"github.com/decred/dcrd/dcrec"
 	"github.com/decred/dcrd/dcrutil/v4"
+	"github.com/decred/dcrd/mixing/mixclient"
 	"github.com/decred/dcrd/txscript/v4"
 	"github.com/decred/dcrd/txscript/v4/sign"
 	"github.com/decred/dcrd/txscript/v4/stdaddr"
@@ -1114,40 +1111,25 @@ func (w *Wallet) mixedSplit(ctx context.Context, req *PurchaseTicketsRequest, ne
 	if change != nil && dcrutil.Amount(change.Value) < smallestMixChange(relayFee) {
 		change = nil
 	}
-	const (
-		txVersion = 1
-		locktime  = 0
-		expiry    = 0
-	)
-	pairing := coinjoin.EncodeDesc(coinjoin.P2PKHv0, int64(neededPerTicket), txVersion, locktime, expiry)
-	cj := w.newCsppJoin(ctx, change, neededPerTicket, req.MixedSplitAccount, req.MixedAccountBranch, req.Count)
+	gen := w.makeGen(ctx, req.MixedAccount, req.MixedAccountBranch)
+	expires := w.dicemixExpiry(ctx)
+	cj := mixclient.NewCoinJoin(gen, change, int64(neededPerTicket), expires, uint32(req.Count))
 	for i, in := range atx.Tx.TxIn {
-		cj.addTxIn(atx.PrevScripts[i], in)
+		var scriptVersion uint16 = 0 // XXX
+		err = w.addCoinJoinInput(ctx, cj, in, atx.PrevScripts[i], scriptVersion)
+		if err != nil {
+			return
+		}
 	}
 
-	csppSession, err := cspp.NewSession(rand.Reader, debugLog, pairing, req.Count)
-	if err != nil {
-		return
-	}
-	var conn net.Conn
-	if req.DialCSPPServer != nil {
-		conn, err = req.DialCSPPServer(ctx, "tcp", req.CSPPServer)
-	} else {
-		conn, err = tls.Dial("tcp", req.CSPPServer, nil)
-	}
-	if err != nil {
-		return
-	}
-	defer conn.Close()
-	log.Infof("Dialed CSPPServer %v -> %v", conn.LocalAddr(), conn.RemoteAddr())
-	err = csppSession.DiceMix(ctx, conn, cj)
+	err = w.mixClient.Dicemix(ctx, rand.Reader, cj)
 	if err != nil {
 		return
 	}
-	splitTx := cj.tx
+	splitTx := cj.Tx()
 	splitTxHash := splitTx.TxHash()
 	log.Infof("Completed CoinShuffle++ mix of ticket split transaction %v", &splitTxHash)
-	return splitTx, cj.mixOutputIndexes(), nil
+	return splitTx, cj.MixedIndices(), nil
 }
 
 func (w *Wallet) individualSplit(ctx context.Context, req *PurchaseTicketsRequest, neededPerTicket dcrutil.Amount) (tx *wire.MsgTx, outIndexes []int, err error) {
@@ -1611,7 +1593,7 @@ func (w *Wallet) purchaseTickets(ctx context.Context, op errors.Op,
 	var splitOutputIndexes []int
 	for {
 		switch {
-		case req.CSPPServer != "":
+		case req.Mixing:
 			splitTx, splitOutputIndexes, err = w.mixedSplit(ctx, req, neededPerTicket)
 		case req.VSPAddress != nil:
 			splitTx, splitOutputIndexes, err = w.vspSplit(ctx, req, neededPerTicket, vspFee)
@@ -1664,7 +1646,7 @@ func (w *Wallet) purchaseTickets(ctx context.Context, op errors.Op,
 	// Random times between 20s to 1m from now are chosen for each ticket,
 	// and tickets will not be published until their trickle time is reached.
 	var trickleTickets []time.Time
-	if req.CSPPServer != "" {
+	if req.Mixing {
 		now := time.Now()
 		trickleTickets = make([]time.Time, 0, len(splitOutputIndexes))
 		for range splitOutputIndexes {
@@ -1746,7 +1728,7 @@ func (w *Wallet) purchaseTickets(ctx context.Context, op errors.Op,
 			}
 		} else {
 			addrVote = req.VotingAddress
-			if addrVote == nil && req.CSPPServer == "" {
+			if addrVote == nil && req.Mixing {
 				addrVote = w.ticketAddress
 			}
 			if addrVote == nil {
@@ -1758,7 +1740,7 @@ func (w *Wallet) purchaseTickets(ctx context.Context, op errors.Op,
 		}
 		subsidyAccount := req.SourceAccount
 		var branch uint32 = 1
-		if req.CSPPServer != "" {
+		if req.Mixing {
 			subsidyAccount = req.MixedAccount
 			branch = req.MixedAccountBranch
 		}
diff --git a/wallet/log.go b/wallet/log.go
index 71fac604..713c3783 100644
--- a/wallet/log.go
+++ b/wallet/log.go
@@ -25,3 +25,14 @@ var debugLog debugLogger
 
 func (debugLogger) Print(args ...any)                 { log.Debug(args...) }
 func (debugLogger) Printf(format string, args ...any) { log.Debugf(format, args...) }
+func (debugLogger) Log(args ...any)                   { log.Debug(args...) }
+func (debugLogger) Logf(format string, args ...any)   { log.Debugf(format, args...) }
+
+type infoLogger struct{}
+
+var infoLog infoLogger
+
+func (infoLogger) Print(args ...any)                 { log.Info(args...) }
+func (infoLogger) Printf(format string, args ...any) { log.Infof(format, args...) }
+func (infoLogger) Log(args ...any)                   { log.Info(args...) }
+func (infoLogger) Logf(format string, args ...any)   { log.Infof(format, args...) }
diff --git a/wallet/mixing.go b/wallet/mixing.go
index e6af4fcf..3c9d519a 100644
--- a/wallet/mixing.go
+++ b/wallet/mixing.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2019-2020 The Decred developers
+// Copyright (c) 2019-2023 The Decred developers
 // Use of this source code is governed by an ISC
 // license that can be found in the LICENSE file.
 
@@ -6,24 +6,220 @@ package wallet
 
 import (
 	"context"
-	"crypto/rand"
-	"crypto/tls"
-	"net"
-	"time"
+	cryptorand "crypto/rand"
 
-	"decred.org/cspp/v2"
-	"decred.org/cspp/v2/coinjoin"
 	"decred.org/dcrwallet/v4/errors"
 	"decred.org/dcrwallet/v4/wallet/txrules"
 	"decred.org/dcrwallet/v4/wallet/txsizes"
 	"decred.org/dcrwallet/v4/wallet/udb"
 	"decred.org/dcrwallet/v4/wallet/walletdb"
+	"github.com/decred/dcrd/chaincfg/chainhash"
+	"github.com/decred/dcrd/chaincfg/v3"
+	"github.com/decred/dcrd/dcrec"
+	"github.com/decred/dcrd/dcrec/secp256k1/v4"
 	"github.com/decred/dcrd/dcrutil/v4"
+	"github.com/decred/dcrd/mixing"
+	"github.com/decred/dcrd/mixing/mixclient"
+	"github.com/decred/dcrd/mixing/mixpool"
+	"github.com/decred/dcrd/txscript/v4"
+	"github.com/decred/dcrd/txscript/v4/sign"
+	"github.com/decred/dcrd/txscript/v4/stdaddr"
+	"github.com/decred/dcrd/txscript/v4/stdscript"
 	"github.com/decred/dcrd/wire"
 	"github.com/decred/go-socks/socks"
 	"golang.org/x/sync/errgroup"
 )
 
+// MixMessage queries the mixpool for a message.  Only messages that have been
+// recently inv'd should be queried.
+func (w *Wallet) MixMessage(query *chainhash.Hash) (mixing.Message, error) {
+	return w.mixpool.Message(query)
+}
+
+type mixpoolBlockchain Wallet
+
+func (b *mixpoolBlockchain) CurrentTip() (chainhash.Hash, int64) {
+	w := (*Wallet)(b)
+	ctx := context.Background()
+	hash, height := w.MainChainTip(ctx)
+	return hash, int64(height)
+}
+
+func (b *mixpoolBlockchain) ChainParams() *chaincfg.Params {
+	return (*Wallet)(b).chainParams
+}
+
+func (w *Wallet) makeGen(ctx context.Context, account, branch uint32) mixclient.GenFunc {
+	const op errors.Op = "gen"
+
+	gen := func(mcount uint32) (wire.MixVect, error) {
+		gen := make(wire.MixVect, mcount)
+		var updates []func(walletdb.ReadWriteTx) error
+
+		for i := uint32(0); i < mcount; i++ {
+			persist := w.deferPersistReturnedChild(ctx, &updates)
+			const accountName = "" // not used, so can be faked.
+			mixAddr, err := w.nextAddress(ctx, op, persist,
+				accountName, account, branch, WithGapPolicyIgnore())
+			if err != nil {
+				return nil, err
+			}
+			hash160er, ok := mixAddr.(stdaddr.Hash160er)
+			if !ok {
+				return nil, errors.E("address does not have Hash160 method")
+			}
+			gen[i] = *hash160er.Hash160()
+		}
+
+		err := walletdb.Update(ctx, w.db, func(dbtx walletdb.ReadWriteTx) error {
+			for _, f := range updates {
+				if err := f(dbtx); err != nil {
+					return err
+				}
+			}
+			return nil
+		})
+		if err != nil {
+			return nil, errors.E(op, err)
+		}
+		return gen, nil
+	}
+	return mixclient.GenFunc(gen)
+}
+
+// mixingWallet implements the mixclient.Wallet interface.
+type mixingWallet Wallet
+
+// BestBlock returns the wallet's current best tip block height and hash.
+func (w *mixingWallet) BestBlock() (uint32, chainhash.Hash) {
+	wallet := (*Wallet)(w)
+	hash, height := wallet.MainChainTip(context.Background())
+	return uint32(height), hash
+}
+
+// Mixpool returns access to the wallet's mixing message pool.
+//
+// The mixpool should only be used for message access and deletion,
+// but never publishing; SubmitMixMessage must be used instead for
+// message publishing.
+func (w *mixingWallet) Mixpool() *mixpool.Pool {
+	wallet := (*Wallet)(w)
+	return wallet.mixpool
+}
+
+// SubmitMixMessage submits a mixing message to the wallet's mixpool
+// and broadcasts it to the network.
+func (w *mixingWallet) SubmitMixMessage(ctx context.Context, msg mixing.Message) (err error) {
+	defer func() {
+		if err != nil {
+			w.mixpool.RemoveMessage(msg)
+		}
+	}()
+
+	_, err = w.mixpool.AcceptMessage(msg)
+	if err != nil {
+		return err
+	}
+
+	w.networkBackendMu.Lock()
+	n := w.networkBackend
+	w.networkBackendMu.Unlock()
+	if n == nil {
+		return errors.NoPeers
+	}
+	return n.PublishMixMessages(ctx, msg)
+}
+
+// SignInput adds a signature script to a transaction input.
+func (w *mixingWallet) SignInput(tx *wire.MsgTx, index int, prevScript []byte) error {
+	wallet := (*Wallet)(w)
+	ctx := context.Background()
+	in := tx.TxIn[index]
+
+	return walletdb.View(ctx, wallet.db, func(dbtx walletdb.ReadTx) error {
+		addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey)
+
+		const scriptVersion = 0
+		_, addrs := stdscript.ExtractAddrs(scriptVersion, prevScript,
+			wallet.chainParams)
+		if len(addrs) != 1 {
+			return errors.E(errors.Invalid, "previous output is not P2PKH")
+		}
+		apkh, ok := addrs[0].(*stdaddr.AddressPubKeyHashEcdsaSecp256k1V0)
+		if !ok {
+			return errors.E(errors.Invalid, "previous output is not P2PKH")
+		}
+		privKey, done, err := wallet.manager.PrivateKey(addrmgrNs, apkh)
+		if err != nil {
+			return err
+		}
+		defer done()
+		sigscript, err := sign.SignatureScript(tx, index, prevScript,
+			txscript.SigHashAll, privKey.Serialize(),
+			dcrec.STEcdsaSecp256k1, true)
+		if err != nil {
+			return errors.E(errors.Op("sign.SignatureScript"), err)
+		}
+		in.SignatureScript = sigscript
+		return nil
+	})
+}
+
+// PublishTransaction adds the transaction to the wallet and publishes
+// it to the network.
+func (w *mixingWallet) PublishTransaction(ctx context.Context, tx *wire.MsgTx) error {
+	wallet := (*Wallet)(w)
+
+	wallet.networkBackendMu.Lock()
+	n := wallet.networkBackend
+	wallet.networkBackendMu.Unlock()
+
+	_, err := wallet.PublishTransaction(ctx, tx, n)
+	return err
+}
+
+func (w *Wallet) dicemixExpiry(ctx context.Context) uint32 {
+	_, height := w.MainChainTip(ctx)
+	return mixing.MaxExpiry(uint32(height), w.chainParams) - 2
+}
+
+// addCoinJoinInput adds a wallet's controlled UTXO to the coinjoin
+// transaction.  This method looks up the private key of the previous output
+// to create the UTXO signature proof and requires the wallet or account to be
+// unlocked.
+func (w *Wallet) addCoinJoinInput(ctx context.Context, cj *mixclient.CoinJoin,
+	input *wire.TxIn, prevScript []byte, prevScriptVersion uint16) error {
+
+	const scriptVersion = 0
+	_, addrs := stdscript.ExtractAddrs(scriptVersion, prevScript,
+		w.chainParams)
+	if len(addrs) != 1 {
+		return errors.E(errors.Invalid, "previous output is not P2PKH")
+	}
+	prevP2PKH, ok := addrs[0].(*stdaddr.AddressPubKeyHashEcdsaSecp256k1V0)
+	if !ok {
+		return errors.E(errors.Invalid, "previous output is not P2PKH")
+	}
+	var privKey *secp256k1.PrivateKey
+	var privKeyDone func()
+	err := walletdb.View(ctx, w.db, func(dbtx walletdb.ReadTx) error {
+		addrmgrNs := dbtx.ReadBucket(waddrmgrNamespaceKey)
+		var err error
+		privKey, privKeyDone, err = w.manager.PrivateKey(addrmgrNs, prevP2PKH)
+		return err
+	})
+	if err != nil {
+		if privKeyDone != nil {
+			privKeyDone()
+		}
+		return err
+	}
+
+	err = cj.AddInput(input, prevScript, prevScriptVersion, privKey)
+	privKeyDone()
+	return err
+}
+
 // must be sorted large to small
 var splitPoints = [...]dcrutil.Amount{
 	1 << 36, // 687.19476736
@@ -64,14 +260,19 @@ var (
 	errThrottledMixRequest = errors.New("throttled mix request for split denomination")
 )
 
-// DialFunc provides a method to dial a network connection.
-// If the dialed network connection is secured by TLS, TLS
-// configuration is provided by the method, not the caller.
-type DialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
+// MixOutput performs a mix of a single output into standard sized outputs
+// under the current ticket price.
+func (w *Wallet) MixOutput(ctx context.Context, output *wire.OutPoint, changeAccount, mixAccount,
+	mixBranch uint32) error {
 
-func (w *Wallet) MixOutput(ctx context.Context, dialTLS DialFunc, csppserver string, output *wire.OutPoint, changeAccount, mixAccount, mixBranch uint32) error {
 	op := errors.Opf("wallet.MixOutput(%v)", output)
 
+	// Mixing requests require wallet mixing support.
+	if !w.mixing {
+		s := "wallet mixing support is disabled"
+		return errors.E(op, errors.Invalid, s)
+	}
+
 	sdiff, err := w.NextStakeDifficulty(ctx)
 	if err != nil {
 		return errors.E(op, err)
@@ -85,6 +286,7 @@ func (w *Wallet) MixOutput(ctx context.Context, dialTLS DialFunc, csppserver str
 	}
 
 	var prevScript []byte
+	var prevScriptVersion uint16
 	var amount dcrutil.Amount
 	err = walletdb.View(ctx, w.db, func(dbtx walletdb.ReadTx) error {
 		txmgrNs := dbtx.ReadBucket(wtxmgrNamespaceKey)
@@ -92,7 +294,9 @@ func (w *Wallet) MixOutput(ctx context.Context, dialTLS DialFunc, csppserver str
 		if err != nil {
 			return err
 		}
-		prevScript = txDetails.MsgTx.TxOut[output.Index].PkScript
+		out := txDetails.MsgTx.TxOut[output.Index]
+		prevScript = out.PkScript
+		prevScriptVersion = out.Version
 		amount = dcrutil.Amount(txDetails.MsgTx.TxOut[output.Index].Value)
 		return nil
 	})
@@ -109,7 +313,8 @@ func (w *Wallet) MixOutput(ctx context.Context, dialTLS DialFunc, csppserver str
 		w.lockedOutpointMu.Unlock()
 	}()
 
-	var i, count int
+	var i int
+	var count uint32
 	var mixValue, remValue, changeValue dcrutil.Amount
 	var feeRate = w.RelayFee()
 	var smallestMixChange = smallestMixChange(feeRate)
@@ -130,7 +335,7 @@ SplitPoints:
 			continue
 		}
 
-		count = int(amount / mixValue)
+		count = uint32(amount / mixValue)
 		if count > 4 {
 			count = 4
 		}
@@ -206,68 +411,29 @@ SplitPoints:
 		}
 	}
 
-	const (
-		txVersion = 1
-		locktime  = 0
-		expiry    = 0
-	)
-	pairing := coinjoin.EncodeDesc(coinjoin.P2PKHv0, int64(mixValue), txVersion, locktime, expiry)
-	ses, err := cspp.NewSession(rand.Reader, debugLog, pairing, count)
-	if err != nil {
-		return errors.E(op, err)
-	}
-	var conn net.Conn
-	if dialTLS != nil {
-		conn, err = dialTLS(ctx, "tcp", csppserver)
-	} else {
-		conn, err = tls.Dial("tcp", csppserver, nil)
-	}
-	if err != nil {
-		return errors.E(op, err)
-	}
-	defer conn.Close()
-	log.Infof("Dialed CSPPServer %v -> %v", conn.LocalAddr(), conn.RemoteAddr())
-
 	log.Infof("Mixing output %v (%v)", output, amount)
-	cj := w.newCsppJoin(ctx, change, mixValue, mixAccount, mixBranch, count)
-	cj.addTxIn(prevScript, &wire.TxIn{
+
+	gen := w.makeGen(ctx, mixAccount, mixBranch)
+	expires := w.dicemixExpiry(ctx)
+	cj := mixclient.NewCoinJoin(gen, change, int64(mixValue), expires, count)
+	input := &wire.TxIn{
 		PreviousOutPoint: *output,
 		ValueIn:          int64(amount),
-	})
-	err = ses.DiceMix(ctx, conn, cj)
+	}
+	err = w.addCoinJoinInput(ctx, cj, input, prevScript, prevScriptVersion)
 	if err != nil {
 		return errors.E(op, err)
 	}
-	cjHash := cj.tx.TxHash()
-	log.Infof("Completed CoinShuffle++ mix of output %v in transaction %v", output, &cjHash)
 
-	var watch []wire.OutPoint
-	w.lockedOutpointMu.Lock()
-	err = walletdb.Update(ctx, w.db, func(dbtx walletdb.ReadWriteTx) error {
-		for _, f := range updates {
-			if err := f(dbtx); err != nil {
-				return err
-			}
-		}
-		rec, err := udb.NewTxRecordFromMsgTx(cj.tx, time.Now())
-		if err != nil {
-			return errors.E(op, err)
-		}
-		watch, err = w.processTransactionRecord(ctx, dbtx, rec, nil, nil)
-		if err != nil {
-			return err
-		}
-		return nil
-	})
-	w.lockedOutpointMu.Unlock()
+	err = w.mixClient.Dicemix(ctx, cryptorand.Reader, cj)
 	if err != nil {
 		return errors.E(op, err)
 	}
-	n, _ := w.NetworkBackend()
-	if n != nil {
-		err = w.publishAndWatch(ctx, op, n, cj.tx, watch)
-	}
-	return err
+
+	tx := cj.Tx()
+	cjHash := tx.TxHash()
+	log.Infof("Completed CoinShuffle++ mix of output %v in transaction %v", output, &cjHash)
+	return nil
 }
 
 // MixAccount individually mixes outputs of an account into standard
@@ -275,15 +441,22 @@ SplitPoints:
 //
 // Due to performance concerns of timing out in a CoinShuffle++ run, this
 // function may throttle how many of the outputs are mixed each call.
-func (w *Wallet) MixAccount(ctx context.Context, dialTLS DialFunc, csppserver string, changeAccount, mixAccount, mixBranch uint32) error {
+func (w *Wallet) MixAccount(ctx context.Context, changeAccount, mixAccount,
+	mixBranch uint32) error {
 	const op errors.Op = "wallet.MixAccount"
 
+	// Mixing requests require wallet mixing support.
+	if !w.mixing {
+		s := "wallet mixing support is disabled"
+		return errors.E(op, errors.Invalid, s)
+	}
+
 	_, tipHeight := w.MainChainTip(ctx)
 	w.lockedOutpointMu.Lock()
 	var credits []Input
 	err := walletdb.View(ctx, w.db, func(dbtx walletdb.ReadTx) error {
 		var err error
-		const minconf = 1
+		const minconf = 2
 		const targetAmount = 0
 		var minAmount = splitPoints[len(splitPoints)-1]
 		var maxResults = cap(w.mixSems.splitSems[0]) * len(splitPoints)
@@ -301,7 +474,7 @@ func (w *Wallet) MixAccount(ctx context.Context, dialTLS DialFunc, csppserver st
 	for i := range credits {
 		op := &credits[i].OutPoint
 		g.Go(func() error {
-			err := w.MixOutput(ctx, dialTLS, csppserver, op, changeAccount, mixAccount, mixBranch)
+			err := w.MixOutput(ctx, op, changeAccount, mixAccount, mixBranch)
 			if errors.Is(err, errThrottledMixRequest) {
 				return nil
 			}
@@ -365,3 +538,14 @@ func PossibleCoinJoin(tx *wire.MsgTx) (isMix bool, mixDenom int64, mixCount uint
 	isMix = mixCount >= uint32(len(tx.TxOut)/2)
 	return
 }
+
+// AcceptMixMessage adds a mixing message received from the network backend to
+// the wallet's mixpool.
+func (w *Wallet) AcceptMixMessage(msg mixing.Message) error {
+	_, err := w.mixpool.AcceptMessage(msg)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/wallet/network.go b/wallet/network.go
index 0a4c7675..0ed55942 100644
--- a/wallet/network.go
+++ b/wallet/network.go
@@ -11,6 +11,7 @@ import (
 	"github.com/decred/dcrd/chaincfg/chainhash"
 	"github.com/decred/dcrd/dcrutil/v4"
 	"github.com/decred/dcrd/gcs/v4"
+	"github.com/decred/dcrd/mixing"
 	"github.com/decred/dcrd/txscript/v4/stdaddr"
 	"github.com/decred/dcrd/wire"
 )
@@ -35,6 +36,7 @@ type NetworkBackend interface {
 	Blocks(ctx context.Context, blockHashes []*chainhash.Hash) ([]*wire.MsgBlock, error)
 	CFiltersV2(ctx context.Context, blockHashes []*chainhash.Hash) ([]FilterProof, error)
 	PublishTransactions(ctx context.Context, txs ...*wire.MsgTx) error
+	PublishMixMessages(ctx context.Context, msgs ...mixing.Message) error
 	LoadTxFilter(ctx context.Context, reload bool, addrs []stdaddr.Address, outpoints []wire.OutPoint) error
 	Rescan(ctx context.Context, blocks []chainhash.Hash, save func(block *chainhash.Hash, txs []*wire.MsgTx) error) error
 
@@ -100,6 +102,10 @@ func (o OfflineNetworkBackend) PublishTransactions(ctx context.Context, txs ...*
 	return errOfflineNetworkBackend
 }
 
+func (o OfflineNetworkBackend) PublishMixMessages(ctx context.Context, msgs ...mixing.Message) error {
+	return errOfflineNetworkBackend
+}
+
 func (o OfflineNetworkBackend) LoadTxFilter(ctx context.Context, reload bool, addrs []stdaddr.Address, outpoints []wire.OutPoint) error {
 	return errOfflineNetworkBackend
 }
diff --git a/wallet/wallet.go b/wallet/wallet.go
index b9175dae..0aca8770 100644
--- a/wallet/wallet.go
+++ b/wallet/wallet.go
@@ -23,6 +23,7 @@ import (
 	"decred.org/dcrwallet/v4/deployments"
 	"decred.org/dcrwallet/v4/errors"
 	"decred.org/dcrwallet/v4/internal/compat"
+	"decred.org/dcrwallet/v4/internal/loggers"
 	"decred.org/dcrwallet/v4/rpc/client/dcrd"
 	"decred.org/dcrwallet/v4/rpc/jsonrpc/types"
 	"decred.org/dcrwallet/v4/validate"
@@ -43,6 +44,8 @@ import (
 	"github.com/decred/dcrd/dcrutil/v4"
 	gcs2 "github.com/decred/dcrd/gcs/v4"
 	"github.com/decred/dcrd/hdkeychain/v3"
+	"github.com/decred/dcrd/mixing/mixclient"
+	"github.com/decred/dcrd/mixing/mixpool"
 	dcrdtypes "github.com/decred/dcrd/rpc/jsonrpc/types/v4"
 	"github.com/decred/dcrd/txscript/v4"
 	"github.com/decred/dcrd/txscript/v4/sign"
@@ -160,8 +163,11 @@ type Wallet struct {
 	passphraseTimeoutMu     sync.Mutex
 	passphraseTimeoutCancel chan struct{}
 
-	// Mix rate limiting
-	mixSems mixSemaphores
+	// Mixing
+	mixing    bool
+	mixpool   *mixpool.Pool
+	mixSems   mixSemaphores
+	mixClient *mixclient.Client
 
 	// Cached Blake3 anchor candidate
 	cachedBlake3WorkDiffCandidateAnchor   *wire.BlockHeader
@@ -191,6 +197,7 @@ type Config struct {
 	AccountGapLimit         int
 	MixSplitLimit           int
 	DisableCoinTypeUpgrades bool
+	DisableMixing           bool
 
 	StakePoolColdExtKey string
 	ManualTickets       bool
@@ -1553,13 +1560,12 @@ type PurchaseTicketsRequest struct {
 	VotingAddress    stdaddr.StakeAddress
 	MinConf          int32
 	Expiry           int32
-	VotingAccount    uint32 // Used when VotingAddress == nil, or CSPPServer != ""
+	VotingAccount    uint32 // Used when VotingAddress == nil, or Mixing == true
 	UseVotingAccount bool   // Forces use of supplied voting account.
 	DontSignTx       bool
 
 	// Mixed split buying through CoinShuffle++
-	CSPPServer         string
-	DialCSPPServer     DialFunc
+	Mixing             bool
 	MixedAccount       uint32
 	MixedAccountBranch uint32
 	MixedSplitAccount  uint32
@@ -1610,7 +1616,11 @@ func (w *Wallet) PurchaseTickets(ctx context.Context, n NetworkBackend,
 	// Do not attempt to split utxos for a fee payment when spending from
 	// the mixed account.  This error is rather unlikely anyways, as mixed
 	// accounts probably have very many outputs.
-	if req.CSPPServer != "" && req.MixedAccount == req.SourceAccount {
+	if req.Mixing && !w.mixing {
+		s := "wallet mixing support is disabled"
+		return nil, errors.E(op, errors.Invalid, s)
+	}
+	if req.Mixing && req.MixedAccount == req.SourceAccount {
 		return nil, errors.E(op, errors.InsufficientBalance)
 	}
 
@@ -5481,6 +5491,7 @@ func Open(ctx context.Context, cfg *Config) (*Wallet, error) {
 		addressBuffers: make(map[uint32]*bip0044AccountData),
 
 		mixSems: newMixSemaphores(cfg.MixSplitLimit),
+		mixing:  !cfg.DisableMixing,
 	}
 
 	// Open database managers
@@ -5588,9 +5599,29 @@ func Open(ctx context.Context, cfg *Config) (*Wallet, error) {
 	// Record current tip as initialHeight.
 	_, w.initialHeight = w.MainChainTip(ctx)
 
+	if w.mixing {
+		w.mixpool = mixpool.NewPool((*mixpoolBlockchain)(w))
+		w.mixClient = mixclient.NewClient((*mixingWallet)(w))
+		w.mixClient.SetLogger(loggers.MixcLog)
+	}
+
 	return w, nil
 }
 
+// MixingEnabled returns whether the wallet is enabled for mixing and is
+// running the mixing client.
+func (w *Wallet) MixingEnabled() bool {
+	return w.mixing
+}
+
+// Run executes any necessary background goroutines for the wallet.
+func (w *Wallet) Run(ctx context.Context) error {
+	if w.mixing {
+		return w.mixClient.Run(ctx)
+	}
+	return nil
+}
+
 // getCoinjoinTxsSumbByAcct returns a map with key representing the account and
 // the sum of coinjoin output transactions from the account.
 func (w *Wallet) getCoinjoinTxsSumbByAcct(ctx context.Context) (map[uint32]int, error) {
diff --git a/walletsetup.go b/walletsetup.go
index 5584d2bd..6a3a05af 100644
--- a/walletsetup.go
+++ b/walletsetup.go
@@ -118,8 +118,8 @@ func createWallet(ctx context.Context, cfg *config) error {
 	}
 	loader := loader.NewLoader(activeNet.Params, dbDir, stakeOptions,
 		cfg.GapLimit, cfg.WatchLast, cfg.AllowHighFees, cfg.RelayFee.Amount,
-		cfg.AccountGapLimit, cfg.DisableCoinTypeUpgrades, cfg.ManualTickets,
-		cfg.MixSplitLimit)
+		cfg.AccountGapLimit, cfg.DisableCoinTypeUpgrades, !cfg.Mixing,
+		cfg.ManualTickets, cfg.MixSplitLimit)
 
 	var privPass, pubPass, seed []byte
 	var imported bool


Test patch:
diff --git a/testsolver.go b/testsolver.go
new file mode 100644
index 00000000..fb3be9c0
--- /dev/null
+++ b/testsolver.go
@@ -0,0 +1,34 @@
+// Copyright (c) 2024 The Decred developers
+// Use of this source code is governed by an ISC
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"errors"
+	"io"
+	"math/big"
+	"net/rpc"
+
+	"decred.org/cspp/v2/solverrpc"
+	"github.com/decred/dcrd/mixing"
+)
+
+func testStartedSolverWorks() bool {
+	if err := solverrpc.StartSolver(); err != nil {
+		return false
+	}
+
+	// Values don't matter; we just need to not observe an io.UnexpectedEOF
+	// or net/rpc.ErrShutdown.
+	coeffs := []*big.Int{
+		big.NewInt(1),
+		big.NewInt(2),
+	}
+	_, err := solverrpc.Roots(coeffs, mixing.F)
+	if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, rpc.ErrShutdown) {
+		return false
+	}
+
+	return true
+}
diff --git a/wallet/network_test.go b/wallet/network_test.go
index cb369895..10966f6c 100644
--- a/wallet/network_test.go
+++ b/wallet/network_test.go
@@ -9,6 +9,7 @@ import (
 
 	"github.com/decred/dcrd/chaincfg/chainhash"
 	"github.com/decred/dcrd/dcrutil/v4"
+	"github.com/decred/dcrd/mixing"
 	"github.com/decred/dcrd/txscript/v4/stdaddr"
 	"github.com/decred/dcrd/wire"
 )
@@ -24,7 +25,8 @@ func (mockNetwork) Blocks(ctx context.Context, blockHashes []*chainhash.Hash) ([
 func (mockNetwork) CFiltersV2(ctx context.Context, blockHashes []*chainhash.Hash) ([]FilterProof, error) {
 	return nil, nil
 }
-func (mockNetwork) PublishTransactions(ctx context.Context, txs ...*wire.MsgTx) error { return nil }
+func (mockNetwork) PublishTransactions(ctx context.Context, txs ...*wire.MsgTx) error   { return nil }
+func (mockNetwork) PublishMixMessages(ctx context.Context, txs ...mixing.Message) error { return nil }
 func (mockNetwork) LoadTxFilter(ctx context.Context, reload bool, addrs []stdaddr.Address, outpoints []wire.OutPoint) error {
 	return nil
 }