From aeaeea56f9527b12abaa0093fcf0bf18ae609c65 Mon Sep 17 00:00:00 2001 From: Wisdom Arerosuoghene Date: Fri, 17 Nov 2023 21:44:27 +0100 Subject: [PATCH] client/asset: add FundsMixer wallet trait and implement for dcr (#2478) * client/asset: add FundsMixer wallet trait and implement for dcr --- client/asset/dcr/config.go | 40 +++- client/asset/dcr/dcr.go | 156 ++++++--------- client/asset/dcr/dcr_test.go | 22 +- client/asset/dcr/native_wallet.go | 233 ++++++++++++++++++++++ client/asset/dcr/rpcwallet.go | 35 +++- client/asset/dcr/spv.go | 167 ++++++++++++++-- client/asset/dcr/spv_mixing.go | 136 +++++++++++++ client/asset/dcr/spv_test.go | 67 +++++-- client/asset/dcr/wallet.go | 12 +- client/asset/interface.go | 49 +++++ client/core/core.go | 26 +++ client/webserver/site/src/html/forms.tmpl | 2 +- 12 files changed, 784 insertions(+), 161 deletions(-) create mode 100644 client/asset/dcr/native_wallet.go create mode 100644 client/asset/dcr/spv_mixing.go diff --git a/client/asset/dcr/config.go b/client/asset/dcr/config.go index 99cc6f4efb..e3708ac06f 100644 --- a/client/asset/dcr/config.go +++ b/client/asset/dcr/config.go @@ -31,9 +31,6 @@ var ( ) type walletConfig struct { - PrimaryAccount string `ini:"account"` - UnmixedAccount string `ini:"unmixedaccount"` - TradingAccount string `ini:"tradingaccount"` UseSplitTx bool `ini:"txsplit"` FallbackFeeRate float64 `ini:"fallbackfee"` FeeRateLimit float64 `ini:"feeratelimit"` @@ -43,10 +40,13 @@ type walletConfig struct { } type rpcConfig struct { - RPCUser string `ini:"username"` - RPCPass string `ini:"password"` - RPCListen string `ini:"rpclisten"` - RPCCert string `ini:"rpccert"` + PrimaryAccount string `ini:"account"` + UnmixedAccount string `ini:"unmixedaccount"` + TradingAccount string `ini:"tradingaccount"` + RPCUser string `ini:"username"` + RPCPass string `ini:"password"` + RPCListen string `ini:"rpclisten"` + RPCCert string `ini:"rpccert"` } func loadRPCConfig(settings map[string]string, network dex.Network) (*rpcConfig, *chaincfg.Params, error) { @@ -55,6 +55,7 @@ func loadRPCConfig(settings map[string]string, network dex.Network) (*rpcConfig, if err != nil { return nil, nil, err } + var defaultServer string switch network { case dex.Simnet: @@ -74,6 +75,31 @@ func loadRPCConfig(settings map[string]string, network dex.Network) (*rpcConfig, } else { cfg.RPCCert = dex.CleanAndExpandPath(cfg.RPCCert) } + + if cfg.PrimaryAccount == "" { + cfg.PrimaryAccount = defaultAcctName + } + + // Both UnmixedAccount and TradingAccount must be provided if primary + // account is a mixed account. Providing one but not the other is bad + // configuration. If set, the account names will be validated on Connect. + if (cfg.UnmixedAccount == "") != (cfg.TradingAccount == "") { + return nil, nil, fmt.Errorf("'Change Account Name' and 'Temporary Trading Account' MUST "+ + "be set to treat %[1]q as a mixed account. If %[1]q is not a mixed account, values "+ + "should NOT be set for 'Change Account Name' and 'Temporary Trading Account'", + cfg.PrimaryAccount) + } + if cfg.UnmixedAccount != "" { + switch { + case cfg.PrimaryAccount == cfg.UnmixedAccount: + return nil, nil, fmt.Errorf("Primary Account should not be the same as Change Account") + case cfg.PrimaryAccount == cfg.TradingAccount: + return nil, nil, fmt.Errorf("Primary Account should not be the same as Temporary Trading Account") + case cfg.TradingAccount == cfg.UnmixedAccount: + return nil, nil, fmt.Errorf("Temporary Trading Account should not be the same as Change Account") + } + } + return cfg, chainParams, nil } diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index bd136a0bb2..d8d08e9905 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -100,6 +100,9 @@ const ( requiredRedeemConfirms = 2 vspFileName = "vsp.json" + + defaultCSPPMainnet = "mix.decred.org:5760" + defaultCSPPTestnet3 = "mix.decred.org:15760" ) var ( @@ -587,12 +590,6 @@ type feeStamped struct { // exchangeWalletConfig is the validated, unit-converted, user-configurable // wallet settings. type exchangeWalletConfig struct { - primaryAcct string - unmixedAccount string // mixing-enabled wallets only - // tradingAccount (mixing-enabled wallets only) stores utxos reserved for - // executing order matches, the external branch stores split tx outputs, - // internal branch stores chained (non-final) swap change. - tradingAccount string useSplitTx bool fallbackFeeRate uint64 feeRateLimit uint64 @@ -701,7 +698,7 @@ type findRedemptionResult struct { // NewWallet is the exported constructor by which the DEX will import the // exchange wallet. -func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (*ExchangeWallet, error) { +func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) { // loadConfig will set fields if defaults are used and set the chainParams // variable. walletCfg := new(walletConfig) @@ -715,6 +712,8 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) return nil, err } + var w asset.Wallet = dcr + switch cfg.Type { case walletTypeDcrwRPC, walletTypeLegacy: dcr.wallet, err = newRPCWallet(cfg.Settings, logger, network) @@ -726,6 +725,10 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) if err != nil { return nil, err } + w, err = initNativeWallet(dcr) + if err != nil { + return nil, err + } default: if makeCustomWallet, ok := customWalletConstructors[cfg.Type]; ok { dcr.wallet, err = makeCustomWallet(cfg.Settings, chainParams, logger) @@ -737,7 +740,7 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) } } - return dcr, nil + return w, nil } func getExchangeWalletCfg(dcrCfg *walletConfig, logger dex.Logger) (*exchangeWalletConfig, error) { @@ -767,36 +770,7 @@ func getExchangeWalletCfg(dcrCfg *walletConfig, logger dex.Logger) (*exchangeWal } logger.Tracef("Redeem conf target set to %d blocks", redeemConfTarget) - primaryAcct := dcrCfg.PrimaryAccount - if primaryAcct == "" { - primaryAcct = defaultAcctName - } - logger.Tracef("Primary account set to %s", primaryAcct) - - // Both UnmixedAccount and TradingAccount must be provided if primary - // account is a mixed account. Providing one but not the other is bad - // configuration. If set, the account names will be validated on Connect. - if (dcrCfg.UnmixedAccount == "") != (dcrCfg.TradingAccount == "") { - return nil, fmt.Errorf("'Change Account Name' and 'Temporary Trading Account' MUST "+ - "be set to treat %[1]q as a mixed account. If %[1]q is not a mixed account, values "+ - "should NOT be set for 'Change Account Name' and 'Temporary Trading Account'", - dcrCfg.PrimaryAccount) - } - if dcrCfg.UnmixedAccount != "" { - switch { - case dcrCfg.PrimaryAccount == dcrCfg.UnmixedAccount: - return nil, fmt.Errorf("Primary Account should not be the same as Change Account") - case dcrCfg.PrimaryAccount == dcrCfg.TradingAccount: - return nil, fmt.Errorf("Primary Account should not be the same as Temporary Trading Account") - case dcrCfg.TradingAccount == dcrCfg.UnmixedAccount: - return nil, fmt.Errorf("Temporary Trading Account should not be the same as Change Account") - } - } - return &exchangeWalletConfig{ - primaryAcct: primaryAcct, - unmixedAccount: dcrCfg.UnmixedAccount, - tradingAccount: dcrCfg.TradingAccount, fallbackFeeRate: fallbackFeesPerByte, feeRateLimit: feesLimitPerByte, redeemConfTarget: redeemConfTarget, @@ -862,8 +836,6 @@ func openSPVWallet(dataDir string, chainParams *chaincfg.Params, log dex.Logger) } return &spvWallet{ - acctNum: defaultAcct, - acctName: defaultAcctName, dir: dir, chainParams: chainParams, log: log.SubLogger("SPV"), @@ -965,14 +937,7 @@ func (dcr *ExchangeWallet) Reconfigure(ctx context.Context, cfg *asset.WalletCon return false, err } - var depositAccount string - if dcrCfg.UnmixedAccount != "" { - depositAccount = dcrCfg.UnmixedAccount - } else { - depositAccount = dcrCfg.PrimaryAccount - } - - restart, err = dcr.wallet.Reconfigure(ctx, cfg, dcr.network, currentAddress, depositAccount) + restart, err = dcr.wallet.Reconfigure(ctx, cfg, dcr.network, currentAddress) if err != nil || restart { return restart, err } @@ -988,33 +953,30 @@ func (dcr *ExchangeWallet) Reconfigure(ctx context.Context, cfg *asset.WalletCon // depositAccount returns the account that may be used to receive funds into // the wallet, either by a direct deposit action or via redemption or refund. func (dcr *ExchangeWallet) depositAccount() string { - cfg := dcr.config() - - if cfg.unmixedAccount != "" { - return cfg.unmixedAccount + accts := dcr.wallet.Accounts() + if accts.UnmixedAccount != "" { + return accts.UnmixedAccount } - return cfg.primaryAcct + return accts.PrimaryAccount } // fundingAccounts returns the primary account along with any configured trading // account which may contain spendable outputs (split tx outputs or chained swap // change). func (dcr *ExchangeWallet) fundingAccounts() []string { - cfg := dcr.config() - - if cfg.unmixedAccount == "" { - return []string{cfg.primaryAcct} + accts := dcr.wallet.Accounts() + if accts.UnmixedAccount == "" { + return []string{accts.PrimaryAccount} } - return []string{cfg.primaryAcct, cfg.tradingAccount} + return []string{accts.PrimaryAccount, accts.TradingAccount} } func (dcr *ExchangeWallet) allAccounts() []string { - cfg := dcr.config() - - if cfg.unmixedAccount == "" { - return []string{cfg.primaryAcct} + accts := dcr.wallet.Accounts() + if accts.UnmixedAccount == "" { + return []string{accts.PrimaryAccount} } - return []string{cfg.primaryAcct, cfg.tradingAccount, cfg.unmixedAccount} + return []string{accts.PrimaryAccount, accts.TradingAccount, accts.UnmixedAccount} } // OwnsDepositAddress indicates if the provided address can be used to deposit @@ -1028,13 +990,13 @@ func (dcr *ExchangeWallet) OwnsDepositAddress(address string) (bool, error) { } func (dcr *ExchangeWallet) balance() (*asset.Balance, error) { - cfg := dcr.config() + accts := dcr.wallet.Accounts() - locked, err := dcr.lockedAtoms(cfg.primaryAcct) + locked, err := dcr.lockedAtoms(accts.PrimaryAccount) if err != nil { return nil, err } - ab, err := dcr.wallet.AccountBalance(dcr.ctx, 0, cfg.primaryAcct) + ab, err := dcr.wallet.AccountBalance(dcr.ctx, 0, accts.PrimaryAccount) if err != nil { return nil, err } @@ -1046,7 +1008,7 @@ func (dcr *ExchangeWallet) balance() (*asset.Balance, error) { Other: make(map[asset.BalanceCategory]asset.CustomBalance), } - if cfg.unmixedAccount == "" { + if accts.UnmixedAccount == "" { return bal, nil } @@ -1054,15 +1016,15 @@ func (dcr *ExchangeWallet) balance() (*asset.Balance, error) { // 1) trading account spendable (-locked) as available, // 2) all unmixed funds as immature, and // 3) all locked utxos in the trading account as locked (for swapping). - tradingAcctBal, err := dcr.wallet.AccountBalance(dcr.ctx, 0, cfg.tradingAccount) + tradingAcctBal, err := dcr.wallet.AccountBalance(dcr.ctx, 0, accts.TradingAccount) if err != nil { return nil, err } - tradingAcctLocked, err := dcr.lockedAtoms(cfg.tradingAccount) + tradingAcctLocked, err := dcr.lockedAtoms(accts.TradingAccount) if err != nil { return nil, err } - unmixedAcctBal, err := dcr.wallet.AccountBalance(dcr.ctx, 0, cfg.unmixedAccount) + unmixedAcctBal, err := dcr.wallet.AccountBalance(dcr.ctx, 0, accts.UnmixedAccount) if err != nil { return nil, err } @@ -1744,7 +1706,7 @@ func (dcr *ExchangeWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes useSplit = *customCfg.Split } - changeForReserves := useSplit && cfg.unmixedAccount == "" + changeForReserves := useSplit && dcr.wallet.Accounts().UnmixedAccount == "" reserves := dcr.bondReserves.Load() coins, redeemScripts, sum, inputsSize, err := dcr.fund(reserves, orderEnough(ord.Value, ord.MaxSwapCount, bumpedMaxRate, changeForReserves)) @@ -2072,12 +2034,12 @@ func (dcr *ExchangeWallet) submitMultiSplitTx(fundingCoins asset.Coins, spents [ return nil, 0, err } - cfg := dcr.config() + accts := dcr.wallet.Accounts() getAddr := func() (stdaddr.Address, error) { - if cfg.tradingAccount != "" { - return dcr.wallet.ExternalAddress(dcr.ctx, cfg.tradingAccount) + if accts.TradingAccount != "" { + return dcr.wallet.ExternalAddress(dcr.ctx, accts.TradingAccount) } - return dcr.wallet.InternalAddress(dcr.ctx, cfg.primaryAcct) + return dcr.wallet.InternalAddress(dcr.ctx, accts.PrimaryAccount) } requiredForOrders, _ := dcr.fundsRequiredForMultiOrders(orders, maxFeeRate, splitBuffer) @@ -2405,24 +2367,24 @@ func (dcr *ExchangeWallet) fund(keep uint64, // leave utxos for this reserve amt // spendableUTXOs generates a slice of spendable *compositeUTXO. func (dcr *ExchangeWallet) spendableUTXOs() ([]*compositeUTXO, error) { - cfg := dcr.config() - unspents, err := dcr.wallet.Unspents(dcr.ctx, cfg.primaryAcct) + accts := dcr.wallet.Accounts() + unspents, err := dcr.wallet.Unspents(dcr.ctx, accts.PrimaryAccount) if err != nil { return nil, err } - if cfg.tradingAccount != "" { + if accts.TradingAccount != "" { // Trading account may contain spendable utxos such as unspent split tx // outputs that are unlocked/returned. TODO: Care should probably be // taken to ensure only unspent split tx outputs are selected and other // unmixed outputs in the trading account are ignored. - tradingAcctSpendables, err := dcr.wallet.Unspents(dcr.ctx, cfg.tradingAccount) + tradingAcctSpendables, err := dcr.wallet.Unspents(dcr.ctx, accts.TradingAccount) if err != nil { return nil, err } unspents = append(unspents, tradingAcctSpendables...) } if len(unspents) == 0 { - return nil, fmt.Errorf("insufficient funds. 0 DCR available to spend in account %q", cfg.primaryAcct) + return nil, fmt.Errorf("insufficient funds. 0 DCR available to spend in account %q", accts.PrimaryAccount) } // Parse utxos to include script size for spending input. Returned utxos @@ -2595,12 +2557,12 @@ func (dcr *ExchangeWallet) split(value uint64, lots uint64, coins asset.Coins, i // spent, it won't be transferred to the unmixed account for re-mixing. // Instead, it'll simply be unlocked in the trading account and can thus be // used to fund future orders. - cfg := dcr.config() + accts := dcr.wallet.Accounts() getAddr := func() (stdaddr.Address, error) { - if cfg.tradingAccount != "" { - return dcr.wallet.ExternalAddress(dcr.ctx, cfg.tradingAccount) + if accts.TradingAccount != "" { + return dcr.wallet.ExternalAddress(dcr.ctx, accts.TradingAccount) } - return dcr.wallet.InternalAddress(dcr.ctx, cfg.primaryAcct) + return dcr.wallet.InternalAddress(dcr.ctx, accts.PrimaryAccount) } addr, err := getAddr() if err != nil { @@ -2716,8 +2678,8 @@ func (dcr *ExchangeWallet) ReturnCoins(unspents asset.Coins) error { dcr.fundingMtx.Lock() returnedCoins, err := dcr.returnCoins(unspents) dcr.fundingMtx.Unlock() - cfg := dcr.config() - if err != nil || cfg.unmixedAccount == "" { + accts := dcr.wallet.Accounts() + if err != nil || accts.UnmixedAccount == "" { return err } @@ -2748,13 +2710,13 @@ func (dcr *ExchangeWallet) ReturnCoins(unspents asset.Coins) error { // Move this coin to the unmixed account if it was sent to the internal // branch of the trading account. This excludes unspent split tx outputs // which are sent to the external branch of the trading account. - if addrInfo.Branch == acctInternalBranch && addrInfo.Account == cfg.tradingAccount { + if addrInfo.Branch == acctInternalBranch && addrInfo.Account == accts.TradingAccount { coinsToTransfer = append(coinsToTransfer, coin.op) } } if len(coinsToTransfer) > 0 { - tx, totalSent, err := dcr.sendAll(coinsToTransfer, cfg.unmixedAccount) + tx, totalSent, err := dcr.sendAll(coinsToTransfer, accts.UnmixedAccount) if err != nil { dcr.log.Errorf("unable to transfer unlocked swapped change from temp trading "+ "account to unmixed account: %v", err) @@ -2987,11 +2949,11 @@ func (dcr *ExchangeWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin // Sign the tx but don't send the transaction yet until // the individual swap refund txs are prepared and signed. changeAcct := dcr.depositAccount() - cfg := dcr.config() - if swaps.LockChange && cfg.tradingAccount != "" { + tradingAccount := dcr.wallet.Accounts().TradingAccount + if swaps.LockChange && tradingAccount != "" { // Change will likely be used to fund more swaps, send to trading // account. - changeAcct = cfg.tradingAccount + changeAcct = tradingAccount } msgTx, change, changeAddr, fees, err := dcr.signTxAndAddChange(baseTx, feeRate, -1, changeAcct) if err != nil { @@ -3973,11 +3935,11 @@ func (dcr *ExchangeWallet) Unlock(pw []byte) error { // Lock locks the exchange wallet. func (dcr *ExchangeWallet) Lock() error { - cfg := dcr.config() - if cfg.unmixedAccount != "" { + accts := dcr.wallet.Accounts() + if accts.UnmixedAccount != "" { return nil // don't lock if mixing is enabled } - return dcr.wallet.LockAccount(dcr.ctx, cfg.primaryAcct) + return dcr.wallet.LockAccount(dcr.ctx, accts.PrimaryAccount) } // Locked will be true if the wallet is currently locked. @@ -4192,7 +4154,7 @@ func (dcr *ExchangeWallet) makeBondRefundTxV0(txid *chainhash.Hash, vout uint32, return nil, fmt.Errorf("irredeemable bond at fee rate %d atoms/byte", feeRate) } - redeemAddr, err := dcr.wallet.InternalAddress(dcr.ctx, dcr.config().primaryAcct) + redeemAddr, err := dcr.wallet.InternalAddress(dcr.ctx, dcr.wallet.Accounts().PrimaryAccount) if err != nil { return nil, fmt.Errorf("error getting new address from the wallet: %w", translateRPCCancelErr(err)) } @@ -4544,7 +4506,7 @@ func (dcr *ExchangeWallet) withdraw(addr stdaddr.Address, val, feeRate uint64) ( return nil, 0, fmt.Errorf("cannot withdraw value = 0") } baseSize := uint32(dexdcr.MsgTxOverhead + dexdcr.P2PKHOutputSize*2) - reportChange := dcr.config().unmixedAccount == "" // otherwise change goes to unmixed account + reportChange := dcr.wallet.Accounts().UnmixedAccount == "" // otherwise change goes to unmixed account enough := sendEnough(val, feeRate, true, baseSize, reportChange) reserves := dcr.bondReserves.Load() coins, _, _, _, err := dcr.fund(reserves, enough) @@ -4568,7 +4530,7 @@ func (dcr *ExchangeWallet) withdraw(addr stdaddr.Address, val, feeRate uint64) ( // TODO: Just use the sendtoaddress rpc since dcrwallet respects locked utxos! func (dcr *ExchangeWallet) sendToAddress(addr stdaddr.Address, amt, feeRate uint64) (*wire.MsgTx, uint64, error) { baseSize := uint32(dexdcr.MsgTxOverhead + dexdcr.P2PKHOutputSize*2) // may be extra if change gets omitted (see signTxAndAddChange) - reportChange := dcr.config().unmixedAccount == "" // otherwise change goes to unmixed account + reportChange := dcr.wallet.Accounts().UnmixedAccount == "" // otherwise change goes to unmixed account enough := sendEnough(amt, feeRate, false, baseSize, reportChange) reserves := dcr.bondReserves.Load() coins, _, _, _, err := dcr.fund(reserves, enough) @@ -4898,7 +4860,7 @@ func (dcr *ExchangeWallet) EstimateSendTxFee(address string, sendAmount, feeRate } minTxSize := uint32(tx.SerializeSize()) - reportChange := dcr.config().unmixedAccount == "" + reportChange := dcr.wallet.Accounts().UnmixedAccount == "" enough := sendEnough(sendAmount, feeRate, subtract, minTxSize, reportChange) sum, extra, inputsSize, _, _, _, err := tryFund(utxos, enough) if err != nil { diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index 2e44f3b695..0791d1cef8 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -150,15 +150,19 @@ func tNewWalletMonitorBlocks(monitorBlocks bool) (*ExchangeWallet, *tRPCClient, } walletCtx, shutdown := context.WithCancel(tCtx) - wallet, err := unconnectedWallet(walletCfg, &walletConfig{PrimaryAccount: tAcctName}, tChainParams, tLogger, dex.Simnet) + wallet, err := unconnectedWallet(walletCfg, &walletConfig{}, tChainParams, tLogger, dex.Simnet) if err != nil { shutdown() panic(err.Error()) } - wallet.wallet = &rpcWallet{ + rpcw := &rpcWallet{ rpcClient: client, log: log, } + rpcw.accountsV.Store(XCWalletAccounts{ + PrimaryAccount: tAcctName, + }) + wallet.wallet = rpcw wallet.ctx = walletCtx // Initialize the best block. @@ -3966,7 +3970,7 @@ type tReconfigurer struct { err error } -func (r *tReconfigurer) Reconfigure(ctx context.Context, cfg *asset.WalletConfig, net dex.Network, currentAddress, depositAccount string) (restartRequired bool, err error) { +func (r *tReconfigurer) Reconfigure(ctx context.Context, cfg *asset.WalletConfig, net dex.Network, currentAddress string) (restartRequired bool, err error) { return r.restart, r.err } @@ -3984,9 +3988,6 @@ func TestReconfigure(t *testing.T) { defer cancel() cfg1 := &walletConfig{ - PrimaryAccount: "primary", - UnmixedAccount: "unmixed", - TradingAccount: "trading", UseSplitTx: true, FallbackFeeRate: 55, FeeRateLimit: 98, @@ -3995,9 +3996,6 @@ func TestReconfigure(t *testing.T) { } cfg2 := &walletConfig{ - PrimaryAccount: "primary2", - UnmixedAccount: "unmixed2", - TradingAccount: "trading2", UseSplitTx: false, FallbackFeeRate: 66, FeeRateLimit: 97, @@ -4005,11 +4003,9 @@ func TestReconfigure(t *testing.T) { ApiFeeFallback: false, } + // TODO: Test account names reconfiguration for rpcwallets. checkConfig := func(cfg *walletConfig) { - if cfg.PrimaryAccount != wallet.config().primaryAcct || - cfg.UnmixedAccount != wallet.config().unmixedAccount || - cfg.TradingAccount != wallet.config().tradingAccount || - cfg.UseSplitTx != wallet.config().useSplitTx || + if cfg.UseSplitTx != wallet.config().useSplitTx || toAtoms(cfg.FallbackFeeRate/1000) != wallet.config().fallbackFeeRate || toAtoms(cfg.FeeRateLimit/1000) != wallet.config().feeRateLimit || cfg.RedeemConfTarget != wallet.config().redeemConfTarget || diff --git a/client/asset/dcr/native_wallet.go b/client/asset/dcr/native_wallet.go new file mode 100644 index 0000000000..c3996f3f92 --- /dev/null +++ b/client/asset/dcr/native_wallet.go @@ -0,0 +1,233 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package dcr + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "net" + "os" + "path/filepath" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/dex" + "github.com/decred/dcrd/chaincfg/chainhash" + "github.com/decred/dcrd/dcrutil/v4" +) + +const ( + csppConfigFileName = "cspp_config.json" +) + +type NativeWallet struct { + *ExchangeWallet + csppConfigFilePath string +} + +// NativeWallet must also satisfy the following interface(s). +var _ asset.FundsMixer = (*NativeWallet)(nil) + +type csppConfig struct { + CSPPServer string `json:"csppserver"` + CSPPServerCAPath string `json:"csppservercapath"` +} + +func initNativeWallet(w *ExchangeWallet) (*NativeWallet, error) { + spvWallet, ok := w.wallet.(*spvWallet) + if !ok { + return nil, fmt.Errorf("spvwallet is required to init NativeWallet") + } + + csppConfigFilePath := filepath.Join(spvWallet.dir, csppConfigFileName) + b, err := os.ReadFile(csppConfigFilePath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("unable to read cspp config file: %v", err) + } + + if len(b) > 0 { + var cfg csppConfig + err = json.Unmarshal(b, &cfg) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal csppConfig: %v", err) + } + + spvWallet.csppServer = cfg.CSPPServer + spvWallet.csppTLSConfig, err = makeCSPPTLSConfig(cfg.CSPPServer, cfg.CSPPServerCAPath) + if err != nil { + return nil, fmt.Errorf("unable to parse cspp tls config: %v", err) + } + } + + return &NativeWallet{ + ExchangeWallet: w, + csppConfigFilePath: csppConfigFilePath, + }, nil +} + +func makeCSPPTLSConfig(serverAddress, caPath string) (*tls.Config, error) { + serverName, _, err := net.SplitHostPort(serverAddress) + if err != nil { + return nil, fmt.Errorf("Cannot parse CoinShuffle++ server name %q: %v", serverAddress, err) + } + + tlsConfig := new(tls.Config) + tlsConfig.ServerName = serverName + + if caPath != "" { + caPath = dex.CleanAndExpandPath(caPath) + ca, err := os.ReadFile(caPath) + if err != nil { + return nil, fmt.Errorf("Cannot read CoinShuffle++ Certificate Authority file: %v", err) + } + + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(ca) + tlsConfig.RootCAs = pool + } + + return tlsConfig, nil +} + +// ConfigureFundsMixer configures the wallet for funds mixing. Part of the +// asset.FundsMixer interface. +func (dcr *NativeWallet) ConfigureFundsMixer(serverAddress, serverTLSCertPath string) error { + spvWallet, ok := dcr.wallet.(*spvWallet) + if !ok { + return fmt.Errorf("invalid NativeWallet") + } + + if serverAddress == "" { + switch dcr.network { + case dex.Mainnet: + serverAddress = defaultCSPPMainnet + case dex.Testnet: + serverAddress = defaultCSPPTestnet3 + default: + return fmt.Errorf("cspp server address is required for network %q (ID %d)", dcr.network, uint8(dcr.network)) + } + } + + tlsConfig, err := makeCSPPTLSConfig(serverAddress, serverTLSCertPath) + if err != nil { + return err + } + + csppCfgBytes, err := json.Marshal(&csppConfig{ + CSPPServer: serverAddress, + CSPPServerCAPath: serverTLSCertPath, + }) + if err != nil { + return err + } + if err := os.WriteFile(dcr.csppConfigFilePath, csppCfgBytes, 0666); err != nil { + return err + } + + spvWallet.updateCSPPServerConfig(serverAddress, tlsConfig) + return nil +} + +// FundsMixingStats returns the current state of the wallet's funds mixer. Part +// of the asset.FundsMixer interface. +func (dcr *NativeWallet) FundsMixingStats() (*asset.FundsMixingStats, error) { + spvWallet, ok := dcr.wallet.(*spvWallet) + if !ok { + return nil, fmt.Errorf("invalid NativeWallet") + } + + mixedBalance, err := dcr.wallet.AccountBalance(dcr.ctx, 0, mixedAccountName) + if err != nil { + return nil, err + } + + unmixedBalance, err := dcr.wallet.AccountBalance(dcr.ctx, 0, defaultAcctName) + if err != nil { + return nil, err + } + + return &asset.FundsMixingStats{ + Enabled: spvWallet.isMixerEnabled(), + IsMixing: spvWallet.isMixing(), + MixedBalance: toAtoms(mixedBalance.Total), + UnmixedBalance: toAtoms(unmixedBalance.Total), + UnmixedBalanceThreshold: smalletCSPPSplitPoint, + }, nil +} + +// StartFundsMixer starts the funds mixer. This will error if the wallet does +// not allow starting or stopping the mixer or if the mixer was already +// started. Part of the asset.FundsMixer interface. +func (dcr *NativeWallet) StartFundsMixer(ctx context.Context) error { + spvWallet, ok := dcr.wallet.(*spvWallet) + if !ok { + return fmt.Errorf("invalid NativeWallet") + } + + return spvWallet.startFundsMixer(ctx) +} + +// StopFundsMixer stops the funds mixer. This will error if the wallet does not +// allow starting or stopping the mixer or if the mixer is not already running. +// Part of the asset.FundsMixer interface. +func (dcr *NativeWallet) StopFundsMixer() error { + spvWallet, ok := dcr.wallet.(*spvWallet) + if !ok { + return fmt.Errorf("invalid NativeWallet") + } + return spvWallet.StopFundsMixer() +} + +// DisableFundsMixer disables the funds mixer and moves all funds to the default +// account. The wallet will need to be re-configured to re-enable mixing. Part +// of the asset.FundsMixer interface. +func (dcr *NativeWallet) DisableFundsMixer() error { + if spvWallet, ok := dcr.wallet.(*spvWallet); ok { + spvWallet.StopFundsMixer() // ignore any error, just means mixer wasn't running + } + + // Move funds from mixed and trading account to default account. + unspents, err := dcr.wallet.Unspents(dcr.ctx, mixedAccountName) + if err != nil { + return err + } + tradingAcctSpendables, err := dcr.wallet.Unspents(dcr.ctx, tradingAccount) + if err != nil { + return err + } + unspents = append(unspents, tradingAcctSpendables...) + + if len(unspents) > 0 { + var coinsToTransfer asset.Coins + for _, unspent := range unspents { + txHash, err := chainhash.NewHashFromStr(unspent.TxID) + if err != nil { + return fmt.Errorf("error decoding txid: %w", err) + } + v := toAtoms(unspent.Amount) + op := newOutput(txHash, unspent.Vout, v, unspent.Tree) + coinsToTransfer = append(coinsToTransfer, op) + } + + tx, totalSent, err := dcr.sendAll(coinsToTransfer, defaultAcctName) + if err != nil { + return fmt.Errorf("unable to transfer all funds from mixed and trading accounts: %v", err) + } else { + dcr.log.Infof("Transferred %s from mixed and trading accounts to default account in tx %s.", + dcrutil.Amount(totalSent), tx.TxHash()) + } + } + + // Delete the cspp config file after moving funds, to prevent the mixer from + // starting when the wallet is restarted. If moving the funds above failed, + // this file will be left untouched and the mixer isn't really disabled yet. + if err := os.Remove(dcr.csppConfigFilePath); err != nil { + return fmt.Errorf("unable to delete cfg file: %v", err) + } + + return nil +} diff --git a/client/asset/dcr/rpcwallet.go b/client/asset/dcr/rpcwallet.go index 46284f7328..e00a356a90 100644 --- a/client/asset/dcr/rpcwallet.go +++ b/client/asset/dcr/rpcwallet.go @@ -61,6 +61,7 @@ type rpcWallet struct { chainParams *chaincfg.Params log dex.Logger rpcCfg *rpcclient.ConnConfig + accountsV atomic.Value // XCWalletAccounts rpcMtx sync.RWMutex spvMode bool @@ -203,11 +204,22 @@ func newRPCWallet(settings map[string]string, logger dex.Logger, net dex.Network rpcw.rpcConnector = nodeRPCClient rpcw.rpcClient = newCombinedClient(nodeRPCClient, chainParams) + rpcw.accountsV.Store(XCWalletAccounts{ + PrimaryAccount: cfg.PrimaryAccount, + UnmixedAccount: cfg.UnmixedAccount, + TradingAccount: cfg.TradingAccount, + }) + return rpcw, nil } +// Accounts returns the names of the accounts for use by the exchange wallet. +func (w *rpcWallet) Accounts() XCWalletAccounts { + return w.accountsV.Load().(XCWalletAccounts) +} + // Reconfigure updates the wallet to user a new configuration. -func (w *rpcWallet) Reconfigure(ctx context.Context, cfg *asset.WalletConfig, net dex.Network, currentAddress, depositAccount string) (restart bool, err error) { +func (w *rpcWallet) Reconfigure(ctx context.Context, cfg *asset.WalletConfig, net dex.Network, currentAddress string) (restart bool, err error) { if !(cfg.Type == walletTypeDcrwRPC || cfg.Type == walletTypeLegacy) { return true, nil } @@ -226,14 +238,28 @@ func (w *rpcWallet) Reconfigure(ctx context.Context, cfg *asset.WalletConfig, ne if chainParams.Net != w.chainParams.Net { return false, errors.New("cannot reconfigure to use different network") } + certs, err := os.ReadFile(rpcCfg.RPCCert) if err != nil { return false, fmt.Errorf("TLS certificate read error: %w", err) } + + var allOk bool + defer func() { + if allOk { // update the account names as the last step + w.accountsV.Store(XCWalletAccounts{ + PrimaryAccount: rpcCfg.PrimaryAccount, + UnmixedAccount: rpcCfg.UnmixedAccount, + TradingAccount: rpcCfg.TradingAccount, + }) + } + }() + if rpcCfg.RPCUser == w.rpcCfg.User && rpcCfg.RPCPass == w.rpcCfg.Pass && bytes.Equal(certs, w.rpcCfg.Certificates) && rpcCfg.RPCListen == w.rpcCfg.Host { + allOk = true return false, nil } @@ -252,6 +278,12 @@ func (w *rpcWallet) Reconfigure(ctx context.Context, cfg *asset.WalletConfig, ne if err != nil { return false, err } + var depositAccount string + if rpcCfg.UnmixedAccount != "" { + depositAccount = rpcCfg.UnmixedAccount + } else { + depositAccount = rpcCfg.PrimaryAccount + } owns, err := newWallet.AccountOwnsAddress(ctx, a, depositAccount) if err != nil { return false, err @@ -269,6 +301,7 @@ func (w *rpcWallet) Reconfigure(ctx context.Context, cfg *asset.WalletConfig, ne w.rpcConnector = newWallet.rpcConnector w.rpcClient = newWallet.rpcClient + allOk = true return false, nil } diff --git a/client/asset/dcr/spv.go b/client/asset/dcr/spv.go index 68b09b1450..2e46a17a7a 100644 --- a/client/asset/dcr/spv.go +++ b/client/asset/dcr/spv.go @@ -5,6 +5,7 @@ package dcr import ( "context" + "crypto/tls" "encoding/base64" "encoding/hex" "errors" @@ -52,6 +53,7 @@ const ( defaultRelayFeePerKb = 1e4 defaultAccountGapLimit = 10 defaultManualTickets = false + defaultMixSplitLimit = 10 defaultAcct = 0 defaultAcctName = "default" @@ -63,12 +65,15 @@ const ( type dcrWallet interface { KnownAddress(ctx context.Context, a stdaddr.Address) (wallet.KnownAddress, error) + AccountNumber(ctx context.Context, accountName string) (uint32, error) AccountBalance(ctx context.Context, account uint32, confirms int32) (wallet.Balances, error) LockedOutpoints(ctx context.Context, accountName string) ([]chainjson.TransactionInput, error) ListUnspent(ctx context.Context, minconf, maxconf int32, addresses map[string]struct{}, accountName string) ([]*walletjson.ListUnspentResult, error) LockOutpoint(txHash *chainhash.Hash, index uint32) ListTransactionDetails(ctx context.Context, txHash *chainhash.Hash) ([]walletjson.ListTransactionsResult, error) + MixAccount(ctx context.Context, dialTLS wallet.DialFunc, csppserver string, changeAccount, mixAccount, mixBranch uint32) error MainChainTip(ctx context.Context) (hash chainhash.Hash, height int32) + MainTipChangedNotifications() (chan *wallet.MainTipChangedNotification, func()) NewExternalAddress(ctx context.Context, account uint32, callOpts ...wallet.NextAddressCallOption) (stdaddr.Address, error) NewInternalAddress(ctx context.Context, account uint32, callOpts ...wallet.NextAddressCallOption) (stdaddr.Address, error) PublishTransaction(ctx context.Context, tx *wire.MsgTx, n wallet.NetworkBackend) (*chainhash.Hash, error) @@ -129,13 +134,19 @@ func (w *extendedWallet) TxDetails(ctx context.Context, txHash *chainhash.Hash) return wallet.UnstableAPI(w.Wallet).TxDetails(ctx, txHash) } +// MainTipChangedNotifications returns a channel for receiving main tip change +// notifications, along with a function to close the channel when it is no +// longer needed. +func (w *extendedWallet) MainTipChangedNotifications() (chan *wallet.MainTipChangedNotification, func()) { + ntfn := w.NtfnServer.MainTipChangedNotifications() + return ntfn.C, ntfn.Done +} + // spvWallet is a Wallet built on dcrwallet's *wallet.Wallet running in SPV // mode. type spvWallet struct { dcrWallet // *wallet.Wallet db wallet.DB - acctNum uint32 - acctName string dir string chainParams *chaincfg.Params log dex.Logger @@ -145,6 +156,15 @@ type spvWallet struct { blockCache blockCache + // hasMixingAccts is used to track if a wallet has the accounts required for + // funds mixing. + hasMixingAccts atomic.Bool + + csppMtx sync.Mutex + csppServer string + csppTLSConfig *tls.Config + cancelCSPPMixer context.CancelFunc + cancel context.CancelFunc wg sync.WaitGroup } @@ -231,6 +251,11 @@ func createSPVWallet(pw, seed []byte, dataDir string, extIdx, intIdx uint32, cha return fmt.Errorf("error setting Decred account %d passphrase: %v", defaultAcct, err) } + err = setupMixingAccounts(ctx, w, pw) + if err != nil { + return fmt.Errorf("error setting up mixing accounts: %v", err) + } + w.Lock() if extIdx > 0 || intIdx > 0 { @@ -279,7 +304,50 @@ func (w *spvWallet) initializeSimnetTspends(ctx context.Context) { } } -func (w *spvWallet) Reconfigure(ctx context.Context, cfg *asset.WalletConfig, net dex.Network, currentAddress, depositAccount string) (restart bool, err error) { +// setupMixingAccounts checks if the mixed, unmixed and trading accounts +// required to use this wallet for funds mixing exists and creates any of the +// accounts that does not yet exist. The wallet should be unlocked before +// calling this function. +func setupMixingAccounts(ctx context.Context, w *wallet.Wallet, pw []byte) error { + requiredAccts := []string{mixedAccountName, tradingAccount} // unmixed (default) acct already exists + for _, acct := range requiredAccts { + _, err := w.AccountNumber(ctx, acct) + if err == nil { + continue // account exist, check next account + } + + if !errors.Is(err, walleterrors.NotExist) { + return err + } + + acctNum, err := w.NextAccount(ctx, acct) + if err != nil { + return err + } + if err = w.SetAccountPassphrase(ctx, acctNum, pw); err != nil { + return err + } + } + + return nil +} + +// Accounts returns the names of the accounts for use by the exchange wallet. +func (w *spvWallet) Accounts() XCWalletAccounts { + if w.isMixerEnabled() { + return XCWalletAccounts{ + PrimaryAccount: mixedAccountName, + UnmixedAccount: defaultAcctName, + TradingAccount: tradingAccount, + } + } + + return XCWalletAccounts{ + PrimaryAccount: defaultAcctName, + } +} + +func (w *spvWallet) Reconfigure(ctx context.Context, cfg *asset.WalletConfig, net dex.Network, currentAddress string) (restart bool, err error) { return cfg.Type != walletTypeSPV, nil } @@ -469,7 +537,7 @@ func (w *spvWallet) AddressInfo(ctx context.Context, addrStr string) (*AddressIn // AccountOwnsAddress checks if the provided address belongs to the specified // account. // Part of the Wallet interface. -func (w *spvWallet) AccountOwnsAddress(ctx context.Context, addr stdaddr.Address, _ string) (bool, error) { +func (w *spvWallet) AccountOwnsAddress(ctx context.Context, addr stdaddr.Address, account string) (bool, error) { ka, err := w.KnownAddress(ctx, addr) if err != nil { if errors.Is(err, walleterrors.NotExist) { @@ -477,7 +545,7 @@ func (w *spvWallet) AccountOwnsAddress(ctx context.Context, addr stdaddr.Address } return false, fmt.Errorf("KnownAddress error: %w", err) } - if ka.AccountName() != w.acctName { + if ka.AccountName() != account { return false, nil } if kind := ka.AccountKind(); kind != wallet.AccountKindBIP0044 && kind != wallet.AccountKindImported { @@ -488,14 +556,14 @@ func (w *spvWallet) AccountOwnsAddress(ctx context.Context, addr stdaddr.Address // AccountBalance returns the balance breakdown for the specified account. // Part of the Wallet interface. -func (w *spvWallet) AccountBalance(ctx context.Context, confirms int32, _ string) (*walletjson.GetAccountBalanceResult, error) { - bal, err := w.dcrWallet.AccountBalance(ctx, w.acctNum, confirms) +func (w *spvWallet) AccountBalance(ctx context.Context, confirms int32, accountName string) (*walletjson.GetAccountBalanceResult, error) { + bal, err := w.accountBalance(ctx, confirms, accountName) if err != nil { return nil, err } return &walletjson.GetAccountBalanceResult{ - AccountName: w.acctName, + AccountName: accountName, ImmatureCoinbaseRewards: bal.ImmatureCoinbaseRewards.ToCoin(), ImmatureStakeGeneration: bal.ImmatureStakeGeneration.ToCoin(), LockedByTickets: bal.LockedByTickets.ToCoin(), @@ -506,16 +574,24 @@ func (w *spvWallet) AccountBalance(ctx context.Context, confirms int32, _ string }, nil } +func (w *spvWallet) accountBalance(ctx context.Context, confirms int32, accountName string) (wallet.Balances, error) { + acctNum, err := w.dcrWallet.AccountNumber(ctx, accountName) + if err != nil { + return wallet.Balances{}, err + } + return w.dcrWallet.AccountBalance(ctx, acctNum, confirms) +} + // LockedOutputs fetches locked outputs for the specified account. // Part of the Wallet interface. -func (w *spvWallet) LockedOutputs(ctx context.Context, _ string) ([]chainjson.TransactionInput, error) { - return w.dcrWallet.LockedOutpoints(ctx, w.acctName) +func (w *spvWallet) LockedOutputs(ctx context.Context, accountName string) ([]chainjson.TransactionInput, error) { + return w.dcrWallet.LockedOutpoints(ctx, accountName) } // Unspents fetches unspent outputs for the specified account. // Part of the Wallet interface. -func (w *spvWallet) Unspents(ctx context.Context, _ string) ([]*walletjson.ListUnspentResult, error) { - return w.dcrWallet.ListUnspent(ctx, 0, math.MaxInt32, nil, w.acctName) +func (w *spvWallet) Unspents(ctx context.Context, accountName string) ([]*walletjson.ListUnspentResult, error) { + return w.dcrWallet.ListUnspent(ctx, 0, math.MaxInt32, nil, accountName) } // LockUnspent locks or unlocks the specified outpoint. @@ -598,14 +674,22 @@ func (w *spvWallet) UnspentOutput(ctx context.Context, txHash *chainhash.Hash, i // Part of the Wallet interface. // Using GapPolicyWrap here, introducing a relatively small risk of address // reuse, but improving wallet recoverability. -func (w *spvWallet) ExternalAddress(ctx context.Context, _ string) (stdaddr.Address, error) { - return w.NewExternalAddress(ctx, w.acctNum, wallet.WithGapPolicyWrap()) +func (w *spvWallet) ExternalAddress(ctx context.Context, accountName string) (stdaddr.Address, error) { + acctNum, err := w.dcrWallet.AccountNumber(ctx, accountName) + if err != nil { + return nil, err + } + return w.NewExternalAddress(ctx, acctNum, wallet.WithGapPolicyWrap()) } // InternalAddress returns an internal address using GapPolicyIgnore. // Part of the Wallet interface. -func (w *spvWallet) InternalAddress(ctx context.Context, _ string) (stdaddr.Address, error) { - return w.NewInternalAddress(ctx, w.acctNum, wallet.WithGapPolicyWrap()) +func (w *spvWallet) InternalAddress(ctx context.Context, accountName string) (stdaddr.Address, error) { + acctNum, err := w.dcrWallet.AccountNumber(ctx, accountName) + if err != nil { + return nil, err + } + return w.NewInternalAddress(ctx, acctNum, wallet.WithGapPolicyWrap()) } // SignRawTransaction signs the provided transaction. @@ -815,20 +899,58 @@ func (w *spvWallet) GetBlockHash(ctx context.Context, blockHeight int64) (*chain // AccountUnlocked returns true if the account is unlocked. // Part of the Wallet interface. -func (w *spvWallet) AccountUnlocked(ctx context.Context, _ string) (bool, error) { - return w.dcrWallet.AccountUnlocked(ctx, w.acctNum) +func (w *spvWallet) AccountUnlocked(ctx context.Context, accountName string) (bool, error) { + acctNum, err := w.dcrWallet.AccountNumber(ctx, accountName) + if err != nil { + return false, err + } + return w.dcrWallet.AccountUnlocked(ctx, acctNum) } // LockAccount locks the specified account. // Part of the Wallet interface. -func (w *spvWallet) LockAccount(ctx context.Context, _ string) error { - return w.dcrWallet.LockAccount(ctx, w.acctNum) +func (w *spvWallet) LockAccount(ctx context.Context, accountName string) error { + acctNum, err := w.dcrWallet.AccountNumber(ctx, accountName) + if err != nil { + return err + } + return w.dcrWallet.LockAccount(ctx, acctNum) } // UnlockAccount unlocks the specified account or the wallet if account is not // encrypted. Part of the Wallet interface. -func (w *spvWallet) UnlockAccount(ctx context.Context, pw []byte, _ string) error { - return w.dcrWallet.UnlockAccount(ctx, w.acctNum, pw) +func (w *spvWallet) UnlockAccount(ctx context.Context, pw []byte, accountName string) error { + acctNum, err := w.dcrWallet.AccountNumber(ctx, accountName) + if err != nil { + return err + } + if err = w.checkMixingAccounts(ctx, pw); err != nil { + return fmt.Errorf("error checking mixing accounts: %v", err) + } + return w.dcrWallet.UnlockAccount(ctx, acctNum, pw) +} + +func (w *spvWallet) checkMixingAccounts(ctx context.Context, pw []byte) error { + if w.hasMixingAccts.Load() { + return nil + } + + originalW, ok := w.dcrWallet.(*extendedWallet) + if !ok { + return nil // assume the accts exist, since we can't verify + } + + if err := originalW.Unlock(ctx, pw, nil); err != nil { + return fmt.Errorf("cannot unlock wallet to check mixing accts: %v", err) + } + defer originalW.Lock() + + if err := setupMixingAccounts(ctx, originalW.Wallet, pw); err != nil { + return err + } + + w.hasMixingAccts.Store(true) + return nil } // SyncStatus returns the wallet's sync status. @@ -1320,6 +1442,7 @@ func newWalletConfig(db wallet.DB, chainParams *chaincfg.Params) *wallet.Config AllowHighFees: defaultAllowHighFees, RelayFee: defaultRelayFeePerKb, Params: chainParams, + MixSplitLimit: defaultMixSplitLimit, } } diff --git a/client/asset/dcr/spv_mixing.go b/client/asset/dcr/spv_mixing.go new file mode 100644 index 0000000000..9f4f805f90 --- /dev/null +++ b/client/asset/dcr/spv_mixing.go @@ -0,0 +1,136 @@ +package dcr + +import ( + "context" + "crypto/tls" + "fmt" + "net" + + "decred.org/dcrwallet/v3/wallet/udb" +) + +const ( + smalletCSPPSplitPoint = 1 << 18 // 262144 + mixedAccountName = "mixed" + mixedAccountBranch = udb.InternalBranch + tradingAccount = "dextrading" +) + +func (w *spvWallet) updateCSPPServerConfig(serverAddress string, tlsConfig *tls.Config) { + if serverAddress == w.csppServer && tlsConfig.ServerName == w.csppTLSConfig.ServerName && + tlsConfig.RootCAs.Equal(w.csppTLSConfig.RootCAs) { + return // nothing to update + } + + w.csppMtx.Lock() + defer w.csppMtx.Unlock() + + // Stop the mixer if it is currently running. The next run will use the + // updated server address and tlsConfig. + if w.cancelCSPPMixer != nil { + w.cancelCSPPMixer() + w.cancelCSPPMixer = nil + } + + w.csppServer = serverAddress + w.csppTLSConfig = tlsConfig +} + +func (w *spvWallet) isMixerEnabled() bool { + w.csppMtx.Lock() + defer w.csppMtx.Unlock() + return w.csppServer != "" +} + +// isMixing is true if the wallet is currently mixing funds. +func (w *spvWallet) isMixing() bool { + w.csppMtx.Lock() + defer w.csppMtx.Unlock() + return w.cancelCSPPMixer != nil +} + +// startFundsMixer starts mixing funds. Requires the wallet to be unlocked. +func (w *spvWallet) startFundsMixer(ctx context.Context) error { + w.csppMtx.Lock() + defer w.csppMtx.Unlock() + + if w.cancelCSPPMixer != nil { + return fmt.Errorf("funds mixer is already running") + } + + if w.csppServer == "" { + return fmt.Errorf("funds mixer is disabled, configure first") + } + + mixedAccount, err := w.dcrWallet.AccountNumber(ctx, mixedAccountName) + if err != nil { + return fmt.Errorf("unable to look up mixed account: %v", err) + } + + // unmixed account is the default account + unmixedAccount := uint32(defaultAcct) + + ctx, cancel := context.WithCancel(ctx) + w.cancelCSPPMixer = cancel + + csppServer, csppTLSConfig := w.csppServer, w.csppTLSConfig + dialCSPPServer := func(ctx context.Context, network, addr string) (net.Conn, error) { + conn, err := new(net.Dialer).DialContext(ctx, network, addr) + if err != nil { + return nil, err + } + conn = tls.Client(conn, csppTLSConfig) + return conn, nil + } + + w.log.Debugf("Starting cspp funds mixer with %s", csppServer) + + go func() { + tipChangeCh, stopTipChangeNtfn := w.dcrWallet.MainTipChangedNotifications() + defer func() { + stopTipChangeNtfn() + w.StopFundsMixer() + }() + + for { + select { + case <-ctx.Done(): + return + + case n := <-tipChangeCh: + if len(n.AttachedBlocks) == 0 { + continue + } + + // Don't perform any actions while transactions are not synced + // through the tip block. + if !w.spv.Synced() { + w.log.Debugf("Skipping autobuyer actions: transactions are not synced") + continue + } + + go func() { + err := w.dcrWallet.MixAccount(ctx, dialCSPPServer, csppServer, unmixedAccount, mixedAccount, mixedAccountBranch) + if err != nil { + w.log.Error(err) + } + }() + } + } + }() + + return nil +} + +// StopFundsMixer stops the funds mixer. This will error if the mixer was not +// already running. +func (w *spvWallet) StopFundsMixer() error { + w.csppMtx.Lock() + defer w.csppMtx.Unlock() + if w.cancelCSPPMixer == nil { + return fmt.Errorf("funds mixer isn't running") + } + w.cancelCSPPMixer() + w.cancelCSPPMixer = nil + return nil +} diff --git a/client/asset/dcr/spv_test.go b/client/asset/dcr/spv_test.go index 3cda395596..a4d1f15f8a 100644 --- a/client/asset/dcr/spv_test.go +++ b/client/asset/dcr/spv_test.go @@ -5,6 +5,7 @@ package dcr import ( "context" "errors" + "fmt" "testing" "time" @@ -63,25 +64,34 @@ type tDcrWallet struct { filterErr error blockInfo *wallet.BlockInfo blockInfoErr error - acctLocked bool - acctUnlockedErr error - lockAcctErr error - unlockAcctErr error - priv *secp256k1.PrivateKey - privKeyErr error - txDetails *udb.TxDetails - txDetailsErr error - remotePeers map[string]*p2p.RemotePeer - spvBlocks []*wire.MsgBlock - spvBlocksErr error - unlockedOutpoint *wire.OutPoint - lockedOutpoint *wire.OutPoint + // walletLocked bool + acctLocked bool + acctUnlockedErr error + lockAcctErr error + unlockAcctErr error + priv *secp256k1.PrivateKey + privKeyErr error + txDetails *udb.TxDetails + txDetailsErr error + remotePeers map[string]*p2p.RemotePeer + spvBlocks []*wire.MsgBlock + spvBlocksErr error + unlockedOutpoint *wire.OutPoint + lockedOutpoint *wire.OutPoint } func (w *tDcrWallet) KnownAddress(ctx context.Context, a stdaddr.Address) (wallet.KnownAddress, error) { return w.knownAddr, w.knownAddrErr } +func (w *tDcrWallet) AccountNumber(ctx context.Context, accountName string) (uint32, error) { + return 0, nil +} + +func (w *tDcrWallet) NextAccount(ctx context.Context, name string) (uint32, error) { + return 0, fmt.Errorf("not stubbed") +} + func (w *tDcrWallet) AccountBalance(ctx context.Context, account uint32, confirms int32) (wallet.Balances, error) { return w.acctBal, w.acctBalErr } @@ -112,10 +122,18 @@ func (w *tDcrWallet) ListTransactionDetails(ctx context.Context, txHash *chainha return w.listTxs, w.listTxsErr } +func (w *tDcrWallet) MixAccount(ctx context.Context, dialTLS wallet.DialFunc, csppserver string, changeAccount, mixAccount, mixBranch uint32) error { + return fmt.Errorf("not stubbed") +} + func (w *tDcrWallet) MainChainTip(ctx context.Context) (hash chainhash.Hash, height int32) { return w.tip.hash, w.tip.height } +func (w *tDcrWallet) MainTipChangedNotifications() (chan *wallet.MainTipChangedNotification, func()) { + return nil, nil +} + func (w *tDcrWallet) NewExternalAddress(ctx context.Context, account uint32, callOpts ...wallet.NextAddressCallOption) (stdaddr.Address, error) { return w.extAddr, w.extAddrErr } @@ -155,6 +173,14 @@ func (w *tDcrWallet) BlockInfo(ctx context.Context, blockID *wallet.BlockIdentif return w.blockInfo, w.blockInfoErr } +func (w *tDcrWallet) AccountHasPassphrase(ctx context.Context, account uint32) (bool, error) { + return false, fmt.Errorf("not stubbed") +} + +func (w *tDcrWallet) SetAccountPassphrase(ctx context.Context, account uint32, passphrase []byte) error { + return fmt.Errorf("not stubbed") +} + func (w *tDcrWallet) AccountUnlocked(ctx context.Context, account uint32) (bool, error) { return !w.acctLocked, w.acctUnlockedErr } @@ -167,6 +193,10 @@ func (w *tDcrWallet) UnlockAccount(ctx context.Context, account uint32, passphra return w.unlockAcctErr } +func (w *tDcrWallet) Unlock(ctx context.Context, passphrase []byte, timeout <-chan time.Time) error { + return fmt.Errorf("not stubbed") +} + func (w *tDcrWallet) LoadPrivateKey(ctx context.Context, addr stdaddr.Address) (key *secp256k1.PrivateKey, zero func(), err error) { return w.priv, func() {}, w.privKeyErr } @@ -351,7 +381,6 @@ func tNewSpvWallet() (*spvWallet, *tDcrWallet) { blockCache: blockCache{ blocks: make(map[chainhash.Hash]*cachedBlock), }, - acctName: tAcctName, }, dcrw } @@ -383,7 +412,7 @@ func TestAccountOwnsAddress(t *testing.T) { dcrw.knownAddr = kaddr // Initial success - if have, err := w.AccountOwnsAddress(tCtx, tPKHAddr, ""); err != nil { + if have, err := w.AccountOwnsAddress(tCtx, tPKHAddr, tAcctName); err != nil { t.Fatalf("initial success trial failed: %v", err) } else if !have { t.Fatal("failed initial success. have = false") @@ -391,7 +420,7 @@ func TestAccountOwnsAddress(t *testing.T) { // Foreign address dcrw.knownAddrErr = walleterrors.NotExist - if have, err := w.AccountOwnsAddress(tCtx, tPKHAddr, ""); err != nil { + if have, err := w.AccountOwnsAddress(tCtx, tPKHAddr, tAcctName); err != nil { t.Fatalf("unexpected error when should just be have = false for foreign address: %v", err) } else if have { t.Fatalf("shouldn't have, but have for foreign address") @@ -399,14 +428,14 @@ func TestAccountOwnsAddress(t *testing.T) { // Other KnownAddress error dcrw.knownAddrErr = tErr - if _, err := w.AccountOwnsAddress(tCtx, tPKHAddr, ""); err == nil { + if _, err := w.AccountOwnsAddress(tCtx, tPKHAddr, tAcctName); err == nil { t.Fatal("no error for KnownAddress error") } dcrw.knownAddrErr = nil // Wrong account kaddr.acctName = "not the right name" - if have, err := w.AccountOwnsAddress(tCtx, tPKHAddr, ""); err != nil { + if have, err := w.AccountOwnsAddress(tCtx, tPKHAddr, tAcctName); err != nil { t.Fatalf("unexpected error when should just be have = false for wrong account: %v", err) } else if have { t.Fatalf("shouldn't have, but have for wrong account") @@ -415,7 +444,7 @@ func TestAccountOwnsAddress(t *testing.T) { // Wrong type kaddr.acctType = wallet.AccountKindImportedXpub - if have, err := w.AccountOwnsAddress(tCtx, tPKHAddr, ""); err != nil { + if have, err := w.AccountOwnsAddress(tCtx, tPKHAddr, tAcctName); err != nil { t.Fatalf("don't have trial failed: %v", err) } else if have { t.Fatal("have, but shouldn't") diff --git a/client/asset/dcr/wallet.go b/client/asset/dcr/wallet.go index 112971bfd4..2bdcc02574 100644 --- a/client/asset/dcr/wallet.go +++ b/client/asset/dcr/wallet.go @@ -63,6 +63,12 @@ type AddressInfo struct { Branch uint32 } +type XCWalletAccounts struct { + PrimaryAccount string + UnmixedAccount string + TradingAccount string +} + // Wallet defines methods that the ExchangeWallet uses for communicating with // a Decred wallet and blockchain. type Wallet interface { @@ -73,6 +79,9 @@ type Wallet interface { // SpvMode returns true if the wallet is connected to the Decred // network via SPV peers. SpvMode() bool + // Accounts returns the names of the accounts for use by the exchange + // wallet. + Accounts() XCWalletAccounts // NotifyOnTipChange registers a callback function that should be // invoked when the wallet sees new mainchain blocks. The return value // indicates if this notification can be provided. Where this tip change @@ -157,8 +166,8 @@ type Wallet interface { // be voted on. SetVotingPreferences(ctx context.Context, choices, tspendPolicy, treasuryPolicy map[string]string) error SetTxFee(ctx context.Context, feePerKB dcrutil.Amount) error - Reconfigure(ctx context.Context, cfg *asset.WalletConfig, net dex.Network, currentAddress, depositAccount string) (restart bool, err error) StakeInfo(ctx context.Context) (*wallet.StakeInfoData, error) + Reconfigure(ctx context.Context, cfg *asset.WalletConfig, net dex.Network, currentAddress string) (restart bool, err error) } // WalletTransaction is a pared down version of walletjson.GetTransactionResult. @@ -174,6 +183,7 @@ type WalletTransaction struct { // DRAFT NOTE: This is alternative to NotifyOnTipChange. I prefer this method, // and would vote to export this interface and get rid of NotifyOnTipChange. // @itswisdomagain might be using the current API though. +// TODO: Makes sense. type tipNotifier interface { tipFeed() <-chan *block } diff --git a/client/asset/interface.go b/client/asset/interface.go index 7c5dad7d87..ecb179c6f1 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -34,6 +34,7 @@ const ( WalletTraitAccountLocker // The wallet must have enough balance for redemptions before a trade. WalletTraitTicketBuyer // The wallet can participate in decred staking. WalletTraitHistorian // This wallet can return its transaction history + WalletTraitFundsMixer // The wallet can mix funds. ) // IsRescanner tests if the WalletTrait has the WalletTraitRescanner bit set. @@ -138,6 +139,12 @@ func (wt WalletTrait) IsHistorian() bool { return wt&WalletTraitHistorian != 0 } +// IsFundsMixer tests if the WalletTrait has the WalletTraitFundsMixer bit set, +// which indicates the wallet implements the FundsMixer interface. +func (wt WalletTrait) IsFundsMixer() bool { + return wt&WalletTraitFundsMixer != 0 +} + // DetermineWalletTraits returns the WalletTrait bitset for the provided Wallet. func DetermineWalletTraits(w Wallet) (t WalletTrait) { if _, is := w.(Rescanner); is { @@ -191,6 +198,9 @@ func DetermineWalletTraits(w Wallet) (t WalletTrait) { if _, is := w.(WalletHistorian); is { t |= WalletTraitHistorian } + if _, is := w.(FundsMixer); is { + t |= WalletTraitFundsMixer + } return t } @@ -685,6 +695,45 @@ type FeeRater interface { FeeRate() uint64 } +// FundsMixingStats describes the current state of a wallet's funds mixer. +type FundsMixingStats struct { + // Enabled is true if the wallet is configured for funds mixing. The wallet + // must be configured before mixing can be started. + Enabled bool + // IsMixing is true if the wallet is currently mixing funds. + IsMixing bool + // MixedBalance is the amount of funds that have been successfully mixed and + // may be withdrawn or used to fund trades. + MixedBalance uint64 + // UnmixedBalance is the amount of funds that are available and ready for + // mixing. If the wallet is not configured for mixing, this balance may be + // withdrawn or used to fund trades. + UnmixedBalance uint64 + // UnmixedBalanceThreshold is the minimum amount of unmixed funds that must + // be in the wallet for mixing to happen. + UnmixedBalanceThreshold uint64 +} + +// FundsMixer defines methods for mixing funds in a wallet. +type FundsMixer interface { + // FundsMixingStats returns the current state of the wallet's funds mixer. + FundsMixingStats() (*FundsMixingStats, error) + // ConfigureFundsMixer configures the wallet for funds mixing. + ConfigureFundsMixer(serverAddress, serverTLSCertPath string) error + // StartFundsMixer starts the funds mixer. This will error if the wallet + // does not allow starting or stopping the mixer or if the mixer was already + // started. + StartFundsMixer(ctx context.Context) error + // StopFundsMixer stops the funds mixer. This will error if the wallet does + // not allow starting or stopping the mixer or if the mixer was not already + // running. + StopFundsMixer() error + // DisableFundsMixer disables the funds mixer and moves all funds to the + // default account. The wallet will need to be re-configured to re-enable + // mixing. + DisableFundsMixer() error +} + // WalletRestoration contains all the information needed for a user to restore // their wallet in an external wallet. type WalletRestoration struct { diff --git a/client/core/core.go b/client/core/core.go index 19d753276c..8aff5638a7 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -5303,6 +5303,7 @@ func (c *Core) resolveActiveTrades(crypter encrypt.Crypter) { // resumeTrades will be a no-op if there are no trades in any // dexConnection's trades map that is not ready to tick. c.resumeTrades(crypter) + c.resumeMixing(crypter) } func (c *Core) wait(coinID []byte, assetID uint32, trigger func() (bool, error), action func(error)) { @@ -7587,6 +7588,31 @@ func (c *Core) dbTrackers(dc *dexConnection) (map[order.OrderID]*trackedTrade, e return trackers, nil } +// resumeMixing unlocks and starts mixing on any FundsMixer that is enabled. +func (c *Core) resumeMixing(crypter encrypt.Crypter) { + for _, w := range c.xcWallets() { + if mixer, is := w.Wallet.(asset.FundsMixer); is { + stats, err := mixer.FundsMixingStats() + if err != nil { + c.log.Errorf("FundsMixingStats error during login: %v", err) + continue + } + if !stats.Enabled { + continue + } + if !w.unlocked() { + if err := w.Unlock(crypter); err != nil { + c.log.Errorf("Error unlocking mixing wallet on initialization: %v", err) + continue + } + } + if err := mixer.StartFundsMixer(c.ctx); err != nil { + c.log.Errorf("Error starting funds mixer on initialization: %v", err) + } + } + } +} + // loadDBTrades load's the active trades from the db, populates the trade's // wallets field and some other metadata, and adds the trade to the // dexConnection's trades map. Every trade added to the trades map will diff --git a/client/webserver/site/src/html/forms.tmpl b/client/webserver/site/src/html/forms.tmpl index 2499f67dbe..9c48da4d75 100644 --- a/client/webserver/site/src/html/forms.tmpl +++ b/client/webserver/site/src/html/forms.tmpl @@ -10,7 +10,7 @@ -
+