diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index bb652f80f6..bcb6cb0f9a 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -419,7 +419,6 @@ type WalletConfig struct { RedeemConfTarget uint64 `ini:"redeemconftarget"` ActivelyUsed bool `ini:"special:activelyUsed"` //injected by core WalletName string `ini:"walletname"` // RPC - Peer string `ini:"peer"` // SPV Birthday uint64 `ini:"walletbirthday"` // SPV } @@ -523,7 +522,14 @@ func (d *Driver) Create(params *asset.CreateWalletParams) error { return err } - return createSPVWallet(params.Pass, params.Seed, walletCfg.adjustedBirthday(), params.DataDir, params.Logger, chainParams) + recoveryCfg := new(RecoveryCfg) + err = config.Unmapify(params.Settings, recoveryCfg) + if err != nil { + return err + } + + return createSPVWallet(params.Pass, params.Seed, walletCfg.adjustedBirthday(), params.DataDir, + params.Logger, recoveryCfg.NumExternalAddresses, recoveryCfg.NumInternalAddresses, chainParams) } // Open opens or connects to the BTC exchange wallet. Start the wallet with its @@ -626,6 +632,49 @@ var _ asset.Accelerator = (*ExchangeWalletSPV)(nil) var _ asset.Rescanner = (*ExchangeWalletSPV)(nil) var _ asset.FeeRater = (*ExchangeWalletFullNode)(nil) var _ asset.LogFiler = (*ExchangeWalletSPV)(nil) +var _ asset.Recoverer = (*ExchangeWalletSPV)(nil) + +// RecoveryCfg is the information that is transferred from the old wallet +// to the new one when the wallet is recovered. +type RecoveryCfg struct { + NumExternalAddresses uint32 `ini:"numexternaladdr"` + NumInternalAddresses uint32 `ini:"numinternaladdr"` +} + +// GetRecoveryCfg returns information that will help the wallet get +// back to its previous state after it is recreated. Part of the +// Recoverer interface. +func (btc *ExchangeWalletSPV) GetRecoveryCfg() (map[string]string, error) { + w := btc.node.(*spvWallet) + + internal, external, err := w.numDerivedAddresses() + if err != nil { + return nil, err + } + + reCfg := &RecoveryCfg{ + NumInternalAddresses: internal, + NumExternalAddresses: external, + } + cfg, err := config.Mapify(reCfg) + if err != nil { + return nil, err + } + + return cfg, nil +} + +// Destroy will delete all the wallet files so the wallet can be recreated. +// Part of the Recoverer interface. +func (btc *ExchangeWalletSPV) Move(backupDir string) error { + w := btc.node.(*spvWallet) + err := w.moveWalletData(backupDir) + if err != nil { + return fmt.Errorf("unable to move wallet data: %w", err) + } + + return nil +} // Rescan satisfies the asset.Rescanner interface, and issues a rescan wallet // command if the backend is an SPV wallet. @@ -892,13 +941,8 @@ func openSPVWallet(cfg *BTCCloneCFG) (*ExchangeWalletSPV, error) { return nil, err } - var peers []string - if walletCfg.Peer != "" { - peers = append(peers, walletCfg.Peer) - } - allowAutomaticRescan := !walletCfg.ActivelyUsed - btc.node = loadSPVWallet(cfg.WalletCFG.DataDir, cfg.Logger.SubLogger("SPV"), peers, cfg.ChainParams, walletCfg.adjustedBirthday(), allowAutomaticRescan) + btc.node = loadSPVWallet(cfg.WalletCFG.DataDir, cfg.Logger.SubLogger("SPV"), cfg.ChainParams, walletCfg.adjustedBirthday(), allowAutomaticRescan) return &ExchangeWalletSPV{baseWallet: btc}, nil } @@ -3585,7 +3629,7 @@ func (btc *baseWallet) watchBlocks(ctx context.Context) { blockAllowance := walletBlockAllowance syncStatus, err := btc.node.syncStatus() if err != nil { - btc.log.Errorf("Error retreiving sync status before queuing polled block: %v", err) + btc.log.Errorf("Error retrieving sync status before queuing polled block: %v", err) } else if syncStatus.Syncing { blockAllowance *= 10 } diff --git a/client/asset/btc/spv.go b/client/asset/btc/spv.go index cfe619d53f..d755b3c5d1 100644 --- a/client/asset/btc/spv.go +++ b/client/asset/btc/spv.go @@ -79,6 +79,8 @@ const ( defaultAcctName = "default" ) +var wAddrMgrBkt = []byte("waddrmgr") + // btcWallet is satisfied by *btcwallet.Wallet -> *walletExtender. type btcWallet interface { PublishTransaction(tx *wire.MsgTx, label string) error @@ -127,25 +129,43 @@ type neutrinoService interface { var _ neutrinoService = (*neutrino.ChainService)(nil) +func extendAddresses(extIdx, intIdx uint32, btcw *wallet.Wallet) error { + scopedKeyManager, err := btcw.Manager.FetchScopedKeyManager(waddrmgr.KeyScopeBIP0084) + if err != nil { + return err + } + + return walletdb.Update(btcw.Database(), func(dbtx walletdb.ReadWriteTx) error { + ns := dbtx.ReadWriteBucket(wAddrMgrBkt) + if extIdx > 0 { + scopedKeyManager.ExtendExternalAddresses(ns, defaultAcctNum, extIdx) + } + if intIdx > 0 { + scopedKeyManager.ExtendInternalAddresses(ns, defaultAcctNum, intIdx) + } + return nil + }) +} + // createSPVWallet creates a new SPV wallet. -func createSPVWallet(privPass []byte, seed []byte, bday time.Time, dbDir string, log dex.Logger, net *chaincfg.Params) error { +func createSPVWallet(privPass []byte, seed []byte, bday time.Time, dbDir string, log dex.Logger, extIdx, intIdx uint32, net *chaincfg.Params) error { netDir := filepath.Join(dbDir, net.Name) if err := logNeutrino(netDir); err != nil { - return fmt.Errorf("error initializing btcwallet+neutrino logging: %v", err) + return fmt.Errorf("error initializing btcwallet+neutrino logging: %w", err) } logDir := filepath.Join(netDir, logDirName) err := os.MkdirAll(logDir, 0744) if err != nil { - return fmt.Errorf("error creating wallet directories: %v", err) + return fmt.Errorf("error creating wallet directories: %w", err) } loader := wallet.NewLoader(net, netDir, true, 60*time.Second, 250) pubPass := []byte(wallet.InsecurePubPassphrase) - _, err = loader.CreateNewWallet(pubPass, privPass, seed, bday) + btcw, err := loader.CreateNewWallet(pubPass, privPass, seed, bday) if err != nil { return fmt.Errorf("CreateNewWallet error: %w", err) } @@ -156,11 +176,20 @@ func createSPVWallet(privPass []byte, seed []byte, bday time.Time, dbDir string, } } + if extIdx > 0 || intIdx > 0 { + err = extendAddresses(extIdx, intIdx, btcw) + if err != nil { + bailOnWallet() + return fmt.Errorf("failed to set starting address indexes: %w", err) + } + } + + // The chain service DB neutrinoDBPath := filepath.Join(netDir, neutrinoDBName) db, err := walletdb.Create("bdb", neutrinoDBPath, true, 5*time.Second) if err != nil { bailOnWallet() - return fmt.Errorf("unable to create wallet db at %q: %v", neutrinoDBPath, err) + return fmt.Errorf("unable to create neutrino db at %q: %w", neutrinoDBPath, err) } if err = db.Close(); err != nil { bailOnWallet() @@ -290,7 +319,6 @@ type spvWallet struct { acctName string netDir string neutrinoDB walletdb.DB - connectPeers []string txBlocksMtx sync.Mutex txBlocks map[chainhash.Hash]*hashEntry @@ -314,7 +342,7 @@ var _ Wallet = (*spvWallet)(nil) var _ tipNotifier = (*spvWallet)(nil) // loadSPVWallet loads an existing wallet. -func loadSPVWallet(dbDir string, logger dex.Logger, connectPeers []string, chainParams *chaincfg.Params, birthday time.Time, allowAutomaticRescan bool) *spvWallet { +func loadSPVWallet(dbDir string, logger dex.Logger, chainParams *chaincfg.Params, birthday time.Time, allowAutomaticRescan bool) *spvWallet { return &spvWallet{ chainParams: chainParams, acctNum: defaultAcctNum, @@ -323,7 +351,6 @@ func loadSPVWallet(dbDir string, logger dex.Logger, connectPeers []string, chain txBlocks: make(map[chainhash.Hash]*hashEntry), checkpoints: make(map[outPoint]*scanCheckpoint), log: logger, - connectPeers: connectPeers, tipChan: make(chan *block, 8), allowAutomaticRescan: allowAutomaticRescan, birthday: birthday, @@ -1169,15 +1196,14 @@ func (w *spvWallet) startWallet() error { // mainet and testnet3, add a known reliable persistent peer to be used in // addition to normal DNS seed-based peer discovery. var addPeers []string + var connectPeers []string switch w.chainParams.Net { case wire.MainNet: addPeers = []string{"cfilters.ssgen.io"} case wire.TestNet3: addPeers = []string{"dex-test.ssgen.io"} case wire.TestNet, wire.SimNet: // plain "wire.TestNet" is regnet! - if len(w.connectPeers) == 0 { - w.connectPeers = []string{"localhost:20575"} - } + connectPeers = []string{"localhost:20575"} } w.log.Debug("Starting neutrino chain service...") chainService, err := neutrino.NewChainService(neutrino.Config{ @@ -1186,7 +1212,7 @@ func (w *spvWallet) startWallet() error { ChainParams: *w.chainParams, PersistToDisk: true, // keep cfilter headers on disk for efficient rescanning AddPeers: addPeers, - ConnectPeers: w.connectPeers, + ConnectPeers: connectPeers, // WARNING: PublishTransaction currently uses the entire duration // because if an external bug, but even if the resolved, a typical // inv/getdata round trip is ~4 seconds, so we set this so neutrino does @@ -1220,7 +1246,7 @@ func (w *spvWallet) startWallet() error { if !oldBday.Equal(w.birthday) { err = walletdb.Update(wdb, func(dbtx walletdb.ReadWriteTx) error { - ns := dbtx.ReadWriteBucket([]byte("waddrmgr")) + ns := dbtx.ReadWriteBucket(wAddrMgrBkt) return btcw.Manager.SetBirthday(ns, w.birthday) }) if err != nil { @@ -1244,6 +1270,33 @@ func (w *spvWallet) startWallet() error { return nil } +// moveWalletData will move all wallet files to a backup directory. +func (w *spvWallet) moveWalletData(backupDir string) error { + timeString := time.Now().Format("2006-01-02T15:04:05") + err := os.MkdirAll(backupDir, 0744) + if err != nil { + return err + } + backupFolder := filepath.Join(backupDir, timeString) + return os.Rename(w.netDir, backupFolder) +} + +// numDerivedAddresses returns the number of internal and external addresses +// that the wallet has derived. +func (w *spvWallet) numDerivedAddresses() (internal, external uint32, err error) { + btcw, ok := w.loader.LoadedWallet() + if !ok { + return 0, 0, err + } + + props, err := btcw.AccountProperties(waddrmgr.KeyScopeBIP0084, w.acctNum) + if err != nil { + return 0, 0, err + } + + return props.InternalKeyCount, props.ExternalKeyCount, nil +} + // rescanWalletAsync initiates a full wallet recovery (used address discovery // and transaction scanning) by stopping the btcwallet, dropping the transaction // history from the wallet db, resetting the synced-to height of the wallet @@ -1306,8 +1359,8 @@ func (w *spvWallet) forceRescan() { } err = walletdb.Update(wdb, func(dbtx walletdb.ReadWriteTx) error { - ns := dbtx.ReadWriteBucket([]byte("waddrmgr")) // it'll be fine - return btcw.Manager.SetSyncedTo(ns, nil) // never synced, forcing recover from birthday + ns := dbtx.ReadWriteBucket(wAddrMgrBkt) // it'll be fine + return btcw.Manager.SetSyncedTo(ns, nil) // never synced, forcing recover from birthday }) if err != nil { w.log.Errorf("Failed to reset wallet manager sync height: %v", err) diff --git a/client/asset/interface.go b/client/asset/interface.go index 8cad6462ac..1fc1148591 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -20,6 +20,7 @@ const ( WalletTraitLogFiler // The Wallet allows for downloading of a log file. WalletTraitFeeRater // Wallet can provide a fee rate for non-critical transactions WalletTraitAccelerator // This wallet can accelerate transactions using the CPFP technique + WalletTraitRecoverer // The wallet is an asset.Recoverer. ) // IsRescanner tests if the WalletTrait has the WalletTraitRescanner bit set. @@ -52,6 +53,12 @@ func (wt WalletTrait) IsAccelerator() bool { return wt&WalletTraitAccelerator != 0 } +// IsRecoverer tests if the WalletTrait has the WalletTraitRecoverer bit set, +// which indicates the wallet implements the Recoverer interface. +func (wt WalletTrait) IsRecoverer() bool { + return wt&WalletTraitRecoverer != 0 +} + // DetermineWalletTraits returns the WalletTrait bitset for the provided Wallet. func DetermineWalletTraits(w Wallet) (t WalletTrait) { if _, is := w.(Rescanner); is { @@ -69,6 +76,9 @@ func DetermineWalletTraits(w Wallet) (t WalletTrait) { if _, is := w.(Accelerator); is { t |= WalletTraitAccelerator } + if _, is := w.(Recoverer); is { + t |= WalletTraitRecoverer + } return t } @@ -336,6 +346,16 @@ type Rescanner interface { Rescan(ctx context.Context) error } +// Recoverer is a wallet implementation with recover functionality. +type Recoverer interface { + // GetRecoveryCfg returns information that will help the wallet get back to + // its previous state after it is recreated. + GetRecoveryCfg() (map[string]string, error) + // Move will move all wallet files to a backup directory so the wallet can + // be recreated. + Move(backupdir string) error +} + // Sender is a wallet that can send funds to an address, as opposed to // withdrawing a certain amount from the source wallet/account. type Sender interface { diff --git a/client/core/core.go b/client/core/core.go index d1071da5f2..529b9cd06c 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -883,7 +883,7 @@ func (dc *dexConnection) reconcileTrades(srvOrderStatuses []*msgjson.OrderStatus var orderStatusResults []*msgjson.OrderStatus err := sendRequest(dc.WsConn, msgjson.OrderStatusRoute, orderStatusRequests, &orderStatusResults, DefaultResponseTimeout) if err != nil { - dc.log.Errorf("Error retreiving order statuses from DEX %s: %v", dc.acct.host, err) + dc.log.Errorf("Error retrieving order statuses from DEX %s: %v", dc.acct.host, err) return } @@ -1456,7 +1456,7 @@ func (c *Core) storeDepositAddress(wdbID []byte, addr string) error { // Store the new address in the DB. dbWallet, err := c.db.Wallet(wdbID) if err != nil { - return fmt.Errorf("error retreiving DB wallet: %w", err) + return fmt.Errorf("error retrieving DB wallet: %w", err) } dbWallet.Address = addr return c.db.UpdateWallet(dbWallet) @@ -1875,6 +1875,13 @@ func (c *Core) assetDataDirectory(assetID uint32) string { return filepath.Join(filepath.Dir(c.cfg.DBPath), "assetdb", unbip(assetID)) } +// assetDataBackupDirectory is a directory for a wallet to use for backups of +// data. Wallet data is copied here instead of being deleted when recovering a +// wallet. +func (c *Core) assetDataBackupDirectory(assetID uint32) string { + return filepath.Join(filepath.Dir(c.cfg.DBPath), "assetdb-backup", unbip(assetID)) +} + // loadWallet uses the data from the database to construct a new exchange // wallet. The returned wallet is running but not connected. func (c *Core) loadWallet(dbWallet *db.Wallet) (*xcWallet, error) { @@ -2049,6 +2056,123 @@ func (c *Core) RescanWallet(assetID uint32, force bool) error { return nil } +func (c *Core) removeWallet(assetID uint32) { + c.walletMtx.Lock() + defer c.walletMtx.Unlock() + delete(c.wallets, assetID) +} + +// RecoverWallet will retrieve some recovery information from the wallet, +// which may not be possible if the wallet is too corrupted, disconnect and +// destroy the old wallet, create a new one, and if the recovery information +// was retrieved from the old wallet, send this information to the new one. +// If force is false, this will check for active orders involving this +// asset before initiating a rescan. WARNING: It is ill-advised to initiate +// a wallet recovery with active orders unless the wallet db is definitely +// corrupted and even a rescan will not save it. +// +// DO NOT MAKE CONCURRENT CALLS TO THIS FUNCTION WITH THE SAME ASSET. +func (c *Core) RecoverWallet(assetID uint32, appPW []byte, force bool) error { + crypter, err := c.encryptionKey(appPW) + if err != nil { + return newError(authErr, "RecoverWallet password error: %w", err) + } + defer crypter.Close() + + if !force { + for _, dc := range c.dexConnections() { + if dc.hasActiveAssetOrders(assetID) { + return newError(activeOrdersErr, "active orders for %v", unbip(assetID)) + } + } + } + + oldWallet, found := c.wallet(assetID) + if !found { + return fmt.Errorf("RecoverWallet: wallet not found for %d -> %s: %w", + assetID, unbip(assetID), err) + } + + recoverer, isRecoverer := oldWallet.Wallet.(asset.Recoverer) + if !isRecoverer { + return errors.New("wallet is not a recoverer") + } + walletDef, err := walletDefinition(assetID, oldWallet.walletType) + if err != nil { + return err + } + // Unseeded wallets shouldn't implement the Recoverer interface. This + // is just an additional check for safety. + if !walletDef.Seeded { + return fmt.Errorf("can only recover a seeded wallet") + } + + dbWallet, err := c.db.Wallet(oldWallet.dbID) + if err != nil { + return fmt.Errorf("error retrieving DB wallet: %w", err) + } + + seed, pw, err := c.assetSeedAndPass(assetID, crypter) + if err != nil { + return err + } + defer encode.ClearBytes(seed) + defer encode.ClearBytes(pw) + + if recoveryCfg, err := recoverer.GetRecoveryCfg(); err != nil { + c.log.Errorf("RecoverWallet: unable to get recovery config: %v", err) + } else { + // merge recoveryCfg with dbWallet.Settings + for key, val := range recoveryCfg { + dbWallet.Settings[key] = val + } + } + + // Before we pull the plug, remove the wallet from wallets map. Otherwise, + // connectedWallet would try to connect it. + c.removeWallet(assetID) + oldWallet.Disconnect() // wallet now shut down and w.hookedUp == false -> connected() returns false + + if err = recoverer.Move(c.assetDataBackupDirectory(assetID)); err != nil { + return fmt.Errorf("failed to move wallet data to backup folder: %w", err) + } + + if err = asset.CreateWallet(assetID, &asset.CreateWalletParams{ + Type: dbWallet.Type, + Seed: seed, + Pass: pw, + Settings: dbWallet.Settings, + DataDir: c.assetDataDirectory(assetID), + Net: c.net, + Logger: c.log.SubLogger("CREATE"), + }); err != nil { + return fmt.Errorf("error creating wallet: %w", err) + } + + newWallet, err := c.loadWallet(dbWallet) + if err != nil { + return newError(walletErr, "error loading wallet for %d -> %s: %w", + assetID, unbip(assetID), err) + } + + _, err = c.connectWallet(newWallet) + if err != nil { + return err + } + + c.updateAssetWalletRefs(newWallet) + + err = newWallet.Unlock(crypter) + if err != nil { + return err + } + + state := newWallet.state() + c.notify(newWalletStateNote(state)) + + return nil +} + // OpenWallet opens (unlocks) the wallet for use. func (c *Core) OpenWallet(assetID uint32, appPW []byte) error { crypter, err := c.encryptionKey(appPW) @@ -2330,24 +2454,7 @@ func (c *Core) ReconfigureWallet(appPW, newWalletPW []byte, form *WalletForm) er return newError(dbErr, "error saving wallet configuration: %w", err) } - // Update all relevant trackedTrades' toWallet and fromWallet. - for _, dc := range c.dexConnections() { - dc.tradeMtx.RLock() - for _, tracker := range dc.trades { - tracker.mtx.Lock() - if tracker.wallets.fromWallet.AssetID == assetID { - tracker.wallets.fromWallet = wallet - } else if tracker.wallets.toWallet.AssetID == assetID { - tracker.wallets.toWallet = wallet - } - tracker.mtx.Unlock() - } - dc.tradeMtx.RUnlock() - } - - c.walletMtx.Lock() - c.wallets[assetID] = wallet - c.walletMtx.Unlock() + c.updateAssetWalletRefs(wallet) restartOnFail = false @@ -2386,6 +2493,28 @@ func (c *Core) ReconfigureWallet(appPW, newWalletPW []byte, form *WalletForm) er return nil } +// updateAssetWalletRefs sets all references of an asset's wallet to newWallet. +func (c *Core) updateAssetWalletRefs(newWallet *xcWallet) { + assetID := newWallet.AssetID + for _, dc := range c.dexConnections() { + dc.tradeMtx.RLock() + for _, tracker := range dc.trades { + tracker.mtx.Lock() + if tracker.wallets.fromWallet.AssetID == assetID { + tracker.wallets.fromWallet = newWallet + } else if tracker.wallets.toWallet.AssetID == assetID { + tracker.wallets.toWallet = newWallet + } + tracker.mtx.Unlock() + } + dc.tradeMtx.RUnlock() + } + + c.walletMtx.Lock() + c.wallets[assetID] = newWallet + c.walletMtx.Unlock() +} + // SetWalletPassword updates the (encrypted) password for the wallet. Returns // passwordErr if provided newPW is nil. The wallet will be connected if it is // not already. @@ -5114,7 +5243,7 @@ func (c *Core) loadDBTrades(dc *dexConnection, crypter encrypt.Crypter, failed m // Parse the active trades and see if any wallets need unlocking. trades, err := c.dbTrackers(dc) if err != nil { - return nil, fmt.Errorf("error retreiving active matches: %w", err) + return nil, fmt.Errorf("error retrieving active matches: %w", err) } errs := newErrorSet(dc.acct.host + ": ") diff --git a/client/core/core_test.go b/client/core/core_test.go index 4b5596757d..632ca7b7ef 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -6218,7 +6218,7 @@ func TestAssetBalance(t *testing.T) { tWallet.bal = bal walletBal, err := tCore.AssetBalance(tUTXOAssetA.ID) if err != nil { - t.Fatalf("error retreiving asset balance: %v", err) + t.Fatalf("error retrieving asset balance: %v", err) } dbtest.MustCompareAssetBalances(t, "zero-conf", bal, &walletBal.Balance.Balance) if walletBal.ContractLocked != 0 { diff --git a/client/webserver/api.go b/client/webserver/api.go index 3a3c6051f7..44e04df521 100644 --- a/client/webserver/api.go +++ b/client/webserver/api.go @@ -166,6 +166,33 @@ func (s *WebServer) apiNewWallet(w http.ResponseWriter, r *http.Request) { writeJSON(w, simpleAck(), s.indent) } +// apiRecoverWallet is the handler for the '/recoverwallet' API request. Commands +// a recovery of the specified wallet. +func (s *WebServer) apiRecoverWallet(w http.ResponseWriter, r *http.Request) { + var form struct { + AppPW encode.PassBytes `json:"appPW"` + AssetID uint32 `json:"assetID"` + Force bool `json:"force"` + } + if !readPost(w, r, &form) { + return + } + status := s.core.WalletState(form.AssetID) + if status == nil { + s.writeAPIError(w, fmt.Errorf("no wallet for %d -> %s", form.AssetID, unbip(form.AssetID))) + return + } + err := s.core.RecoverWallet(form.AssetID, form.AppPW, form.Force) + if err != nil { + // NOTE: client may check for code activeOrdersErr to prompt for + // override the active orders safety check. + s.writeAPIError(w, fmt.Errorf("error recovering %s wallet: %w", unbip(form.AssetID), err)) + return + } + + writeJSON(w, simpleAck(), s.indent) +} + // apiRescanWallet is the handler for the '/rescanwallet' API request. Commands // a rescan of the specified wallet. func (s *WebServer) apiRescanWallet(w http.ResponseWriter, r *http.Request) { @@ -183,9 +210,7 @@ func (s *WebServer) apiRescanWallet(w http.ResponseWriter, r *http.Request) { } err := s.core.RescanWallet(form.AssetID, form.Force) if err != nil { - // NOTE: client may may check for code activeOrdersErr to prompt for - // override the active orders safety check. - s.writeAPIError(w, fmt.Errorf("error unlocking %s wallet: %w", unbip(form.AssetID), err)) + s.writeAPIError(w, fmt.Errorf("error rescanning %s wallet: %w", unbip(form.AssetID), err)) return } diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index d4cb70df15..a48a17763b 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -1594,6 +1594,9 @@ func (c *TCore) ExportSeed(pw []byte) ([]byte, error) { func (c *TCore) WalletLogFilePath(uint32) (string, error) { return "", nil } +func (c *TCore) RecoverWallet(uint32, []byte, bool) error { + return nil +} func TestServer(t *testing.T) { // Register dummy drivers for unimplemented assets. diff --git a/client/webserver/locales/en-us.go b/client/webserver/locales/en-us.go index d2de89d46e..90b9a09ba3 100644 --- a/client/webserver/locales/en-us.go +++ b/client/webserver/locales/en-us.go @@ -226,4 +226,11 @@ var EnUS = map[string]string{ "recent_acceleration_msg": `Your latest acceleration was only minutes ago! Are you sure you want to accelerate?`, "recent_swap_msg": `Your oldest unconfirmed swap transaction was submitted only minutes ago! Are you sure you want to accelerate?`, "early_acceleration_help_msg": `It will cause no harm to your order, but you might be wasting money. Acceleration is only helpful if the fee rate on an existing unconfirmed transaction has become too low to be mined in the next block, but not if blocks are just being mined slowly. You can confirm this in the block explorer by closing this popup and clicking on your previous transactions.`, + "recover": "Recover", + "recover_wallet": "Recover Wallet", + "recover_warning": "Recovering your wallet will move all wallet data to a backup folder. You will have to wait until the wallet resyncs with the network, which could potentially take a long time, before you can use the wallet again.", + "wallet_actively_used": "Wallet actively used!", + "confirm_force_message": "This wallet is actively managing orders. After taking this action, it will take a long time to resync your wallet, potentially causing orders to fail. Only take this action if absolutely necessary!", + "confirm": "Confirm", + "cancel": "Cancel", } diff --git a/client/webserver/site/src/css/main_dark.scss b/client/webserver/site/src/css/main_dark.scss index 640d332215..6f6d20d651 100644 --- a/client/webserver/site/src/css/main_dark.scss +++ b/client/webserver/site/src/css/main_dark.scss @@ -67,6 +67,10 @@ body.dark { &.selected { background-color: #0b5831; } + + &.danger { + background-color: #c91e1e; + } } select, diff --git a/client/webserver/site/src/css/wallets.scss b/client/webserver/site/src/css/wallets.scss index 2385f412d6..0564c80c56 100644 --- a/client/webserver/site/src/css/wallets.scss +++ b/client/webserver/site/src/css/wallets.scss @@ -82,3 +82,8 @@ table.wallets { #depositAddress { user-select: all; } + +#recoverWalletConfirm, +#confirmForce { + width: 400px; +} diff --git a/client/webserver/site/src/html/bodybuilder.tmpl b/client/webserver/site/src/html/bodybuilder.tmpl index 51c9a6844c..f82ffddfd3 100644 --- a/client/webserver/site/src/html/bodybuilder.tmpl +++ b/client/webserver/site/src/html/bodybuilder.tmpl @@ -5,10 +5,10 @@ {{- /* The above 2 meta tags *must* come first in the head; any other head content must come *after* these tags */ -}} - + {{.Title}} - +

- +
+ + +
+ + + + {{- /* POP-UP FORMS */ -}} +
+ + {{- /* CONFIRM FORCE FORM */ -}} +
+
+
+ [[[wallet_actively_used]]] +
+
+ [[[confirm_force_message]]] +
+
+ + +
+
+
+ + {{- /* RECOVER WALLET AUTHORIZATION */ -}} +
+
+
+ [[[recover_wallet]]] +
+
+ [[[recover_warning]]] +
+
+ + +
+
+ +
+
diff --git a/client/webserver/site/src/js/wallets.ts b/client/webserver/site/src/js/wallets.ts index 2d612df4c2..4cbd707cde 100644 --- a/client/webserver/site/src/js/wallets.ts +++ b/client/webserver/site/src/js/wallets.ts @@ -19,6 +19,8 @@ const bind = Doc.bind const animationLength = 300 const traitNewAddresser = 1 << 1 const traitLogFiler = 1 << 2 +const traitRecoverer = 1 << 4 +const activeOrdersErrCode = 35 interface Actions { connect: HTMLElement @@ -48,6 +50,12 @@ interface ReconfigRequest { appPW: string } +interface RescanRecoveryRequest { + assetID: number + appPW?: string + force?: boolean +} + export default class WalletsPage extends BasePage { body: HTMLElement page: Record @@ -67,12 +75,22 @@ export default class WalletsPage extends BasePage { openAsset: number walletAsset: number reconfigAsset: number + forms: PageElement[] + forceReq: RescanRecoveryRequest + forceUrl: string + currentForm: PageElement constructor (body: HTMLElement) { super() this.body = body const page = this.page = Doc.idDescendants(body) + this.forms = Doc.applySelector(page.forms, ':scope > form') + page.forms.querySelectorAll('.form-closer').forEach(el => { + Doc.bind(el, 'click', () => { this.closePopups() }) + }) + Doc.bind(page.cancelForce, 'click', () => { this.closePopups() }) + // Read the document, storing some info about each asset's row. const getAction = (row: HTMLElement, name: string) => row.querySelector(`[data-action=${name}]`) as HTMLElement const rowInfos: Record = this.rowInfos = {} @@ -134,14 +152,25 @@ export default class WalletsPage extends BasePage { }) }) + Doc.bind(page.forms, 'mousedown', (e: MouseEvent) => { + if (!Doc.mouseInElement(e, this.currentForm)) { this.closePopups() } + }) + this.keyup = (e: KeyboardEvent) => { if (e.key === 'Escape') { - this.showMarkets(this.lastFormAsset) + if (Doc.isDisplayed(this.page.forms)) { + this.closePopups() + } else { + this.showMarkets(this.lastFormAsset) + } } } bind(document, 'keyup', this.keyup) bind(page.downloadLogs, 'click', async () => { this.downloadLogs() }) + bind(page.recoverWallet, 'click', async () => { this.showRecoverWallet() }) + bindForm(page.recoverWalletConfirm, page.recoverWalletSubmit, () => { this.recoverWallet() }) + bindForm(page.confirmForce, page.confirmForceSubmit, async () => { this.confirmForceSubmit() }) // Bind buttons for (const [k, asset] of Object.entries(rowInfos)) { @@ -199,6 +228,10 @@ export default class WalletsPage extends BasePage { } } + closePopups () { + Doc.hide(this.page.forms) + } + /* * setPWSettingViz sets the visibility of the password field section. */ @@ -238,6 +271,20 @@ export default class WalletsPage extends BasePage { this.displayed = box } + /* showForm shows a modal form with a little animation. */ + async showForm (form: PageElement) { + const page = this.page + this.currentForm = form + this.forms.forEach(form => Doc.hide(form)) + form.style.right = '10000px' + Doc.show(page.forms, form) + const shift = (page.forms.offsetWidth + form.offsetWidth) / 2 + await Doc.animate(animationLength, progress => { + form.style.right = `${(1 - progress) * shift}px` + }, 'easeOutHard') + form.style.right = '0' + } + /* * Show the markets box, which lists the markets available for a selected * asset. @@ -291,14 +338,29 @@ export default class WalletsPage extends BasePage { async rescanWallet (assetID: number) { const loaded = app().loading(this.body) - const res = await postJSON('/api/rescanwallet', { - assetID: assetID, - force: false // TODO input arg - }) + const url = '/api/rescanwallet' + const req = { assetID: assetID } + const res = await postJSON(url, req) loaded() + if (res.code === activeOrdersErrCode) { + this.forceUrl = url + this.forceReq = req + this.showConfirmForce() + return + } app().checkResponse(res) } + showConfirmForce () { + Doc.hide(this.page.confirmForceErr) + this.showForm(this.page.confirmForce) + } + + showRecoverWallet () { + Doc.hide(this.page.recoverWalletErr) + this.showForm(this.page.recoverWalletConfirm) + } + /* Show the open wallet form if the password is not cached, and otherwise * attempt to open the wallet. */ @@ -359,6 +421,8 @@ export default class WalletsPage extends BasePage { const wallet = app().walletMap[assetID] if ((wallet.traits & traitLogFiler) !== 0) Doc.show(page.downloadLogs) else Doc.hide(page.downloadLogs) + if ((wallet.traits & traitRecoverer)) Doc.show(page.recoverWallet) + else Doc.hide(page.recoverWallet) page.recfgAssetLogo.src = Doc.logoPath(asset.symbol) page.recfgAssetName.textContent = asset.info.name @@ -576,6 +640,48 @@ export default class WalletsPage extends BasePage { window.open(url.toString()) } + async recoverWallet () { + const page = this.page + Doc.hide(page.recoverWalletErr) + const req = { + assetID: this.reconfigAsset, + appPW: page.recoverWalletPW.value + } + page.recoverWalletPW.value = '' + const url = '/api/recoverwallet' + const loaded = app().loading(page.forms) + const res = await postJSON(url, req) + loaded() + if (res.code === activeOrdersErrCode) { + this.forceUrl = url + this.forceReq = req + this.showConfirmForce() + } else if (app().checkResponse(res)) { + this.closePopups() + } else { + page.recoverWalletErr.textContent = res.msg + Doc.show(page.recoverWalletErr) + } + } + + /* + * confirmForceSubmit resubmits either the recover or rescan requests with + * force set to true. These two requests require force to be set to true if + * they are called while the wallet is managing active orders. + */ + async confirmForceSubmit () { + const page = this.page + this.forceReq.force = true + const loaded = app().loading(page.forms) + const res = await postJSON(this.forceUrl, this.forceReq) + loaded() + if (app().checkResponse(res)) this.closePopups() + else { + page.confirmForceErr.textContent = res.msg + Doc.show(page.confirmForceErr) + } + } + /* handleBalance handles notifications updating a wallet's balance. */ handleBalanceNote (note: BalanceNote) { const td = Doc.safeSelector(this.page.walletTable, `[data-balance-target="${note.assetID}"]`) diff --git a/client/webserver/site/src/localized_html/en-US/bodybuilder.tmpl b/client/webserver/site/src/localized_html/en-US/bodybuilder.tmpl index 232182b851..6da1c399eb 100644 --- a/client/webserver/site/src/localized_html/en-US/bodybuilder.tmpl +++ b/client/webserver/site/src/localized_html/en-US/bodybuilder.tmpl @@ -5,10 +5,10 @@ {{- /* The above 2 meta tags *must* come first in the head; any other head content must come *after* these tags */ -}} - + {{.Title}} - +

- +
+ + +
+ + + + {{- /* POP-UP FORMS */ -}} +
+ + {{- /* CONFIRM FORCE FORM */ -}} +
+
+
+ Wallet actively used! +
+
+ This wallet is actively managing orders. After taking this action, it will take a long time to resync your wallet, potentially causing orders to fail. Only take this action if absolutely necessary! +
+
+ + +
+
+
+ + {{- /* RECOVER WALLET AUTHORIZATION */ -}} +
+
+
+ Recover Wallet +
+
+ Recovering your wallet will move all wallet data to a backup folder. You will have to wait until the wallet resyncs with the network, which could potentially take a long time, before you can use the wallet again. +
+
+ + +
+
+ +
+
diff --git a/client/webserver/site/src/localized_html/pl-PL/bodybuilder.tmpl b/client/webserver/site/src/localized_html/pl-PL/bodybuilder.tmpl index a8aca4cd9b..dc0c6d4680 100644 --- a/client/webserver/site/src/localized_html/pl-PL/bodybuilder.tmpl +++ b/client/webserver/site/src/localized_html/pl-PL/bodybuilder.tmpl @@ -5,10 +5,10 @@ {{- /* The above 2 meta tags *must* come first in the head; any other head content must come *after* these tags */ -}} - + {{.Title}} - +

- +
+ + +
+ + + + {{- /* POP-UP FORMS */ -}} +
+ + {{- /* CONFIRM FORCE FORM */ -}} +
+
+
+ Wallet actively used! +
+
+ This wallet is actively managing orders. After taking this action, it will take a long time to resync your wallet, potentially causing orders to fail. Only take this action if absolutely necessary! +
+
+ + +
+
+
+ + {{- /* RECOVER WALLET AUTHORIZATION */ -}} +
+
+
+ Recover Wallet +
+
+ Recovering your wallet will move all wallet data to a backup folder. You will have to wait until the wallet resyncs with the network, which could potentially take a long time, before you can use the wallet again. +
+
+ + +
+
+ +
+
diff --git a/client/webserver/site/src/localized_html/pt-BR/bodybuilder.tmpl b/client/webserver/site/src/localized_html/pt-BR/bodybuilder.tmpl index c1a00eb8c0..821e749277 100644 --- a/client/webserver/site/src/localized_html/pt-BR/bodybuilder.tmpl +++ b/client/webserver/site/src/localized_html/pt-BR/bodybuilder.tmpl @@ -5,10 +5,10 @@ {{- /* The above 2 meta tags *must* come first in the head; any other head content must come *after* these tags */ -}} - + {{.Title}} - +

- +
+ + +
+ + + + {{- /* POP-UP FORMS */ -}} +
+ + {{- /* CONFIRM FORCE FORM */ -}} +
+
+
+ Wallet actively used! +
+
+ This wallet is actively managing orders. After taking this action, it will take a long time to resync your wallet, potentially causing orders to fail. Only take this action if absolutely necessary! +
+
+ + +
+
+
+ + {{- /* RECOVER WALLET AUTHORIZATION */ -}} +
+
+
+ Recover Wallet +
+
+ Recovering your wallet will move all wallet data to a backup folder. You will have to wait until the wallet resyncs with the network, which could potentially take a long time, before you can use the wallet again. +
+
+ + +
+
+ +
+
diff --git a/client/webserver/site/src/localized_html/zh-CN/bodybuilder.tmpl b/client/webserver/site/src/localized_html/zh-CN/bodybuilder.tmpl index bc1873e252..7a55151d9e 100644 --- a/client/webserver/site/src/localized_html/zh-CN/bodybuilder.tmpl +++ b/client/webserver/site/src/localized_html/zh-CN/bodybuilder.tmpl @@ -5,10 +5,10 @@ {{- /* The above 2 meta tags *must* come first in the head; any other head content must come *after* these tags */ -}} - + {{.Title}} - +

- +
+ + +
+ + + + {{- /* POP-UP FORMS */ -}} +
+ + {{- /* CONFIRM FORCE FORM */ -}} +
+
+
+ Wallet actively used! +
+
+ This wallet is actively managing orders. After taking this action, it will take a long time to resync your wallet, potentially causing orders to fail. Only take this action if absolutely necessary! +
+
+ + +
+
+
+ + {{- /* RECOVER WALLET AUTHORIZATION */ -}} +
+
+
+ Recover Wallet +
+
+ Recovering your wallet will move all wallet data to a backup folder. You will have to wait until the wallet resyncs with the network, which could potentially take a long time, before you can use the wallet again. +
+
+ + +
+
+ +
+
diff --git a/client/webserver/webserver.go b/client/webserver/webserver.go index 168e24eb87..737e41af43 100644 --- a/client/webserver/webserver.go +++ b/client/webserver/webserver.go @@ -82,6 +82,7 @@ type clientCore interface { CreateWallet(appPW, walletPW []byte, form *core.WalletForm) error OpenWallet(assetID uint32, pw []byte) error RescanWallet(assetID uint32, force bool) error + RecoverWallet(assetID uint32, appPW []byte, force bool) error CloseWallet(assetID uint32) error ConnectWallet(assetID uint32) error Wallets() []*core.WalletState @@ -327,6 +328,7 @@ func New(cfg *Config) (*WebServer, error) { apiAuth.Post("/closewallet", s.apiCloseWallet) apiAuth.Post("/connectwallet", s.apiConnectWallet) apiAuth.Post("/rescanwallet", s.apiRescanWallet) + apiAuth.Post("/recoverwallet", s.apiRecoverWallet) apiAuth.Post("/trade", s.apiTrade) apiAuth.Post("/cancel", s.apiCancel) apiAuth.Post("/logout", s.apiLogout) diff --git a/client/webserver/webserver_test.go b/client/webserver/webserver_test.go index 82c127971e..d77f949c29 100644 --- a/client/webserver/webserver_test.go +++ b/client/webserver/webserver_test.go @@ -185,6 +185,9 @@ func (c *TCore) AccelerationEstimate(oidB dex.Bytes, newFeeRate uint64) (uint64, func (c *TCore) PreAccelerateOrder(oidB dex.Bytes) (*core.PreAccelerate, error) { return nil, nil } +func (c *TCore) RecoverWallet(uint32, []byte, bool) error { + return nil +} type TWriter struct { b []byte diff --git a/dex/config/config.go b/dex/config/config.go index 80c9753309..db8aa49851 100644 --- a/dex/config/config.go +++ b/dex/config/config.go @@ -62,3 +62,20 @@ func Unmapify(settings map[string]string, obj interface{}) error { cfgData := Data(settings) return ParseInto(cfgData, obj) } + +// Mapify takes an interface with ini tags, and parses it into +// a settings map. obj must be a pointer to a struct. +func Mapify(obj interface{}) (map[string]string, error) { + cfg := ini.Empty() + err := ini.ReflectFrom(cfg, obj) + if err != nil { + return nil, err + } + cfgKeyValues := make(map[string]string) + for _, section := range cfg.Sections() { + for _, key := range section.Keys() { + cfgKeyValues[key.Name()] = key.String() + } + } + return cfgKeyValues, nil +} diff --git a/dex/config/config_test.go b/dex/config/config_test.go index 808165523c..6c8b31a29e 100644 --- a/dex/config/config_test.go +++ b/dex/config/config_test.go @@ -212,6 +212,15 @@ func TestConfigParsing(t *testing.T) { t.Fatalf("%s: expected parsed cfg key5 to have '%v', got '%v'", tt.name, tt.expect.parsedCfg.Key5, parsedCfg.Key3) } + + mapifiedCfg, err := Mapify(tt.parsedCfg) + if err != nil { + t.Fatalf("%s: unexpected error from Mapify: %v", tt.name, err) + } + + if len(mapifiedCfg) != 4 { + t.Fatalf("%s: expected all keys except ignored to be in map, but got %v", tt.name, len(mapifiedCfg)) + } } } diff --git a/server/market/orderrouter.go b/server/market/orderrouter.go index a4393ce521..83c7fc81c1 100644 --- a/server/market/orderrouter.go +++ b/server/market/orderrouter.go @@ -515,7 +515,7 @@ func (r *OrderRouter) processTrade(oRecord *orderRecord, tunnel MarketTunnel, as coin.ID, fundingAsset.Symbol) return true, nil } - log.Errorf("Error retreiving limit order funding coin ID %s. user = %s: %v", coin.ID, user, err) + log.Errorf("Error retrieving limit order funding coin ID %s. user = %s: %v", coin.ID, user, err) return false, msgjson.NewError(msgjson.FundingError, fmt.Sprintf("error retrieving coin ID %v", coin.ID)) }