diff --git a/client/asset/bch/bch.go b/client/asset/bch/bch.go index ed1de5c8de..caba63785b 100644 --- a/client/asset/bch/bch.go +++ b/client/asset/bch/bch.go @@ -33,9 +33,16 @@ const ( // structure. defaultFee = 100 minNetworkVersion = 221100 + walletTypeRPC = "bitcoindRPC" + walletTypeLegacy = "" ) var ( + netPorts = dexbtc.NetPorts{ + Mainnet: "8332", + Testnet: "18332", + Simnet: "18443", + } fallbackFeeKey = "fallbackfee" configOpts = []*asset.ConfigOption{ { @@ -85,9 +92,14 @@ var ( Name: "Bitcoin Cash", Version: version, // Same as bitcoin. That's dumb. - DefaultConfigPath: dexbtc.SystemConfigPath("bitcoin"), - ConfigOpts: configOpts, - UnitInfo: dexbch.UnitInfo, + UnitInfo: dexbch.UnitInfo, + AvailableWallets: []*asset.WalletDefinition{{ + Type: walletTypeRPC, + Tab: "External", + Description: "Connect to bitcoind", + DefaultConfigPath: dexbtc.SystemConfigPath("bitcoin"), // Same as bitcoin. That's dumb. + ConfigOpts: configOpts, + }}, } ) @@ -98,15 +110,14 @@ func init() { // Driver implements asset.Driver. type Driver struct{} -// Open opens the BCH exchange wallet. Start the wallet with its Run method. +// Check that Driver implements asset.Driver. +var _ asset.Driver = (*Driver)(nil) + +// Open creates the BCH exchange wallet. Start the wallet with its Run method. func (d *Driver) Open(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) { return NewWallet(cfg, logger, network) } -func (d *Driver) Create(params *asset.CreateWalletParams) error { - return fmt.Errorf("no creatable wallet types") -} - // DecodeCoinID creates a human-readable representation of a coin ID for // Bitcoin Cash. func (d *Driver) DecodeCoinID(coinID []byte) (string, error) { @@ -120,9 +131,7 @@ func (d *Driver) Info() *asset.WalletInfo { } // NewWallet is the exported constructor by which the DEX will import the -// exchange wallet. The wallet will shut down when the provided context is -// canceled. The configPath can be an empty string, in which case the standard -// system location of the daemon config file is assumed. +// exchange wallet. func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) { var params *chaincfg.Params switch network { @@ -139,11 +148,6 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) // Designate the clone ports. These will be overwritten by any explicit // settings in the configuration file. Bitcoin Cash uses the same default // ports as Bitcoin. - ports := dexbtc.NetPorts{ - Mainnet: "8332", - Testnet: "18332", - Simnet: "18443", - } cloneCFG := &btc.BTCCloneCFG{ WalletCFG: cfg, MinNetworkVersion: minNetworkVersion, @@ -152,7 +156,7 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) Logger: logger, Network: network, ChainParams: params, - Ports: ports, + Ports: netPorts, DefaultFallbackFee: defaultFee, Segwit: false, LegacyBalance: true, diff --git a/client/asset/bch/regnet_test.go b/client/asset/bch/regnet_test.go index 73a529d1df..13d91f9415 100644 --- a/client/asset/bch/regnet_test.go +++ b/client/asset/bch/regnet_test.go @@ -23,8 +23,6 @@ import ( dexbtc "decred.org/dcrdex/dex/networks/btc" ) -const alphaAddress = "bchreg:qqnm4z2tftyyeu3kvzzepmlp9mj3g6fvxgft570vll" - var ( tLotSize uint64 = 1e6 tRateStep uint64 = 10 @@ -40,5 +38,9 @@ var ( ) func TestWallet(t *testing.T) { - livetest.Run(t, NewWallet, alphaAddress, tLotSize, tBCH, false) + livetest.Run(t, &livetest.Config{ + NewWallet: NewWallet, + LotSize: tLotSize, + Asset: tBCH, + }) } diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 6b1dc11bd5..eb104c290e 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "math" + "path/filepath" "sort" "strconv" "strings" @@ -21,6 +22,7 @@ import ( "decred.org/dcrdex/client/asset" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/dex/config" dexbtc "decred.org/dcrdex/dex/networks/btc" "github.com/btcsuite/btcd/btcec" "github.com/btcsuite/btcd/btcjson" @@ -29,6 +31,7 @@ import ( "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcutil" + "github.com/btcsuite/btcwallet/wallet" "github.com/decred/dcrd/dcrjson/v4" // for dcrjson.RPCError returns from rpcclient "github.com/decred/dcrd/rpcclient/v7" ) @@ -42,7 +45,6 @@ const ( // rpcclient.Client's GetBlockVerboseTx appears to be busted. methodGetNetworkInfo = "getnetworkinfo" methodGetBlockchainInfo = "getblockchaininfo" - methodSendRawTx = "sendrawtransaction" // BipID is the BIP-0044 asset ID. BipID = 0 @@ -66,13 +68,17 @@ const ( // We include the 2 bytes for marker and flag. splitTxBaggageSegwit = dexbtc.MinimumTxOverhead + 2*dexbtc.P2WPKHOutputSize + dexbtc.RedeemP2WPKHInputSize + ((dexbtc.RedeemP2WPKHInputWitnessWeight + 2 + 3) / 4) + + walletTypeLegacy = "" + walletTypeRPC = "bitcoindRPC" + walletTypeSPV = "SPV" ) var ( // blockTicker is the delay between calls to check for new blocks. blockTicker = time.Second conventionalConversionFactor = float64(dexbtc.UnitInfo.Conventional.ConversionFactor) - configOpts = []*asset.ConfigOption{ + rpcOpts = []*asset.ConfigOption{ { Key: "walletname", DisplayName: "Wallet Name", @@ -101,6 +107,9 @@ var ( Description: "Port for RPC connections (if not set in rpcbind)", DefaultValue: "8332", }, + } + + commonOpts = []*asset.ConfigOption{ { Key: "fallbackfee", DisplayName: "Fallback fee rate", @@ -135,16 +144,35 @@ var ( "or the order is canceled. This an extra transaction for which network " + "mining fees are paid. Used only for standing-type orders, e.g. limit " + "orders without immediate time-in-force.", - IsBoolean: true, + IsBoolean: true, + DefaultValue: false, }, } + rpcWalletDefinition = &asset.WalletDefinition{ + Type: walletTypeRPC, + Tab: "External", + Description: "Connect to bitcoind", + DefaultConfigPath: dexbtc.SystemConfigPath("bitcoin"), + ConfigOpts: append(rpcOpts, commonOpts...), + } + spvWalletDefinition = &asset.WalletDefinition{ + Type: walletTypeSPV, + Tab: "Native", + Description: "Use the built-in SPV wallet", + ConfigOpts: commonOpts, + Seeded: true, + } + // WalletInfo defines some general information about a Bitcoin wallet. WalletInfo = &asset.WalletInfo{ - Name: "Bitcoin", - Version: version, - DefaultConfigPath: dexbtc.SystemConfigPath("bitcoin"), - ConfigOpts: configOpts, - UnitInfo: dexbtc.UnitInfo, + Name: "Bitcoin", + Version: version, + UnitInfo: dexbtc.UnitInfo, + AvailableWallets: []*asset.WalletDefinition{ + spvWalletDefinition, + rpcWalletDefinition, + }, + LegacyWalletIndex: 1, } ) @@ -327,16 +355,106 @@ func (r *swapReceipt) SignedRefund() dex.Bytes { return r.signedRefund } +// RPCWalletConfig is a combination of RPCConfig and WalletConfig. Used for a +// wallet based on a bitcoind-like RPC API. +type RPCWalletConfig struct { + dexbtc.RPCConfig + WalletConfig +} + +// WalletConfig are wallet-level configuration settings. +type WalletConfig struct { + UseSplitTx bool `ini:"txsplit"` + FallbackFeeRate float64 `ini:"fallbackfee"` + FeeRateLimit float64 `ini:"feeratelimit"` + RedeemConfTarget uint64 `ini:"redeemconftarget"` + WalletName string `ini:"walletname"` // RPC + Peer string `ini:"peer"` // SPV +} + +// readRPCWalletConfig parses the settings map into a *RPCWalletConfig. +func readRPCWalletConfig(settings map[string]string, symbol string, net dex.Network, ports dexbtc.NetPorts) (cfg *RPCWalletConfig, err error) { + cfg = new(RPCWalletConfig) + err = config.Unmapify(settings, &cfg.WalletConfig) + if err != nil { + return nil, fmt.Errorf("error parsing wallet config: %w", err) + } + err = config.Unmapify(settings, &cfg.RPCConfig) + if err != nil { + return nil, fmt.Errorf("error parsing rpc config: %w", err) + } + err = dexbtc.CheckRPCConfig(&cfg.RPCConfig, symbol, net, ports) + return +} + +// parseRPCWalletConfig parses a *RPCWalletConfig from the settings map and +// creates the unconnected *rpcclient.Client. +func parseRPCWalletConfig(settings map[string]string, symbol string, net dex.Network, ports dexbtc.NetPorts) (*RPCWalletConfig, *rpcclient.Client, error) { + cfg, err := readRPCWalletConfig(settings, symbol, net, ports) + if err != nil { + return nil, nil, err + } + endpoint := cfg.RPCBind + "/wallet/" + cfg.WalletName + cl, err := rpcclient.New(&rpcclient.ConnConfig{ + HTTPPostMode: true, + DisableTLS: true, + Host: endpoint, + User: cfg.RPCUser, + Pass: cfg.RPCPass, + }, nil) + if err != nil { + return nil, nil, err + } + return cfg, cl, nil +} + // Driver implements asset.Driver. type Driver struct{} -// Open opens the BTC exchange wallet. Start the wallet with its Run method. -func (d *Driver) Open(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) { - return NewWallet(cfg, logger, network) +// Check that Driver implements Driver and Creator. +var _ asset.Driver = (*Driver)(nil) +var _ asset.Creator = (*Driver)(nil) + +// Exists checks the existence of the wallet. Part of the Creator interface, so +// only used for wallets with WalletDefinition.Seeded = true. +func (d *Driver) Exists(walletType, dataDir string, settings map[string]string, net dex.Network) (bool, error) { + if walletType != walletTypeSPV { + return false, fmt.Errorf("no Bitcoin wallet of type %q available", walletType) + } + + chainParams, err := parseChainParams(net) + if err != nil { + return false, err + } + netDir := filepath.Join(dataDir, chainParams.Name) + // timeout and recoverWindow arguments borrowed from btcwallet directly. + loader := wallet.NewLoader(chainParams, netDir, true, 60*time.Second, 250) + return loader.WalletExists() +} + +// Create creates a new SPV wallet. +func (d *Driver) Create(params *asset.CreateWalletParams) error { + if params.Type != walletTypeSPV { + return fmt.Errorf("SPV is the only seeded wallet type. required = %q, requested = %q", walletTypeSPV, params.Type) + } + if len(params.Seed) == 0 { + return errors.New("wallet seed cannot be empty") + } + if len(params.DataDir) == 0 { + return errors.New("must specify wallet data directory") + } + chainParams, err := parseChainParams(params.Net) + if err != nil { + return fmt.Errorf("error parsing chain: %w", err) + } + + return createSPVWallet(params.Pass, params.Seed, params.DataDir, params.Logger, chainParams) } -func (d *Driver) Create(*asset.CreateWalletParams) error { - return fmt.Errorf("no creatable wallet types") +// Open opens or connects to the BTC exchange wallet. Start the wallet with its +// Run method. +func (d *Driver) Open(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) { + return NewWallet(cfg, logger, network) } // DecodeCoinID creates a human-readable representation of a coin ID for @@ -434,29 +552,33 @@ type findRedemptionResult struct { // Check that ExchangeWallet satisfies the Wallet interface. var _ asset.Wallet = (*ExchangeWallet)(nil) -// NewWallet is the exported constructor by which the DEX will import the -// exchange wallet. The wallet will shut down when the provided context is -// canceled. The configPath can be an empty string, in which case the standard -// system location of the bitcoind config file is assumed. -func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) { - var params *chaincfg.Params - switch network { +func parseChainParams(net dex.Network) (*chaincfg.Params, error) { + switch net { case dex.Mainnet: - params = &chaincfg.MainNetParams + return &chaincfg.MainNetParams, nil case dex.Testnet: - params = &chaincfg.TestNet3Params + return &chaincfg.TestNet3Params, nil case dex.Regtest: - params = &chaincfg.RegressionNetParams - default: - return nil, fmt.Errorf("unknown network ID %v", network) + return &chaincfg.RegressionNetParams, nil + } + return nil, fmt.Errorf("unknown network ID %v", net) +} + +// NewWallet is the exported constructor by which the DEX will import the +// exchange wallet. +func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, net dex.Network) (asset.Wallet, error) { + params, err := parseChainParams(net) + if err != nil { + return nil, err } + cloneCFG := &BTCCloneCFG{ WalletCFG: cfg, MinNetworkVersion: minNetworkVersion, WalletInfo: WalletInfo, Symbol: "btc", Logger: logger, - Network: network, + Network: net, ChainParams: params, Ports: dexbtc.RPCPorts, DefaultFallbackFee: defaultFee, @@ -464,7 +586,14 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) Segwit: true, } - return BTCCloneWallet(cloneCFG) + switch cfg.Type { + case walletTypeSPV: + return openSPVWallet(cloneCFG) + case walletTypeRPC, walletTypeLegacy: + return BTCCloneWallet(cloneCFG) + default: + return nil, fmt.Errorf("unknown wallet type %q", cfg.Type) + } } // BTCCloneWallet creates a wallet backend for a set of network parameters and @@ -472,40 +601,46 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) // conjunction with ReadCloneParams, to create a ExchangeWallet for other assets // with minimal coding. func BTCCloneWallet(cfg *BTCCloneCFG) (*ExchangeWallet, error) { - // Read the configuration parameters - btcCfg, err := dexbtc.LoadConfigFromSettings(cfg.WalletCFG.Settings, - cfg.Symbol, cfg.Network, cfg.Ports) + clientCfg, client, err := parseRPCWalletConfig(cfg.WalletCFG.Settings, cfg.Symbol, cfg.Network, cfg.Ports) if err != nil { return nil, err } - endpoint := btcCfg.RPCBind + "/wallet/" + cfg.WalletCFG.Settings["walletname"] - cfg.Logger.Infof("Setting up new %s wallet at %s.", cfg.Symbol, endpoint) + btc, err := newRPCWallet(client, cfg, &clientCfg.WalletConfig) + if err != nil { + return nil, fmt.Errorf("error creating %s ExchangeWallet: %v", cfg.Symbol, + err) + } + + return btc, nil +} - client, err := rpcclient.New(&rpcclient.ConnConfig{ +func newRPCWalletConnection(cfg *RPCWalletConfig) (*rpcclient.Client, error) { + endpoint := cfg.RPCBind + "/wallet/" + cfg.WalletName + return rpcclient.New(&rpcclient.ConnConfig{ HTTPPostMode: true, DisableTLS: true, Host: endpoint, - User: btcCfg.RPCUser, - Pass: btcCfg.RPCPass, + User: cfg.RPCUser, + Pass: cfg.RPCPass, }, nil) +} + +// newRPCWallet creates the ExchangeWallet and starts the block monitor. +func newRPCWallet(requester RawRequesterWithContext, cfg *BTCCloneCFG, walletConfig *WalletConfig) (*ExchangeWallet, error) { + btc, err := newUnconnectedWallet(cfg, walletConfig) if err != nil { return nil, err } - btc, err := newWallet(client, cfg, btcCfg) - if err != nil { - return nil, fmt.Errorf("error creating %s ExchangeWallet: %v", cfg.Symbol, - err) - } - + btc.node = newRPCClient(requester, cfg.Segwit, btc.decodeAddr, cfg.ArglessChangeAddrRPC, + cfg.LegacyRawFeeLimit, cfg.MinNetworkVersion, cfg.Logger.SubLogger("RPC"), cfg.ChainParams) return btc, nil } -// newWallet creates the ExchangeWallet and starts the block monitor. -func newWallet(requester RawRequesterWithContext, cfg *BTCCloneCFG, btcCfg *dexbtc.Config) (*ExchangeWallet, error) { +func newUnconnectedWallet(cfg *BTCCloneCFG, walletCfg *WalletConfig) (*ExchangeWallet, error) { // If set in the user config, the fallback fee will be in conventional units // per kB, e.g. BTC/kB. Translate that to sats/byte. - fallbackFeesPerByte := toSatoshi(btcCfg.FallbackFeeRate / 1000) + fallbackFeesPerByte := toSatoshi(walletCfg.FallbackFeeRate / 1000) if fallbackFeesPerByte == 0 { fallbackFeesPerByte = cfg.DefaultFallbackFee } @@ -515,16 +650,16 @@ func newWallet(requester RawRequesterWithContext, cfg *BTCCloneCFG, btcCfg *dexb // If set in the user config, the fee rate limit will be in units of BTC/KB. // Convert to sats/byte & error if value is smaller than smallest unit. feesLimitPerByte := uint64(defaultFeeRateLimit) - if btcCfg.FeeRateLimit > 0 { - feesLimitPerByte = toSatoshi(btcCfg.FeeRateLimit / 1000) + if walletCfg.FeeRateLimit > 0 { + feesLimitPerByte = toSatoshi(walletCfg.FeeRateLimit / 1000) if feesLimitPerByte == 0 { return nil, fmt.Errorf("Fee rate limit is smaller than smallest unit: %v", - btcCfg.FeeRateLimit) + walletCfg.FeeRateLimit) } } cfg.Logger.Tracef("Fees rate limit set at %d sats/byte", feesLimitPerByte) - redeemConfTarget := btcCfg.RedeemConfTarget + redeemConfTarget := walletCfg.RedeemConfTarget if redeemConfTarget == 0 { redeemConfTarget = defaultRedeemConfTarget } @@ -540,11 +675,7 @@ func newWallet(requester RawRequesterWithContext, cfg *BTCCloneCFG, btcCfg *dexb nonSegwitSigner = cfg.NonSegwitSigner } - cl := newRPCClient(requester, cfg.Segwit, addrDecoder, cfg.ArglessChangeAddrRPC, - cfg.LegacyRawFeeLimit, cfg.MinNetworkVersion, cfg.Logger.SubLogger("RPC"), cfg.ChainParams) - w := &ExchangeWallet{ - node: cl, symbol: cfg.Symbol, chainParams: cfg.ChainParams, log: cfg.Logger, @@ -555,7 +686,7 @@ func newWallet(requester RawRequesterWithContext, cfg *BTCCloneCFG, btcCfg *dexb fallbackFeeRate: fallbackFeesPerByte, feeRateLimit: feesLimitPerByte, redeemConfTarget: redeemConfTarget, - useSplitTx: btcCfg.UseSplitTx, + useSplitTx: walletCfg.UseSplitTx, useLegacyBalance: cfg.LegacyBalance, segwit: cfg.Segwit, legacyRawFeeLimit: cfg.LegacyRawFeeLimit, @@ -572,6 +703,29 @@ func newWallet(requester RawRequesterWithContext, cfg *BTCCloneCFG, btcCfg *dexb return w, nil } +// openSPVWallet opens the previously created native SPV wallet. +func openSPVWallet(cfg *BTCCloneCFG) (*ExchangeWallet, error) { + walletCfg := new(WalletConfig) + err := config.Unmapify(cfg.WalletCFG.Settings, walletCfg) + if err != nil { + return nil, err + } + + btc, err := newUnconnectedWallet(cfg, walletCfg) + if err != nil { + return nil, err + } + + var peers []string + if walletCfg.Peer != "" { + peers = append(peers, walletCfg.Peer) + } + + btc.node = loadSPVWallet(cfg.WalletCFG.DataDir, cfg.Logger.SubLogger("SPV"), peers, cfg.ChainParams) + + return btc, nil +} + var _ asset.Wallet = (*ExchangeWallet)(nil) // Info returns basic information about the wallet and asset. @@ -588,7 +742,8 @@ func (btc *ExchangeWallet) Net() *chaincfg.Params { // Connect connects the wallet to the RPC server. Satisfies the dex.Connector // interface. func (btc *ExchangeWallet) Connect(ctx context.Context) (*sync.WaitGroup, error) { - if err := btc.node.connect(ctx); err != nil { + var wg sync.WaitGroup + if err := btc.node.connect(ctx, &wg); err != nil { return nil, err } // Initialize the best block. @@ -609,7 +764,6 @@ func (btc *ExchangeWallet) Connect(ctx context.Context) (*sync.WaitGroup, error) return nil, fmt.Errorf("error parsing best block for %s: %w", btc.symbol, err) } atomic.StoreInt64(&btc.tipAtConnect, btc.currentTip.height) - var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() @@ -842,9 +996,14 @@ func (btc *ExchangeWallet) estimateSwap(lots, lotSize, feeSuggestion uint64, utx val := lots * lotSize - sum, inputsSize, _, _, _, _, err := btc.fund(val, lots, utxos, nfo) + enough := func(inputsSize, inputsVal uint64) bool { + reqFunds := calc.RequiredOrderFunds(val, inputsSize, lots, nfo) + return inputsVal >= reqFunds + } + + sum, inputsSize, _, _, _, _, err := fund(utxos, enough) if err != nil { - return nil, false, err + return nil, false, fmt.Errorf("error funding swap value %s: %w", amount(val), err) } reqFunds := calc.RequiredOrderFundsAlt(val, uint64(inputsSize), lots, nfo.SwapSizeBase, nfo.SwapSize, nfo.MaxFeeRate) @@ -957,9 +1116,14 @@ func (btc *ExchangeWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes ordValStr, amount(avail)) } - sum, size, coins, fundingCoins, redeemScripts, spents, err := btc.fund(ord.Value, ord.MaxSwapCount, utxos, ord.DEXConfig) + enough := func(inputsSize, inputsVal uint64) bool { + reqFunds := calc.RequiredOrderFunds(ord.Value, inputsSize, ord.MaxSwapCount, ord.DEXConfig) + return inputsVal >= reqFunds + } + + sum, size, coins, fundingCoins, redeemScripts, spents, err := fund(utxos, enough) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("error funding swap value of %s: %w", amount(ord.Value), err) } if btc.useSplitTx && !ord.Immediate { @@ -988,14 +1152,15 @@ func (btc *ExchangeWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes return coins, redeemScripts, nil } -func (btc *ExchangeWallet) fund(val, lots uint64, utxos []*compositeUTXO, nfo *dex.Asset) ( +func fund(utxos []*compositeUTXO, enough func(uint64, uint64) bool) ( sum uint64, size uint32, coins asset.Coins, fundingCoins map[outPoint]*utxo, redeemScripts []dex.Bytes, spents []*output, err error) { fundingCoins = make(map[outPoint]*utxo) isEnoughWith := func(unspent *compositeUTXO) bool { - reqFunds := calc.RequiredOrderFunds(val, uint64(size+unspent.input.VBytes()), lots, nfo) - return sum+unspent.amount >= reqFunds + return enough(uint64(size+unspent.input.VBytes()), sum+unspent.amount) + // reqFunds := calc.RequiredOrderFunds(val, uint64(size+unspent.input.VBytes()), lots, nfo) + // return sum+unspent.amount >= reqFunds } addUTXO := func(unspent *compositeUTXO) { @@ -1044,8 +1209,7 @@ func (btc *ExchangeWallet) fund(val, lots uint64, utxos []*compositeUTXO, nfo *d // First try with confs>0, falling back to allowing 0-conf outputs. if !tryUTXOs(1) { if !tryUTXOs(0) { - return 0, 0, nil, nil, nil, nil, fmt.Errorf("not enough to cover requested funds (%s %s + tx fees). %s available", - amount(val), btc.symbol, amount(sum)) + return 0, 0, nil, nil, nil, nil, fmt.Errorf("not enough to cover requested funds %s available", amount(sum)) } } @@ -1306,7 +1470,7 @@ outer: // Unlock unlocks the ExchangeWallet. The pw supplied should be the same as the // password for the underlying bitcoind wallet which will also be unlocked. -func (btc *ExchangeWallet) Unlock(pw string) error { +func (btc *ExchangeWallet) Unlock(pw []byte) error { return btc.node.walletUnlock(pw) } @@ -1426,7 +1590,7 @@ func (btc *ExchangeWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin txHash := msgTx.TxHash() for i, contract := range swaps.Contracts { output := newOutput(&txHash, uint32(i), contract.Value) - signedRefundTx, err := btc.refundTx(output.txHash(), output.vout(), contracts[i], contract.Value, refundAddrs[i]) + signedRefundTx, err := btc.refundTx(output.txHash(), output.vout(), contracts[i], contract.Value, refundAddrs[i], swaps.FeeRate) if err != nil { return nil, nil, 0, fmt.Errorf("error creating refund tx: %w", err) } @@ -1665,13 +1829,30 @@ func (btc *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, sin if err != nil { return nil, fmt.Errorf("error extracting swap addresses: %w", err) } - // Get the contracts P2SH address from the tx output's pubkey script. - txOut, _, err := btc.node.getTxOut(txHash, vout, contract, since) + pkScript, err := btc.scriptHashScript(contract) if err != nil { + return nil, fmt.Errorf("error parsing pubkey script: %w", err) + } + // Get the contracts P2SH address from the tx output's pubkey script. + txOut, _, err := btc.node.getTxOut(txHash, vout, pkScript, since) + if err != nil && !errors.Is(err, asset.CoinNotFoundError) { return nil, fmt.Errorf("error finding unspent contract: %s:%d : %w", txHash, vout, err) } - if txOut == nil { - return nil, asset.CoinNotFoundError + + // Even if we haven't found the output, we can perform basic validation + // using the txData. We may also want to broadcast the transaction if using + // an spvWallet. It may be worth separating data validation from coin + // retrieval at the asset.Wallet interface level. + coinNotFound := txOut == nil + if coinNotFound { + tx, err := msgTxFromBytes(txData) + if err != nil { + return nil, fmt.Errorf("coin not found, and error encountered decoding tx data: %v", err) + } + if len(tx.TxOut) <= int(vout) { + return nil, fmt.Errorf("specified output %d not found in decoded tx %s", vout, txHash) + } + txOut = tx.TxOut[vout] } // Check for standard P2SH. @@ -1708,6 +1889,10 @@ func (btc *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, sin return nil, fmt.Errorf("contract hash doesn't match script address. %x != %x", contractHash, addr.ScriptAddress()) } + + if coinNotFound { + return nil, asset.CoinNotFoundError + } return &asset.AuditInfo{ Coin: newOutput(txHash, vout, uint64(txOut.Value)), Recipient: receiver.String(), @@ -1987,19 +2172,25 @@ func (btc *ExchangeWallet) tryRedemptionRequests(ctx context.Context, startBlock // wallet does not store it, even though it was known when the init transaction // was created. The client should store this information for persistence across // sessions. -func (btc *ExchangeWallet) Refund(coinID, contract dex.Bytes) (dex.Bytes, error) { +func (btc *ExchangeWallet) Refund(coinID, contract dex.Bytes, feeSuggestion uint64) (dex.Bytes, error) { txHash, vout, err := decodeCoinID(coinID) if err != nil { return nil, err } - utxo, _, err := btc.node.getTxOut(txHash, vout, contract, time.Time{}) + + pkScript, err := btc.scriptHashScript(contract) + if err != nil { + return nil, fmt.Errorf("error parsing pubkey script: %w", err) + } + + utxo, _, err := btc.node.getTxOut(txHash, vout, pkScript, time.Time{}) if err != nil { return nil, fmt.Errorf("error finding unspent contract: %w", err) } if utxo == nil { return nil, asset.CoinNotFoundError } - msgTx, err := btc.refundTx(txHash, vout, contract, uint64(utxo.Value), nil) + msgTx, err := btc.refundTx(txHash, vout, contract, uint64(utxo.Value), nil, feeSuggestion) if err != nil { return nil, fmt.Errorf("error creating refund tx: %w", err) } @@ -2018,14 +2209,14 @@ func (btc *ExchangeWallet) Refund(coinID, contract dex.Bytes) (dex.Bytes, error) // refundTx creates and signs a contract`s refund transaction. If refundAddr is // not supplied, one will be requested from the wallet. -func (btc *ExchangeWallet) refundTx(txHash *chainhash.Hash, vout uint32, contract dex.Bytes, val uint64, refundAddr btcutil.Address) (*wire.MsgTx, error) { +func (btc *ExchangeWallet) refundTx(txHash *chainhash.Hash, vout uint32, contract dex.Bytes, val uint64, refundAddr btcutil.Address, feeSuggestion uint64) (*wire.MsgTx, error) { sender, _, lockTime, _, err := dexbtc.ExtractSwapDetails(contract, btc.segwit, btc.chainParams) if err != nil { return nil, fmt.Errorf("error extracting swap addresses: %w", err) } // Create the transaction that spends the contract. - feeRate := btc.feeRateWithFallback(2, 0) // meh level urgency + feeRate := btc.feeRateWithFallback(2, feeSuggestion) // meh level urgency msgTx := wire.NewMsgTx(wire.TxVersion) msgTx.LockTime = uint32(lockTime) prevOut := wire.NewOutPoint(txHash, vout) @@ -2097,8 +2288,8 @@ func (btc *ExchangeWallet) Address() (string, error) { // PayFee sends the dex registration fee. Transaction fees are in addition to // the registration fee, and the fee rate is taken from the DEX configuration. -func (btc *ExchangeWallet) PayFee(address string, regFee uint64) (asset.Coin, error) { - txHash, vout, sent, err := btc.send(address, regFee, btc.feeRateWithFallback(1, 0), false) +func (btc *ExchangeWallet) PayFee(address string, regFee, feeRateSuggestion uint64) (asset.Coin, error) { + txHash, vout, sent, err := btc.send(address, regFee, btc.feeRateWithFallback(1, feeRateSuggestion), false) if err != nil { btc.log.Errorf("PayFee error - address = '%s', fee = %s: %v", address, amount(regFee), err) return nil, err @@ -2108,8 +2299,8 @@ func (btc *ExchangeWallet) PayFee(address string, regFee uint64) (asset.Coin, er // Withdraw withdraws funds to the specified address. Fees are subtracted from // the value. feeRate is in units of atoms/byte. -func (btc *ExchangeWallet) Withdraw(address string, value uint64) (asset.Coin, error) { - txHash, vout, sent, err := btc.send(address, value, btc.feeRateWithFallback(2, 0), true) +func (btc *ExchangeWallet) Withdraw(address string, value, feeSuggestion uint64) (asset.Coin, error) { + txHash, vout, sent, err := btc.send(address, value, btc.feeRateWithFallback(2, feeSuggestion), true) if err != nil { btc.log.Errorf("Withdraw error - address = '%s', amount = %s: %v", address, amount(value), err) return nil, err @@ -2127,20 +2318,33 @@ func (btc *ExchangeWallet) ValidateSecret(secret, secretHash []byte) bool { // the fees will be subtracted from the value. If false, the fees are in // addition to the value. feeRate is in units of atoms/byte. func (btc *ExchangeWallet) send(address string, val uint64, feeRate uint64, subtract bool) (*chainhash.Hash, uint32, uint64, error) { + addr, err := btc.decodeAddr(address, btc.chainParams) + if err != nil { + return nil, 0, 0, fmt.Errorf("address decode error: %w", err) + } + pay2script, err := txscript.PayToAddrScript(addr) + if err != nil { + return nil, 0, 0, fmt.Errorf("PayToAddrScript error: %w", err) + } txHash, err := btc.node.sendToAddress(address, val, feeRate, subtract) if err != nil { return nil, 0, 0, fmt.Errorf("SendToAddress error: %w", err) } - tx, err := btc.node.getWalletTransaction(txHash) + txRes, err := btc.node.getWalletTransaction(txHash) if err != nil { if isTxNotFoundErr(err) { return nil, 0, 0, asset.CoinNotFoundError } return nil, 0, 0, fmt.Errorf("failed to fetch transaction after send: %w", err) } - for _, details := range tx.Details { - if details.Address == address { - return txHash, details.Vout, toSatoshi(details.Amount), nil + + tx, err := msgTxFromBytes(txRes.Hex) + if err != nil { + return nil, 0, 0, fmt.Errorf("error decoding transaction: %w", err) + } + for vout, txOut := range tx.TxOut { + if bytes.Equal(txOut.PkScript, pay2script) { + return txHash, uint32(vout), uint64(txOut.Value), nil } } return nil, 0, 0, fmt.Errorf("failed to locate transaction vout") @@ -2154,7 +2358,11 @@ func (btc *ExchangeWallet) SwapConfirmations(_ context.Context, id dex.Bytes, co if err != nil { return 0, false, err } - return btc.node.swapConfirmations(txHash, vout, contract, startTime) + pkScript, err := btc.scriptHashScript(contract) + if err != nil { + return 0, false, err + } + return btc.node.swapConfirmations(txHash, vout, pkScript, startTime) } // RegFeeConfirmations gets the number of confirmations for the specified output @@ -2590,11 +2798,31 @@ type compositeUTXO struct { // spendableUTXOs filters the RPC utxos for those that are spendable with with // regards to the DEX's configuration, and considered safe to spend according to // confirmations and coin source. The UTXOs will be sorted by ascending value. +// spendableUTXOs should only be called with the fundingMtx RLock'ed. func (btc *ExchangeWallet) spendableUTXOs(confs uint32) ([]*compositeUTXO, map[outPoint]*compositeUTXO, uint64, error) { unspents, err := btc.node.listUnspent() if err != nil { return nil, nil, 0, err } + utxos, utxoMap, sum, err := convertUnspent(confs, unspents, btc.chainParams) + if err != nil { + return nil, nil, 0, err + } + for _, utxo := range utxos { + // Guard against inconsistencies between the wallet's view of + // spendable unlocked UTXOs and ExchangeWallet's. e.g. User manually + // unlocked something or even restarted the wallet software. + pt := newOutPoint(utxo.txHash, utxo.vout) + if btc.fundingCoins[pt] != nil { + btc.log.Warnf("Known order-funding coin %s returned by listunspent!", pt) + // TODO: Consider relocking the coin in the wallet. + //continue + } + } + return utxos, utxoMap, sum, nil +} + +func convertUnspent(confs uint32, unspents []*ListUnspentResult, chainParams *chaincfg.Params) ([]*compositeUTXO, map[outPoint]*compositeUTXO, uint64, error) { sort.Slice(unspents, func(i, j int) bool { return unspents[i].Amount < unspents[j].Amount }) var sum uint64 utxos := make([]*compositeUTXO, 0, len(unspents)) @@ -2606,17 +2834,7 @@ func (btc *ExchangeWallet) spendableUTXOs(confs uint32) ([]*compositeUTXO, map[o return nil, nil, 0, fmt.Errorf("error decoding txid in ListUnspentResult: %w", err) } - // Guard against inconsistencies between the wallet's view of - // spendable unlocked UTXOs and ExchangeWallet's. e.g. User manually - // unlocked something or even restarted the wallet software. - pt := newOutPoint(txHash, txout.Vout) - if btc.fundingCoins[pt] != nil { - btc.log.Warnf("Known order-funding coin %s returned by listunspent!", pt) - // TODO: Consider relocking the coin in the wallet. - //continue - } - - nfo, err := dexbtc.InputInfo(txout.ScriptPubKey, txout.RedeemScript, btc.chainParams) + nfo, err := dexbtc.InputInfo(txout.ScriptPubKey, txout.RedeemScript, chainParams) if err != nil { return nil, nil, 0, fmt.Errorf("error reading asset info: %w", err) } @@ -2633,7 +2851,7 @@ func (btc *ExchangeWallet) spendableUTXOs(confs uint32) ([]*compositeUTXO, map[o input: nfo, } utxos = append(utxos, utxo) - utxoMap[pt] = utxo + utxoMap[newOutPoint(txHash, txout.Vout)] = utxo sum += toSatoshi(txout.Amount) } } @@ -2740,6 +2958,14 @@ func (btc *ExchangeWallet) scriptHashAddress(contract []byte) (btcutil.Address, return scriptHashAddress(btc.segwit, contract, btc.chainParams) } +func (btc *ExchangeWallet) scriptHashScript(contract []byte) ([]byte, error) { + addr, err := btc.scriptHashAddress(contract) + if err != nil { + return nil, err + } + return txscript.PayToAddrScript(addr) +} + func scriptHashAddress(segwit bool, contract []byte, chainParams *chaincfg.Params) (btcutil.Address, error) { if segwit { return btcutil.NewAddressWitnessScriptHash(hashContract(segwit, contract), chainParams) @@ -2798,3 +3024,38 @@ func toBTC(v uint64) float64 { func rawTxInSig(tx *wire.MsgTx, idx int, pkScript []byte, hashType txscript.SigHashType, key *btcec.PrivateKey, _ uint64) ([]byte, error) { return txscript.RawTxInSignature(tx, idx, pkScript, txscript.SigHashAll, key) } + +// findRedemptionsInTx searches the MsgTx for the redemptions for the specified +// swaps. +func findRedemptionsInTx(ctx context.Context, segwit bool, reqs map[outPoint]*findRedemptionReq, msgTx *wire.MsgTx, + chainParams *chaincfg.Params) (discovered map[outPoint]*findRedemptionResult) { + + discovered = make(map[outPoint]*findRedemptionResult, len(reqs)) + + for vin, txIn := range msgTx.TxIn { + if ctx.Err() != nil { + return discovered + } + poHash, poVout := txIn.PreviousOutPoint.Hash, txIn.PreviousOutPoint.Index + for outPt, req := range reqs { + if discovered[outPt] != nil { + continue + } + if outPt.txHash == poHash && outPt.vout == poVout { + // Match! + txHash := msgTx.TxHash() + secret, err := dexbtc.FindKeyPush(txIn.Witness, txIn.SignatureScript, req.contractHash[:], segwit, chainParams) + if err != nil { + req.fail("no secret extracted from redemption input %s:%d for swap output %s: %v", + txHash, vin, outPt, err) + continue + } + discovered[outPt] = &findRedemptionResult{ + redemptionCoinID: toCoinID(&txHash, uint32(vin)), + secret: secret, + } + } + } + } + return +} diff --git a/client/asset/btc/btc_test.go b/client/asset/btc/btc_test.go index ce9e8363ee..9265870d50 100644 --- a/client/asset/btc/btc_test.go +++ b/client/asset/btc/btc_test.go @@ -1,5 +1,5 @@ -//go:build !harness -// +build !harness +//go:build !spvlive +// +build !spvlive package btc @@ -71,7 +71,7 @@ func randBytes(l int) []byte { return b } -func signFunc(t *testing.T, params []json.RawMessage, sizeTweak int, sigComplete, segwit bool) (json.RawMessage, error) { +func signFuncRaw(t *testing.T, params []json.RawMessage, sizeTweak int, sigComplete, segwit bool) (json.RawMessage, error) { signTxRes := SignTxResult{ Complete: sigComplete, } @@ -87,30 +87,35 @@ func signFunc(t *testing.T, params []json.RawMessage, sizeTweak int, sigComplete if err != nil { t.Fatalf("error deserializing contract: %v", err) } + + signFunc(msgTx, sizeTweak, segwit) + + buf := new(bytes.Buffer) + err = msgTx.Serialize(buf) + if err != nil { + t.Fatalf("error serializing contract: %v", err) + } + signTxRes.Hex = buf.Bytes() + return mustMarshal(signTxRes), nil +} + +func signFunc(tx *wire.MsgTx, sizeTweak int, segwit bool) { // Set the sigScripts to random bytes of the correct length for spending a // p2pkh output. if segwit { sigSize := 73 + sizeTweak - for i := range msgTx.TxIn { - msgTx.TxIn[i].Witness = wire.TxWitness{ + for i := range tx.TxIn { + tx.TxIn[i].Witness = wire.TxWitness{ randBytes(sigSize), randBytes(33), } } } else { scriptSize := dexbtc.RedeemP2PKHSigScriptSize + sizeTweak - for i := range msgTx.TxIn { - msgTx.TxIn[i].SignatureScript = randBytes(scriptSize) + for i := range tx.TxIn { + tx.TxIn[i].SignatureScript = randBytes(scriptSize) } } - - buf := new(bytes.Buffer) - err = msgTx.Serialize(buf) - if err != nil { - t.Fatalf("error serializing contract: %v", err) - } - signTxRes.Hex = buf.Bytes() - return mustMarshal(signTxRes), nil } type msgBlockWithHeight struct { @@ -118,17 +123,21 @@ type msgBlockWithHeight struct { height int64 } -type tRPCClient struct { - sendHash *chainhash.Hash - sendErr error - sentRawTx *wire.MsgTx - txOutRes *btcjson.GetTxOutResult - txOutErr error - signFunc func([]json.RawMessage) (json.RawMessage, error) - signMsgFunc func([]json.RawMessage) (json.RawMessage, error) - blockchainMtx sync.RWMutex - verboseBlocks map[string]*msgBlockWithHeight - mainchain map[int64]*chainhash.Hash +type testData struct { + badSendHash *chainhash.Hash + sendErr error + sentRawTx *wire.MsgTx + txOutRes *btcjson.GetTxOutResult + txOutErr error + sigIncomplete bool + signFunc func(*wire.MsgTx) + signMsgFunc func([]json.RawMessage) (json.RawMessage, error) + + blockchainMtx sync.RWMutex + verboseBlocks map[string]*msgBlockWithHeight + dbBlockForTx map[chainhash.Hash]*hashEntry + mainchain map[int64]*chainhash.Hash + getBestBlockHashErr error mempoolTxs map[chainhash.Hash]*wire.MsgTx rawVerboseErr error @@ -150,9 +159,7 @@ type tRPCClient struct { getBlockchainInfo *getBlockchainInfoResult getBlockchainInfoErr error - unlock bool unlockErr error - lock bool lockErr error sendToAddress string sendToAddressErr error @@ -160,31 +167,44 @@ type tRPCClient struct { signTxErr error listUnspent []*ListUnspentResult listUnspentErr error + + // spv + fetchInputInfoTx *wire.MsgTx + getCFilterScripts map[chainhash.Hash][][]byte + checkpoints map[outPoint]*scanCheckpoint + confs uint32 + confsSpent bool + confsErr error + walletTxSpent bool } -func newTRPCClient() *tRPCClient { +func newTestData() *testData { // setup genesis block, required by bestblock polling goroutine - var newHash chainhash.Hash - copy(newHash[:], randBytes(32)) - return &tRPCClient{ + genesisHash := chaincfg.MainNetParams.GenesisHash + return &testData{ txOutRes: newTxOutResult([]byte{}, 1, 0), verboseBlocks: map[string]*msgBlockWithHeight{ - newHash.String(): {msgBlock: &wire.MsgBlock{}}, + genesisHash.String(): {msgBlock: &wire.MsgBlock{}}, }, + dbBlockForTx: make(map[chainhash.Hash]*hashEntry), mainchain: map[int64]*chainhash.Hash{ - 0: &newHash, + 0: genesisHash, }, - mempoolTxs: make(map[chainhash.Hash]*wire.MsgTx), + mempoolTxs: make(map[chainhash.Hash]*wire.MsgTx), + fetchInputInfoTx: dummyTx(), + getCFilterScripts: make(map[chainhash.Hash][][]byte), + confsErr: WalletTransactionNotFound, + checkpoints: make(map[outPoint]*scanCheckpoint), } } -func (c *tRPCClient) getBlock(blockHash string) *msgBlockWithHeight { +func (c *testData) getBlock(blockHash string) *msgBlockWithHeight { c.blockchainMtx.Lock() defer c.blockchainMtx.Unlock() return c.verboseBlocks[blockHash] } -func (c *tRPCClient) GetBestBlockHeight() int64 { +func (c *testData) GetBestBlockHeight() int64 { c.blockchainMtx.RLock() defer c.blockchainMtx.RUnlock() var bestBlkHeight int64 @@ -196,6 +216,20 @@ func (c *tRPCClient) GetBestBlockHeight() int64 { return bestBlkHeight } +func (c *testData) bestBlock() (*chainhash.Hash, int64) { + c.blockchainMtx.RLock() + defer c.blockchainMtx.RUnlock() + var bestHash *chainhash.Hash + var bestBlkHeight int64 + for height, hash := range c.mainchain { + if height >= bestBlkHeight { + bestBlkHeight = height + bestHash = hash + } + } + return bestHash, bestBlkHeight +} + func encodeOrError(thing interface{}, err error) (json.RawMessage, error) { if err != nil { return nil, err @@ -204,15 +238,15 @@ func encodeOrError(thing interface{}, err error) (json.RawMessage, error) { } type tRawRequester struct { - *tRPCClient + *testData } func (c *tRawRequester) RawRequest(_ context.Context, method string, params []json.RawMessage) (json.RawMessage, error) { switch method { // TODO: handle methodGetBlockHash and add actual tests to cover it. case methodEstimateSmartFee: - if c.tRPCClient.estFeeErr != nil { - return nil, c.tRPCClient.estFeeErr + if c.testData.estFeeErr != nil { + return nil, c.testData.estFeeErr } optimalRate := float64(optimalFeeRate) * 1e-5 // ~0.00024 return json.Marshal(&btcjson.EstimateSmartFeeResult{ @@ -230,30 +264,21 @@ func (c *tRawRequester) RawRequest(_ context.Context, method string, params []js return nil, err } c.sentRawTx = tx - if c.sendErr == nil && c.sendHash == nil { + if c.sendErr == nil && c.badSendHash == nil { h := tx.TxHash().String() return json.Marshal(&h) } if c.sendErr != nil { return nil, c.sendErr } - return json.Marshal(c.sendHash.String()) + return json.Marshal(c.badSendHash.String()) case methodGetTxOut: return encodeOrError(c.txOutRes, c.txOutErr) case methodGetBestBlockHash: if c.getBestBlockHashErr != nil { return nil, c.getBestBlockHashErr } - c.blockchainMtx.RLock() - defer c.blockchainMtx.RUnlock() - var bestHash *chainhash.Hash - var bestBlkHeight int64 - for height, hash := range c.mainchain { - if height >= bestBlkHeight { - bestBlkHeight = height - bestHash = hash - } - } + bestHash, _ := c.bestBlock() return json.Marshal(bestHash.String()) case methodGetBlockHash: var blockHeight int64 @@ -276,8 +301,8 @@ func (c *tRawRequester) RawRequest(_ context.Context, method string, params []js } return json.Marshal(hashes) case methodGetRawTransaction: - if c.tRPCClient.rawVerboseErr != nil { - return nil, c.tRPCClient.rawVerboseErr + if c.testData.rawVerboseErr != nil { + return nil, c.testData.rawVerboseErr } var hashStr string err := json.Unmarshal(params[0], &hashStr) @@ -298,7 +323,31 @@ func (c *tRawRequester) RawRequest(_ context.Context, method string, params []js if c.signTxErr != nil { return nil, c.signTxErr } - return c.signFunc(params) + signTxRes := SignTxResult{ + Complete: !c.sigIncomplete, + } + var msgHex string + err := json.Unmarshal(params[0], &msgHex) + if err != nil { + return nil, fmt.Errorf("json.Unmarshal error for tRawRequester -> RawRequest -> methodSignTx: %v", err) + } + msgBytes, _ := hex.DecodeString(msgHex) + txReader := bytes.NewReader(msgBytes) + msgTx := wire.NewMsgTx(wire.TxVersion) + err = msgTx.Deserialize(txReader) + if err != nil { + return nil, fmt.Errorf("MsgTx.Deserialize error for tRawRequester -> RawRequest -> methodSignTx: %v", err) + } + + c.signFunc(msgTx) + + buf := new(bytes.Buffer) + err = msgTx.Serialize(buf) + if err != nil { + return nil, fmt.Errorf("MsgTx.Serialize error for tRawRequester -> RawRequest -> methodSignTx: %v", err) + } + signTxRes.Hex = buf.Bytes() + return mustMarshal(signTxRes), nil case methodGetBlock: c.blockchainMtx.Lock() defer c.blockchainMtx.Unlock() @@ -363,9 +412,9 @@ func (c *tRawRequester) RawRequest(_ context.Context, method string, params []js case methodGetBlockchainInfo: return encodeOrError(c.getBlockchainInfo, c.getBlockchainInfoErr) case methodLock: - return encodeOrError(c.lock, c.lockErr) + return nil, c.lockErr case methodUnlock: - return encodeOrError(c.unlock, c.unlockErr) + return nil, c.unlockErr case methodSendToAddress: return encodeOrError(c.sendToAddress, c.sendToAddressErr) case methodSetTxFee: @@ -376,7 +425,13 @@ func (c *tRawRequester) RawRequest(_ context.Context, method string, params []js panic("method not registered: " + method) } -func (c *tRPCClient) addRawTx(blockHeight int64, tx *wire.MsgTx) (*chainhash.Hash, *wire.MsgBlock) { +const testBlocksPerBlockTimeOffset = 4 + +func generateTestBlockTime(blockHeight int64) time.Time { + return time.Unix(1e6, 0).Add(time.Duration(blockHeight) * maxFutureBlockTime / testBlocksPerBlockTimeOffset) +} + +func (c *testData) addRawTx(blockHeight int64, tx *wire.MsgTx) (*chainhash.Hash, *wire.MsgBlock) { c.blockchainMtx.Lock() defer c.blockchainMtx.Unlock() blockHash, found := c.mainchain[blockHeight] @@ -393,6 +448,7 @@ func (c *tRPCClient) addRawTx(blockHeight int64, tx *wire.MsgTx) (*chainhash.Has } } header := wire.NewBlockHeader(0, prevBlock, &chainhash.Hash{}, 1, 2) + header.Timestamp = generateTestBlockTime(blockHeight) msgBlock := wire.NewMsgBlock(header) c.verboseBlocks[blockHash.String()] = &msgBlockWithHeight{ msgBlock: msgBlock, @@ -405,7 +461,13 @@ func (c *tRPCClient) addRawTx(blockHeight int64, tx *wire.MsgTx) (*chainhash.Has return blockHash, block.msgBlock } -func (c *tRPCClient) getBlockAtHeight(blockHeight int64) (*chainhash.Hash, *msgBlockWithHeight) { +func (c *testData) addDBBlockForTx(txHash, blockHash *chainhash.Hash) { + c.blockchainMtx.Lock() + defer c.blockchainMtx.Unlock() + c.dbBlockForTx[*txHash] = &hashEntry{hash: *blockHash} +} + +func (c *testData) getBlockAtHeight(blockHeight int64) (*chainhash.Hash, *msgBlockWithHeight) { c.blockchainMtx.RLock() defer c.blockchainMtx.RUnlock() blockHash, found := c.mainchain[blockHeight] @@ -416,7 +478,7 @@ func (c *tRPCClient) getBlockAtHeight(blockHeight int64) (*chainhash.Hash, *msgB return blockHash, blk } -func (c *tRPCClient) truncateChains() { +func (c *testData) truncateChains() { c.blockchainMtx.RLock() defer c.blockchainMtx.RUnlock() c.mainchain = make(map[int64]*chainhash.Hash) @@ -460,6 +522,14 @@ func makeRPCVin(txHash *chainhash.Hash, vout uint32, sigScript []byte, witness [ return wire.NewTxIn(wire.NewOutPoint(txHash, vout), sigScript, witness) } +func dummyInput() *wire.TxIn { + return wire.NewTxIn(wire.NewOutPoint(&chainhash.Hash{0x01}, 0), nil, nil) +} + +func dummyTx() *wire.MsgTx { + return makeRawTx([]dex.Bytes{randBytes(32)}, []*wire.TxIn{dummyInput()}) +} + func newTxOutResult(script []byte, value uint64, confs int64) *btcjson.GetTxOutResult { return &btcjson.GetTxOutResult{ Confirmations: confs, @@ -486,7 +556,7 @@ func makeSwapContract(segwit bool, lockTimeOffset time.Duration) (secret []byte, return } -func tNewWallet(segwit bool) (*ExchangeWallet, *tRPCClient, func(), error) { +func tNewWallet(segwit bool, walletType string) (*ExchangeWallet, *testData, func(), error) { if segwit { tBTC.SwapSize = dexbtc.InitTxSizeSegwit tBTC.SwapSizeBase = dexbtc.InitTxSizeBaseSegwit @@ -495,7 +565,7 @@ func tNewWallet(segwit bool) (*ExchangeWallet, *tRPCClient, func(), error) { tBTC.SwapSizeBase = dexbtc.InitTxSizeBase } - client := newTRPCClient() + data := newTestData() walletCfg := &asset.WalletConfig{ TipChange: func(error) {}, } @@ -513,7 +583,29 @@ func tNewWallet(segwit bool) (*ExchangeWallet, *tRPCClient, func(), error) { // rpcClient := newRPCClient(requester, segwit, nil, false, minNetworkVersion, dex.StdOutLogger("RPCTEST", dex.LevelTrace), &chaincfg.MainNetParams) - wallet, err := newWallet(&tRawRequester{client}, cfg, &dexbtc.Config{}) + var wallet *ExchangeWallet + var err error + switch walletType { + case walletTypeRPC: + wallet, err = newRPCWallet(&tRawRequester{data}, cfg, &WalletConfig{}) + case walletTypeSPV: + wallet, err = newUnconnectedWallet(cfg, &WalletConfig{}) + if err == nil { + neutrinoClient := &tNeutrinoClient{data} + wallet.node = &spvWallet{ + chainParams: &chaincfg.MainNetParams, + wallet: &tBtcWallet{data}, + cl: neutrinoClient, + chainClient: nil, + acctNum: 0, + txBlocks: data.dbBlockForTx, + checkpoints: data.checkpoints, + log: cfg.Logger.SubLogger("SPV"), + loader: nil, + } + } + } + if err != nil { shutdown() return nil, nil, nil, err @@ -525,12 +617,12 @@ func tNewWallet(segwit bool) (*ExchangeWallet, *tRPCClient, func(), error) { return nil, nil, nil, err } wallet.tipMtx.Lock() - wallet.currentTip = &block{height: client.GetBestBlockHeight(), + wallet.currentTip = &block{height: data.GetBestBlockHeight(), hash: bestHash.String()} wallet.tipMtx.Unlock() go wallet.run(walletCtx) - return wallet, client, shutdown, nil + return wallet, data, shutdown, nil } func mustMarshal(thing interface{}) []byte { @@ -557,8 +649,26 @@ func TestMain(m *testing.M) { os.Exit(doIt()) } +type testFunc func(t *testing.T, segwit bool, walletType string) + +func runRubric(t *testing.T, f testFunc) { + t.Run("rpc|segwit", func(t *testing.T) { + f(t, true, walletTypeRPC) + }) + t.Run("rpc|non-segwit", func(t *testing.T) { + f(t, false, walletTypeRPC) + }) + t.Run("spv|segwit", func(t *testing.T) { + f(t, true, walletTypeSPV) + }) +} + func TestAvailableFund(t *testing.T) { - wallet, node, shutdown, err := tNewWallet(true) + runRubric(t, testAvailableFund) +} + +func testAvailableFund(t *testing.T, segwit bool, walletType string) { + wallet, node, shutdown, err := tNewWallet(segwit, walletType) defer shutdown() if err != nil { t.Fatal(err) @@ -613,14 +723,16 @@ func TestAvailableFund(t *testing.T) { }, } - msgTx := wire.NewMsgTx(wire.TxVersion) - msgTx.AddTxIn(wire.NewTxIn(wire.NewOutPoint(&chainhash.Hash{0x01}, 0), nil, nil)) - msgTx.AddTxOut(wire.NewTxOut(1, []byte{0x02})) - msgTx.AddTxOut(wire.NewTxOut(int64(lockedVal), []byte{0x02})) + msgTx := makeRawTx([]dex.Bytes{{0x01}, {0x02}}, []*wire.TxIn{dummyInput()}) + msgTx.TxOut[1].Value = int64(lockedVal) txBuf := bytes.NewBuffer(make([]byte, 0, dexbtc.MsgTxVBytes(msgTx))) msgTx.Serialize(txBuf) + const blockHeight = 5 + blockHash, _ := node.addRawTx(blockHeight, msgTx) node.getTransaction = &GetTransactionResult{ + BlockHash: blockHash.String(), + BlockIndex: blockHeight, Details: []*WalletTxDetails{ { Amount: float64(lockedVal) / 1e8, @@ -711,12 +823,15 @@ func TestAvailableFund(t *testing.T) { node.listUnspentErr = nil // Negative response when locking outputs. - node.lockUnspentErr = tErr - _, _, err = wallet.FundOrder(ord) - if err == nil { - t.Fatalf("no error for lockunspent result = false: %v", err) + // There is no way to error locking outpoints in spv + if walletType != walletTypeSPV { + node.lockUnspentErr = tErr + _, _, err = wallet.FundOrder(ord) + if err == nil { + t.Fatalf("no error for lockunspent result = false: %v", err) + } + node.lockUnspentErr = nil } - node.lockUnspentErr = nil // Fund a little bit, with unsafe littleUTXO. littleUTXO.Safe = false @@ -842,8 +957,8 @@ func TestAvailableFund(t *testing.T) { _ = wallet.ReturnCoins(coins) // With a little more locked, the split should be performed. - node.signFunc = func(params []json.RawMessage) (json.RawMessage, error) { - return signFunc(t, params, 0, true, wallet.segwit) + node.signFunc = func(tx *wire.MsgTx) { + signFunc(tx, 0, wallet.segwit) } lottaUTXO.Amount += float64(baggageFees) / 1e8 node.listUnspent = unspents @@ -920,7 +1035,7 @@ func (c *tCoin) String() string { return hex.EncodeToString(c.id) } func (c *tCoin) Value() uint64 { return 100 } func TestReturnCoins(t *testing.T) { - wallet, node, shutdown, err := tNewWallet(true) + wallet, node, shutdown, err := tNewWallet(true, walletTypeRPC) defer shutdown() if err != nil { t.Fatal(err) @@ -957,17 +1072,28 @@ func TestReturnCoins(t *testing.T) { } func TestFundingCoins(t *testing.T) { - wallet, node, shutdown, err := tNewWallet(true) + // runRubric(t, testFundingCoins) + testFundingCoins(t, false, walletTypeRPC) +} + +func testFundingCoins(t *testing.T, segwit bool, walletType string) { + wallet, node, shutdown, err := tNewWallet(segwit, walletType) defer shutdown() if err != nil { t.Fatal(err) } - vout := uint32(123) - coinID := toCoinID(tTxHash, vout) + const vout = 1 + const txBlockHeight = 3 + tx := makeRawTx([]dex.Bytes{{0x01}, tP2PKH}, []*wire.TxIn{dummyInput()}) + txHash := tx.TxHash() + _, _ = node.addRawTx(txBlockHeight, tx) + coinID := toCoinID(&txHash, vout) + // Make spendable (confs > 0) + node.addRawTx(txBlockHeight+1, dummyTx()) p2pkhUnspent := &ListUnspentResult{ - TxID: tTxID, + TxID: txHash.String(), Vout: vout, ScriptPubKey: tP2PKH, Spendable: true, @@ -1068,7 +1194,7 @@ func checkSwapEstimate(t *testing.T, est *asset.SwapEstimate, lots, swapVal, max } func TestFundEdges(t *testing.T) { - wallet, node, shutdown, err := tNewWallet(false) + wallet, node, shutdown, err := tNewWallet(false, walletTypeRPC) defer shutdown() if err != nil { t.Fatal(err) @@ -1154,8 +1280,8 @@ func TestFundEdges(t *testing.T) { // well. wallet.useSplitTx = true node.changeAddr = tP2WPKHAddr - node.signFunc = func(params []json.RawMessage) (json.RawMessage, error) { - return signFunc(t, params, 0, true, wallet.segwit) + node.signFunc = func(tx *wire.MsgTx) { + signFunc(tx, 0, wallet.segwit) } backingFees = uint64(totalBytes+splitTxBaggage) * tBTC.MaxFeeRate // 1 too few atoms @@ -1288,7 +1414,7 @@ func TestFundEdges(t *testing.T) { } func TestFundEdgesSegwit(t *testing.T) { - wallet, node, shutdown, err := tNewWallet(true) + wallet, node, shutdown, err := tNewWallet(true, walletTypeRPC) defer shutdown() if err != nil { t.Fatal(err) @@ -1373,8 +1499,8 @@ func TestFundEdgesSegwit(t *testing.T) { // well. wallet.useSplitTx = true node.changeAddr = tP2WPKHAddr - node.signFunc = func(params []json.RawMessage) (json.RawMessage, error) { - return signFunc(t, params, 0, true, wallet.segwit) + node.signFunc = func(tx *wire.MsgTx) { + signFunc(tx, 0, wallet.segwit) } backingFees = uint64(totalBytes+splitTxBaggageSegwit) * tBTC.MaxFeeRate v := swapVal + backingFees - 1 @@ -1405,16 +1531,11 @@ func TestFundEdgesSegwit(t *testing.T) { } func TestSwap(t *testing.T) { - t.Run("segwit", func(t *testing.T) { - testSwap(t, true) - }) - t.Run("non-segwit", func(t *testing.T) { - testSwap(t, false) - }) + runRubric(t, testSwap) } -func testSwap(t *testing.T, segwit bool) { - wallet, node, shutdown, err := tNewWallet(segwit) +func testSwap(t *testing.T, segwit bool, walletType string) { + wallet, node, shutdown, err := tNewWallet(segwit, walletType) defer shutdown() if err != nil { t.Fatal(err) @@ -1456,17 +1577,15 @@ func testSwap(t *testing.T, segwit bool) { FeeRate: tBTC.MaxFeeRate, } - signatureComplete := true - // Aim for 3 signature cycles. sigSizer := 0 - node.signFunc = func(params []json.RawMessage) (json.RawMessage, error) { + node.signFunc = func(tx *wire.MsgTx) { var sizeTweak int if sigSizer%2 == 0 { sizeTweak = -2 } sigSizer++ - return signFunc(t, params, sizeTweak, signatureComplete, wallet.segwit) + signFunc(tx, sizeTweak, wallet.segwit) } // This time should succeed. @@ -1523,12 +1642,12 @@ func testSwap(t *testing.T, segwit bool) { node.signTxErr = nil // incomplete signatures - signatureComplete = false + node.sigIncomplete = true _, _, _, err = wallet.Swap(swaps) if err == nil { t.Fatalf("no error for incomplete signature rpc error") } - signatureComplete = true + node.sigIncomplete = false // Make sure we can succeed again. _, _, _, err = wallet.Swap(swaps) @@ -1546,16 +1665,11 @@ func (ai *TAuditInfo) Contract() dex.Bytes { return nil } func (ai *TAuditInfo) SecretHash() dex.Bytes { return nil } func TestRedeem(t *testing.T) { - t.Run("segwit", func(t *testing.T) { - testRedeem(t, true) - }) - t.Run("non-segwit", func(t *testing.T) { - testRedeem(t, false) - }) + runRubric(t, testRedeem) } -func testRedeem(t *testing.T, segwit bool) { - wallet, node, shutdown, err := tNewWallet(segwit) +func testRedeem(t *testing.T, segwit bool, walletType string) { + wallet, node, shutdown, err := tNewWallet(segwit, walletType) defer shutdown() if err != nil { t.Fatal(err) @@ -1666,16 +1780,20 @@ func testRedeem(t *testing.T, segwit bool) { // Wrong hash var h chainhash.Hash h[0] = 0x01 - node.sendHash = &h + node.badSendHash = &h _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for wrong return hash") } - node.sendHash = nil + node.badSendHash = nil } func TestSignMessage(t *testing.T) { - wallet, node, shutdown, err := tNewWallet(true) + runRubric(t, testSignMessage) +} + +func testSignMessage(t *testing.T, segwit bool, walletType string) { + wallet, node, shutdown, err := tNewWallet(segwit, walletType) defer shutdown() if err != nil { t.Fatal(err) @@ -1767,21 +1885,15 @@ func TestSignMessage(t *testing.T) { } func TestAuditContract(t *testing.T) { - t.Run("segwit", func(t *testing.T) { - testAuditContract(t, true) - }) - t.Run("non-segwit", func(t *testing.T) { - testAuditContract(t, false) - }) + runRubric(t, testAuditContract) } -func testAuditContract(t *testing.T, segwit bool) { - wallet, node, shutdown, err := tNewWallet(segwit) +func testAuditContract(t *testing.T, segwit bool, walletType string) { + wallet, node, shutdown, err := tNewWallet(segwit, walletType) defer shutdown() if err != nil { t.Fatal(err) } - vout := uint32(5) swapVal := toSatoshi(5) secretHash, _ := hex.DecodeString("5124208c80d33507befa517c08ed01aa8d33adbf37ecd70fb5f9352f7a51a88d") lockTime := time.Now().Add(time.Hour * 12) @@ -1805,15 +1917,30 @@ func testAuditContract(t *testing.T, segwit bool) { } pkScript, _ := txscript.PayToAddrScript(contractAddr) - node.txOutRes = &btcjson.GetTxOutResult{ + // Prime a blockchain + const tipHeight = 10 + const txBlockHeight = 9 + for i := int64(1); i < tipHeight; i++ { + node.addRawTx(i, dummyTx()) + } + + tx := makeRawTx([]dex.Bytes{pkScript}, []*wire.TxIn{dummyInput()}) + blockHash, _ := node.addRawTx(txBlockHeight, tx) + node.getCFilterScripts[*blockHash] = [][]byte{pkScript} //spv + node.txOutRes = &btcjson.GetTxOutResult{ // rpc Confirmations: 2, Value: float64(swapVal) / 1e8, ScriptPubKey: btcjson.ScriptPubKeyResult{ Hex: hex.EncodeToString(pkScript), }, } + node.getTransactionErr = WalletTransactionNotFound + + txHash := tx.TxHash() + const vout = 0 + outPt := newOutPoint(&txHash, vout) - audit, err := wallet.AuditContract(toCoinID(tTxHash, vout), contract, nil, now) + audit, err := wallet.AuditContract(toCoinID(&txHash, vout), contract, nil, now) if err != nil { t.Fatalf("audit error: %v", err) } @@ -1835,17 +1962,20 @@ func testAuditContract(t *testing.T, segwit bool) { // GetTxOut error node.txOutErr = tErr - _, err = wallet.AuditContract(toCoinID(tTxHash, vout), contract, nil, now) + delete(node.getCFilterScripts, *blockHash) + delete(node.checkpoints, outPt) + _, err = wallet.AuditContract(toCoinID(&txHash, vout), contract, nil, now) if err == nil { t.Fatalf("no error for unknown txout") } node.txOutErr = nil + node.getCFilterScripts[*blockHash] = [][]byte{pkScript} // Wrong contract pkh, _ := hex.DecodeString("c6a704f11af6cbee8738ff19fc28cdc70aba0b82") wrongAddr, _ := btcutil.NewAddressPubKeyHash(pkh, &chaincfg.MainNetParams) badContract, _ := txscript.PayToAddrScript(wrongAddr) - _, err = wallet.AuditContract(toCoinID(tTxHash, vout), badContract, nil, now) + _, err = wallet.AuditContract(toCoinID(&txHash, vout), badContract, nil, now) if err == nil { t.Fatalf("no error for wrong contract") } @@ -1862,34 +1992,15 @@ func (r *tReceipt) Coin() asset.Coin { return r.coin } func (r *tReceipt) Contract() dex.Bytes { return r.contract } func TestFindRedemption(t *testing.T) { - t.Run("segwit", func(t *testing.T) { - testFindRedemption(t, true) - }) - t.Run("non-segwit", func(t *testing.T) { - testFindRedemption(t, false) - }) + runRubric(t, testFindRedemption) } -func testFindRedemption(t *testing.T, segwit bool) { - node := newTRPCClient() - cfg := &BTCCloneCFG{ - WalletCFG: &asset.WalletConfig{ - TipChange: func(error) {}, - }, - Symbol: "btc", - Logger: tLogger, - ChainParams: &chaincfg.MainNetParams, - WalletInfo: WalletInfo, - DefaultFallbackFee: defaultFee, - DefaultFeeRateLimit: defaultFeeRateLimit, - Segwit: segwit, - } - - wallet, err := newWallet(&tRawRequester{node}, cfg, &dexbtc.Config{}) +func testFindRedemption(t *testing.T, segwit bool, walletType string) { + wallet, node, shutdown, err := tNewWallet(segwit, walletType) + defer shutdown() if err != nil { t.Fatal(err) } - wallet.currentTip = &block{} // since we're not using Connect, run checkForNewBlocks after adding blocks contractHeight := node.GetBestBlockHeight() + 1 otherTxid := "7a7b3b5c3638516bc8e7f19b4a3dec00f052a599fed5036c2b89829de2367bb6" @@ -1943,7 +2054,8 @@ func testFindRedemption(t *testing.T, segwit bool) { // Now add the redemption. redeemVin := makeRPCVin(&contractTxHash, contractVout, redemptionSigScript, redemptionWitness) inputs = append(inputs, redeemVin) - node.addRawTx(contractHeight+2, makeRawTx([]dex.Bytes{otherScript}, inputs)) + redeemBlockHash, _ := node.addRawTx(contractHeight+2, makeRawTx([]dex.Bytes{otherScript}, inputs)) + node.getCFilterScripts[*redeemBlockHash] = [][]byte{pkScript} // Update currentTip from "RPC". Normally run() would do this. wallet.checkForNewBlocks(tCtx) @@ -1967,16 +2079,18 @@ func testFindRedemption(t *testing.T, segwit bool) { // timeout finding missing redemption redeemVin.PreviousOutPoint.Hash = *otherTxHash - ctx, cancel := context.WithTimeout(tCtx, 500*time.Millisecond) // 0.5 seconds is long enough + delete(node.getCFilterScripts, *redeemBlockHash) + timedCtx, cancel := context.WithTimeout(tCtx, 500*time.Millisecond) // 0.5 seconds is long enough defer cancel() - _, k, err := wallet.FindRedemption(ctx, coinID) - if ctx.Err() == nil || k != nil { + _, k, err := wallet.FindRedemption(timedCtx, coinID) + if timedCtx.Err() == nil || k != nil { // Expected ctx to cancel after timeout and no secret should be found. t.Fatalf("unexpected result for missing redemption: secret: %v, err: %v", k, err) } node.blockchainMtx.Lock() redeemVin.PreviousOutPoint.Hash = contractTxHash + node.getCFilterScripts[*redeemBlockHash] = [][]byte{pkScript} node.blockchainMtx.Unlock() // Canceled context @@ -2010,26 +2124,22 @@ func testFindRedemption(t *testing.T, segwit bool) { } func TestRefund(t *testing.T) { - t.Run("segwit", func(t *testing.T) { - testRefund(t, true) - }) - t.Run("non-segwit", func(t *testing.T) { - testRefund(t, false) - }) + runRubric(t, testRefund) } -func testRefund(t *testing.T, segwit bool) { - wallet, node, shutdown, err := tNewWallet(segwit) +func testRefund(t *testing.T, segwit bool, walletType string) { + wallet, node, shutdown, err := tNewWallet(segwit, walletType) defer shutdown() if err != nil { t.Fatal(err) } - _, _, _, contract, addr, _, _ := makeSwapContract(segwit, time.Hour*12) + _, _, pkScript, contract, addr, _, _ := makeSwapContract(segwit, time.Hour*12) bigTxOut := newTxOutResult(nil, 1e8, 2) - node.txOutRes = bigTxOut + node.txOutRes = bigTxOut // rpc node.changeAddr = addr.String() + const feeSuggestion = 100 privBytes, _ := hex.DecodeString("b07209eec1a8fb6cfe5cb6ace36567406971a75c330db7101fb21bc679bc5330") privKey, _ := btcec.PrivKeyFromBytes(btcec.S256(), privBytes) @@ -2039,8 +2149,17 @@ func testRefund(t *testing.T, segwit bool) { } node.privKeyForAddr = wif - contractOutput := newOutput(tTxHash, 0, 1e8) - _, err = wallet.Refund(contractOutput.ID(), contract) + tx := makeRawTx([]dex.Bytes{pkScript}, []*wire.TxIn{dummyInput()}) + const vout = 0 + tx.TxOut[vout].Value = 1e8 + txHash := tx.TxHash() + outPt := newOutPoint(&txHash, vout) + blockHash, _ := node.addRawTx(1, tx) + node.getCFilterScripts[*blockHash] = [][]byte{pkScript} + node.getTransactionErr = WalletTransactionNotFound + + contractOutput := newOutput(&txHash, 0, 1e8) + _, err = wallet.Refund(contractOutput.ID(), contract, feeSuggestion) if err != nil { t.Fatalf("refund error: %v", err) } @@ -2049,106 +2168,109 @@ func testRefund(t *testing.T, segwit bool) { badReceipt := &tReceipt{ coin: &tCoin{id: make([]byte, 15)}, } - _, err = wallet.Refund(badReceipt.coin.id, badReceipt.Contract()) + _, err = wallet.Refund(badReceipt.coin.id, badReceipt.Contract(), feeSuggestion) if err == nil { t.Fatalf("no error for bad receipt") } + ensureErr := func(tag string) { + delete(node.checkpoints, outPt) + _, err = wallet.Refund(contractOutput.ID(), contract, feeSuggestion) + if err == nil { + t.Fatalf("no error for %q", tag) + } + } + // gettxout error node.txOutErr = tErr - _, err = wallet.Refund(contractOutput.ID(), contract) - if err == nil { - t.Fatalf("no error for missing utxo") - } + node.getCFilterScripts[*blockHash] = nil + ensureErr("no utxo") + node.getCFilterScripts[*blockHash] = [][]byte{pkScript} node.txOutErr = nil // bad contract badContractOutput := newOutput(tTxHash, 0, 1e8) badContract := randBytes(50) - _, err = wallet.Refund(badContractOutput.ID(), badContract) + _, err = wallet.Refund(badContractOutput.ID(), badContract, feeSuggestion) if err == nil { t.Fatalf("no error for bad contract") } // Too small. node.txOutRes = newTxOutResult(nil, 100, 2) - _, err = wallet.Refund(contractOutput.ID(), contract) - if err == nil { - t.Fatalf("no error for value < fees") - } + tx.TxOut[0].Value = 2 + ensureErr("value < fees") node.txOutRes = bigTxOut + tx.TxOut[0].Value = 1e8 // getrawchangeaddress error node.changeAddrErr = tErr - _, err = wallet.Refund(contractOutput.ID(), contract) - if err == nil { - t.Fatalf("no error for getrawchangeaddress rpc error") - } + ensureErr("getchangeaddress error") node.changeAddrErr = nil // signature error node.privKeyForAddrErr = tErr - _, err = wallet.Refund(contractOutput.ID(), contract) - if err == nil { - t.Fatalf("no error for dumpprivkey rpc error") - } + ensureErr("dumpprivkey error") node.privKeyForAddrErr = nil // send error node.sendErr = tErr - _, err = wallet.Refund(contractOutput.ID(), contract) - if err == nil { - t.Fatalf("no error for sendrawtransaction rpc error") - } + ensureErr("send error") node.sendErr = nil // bad checkhash var badHash chainhash.Hash badHash[0] = 0x05 - node.sendHash = &badHash - _, err = wallet.Refund(contractOutput.ID(), contract) - if err == nil { - t.Fatalf("no error for tx hash") - } - node.sendHash = nil + node.badSendHash = &badHash + ensureErr("checkhash error") + node.badSendHash = nil // Sanity check that we can succeed again. - _, err = wallet.Refund(contractOutput.ID(), contract) + _, err = wallet.Refund(contractOutput.ID(), contract, feeSuggestion) if err != nil { t.Fatalf("re-refund error: %v", err) } + + // TODO test spv spent } func TestLockUnlock(t *testing.T) { - wallet, node, shutdown, err := tNewWallet(true) + runRubric(t, testLockUnlock) +} + +func testLockUnlock(t *testing.T, segwit bool, walletType string) { + wallet, node, shutdown, err := tNewWallet(segwit, walletType) defer shutdown() if err != nil { t.Fatal(err) } + pw := []byte("pass") + // just checking that the errors come through. - node.unlock = true - err = wallet.Unlock("pass") + err = wallet.Unlock(pw) if err != nil { t.Fatalf("unlock error: %v", err) } node.unlockErr = tErr - err = wallet.Unlock("pass") + err = wallet.Unlock(pw) if err == nil { t.Fatalf("no error for walletpassphrase error") } - // same for locking - node.lock = true - err = wallet.Lock() - if err != nil { - t.Fatalf("lock error: %v", err) - } - node.lockErr = tErr - err = wallet.Lock() - if err == nil { - t.Fatalf("no error for walletlock rpc error") + // Locking can't error on SPV. + if walletType == walletTypeRPC { + err = wallet.Lock() + if err != nil { + t.Fatalf("lock error: %v", err) + } + node.lockErr = tErr + err = wallet.Lock() + if err == nil { + t.Fatalf("no error for walletlock rpc error") + } } + } type tSenderType byte @@ -2158,55 +2280,66 @@ const ( tWithdrawSender ) -func testSender(t *testing.T, senderType tSenderType) { - wallet, node, shutdown, err := tNewWallet(true) +func testSender(t *testing.T, senderType tSenderType, segwit bool, walletType string) { + wallet, node, shutdown, err := tNewWallet(segwit, walletType) defer shutdown() if err != nil { t.Fatal(err) } + const feeSuggestion = 100 sender := func(addr string, val uint64) (asset.Coin, error) { - return wallet.PayFee(addr, val) + return wallet.PayFee(addr, val, defaultFee) } if senderType == tWithdrawSender { sender = func(addr string, val uint64) (asset.Coin, error) { - return wallet.Withdraw(addr, val) + return wallet.Withdraw(addr, val, feeSuggestion) } } - addr := tP2PKHAddr + addr := btcAddr(segwit) fee := float64(1) // BTC node.setTxFee = true - node.changeAddr = tP2PKHAddr - node.sendToAddress = tTxID + node.changeAddr = btcAddr(segwit).String() + + pkScript, _ := txscript.PayToAddrScript(addr) + tx := makeRawTx([]dex.Bytes{randBytes(5), pkScript}, []*wire.TxIn{dummyInput()}) + txHash := tx.TxHash() + const vout = 1 + const blockHeight = 2 + blockHash, _ := node.addRawTx(blockHeight, tx) + + txB, _ := serializeMsgTx(tx) + + node.sendToAddress = txHash.String() node.getTransaction = &GetTransactionResult{ - Details: []*WalletTxDetails{ - { - Address: tP2PKHAddr, - Category: TxCatReceive, - Vout: 1, - Amount: -fee, - }, - }, + BlockHash: blockHash.String(), + BlockIndex: blockHeight, + Hex: txB, } unspents := []*ListUnspentResult{{ - TxID: tTxID, - Address: "1Bggq7Vu5oaoLFV1NNp5KhAzcku83qQhgi", + TxID: txHash.String(), + Address: addr.String(), Amount: 100, Confirmations: 1, - Vout: 1, - ScriptPubKey: tP2PKH, + Vout: vout, + ScriptPubKey: pkScript, Safe: true, + Spendable: true, }} node.listUnspent = unspents - _, err = sender(addr, toSatoshi(fee)) + node.signFunc = func(tx *wire.MsgTx) { + signFunc(tx, 0, wallet.segwit) + } + + _, err = sender(addr.String(), toSatoshi(fee)) if err != nil { - t.Fatalf("PayFee error: %v", err) + t.Fatalf("send error: %v", err) } // SendToAddress error node.sendToAddressErr = tErr - _, err = sender(addr, 1e8) + _, err = sender(addr.String(), 1e8) if err == nil { t.Fatalf("no error for SendToAddress error: %v", err) } @@ -2214,66 +2347,108 @@ func testSender(t *testing.T, senderType tSenderType) { // GetTransaction error node.getTransactionErr = tErr - _, err = sender(addr, 1e8) + _, err = sender(addr.String(), 1e8) if err == nil { t.Fatalf("no error for gettransaction error: %v", err) } node.getTransactionErr = nil // good again - _, err = sender(addr, toSatoshi(fee)) + _, err = sender(addr.String(), toSatoshi(fee)) if err != nil { t.Fatalf("PayFee error afterwards: %v", err) } } func TestPayFee(t *testing.T) { - testSender(t, tPayFeeSender) + runRubric(t, func(t *testing.T, segwit bool, walletType string) { + testSender(t, tPayFeeSender, segwit, walletType) + }) } func TestWithdraw(t *testing.T) { - testSender(t, tWithdrawSender) + runRubric(t, func(t *testing.T, segwit bool, walletType string) { + testSender(t, tWithdrawSender, segwit, walletType) + }) } func TestConfirmations(t *testing.T) { - wallet, node, shutdown, err := tNewWallet(true) + runRubric(t, testConfirmations) +} + +func testConfirmations(t *testing.T, segwit bool, walletType string) { + wallet, node, shutdown, err := tNewWallet(segwit, walletType) defer shutdown() if err != nil { t.Fatal(err) } - coinID := make([]byte, 36) - copy(coinID[:32], tTxHash[:]) + // coinID := make([]byte, 36) + // copy(coinID[:32], tTxHash[:]) + + _, _, pkScript, contract, _, _, _ := makeSwapContract(segwit, time.Hour*12) + const tipHeight = 10 + const swapHeight = 2 + const spendHeight = 4 + const expConfs = tipHeight - swapHeight + 1 + + tx := makeRawTx([]dex.Bytes{pkScript}, []*wire.TxIn{dummyInput()}) + blockHash, swapBlock := node.addRawTx(swapHeight, tx) + txHash := tx.TxHash() + coinID := toCoinID(&txHash, 0) + // Simulate a spending transaction, and advance the tip so that the swap + // has two confirmations. + spendingTx := dummyTx() + spendingTx.TxIn[0].PreviousOutPoint.Hash = txHash + spendingBlockHash, _ := node.addRawTx(spendHeight, spendingTx) + + // Prime the blockchain + for i := int64(1); i <= tipHeight; i++ { + node.addRawTx(i, dummyTx()) + } + + matchTime := swapBlock.Header.Timestamp // Bad coin id - _, _, err = wallet.SwapConfirmations(context.Background(), randBytes(35), nil, time.Time{}) + _, _, err = wallet.SwapConfirmations(context.Background(), randBytes(35), contract, matchTime) if err == nil { t.Fatalf("no error for bad coin ID") } // Short path. - node.txOutRes = &btcjson.GetTxOutResult{ - Confirmations: 2, + txOutRes := &btcjson.GetTxOutResult{ + Confirmations: expConfs, + BestBlock: blockHash.String(), } - confs, _, err := wallet.SwapConfirmations(context.Background(), coinID, nil, time.Time{}) + node.txOutRes = txOutRes + node.getCFilterScripts[*blockHash] = [][]byte{pkScript} + confs, _, err := wallet.SwapConfirmations(context.Background(), coinID, contract, matchTime) if err != nil { t.Fatalf("error for gettransaction path: %v", err) } - if confs != 2 { - t.Fatalf("confs not retrieved from gettxout path. expected 2, got %d", confs) + if confs != expConfs { + t.Fatalf("confs not retrieved from gettxout path. expected %d, got %d", expConfs, confs) } - // gettransaction error + // no tx output found node.txOutRes = nil + node.getCFilterScripts[*blockHash] = nil node.getTransactionErr = tErr - _, _, err = wallet.SwapConfirmations(context.Background(), coinID, nil, time.Time{}) + _, _, err = wallet.SwapConfirmations(context.Background(), coinID, contract, matchTime) if err == nil { t.Fatalf("no error for gettransaction error") } + node.getCFilterScripts[*blockHash] = [][]byte{pkScript} node.getTransactionErr = nil - node.getTransaction = &GetTransactionResult{} + txB, _ := serializeMsgTx(tx) + node.getTransaction = &GetTransactionResult{ + BlockHash: blockHash.String(), + Hex: txB, + } - _, spent, err := wallet.SwapConfirmations(context.Background(), coinID, nil, time.Time{}) + node.getCFilterScripts[*spendingBlockHash] = [][]byte{pkScript} + node.walletTxSpent = true + _, spent, err := wallet.SwapConfirmations(context.Background(), coinID, contract, matchTime) if err != nil { t.Fatalf("error for spent swap: %v", err) } @@ -2283,16 +2458,11 @@ func TestConfirmations(t *testing.T) { } func TestSendEdges(t *testing.T) { - t.Run("segwit", func(t *testing.T) { - testSendEdges(t, true) - }) - t.Run("non-segwit", func(t *testing.T) { - testSendEdges(t, false) - }) + runRubric(t, testSendEdges) } -func testSendEdges(t *testing.T, segwit bool) { - wallet, node, shutdown, err := tNewWallet(segwit) +func testSendEdges(t *testing.T, segwit bool, walletType string) { + wallet, node, shutdown, err := tNewWallet(segwit, walletType) defer shutdown() if err != nil { t.Fatal(err) @@ -2327,8 +2497,8 @@ func testSendEdges(t *testing.T, segwit bool) { return baseTx } - node.signFunc = func(params []json.RawMessage) (json.RawMessage, error) { - return signFunc(t, params, 0, true, wallet.segwit) + node.signFunc = func(tx *wire.MsgTx) { + signFunc(tx, 0, wallet.segwit) } tests := []struct { @@ -2375,7 +2545,11 @@ func testSendEdges(t *testing.T, segwit bool) { } func TestSyncStatus(t *testing.T) { - wallet, node, shutdown, err := tNewWallet(false) + runRubric(t, testSyncStatus) +} + +func testSyncStatus(t *testing.T, segwit bool, walletType string) { + wallet, node, shutdown, err := tNewWallet(segwit, walletType) defer shutdown() if err != nil { t.Fatal(err) @@ -2396,18 +2570,21 @@ func TestSyncStatus(t *testing.T) { t.Fatalf("progress not complete when loading last block") } - node.getBlockchainInfoErr = tErr + node.getBlockchainInfoErr = tErr // rpc + node.getBestBlockHashErr = tErr // spv _, _, err = wallet.SyncStatus() if err == nil { t.Fatalf("SyncStatus error not propagated") } node.getBlockchainInfoErr = nil + node.getBestBlockHashErr = nil wallet.tipAtConnect = 100 node.getBlockchainInfo = &getBlockchainInfoResult{ Headers: 200, Blocks: 150, } + node.addRawTx(150, makeRawTx([]dex.Bytes{randBytes(1)}, []*wire.TxIn{dummyInput()})) // spv needs this for BestBlock synced, progress, err = wallet.SyncStatus() if err != nil { t.Fatalf("SyncStatus error (half-synced): %v", err) @@ -2421,7 +2598,11 @@ func TestSyncStatus(t *testing.T) { } func TestPreSwap(t *testing.T) { - wallet, node, shutdown, err := tNewWallet(false) + runRubric(t, testPreSwap) +} + +func testPreSwap(t *testing.T, segwit bool, walletType string) { + wallet, node, shutdown, err := tNewWallet(segwit, walletType) defer shutdown() if err != nil { t.Fatal(err) @@ -2432,27 +2613,34 @@ func TestPreSwap(t *testing.T) { swapVal := uint64(1e7) lots := swapVal / tLotSize // 10 lots - const swapSize = 225 - const totalBytes = 2250 - const bestCaseBytes = 513 + // var swapSize = 225 + var totalBytes uint64 = 2250 + var bestCaseBytes uint64 = 513 + pkScript := tP2PKH + if segwit { + // swapSize = 153 + totalBytes = 1530 + bestCaseBytes = 540 + pkScript = tP2WPKH + } - backingFees := uint64(totalBytes) * tBTC.MaxFeeRate // total_bytes * fee_rate + backingFees := totalBytes * tBTC.MaxFeeRate // total_bytes * fee_rate minReq := swapVal + backingFees - p2pkhUnspent := &ListUnspentResult{ + unspent := &ListUnspentResult{ TxID: tTxID, Address: tP2PKHAddr, Confirmations: 5, - ScriptPubKey: tP2PKH, + ScriptPubKey: pkScript, Spendable: true, Solvable: true, Safe: true, } - unspents := []*ListUnspentResult{p2pkhUnspent} + unspents := []*ListUnspentResult{unspent} setFunds := func(v uint64) { - p2pkhUnspent.Amount = float64(v) / 1e8 + unspent.Amount = float64(v) / 1e8 node.listUnspent = unspents } @@ -2493,10 +2681,14 @@ func TestPreSwap(t *testing.T) { } func TestPreRedeem(t *testing.T) { - wallet, _, shutdown, _ := tNewWallet(false) + runRubric(t, testPreRedeem) +} + +func testPreRedeem(t *testing.T, segwit bool, walletType string) { + wallet, _, shutdown, _ := tNewWallet(segwit, walletType) defer shutdown() - nonSegRedeem, err := wallet.PreRedeem(&asset.PreRedeemForm{ + preRedeem, err := wallet.PreRedeem(&asset.PreRedeemForm{ LotSize: 123456, // Doesn't actually matter Lots: 5, }) @@ -2505,29 +2697,19 @@ func TestPreRedeem(t *testing.T) { t.Fatalf("PreRedeem non-segwit error: %v", err) } - wallet.segwit = true - - segwitRedeem, err := wallet.PreRedeem(&asset.PreRedeemForm{ - LotSize: 123456, - Lots: 5, - }) - if err != nil { - t.Fatalf("PreRedeem segwit error: %v", err) - } - // Just a couple of sanity checks. - if nonSegRedeem.Estimate.RealisticBestCase >= nonSegRedeem.Estimate.RealisticWorstCase { + if preRedeem.Estimate.RealisticBestCase >= preRedeem.Estimate.RealisticWorstCase { t.Fatalf("best case > worst case") } - - if segwitRedeem.Estimate.RealisticWorstCase >= nonSegRedeem.Estimate.RealisticWorstCase { - t.Fatalf("segwit > non-segwit") - } - } func TestTryRedemptionRequests(t *testing.T) { - wallet, node, shutdown, _ := tNewWallet(false) + // runRubric(t, testTryRedemptionRequests) + testTryRedemptionRequests(t, true, walletTypeSPV) +} + +func testTryRedemptionRequests(t *testing.T, segwit bool, walletType string) { + wallet, node, shutdown, _ := tNewWallet(segwit, walletType) defer shutdown() const swapVout = 1 @@ -2538,7 +2720,7 @@ func TestTryRedemptionRequests(t *testing.T) { return &h } - otherScript, _ := txscript.PayToAddrScript(btcAddr(false)) + otherScript, _ := txscript.PayToAddrScript(btcAddr(segwit)) otherInput := []*wire.TxIn{makeRPCVin(randHash(), 0, randBytes(5), nil)} otherTx := func() *wire.MsgTx { return makeRawTx([]dex.Bytes{otherScript}, otherInput) @@ -2553,19 +2735,19 @@ func TestTryRedemptionRequests(t *testing.T) { } } - getTx := func(blockHeight int64, txIdx int) *wire.MsgTx { + getTx := func(blockHeight int64, txIdx int) (*wire.MsgTx, *chainhash.Hash) { if blockHeight == -1 { // mempool txHash := randHash() tx := otherTx() node.mempoolTxs[*txHash] = tx - return tx + return tx, nil } - _, blk := node.getBlockAtHeight(blockHeight) + blockHash, blk := node.getBlockAtHeight(blockHeight) for len(blk.msgBlock.Transactions) <= txIdx { blk.msgBlock.Transactions = append(blk.msgBlock.Transactions, otherTx()) } - return blk.msgBlock.Transactions[txIdx] + return blk.msgBlock.Transactions[txIdx], blockHash } type tRedeem struct { @@ -2583,17 +2765,28 @@ func TestTryRedemptionRequests(t *testing.T) { } swapTxHash := randHash() - secret, _, pkScript, contract, _, _, _ := makeSwapContract(false, time.Hour*12) + secret, _, pkScript, contract, _, _, _ := makeSwapContract(segwit, time.Hour*12) if !r.notRedeemed { - redeemTx := getTx(r.redeemBlockHeight, r.redeemTxIdx) + redeemTx, redeemBlockHash := getTx(r.redeemBlockHeight, r.redeemTxIdx) - redemptionSigScript, _ := dexbtc.RedeemP2SHContract(contract, randBytes(73), randBytes(33), secret) + // redemptionSigScript, _ := dexbtc.RedeemP2SHContract(contract, randBytes(73), randBytes(33), secret) for len(redeemTx.TxIn) < r.redeemVin { redeemTx.TxIn = append(redeemTx.TxIn, makeRPCVin(randHash(), 0, nil, nil)) } - redeemTx.TxIn = append(redeemTx.TxIn, makeRPCVin(swapTxHash, swapVout, redemptionSigScript, nil)) + var redemptionSigScript []byte + var redemptionWitness [][]byte + if segwit { + redemptionWitness = dexbtc.RedeemP2WSHContract(contract, randBytes(73), randBytes(33), secret) + } else { + redemptionSigScript, _ = dexbtc.RedeemP2SHContract(contract, randBytes(73), randBytes(33), secret) + } + + redeemTx.TxIn = append(redeemTx.TxIn, makeRPCVin(swapTxHash, swapVout, redemptionSigScript, redemptionWitness)) + if redeemBlockHash != nil { + node.getCFilterScripts[*redeemBlockHash] = [][]byte{pkScript} + } } req := &findRedemptionReq{ @@ -2602,7 +2795,7 @@ func TestTryRedemptionRequests(t *testing.T) { blockHeight: int32(swapHeight), resultChan: make(chan *findRedemptionResult, 1), pkScript: pkScript, - contractHash: hashContract(false, contract), + contractHash: hashContract(segwit, contract), } wallet.findRedemptionQueue[req.outPt] = req return req @@ -2616,6 +2809,15 @@ func TestTryRedemptionRequests(t *testing.T) { canceledCtx bool } + isMempoolTest := func(tt *test) bool { + for _, r := range tt.redeems { + if r.redeemBlockHeight == -1 { + return true + } + } + return false + } + tests := []*test{ { // Normal redemption numBlocks: 2, @@ -2710,6 +2912,11 @@ func TestTryRedemptionRequests(t *testing.T) { } for _, tt := range tests { + // Skip tests where we're expected to see mempool in SPV. + if walletType == walletTypeSPV && isMempoolTest(tt) { + continue + } + node.truncateChains() wallet.findRedemptionQueue = make(map[outPoint]*findRedemptionReq) node.getBestBlockHashErr = nil diff --git a/client/asset/btc/livetest/livetest.go b/client/asset/btc/livetest/livetest.go index 8ece11a8d8..18b46a73b2 100644 --- a/client/asset/btc/livetest/livetest.go +++ b/client/asset/btc/livetest/livetest.go @@ -16,6 +16,7 @@ import ( "bytes" "context" "crypto/sha256" + "errors" "fmt" "math/rand" "os/exec" @@ -32,24 +33,25 @@ import ( type WalletConstructor func(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) -func tBackend(ctx context.Context, t *testing.T, newWallet WalletConstructor, symbol, node, name string, - logger dex.Logger, blkFunc func(string, error), splitTx bool) (asset.Wallet, *dex.ConnectionMaster) { +func tBackend(ctx context.Context, t *testing.T, cfg *Config, node, name string, + logger dex.Logger, blkFunc func(string, error)) (asset.Wallet, *dex.ConnectionMaster) { + t.Helper() user, err := user.Current() if err != nil { t.Fatalf("error getting current user: %v", err) } - cfgPath := filepath.Join(user.HomeDir, "dextest", symbol, node, node+".conf") + cfgPath := filepath.Join(user.HomeDir, "dextest", cfg.Asset.Symbol, node, node+".conf") settings, err := config.Parse(cfgPath) if err != nil { t.Fatalf("error reading config options: %v", err) } settings["walletname"] = name - if splitTx { + if cfg.SplitTx { settings["txsplit"] = "1" } - reportName := fmt.Sprintf("%s:%s", symbol, node) + reportName := fmt.Sprintf("%s:%s-%s", cfg.Asset.Symbol, node, name) walletCfg := &asset.WalletConfig{ Settings: settings, @@ -57,16 +59,19 @@ func tBackend(ctx context.Context, t *testing.T, newWallet WalletConstructor, sy blkFunc(reportName, err) }, } - backend, err := newWallet(walletCfg, logger, dex.Regtest) + + w, err := cfg.NewWallet(walletCfg, logger, dex.Regtest) if err != nil { t.Fatalf("error creating backend: %v", err) } - cm := dex.NewConnectionMaster(backend) + + cm := dex.NewConnectionMaster(w) err = cm.Connect(ctx) if err != nil { t.Fatalf("error connecting backend: %v", err) } - return backend, cm + + return w, cm } type testRig struct { @@ -79,9 +84,11 @@ type testRig struct { func (rig *testRig) alpha() asset.Wallet { return rig.backends["alpha"] } -func (rig *testRig) beta() asset.Wallet { - return rig.backends["beta"] -} + +// TODO: Test with beta since it's a different node. +// func (rig *testRig) beta() asset.Wallet { +// return rig.backends["beta"] +// } func (rig *testRig) gamma() asset.Wallet { return rig.backends["gamma"] } @@ -94,7 +101,7 @@ func (rig *testRig) close() { }() select { case <-closed: - case <-time.NewTimer(time.Second).C: + case <-time.NewTimer(time.Second * 30).C: rig.t.Fatalf("failed to disconnect from %s", name) } } @@ -110,16 +117,22 @@ func randBytes(l int) []byte { return b } -func Run(t *testing.T, newWallet WalletConstructor, address string, lotSize uint64, dexAsset *dex.Asset, splitTx bool) { - tLogger := dex.StdOutLogger("TEST", dex.LevelTrace) +type Config struct { + NewWallet WalletConstructor + LotSize uint64 + Asset *dex.Asset + SplitTx bool + SPV bool +} + +func Run(t *testing.T, cfg *Config) { + tLogger := dex.StdOutLogger("TEST", dex.LevelDebug) tCtx, shutdown := context.WithCancel(context.Background()) defer shutdown() tStart := time.Now() - tBlockTick := time.Second - tBlockWait := tBlockTick + time.Millisecond*50 - walletPassword := "abc" + walletPassword := []byte("abc") var blockReported uint32 blkFunc := func(name string, err error) { @@ -129,16 +142,48 @@ func Run(t *testing.T, newWallet WalletConstructor, address string, lotSize uint rig := &testRig{ t: t, - symbol: dexAsset.Symbol, + symbol: cfg.Asset.Symbol, backends: make(map[string]asset.Wallet), connectionMasters: make(map[string]*dex.ConnectionMaster, 3), } - rig.backends["alpha"], rig.connectionMasters["alpha"] = tBackend(tCtx, t, newWallet, dexAsset.Symbol, "alpha", "", tLogger, blkFunc, splitTx) - rig.backends["beta"], rig.connectionMasters["beta"] = tBackend(tCtx, t, newWallet, dexAsset.Symbol, "beta", "", tLogger, blkFunc, splitTx) - rig.backends["gamma"], rig.connectionMasters["gamma"] = tBackend(tCtx, t, newWallet, dexAsset.Symbol, "alpha", "gamma", tLogger, blkFunc, splitTx) + + var expConfs uint32 + blockWait := time.Second + if cfg.SPV { + blockWait = time.Second * 3 + } + mine := func() { + rig.mineAlpha() + expConfs++ + time.Sleep(blockWait) + } + + t.Log("Setting up alpha/beta/gamma wallet backends...") + rig.backends["alpha"], rig.connectionMasters["alpha"] = tBackend(tCtx, t, cfg, "alpha", "", tLogger.SubLogger("alpha"), blkFunc) + // rig.backends["beta"], rig.connectionMasters["beta"] = tBackend(tCtx, t, cfg, "beta", "", tLogger.SubLogger("beta"), blkFunc) + rig.backends["gamma"], rig.connectionMasters["gamma"] = tBackend(tCtx, t, cfg, "alpha", "gamma", tLogger.SubLogger("gamma"), blkFunc) defer rig.close() + + // Unlock the wallet for use. + err := rig.alpha().Unlock(walletPassword) + if err != nil { + t.Fatalf("error unlocking gamma wallet: %v", err) + } + + if cfg.SPV { + // // The test expects beta and gamma to be unlocked. + // if err := rig.beta().Unlock(walletPassword); err != nil { + // t.Fatalf("beta Unlock error: %v", err) + // } + if err := rig.gamma().Unlock(walletPassword); err != nil { + t.Fatalf("gamma Unlock error: %v", err) + } + } + var lots uint64 = 2 - contractValue := lots * lotSize + contractValue := lots * cfg.LotSize + + tLogger.Info("Wallets configured") inUTXOs := func(utxo asset.Coin, utxos []asset.Coin) bool { for _, u := range utxos { @@ -155,29 +200,24 @@ func Run(t *testing.T, newWallet WalletConstructor, address string, lotSize uint if err != nil { t.Fatalf("error getting available balance: %v", err) } - tLogger.Debugf("%s %f available, %f immature, %f locked", + tLogger.Infof("%s %f available, %f immature, %f locked", name, float64(bal.Available)/1e8, float64(bal.Immature)/1e8, float64(bal.Locked)/1e8) } - // Unlock the wallet for use. - err := rig.alpha().Unlock(walletPassword) - if err != nil { - t.Fatalf("error unlocking gamma wallet: %v", err) - } - ord := &asset.Order{ Value: contractValue * 3, MaxSwapCount: lots * 3, - DEXConfig: dexAsset, + DEXConfig: cfg.Asset, } setOrderValue := func(v uint64) { ord.Value = v - ord.MaxSwapCount = v / lotSize + ord.MaxSwapCount = v / cfg.LotSize } + tLogger.Info("Testing FundOrder") + // Gamma should only have 10 BTC utxos, so calling fund for less should only // return 1 utxo. - utxos, _, err := rig.gamma().FundOrder(ord) if err != nil { t.Fatalf("Funding error: %v", err) @@ -193,7 +233,7 @@ func Run(t *testing.T, newWallet WalletConstructor, address string, lotSize uint rig.gamma().ReturnCoins(utxos) // Make sure we get the first utxo back with Fund. utxos, _, _ = rig.gamma().FundOrder(ord) - if !splitTx && !inUTXOs(utxo, utxos) { + if !cfg.SplitTx && !inUTXOs(utxo, utxos) { t.Fatalf("unlocked output not returned") } rig.gamma().ReturnCoins(utxos) @@ -211,6 +251,11 @@ func Run(t *testing.T, newWallet WalletConstructor, address string, lotSize uint t.Fatalf("error funding second contract: %v", err) } + address, err := rig.alpha().Address() + if err != nil { + t.Fatalf("error getting alpha address: %v", err) + } + secretKey1 := randBytes(32) keyHash1 := sha256.Sum256(secretKey1) secretKey2 := randBytes(32) @@ -232,25 +277,40 @@ func Run(t *testing.T, newWallet WalletConstructor, address string, lotSize uint swaps := &asset.Swaps{ Inputs: append(utxos1, utxos2...), Contracts: []*asset.Contract{contract1, contract2}, - FeeRate: dexAsset.MaxFeeRate, + FeeRate: cfg.Asset.MaxFeeRate, } + tLogger.Info("Testing Swap") + receipts, _, _, err := rig.gamma().Swap(swaps) if err != nil { t.Fatalf("error sending swap transaction: %v", err) } - if len(receipts) != 2 { t.Fatalf("expected 1 receipt, got %d", len(receipts)) } + tLogger.Infof("Sent %d swaps", len(receipts)) + for i, r := range receipts { + tLogger.Infof(" Swap # %d: %s", i+1, r.Coin()) + } + + // Don't check zero confs for SPV. Core deals with the failures until the + // tx is mined. + + if cfg.SPV { + mine() + } + confCoin := receipts[0].Coin() confContract := receipts[0].Contract() checkConfs := func(n uint32, expSpent bool) { t.Helper() confs, spent, err := rig.gamma().SwapConfirmations(context.Background(), confCoin.ID(), confContract, tStart) if err != nil { - t.Fatalf("error getting %d confs: %v", n, err) + if n > 0 || !errors.Is(err, asset.CoinNotFoundError) { + t.Fatalf("error getting %d confs: %v", n, err) + } } if confs != n { t.Fatalf("expected %d confs, got %d", n, confs) @@ -259,11 +319,12 @@ func Run(t *testing.T, newWallet WalletConstructor, address string, lotSize uint t.Fatalf("checkConfs: expected spent = %t, got %t", expSpent, spent) } } - // Check that there are 0 confirmations. - checkConfs(0, false) + + checkConfs(expConfs, false) makeRedemption := func(swapVal uint64, receipt asset.Receipt, secret []byte) *asset.Redemption { t.Helper() + // Alpha should be able to redeem. ci, err := rig.alpha().AuditContract(receipt.Coin().ID(), receipt.Contract(), nil, tStart) if err != nil { @@ -280,8 +341,8 @@ func Run(t *testing.T, newWallet WalletConstructor, address string, lotSize uint if err != nil { t.Fatalf("error getting confirmations: %v", err) } - if confs != 0 { - t.Fatalf("unexpected number of confirmations. wanted 0, got %d", confs) + if confs != expConfs { + t.Fatalf("unexpected number of confirmations. wanted %d, got %d", expConfs, confs) } if spent { t.Fatalf("makeRedemption: expected unspent, got spent") @@ -295,11 +356,15 @@ func Run(t *testing.T, newWallet WalletConstructor, address string, lotSize uint } } + tLogger.Info("Testing AuditContract") + redemptions := []*asset.Redemption{ makeRedemption(contractValue, receipts[0], secretKey1), makeRedemption(contractValue*2, receipts[1], secretKey2), } + tLogger.Info("Testing Redeem") + _, _, _, err = rig.alpha().Redeem(&asset.RedeemForm{ Redemptions: redemptions, }) @@ -308,29 +373,37 @@ func Run(t *testing.T, newWallet WalletConstructor, address string, lotSize uint } // Find the redemption + + // Only do the mempool zero-conf redemption check when not spv. + if cfg.SPV { + mine() + } + swapReceipt := receipts[0] - ctx, cancel := context.WithDeadline(tCtx, time.Now().Add(time.Second*10)) + ctx, cancel := context.WithDeadline(tCtx, time.Now().Add(time.Second*30)) defer cancel() - _, checkKey, err := rig.gamma().FindRedemption(ctx, swapReceipt.Coin().ID()) + + tLogger.Info("Testing FindRedemption") + + _, _, err = rig.gamma().FindRedemption(ctx, swapReceipt.Coin().ID()) if err != nil { t.Fatalf("error finding unconfirmed redemption: %v", err) } - if !bytes.Equal(checkKey, secretKey1) { - t.Fatalf("findRedemption (unconfirmed) key mismatch. %x != %x", checkKey, secretKey1) - } // Mine a block and find the redemption again. - rig.mineAlpha() - time.Sleep(tBlockWait) + mine() if atomic.LoadUint32(&blockReported) == 0 { t.Fatalf("no block reported") } // Check that there is 1 confirmation on the swap - checkConfs(1, true) - _, _, err = rig.gamma().FindRedemption(ctx, swapReceipt.Coin().ID()) + checkConfs(expConfs, true) + _, checkKey, err := rig.gamma().FindRedemption(ctx, swapReceipt.Coin().ID()) if err != nil { t.Fatalf("error finding confirmed redemption: %v", err) } + if !bytes.Equal(checkKey, secretKey1) { + t.Fatalf("findRedemption (unconfirmed) key mismatch. %x != %x", checkKey, secretKey1) + } // Now send another one with lockTime = now and try to refund it. secretKey := randBytes(32) @@ -349,10 +422,10 @@ func Run(t *testing.T, newWallet WalletConstructor, address string, lotSize uint swaps = &asset.Swaps{ Inputs: utxos, Contracts: []*asset.Contract{contract}, - FeeRate: dexAsset.MaxFeeRate, + FeeRate: cfg.Asset.MaxFeeRate, } - time.Sleep(time.Second) + tLogger.Info("Testing Refund") receipts, _, _, err = rig.gamma().Swap(swaps) if err != nil { @@ -364,22 +437,34 @@ func Run(t *testing.T, newWallet WalletConstructor, address string, lotSize uint } swapReceipt = receipts[0] - _, err = rig.gamma().Refund(swapReceipt.Coin().ID(), swapReceipt.Contract()) + // SPV doesn't recognize ownership of the swap output, so we need to mine + // the transaction in order to establish spent status. In theory, we could + // just yolo and refund regardless of spent status. + if cfg.SPV { + mine() + } + + coinID, err := rig.gamma().Refund(swapReceipt.Coin().ID(), swapReceipt.Contract(), 100) if err != nil { t.Fatalf("refund error: %v", err) } + c, _ := asset.DecodeCoinID(cfg.Asset.ID, coinID) + tLogger.Infof("Refunded with %s", c) // Test PayFee - coin, err := rig.gamma().PayFee(address, 1e8) + const defaultFee = 100 + coin, err := rig.gamma().PayFee(address, 1e8, defaultFee) if err != nil { t.Fatalf("error paying fees: %v", err) } - tLogger.Infof("fee paid with tx %s", coin.String()) + tLogger.Infof("Fee paid with %s", coin.String()) + + tLogger.Info("Testing Withdraw") // Test Withdraw - coin, err = rig.gamma().Withdraw(address, 5e7) + coin, err = rig.gamma().Withdraw(address, 5e7, 100) if err != nil { t.Fatalf("error withdrawing: %v", err) } - tLogger.Infof("withdrew with tx %s", coin.String()) + tLogger.Infof("Withdrew with %s", coin.String()) } diff --git a/client/asset/btc/livetest/regnet_test.go b/client/asset/btc/livetest/regnet_test.go index 41a44f8dca..aa2f6311fd 100644 --- a/client/asset/btc/livetest/regnet_test.go +++ b/client/asset/btc/livetest/regnet_test.go @@ -4,16 +4,28 @@ package livetest import ( + "context" + "encoding/hex" "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" "testing" + "time" + "decred.org/dcrdex/client/asset" "decred.org/dcrdex/client/asset/btc" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/encode" dexbtc "decred.org/dcrdex/dex/networks/btc" ) const ( - alphaAddress = "bcrt1qy7agjj62epx0ydnqskgwlcfwu52xjtpj36hr0d" + alphaAddress = "bcrt1qaujcvxuvp9vdcqaa6s3acyh8kxmuyqnyg4jcfl" + betaAddress = "bcrt1qwhxklx3vms6xc0lxlunez93m9wn8qzxkkn5dy2" + gammaAddress = "bcrt1qll362edf4levwg7yqyt7kawjklvejvj74w87py" + walletTypeSPV = "SPV" ) var ( @@ -26,12 +38,171 @@ var ( MaxFeeRate: 10, SwapConf: 1, } + + usr, _ = user.Current() + harnessCtlDir = filepath.Join(usr.HomeDir, "dextest", "btc", "harness-ctl") + tPW = []byte("abc") ) func TestWallet(t *testing.T) { const lotSize = 1e6 - fmt.Println("////////// WITHOUT SPLIT FUNDING TRANSACTIONS //////////") - Run(t, btc.NewWallet, alphaAddress, lotSize, tBTC, false) - fmt.Println("////////// WITH SPLIT FUNDING TRANSACTIONS //////////") - Run(t, btc.NewWallet, alphaAddress, lotSize, tBTC, true) + + fmt.Println("////////// RPC WALLET W/O SPLIT //////////") + Run(t, &Config{ + NewWallet: btc.NewWallet, + LotSize: lotSize, + Asset: tBTC, + }) + + fmt.Println("////////// RPC WALLET WITH SPLIT //////////") + Run(t, &Config{ + NewWallet: btc.NewWallet, + LotSize: lotSize, + Asset: tBTC, + SplitTx: true, + }) + + spvDir, err := os.MkdirTemp("", "") + if err != nil { + t.Fatalf("MkdirTemp error: %v", err) + } + defer os.RemoveAll(spvDir) // clean up + + createWallet := func(cfg *asset.WalletConfig, name string, logger dex.Logger) error { + // var seed [32]byte + // copy(seed[:], []byte(name)) + seed := encode.RandomBytes(32) + + err = (&btc.Driver{}).Create(&asset.CreateWalletParams{ + Type: walletTypeSPV, + Seed: seed[:], + Pass: tPW, // match walletPassword in livetest.go -> Run + DataDir: cfg.DataDir, + Net: dex.Simnet, + Logger: logger, + }) + if err != nil { + return fmt.Errorf("error creating SPV wallet: %w", err) + } + + w, err := btc.NewWallet(cfg, logger, dex.Regtest) + if err != nil { + t.Fatalf("error creating backend: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cm := dex.NewConnectionMaster(w) + err = cm.Connect(ctx) + if err != nil { + t.Fatalf("error connecting backend: %v", err) + } + defer cm.Disconnect() + + addr, err := w.Address() + if err != nil { + return fmt.Errorf("Address error: %w", err) + } + + // TODO: Randomize the address scope passed to + // btcwallet.Wallet.{NewAddres, NewChangeAddress} between + // waddrmgr.KeyScopeBIP0084 and waddrmgr.KeyScopeBIP0044 so that we + // know we can handle non-segwit previous outpoints too. + if err := loadAddress(addr); err != nil { + return fmt.Errorf("loadAddress error: %v", err) + } + + time.Sleep(time.Second * 3) + + for { + synced, progress, err := w.SyncStatus() + if err != nil { + return fmt.Errorf("SyncStatus error: %w", err) + } + if synced { + break + } + fmt.Printf("%s sync progress %.1f \n", name, progress*100) + time.Sleep(time.Second) + } + + bal, _ := w.Balance() + logger.Infof("%s with address %s is synced with balance = %+v \n", name, addr, bal) + + return nil + } + + spvConstructor := func(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) { + token := hex.EncodeToString(encode.RandomBytes(4)) + cfg.Type = walletTypeSPV + cfg.DataDir = filepath.Join(spvDir, token) + + name := parseName(cfg.Settings) + + // regtest connects to alpha node by default if "peer" isn't set. + if name == "beta" { + cfg.Settings["peer"] = "localhost:20576" // beta node + } + + err := createWallet(cfg, name, logger) + if err != nil { + return nil, fmt.Errorf("createWallet error: %v", err) + } + + w, err := btc.NewWallet(cfg, logger, network) + if err != nil { + return nil, err + } + + return w, nil + } + + fmt.Println("////////// SPV WALLET W/O SPLIT //////////") + Run(t, &Config{ + NewWallet: spvConstructor, + LotSize: lotSize, + Asset: tBTC, + SPV: true, + }) + + fmt.Println("////////// SPV WALLET WITH SPLIT //////////") + Run(t, &Config{ + NewWallet: spvConstructor, + LotSize: lotSize, + Asset: tBTC, + SPV: true, + SplitTx: true, + }) +} + +func loadAddress(addr string) error { + for _, v := range []string{"10", "18", "5", "7", "1", "15", "3", "25"} { + if err := runCmd("./alpha", "sendtoaddress", addr, v); err != nil { + return err + } + } + return runCmd("./mine-alpha", "1") +} + +func runCmdWithOutput(exe string, args ...string) (string, error) { + cmd := exec.Command(exe, args...) + cmd.Dir = harnessCtlDir + b, err := cmd.Output() + // if len(op) > 0 { + // fmt.Printf("output from command %q: %s \n", cmd, string(op)) + // } + return string(b), err +} + +func runCmd(exe string, args ...string) error { + _, err := runCmdWithOutput(exe, args...) + return err +} + +func parseName(settings map[string]string) string { + name := settings["walletname"] + if name == "" { + name = filepath.Base(settings["datadir"]) + } + return name } diff --git a/client/asset/btc/rpcclient.go b/client/asset/btc/rpcclient.go index b4a652ac66..6cced815ae 100644 --- a/client/asset/btc/rpcclient.go +++ b/client/asset/btc/rpcclient.go @@ -9,6 +9,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "sync" "time" "decred.org/dcrdex/client/asset" @@ -95,7 +96,7 @@ func newRPCClient(requester RawRequesterWithContext, segwit bool, addrDecoder de } } -func (wc *rpcClient) connect(ctx context.Context) error { +func (wc *rpcClient) connect(ctx context.Context, _ *sync.WaitGroup) error { wc.ctx = ctx // Check the version. Do it here, so we can also diagnose a bad connection. netVer, codeVer, err := wc.getVersion() @@ -393,9 +394,9 @@ func (wc *rpcClient) getWalletTransaction(txHash *chainhash.Hash) (*GetTransacti } // walletUnlock unlocks the wallet. -func (wc *rpcClient) walletUnlock(pass string) error { +func (wc *rpcClient) walletUnlock(pw []byte) error { // 100000000 comes from bitcoin-cli help walletpassphrase - return wc.call(methodUnlock, anylist{pass, 100000000}, nil) + return wc.call(methodUnlock, anylist{string(pw), 100000000}, nil) } // walletLock locks the wallet. @@ -404,7 +405,7 @@ func (wc *rpcClient) walletLock() error { } // sendToAddress sends the amount to the address. feeRate is in units of -// atoms/byte. +// sats/byte. func (wc *rpcClient) sendToAddress(address string, value, feeRate uint64, subtract bool) (*chainhash.Hash, error) { var success bool // 1e-5 = 1e-8 for satoshis * 1000 for kB. @@ -474,7 +475,7 @@ func (wc *rpcClient) SendRawTransaction(tx *wire.MsgTx) (*chainhash.Hash, error) return nil, err } var txid string - err = wc.call(methodSendRawTx, anylist{hex.EncodeToString(b)}, &txid) + err = wc.call(methodSendRawTransaction, anylist{hex.EncodeToString(b)}, &txid) if err != nil { return nil, err } @@ -592,41 +593,6 @@ func (wc *rpcClient) findRedemptionsInMempool(ctx context.Context, reqs map[outP return } -// findRedemptionsInTx searches the MsgTx for the redemptions for the specified -// swaps. -func findRedemptionsInTx(ctx context.Context, segwit bool, reqs map[outPoint]*findRedemptionReq, msgTx *wire.MsgTx, - chainParams *chaincfg.Params) (discovered map[outPoint]*findRedemptionResult) { - - discovered = make(map[outPoint]*findRedemptionResult, len(reqs)) - - for vin, txIn := range msgTx.TxIn { - if ctx.Err() != nil { - return discovered - } - poHash, poVout := txIn.PreviousOutPoint.Hash, txIn.PreviousOutPoint.Index - for outPt, req := range reqs { - if discovered[outPt] != nil { - continue - } - if outPt.txHash == poHash && outPt.vout == poVout { - // Match! - txHash := msgTx.TxHash() - secret, err := dexbtc.FindKeyPush(txIn.Witness, txIn.SignatureScript, req.contractHash[:], segwit, chainParams) - if err != nil { - req.fail("no secret extracted from redemption input %s:%d for swap output %s: %v", - msgTx.TxHash(), vin, outPt, err) - continue - } - discovered[outPt] = &findRedemptionResult{ - redemptionCoinID: toCoinID(&txHash, uint32(vin)), - secret: secret, - } - } - } - } - return -} - // searchBlockForRedemptions attempts to find spending info for the specified // contracts by searching every input of all txs in the provided block range. func (wc *rpcClient) searchBlockForRedemptions(ctx context.Context, reqs map[outPoint]*findRedemptionReq, blockHash chainhash.Hash) (discovered map[outPoint]*findRedemptionResult) { @@ -710,6 +676,5 @@ func txOutFromTxBytes(txB []byte, vout uint32) (*wire.TxOut, error) { if len(msgTx.TxOut) <= int(vout) { return nil, fmt.Errorf("no vout %d in tx %s", vout, msgTx.TxHash()) } - return msgTx.TxOut[vout], nil } diff --git a/client/asset/btc/spv.go b/client/asset/btc/spv.go new file mode 100644 index 0000000000..4a8c7afe2f --- /dev/null +++ b/client/asset/btc/spv.go @@ -0,0 +1,1719 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +// spvWallet implements a Wallet backed by a built-in btcwallet + Neutrino. +// +// There are a few challenges presented in using an SPV wallet for DEX. +// 1. Finding non-wallet related blockchain data requires possession of the +// pubkey script, not just transaction hash and output index +// 2. Finding non-wallet related blockchain data can often entail extensive +// scanning of compact filters. We can limit these scans with more +// information, such as the match time, which would be the earliest a +// transaction could be found on-chain. +// 3. We don't see a mempool. We're blind to new transactions until they are +// mined. This requires special handling by the caller. We've been +// anticipating this, so Core and Swapper are permissive of missing acks for +// audit requests. + +package btc + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "sort" + "sync" + "sync/atomic" + "time" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/dex" + dexbtc "decred.org/dcrdex/dex/networks/btc" + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog" + "github.com/btcsuite/btcutil" + "github.com/btcsuite/btcutil/gcs" + "github.com/btcsuite/btcutil/psbt" + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet" + "github.com/btcsuite/btcwallet/wallet/txauthor" + "github.com/btcsuite/btcwallet/walletdb" + _ "github.com/btcsuite/btcwallet/walletdb/bdb" // bdb init() registers a driver + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/jrick/logrotate/rotator" + "github.com/lightninglabs/neutrino" + "github.com/lightninglabs/neutrino/headerfs" +) + +const ( + WalletTransactionNotFound = dex.ErrorKind("wallet transaction not found") + SpentStatusUnknown = dex.ErrorKind("spend status not known") + + // see btcd/blockchain/validate.go + maxFutureBlockTime = 2 * time.Hour + neutrinoDBName = "neutrino.db" + logDirName = "logs" + defaultAcctNum = 0 + defaultAcctName = "default" +) + +// btcWallet is satisfied by *btcwallet.Wallet -> *walletExtender. +type btcWallet interface { + PublishTransaction(tx *wire.MsgTx, label string) error + CalculateAccountBalances(account uint32, confirms int32) (wallet.Balances, error) + ListUnspent(minconf, maxconf int32, acctName string) ([]*btcjson.ListUnspentResult, error) + FetchInputInfo(prevOut *wire.OutPoint) (*wire.MsgTx, *wire.TxOut, *psbt.Bip32Derivation, int64, error) + ResetLockedOutpoints() + LockOutpoint(op wire.OutPoint) + UnlockOutpoint(op wire.OutPoint) + LockedOutpoints() []btcjson.TransactionInput + NewChangeAddress(account uint32, scope waddrmgr.KeyScope) (btcutil.Address, error) + NewAddress(account uint32, scope waddrmgr.KeyScope) (btcutil.Address, error) + SignTransaction(tx *wire.MsgTx, hashType txscript.SigHashType, additionalPrevScriptsadditionalPrevScripts map[wire.OutPoint][]byte, + additionalKeysByAddress map[string]*btcutil.WIF, p2shRedeemScriptsByAddress map[string][]byte) ([]wallet.SignatureError, error) + PrivKeyForAddress(a btcutil.Address) (*btcec.PrivateKey, error) + Database() walletdb.DB + Unlock(passphrase []byte, lock <-chan time.Time) error + Lock() + Locked() bool + SendOutputs(outputs []*wire.TxOut, keyScope *waddrmgr.KeyScope, account uint32, minconf int32, satPerKb btcutil.Amount, label string) (*wire.MsgTx, error) + HaveAddress(a btcutil.Address) (bool, error) + Stop() + WaitForShutdown() + ChainSynced() bool + SynchronizeRPC(chainClient chain.Interface) + // walletExtender methods + walletTransaction(txHash *chainhash.Hash) (*wtxmgr.TxDetails, error) + syncedTo() waddrmgr.BlockStamp + signTransaction(*wire.MsgTx) error +} + +var _ btcWallet = (*walletExtender)(nil) + +// neutrinoService is satisfied by *neutrino.ChainService. +type neutrinoService interface { + GetBlockHash(int64) (*chainhash.Hash, error) + BestBlock() (*headerfs.BlockStamp, error) + Peers() []*neutrino.ServerPeer + GetBlockHeight(hash *chainhash.Hash) (int32, error) + GetBlockHeader(*chainhash.Hash) (*wire.BlockHeader, error) + GetCFilter(blockHash chainhash.Hash, filterType wire.FilterType, options ...neutrino.QueryOption) (*gcs.Filter, error) + GetBlock(blockHash chainhash.Hash, options ...neutrino.QueryOption) (*btcutil.Block, error) + Stop() error +} + +var _ neutrinoService = (*neutrino.ChainService)(nil) + +// createSPVWallet creates a new SPV wallet. +func createSPVWallet(privPass []byte, seed []byte, dbDir string, log dex.Logger, 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) + } + + logDir := filepath.Join(netDir, logDirName) + err := os.MkdirAll(logDir, 0744) + if err != nil { + return fmt.Errorf("error creating wallet directories: %v", err) + } + + loader := wallet.NewLoader(net, netDir, true, 60*time.Second, 250) + + pubPass := []byte(wallet.InsecurePubPassphrase) + _, err = loader.CreateNewWallet(pubPass, privPass, seed, time.Now()) + if err != nil { + return fmt.Errorf("CreateNewWallet error: %w", err) + } + + bailOnWallet := func() { + if err := loader.UnloadWallet(); err != nil { + log.Errorf("Error unloading wallet after createSPVWallet error: %v", err) + } + } + + 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) + } + if err = db.Close(); err != nil { + bailOnWallet() + return fmt.Errorf("error closing newly created wallet database: %w", err) + } + + if err := loader.UnloadWallet(); err != nil { + return fmt.Errorf("error unloading wallet: %w", err) + } + + return nil +} + +var ( + // loggingInited will be set when the log rotator has been initilized. + loggingInited uint32 +) + +// logRotator initializes a rotating file logger. +func logRotator(netDir string) (*rotator.Rotator, error) { + const maxLogRolls = 8 + logDir := filepath.Join(netDir, logDirName) + if err := os.MkdirAll(logDir, 0744); err != nil { + return nil, fmt.Errorf("error creating log directory: %w", err) + } + + logFilename := filepath.Join(logDir, "neutrino.log") + return rotator.New(logFilename, 32*1024, false, maxLogRolls) +} + +// logNeutrino initializes logging in the neutrino + wallet packages. Logging +// only has to be initialized once, so an atomic flag is used internally to +// return early on subsequent invocations. +// +// In theory, the the rotating file logger must be Close'd at some point, but +// there are concurrency issues with that since btcd and btcwallet have +// unsupervised goroutines still running after shutdown. So we leave the rotator +// running at the risk of losing some logs. +func logNeutrino(netDir string) error { + if !atomic.CompareAndSwapUint32(&loggingInited, 0, 1) { + return nil + } + + logSpinner, err := logRotator(netDir) + if err != nil { + return fmt.Errorf("error initializing log rotator: %w", err) + } + + backendLog := btclog.NewBackend(logWriter{logSpinner}) + + logger := func(name string, lvl btclog.Level) btclog.Logger { + l := backendLog.Logger(name) + l.SetLevel(lvl) + return l + } + + neutrino.UseLogger(logger("NTRNO", btclog.LevelDebug)) + wallet.UseLogger(logger("BTCW", btclog.LevelInfo)) + wtxmgr.UseLogger(logger("TXMGR", btclog.LevelInfo)) + chain.UseLogger(logger("CHAIN", btclog.LevelInfo)) + + return nil +} + +// spendingInput is added to a filterScanResult if a spending input is found. +type spendingInput struct { + txHash chainhash.Hash + vin uint32 + blockHash chainhash.Hash + blockHeight uint32 +} + +// filterScanResult is the result from a filter scan. +type filterScanResult struct { + // blockHash is the block that the output was found in. + blockHash *chainhash.Hash + // blockHeight is the height of the block that the output was found in. + blockHeight uint32 + // txOut is the output itself. + txOut *wire.TxOut + // spend will be set if a spending input is found. + spend *spendingInput + // checkpoint is used to track the last block scanned so that future scans + // can skip scanned blocks. + checkpoint chainhash.Hash +} + +// hashEntry stores a chainhash.Hash with a last-access time that can be used +// for cache maintenance. +type hashEntry struct { + hash chainhash.Hash + lastAccess time.Time +} + +// scanCheckpoint is a cached, incomplete filterScanResult. When another scan +// is requested for an outpoint with a cached *scanCheckpoint, the scan can +// pick up where it left off. +type scanCheckpoint struct { + res *filterScanResult + lastAccess time.Time +} + +// logWriter implements an io.Writer that outputs to a rotating log file. +type logWriter struct { + *rotator.Rotator +} + +// Write writes the data in p to the log file. +func (w logWriter) Write(p []byte) (n int, err error) { + return w.Rotator.Write(p) +} + +// spvWallet is an in-process btcwallet.Wallet + neutrino light-filter-based +// Bitcoin wallet. spvWallet controls an instance of btcwallet.Wallet directly +// and does not run or connect to the RPC server. +type spvWallet struct { + chainParams *chaincfg.Params + wallet btcWallet + cl neutrinoService + chainClient *chain.NeutrinoClient + acctNum uint32 + acctName string + netDir string + neutrinoDB walletdb.DB + connectPeers []string + + txBlocksMtx sync.Mutex + txBlocks map[chainhash.Hash]*hashEntry + + checkpointMtx sync.Mutex + checkpoints map[outPoint]*scanCheckpoint + + log dex.Logger + loader *wallet.Loader +} + +var _ Wallet = (*spvWallet)(nil) + +// loadSPVWallet loads an existing wallet. +func loadSPVWallet(dbDir string, logger dex.Logger, connectPeers []string, chainParams *chaincfg.Params) *spvWallet { + return &spvWallet{ + chainParams: chainParams, + acctNum: defaultAcctNum, + acctName: defaultAcctName, + netDir: filepath.Join(dbDir, chainParams.Name), + txBlocks: make(map[chainhash.Hash]*hashEntry), + checkpoints: make(map[outPoint]*scanCheckpoint), + log: logger, + connectPeers: connectPeers, + } +} + +// storeTxBlock stores the block hash for the tx in the cache. +func (w *spvWallet) storeTxBlock(txHash, blockHash chainhash.Hash) { + w.txBlocksMtx.Lock() + defer w.txBlocksMtx.Unlock() + w.txBlocks[txHash] = &hashEntry{ + hash: blockHash, + lastAccess: time.Now(), + } +} + +// txBlock attempts to retrieve the block hash for the tx from the cache. +func (w *spvWallet) txBlock(txHash chainhash.Hash) (chainhash.Hash, bool) { + w.txBlocksMtx.Lock() + defer w.txBlocksMtx.Unlock() + entry, found := w.txBlocks[txHash] + if !found { + return chainhash.Hash{}, false + } + entry.lastAccess = time.Now() + return entry.hash, true +} + +// cacheCheckpoint caches a *filterScanResult so that future scans can be +// skipped or shortened. +func (w *spvWallet) cacheCheckpoint(txHash *chainhash.Hash, vout uint32, res *filterScanResult) { + if res.spend != nil && res.blockHash == nil { + // Probably set the start time too late. Don't cache anything + return + } + w.checkpointMtx.Lock() + defer w.checkpointMtx.Unlock() + w.checkpoints[newOutPoint(txHash, vout)] = &scanCheckpoint{ + res: res, + lastAccess: time.Now(), + } +} + +// unvalidatedCheckpoint returns any cached *filterScanResult for the outpoint. +func (w *spvWallet) unvalidatedCheckpoint(txHash *chainhash.Hash, vout uint32) *filterScanResult { + w.checkpointMtx.Lock() + defer w.checkpointMtx.Unlock() + check, found := w.checkpoints[newOutPoint(txHash, vout)] + if !found { + return nil + } + check.lastAccess = time.Now() + res := *check.res + return &res +} + +// checkpoint returns a filterScanResult and the checkpoint block hash. If a +// result is found with an orphaned checkpoint block hash, it is cleared from +// the cache and not returned. +func (w *spvWallet) checkpoint(txHash *chainhash.Hash, vout uint32) *filterScanResult { + res := w.unvalidatedCheckpoint(txHash, vout) + if res == nil { + return nil + } + if !w.blockIsMainchain(&res.checkpoint, -1) { + // reorg detected, abandon the checkpoint. + w.log.Debugf("abandoning checkpoint %s because checkpoint block %q is orphaned", + newOutPoint(txHash, vout), res.checkpoint) + w.checkpointMtx.Lock() + delete(w.checkpoints, newOutPoint(txHash, vout)) + w.checkpointMtx.Unlock() + return nil + } + return res +} + +func (w *spvWallet) RawRequest(method string, params []json.RawMessage) (json.RawMessage, error) { + // Not needed for spv wallet. + return nil, errors.New("RawRequest not available on spv") +} + +func (w *spvWallet) estimateSmartFee(confTarget int64, mode *btcjson.EstimateSmartFeeMode) (*btcjson.EstimateSmartFeeResult, error) { + return nil, errors.New("EstimateSmartFee not available on spv") +} + +func (w *spvWallet) ownsAddress(addr btcutil.Address) (bool, error) { + return w.wallet.HaveAddress(addr) +} + +func (w *spvWallet) sendRawTransaction(tx *wire.MsgTx) (*chainhash.Hash, error) { + // Fee sanity check? + err := w.wallet.PublishTransaction(tx, "") + if err != nil { + return nil, err + } + txHash := tx.TxHash() + + // bitcoind would unlock these, but it seems that btcwallet doesn't, but it + // seems like they're no longer returned from ListUnspent even if we unlock + // the outpoint before the transaction is mined. + for _, txIn := range tx.TxIn { + w.wallet.UnlockOutpoint(txIn.PreviousOutPoint) + } + + return &txHash, nil +} + +func (w *spvWallet) getBlock(blockHash chainhash.Hash) (*wire.MsgBlock, error) { + block, err := w.cl.GetBlock(blockHash) + if err != nil { + return nil, fmt.Errorf("neutrino GetBlock error: %v", err) + } + + return block.MsgBlock(), nil +} + +func (w *spvWallet) getBlockHash(blockHeight int64) (*chainhash.Hash, error) { + return w.cl.GetBlockHash(blockHeight) +} + +func (w *spvWallet) getBlockHeight(h *chainhash.Hash) (int32, error) { + return w.cl.GetBlockHeight(h) +} + +func (w *spvWallet) getBestBlockHash() (*chainhash.Hash, error) { + blk, err := w.cl.BestBlock() + if err != nil { + return nil, err + } + return &blk.Hash, err +} + +func (w *spvWallet) getBestBlockHeight() (int32, error) { + blk, err := w.cl.BestBlock() + if err != nil { + return -1, err + } + return blk.Height, err +} + +// syncHeight is the best known sync height among peers. +func (w *spvWallet) syncHeight() int32 { + var maxHeight int32 + for _, p := range w.cl.Peers() { + tipHeight := p.StartingHeight() + lastBlockHeight := p.LastBlock() + if lastBlockHeight > tipHeight { + tipHeight = lastBlockHeight + } + if tipHeight > maxHeight { + maxHeight = tipHeight + } + } + return maxHeight +} + +// syncStatus is information about the wallet's sync status. +func (w *spvWallet) syncStatus() (*syncStatus, error) { + blk, err := w.cl.BestBlock() + if err != nil { + return nil, err + } + + target := w.syncHeight() + currentHeight := blk.Height + synced := w.wallet.ChainSynced() + // Sometimes the wallet doesn't report the chain as synced right away. + // Seems to be a bug. + if !synced && target > 0 && target == currentHeight { + synced = true + } + + return &syncStatus{ + Target: target, + Height: currentHeight, + Syncing: !synced, + }, nil +} + +// Balances retrieves a wallet's balance details. +func (w *spvWallet) balances() (*GetBalancesResult, error) { + bals, err := w.wallet.CalculateAccountBalances(w.acctNum, 0 /* confs */) + if err != nil { + return nil, err + } + + return &GetBalancesResult{ + Mine: Balances{ + Trusted: bals.Spendable.ToBTC(), + Untrusted: 0, // ? do we need to scan utxos instead ? + Immature: bals.ImmatureReward.ToBTC(), + }, + }, nil +} + +// ListUnspent retrieves list of the wallet's UTXOs. +func (w *spvWallet) listUnspent() ([]*ListUnspentResult, error) { + unspents, err := w.wallet.ListUnspent(0, math.MaxInt32, w.acctName) + if err != nil { + return nil, err + } + res := make([]*ListUnspentResult, 0, len(unspents)) + for _, utxo := range unspents { + // If the utxo is unconfirmed, we should determine whether it's "safe" + // by seeing if we control the inputs of its transaction. + var safe bool + if utxo.Confirmations > 0 { + safe = true + } else { + txHash, err := chainhash.NewHashFromStr(utxo.TxID) + if err != nil { + return nil, fmt.Errorf("error decoding txid %q: %v", utxo.TxID, err) + } + txDetails, err := w.wallet.walletTransaction(txHash) + if err != nil { + return nil, fmt.Errorf("walletTransaction error: %v", err) + } + // To be "safe", we need to show that we own the inputs for the + // utxo's transaction. We'll just try to find one. + safe = true + // TODO: Keep a cache of our redemption outputs and allow those as + // safe inputs. + for _, txIn := range txDetails.MsgTx.TxIn { + _, _, _, _, err := w.wallet.FetchInputInfo(&txIn.PreviousOutPoint) + if err != nil { + if !errors.Is(err, wallet.ErrNotMine) { + w.log.Warnf("FetchInputInfo error: %v", err) + } + safe = false + break + } + } + } + + pkScript, err := hex.DecodeString(utxo.ScriptPubKey) + if err != nil { + return nil, err + } + + redeemScript, err := hex.DecodeString(utxo.RedeemScript) + if err != nil { + return nil, err + } + + res = append(res, &ListUnspentResult{ + TxID: utxo.TxID, + Vout: utxo.Vout, + Address: utxo.Address, + // Label: , + ScriptPubKey: pkScript, + Amount: utxo.Amount, + Confirmations: uint32(utxo.Confirmations), + RedeemScript: redeemScript, + Spendable: utxo.Spendable, + // Solvable: , + Safe: safe, + }) + } + return res, nil +} + +// lockUnspent locks and unlocks outputs for spending. An output that is part of +// an order, but not yet spent, should be locked until spent or until the order +// is canceled or fails. +func (w *spvWallet) lockUnspent(unlock bool, ops []*output) error { + switch { + case unlock && len(ops) == 0: + w.wallet.ResetLockedOutpoints() + default: + for _, op := range ops { + op := wire.OutPoint{Hash: op.pt.txHash, Index: op.pt.vout} + if unlock { + w.wallet.UnlockOutpoint(op) + } else { + w.wallet.LockOutpoint(op) + } + } + } + return nil +} + +// listLockUnspent returns a slice of outpoints for all unspent outputs marked +// as locked by a wallet. +func (w *spvWallet) listLockUnspent() ([]*RPCOutpoint, error) { + outpoints := w.wallet.LockedOutpoints() + pts := make([]*RPCOutpoint, 0, len(outpoints)) + for _, pt := range outpoints { + pts = append(pts, &RPCOutpoint{ + TxID: pt.Txid, + Vout: pt.Vout, + }) + } + return pts, nil +} + +// changeAddress gets a new internal address from the wallet. The address will +// be bech32-encoded (P2WPKH). +func (w *spvWallet) changeAddress() (btcutil.Address, error) { + return w.wallet.NewChangeAddress(w.acctNum, waddrmgr.KeyScopeBIP0084) +} + +// AddressPKH gets a new base58-encoded (P2PKH) external address from the +// wallet. +func (w *spvWallet) addressPKH() (btcutil.Address, error) { + return nil, errors.New("unimplemented") +} + +// addressWPKH gets a new bech32-encoded (P2WPKH) external address from the +// wallet. +func (w *spvWallet) addressWPKH() (btcutil.Address, error) { + return w.wallet.NewAddress(w.acctNum, waddrmgr.KeyScopeBIP0084) +} + +// signTx attempts to have the wallet sign the transaction inputs. +func (w *spvWallet) signTx(tx *wire.MsgTx) (*wire.MsgTx, error) { + // Can't use btcwallet.Wallet.SignTransaction, because it doesn't work for + // segwit transactions (for real?). + return tx, w.wallet.signTransaction(tx) +} + +// privKeyForAddress retrieves the private key associated with the specified +// address. +func (w *spvWallet) privKeyForAddress(addr string) (*btcec.PrivateKey, error) { + a, err := btcutil.DecodeAddress(addr, w.chainParams) + if err != nil { + return nil, err + } + return w.wallet.PrivKeyForAddress(a) +} + +// Unlock unlocks the wallet. +func (w *spvWallet) Unlock(pw []byte) error { + return w.wallet.Unlock(pw, nil) +} + +// Lock locks the wallet. +func (w *spvWallet) Lock() error { + w.wallet.Lock() + return nil +} + +// sendToAddress sends the amount to the address. feeRate is in units of +// sats/byte. +func (w *spvWallet) sendToAddress(address string, value, feeRate uint64, subtract bool) (*chainhash.Hash, error) { + addr, err := btcutil.DecodeAddress(address, w.chainParams) + if err != nil { + return nil, err + } + + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + return nil, err + } + + if subtract { + return w.sendWithSubtract(pkScript, value, feeRate) + } + + wireOP := wire.NewTxOut(int64(value), pkScript) + // converting sats/vB -> sats/kvB + feeRateAmt := btcutil.Amount(feeRate * 1e3) + tx, err := w.wallet.SendOutputs([]*wire.TxOut{wireOP}, nil, w.acctNum, 0, feeRateAmt, "") + if err != nil { + return nil, err + } + + txHash := tx.TxHash() + + return &txHash, nil +} + +func (w *spvWallet) sendWithSubtract(pkScript []byte, value, feeRate uint64) (*chainhash.Hash, error) { + txOutSize := dexbtc.TxOutOverhead + uint64(len(pkScript)) // send-to address + var unfundedTxSize uint64 = dexbtc.MinimumTxOverhead + dexbtc.P2WPKHOutputSize /* change */ + txOutSize + + unspents, err := w.listUnspent() + if err != nil { + return nil, fmt.Errorf("error listing unspent outputs: %w", err) + } + + utxos, _, _, err := convertUnspent(0, unspents, w.chainParams) + if err != nil { + return nil, fmt.Errorf("error converting unspent outputs: %w", err) + } + + // With sendWithSubtract, fees are subtracted from the sent amount, so we + // target an input sum, not an output value. Makes the math easy. + enough := func(_, inputsVal uint64) bool { + return inputsVal >= value + } + + sum, inputsSize, _, fundingCoins, _, _, err := fund(utxos, enough) + if err != nil { + return nil, fmt.Errorf("error funding sendWithSubtract value of %s: %v", amount(value), err) + } + + fees := (unfundedTxSize + uint64(inputsSize)) * feeRate + send := value - fees + extra := sum - send + + switch { + case fees > sum: + return nil, fmt.Errorf("fees > sum") + case fees > value: + return nil, fmt.Errorf("fees > value") + case send > sum: + return nil, fmt.Errorf("send > sum") + } + + tx := wire.NewMsgTx(wire.TxVersion) + for op := range fundingCoins { + wireOP := wire.NewOutPoint(&op.txHash, op.vout) + txIn := wire.NewTxIn(wireOP, []byte{}, nil) + tx.AddTxIn(txIn) + } + + change := extra - fees + changeAddr, err := w.changeAddress() + if err != nil { + return nil, fmt.Errorf("error retrieving change address: %w", err) + } + + changeScript, err := txscript.PayToAddrScript(changeAddr) + if err != nil { + return nil, fmt.Errorf("error generating pubkey script: %w", err) + } + + changeOut := wire.NewTxOut(int64(change), changeScript) + + // One last check for dust. + if dexbtc.IsDust(changeOut, feeRate) { + // Re-calculate fees and change + fees = (unfundedTxSize - dexbtc.P2WPKHOutputSize + uint64(inputsSize)) * feeRate + send = sum - fees + } else { + tx.AddTxOut(changeOut) + } + + wireOP := wire.NewTxOut(int64(send), pkScript) + tx.AddTxOut(wireOP) + + if err := w.wallet.signTransaction(tx); err != nil { + return nil, fmt.Errorf("signing error: %w", err) + } + + return w.sendRawTransaction(tx) +} + +// swapConfirmations attempts to get the number of confirmations and the spend +// status for the specified tx output. For swap outputs that were not generated +// by this wallet, startTime must be supplied to limit the search. Use the match +// time assigned by the server. +func (w *spvWallet) swapConfirmations(txHash *chainhash.Hash, vout uint32, pkScript []byte, + startTime time.Time) (confs uint32, spent bool, err error) { + + // First, check if it's a wallet transaction. We probably won't be able + // to see the spend status, since the wallet doesn't track the swap contract + // output, but we can get the block if it's been mined. + blockHash, confs, spent, err := w.confirmations(txHash, vout) + if err == nil { + return confs, spent, nil + } + var assumedMempool bool + switch err { + case WalletTransactionNotFound: + case SpentStatusUnknown: + if blockHash == nil { + // We generated this swap, but it probably hasn't been mined yet. + // It's SpentStatusUnknown because the wallet doesn't track the + // spend status of the swap contract output itself, since it's not + // recognized as a wallet output. We'll still try to find the + // confirmations with other means, but if we can't find it, we'll + // report it as a zero-conf unspent output. This ignores the remote + // possibility that the output could be both in mempool and spent. + assumedMempool = true + } + default: + return 0, false, err + } + + // If we still don't have the block hash, we may have it stored. Check the + // dex database first. This won't give us the confirmations and spent + // status, but it will allow us to short circuit a longer scan if we already + // know the output is spent. + if blockHash == nil { + blockHash, _ = w.mainchainBlockForStoredTx(txHash) + } + + // Our last option is neutrino. + utxo, err := w.scanFilters(txHash, vout, pkScript, startTime, blockHash) + if err != nil { + return 0, false, err + } + + if utxo.spend == nil && utxo.blockHash == nil { + if assumedMempool { + return 0, false, nil + } + return 0, false, fmt.Errorf("output %s:%v not found with search parameters startTime = %s, pkScript = %x", + txHash, vout, startTime, pkScript) + } + + if utxo.blockHash != nil { + bestHeight, err := w.getBestBlockHeight() + if err != nil { + return 0, false, fmt.Errorf("getBestBlockHeight error: %v", err) + } + confs = uint32(bestHeight) - utxo.blockHeight + 1 + } + + if utxo.spend != nil { + // In the off-chance that a spend was found but not the output itself, + // confs will be incorrect here. + // In situations where we're looking for the counter-party's swap, we + // revoke if it's found to be spent, without inspecting the confs, so + // accuracy of confs is not significant. When it's our output, we'll + // know the block and won't end up here. (even if we did, we just end up + // sending out some inaccurate Data-severity notifications to the UI + // until the match progresses) + return confs, true, nil + } + + // unspent + return confs, false, nil +} + +func (w *spvWallet) locked() bool { + return w.wallet.Locked() +} + +func (w *spvWallet) walletLock() error { + w.wallet.Lock() + return nil +} + +func (w *spvWallet) walletUnlock(pw []byte) error { + return w.Unlock(pw) +} + +func (w *spvWallet) getBlockHeader(hashStr string) (*blockHeader, error) { + blockHash, err := chainhash.NewHashFromStr(hashStr) + if err != nil { + return nil, err + } + hdr, err := w.cl.GetBlockHeader(blockHash) + if err != nil { + return nil, err + } + + medianTime, err := w.calcMedianTime(blockHash) + if err != nil { + return nil, err + } + + tip, err := w.cl.BestBlock() + if err != nil { + return nil, fmt.Errorf("BestBlock error: %v", err) + } + + blockHeight, err := w.cl.GetBlockHeight(blockHash) + if err != nil { + return nil, err + } + + return &blockHeader{ + Hash: hdr.BlockHash().String(), + Confirmations: int64(confirms(blockHeight, tip.Height)), + Height: int64(blockHeight), + Time: hdr.Timestamp.Unix(), + MedianTime: medianTime.Unix(), + }, nil +} + +const medianTimeBlocks = 11 + +// calcMedianTime calculates the median time of the previous 11 block headers. +// The median time is used for validating time-locked transactions. See notes in +// btcd/blockchain (*blockNode).CalcPastMedianTime() regarding incorrectly +// calculated median time for blocks 1, 3, 5, 7, and 9. +func (w *spvWallet) calcMedianTime(blockHash *chainhash.Hash) (time.Time, error) { + timestamps := make([]int64, 0, medianTimeBlocks) + + zeroHash := chainhash.Hash{} + + h := blockHash + for i := 0; i < medianTimeBlocks; i++ { + hdr, err := w.cl.GetBlockHeader(h) + if err != nil { + return time.Time{}, fmt.Errorf("BlockHeader error for hash %q: %v", h, err) + } + timestamps = append(timestamps, hdr.Timestamp.Unix()) + + if hdr.PrevBlock == zeroHash { + break + } + h = &hdr.PrevBlock + } + + sort.Slice(timestamps, func(i, j int) bool { + return timestamps[i] < timestamps[j] + }) + + medianTimestamp := timestamps[len(timestamps)/2] + return time.Unix(medianTimestamp, 0), nil +} + +// connect will start the wallet and begin syncing. +func (w *spvWallet) connect(ctx context.Context, wg *sync.WaitGroup) error { + if err := logNeutrino(w.netDir); err != nil { + return fmt.Errorf("error initializing btcwallet+neutrino logging: %v", err) + } + + err := w.startWallet() + if err != nil { + return err + } + + // Possible to subscribe to block notifications here with a NewRescan -> + // *Rescan supplied with a QuitChan-type RescanOption. + // Actually, should use btcwallet.Wallet.NtfnServer ? + + // notes := make(<-chan interface{}) + // if w.chainClient != nil { + // notes = w.chainClient.Notifications() + // } + + // Nanny for the caches checkpoints and txBlocks caches. + wg.Add(1) + go func() { + defer wg.Done() + defer w.stop() + + ticker := time.NewTicker(time.Minute * 20) + defer ticker.Stop() + expiration := time.Hour * 2 + for { + select { + case <-ticker.C: + w.txBlocksMtx.Lock() + for txHash, entry := range w.txBlocks { + if time.Since(entry.lastAccess) > expiration { + delete(w.txBlocks, txHash) + } + } + w.txBlocksMtx.Unlock() + + w.checkpointMtx.Lock() + for outPt, check := range w.checkpoints { + if time.Since(check.lastAccess) > expiration { + delete(w.checkpoints, outPt) + } + } + w.checkpointMtx.Unlock() + // case note := <-notes: + // fmt.Printf("--Notification received: %T: %+v \n", note, note) + case <-ctx.Done(): + return + } + } + }() + + return nil +} + +// startWallet initializes the *btcwallet.Wallet and its supporting players and +// starts syncing. +func (w *spvWallet) startWallet() error { + // timeout and recoverWindow arguments borrowed from btcwallet directly. + w.loader = wallet.NewLoader(w.chainParams, w.netDir, true, 60*time.Second, 250) + + exists, err := w.loader.WalletExists() + if err != nil { + return fmt.Errorf("error verifying wallet existence: %v", err) + } + if !exists { + return errors.New("wallet not found") + } + + btcw, err := w.loader.OpenExistingWallet([]byte(wallet.InsecurePubPassphrase), false) + if err != nil { + return fmt.Errorf("couldn't load wallet: %w", err) + } + + bailOnWallet := func() { + if err := w.loader.UnloadWallet(); err != nil { + w.log.Errorf("Error unloading wallet after loadChainClient error: %v", err) + } + } + + neutrinoDBPath := filepath.Join(w.netDir, neutrinoDBName) + w.neutrinoDB, err = walletdb.Create("bdb", neutrinoDBPath, true, wallet.DefaultDBTimeout) + if err != nil { + bailOnWallet() + return fmt.Errorf("unable to create wallet db at %q: %v", neutrinoDBPath, err) + } + + bailOnWalletAndDB := func() { + if err := w.neutrinoDB.Close(); err != nil { + w.log.Errorf("Error closing neutrino database after loadChainClient error: %v", err) + } + bailOnWallet() + } + + // If we're on regtest and the peers haven't been explicitly set, add the + // simnet harness alpha node as an additional peer so we don't have to type + // it in. + if w.chainParams.Name == "regtest" && len(w.connectPeers) == 0 { + w.connectPeers = append(w.connectPeers, "localhost:20575") + } + + chainService, err := neutrino.NewChainService(neutrino.Config{ + DataDir: w.netDir, + Database: w.neutrinoDB, + ChainParams: *w.chainParams, + ConnectPeers: w.connectPeers, + BroadcastTimeout: 10 * time.Second, + }) + if err != nil { + bailOnWalletAndDB() + return fmt.Errorf("couldn't create Neutrino ChainService: %v", err) + } + + bailOnEverything := func() { + if err := chainService.Stop(); err != nil { + w.log.Errorf("Error closing neutrino chain service after loadChainClient error: %v", err) + } + bailOnWalletAndDB() + } + + w.cl = chainService + w.chainClient = chain.NewNeutrinoClient(w.chainParams, chainService) + w.wallet = &walletExtender{btcw, w.chainParams} + + if err = w.chainClient.Start(); err != nil { // lazily starts connmgr + bailOnEverything() + if err != nil { + w.log.Errorf("error unloading wallet after chain client start error: %v", err) + } + return fmt.Errorf("couldn't start Neutrino client: %v", err) + } + + btcw.SynchronizeRPC(w.chainClient) + + return nil +} + +// stop stops the wallet and database threads. +func (w *spvWallet) stop() { + w.log.Info("Unloading wallet") + if err := w.loader.UnloadWallet(); err != nil { + w.log.Errorf("UnloadWallet error: %v", err) + } + if w.chainClient != nil { + w.log.Trace("Stopping neutrino client chain interface") + w.chainClient.Stop() + w.chainClient.WaitForShutdown() + } + w.log.Trace("Stopping neutrino chain sync service") + if err := w.cl.Stop(); err != nil { + w.log.Errorf("error stopping neutrino chain service: %v", err) + } + w.log.Trace("Stopping neutrino DB.") + if err := w.neutrinoDB.Close(); err != nil { + w.log.Errorf("wallet db close error: %v", err) + } + + w.log.Info("SPV wallet closed") +} + +// blockForStoredTx looks for a block hash in the txBlocks index. +func (w *spvWallet) blockForStoredTx(txHash *chainhash.Hash) (*chainhash.Hash, int32, error) { + // Check if we know the block hash for the tx. + blockHash, found := w.txBlock(*txHash) + if !found { + return nil, 0, nil + } + // Check that the block is still mainchain. + blockHeight, err := w.cl.GetBlockHeight(&blockHash) + if err != nil { + w.log.Errorf("Error retrieving block height for hash %s: %v", blockHash, err) + return nil, 0, err + } + return &blockHash, blockHeight, nil +} + +// blockIsMainchain will be true if the blockHash is that of a mainchain block. +func (w *spvWallet) blockIsMainchain(blockHash *chainhash.Hash, blockHeight int32) bool { + if blockHeight < 0 { + var err error + blockHeight, err = w.cl.GetBlockHeight(blockHash) + if err != nil { + w.log.Errorf("Error getting block height for hash %s", blockHash) + return false + } + } + checkHash, err := w.cl.GetBlockHash(int64(blockHeight)) + if err != nil { + w.log.Errorf("Error retrieving block hash for height %d", blockHeight) + return false + } + + return *checkHash == *blockHash +} + +// mainchainBlockForStoredTx gets the block hash and height for the transaction +// IFF an entry has been stored in the txBlocks index. +func (w *spvWallet) mainchainBlockForStoredTx(txHash *chainhash.Hash) (*chainhash.Hash, int32) { + // Check that the block is still mainchain. + blockHash, blockHeight, err := w.blockForStoredTx(txHash) + if err != nil { + w.log.Errorf("Error retrieving mainchain block height for hash %s", blockHash) + return nil, 0 + } + if blockHash == nil { + return nil, 0 + } + if !w.blockIsMainchain(blockHash, blockHeight) { + return nil, 0 + } + return blockHash, blockHeight +} + +// findBlockForTime locates a good start block so that a search beginning at the +// returned block has a very low likelihood of missing any blocks that have time +// > matchTime. This is done by performing a binary search (sort.Search) to find +// a block with a block time maxFutureBlockTime before matchTime. To ensure +// we also accommodate the median-block time rule and aren't missing anything +// due to out of sequence block times we use an unsophisticated algorithm of +// choosing the first block in an 11 block window with no times >= matchTime. +func (w *spvWallet) findBlockForTime(matchTime time.Time) (*chainhash.Hash, int32, error) { + offsetTime := matchTime.Add(-maxFutureBlockTime) + + bestHeight, err := w.getBestBlockHeight() + if err != nil { + return nil, 0, fmt.Errorf("getBestBlockHeight error: %v", err) + } + + getBlockTimeForHeight := func(height int32) (*chainhash.Hash, time.Time, error) { + hash, err := w.cl.GetBlockHash(int64(height)) + if err != nil { + return nil, time.Time{}, err + } + header, err := w.cl.GetBlockHeader(hash) + if err != nil { + return nil, time.Time{}, err + } + return hash, header.Timestamp, nil + } + + iHeight := sort.Search(int(bestHeight), func(h int) bool { + var iTime time.Time + _, iTime, err = getBlockTimeForHeight(int32(h)) + if err != nil { + return true + } + return iTime.After(offsetTime) + }) + if err != nil { + return nil, 0, fmt.Errorf("binary search error finding best block for time %q: %w", matchTime, err) + } + + // We're actually breaking an assumption of sort.Search here because block + // times aren't always monotonically increasing. This won't matter though as + // long as there are not > medianTimeBlocks blocks with inverted time order. + var count int + var iHash *chainhash.Hash + var iTime time.Time + for iHeight > 0 { + iHash, iTime, err = getBlockTimeForHeight(int32(iHeight)) + if err != nil { + return nil, 0, fmt.Errorf("getBlockTimeForHeight error: %w", err) + } + if iTime.Before(offsetTime) { + count++ + if count == medianTimeBlocks { + return iHash, int32(iHeight), nil + } + } else { + count = 0 + } + iHeight-- + } + return w.chainParams.GenesisHash, 0, nil + +} + +// scanFilters enables searching for an output and its spending input by +// scanning BIP158 compact filters. Caller should supply either blockHash or +// startTime. blockHash takes precedence. If blockHash is supplied, the scan +// will start at that block and continue to the current blockchain tip, or until +// both the output and a spending transaction is found. if startTime is +// supplied, and the blockHash for the output is not known to the wallet, a +// candidate block will be selected with findBlockTime. +func (w *spvWallet) scanFilters(txHash *chainhash.Hash, vout uint32, pkScript []byte, startTime time.Time, blockHash *chainhash.Hash) (*filterScanResult, error) { + // TODO: Check that any blockHash supplied is not orphaned? + + // Check if we know the block hash for the tx. + var limitHeight int32 + // See if we have a checkpoint to use. + checkPt := w.checkpoint(txHash, vout) + if checkPt != nil { + if checkPt.blockHash != nil && checkPt.spend != nil { + // We already have the output and the spending input, and + // checkpointBlock already verified it's still mainchain. + return checkPt, nil + } + height, err := w.getBlockHeight(&checkPt.checkpoint) + if err != nil { + return nil, fmt.Errorf("getBlockHeight error: %w", err) + } + limitHeight = height + 1 + } else if blockHash == nil { + // No checkpoint and no block hash. Gotta guess based on time. + blockHash, limitHeight = w.mainchainBlockForStoredTx(txHash) + if blockHash == nil { + var err error + _, limitHeight, err = w.findBlockForTime(startTime) + if err != nil { + return nil, err + } + } + } else { + // No checkpoint, but user supplied a block hash. + var err error + limitHeight, err = w.getBlockHeight(blockHash) + if err != nil { + return nil, fmt.Errorf("error getting height for supplied block hash %s", blockHash) + } + } + + w.log.Debugf("Performing cfilters scan for %v:%d from height %d", txHash, vout, limitHeight) + + // Do a filter scan. + utxo, err := w.filterScanFromHeight(*txHash, vout, pkScript, limitHeight, checkPt) + if err != nil { + return nil, fmt.Errorf("filterScanFromHeight error: %w", err) + } + if utxo == nil { + return nil, asset.CoinNotFoundError + } + + // If we found a block, let's store a reference in our local database so we + // can maybe bypass a long search next time. + if utxo.blockHash != nil { + w.storeTxBlock(*txHash, *utxo.blockHash) + } + + w.cacheCheckpoint(txHash, vout, utxo) + + return utxo, nil +} + +// getTxOut finds an unspent transaction output and its number of confirmations. +// To match the behavior of the RPC method, even if an output is found, if it's +// known to be spent, no *wire.TxOut and no error will be returned. +func (w *spvWallet) getTxOut(txHash *chainhash.Hash, vout uint32, pkScript []byte, startTime time.Time) (*wire.TxOut, uint32, error) { + // Check for a wallet transaction first + txDetails, err := w.wallet.walletTransaction(txHash) + var blockHash *chainhash.Hash + if err != nil && !errors.Is(err, WalletTransactionNotFound) { + return nil, 0, fmt.Errorf("walletTransaction error: %w", err) + } + + if txDetails != nil { + spent, found := outputSpendStatus(txDetails, vout) + if found { + if spent { + return nil, 0, nil + } + if len(txDetails.MsgTx.TxOut) <= int(vout) { + return nil, 0, fmt.Errorf("wallet transaction %s doesn't have enough outputs for vout %d", txHash, vout) + } + + var confs uint32 + if txDetails.Block.Height > 0 { + tip, err := w.cl.BestBlock() + if err != nil { + return nil, 0, fmt.Errorf("BestBlock error: %v", err) + } + confs = uint32(confirms(txDetails.Block.Height, tip.Height)) + } + + msgTx := &txDetails.MsgTx + if len(msgTx.TxOut) <= int(vout) { + return nil, 0, fmt.Errorf("wallet transaction %s found, but not enough outputs for vout %d", txHash, vout) + } + return msgTx.TxOut[vout], confs, nil + + } + if txDetails.Block.Hash != (chainhash.Hash{}) { + blockHash = &txDetails.Block.Hash + } + } + + // We don't really know if it's spent, so we'll need to scan. + utxo, err := w.scanFilters(txHash, vout, pkScript, startTime, blockHash) + if err != nil { + return nil, 0, err + } + + if utxo == nil || utxo.spend != nil || utxo.blockHash == nil { + return nil, 0, nil + } + + tip, err := w.cl.BestBlock() + if err != nil { + return nil, 0, fmt.Errorf("BestBlock error: %v", err) + } + + confs := uint32(confirms(int32(utxo.blockHeight), tip.Height)) + + return utxo.txOut, confs, nil +} + +// filterScanFromHeight scans BIP158 filters beginning at the specified block +// height until the tip, or until a spending transaction is found. +func (w *spvWallet) filterScanFromHeight(txHash chainhash.Hash, vout uint32, pkScript []byte, startBlockHeight int32, checkPt *filterScanResult) (*filterScanResult, error) { + tip, err := w.getBestBlockHeight() + if err != nil { + return nil, err + } + + res := checkPt + if res == nil { + res = new(filterScanResult) + } + +search: + for height := startBlockHeight; height <= tip; height++ { + if res.spend != nil && res.blockHash == nil { + w.log.Warnf("A spending input (%s) was found during the scan but the output (%s) "+ + "itself wasn't found. Was the startBlockHeight early enough?", + newOutPoint(&res.spend.blockHash, res.spend.vin), + newOutPoint(&txHash, vout), + ) + return res, nil + } + blockHash, err := w.getBlockHash(int64(height)) + if err != nil { + return nil, fmt.Errorf("error getting block hash for height %d: %w", height, err) + } + matched, err := w.matchPkScript(blockHash, [][]byte{pkScript}) + if err != nil { + return nil, fmt.Errorf("matchPkScript error: %w", err) + } + + res.checkpoint = *blockHash + if !matched { + continue search + } + // Pull the block. + block, err := w.cl.GetBlock(*blockHash) + if err != nil { + return nil, fmt.Errorf("GetBlock error: %v", err) + } + msgBlock := block.MsgBlock() + + // Scan every transaction. + nextTx: + for _, tx := range msgBlock.Transactions { + // Look for a spending input. + if res.spend == nil { + for vin, txIn := range tx.TxIn { + prevOut := &txIn.PreviousOutPoint + if prevOut.Hash == txHash && prevOut.Index == vout { + res.spend = &spendingInput{ + txHash: tx.TxHash(), + vin: uint32(vin), + blockHash: *block.Hash(), + blockHeight: uint32(height), + } + if res.blockHash != nil { + break search + } + // The output could still be in this block, just not + // in this transaction. + continue nextTx + } + } + } + // Only check for the output if this is the right transaction. + if res.blockHash != nil || tx.TxHash() != txHash { + continue nextTx + } + for _, txOut := range tx.TxOut { + if bytes.Equal(txOut.PkScript, pkScript) { + res.blockHash = block.Hash() + res.blockHeight = uint32(height) + res.txOut = txOut + if res.spend != nil { + break search + } + // Keep looking for the spending transaction. + continue nextTx + } + } + } + } + return res, nil +} + +// matchPkScript pulls the filter for the block and attempts to match the +// supplied scripts. +func (w *spvWallet) matchPkScript(blockHash *chainhash.Hash, scripts [][]byte) (bool, error) { + filter, err := w.cl.GetCFilter(*blockHash, wire.GCSFilterRegular) + if err != nil { + return false, fmt.Errorf("GetCFilter error: %w", err) + } + + if filter.N() == 0 { + return false, fmt.Errorf("unexpected empty filter for %s", blockHash) + } + + var filterKey [gcs.KeySize]byte + copy(filterKey[:], blockHash[:gcs.KeySize]) + + matchFound, err := filter.MatchAny(filterKey, scripts) + if err != nil { + return false, fmt.Errorf("MatchAny error: %w", err) + } + return matchFound, nil +} + +// getWalletTransaction checks the wallet database for the specified +// transaction. Only transactions with output scripts that pay to the wallet or +// transactions that spend wallet outputs are stored in the wallet database. +func (w *spvWallet) getWalletTransaction(txHash *chainhash.Hash) (*GetTransactionResult, error) { + return w.getTransaction(txHash) +} + +// searchBlockForRedemptions attempts to find spending info for the specified +// contracts by searching every input of all txs in the provided block range. +func (w *spvWallet) searchBlockForRedemptions(ctx context.Context, reqs map[outPoint]*findRedemptionReq, + blockHash chainhash.Hash) (discovered map[outPoint]*findRedemptionResult) { + + // Just match all the scripts together. + scripts := make([][]byte, 0, len(reqs)) + for _, req := range reqs { + scripts = append(scripts, req.pkScript) + } + + discovered = make(map[outPoint]*findRedemptionResult, len(reqs)) + + matchFound, err := w.matchPkScript(&blockHash, scripts) + if err != nil { + w.log.Errorf("matchPkScript error: %v", err) + return + } + + if !matchFound { + return + } + + // There is at least one match. Pull the block. + block, err := w.cl.GetBlock(blockHash) + if err != nil { + w.log.Errorf("neutrino GetBlock error: %v", err) + return + } + + for _, msgTx := range block.MsgBlock().Transactions { + newlyDiscovered := findRedemptionsInTx(ctx, true, reqs, msgTx, w.chainParams) + for outPt, res := range newlyDiscovered { + discovered[outPt] = res + } + } + return +} + +// findRedemptionsInMempool is unsupported for SPV. +func (w *spvWallet) findRedemptionsInMempool(ctx context.Context, reqs map[outPoint]*findRedemptionReq) (discovered map[outPoint]*findRedemptionResult) { + return +} + +// confirmations looks for the confirmation count and spend status on a +// transaction output that pays to this wallet. +func (w *spvWallet) confirmations(txHash *chainhash.Hash, vout uint32) (blockHash *chainhash.Hash, confs uint32, spent bool, err error) { + details, err := w.wallet.walletTransaction(txHash) + if err != nil { + return nil, 0, false, err + } + + if details.Block.Hash != (chainhash.Hash{}) { + blockHash = &details.Block.Hash + syncBlock := w.wallet.syncedTo() // Better than chainClient.GetBestBlockHeight() ? + confs = uint32(confirms(details.Block.Height, syncBlock.Height)) + } + + spent, found := outputSpendStatus(details, vout) + if found { + return blockHash, confs, spent, nil + } + + return blockHash, confs, false, SpentStatusUnknown +} + +// getTransaction retrieves the specified wallet-related transaction. +// This is pretty much a copy-past from btcwallet 'gettransaction' JSON-RPC +// handler. +func (w *spvWallet) getTransaction(txHash *chainhash.Hash) (*GetTransactionResult, error) { + // Option # 1 just copies from UnstableAPI.TxDetails. Duplicating the + // unexported bucket key feels dirty. + // + // var details *wtxmgr.TxDetails + // err := walletdb.View(w.Database(), func(dbtx walletdb.ReadTx) error { + // txKey := []byte("wtxmgr") + // txmgrNs := dbtx.ReadBucket(txKey) + // var err error + // details, err = w.TxStore.TxDetails(txmgrNs, txHash) + // return err + // }) + + // Option #2 + // This is what the JSON-RPC does (and has since at least May 2018). + details, err := w.wallet.walletTransaction(txHash) + if err != nil { + return nil, err + } + + syncBlock := w.wallet.syncedTo() + + // TODO: The serialized transaction is already in the DB, so + // reserializing can be avoided here. + var txBuf bytes.Buffer + txBuf.Grow(details.MsgTx.SerializeSize()) + err = details.MsgTx.Serialize(&txBuf) + if err != nil { + return nil, err + } + + ret := &GetTransactionResult{ + TxID: txHash.String(), + Hex: txBuf.Bytes(), // 'Hex' field name is a lie, kinda + Time: uint64(details.Received.Unix()), + TimeReceived: uint64(details.Received.Unix()), + } + + if details.Block.Height != -1 { + ret.BlockHash = details.Block.Hash.String() + ret.BlockTime = uint64(details.Block.Time.Unix()) + ret.Confirmations = uint64(confirms(details.Block.Height, syncBlock.Height)) + } + + var ( + debitTotal btcutil.Amount + creditTotal btcutil.Amount // Excludes change + fee btcutil.Amount + feeF64 float64 + ) + for _, deb := range details.Debits { + debitTotal += deb.Amount + } + for _, cred := range details.Credits { + if !cred.Change { + creditTotal += cred.Amount + } + } + // Fee can only be determined if every input is a debit. + if len(details.Debits) == len(details.MsgTx.TxIn) { + var outputTotal btcutil.Amount + for _, output := range details.MsgTx.TxOut { + outputTotal += btcutil.Amount(output.Value) + } + fee = debitTotal - outputTotal + feeF64 = fee.ToBTC() + } + + if len(details.Debits) == 0 { + // Credits must be set later, but since we know the full length + // of the details slice, allocate it with the correct cap. + ret.Details = make([]*WalletTxDetails, 0, len(details.Credits)) + } else { + ret.Details = make([]*WalletTxDetails, 1, len(details.Credits)+1) + + ret.Details[0] = &WalletTxDetails{ + Category: "send", + Amount: (-debitTotal).ToBTC(), // negative since it is a send + Fee: feeF64, + } + ret.Fee = feeF64 + } + + credCat := wallet.RecvCategory(details, syncBlock.Height, w.chainParams).String() + for _, cred := range details.Credits { + // Change is ignored. + if cred.Change { + continue + } + + var address string + _, addrs, _, err := txscript.ExtractPkScriptAddrs( + details.MsgTx.TxOut[cred.Index].PkScript, w.chainParams) + if err == nil && len(addrs) == 1 { + addr := addrs[0] + address = addr.EncodeAddress() + } + + ret.Details = append(ret.Details, &WalletTxDetails{ + Address: address, + Category: WalletTxCategory(credCat), + Amount: cred.Amount.ToBTC(), + Vout: cred.Index, + }) + } + + ret.Amount = creditTotal.ToBTC() + return ret, nil +} + +// walletExtender gives us access to a handful of fields or methods on +// *wallet.Wallet that don't make sense to stub out for testing. +type walletExtender struct { + *wallet.Wallet + chainParams *chaincfg.Params +} + +// walletTransaction pulls the transaction from the database. +func (w *walletExtender) walletTransaction(txHash *chainhash.Hash) (*wtxmgr.TxDetails, error) { + details, err := wallet.UnstableAPI(w.Wallet).TxDetails(txHash) + if err != nil { + return nil, err + } + if details == nil { + return nil, WalletTransactionNotFound + } + + return details, nil +} + +func (w *walletExtender) syncedTo() waddrmgr.BlockStamp { + return w.Manager.SyncedTo() +} + +// signTransaction signs the transaction inputs. +func (w *walletExtender) signTransaction(tx *wire.MsgTx) error { + var prevPkScripts [][]byte + var inputValues []btcutil.Amount + for _, txIn := range tx.TxIn { + _, txOut, _, _, err := w.FetchInputInfo(&txIn.PreviousOutPoint) + if err != nil { + return err + } + inputValues = append(inputValues, btcutil.Amount(txOut.Value)) + prevPkScripts = append(prevPkScripts, txOut.PkScript) + // Zero the previous witness and signature script or else + // AddAllInputScripts does some weird stuff. + txIn.SignatureScript = nil + txIn.Witness = nil + } + return walletdb.View(w.Database(), func(dbtx walletdb.ReadTx) error { + return txauthor.AddAllInputScripts(tx, prevPkScripts, inputValues, &secretSource{w, w.chainParams}) + }) +} + +// secretSource is used to locate keys and redemption scripts while signing a +// transaction. secretSource satisfies the txauthor.SecretsSource interface. +type secretSource struct { + w *walletExtender + chainParams *chaincfg.Params +} + +// ChainParams returns the chain parameters. +func (s *secretSource) ChainParams() *chaincfg.Params { + return s.chainParams +} + +// GetKey fetches a private key for the specified address. +func (s *secretSource) GetKey(addr btcutil.Address) (*btcec.PrivateKey, bool, error) { + ma, err := s.w.AddressInfo(addr) + if err != nil { + return nil, false, err + } + + mpka, ok := ma.(waddrmgr.ManagedPubKeyAddress) + if !ok { + e := fmt.Errorf("managed address type for %v is `%T` but "+ + "want waddrmgr.ManagedPubKeyAddress", addr, ma) + return nil, false, e + } + + privKey, err := mpka.PrivKey() + if err != nil { + return nil, false, err + } + return privKey, ma.Compressed(), nil +} + +// GetScript fetches the redemption script for the specified p2sh/p2wsh address. +func (s *secretSource) GetScript(addr btcutil.Address) ([]byte, error) { + ma, err := s.w.AddressInfo(addr) + if err != nil { + return nil, err + } + + msa, ok := ma.(waddrmgr.ManagedScriptAddress) + if !ok { + e := fmt.Errorf("managed address type for %v is `%T` but "+ + "want waddrmgr.ManagedScriptAddress", addr, ma) + return nil, e + } + return msa.Script() +} + +func confirms(txHeight, curHeight int32) int32 { + switch { + case txHeight == -1, txHeight > curHeight: + return 0 + default: + return curHeight - txHeight + 1 + } +} + +// outputSpendStatus will return the spend status of the output if it's found +// in the TxDetails.Credits. +func outputSpendStatus(details *wtxmgr.TxDetails, vout uint32) (spend, found bool) { + for _, credit := range details.Credits { + if credit.Index == vout { + return credit.Spent, true + } + } + return false, false +} diff --git a/client/asset/btc/spv_test.go b/client/asset/btc/spv_test.go new file mode 100644 index 0000000000..fbee237db2 --- /dev/null +++ b/client/asset/btc/spv_test.go @@ -0,0 +1,701 @@ +//go:build !spvlive +// +build !spvlive + +// 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 btc + +import ( + "errors" + "fmt" + "testing" + "time" + + "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/encode" + dexbtc "decred.org/dcrdex/dex/networks/btc" + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/peer" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcutil" + "github.com/btcsuite/btcutil/gcs" + "github.com/btcsuite/btcutil/gcs/builder" + "github.com/btcsuite/btcutil/psbt" + "github.com/btcsuite/btcwallet/chain" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/btcsuite/btcwallet/wallet" + "github.com/btcsuite/btcwallet/walletdb" + "github.com/btcsuite/btcwallet/wtxmgr" + "github.com/lightninglabs/neutrino" + "github.com/lightninglabs/neutrino/headerfs" +) + +type tBtcWallet struct { + *testData +} + +func (c *tBtcWallet) PublishTransaction(tx *wire.MsgTx, label string) error { + c.sentRawTx = tx + if c.sendErr != nil { + return c.sendErr + } + if c.sendToAddressErr != nil { + return c.sendToAddressErr + } + if c.badSendHash != nil { + // testData would return the badSendHash. We'll do something similar + // by adding a random output. + tx.AddTxOut(wire.NewTxOut(1, []byte{0x01})) + } + return c.sendErr +} + +func (c *tBtcWallet) CalculateAccountBalances(account uint32, confirms int32) (wallet.Balances, error) { + if c.getBalancesErr != nil { + return wallet.Balances{}, c.getBalancesErr + } + bal := &c.getBalances.Mine + mustAmount := func(v float64) btcutil.Amount { + amt, _ := btcutil.NewAmount(v) + return amt + } + return wallet.Balances{ + Total: mustAmount(bal.Trusted + bal.Untrusted), + Spendable: mustAmount(bal.Trusted + bal.Untrusted), + ImmatureReward: mustAmount(bal.Immature), + }, nil +} + +func (c *tBtcWallet) ListUnspent(minconf, maxconf int32, acctName string) ([]*btcjson.ListUnspentResult, error) { + if c.listUnspentErr != nil { + return nil, c.listUnspentErr + } + unspents := make([]*btcjson.ListUnspentResult, 0, len(c.listUnspent)) + for _, u := range c.listUnspent { + unspents = append(unspents, &btcjson.ListUnspentResult{ + TxID: u.TxID, + Vout: u.Vout, + Address: u.Address, + // Account: , + ScriptPubKey: u.ScriptPubKey.String(), + RedeemScript: u.RedeemScript.String(), + Amount: u.Amount, + Confirmations: int64(u.Confirmations), + Spendable: u.Spendable, + }) + } + + return unspents, nil +} + +func (c *tBtcWallet) FetchInputInfo(prevOut *wire.OutPoint) (*wire.MsgTx, *wire.TxOut, *psbt.Bip32Derivation, int64, error) { + return c.fetchInputInfoTx, nil, nil, 0, nil +} + +func (c *tBtcWallet) ResetLockedOutpoints() {} + +func (c *tBtcWallet) LockOutpoint(op wire.OutPoint) { + c.lockedCoins = []*RPCOutpoint{{ + TxID: op.Hash.String(), + Vout: op.Index, + }} +} + +func (c *tBtcWallet) UnlockOutpoint(op wire.OutPoint) {} + +func (c *tBtcWallet) LockedOutpoints() []btcjson.TransactionInput { + unspents := make([]btcjson.TransactionInput, 0) + for _, u := range c.listLockUnspent { + unspents = append(unspents, btcjson.TransactionInput{ + Txid: u.TxID, + Vout: u.Vout, + }) + } + + return unspents +} + +func (c *tBtcWallet) NewChangeAddress(account uint32, scope waddrmgr.KeyScope) (btcutil.Address, error) { + if c.changeAddrErr != nil { + return nil, c.changeAddrErr + } + return btcutil.DecodeAddress(c.changeAddr, &chaincfg.MainNetParams) +} + +func (c *tBtcWallet) NewAddress(account uint32, scope waddrmgr.KeyScope) (btcutil.Address, error) { + if c.newAddressErr != nil { + return nil, c.newAddressErr + } + return btcutil.DecodeAddress(c.newAddress, &chaincfg.MainNetParams) +} + +func (c *tBtcWallet) SignTransaction(tx *wire.MsgTx, hashType txscript.SigHashType, additionalPrevScriptsadditionalPrevScripts map[wire.OutPoint][]byte, + additionalKeysByAddress map[string]*btcutil.WIF, p2shRedeemScriptsByAddress map[string][]byte) ([]wallet.SignatureError, error) { + + if c.signTxErr != nil { + return nil, c.signTxErr + } + c.signFunc(tx) + if c.sigIncomplete { + return []wallet.SignatureError{{Error: errors.New("tBtcWallet SignTransaction error")}}, nil + } + return nil, nil +} + +func (c *tBtcWallet) PrivKeyForAddress(a btcutil.Address) (*btcec.PrivateKey, error) { + if c.privKeyForAddrErr != nil { + return nil, c.privKeyForAddrErr + } + return c.privKeyForAddr.PrivKey, nil +} + +func (c *tBtcWallet) Database() walletdb.DB { + return nil +} + +func (c *tBtcWallet) Unlock(passphrase []byte, lock <-chan time.Time) error { + return c.unlockErr +} + +func (c *tBtcWallet) Lock() {} + +func (c *tBtcWallet) Locked() bool { + return false +} + +func (c *tBtcWallet) SendOutputs(outputs []*wire.TxOut, keyScope *waddrmgr.KeyScope, account uint32, minconf int32, satPerKb btcutil.Amount, label string) (*wire.MsgTx, error) { + if c.sendToAddressErr != nil { + return nil, c.sendToAddressErr + } + tx := wire.NewMsgTx(wire.TxVersion) + for _, txOut := range outputs { + tx.AddTxOut(txOut) + } + tx.AddTxIn(dummyInput()) + + return tx, nil +} + +func (c *tBtcWallet) HaveAddress(a btcutil.Address) (bool, error) { + return false, nil +} + +func (c *tBtcWallet) Stop() {} + +func (c *tBtcWallet) WaitForShutdown() {} + +func (c *tBtcWallet) ChainSynced() bool { + if c.getBlockchainInfo == nil { + return false + } + return c.getBlockchainInfo.Blocks >= c.getBlockchainInfo.Headers-1 +} + +func (c *tBtcWallet) SynchronizeRPC(chainClient chain.Interface) {} + +func (c *tBtcWallet) walletTransaction(txHash *chainhash.Hash) (*wtxmgr.TxDetails, error) { + if c.getTransactionErr != nil { + return nil, c.getTransactionErr + } + if c.testData.getTransaction == nil { + return nil, WalletTransactionNotFound + } + + txData := c.testData.getTransaction + tx, _ := msgTxFromBytes(txData.Hex) + + blockHash, _ := chainhash.NewHashFromStr(txData.BlockHash) + + blk := c.getBlock(txData.BlockHash) + + credits := make([]wtxmgr.CreditRecord, 0, len(tx.TxIn)) + for i := range tx.TxIn { + credits = append(credits, wtxmgr.CreditRecord{ + // Amount:, + Index: uint32(i), + Spent: c.walletTxSpent, + // Change: , + }) + } + + return &wtxmgr.TxDetails{ + TxRecord: wtxmgr.TxRecord{ + MsgTx: *tx, + }, + Block: wtxmgr.BlockMeta{ + Block: wtxmgr.Block{ + Hash: *blockHash, + Height: int32(blk.height), + }, + }, + Credits: credits, + }, nil +} + +func (c *tBtcWallet) getTransaction(txHash *chainhash.Hash) (*GetTransactionResult, error) { + if c.getTransactionErr != nil { + return nil, c.getTransactionErr + } + return c.testData.getTransaction, nil +} + +func (c *tBtcWallet) syncedTo() waddrmgr.BlockStamp { + bestHash, bestHeight := c.bestBlock() + blk := c.getBlock(bestHash.String()) + return waddrmgr.BlockStamp{ + Height: int32(bestHeight), + Hash: *bestHash, + Timestamp: blk.msgBlock.Header.Timestamp, + } +} + +func (c *tBtcWallet) signTransaction(tx *wire.MsgTx) error { + if c.signTxErr != nil { + return c.signTxErr + } + c.signFunc(tx) + if c.sigIncomplete { + return errors.New("tBtcWallet SignTransaction error") + } + return nil +} + +type tNeutrinoClient struct { + *testData +} + +func (c *tNeutrinoClient) Stop() error { return nil } + +func (c *tNeutrinoClient) GetBlockHash(blockHeight int64) (*chainhash.Hash, error) { + c.blockchainMtx.RLock() + defer c.blockchainMtx.RUnlock() + for height, blockHash := range c.mainchain { + if height == blockHeight { + return blockHash, nil + } + } + return nil, fmt.Errorf("no (test) block at height %d", blockHeight) +} + +func (c *tNeutrinoClient) BestBlock() (*headerfs.BlockStamp, error) { + if c.getBestBlockHashErr != nil { + return nil, c.getBestBlockHashErr + } + bestHash, bestHeight := c.bestBlock() + return &headerfs.BlockStamp{ + Height: int32(bestHeight), + Hash: *bestHash, + }, nil +} + +func (c *tNeutrinoClient) Peers() []*neutrino.ServerPeer { + peer := &neutrino.ServerPeer{Peer: &peer.Peer{}} + if c.getBlockchainInfo != nil { + peer.UpdateLastBlockHeight(int32(c.getBlockchainInfo.Headers)) + } + return []*neutrino.ServerPeer{peer} +} + +func (c *tNeutrinoClient) GetBlockHeight(hash *chainhash.Hash) (int32, error) { + block := c.getBlock(hash.String()) + if block == nil { + return 0, fmt.Errorf("(test) block not found for block hash %s", hash) + } + return int32(block.height), nil +} + +func (c *tNeutrinoClient) GetBlockHeader(blkHash *chainhash.Hash) (*wire.BlockHeader, error) { + block := c.getBlock(blkHash.String()) + if block == nil { + return nil, errors.New("no block verbose found") + } + return &block.msgBlock.Header, nil +} + +func (c *tNeutrinoClient) GetCFilter(blockHash chainhash.Hash, filterType wire.FilterType, options ...neutrino.QueryOption) (*gcs.Filter, error) { + var key [gcs.KeySize]byte + copy(key[:], blockHash.CloneBytes()[:]) + scripts := c.getCFilterScripts[blockHash] + scripts = append(scripts, encode.RandomBytes(10)) + return gcs.BuildGCSFilter(builder.DefaultP, builder.DefaultM, key, scripts) +} + +func (c *tNeutrinoClient) GetBlock(blockHash chainhash.Hash, options ...neutrino.QueryOption) (*btcutil.Block, error) { + blk := c.getBlock(blockHash.String()) + if blk == nil { + return nil, fmt.Errorf("no (test) block %s", blockHash) + } + return btcutil.NewBlock(blk.msgBlock), nil +} + +type tSPVPeer struct { + startHeight, lastHeight int32 +} + +func (p *tSPVPeer) StartingHeight() int32 { + return p.startHeight +} + +func (p *tSPVPeer) LastBlock() int32 { + return p.lastHeight +} + +func TestSwapConfirmations(t *testing.T) { + wallet, node, shutdown, _ := tNewWallet(true, walletTypeSPV) + defer shutdown() + + spv := wallet.node.(*spvWallet) + node.txOutRes = nil + + const tipHeight = 10 + const swapHeight = 1 + const spendHeight = 3 + const swapConfs = tipHeight - swapHeight + 1 + + for i := int64(0); i <= tipHeight; i++ { + node.addRawTx(i, dummyTx()) + } + + _, _, pkScript, _, _, _, _ := makeSwapContract(true, time.Hour*12) + + swapTx := makeRawTx([]dex.Bytes{pkScript}, []*wire.TxIn{dummyInput()}) + swapTxHash := swapTx.TxHash() + const vout = 0 + swapOutPt := newOutPoint(&swapTxHash, vout) + swapBlockHash, _ := node.addRawTx(swapHeight, swapTx) + + spendTx := dummyTx() + spendTx.TxIn[0].PreviousOutPoint.Hash = swapTxHash + spendBlockHash, _ := node.addRawTx(spendHeight, spendTx) + spendTxHash := spendTx.TxHash() + + matchTime := time.Unix(1, 0) + + checkSuccess := func(tag string, expConfs uint32, expSpent bool) { + t.Helper() + confs, spent, err := spv.swapConfirmations(&swapTxHash, vout, pkScript, matchTime) + if err != nil { + t.Fatalf("%s error: %v", tag, err) + } + if confs != expConfs { + t.Fatalf("wrong number of %s confs. wanted %d, got %d", tag, expConfs, confs) + } + if spent != expSpent { + t.Fatalf("%s path not expected spent status. wanted %t, got %t", tag, expSpent, spent) + } + } + + checkFailure := func(tag string) { + t.Helper() + _, _, err := spv.swapConfirmations(&swapTxHash, vout, pkScript, matchTime) + if err == nil { + t.Fatalf("no error for %q test", tag) + } + } + + // confirmations() path + node.confsErr = tErr + checkFailure("confirmations") + + node.confsErr = nil + node.confs = 10 + node.confsSpent = true + txB, _ := serializeMsgTx(swapTx) + node.getTransaction = &GetTransactionResult{ + BlockHash: swapBlockHash.String(), + BlockIndex: swapHeight, + Hex: txB, + } + node.walletTxSpent = true + checkSuccess("confirmations", swapConfs, true) + node.getTransaction = nil + node.walletTxSpent = false + node.confsErr = WalletTransactionNotFound + + // DB path. + node.dbBlockForTx[swapTxHash] = &hashEntry{hash: *swapBlockHash} + node.dbBlockForTx[spendTxHash] = &hashEntry{hash: *spendBlockHash} + node.checkpoints[swapOutPt] = &scanCheckpoint{res: &filterScanResult{ + blockHash: swapBlockHash, + blockHeight: swapHeight, + spend: &spendingInput{}, + checkpoint: *spendBlockHash, + }} + checkSuccess("GetSpend", swapConfs, true) + delete(node.checkpoints, swapOutPt) + delete(node.dbBlockForTx, swapTxHash) + + // Neutrino scan + + // Fail cuz no match time provided and block not known. + matchTime = time.Time{} + checkFailure("no match time") + matchTime = time.Unix(1, 0) + + // spent + node.getCFilterScripts[*spendBlockHash] = [][]byte{pkScript} + delete(node.checkpoints, swapOutPt) + checkSuccess("spend", 0, true) + node.getCFilterScripts[*spendBlockHash] = nil + + // Not found + delete(node.checkpoints, swapOutPt) + checkFailure("no utxo") + + // Found. + node.getCFilterScripts[*swapBlockHash] = [][]byte{pkScript} + delete(node.checkpoints, swapOutPt) + checkSuccess("scan find", swapConfs, false) +} + +func TestFindBlockForTime(t *testing.T) { + wallet, node, shutdown, _ := tNewWallet(true, walletTypeSPV) + defer shutdown() + spv := wallet.node.(*spvWallet) + + const tipHeight = 40 + + for i := 0; i <= tipHeight; i++ { + node.addRawTx(int64(i), dummyTx()) + } + + const searchBlock = 35 + matchTime := generateTestBlockTime(searchBlock) + const offsetBlock = searchBlock - testBlocksPerBlockTimeOffset + const startBlock = offsetBlock - medianTimeBlocks + _, height, err := spv.findBlockForTime(matchTime) + if err != nil { + t.Fatalf("findBlockForTime error: %v", err) + } + if height != startBlock { + t.Fatalf("wrong height. wanted %d, got %d", startBlock, height) + } + + // But if we shift the startBlock time to > offsetBlock time, the window + // will continue down 11 more. + _, blk := node.getBlockAtHeight(startBlock) + blk.msgBlock.Header.Timestamp = generateTestBlockTime(offsetBlock) + _, height, err = spv.findBlockForTime(matchTime) + if err != nil { + t.Fatalf("findBlockForTime error for shifted start block: %v", err) + } + if height != startBlock-medianTimeBlocks { + t.Fatalf("wrong height. wanted %d, got %d", startBlock-11, height) + } + + // And doing an early enough block just returns genesis + blockHash, height, err := spv.findBlockForTime(generateTestBlockTime(10)) + if err != nil { + t.Fatalf("findBlockForTime error for genesis test: %v", err) + } + if *blockHash != *chaincfg.MainNetParams.GenesisHash { + t.Fatalf("not genesis: height = %d", height) + } + + // A time way in the future still returns at least the last 11 blocks. + _, height, err = spv.findBlockForTime(generateTestBlockTime(100)) + if err != nil { + t.Fatalf("findBlockForTime error for future test: %v", err) + } + // +1 because tip block is included here, as opposed to the shifted start + // block, where the shifted block wasn't included. + if height != tipHeight-medianTimeBlocks+1 { + t.Fatalf("didn't get tip - 11. wanted %d, got %d", tipHeight-medianTimeBlocks, height) + } +} + +func TestGetTxOut(t *testing.T) { + wallet, node, shutdown, _ := tNewWallet(true, walletTypeSPV) + defer shutdown() + spv := wallet.node.(*spvWallet) + + _, _, pkScript, _, _, _, _ := makeSwapContract(true, time.Hour*12) + const vout = 0 + const blockHeight = 10 + const tipHeight = 20 + tx := makeRawTx([]dex.Bytes{pkScript}, []*wire.TxIn{dummyInput()}) + txHash := tx.TxHash() + outPt := newOutPoint(&txHash, vout) + blockHash, _ := node.addRawTx(blockHeight, tx) + txB, _ := serializeMsgTx(tx) + node.addRawTx(tipHeight, dummyTx()) + spendingTx := dummyTx() + spendingTx.TxIn[0].PreviousOutPoint.Hash = txHash + spendBlockHash, _ := node.addRawTx(tipHeight-1, spendingTx) + + // Prime the db + for h := int64(1); h <= tipHeight; h++ { + node.addRawTx(h, dummyTx()) + } + + // Abnormal error + node.getTransactionErr = tErr + _, _, err := spv.getTxOut(&txHash, vout, pkScript, generateTestBlockTime(blockHeight)) + if err == nil { + t.Fatalf("no error for getWalletTransaction error") + } + + // Wallet transaction found + node.getTransactionErr = nil + node.getTransaction = &GetTransactionResult{ + BlockHash: blockHash.String(), + Hex: txB, + } + + _, confs, err := spv.getTxOut(&txHash, vout, pkScript, generateTestBlockTime(blockHeight)) + if err != nil { + t.Fatalf("error for wallet transaction found: %v", err) + } + if confs != tipHeight-blockHeight+1 { + t.Fatalf("wrong confs for wallet transaction. wanted %d, got %d", tipHeight-blockHeight+1, confs) + } + + // No wallet transaction, but we have a spend recorded. + node.getTransactionErr = WalletTransactionNotFound + node.getTransaction = nil + node.checkpoints[outPt] = &scanCheckpoint{res: &filterScanResult{ + blockHash: blockHash, + spend: &spendingInput{}, + checkpoint: *spendBlockHash, + }} + op, confs, err := spv.getTxOut(&txHash, vout, pkScript, generateTestBlockTime(blockHeight)) + if op != nil || confs != 0 || err != nil { + t.Fatal("wrong result for spent txout", op != nil, confs, err) + } + delete(node.checkpoints, outPt) + + // no spend record. gotta scan + + // case 1: we have a block hash in the database + node.dbBlockForTx[txHash] = &hashEntry{hash: *blockHash} + node.getCFilterScripts[*blockHash] = [][]byte{pkScript} + _, _, err = spv.getTxOut(&txHash, vout, pkScript, generateTestBlockTime(blockHeight)) + if err != nil { + t.Fatalf("error for GetUtxo with cached hash: %v", err) + } + + // case 2: no block hash in db. Will do scan and store them. + delete(node.dbBlockForTx, txHash) + delete(node.checkpoints, outPt) + _, _, err = spv.getTxOut(&txHash, vout, pkScript, generateTestBlockTime(blockHeight)) + if err != nil { + t.Fatalf("error for GetUtxo with no cached hash: %v", err) + } + if _, inserted := node.dbBlockForTx[txHash]; !inserted { + t.Fatalf("db not updated after GetUtxo scan success") + } + + // case 3: spending tx found first + delete(node.checkpoints, outPt) + node.getCFilterScripts[*spendBlockHash] = [][]byte{pkScript} + txOut, _, err := spv.getTxOut(&txHash, vout, pkScript, generateTestBlockTime(blockHeight)) + if err != nil { + t.Fatalf("error for spent tx: %v", err) + } + if txOut != nil { + t.Fatalf("spend output returned from getTxOut") + } + + // Make sure we can find it with the checkpoint. + node.checkpoints[outPt].res.spend = nil + node.getCFilterScripts[*spendBlockHash] = nil + // We won't actually scan for the output itself, so nil'ing these should + // have no effect. + node.getCFilterScripts[*blockHash] = nil + _, _, err = spv.getTxOut(&txHash, vout, pkScript, generateTestBlockTime(blockHeight)) + if err != nil { + t.Fatalf("error for checkpointed output: %v", err) + } +} + +func TestSendWithSubtract(t *testing.T) { + wallet, node, shutdown, _ := tNewWallet(true, walletTypeSPV) + defer shutdown() + spv := wallet.node.(*spvWallet) + + const availableFunds = 5e8 + const feeRate = 100 + const inputSize = dexbtc.RedeemP2WPKHInputSize + ((dexbtc.RedeemP2WPKHInputWitnessWeight + 2 + 3) / 4) + const feesWithChange = (dexbtc.MinimumTxOverhead + 2*dexbtc.P2WPKHOutputSize + inputSize) * feeRate + const feesWithoutChange = (dexbtc.MinimumTxOverhead + dexbtc.P2WPKHOutputSize + inputSize) * feeRate + + addr, _ := btcutil.DecodeAddress(tP2WPKHAddr, &chaincfg.MainNetParams) + pkScript, _ := txscript.PayToAddrScript(addr) + + node.changeAddr = tP2WPKHAddr + node.signFunc = func(tx *wire.MsgTx) { + signFunc(tx, 0, true) + } + node.listUnspent = []*ListUnspentResult{{ + TxID: tTxID, + Address: tP2WPKHAddr, + Confirmations: 5, + ScriptPubKey: pkScript, + Spendable: true, + Solvable: true, + Safe: true, + Amount: float64(availableFunds) / 1e8, + }} + + test := func(req, expVal int64, expChange bool) { + t.Helper() + _, err := spv.sendWithSubtract(pkScript, uint64(req), feeRate) + if err != nil { + t.Fatalf("half withdraw error: %v", err) + } + opCount := len(node.sentRawTx.TxOut) + if (opCount == 1 && expChange) || (opCount == 2 && !expChange) { + t.Fatalf("%d outputs when expChange = %t", opCount, expChange) + } + received := node.sentRawTx.TxOut[opCount-1].Value + if received != expVal { + t.Fatalf("wrong value received. expected %d, got %d", expVal, received) + } + } + + // No change + var req int64 = availableFunds / 2 + test(req, req-feesWithChange, true) + + // Drain it + test(availableFunds, availableFunds-feesWithoutChange, false) + + // Requesting just a little less shouldn't result in a reduction of the + // amount received, since the change would be dust. + test(availableFunds-10, availableFunds-feesWithoutChange, false) + + // Requesting too + + // listUnspent error + node.listUnspentErr = tErr + _, err := spv.sendWithSubtract(pkScript, availableFunds/2, feeRate) + if err == nil { + t.Fatalf("test passed with listUnspent error") + } + node.listUnspentErr = nil + + node.changeAddrErr = tErr + _, err = spv.sendWithSubtract(pkScript, availableFunds/2, feeRate) + if err == nil { + t.Fatalf("test passed with NewChangeAddress error") + } + node.changeAddrErr = nil + + node.signTxErr = tErr + _, err = spv.sendWithSubtract(pkScript, availableFunds/2, feeRate) + if err == nil { + t.Fatalf("test passed with SignTransaction error") + } + node.signTxErr = nil + + // outrageous fees + _, err = spv.sendWithSubtract(pkScript, availableFunds/2, 1e8) + if err == nil { + t.Fatalf("test passed with fees > available error") + } +} diff --git a/client/asset/btc/wallet.go b/client/asset/btc/wallet.go index 0f760c7477..fe7ccacda0 100644 --- a/client/asset/btc/wallet.go +++ b/client/asset/btc/wallet.go @@ -2,6 +2,7 @@ package btc import ( "context" + "sync" "time" "github.com/btcsuite/btcd/btcec" @@ -13,7 +14,7 @@ import ( type Wallet interface { RawRequester - connect(ctx context.Context) error + connect(ctx context.Context, wg *sync.WaitGroup) error estimateSmartFee(confTarget int64, mode *btcjson.EstimateSmartFeeMode) (*btcjson.EstimateSmartFeeResult, error) sendRawTransaction(tx *wire.MsgTx) (*chainhash.Hash, error) getTxOut(txHash *chainhash.Hash, index uint32, pkScript []byte, startTime time.Time) (*wire.TxOut, uint32, error) @@ -30,7 +31,7 @@ type Wallet interface { addressWPKH() (btcutil.Address, error) signTx(inTx *wire.MsgTx) (*wire.MsgTx, error) privKeyForAddress(addr string) (*btcec.PrivateKey, error) - walletUnlock(pass string) error + walletUnlock(pw []byte) error walletLock() error sendToAddress(address string, value, feeRate uint64, subtract bool) (*chainhash.Hash, error) locked() bool diff --git a/client/asset/dcr/config.go b/client/asset/dcr/config.go index b4f1e20117..60659ee8da 100644 --- a/client/asset/dcr/config.go +++ b/client/asset/dcr/config.go @@ -31,6 +31,7 @@ var ( // Config holds the parameters needed to initialize an RPC connection to a dcr // wallet. Default values are used for RPCListen and/or RPCCert if not set. type Config struct { + Account string `ini:"account"` RPCUser string `ini:"username"` RPCPass string `ini:"password"` RPCListen string `ini:"rpclisten"` diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index b695e71179..c864d76823 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -69,14 +69,13 @@ const ( methodListUnspent = "listunspent" methodListLockUnspent = "listlockunspent" methodSignRawTransaction = "signrawtransaction" + walletTypeDcrwRPC = "dcrwalletRPC" ) var ( requiredWalletVersion = dex.Semver{Major: 8, Minor: 5, Patch: 0} requiredNodeVersion = dex.Semver{Major: 7, Minor: 0, Patch: 0} -) -var ( // blockTicker is the delay between calls to check for new blocks. blockTicker = time.Second conventionalConversionFactor = float64(dexdcr.UnitInfo.Conventional.ConversionFactor) @@ -148,11 +147,16 @@ var ( } // WalletInfo defines some general information about a Decred wallet. WalletInfo = &asset.WalletInfo{ - Name: "Decred", - Version: version, - DefaultConfigPath: defaultConfigPath, - ConfigOpts: configOpts, - UnitInfo: dexdcr.UnitInfo, + Name: "Decred", + Version: version, + UnitInfo: dexdcr.UnitInfo, + AvailableWallets: []*asset.WalletDefinition{{ + Type: walletTypeDcrwRPC, + Tab: "External", + Description: "Connect to dcrwallet", + DefaultConfigPath: defaultConfigPath, + ConfigOpts: configOpts, + }}, } ) @@ -378,15 +382,14 @@ type fundingCoin struct { // Driver implements asset.Driver. type Driver struct{} -// Open opens the DCR exchange wallet. Start the wallet with its Run method. +// Check that Driver implements asset.Driver. +var _ asset.Driver = (*Driver)(nil) + +// Open creates the DCR exchange wallet. Start the wallet with its Run method. func (d *Driver) Open(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) { return NewWallet(cfg, logger, network) } -func (d *Driver) Create(*asset.CreateWalletParams) error { - return fmt.Errorf("no creatable wallet types") -} - // DecodeCoinID creates a human-readable representation of a coin ID for Decred. func (d *Driver) DecodeCoinID(coinID []byte) (string, error) { txid, vout, err := decodeCoinID(coinID) @@ -479,8 +482,7 @@ func (cc *combinedClient) ValidateAddress(ctx context.Context, address stdaddr.A var _ asset.Wallet = (*ExchangeWallet)(nil) // NewWallet is the exported constructor by which the DEX will import the -// exchange wallet. The wallet will shut down when the provided context is -// canceled. +// exchange wallet. func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (*ExchangeWallet, error) { // loadConfig will set fields if defaults are used and set the chainParams // package variable. @@ -539,7 +541,7 @@ func unconnectedWallet(cfg *asset.WalletConfig, dcrCfg *Config, chainParams *cha return &ExchangeWallet{ log: logger, chainParams: chainParams, - acct: cfg.Settings["account"], + acct: dcrCfg.Account, tipChange: cfg.TipChange, fundingCoins: make(map[outPoint]*fundingCoin), findRedemptionQueue: make(map[outPoint]*findRedemptionReq), @@ -1436,7 +1438,7 @@ func (dcr *ExchangeWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin txHash := msgTx.TxHash() for i, contract := range swaps.Contracts { output := newOutput(&txHash, uint32(i), contract.Value, wire.TxTreeRegular) - signedRefundTx, err := dcr.refundTx(output.ID(), contracts[i], contract.Value, refundAddrs[i]) + signedRefundTx, err := dcr.refundTx(output.ID(), contracts[i], contract.Value, refundAddrs[i], swaps.FeeRate) if err != nil { return nil, nil, 0, fmt.Errorf("error creating refund tx: %w", err) } @@ -1641,22 +1643,38 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t return nil, fmt.Errorf("error extracting swap addresses: %w", err) } // Get the contracts P2SH address from the tx output's pubkey script. - txOut, txTree, err := dcr.getTxOut(txHash, vout, true) + txOutRes, txTree, err := dcr.getTxOut(txHash, vout, true) if err != nil { return nil, fmt.Errorf("error finding unspent contract: %w", translateRPCCancelErr(err)) } - if txOut == nil { - return nil, asset.CoinNotFoundError - } - - pkScript, err := hex.DecodeString(txOut.ScriptPubKey.Hex) - if err != nil { - return nil, fmt.Errorf("error decoding pubkey script from hex '%s': %w", - txOut.ScriptPubKey.Hex, err) + coinNotFound := txOutRes == nil + var pkScript []byte + var value uint64 + var version uint16 + if txOutRes != nil { + pkScript, err = hex.DecodeString(txOutRes.ScriptPubKey.Hex) + if err != nil { + return nil, fmt.Errorf("error decoding pubkey script from hex '%s': %w", + txOutRes.ScriptPubKey.Hex, err) + } + value = toAtoms(txOutRes.Value) + version = txOutRes.ScriptPubKey.Version + } else { + tx, err := msgTxFromBytes(txData) + if err != nil { + return nil, fmt.Errorf("coin not found, and error encountered decoding tx data: %v", err) + } + if len(tx.TxOut) <= int(vout) { + return nil, fmt.Errorf("specified output %d not found in decoded tx %s", vout, txHash) + } + txOut := tx.TxOut[vout] + pkScript = txOut.PkScript + value = uint64(txOut.Value) + version = txOut.Version } // Check for standard P2SH. - scriptClass, addrs, numReq, err := txscript.ExtractPkScriptAddrs(txOut.ScriptPubKey.Version, + scriptClass, addrs, numReq, err := txscript.ExtractPkScriptAddrs(version, pkScript, dcr.chainParams, false) if err != nil { return nil, fmt.Errorf("error extracting script addresses from '%x': %w", pkScript, err) @@ -1681,8 +1699,11 @@ func (dcr *ExchangeWallet) AuditContract(coinID, contract, txData dex.Bytes, _ t return nil, fmt.Errorf("contract hash doesn't match script address. %x != %x", contractHash, addrScript) } + if coinNotFound { + return nil, asset.CoinNotFoundError + } return &asset.AuditInfo{ - Coin: newOutput(txHash, vout, toAtoms(txOut.Value), txTree), + Coin: newOutput(txHash, vout, value, txTree), Contract: contract, SecretHash: secretHash, Recipient: receiver.String(), @@ -2130,8 +2151,8 @@ func (dcr *ExchangeWallet) blockMaybeContainsScripts(blockHash string, scripts [ // wallet does not store it, even though it was known when the init transaction // was created. The client should store this information for persistence across // sessions. -func (dcr *ExchangeWallet) Refund(coinID, contract dex.Bytes) (dex.Bytes, error) { - msgTx, err := dcr.refundTx(coinID, contract, 0, nil) +func (dcr *ExchangeWallet) Refund(coinID, contract dex.Bytes, feeSuggestion uint64) (dex.Bytes, error) { + msgTx, err := dcr.refundTx(coinID, contract, 0, nil, feeSuggestion) if err != nil { return nil, fmt.Errorf("error creating refund tx: %w", err) } @@ -2151,7 +2172,7 @@ func (dcr *ExchangeWallet) Refund(coinID, contract dex.Bytes) (dex.Bytes, error) // refundTx crates and signs a contract`s refund transaction. If refundAddr is // not supplied, one will be requested from the wallet. If val is not supplied // it will be retrieved with gettxout. -func (dcr *ExchangeWallet) refundTx(coinID, contract dex.Bytes, val uint64, refundAddr stdaddr.Address) (*wire.MsgTx, error) { +func (dcr *ExchangeWallet) refundTx(coinID, contract dex.Bytes, val uint64, refundAddr stdaddr.Address, feeSuggestion uint64) (*wire.MsgTx, error) { txHash, vout, err := decodeCoinID(coinID) if err != nil { return nil, err @@ -2173,7 +2194,7 @@ func (dcr *ExchangeWallet) refundTx(coinID, contract dex.Bytes, val uint64, refu } // Create the transaction that spends the contract. - feeRate := dcr.feeRateWithFallback(2, 0) + feeRate := dcr.feeRateWithFallback(2, feeSuggestion) msgTx := wire.NewMsgTx() msgTx.LockTime = uint32(lockTime) prevOut := wire.NewOutPoint(txHash, vout, wire.TxTreeRegular) @@ -2237,20 +2258,20 @@ func (dcr *ExchangeWallet) accountUnlocked(ctx context.Context, acct string) (en } // Unlock unlocks the exchange wallet. -func (dcr *ExchangeWallet) Unlock(pw string) error { +func (dcr *ExchangeWallet) Unlock(pw []byte) error { encryptedAcct, unlocked, err := dcr.accountUnlocked(dcr.ctx, dcr.acct) if err != nil { return err } if !encryptedAcct { - return translateRPCCancelErr(dcr.node.WalletPassphrase(dcr.ctx, pw, 0)) + return translateRPCCancelErr(dcr.node.WalletPassphrase(dcr.ctx, string(pw), 0)) } if unlocked { return nil } - return translateRPCCancelErr(dcr.node.UnlockAccount(dcr.ctx, dcr.acct, pw)) + return translateRPCCancelErr(dcr.node.UnlockAccount(dcr.ctx, dcr.acct, string(pw))) } // Lock locks the exchange wallet. @@ -2308,14 +2329,14 @@ func (dcr *ExchangeWallet) Locked() bool { // PayFee sends the dex registration fee. Transaction fees are in addition to // the registration fee, and the fee rate is taken from the DEX configuration. -func (dcr *ExchangeWallet) PayFee(address string, regFee uint64) (asset.Coin, error) { +func (dcr *ExchangeWallet) PayFee(address string, regFee, feeRateSuggestion uint64) (asset.Coin, error) { addr, err := stdaddr.DecodeAddress(address, dcr.chainParams) if err != nil { return nil, err } // TODO: Evaluate SendToAddress and how it deals with the change output // address index to see if it can be used here instead. - msgTx, sent, err := dcr.sendRegFee(addr, regFee, dcr.feeRateWithFallback(1, 0)) + msgTx, sent, err := dcr.sendRegFee(addr, regFee, dcr.feeRateWithFallback(1, feeRateSuggestion)) if err != nil { return nil, err } @@ -2328,12 +2349,12 @@ func (dcr *ExchangeWallet) PayFee(address string, regFee uint64) (asset.Coin, er // Withdraw withdraws funds to the specified address. Fees are subtracted from // the value. -func (dcr *ExchangeWallet) Withdraw(address string, value uint64) (asset.Coin, error) { +func (dcr *ExchangeWallet) Withdraw(address string, value, feeSuggestion uint64) (asset.Coin, error) { addr, err := stdaddr.DecodeAddress(address, dcr.chainParams) if err != nil { return nil, err } - msgTx, net, err := dcr.sendMinusFees(addr, value, dcr.feeRateWithFallback(2, 0)) + msgTx, net, err := dcr.sendMinusFees(addr, value, dcr.feeRateWithFallback(2, feeSuggestion)) if err != nil { return nil, err } @@ -2608,6 +2629,15 @@ func msgTxFromHex(txHex string) (*wire.MsgTx, error) { return msgTx, nil } +// msgTxFromBytes creates a wire.MsgTx by deserializing the transaction bytes. +func msgTxFromBytes(txB []byte) (*wire.MsgTx, error) { + msgTx := wire.NewMsgTx() + if err := msgTx.Deserialize(bytes.NewReader(txB)); err != nil { + return nil, err + } + return msgTx, nil +} + func msgTxToHex(msgTx *wire.MsgTx) (string, error) { b, err := msgTx.Bytes() if err != nil { diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index 523155be7f..f6c4745c77 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -151,13 +151,10 @@ func newTxOutResult(script []byte, value uint64, confs int64) *chainjson.GetTxOu func tNewWallet() (*ExchangeWallet, *tRPCClient, func(), error) { client := newTRPCClient() walletCfg := &asset.WalletConfig{ - Settings: map[string]string{ - "account": "default", - }, TipChange: func(error) {}, } walletCtx, shutdown := context.WithCancel(tCtx) - wallet, err := unconnectedWallet(walletCfg, &Config{}, tChainParams, tLogger) + wallet, err := unconnectedWallet(walletCfg, &Config{Account: "default"}, tChainParams, tLogger) if err != nil { shutdown() return nil, nil, nil, err @@ -1884,6 +1881,7 @@ func TestRefund(t *testing.T) { if err != nil { t.Fatalf("error making swap contract: %v", err) } + const feeSuggestion = 100 bigTxOut := makeGetTxOutRes(2, 5, nil) bigOutID := newOutPoint(tTxHash, 0) @@ -1898,7 +1896,7 @@ func TestRefund(t *testing.T) { } contractOutput := newOutput(tTxHash, 0, 1e8, wire.TxTreeRegular) - _, err = wallet.Refund(contractOutput.ID(), contract) + _, err = wallet.Refund(contractOutput.ID(), contract, feeSuggestion) if err != nil { t.Fatalf("refund error: %v", err) } @@ -1907,14 +1905,14 @@ func TestRefund(t *testing.T) { badReceipt := &tReceipt{ coin: &tCoin{id: make([]byte, 15)}, } - _, err = wallet.Refund(badReceipt.coin.id, badReceipt.contract) + _, err = wallet.Refund(badReceipt.coin.id, badReceipt.contract, feeSuggestion) if err == nil { t.Fatalf("no error for bad receipt") } // gettxout error node.txOutErr = tErr - _, err = wallet.Refund(contractOutput.ID(), contract) + _, err = wallet.Refund(contractOutput.ID(), contract, feeSuggestion) if err == nil { t.Fatalf("no error for missing utxo") } @@ -1922,14 +1920,14 @@ func TestRefund(t *testing.T) { // bad contract badContractOutput := newOutput(tTxHash, 0, 1e8, wire.TxTreeRegular) - _, err = wallet.Refund(badContractOutput.ID(), randBytes(50)) + _, err = wallet.Refund(badContractOutput.ID(), randBytes(50), feeSuggestion) if err == nil { t.Fatalf("no error for bad contract") } // Too small. node.txOutRes[bigOutID] = newTxOutResult(nil, 100, 2) - _, err = wallet.Refund(contractOutput.ID(), contract) + _, err = wallet.Refund(contractOutput.ID(), contract, feeSuggestion) if err == nil { t.Fatalf("no error for value < fees") } @@ -1937,7 +1935,7 @@ func TestRefund(t *testing.T) { // signature error node.privWIFErr = tErr - _, err = wallet.Refund(contractOutput.ID(), contract) + _, err = wallet.Refund(contractOutput.ID(), contract, feeSuggestion) if err == nil { t.Fatalf("no error for dumpprivkey rpc error") } @@ -1945,7 +1943,7 @@ func TestRefund(t *testing.T) { // send error node.sendRawErr = tErr - _, err = wallet.Refund(contractOutput.ID(), contract) + _, err = wallet.Refund(contractOutput.ID(), contract, feeSuggestion) if err == nil { t.Fatalf("no error for sendrawtransaction rpc error") } @@ -1955,14 +1953,14 @@ func TestRefund(t *testing.T) { var badHash chainhash.Hash badHash[0] = 0x05 node.sendRawHash = &badHash - _, err = wallet.Refund(contractOutput.ID(), contract) + _, err = wallet.Refund(contractOutput.ID(), contract, feeSuggestion) if err == nil { t.Fatalf("no error for tx hash") } node.sendRawHash = nil // Sanity check that we can succeed again. - _, err = wallet.Refund(contractOutput.ID(), contract) + _, err = wallet.Refund(contractOutput.ID(), contract, feeSuggestion) if err != nil { t.Fatalf("re-refund error: %v", err) } @@ -1985,14 +1983,15 @@ func testSender(t *testing.T, senderType tSenderType) { var unspentVal uint64 = 100e8 funName := "PayFee" sender := func(addr string, val uint64) (asset.Coin, error) { - return wallet.PayFee(addr, val) + return wallet.PayFee(addr, val, defaultFee) } if senderType == tWithdrawSender { + const feeSuggestion = 100 funName = "Withdraw" // For withdraw, test with unspent total = withdraw value unspentVal = sendVal sender = func(addr string, val uint64) (asset.Coin, error) { - return wallet.Withdraw(addr, val) + return wallet.Withdraw(addr, val, feeSuggestion) } } addr := tPKHAddr.String() diff --git a/client/asset/dcr/simnet_test.go b/client/asset/dcr/simnet_test.go index 10c3c83958..cb178bda92 100644 --- a/client/asset/dcr/simnet_test.go +++ b/client/asset/dcr/simnet_test.go @@ -32,9 +32,8 @@ import ( ) const ( - walletPassword = "abc" - alphaAddress = "SsWKp7wtdTZYabYFYSc9cnxhwFEjA5g4pFc" - betaAddress = "Ssge52jCzbixgFC736RSTrwAnvH3a4hcPRX" + alphaAddress = "SsWKp7wtdTZYabYFYSc9cnxhwFEjA5g4pFc" + betaAddress = "Ssge52jCzbixgFC736RSTrwAnvH3a4hcPRX" ) var ( @@ -51,6 +50,7 @@ var ( MaxFeeRate: 10, SwapConf: 1, } + walletPassword = []byte("abc") ) func mineAlpha() error { @@ -399,20 +399,20 @@ func runTest(t *testing.T, splitTx bool) { swapReceipt = receipts[0] waitNetwork() - _, err = rig.beta().Refund(swapReceipt.Coin().ID(), swapReceipt.Contract()) + _, err = rig.beta().Refund(swapReceipt.Coin().ID(), swapReceipt.Contract(), tDCR.MaxFeeRate/4) if err != nil { t.Fatalf("refund error: %v", err) } // Test PayFee - coin, err := rig.beta().PayFee(alphaAddress, 1e8) + coin, err := rig.beta().PayFee(alphaAddress, 1e8, defaultFee) if err != nil { t.Fatalf("error paying fees: %v", err) } tLogger.Infof("fee paid with tx %s", coin.String()) // Test Withdraw - coin, err = rig.beta().Withdraw(alphaAddress, 5e7) + coin, err = rig.beta().Withdraw(alphaAddress, 5e7, tDCR.MaxFeeRate/4) if err != nil { t.Fatalf("error withdrawing: %v", err) } diff --git a/client/asset/driver.go b/client/asset/driver.go index 38dd167de7..734341785e 100644 --- a/client/asset/driver.go +++ b/client/asset/driver.go @@ -17,23 +17,32 @@ var ( // CreateWalletParams are the parameters for internal wallet creation. The // Settings provided should be the same wallet configuration settings passed to -// Open. +// OpenWallet. type CreateWalletParams struct { + Type string Seed []byte Pass []byte Settings map[string]string DataDir string Net dex.Network + Logger dex.Logger } // Driver is the interface required of all exchange wallets. type Driver interface { - Create(*CreateWalletParams) error Open(*WalletConfig, dex.Logger, dex.Network) (Wallet, error) DecodeCoinID(coinID []byte) (string, error) Info() *WalletInfo } +// Creator defines methods for Drivers that will be called to initialize seeded +// wallets during CreateWallet. Only assets that provide seeded wallets need to +// implement Creator. +type Creator interface { + Exists(walletType, dataDir string, settings map[string]string, net dex.Network) (bool, error) + Create(*CreateWalletParams) error +} + func withDriver(assetID uint32, f func(Driver) error) error { driversMtx.Lock() defer driversMtx.Unlock() @@ -61,11 +70,26 @@ func Register(assetID uint32, driver Driver) { drivers[assetID] = driver } -// CreateWallet creates a new wallet. This method should only be used once to create a -// seeded wallet, after which OpenWallet should be used to load and access the wallet. +// WalletExists will be true if the specified wallet exists. +func WalletExists(assetID uint32, walletType, dataDir string, settings map[string]string, net dex.Network) (exists bool, err error) { + return exists, withDriver(assetID, func(drv Driver) error { + creator, is := drv.(Creator) + if !is { + return fmt.Errorf("driver has no Exists method") + } + exists, err = creator.Exists(walletType, dataDir, settings, net) + return err + }) +} + +// CreateWallet creates a new wallet. Only use Create for seeded wallet types. func CreateWallet(assetID uint32, seedParams *CreateWalletParams) error { return withDriver(assetID, func(drv Driver) error { - return drv.Create(seedParams) + creator, is := drv.(Creator) + if !is { + return fmt.Errorf("driver has no Create method") + } + return creator.Create(seedParams) }) } diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index 7cb99ae8cd..e749fe2f0a 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -77,24 +77,33 @@ var ( } // WalletInfo defines some general information about a Ethereum wallet. WalletInfo = &asset.WalletInfo{ - Name: "Ethereum", - DefaultConfigPath: defaultAppDir, // Incorrect if changed by user? - ConfigOpts: configOpts, - UnitInfo: dexeth.UnitInfo, - Seeded: true, + Name: "Ethereum", + UnitInfo: dexeth.UnitInfo, + AvailableWallets: []*asset.WalletDefinition{ + { + Type: "geth", + Tab: "Internal", + Description: "Use the built-in DEX wallet with snap sync", + ConfigOpts: configOpts, + Seeded: true, + DefaultConfigPath: defaultAppDir, // Incorrect if changed by user? + }, + }, } + mainnetContractAddr = common.HexToAddress("") ) // Check that Driver implements asset.Driver. var _ asset.Driver = (*Driver)(nil) +var _ asset.Creator = (*Driver)(nil) // Driver implements asset.Driver. type Driver struct{} -func (d *Driver) Create(params *asset.CreateWalletParams) error { - return CreateWallet(params) -} +// Check that Driver implements Driver and Creator. +var _ asset.Driver = (*Driver)(nil) +var _ asset.Creator = (*Driver)(nil) // Open opens the ETH exchange wallet. Start the wallet with its Run method. func (d *Driver) Open(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) { @@ -115,6 +124,15 @@ func (d *Driver) Info() *asset.WalletInfo { return WalletInfo } +// Exists checks the existence of the wallet. +func (d *Driver) Exists(walletType, dataDir string, settings map[string]string, net dex.Network) (bool, error) { + return false, errors.New("unimplemented") +} + +func (d *Driver) Create(params *asset.CreateWalletParams) error { + return CreateWallet(params) +} + // rawWallet is an unexported return type from the eth client. Watch for changes at // https://github.com/ethereum/go-ethereum/blob/c503f98f6d5e80e079c1d8a3601d188af2a899da/internal/ethapi/api.go#L227-L253 type rawWallet struct { @@ -710,7 +728,7 @@ func (*ExchangeWallet) FindRedemption(ctx context.Context, coinID dex.Bytes) (re // Refund refunds a contract. This can only be used after the time lock has // expired. -func (*ExchangeWallet) Refund(coinID, contract dex.Bytes) (dex.Bytes, error) { +func (*ExchangeWallet) Refund(coinID, contract dex.Bytes, feeSuggestion uint64) (dex.Bytes, error) { return nil, asset.ErrNotImplemented } @@ -720,8 +738,8 @@ func (eth *ExchangeWallet) Address() (string, error) { } // Unlock unlocks the exchange wallet. -func (eth *ExchangeWallet) Unlock(pw string) error { - return eth.node.unlock(eth.ctx, pw, eth.acct) +func (eth *ExchangeWallet) Unlock(pw []byte) error { + return eth.node.unlock(eth.ctx, string(pw), eth.acct) } // Lock locks the exchange wallet. @@ -759,7 +777,7 @@ func (eth *ExchangeWallet) Locked() bool { // the registration fee, and the fee rate is taken from the DEX configuration. // // NOTE: PayFee is not intended to be used with Ethereum at this time. -func (*ExchangeWallet) PayFee(address string, regFee uint64) (asset.Coin, error) { +func (*ExchangeWallet) PayFee(address string, regFee, feeRateSuggestion uint64) (asset.Coin, error) { return nil, asset.ErrNotImplemented } @@ -782,7 +800,7 @@ func (eth *ExchangeWallet) sendToAddr(addr common.Address, amt, gasPrice *big.In // // TODO: Return the asset.Coin. // TODO: Subtract fees from the value. -func (eth *ExchangeWallet) Withdraw(addr string, value uint64) (asset.Coin, error) { +func (eth *ExchangeWallet) Withdraw(addr string, value, feeSuggestion uint64) (asset.Coin, error) { bigVal := big.NewInt(0).SetUint64(value) gweiFactorBig := big.NewInt(srveth.GweiFactor) _, err := eth.sendToAddr(common.HexToAddress(addr), diff --git a/client/asset/interface.go b/client/asset/interface.go index 1f003e8723..d9f2682fe5 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -22,6 +22,28 @@ const ( ErrUnsupported = dex.ErrorKind("unsupported") ) +type WalletDefinition struct { + // If seeded is true, the Create method will be called with a deterministic + // seed that should be used to set the wallet key(s). This would be + // true for built-in wallets. + Seeded bool `json:"seeded"` + // Type is a string identifying the wallet type. + Type string `json:"type"` + // Tab is a displayable string for the wallet type. One or two words. First + // word capitalized. Displayed on a wallet selection tab. + Tab string `json:"tab"` + // Description is a short description of the wallet, suitable for a tooltip. + Description string `json:"description"` + // DefaultConfigPath is the default file path that the Wallet uses for its + // configuration file. Probably only useful for unseeded / external wallets. + DefaultConfigPath string `json:"configpath"` + // ConfigOpts is a slice of expected Wallet config options, with the display + // name, config key (for parsing the option from a config file/text) and + // description for each option. This can be used to request config info from + // users e.g. via dynamically generated GUI forms. + ConfigOpts []*ConfigOption `json:"configopts"` +} + // WalletInfo is auxiliary information about an ExchangeWallet. type WalletInfo struct { // Name is the display name for the currency, e.g. "Decred" @@ -30,14 +52,15 @@ type WalletInfo struct { // major changes are made to internal details such as coin ID encoding and // contract structure that must be common to a server's. Version uint32 - // DefaultConfigPath is the default file path that the Wallet uses for its - // configuration file. - DefaultConfigPath string `json:"configpath"` - // ConfigOpts is a slice of expected Wallet config options, with the display - // name, config key (for parsing the option from a config file/text) and - // description for each option. This can be used to request config info from - // users e.g. via dynamically generated GUI forms. - ConfigOpts []*ConfigOption `json:"configopts"` + // AvailableWallets is an ordered list of available WalletDefinition. The + // first WalletDefinition is considered the default, and might, for instance + // be the initial form offered to the user for configuration, with others + // available to select. + AvailableWallets []*WalletDefinition `json:"availablewallets"` + // LegacyWalletIndex should be set for assets that existed before wallets + // were typed. The index should point to the WalletDefinition that should + // be assumed when the type is provided as an empty string. + LegacyWalletIndex int `json:"emptyidx"` // UnitInfo is the information about unit names and conversion factors for // the asset. UnitInfo dex.UnitInfo `json:"unitinfo"` @@ -59,6 +82,9 @@ type ConfigOption struct { // WalletConfig is the configuration settings for the wallet. WalletConfig // is passed to the wallet constructor. type WalletConfig struct { + // Type is the type of wallet, corresponding to the Type field of an + // available WalletDefinition. + Type string // Settings is the key-value store of wallet connection parameters. The // Settings are supplied by the user according the the WalletInfo's // ConfigOpts. @@ -168,20 +194,20 @@ type Wallet interface { // as the wallet does not store it, even though it was known when the init // transaction was created. The client should store this information for // persistence across sessions. - Refund(coinID, contract dex.Bytes) (dex.Bytes, error) + Refund(coinID, contract dex.Bytes, feeSuggestion uint64) (dex.Bytes, error) // Address returns an address for the exchange wallet. Address() (string, error) // OwnsAddress indicates if an address belongs to the wallet. OwnsAddress(address string) (bool, error) // Unlock unlocks the exchange wallet. - Unlock(pw string) error + Unlock(pw []byte) error // Lock locks the exchange wallet. Lock() error // Locked will be true if the wallet is currently locked. Locked() bool // PayFee sends the dex registration fee. Transaction fees are in addition to // the registration fee, and the fee rate is taken from the DEX configuration. - PayFee(address string, feeAmt uint64) (Coin, error) + PayFee(address string, regFee, feeRateSuggestion uint64) (Coin, error) // SwapConfirmations gets the number of confirmations and the spend status // for the specified swap. If the swap was not funded by this wallet, and // it is already spent, you may see CoinNotFoundError. @@ -193,7 +219,7 @@ type Wallet interface { SwapConfirmations(ctx context.Context, coinID dex.Bytes, contract dex.Bytes, matchTime time.Time) (confs uint32, spent bool, err error) // Withdraw withdraws funds to the specified address. Fees are subtracted // from the value. - Withdraw(address string, value uint64) (Coin, error) + Withdraw(address string, value, feeSuggestion uint64) (Coin, error) // ValidateSecret checks that the secret hashes to the secret hash. ValidateSecret(secret, secretHash []byte) bool // SyncStatus is information about the blockchain sync status. diff --git a/client/asset/ltc/ltc.go b/client/asset/ltc/ltc.go index 93c0d4eb5b..2bc1e6bf1a 100644 --- a/client/asset/ltc/ltc.go +++ b/client/asset/ltc/ltc.go @@ -23,9 +23,16 @@ const ( // defaultFeeRateLimit is the default value for the feeratelimit. defaultFeeRateLimit = 100 minNetworkVersion = 180100 + walletTypeRPC = "litecoindRPC" + walletTypeLegacy = "" ) var ( + NetPorts = dexbtc.NetPorts{ + Mainnet: "9332", + Testnet: "19332", + Simnet: "19443", + } configOpts = []*asset.ConfigOption{ { Key: "walletname", @@ -88,11 +95,16 @@ var ( } // WalletInfo defines some general information about a Litecoin wallet. WalletInfo = &asset.WalletInfo{ - Name: "Litecoin", - Version: version, - DefaultConfigPath: dexbtc.SystemConfigPath("litecoin"), - ConfigOpts: configOpts, - UnitInfo: dexltc.UnitInfo, + Name: "Litecoin", + Version: version, + UnitInfo: dexltc.UnitInfo, + AvailableWallets: []*asset.WalletDefinition{{ + Type: walletTypeRPC, + Tab: "External", + Description: "Connect to litecoind", + DefaultConfigPath: dexbtc.SystemConfigPath("litecoin"), + ConfigOpts: configOpts, + }}, } ) @@ -103,15 +115,14 @@ func init() { // Driver implements asset.Driver. type Driver struct{} -// Open opens the LTC exchange wallet. Start the wallet with its Run method. +// Check that Driver implements asset.Driver. +var _ asset.Driver = (*Driver)(nil) + +// Open creates the LTC exchange wallet. Start the wallet with its Run method. func (d *Driver) Open(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) { return NewWallet(cfg, logger, network) } -func (d *Driver) Create(*asset.CreateWalletParams) error { - return fmt.Errorf("no creatable wallet types") -} - // DecodeCoinID creates a human-readable representation of a coin ID for // Litecoin. func (d *Driver) DecodeCoinID(coinID []byte) (string, error) { @@ -125,9 +136,7 @@ func (d *Driver) Info() *asset.WalletInfo { } // NewWallet is the exported constructor by which the DEX will import the -// exchange wallet. The wallet will shut down when the provided context is -// canceled. The configPath can be an empty string, in which case the standard -// system location of the litecoind config file is assumed. +// exchange wallet. func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) { var params *chaincfg.Params switch network { @@ -143,11 +152,6 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) // Designate the clone ports. These will be overwritten by any explicit // settings in the configuration file. - ports := dexbtc.NetPorts{ - Mainnet: "9332", - Testnet: "19332", - Simnet: "19443", - } cloneCFG := &btc.BTCCloneCFG{ WalletCFG: cfg, MinNetworkVersion: minNetworkVersion, @@ -156,7 +160,7 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) Logger: logger, Network: network, ChainParams: params, - Ports: ports, + Ports: NetPorts, DefaultFallbackFee: defaultFee, DefaultFeeRateLimit: defaultFeeRateLimit, LegacyBalance: true, diff --git a/client/asset/ltc/regnet_test.go b/client/asset/ltc/regnet_test.go index 41810c62c9..12d21ffb5e 100644 --- a/client/asset/ltc/regnet_test.go +++ b/client/asset/ltc/regnet_test.go @@ -40,5 +40,9 @@ var ( ) func TestWallet(t *testing.T) { - livetest.Run(t, NewWallet, alphaAddress, tLotSize, tLTC, false) + livetest.Run(t, &livetest.Config{ + NewWallet: NewWallet, + LotSize: tLotSize, + Asset: tLTC, + }) } diff --git a/client/cmd/dexcctl/dexcctl_test.go b/client/cmd/dexcctl/dexcctl_test.go index 0076793a54..f3a8f67c28 100644 --- a/client/cmd/dexcctl/dexcctl_test.go +++ b/client/cmd/dexcctl/dexcctl_test.go @@ -129,15 +129,15 @@ func TestReadTextFile(t *testing.T) { }, { name: "newwallet ok, with cfg file", cmd: "newwallet", - args: []string{"42", "./w.conf"}, + args: []string{"42", "rpc", "./w.conf"}, txtFilePath: "./w.conf", txtToSave: cfgTxt, - want: []string{"42", cfgTxt}, + want: []string{"42", "rpc", cfgTxt}, }, { name: "newwallet ok, no cfg file", cmd: "newwallet", - args: []string{"42"}, - want: []string{"42"}, + args: []string{"42", "rpc"}, + want: []string{"42", "rpc"}, }} for _, test := range tests { if test.txtFilePath != "" { diff --git a/client/cmd/dexcctl/main.go b/client/cmd/dexcctl/main.go index 586c4bdceb..1bcaeafb2b 100644 --- a/client/cmd/dexcctl/main.go +++ b/client/cmd/dexcctl/main.go @@ -69,7 +69,7 @@ var promptPasswords = map[string][]string{ var optionalTextFiles = map[string]int{ "getdexconfig": 1, "register": 3, - "newwallet": 1, + "newwallet": 2, } // promptPWs prompts for passwords on stdin and returns an error if prompting diff --git a/client/cmd/dexcctl/simnet-setup.sh b/client/cmd/dexcctl/simnet-setup.sh index e9fd6369fe..99883001f1 100755 --- a/client/cmd/dexcctl/simnet-setup.sh +++ b/client/cmd/dexcctl/simnet-setup.sh @@ -20,24 +20,24 @@ echo initializing ./dexcctl -p abc --simnet init echo configuring Decred wallet -./dexcctl -p abc -p abc --simnet newwallet 42 ~/dextest/dcr/alpha/alpha.conf '{"account":"default"}' +./dexcctl -p abc -p abc --simnet newwallet 42 dcrwalletRPC ~/dextest/dcr/alpha/alpha.conf '{"account":"default"}' echo configuring Bitcoin wallet -./dexcctl -p abc -p "" --simnet newwallet 0 ~/dextest/btc/alpha/alpha.conf '{"walletname":"gamma"}' +./dexcctl -p abc -p "" --simnet newwallet 0 bitcoindRPC ~/dextest/btc/alpha/alpha.conf '{"walletname":"gamma"}' if [ $LTC_ON -eq 0 ]; then echo configuring Litecoin wallet - ./dexcctl -p abc -p "" --simnet newwallet 2 ~/dextest/ltc/alpha/alpha.conf '{"walletname":"gamma"}' + ./dexcctl -p abc -p "" --simnet newwallet 2 litecoindRPC ~/dextest/ltc/alpha/alpha.conf '{"walletname":"gamma"}' fi if [ $BCH_ON -eq 0 ]; then echo configuring Bitcoin Cash wallet - ./dexcctl -p abc -p "" --simnet newwallet 145 ~/dextest/bch/alpha/alpha.conf '{"walletname":"gamma"}' + ./dexcctl -p abc -p "" --simnet newwallet 145 bitcoindRPC ~/dextest/bch/alpha/alpha.conf '{"walletname":"gamma"}' fi if [ $ETH_ON -eq 0 ]; then echo configuring Eth wallet - ./dexcctl -p abc -p "" --simnet newwallet 60 "" '{"appDir":"~/dextest/eth/testnode"}' + ./dexcctl -p abc -p "" --simnet newwallet 60 geth "" '{"appDir":"~/dextest/eth/testnode"}' fi echo registering with DEX diff --git a/client/core/core.go b/client/core/core.go index 2aabf1936a..34a6d3f72f 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -1058,6 +1058,7 @@ type Config struct { type Core struct { ctx context.Context wg sync.WaitGroup + walletWait sync.WaitGroup ready chan struct{} cfg *Config log dex.Logger @@ -1263,6 +1264,9 @@ func (c *Core) Run(ctx context.Context) { wallet.Disconnect() } + // Let the wallet backends shut down cleanly. + c.walletWait.Wait() + c.log.Infof("DEX client core off") } @@ -1456,7 +1460,7 @@ func (c *Core) connectedWallet(assetID uint32) (*xcWallet, error) { // connectWallet connects to the wallet and returns the deposit address // validated by the xcWallet after connecting. If the wallet backend is still -// synching, this also starts a goroutine to monitor sync status, emitting +// syncing, this also starts a goroutine to monitor sync status, emitting // WalletStateNotes on each progress update. func (c *Core) connectWallet(w *xcWallet) (depositAddr string, err error) { err = w.Connect() // ensures valid deposit address @@ -1496,6 +1500,7 @@ func (c *Core) connectWallet(w *xcWallet) (depositAddr string, err error) { w.mtx.Unlock() c.notify(newWalletStateNote(w.state())) if synced { + c.updateWalletBalance(w) return } @@ -1692,10 +1697,9 @@ func (c *Core) CreateWallet(appPW, walletPW []byte, form *WalletForm) error { return err } - walletInfo, err := asset.Info(assetID) + walletDef, err := walletDefinition(assetID, form.Type) if err != nil { - // Only possible error is unknown asset. - return fmt.Errorf("asset with BIP ID %d is unknown. Did you _ import your asset packages?", assetID) + return err } // Remove unused key-values from parsed settings before saving to db. @@ -1703,8 +1707,8 @@ func (c *Core) CreateWallet(appPW, walletPW []byte, form *WalletForm) error { // config files usually define more key-values than we need. // Expected keys should be lowercase because config.Parse returns lowercase // keys. - expectedKeys := make(map[string]bool, len(walletInfo.ConfigOpts)) - for _, option := range walletInfo.ConfigOpts { + expectedKeys := make(map[string]bool, len(walletDef.ConfigOpts)) + for _, option := range walletDef.ConfigOpts { expectedKeys[strings.ToLower(option.Key)] = true } for key := range form.Config { @@ -1713,9 +1717,9 @@ func (c *Core) CreateWallet(appPW, walletPW []byte, form *WalletForm) error { } } - if walletInfo.Seeded { + if walletDef.Seeded { if len(walletPW) > 0 { - return fmt.Errorf("external password incompatible with built-in wallet") + return errors.New("external password incompatible with seeded wallet") } walletPW, err = c.createSeededWallet(assetID, crypter, form) if err != nil { @@ -1732,6 +1736,7 @@ func (c *Core) CreateWallet(appPW, walletPW []byte, form *WalletForm) error { } dbWallet := &db.Wallet{ + Type: walletDef.Type, AssetID: assetID, Settings: form.Config, EncryptedPW: encPW, @@ -1788,33 +1793,24 @@ func (c *Core) CreateWallet(appPW, walletPW []byte, form *WalletForm) error { return nil } -// createSeededWallet creates a seeded wallet with an asset-specific seed derived -// deterministically from the app seed. +// createSeededWallet initializes a seeded wallet with an asset-specific seed +// derived deterministically from the app seed and a random password. The +// password is returned for encrypting and storing. func (c *Core) createSeededWallet(assetID uint32, crypter encrypt.Crypter, form *WalletForm) ([]byte, error) { - creds := c.creds() - if creds == nil { - return nil, fmt.Errorf("no v2 credentials stored") - } - - appSeed, err := crypter.Decrypt(creds.EncSeed) + seed, pw, err := c.assetSeedAndPass(assetID, crypter) if err != nil { - return nil, fmt.Errorf("app seed decryption error: %w", err) + return nil, err } c.log.Infof("Initializing a built-in %s wallet", unbip(assetID)) - - b := make([]byte, len(appSeed)+4) - copy(b, appSeed) - binary.BigEndian.PutUint32(b[len(appSeed):], assetID) - - seed := blake256.Sum256(b) - pw := encode.RandomBytes(32) if err = asset.CreateWallet(assetID, &asset.CreateWalletParams{ - Seed: seed[:], + Type: form.Type, + Seed: seed, Pass: pw, Settings: form.Config, DataDir: c.assetDataDirectory(assetID), Net: c.net, + Logger: c.log.SubLogger("CREATE"), }); err != nil { return nil, fmt.Errorf("Error creating wallet: %w", err) } @@ -1822,6 +1818,26 @@ func (c *Core) createSeededWallet(assetID uint32, crypter encrypt.Crypter, form return pw, nil } +func (c *Core) assetSeedAndPass(assetID uint32, crypter encrypt.Crypter) (seed, pass []byte, err error) { + creds := c.creds() + if creds == nil { + return nil, nil, errors.New("no v2 credentials stored") + } + + appSeed, err := crypter.Decrypt(creds.EncSeed) + if err != nil { + return nil, nil, fmt.Errorf("app seed decryption error: %w", err) + } + + b := make([]byte, len(appSeed)+4) + copy(b, appSeed) + binary.BigEndian.PutUint32(b[len(appSeed):], assetID) + + s := blake256.Sum256(b) + p := blake256.Sum256(seed) + return s[:], p[:], nil +} + // assetDataDirectory is a directory for a wallet to use for local storage. func (c *Core) assetDataDirectory(assetID uint32) string { return filepath.Join(filepath.Dir(c.cfg.DBPath), "assetdb", unbip(assetID)) @@ -1833,6 +1849,7 @@ func (c *Core) loadWallet(dbWallet *db.Wallet) (*xcWallet, error) { // Create the client/asset.Wallet. assetID := dbWallet.AssetID walletCfg := &asset.WalletConfig{ + Type: dbWallet.Type, Settings: dbWallet.Settings, TipChange: func(err error) { // asset.Wallet implementations should not need wait for the @@ -1841,6 +1858,7 @@ func (c *Core) loadWallet(dbWallet *db.Wallet) (*xcWallet, error) { // of deadlocking a Core method that calls Wallet.Disconnect. go c.tipChange(assetID, err) }, + DataDir: c.assetDataDirectory(assetID), } logger := c.log.SubLogger(unbip(assetID)) @@ -1860,9 +1878,10 @@ func (c *Core) loadWallet(dbWallet *db.Wallet) (*xcWallet, error) { OrderLocked: orderLockedAmt, ContractLocked: contractLockedAmt, }, - encPass: dbWallet.EncryptedPW, - address: dbWallet.Address, - dbID: dbWallet.ID(), + encPass: dbWallet.EncryptedPW, + address: dbWallet.Address, + dbID: dbWallet.ID(), + walletType: dbWallet.Type, }, nil } @@ -1999,29 +2018,88 @@ func (c *Core) ChangeAppPass(appPW, newAppPW []byte) error { // ReconfigureWallet updates the wallet configuration settings, it also updates // the password if newWalletPW is non-nil. Do not make concurrent calls to // ReconfigureWallet for the same asset. -func (c *Core) ReconfigureWallet(appPW, newWalletPW []byte, assetID uint32, cfg map[string]string) error { +func (c *Core) ReconfigureWallet(appPW, newWalletPW []byte, form *WalletForm) error { crypter, err := c.encryptionKey(appPW) if err != nil { return newError(authErr, "ReconfigureWallet password error: %v", err) } + assetID := form.AssetID + walletDef, err := walletDefinition(assetID, form.Type) + if err != nil { + return err + } oldWallet, found := c.wallet(assetID) if !found { return newError(missingWalletErr, "%d -> %s wallet not found", assetID, unbip(assetID)) } seeded := oldWallet.Info().Seeded - if seeded && len(newWalletPW) > 0 { + if seeded && newWalletPW != nil { return newError(passwordErr, "cannot set a password on a built-in wallet") } oldDepositAddr := oldWallet.currentDepositAddress() dbWallet := &db.Wallet{ + Type: form.Type, AssetID: oldWallet.AssetID, - Settings: cfg, + Settings: form.Config, Balance: &db.Balance{}, // in case retrieving new balance after connect fails EncryptedPW: oldWallet.encPW(), Address: oldDepositAddr, } + var restartOnFail bool + + defer func() { + if restartOnFail { + if _, err := c.connectWallet(oldWallet); err != nil { + c.log.Errorf("Failed to reconnect wallet after a failed reconfiguration attempt: %v", err) + } + } + }() + + oldDef, err := walletDefinition(assetID, oldWallet.walletType) + if err != nil { + return fmt.Errorf("failed to locate old wallet definition: %v", err) + } + + if walletDef.Seeded { + if newWalletPW != nil { + return newError(passwordErr, "cannot set a password on a seeded wallet") + } + + exists, err := asset.WalletExists(assetID, form.Type, c.assetDataDirectory(assetID), form.Config, c.net) + if err != nil { + return newError(existenceCheckErr, "error checking wallet pre-existence: %v", err) + } + + if !exists { + newWalletPW, err = c.createSeededWallet(assetID, crypter, form) + if err != nil { + return newError(createWalletErr, "error creating new %q-type %s wallet: %v", form.Type, unbip(assetID), err) + } + } else if oldDef.Seeded { + _, newWalletPW, err = c.assetSeedAndPass(assetID, crypter) + if err != nil { + return newError(authErr, "error retrieving wallet password: %v", err) + } + } + + if oldWallet.connected() { + oldDef, err := walletDefinition(assetID, oldWallet.walletType) + // Error can be normal if the wallet was created before wallet types + // were a thing. Just assume this is an old wallet and therefore not + // seeded. + if err == nil && oldDef.Seeded { + oldWallet.Disconnect() + restartOnFail = true + } + } + } else if newWalletPW == nil && oldDef.Seeded { + // If we're switching from a seeded wallet and no password was provided, + // use empty string = wallet not encrypted. + newWalletPW = []byte{} + } + // Reload the wallet with the new settings. wallet, err := c.loadWallet(dbWallet) if err != nil { @@ -2121,6 +2199,8 @@ func (c *Core) ReconfigureWallet(appPW, newWalletPW []byte, assetID uint32, cfg c.wallets[assetID] = wallet c.walletMtx.Unlock() + restartOnFail = false + if oldWallet.connected() { // NOTE: Cannot lock the wallet backend because it may be the same as // the one just connected. @@ -2208,7 +2288,7 @@ func (c *Core) setWalletPassword(wallet *xcWallet, newPW []byte, crypter encrypt if err != nil { return newError(encryptionErr, "encryption error: %v", err) } - err = wallet.Wallet.Unlock(string(newPW)) + err = wallet.Wallet.Unlock(newPW) if err != nil { return newError(authErr, "setWalletPassword unlocking wallet error, is the new password correct?: %v", err) @@ -2265,13 +2345,18 @@ func (c *Core) NewDepositAddress(assetID uint32) (string, error) { // AutoWalletConfig attempts to load setting from a wallet package's // asset.WalletInfo.DefaultConfigPath. If settings are not found, an empty map // is returned. -func (c *Core) AutoWalletConfig(assetID uint32) (map[string]string, error) { - winfo, err := asset.Info(assetID) +func (c *Core) AutoWalletConfig(assetID uint32, walletType string) (map[string]string, error) { + walletDef, err := walletDefinition(assetID, walletType) if err != nil { - return nil, fmt.Errorf("asset.Info error: %w", err) + return nil, err } - settings, err := config.Parse(winfo.DefaultConfigPath) - c.log.Infof("%d %s configuration settings loaded from file at default location %s", len(settings), unbip(assetID), winfo.DefaultConfigPath) + + if walletDef.DefaultConfigPath == "" { + return nil, fmt.Errorf("no config path found for %s wallet, type %q", unbip(assetID), walletType) + } + + settings, err := config.Parse(walletDef.DefaultConfigPath) + c.log.Infof("%d %s configuration settings loaded from file at default location %s", len(settings), unbip(assetID), walletDef.DefaultConfigPath) if err != nil { c.log.Debugf("config.Parse could not load settings from default path: %v", err) return make(map[string]string), nil @@ -2625,7 +2710,8 @@ func (c *Core) Register(form *RegisterForm) (*RegisterResult, error) { c.log.Infof("Attempting registration fee payment to %s, account ID %v, of %d units of %s. "+ "Do NOT manually send funds to this address even if this fails.", regRes.Address, dc.acct.id, regRes.Fee, regFeeAssetSymbol) - coin, err := wallet.PayFee(regRes.Address, regRes.Fee) + + coin, err := wallet.PayFee(regRes.Address, regRes.Fee, dc.fetchFeeRate(feeAsset.ID)) if err != nil { return nil, newError(feeSendErr, "error paying registration fee: %v", err) } @@ -3563,7 +3649,8 @@ func (c *Core) Withdraw(pw []byte, assetID uint32, value uint64, address string) if err != nil { return nil, err } - coin, err := wallet.Withdraw(address, value) + const feeSuggestion = 100 + coin, err := wallet.Withdraw(address, value, feeSuggestion) if err != nil { subject, details := c.formatDetails(TopicWithdrawError, unbip(assetID), err) c.notify(newWithdrawNote(TopicWithdrawError, subject, details, db.ErrorLevel)) @@ -6182,3 +6269,28 @@ func parseCert(host string, certI interface{}) ([]byte, error) { } return nil, fmt.Errorf("not a valid certificate type %T", certI) } + +// walletDefinition gets the registered WalletDefinition for the asset and +// wallet type. +func walletDefinition(assetID uint32, walletType string) (*asset.WalletDefinition, error) { + winfo, err := asset.Info(assetID) + if err != nil { + return nil, newError(assetSupportErr, "asset.Info error: %v", err) + } + if walletType == "" { + if len(winfo.AvailableWallets) <= winfo.LegacyWalletIndex { + return nil, fmt.Errorf("legacy wallet index out of range") + } + return winfo.AvailableWallets[winfo.LegacyWalletIndex], nil + } + var walletDef *asset.WalletDefinition + for _, def := range winfo.AvailableWallets { + if def.Type == walletType { + walletDef = def + } + } + if walletDef == nil { + return nil, fmt.Errorf("could not find wallet definition for asset %s, type %q", unbip(assetID), walletType) + } + return walletDef, nil +} diff --git a/client/core/core_test.go b/client/core/core_test.go index a91d89c8b1..e2935e4753 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -74,7 +74,7 @@ var ( tDexKey *secp256k1.PublicKey tDexAccountID account.AccountID tPW = []byte("dexpw") - wPW = "walletpw" + wPW = []byte("walletpw") tDexHost = "somedex.tld:7232" tDcrBtcMktName = "dcr_btc" tErr = fmt.Errorf("test error") @@ -83,7 +83,7 @@ var ( tUnparseableHost = string([]byte{0x7f}) tSwapFeesPaid uint64 = 500 tRedemptionFeesPaid uint64 = 350 - tLogger = dex.StdOutLogger("TCORE", dex.LevelTrace) + tLogger = dex.StdOutLogger("TCORE", dex.LevelInfo) tMaxFeeRate uint64 = 10 tWalletInfo = &asset.WalletInfo{ UnitInfo: dex.UnitInfo{ @@ -91,6 +91,9 @@ var ( ConversionFactor: 1e8, }, }, + AvailableWallets: []*asset.WalletDefinition{{ + Type: "type", + }}, } ) @@ -592,7 +595,7 @@ func newTWallet(assetID uint32) (*xcWallet, *TXCWallet) { encPass: []byte{0x01}, synced: true, syncProgress: 1, - pw: string(tPW), + pw: tPW, } return xcWallet, w @@ -724,7 +727,7 @@ func (w *TXCWallet) FindRedemption(ctx context.Context, coinID dex.Bytes) (redem return nil, nil, fmt.Errorf("not mocked") } -func (w *TXCWallet) Refund(dex.Bytes, dex.Bytes) (dex.Bytes, error) { +func (w *TXCWallet) Refund(dex.Bytes, dex.Bytes, uint64) (dex.Bytes, error) { return w.refundCoin, w.refundErr } @@ -732,7 +735,7 @@ func (w *TXCWallet) Address() (string, error) { return "", w.addrErr } -func (w *TXCWallet) Unlock(pw string) error { +func (w *TXCWallet) Unlock(pw []byte) error { return w.unlockErr } @@ -754,11 +757,11 @@ func (w *TXCWallet) ConfirmTime(id dex.Bytes, nConfs uint32) (time.Time, error) return time.Time{}, nil } -func (w *TXCWallet) PayFee(address string, fee uint64) (asset.Coin, error) { +func (w *TXCWallet) PayFee(address string, fee, feeRateSuggestion uint64) (asset.Coin, error) { return w.payFeeCoin, w.payFeeErr } -func (w *TXCWallet) Withdraw(address string, value uint64) (asset.Coin, error) { +func (w *TXCWallet) Withdraw(address string, value, feeSuggestion uint64) (asset.Coin, error) { return w.payFeeCoin, w.payFeeErr } @@ -1368,17 +1371,24 @@ func TestBookFeed(t *testing.T) { } type tDriver struct { - f func(*asset.WalletConfig, dex.Logger, dex.Network) (asset.Wallet, error) - decoder func(coinID []byte) (string, error) - winfo *asset.WalletInfo + f func(*asset.WalletConfig, dex.Logger, dex.Network) (asset.Wallet, error) + decoder func(coinID []byte) (string, error) + winfo *asset.WalletInfo + doesntExist bool + existsErr error + createErr error } -func (drv *tDriver) Open(cfg *asset.WalletConfig, logger dex.Logger, net dex.Network) (asset.Wallet, error) { - return drv.f(cfg, logger, net) +func (drv *tDriver) Exists(walletType, dataDir string, settings map[string]string, net dex.Network) (bool, error) { + return !drv.doesntExist, drv.existsErr +} + +func (drv *tDriver) Create(*asset.CreateWalletParams) error { + return drv.createErr } -func (drv *tDriver) Create(params *asset.CreateWalletParams) error { - return fmt.Errorf("unimplemented") +func (drv *tDriver) Open(cfg *asset.WalletConfig, logger dex.Logger, net dex.Network) (asset.Wallet, error) { + return drv.f(cfg, logger, net) } func (drv *tDriver) DecodeCoinID(coinID []byte) (string, error) { @@ -1412,11 +1422,12 @@ func TestCreateWallet(t *testing.T) { Config: map[string]string{ "rpclisten": "localhost", }, + Type: "type", } ensureErr := func(tag string) { t.Helper() - err := tCore.CreateWallet(tPW, []byte(wPW), form) + err := tCore.CreateWallet(tPW, wPW, form) if err == nil { t.Fatalf("no %s error", tag) } @@ -1478,7 +1489,7 @@ func TestCreateWallet(t *testing.T) { // Success delete(tCore.wallets, tILT.ID) - err := tCore.CreateWallet(tPW, []byte(wPW), form) + err := tCore.CreateWallet(tPW, wPW, form) if err != nil { t.Fatalf("error when should be no error: %v", err) } @@ -4204,7 +4215,7 @@ func TestRefunds(t *testing.T) { t.Fatalf("%s's swap not refundable", match.Side) } // Check refund. - amtRefunded, err := tracker.refundMatches([]*matchTracker{match}) + amtRefunded, err := rig.core.refundMatches(tracker, []*matchTracker{match}) if err != nil { t.Fatalf("unexpected refund error %v", err) } @@ -4217,7 +4228,7 @@ func TestRefunds(t *testing.T) { t.Fatalf("%s's swap refundable after being refunded", match.Side) } // Expect refund re-attempt to not refund any coin. - amtRefunded, err = tracker.refundMatches([]*matchTracker{match}) + amtRefunded, err = rig.core.refundMatches(tracker, []*matchTracker{match}) if err != nil { t.Fatalf("unexpected refund error %v", err) } @@ -5829,36 +5840,69 @@ func TestReconfigureWallet(t *testing.T) { } var assetID uint32 = 54321 + form := &WalletForm{ + AssetID: assetID, + Config: newSettings, + Type: "type", + } + // App Password error rig.crypter.(*tCrypter).recryptErr = tErr - err := tCore.ReconfigureWallet(tPW, nil, assetID, newSettings) + err := tCore.ReconfigureWallet(tPW, nil, form) if !errorHasCode(err, authErr) { t.Fatalf("wrong error for password error: %v", err) } rig.crypter.(*tCrypter).recryptErr = nil // Missing wallet error - err = tCore.ReconfigureWallet(tPW, nil, assetID, newSettings) - if !errorHasCode(err, missingWalletErr) { + err = tCore.ReconfigureWallet(tPW, nil, form) + if !errorHasCode(err, assetSupportErr) { t.Fatalf("wrong error for missing wallet: %v", err) } xyzWallet, tXyzWallet := newTWallet(assetID) tCore.wallets[assetID] = xyzWallet - asset.Register(assetID, &tDriver{ + walletDef := &asset.WalletDefinition{ + Type: "type", + Seeded: true, + } + winfo := *tWalletInfo + winfo.AvailableWallets = []*asset.WalletDefinition{walletDef} + + assetDriver := &tDriver{ f: func(wCfg *asset.WalletConfig, logger dex.Logger, net dex.Network) (asset.Wallet, error) { return xyzWallet.Wallet, nil }, - winfo: tWalletInfo, - }) + winfo: &winfo, + } + asset.Register(assetID, assetDriver) if err = xyzWallet.Connect(); err != nil { t.Fatal(err) } defer xyzWallet.Disconnect() + // Errors for seeded wallets. + walletDef.Seeded = true + // Exists error + assetDriver.existsErr = tErr + err = tCore.ReconfigureWallet(tPW, nil, form) + if !errorHasCode(err, existenceCheckErr) { + t.Fatalf("wrong error when expecting existence check error: %v", err) + } + assetDriver.existsErr = nil + // Create error + assetDriver.doesntExist = true + assetDriver.createErr = tErr + err = tCore.ReconfigureWallet(tPW, nil, form) + if !errorHasCode(err, createWalletErr) { + t.Fatalf("wrong error when expecting wallet creation error error: %v", err) + } + assetDriver.createErr = nil + walletDef.Seeded = false + // Connect error tXyzWallet.connectErr = tErr - err = tCore.ReconfigureWallet(tPW, nil, assetID, newSettings) + err = tCore.ReconfigureWallet(tPW, nil, form) if !errorHasCode(err, connectWalletErr) { t.Fatalf("wrong error when expecting connection error: %v", err) } @@ -5867,7 +5911,7 @@ func TestReconfigureWallet(t *testing.T) { // Unlock error tXyzWallet.Unlock(wPW) tXyzWallet.unlockErr = tErr - err = tCore.ReconfigureWallet(tPW, nil, assetID, newSettings) + err = tCore.ReconfigureWallet(tPW, nil, form) if !errorHasCode(err, walletAuthErr) { t.Fatalf("wrong error when expecting auth error: %v", err) } @@ -5912,7 +5956,7 @@ func TestReconfigureWallet(t *testing.T) { // Error checking if wallet owns address. tXyzWallet.ownsAddressErr = tErr - err = tCore.ReconfigureWallet(tPW, nil, assetID, newSettings) + err = tCore.ReconfigureWallet(tPW, nil, form) if !errorHasCode(err, walletErr) { t.Fatalf("wrong error when expecting ownsAddress wallet error: %v", err) } @@ -5920,14 +5964,14 @@ func TestReconfigureWallet(t *testing.T) { // Wallet doesn't own address. tXyzWallet.ownsAddress = false - err = tCore.ReconfigureWallet(tPW, nil, assetID, newSettings) + err = tCore.ReconfigureWallet(tPW, nil, form) if !errorHasCode(err, walletErr) { t.Fatalf("wrong error when expecting not owned wallet error: %v", err) } tXyzWallet.ownsAddress = true // Success updating settings. - err = tCore.ReconfigureWallet(tPW, nil, assetID, newSettings) + err = tCore.ReconfigureWallet(tPW, nil, form) if err != nil { t.Fatalf("ReconfigureWallet error: %v", err) } @@ -5943,7 +5987,7 @@ func TestReconfigureWallet(t *testing.T) { // Success updating wallet PW. newWalletPW := []byte("password") - err = tCore.ReconfigureWallet(tPW, newWalletPW, assetID, newSettings) + err = tCore.ReconfigureWallet(tPW, newWalletPW, form) if err != nil { t.Fatalf("ReconfigureWallet error: %v", err) } diff --git a/client/core/errors.go b/client/core/errors.go index 4323695766..4335ee10f6 100644 --- a/client/core/errors.go +++ b/client/core/errors.go @@ -43,6 +43,8 @@ const ( accountRetrieveErr accountDisableErr suspendedAcctErr + existenceCheckErr + createWalletErr ) // Error is an error message and an error code. diff --git a/client/core/trade.go b/client/core/trade.go index f35376615c..044b228b40 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -1217,7 +1217,7 @@ func (c *Core) tick(t *trackedTrade) (assetMap, error) { if didUnlock { c.log.Infof("Unexpected unlock needed for the %s wallet while sending a refund", t.wallets.fromAsset.Symbol) } - refunded, err := t.refundMatches(refunds) + refunded, err := c.refundMatches(t, refunds) corder := t.coreOrderInternal() ui := t.wallets.fromWallet.Info().UnitInfo if err != nil { @@ -1926,7 +1926,7 @@ func (t *trackedTrade) findMakersRedemption(match *matchTracker) { // // This method modifies match fields and MUST be called with the trackedTrade // mutex lock held for writes. -func (t *trackedTrade) refundMatches(matches []*matchTracker) (uint64, error) { +func (c *Core) refundMatches(t *trackedTrade, matches []*matchTracker) (uint64, error) { errs := newErrorSet("refundMatches: order %s - ", t.ID()) refundWallet, refundAsset := t.wallets.fromWallet, t.wallets.fromAsset // refunding to our wallet @@ -1961,7 +1961,8 @@ func (t *trackedTrade) refundMatches(matches []*matchTracker) (uint64, error) { t.dc.log.Infof("Refunding %s contract %s for match %s (%s)", refundAsset.Symbol, swapCoinString, match, matchFailureReason) - refundCoin, err := refundWallet.Refund(swapCoinID, contractToRefund) + feeSuggestion := c.feeSuggestion(t.dc, refundAsset.ID, refundAsset.ID == t.Base()) + refundCoin, err := refundWallet.Refund(swapCoinID, contractToRefund, feeSuggestion) if err != nil { if errors.Is(err, asset.CoinNotFoundError) && match.Side == order.Taker { match.refundErr = err @@ -2028,7 +2029,7 @@ func (t *trackedTrade) processAuditMsg(msgID uint64, audit *msgjson.Audit) error err := t.auditContract(match, audit.CoinID, audit.Contract, audit.TxData) if err != nil { contractID := coinIDString(t.wallets.toAsset.ID, audit.CoinID) - t.dc.log.Error("Failed to audit contract coin %v (%s) for match %s: %v", + t.dc.log.Errorf("Failed to audit contract coin %v (%s) for match %s: %v", contractID, t.wallets.toAsset.Symbol, match, err) // Don't revoke in case server sends a revised audit request before // the match is revoked. @@ -2074,6 +2075,7 @@ func (t *trackedTrade) searchAuditInfo(match *matchTracker, coinID []byte, contr var auditInfo *asset.AuditInfo var tries int contractID, contractSymb := coinIDString(t.wallets.toAsset.ID, coinID), t.wallets.toAsset.Symbol + tLastWarning := time.Now() t.latencyQ.Wait(&wait.Waiter{ Expiration: time.Now().Add(24 * time.Hour), // effectively forever TryFunc: func() bool { @@ -2092,7 +2094,8 @@ func (t *trackedTrade) searchAuditInfo(match *matchTracker, coinID []byte, contr "Check your internet and wallet connections!", contractID, contractSymb)) return wait.DontTryAgain } - if tries > 0 && tries%12 == 0 { + if time.Since(tLastWarning) > 30*time.Minute { + tLastWarning = time.Now() subject, detail := t.formatDetails(TopicAuditTrouble, contractID, contractSymb, match) t.notify(newOrderNote(TopicAuditTrouble, subject, detail, db.WarningLevel, t.coreOrder())) } diff --git a/client/core/trade_simnet_test.go b/client/core/trade_simnet_test.go index 0877ef3f0e..b3420d3444 100644 --- a/client/core/trade_simnet_test.go +++ b/client/core/trade_simnet_test.go @@ -32,6 +32,7 @@ import ( "os/user" "path/filepath" "strconv" + "strings" "sync" "sync/atomic" "testing" @@ -65,7 +66,7 @@ var ( appPass: []byte("client2"), wallets: map[uint32]*tWallet{ dcr.BipID: dcrWallet("trading2"), - btc.BipID: btcWallet("alpha", "gamma"), // alpha default ("") is encrypted, gamma is unencrypted + btc.BipID: &tWallet{spv: true}, // the btc/spv startWallet method auto connects to the alpha node for spv syncing }, processedStatus: make(map[order.MatchID]order.MatchStatus), } @@ -101,13 +102,24 @@ func readWalletCfgsAndDexCert() error { for _, client := range clients { dcrw, btcw := client.wallets[dcr.BipID], client.wallets[btc.BipID] - dcrw.config, err = config.Parse(filepath.Join(user.HomeDir, "dextest", "dcr", dcrw.daemon, dcrw.daemon+".conf")) - if err == nil { - btcw.config, err = config.Parse(filepath.Join(user.HomeDir, "dextest", "btc", btcw.daemon, btcw.daemon+".conf")) + if dcrw.spv { + dcrw.config = map[string]string{} + } else { + dcrw.config, err = config.Parse(filepath.Join(user.HomeDir, "dextest", "dcr", dcrw.daemon, dcrw.daemon+".conf")) + if err != nil { + return err + } } - if err != nil { - return err + + if btcw.spv { + btcw.config = map[string]string{} + } else { + btcw.config, err = config.Parse(filepath.Join(user.HomeDir, "dextest", "btc", btcw.daemon, btcw.daemon+".conf")) + if err != nil { + return err + } } + dcrw.config["account"] = dcrw.account btcw.config["walletname"] = btcw.walletName } @@ -141,7 +153,19 @@ func startClients(ctx context.Context) error { // connect wallets for assetID, wallet := range c.wallets { + walletType := "dcrwalletRPC" + if assetID == btc.BipID { + walletType = "bitcoindRPC" + if wallet.spv { + walletType = "SPV" + wallet.pass = nil // should not be set for spv wallets in the first place, but play safe + } + } + + os.RemoveAll(c.core.assetDataDirectory(assetID)) + err = c.core.CreateWallet(c.appPass, wallet.pass, &WalletForm{ + Type: walletType, AssetID: assetID, Config: wallet.config, }) @@ -149,12 +173,39 @@ func startClients(ctx context.Context) error { return err } c.log("Connected %s wallet", unbip(assetID)) + + if wallet.spv { + // Fund newly created wallet. + c.log("Funding newly created SPV wallet") + address := c.core.WalletState(assetID).Address + amts := []int{10, 18, 5, 7, 1, 15, 3, 25} + cmds := make([]string, len(amts)) + for i, amt := range amts { + cmds[i] = fmt.Sprintf("./alpha sendtoaddress %s %d", address, amt) + } + err = tmuxRun(assetID, strings.Join(cmds, " && ")+" && ./mine-alpha 2") + if err != nil { + return err + } + } } err = c.registerDEX(ctx) if err != nil { return err } + + // Wait for spv wallets to sync. + for assetID, wallet := range c.wallets { + if !wallet.spv { + continue + } + c.log("Waiting for %s wallet to sync", unbip(assetID)) + for !c.core.WalletState(assetID).Synced { + time.Sleep(time.Second) + } + c.log("%s wallet synced and ready to use", unbip(assetID)) + } } return nil @@ -1243,6 +1294,7 @@ type tWallet struct { walletName string // for btc wallets, put into config map pass []byte config map[string]string + spv bool } func dcrWallet(daemon string) *tWallet { @@ -1537,26 +1589,27 @@ func (client *tClient) disableWallets() { for _, wallet := range client.core.wallets { wallet.mtx.Lock() wallet.encPass = []byte{0} - wallet.pw = "" + wallet.pw = nil wallet.mtx.Unlock() } client.core.walletMtx.Unlock() } func mineBlocks(assetID, blocks uint32) error { - var harnessID string + return tmuxRun(assetID, fmt.Sprintf("./mine-alpha %d", blocks)) +} + +func tmuxRun(assetID uint32, cmd string) error { + var tmuxWindow string switch assetID { case dcr.BipID: - harnessID = "dcr-harness:0" + tmuxWindow = "dcr-harness:0" case btc.BipID: - harnessID = "btc-harness:2" + tmuxWindow = "btc-harness:2" default: return fmt.Errorf("can't mine blocks for unknown asset %d", assetID) } - return tmuxRun(harnessID, fmt.Sprintf("./mine-alpha %d", blocks)) -} -func tmuxRun(tmuxWindow, cmd string) error { cmd += "; tmux wait-for -S harnessdone" err := exec.Command("tmux", "send-keys", "-t", tmuxWindow, cmd, "C-m").Run() // ; wait-for harnessdone if err != nil { diff --git a/client/core/types.go b/client/core/types.go index edd446ddb8..f6c498b52b 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -77,6 +77,7 @@ func (set *errorSet) Error() string { type WalletForm struct { AssetID uint32 Config map[string]string + Type string } // WalletBalance is an exchange wallet's balance which includes contractlocked @@ -97,6 +98,7 @@ type WalletState struct { Symbol string `json:"symbol"` AssetID uint32 `json:"assetID"` Version uint32 `json:"version"` + WalletType string `json:"type"` Open bool `json:"open"` Running bool `json:"running"` Balance *WalletBalance `json:"balance"` diff --git a/client/core/wallet.go b/client/core/wallet.go index c7caf55ec3..ae21ff230c 100644 --- a/client/core/wallet.go +++ b/client/core/wallet.go @@ -17,14 +17,15 @@ import ( // xcWallet is a wallet. Use (*Core).loadWallet to construct a xcWallet. type xcWallet struct { asset.Wallet - connector *dex.ConnectionMaster - AssetID uint32 - dbID []byte + connector *dex.ConnectionMaster + AssetID uint32 + dbID []byte + walletType string mtx sync.RWMutex encPass []byte // empty means wallet not password protected balance *WalletBalance - pw string + pw encode.PassBytes address string hookedUp bool synced bool @@ -54,11 +55,10 @@ func (w *xcWallet) Unlock(crypter encrypt.Crypter) error { } return nil } - pwB, err := crypter.Decrypt(w.encPW()) + pw, err := crypter.Decrypt(w.encPW()) if err != nil { return fmt.Errorf("unlockWallet decryption error: %w", err) } - pw := string(pwB) err = w.Wallet.Unlock(pw) if err != nil { return err @@ -109,7 +109,8 @@ func (w *xcWallet) Lock() error { if len(w.encPass) == 0 { return nil } - w.pw = "" + w.pw.Clear() + w.pw = nil return w.Wallet.Lock() } @@ -151,6 +152,7 @@ func (w *xcWallet) state() *WalletState { Encrypted: len(w.encPass) > 0, Synced: w.synced, SyncProgress: w.syncProgress, + WalletType: w.walletType, } } diff --git a/client/db/bolt/upgrades.go b/client/db/bolt/upgrades.go index 2b004877fc..ac6f4478d5 100644 --- a/client/db/bolt/upgrades.go +++ b/client/db/bolt/upgrades.go @@ -113,12 +113,6 @@ func getVersionTx(tx *bbolt.Tx) (uint32, error) { } func v1Upgrade(dbtx *bbolt.Tx) error { - const oldVersion = 0 - - if err := ensureVersion(dbtx, oldVersion); err != nil { - return err - } - bkt := dbtx.Bucket(appBucket) if bkt == nil { return fmt.Errorf("appBucket not found") @@ -173,12 +167,6 @@ func v2Upgrade(dbtx *bbolt.Tx) error { } func v3Upgrade(dbtx *bbolt.Tx) error { - const oldVersion = 2 - - if err := ensureVersion(dbtx, oldVersion); err != nil { - return err - } - // Upgrade the match proof. We just have to retrieve and re-store the // buckets. The decoder will recognize the the old version and add the new // field. @@ -263,6 +251,24 @@ func v5Upgrade(dbtx *bbolt.Tx) error { return credsBkt.Put(outerKeyParamsKey, legacyKeyParams) } +// Probably not worth doing. Just let decodeWallet_v0 append the nil and pass it +// up the chain. +// func v6Upgrade(dbtx *bbolt.Tx) error { +// wallets := dbtx.Bucket(walletsBucket) +// if wallets == nil { +// return fmt.Errorf("failed to open orders bucket") +// } + +// return wallets.ForEach(func(wid, _ []byte) error { +// wBkt := wallets.Bucket(wid) +// w, err := dexdb.DecodeWallet(getCopy(wBkt, walletKey)) +// if err != nil { +// return fmt.Errorf("DecodeWallet error: %v", err) +// } +// return wBkt.Put(walletKey, w.Encode()) +// }) +// } + func ensureVersion(tx *bbolt.Tx, ver uint32) error { dbVersion, err := getVersionTx(tx) if err != nil { @@ -357,6 +363,9 @@ func moveActiveOrders(tx *bbolt.Tx) error { } func doUpgrade(tx *bbolt.Tx, upgrade upgradefunc, newVersion uint32) error { + if err := ensureVersion(tx, newVersion-1); err != nil { + return err + } err := upgrade(tx) if err != nil { return fmt.Errorf("error upgrading DB: %v", err) diff --git a/client/db/types.go b/client/db/types.go index cb44a06dff..07a4f2f00c 100644 --- a/client/db/types.go +++ b/client/db/types.go @@ -102,7 +102,7 @@ type AccountInfo struct { // Encode the AccountInfo as bytes. func (ai *AccountInfo) Encode() []byte { - return dbBytes{2}. + return versionedBytes(2). AddData([]byte(ai.Host)). AddData(ai.Cert). AddData(ai.DEXPubKey.SerializeCompressed()). @@ -196,7 +196,7 @@ type AccountProof struct { // Encode encodes the AccountProof to a versioned blob. func (p *AccountProof) Encode() []byte { - return dbBytes{0}. + return versionedBytes(0). AddData([]byte(p.Host)). AddData(uint64Bytes(p.Stamp)). AddData(p.Sig) @@ -340,7 +340,7 @@ func (p *MatchProof) Encode() []byte { selfRevoked = encode.ByteTrue } - return dbBytes{MatchProofVer}. + return versionedBytes(MatchProofVer). AddData(p.Script). AddData(p.CounterContract). AddData(p.SecretHash). @@ -441,7 +441,7 @@ type OrderProof struct { // Encode encodes the OrderProof to a versioned blob. func (p *OrderProof) Encode() []byte { - return dbBytes{0}.AddData(p.DEXSig).AddData(p.Preimage) + return versionedBytes(0).AddData(p.DEXSig).AddData(p.Preimage) } // DecodeOrderProof decodes the versioned blob to an *OrderProof. @@ -469,7 +469,7 @@ func decodeOrderProof_v0(pushes [][]byte) (*OrderProof, error) { // encodeAssetBalance serializes an asset.Balance. func encodeAssetBalance(bal *asset.Balance) []byte { - return dbBytes{0}. + return versionedBytes(0). AddData(uint64Bytes(bal.Available)). AddData(uint64Bytes(bal.Immature)). AddData(uint64Bytes(bal.Locked)) @@ -507,7 +507,7 @@ type Balance struct { // Encode encodes the Balance to a versioned blob. func (b *Balance) Encode() []byte { - return dbBytes{0}. + return versionedBytes(0). AddData(encodeAssetBalance(&b.Balance)). AddData(uint64Bytes(encode.UnixMilliU(b.Stamp))) } @@ -546,6 +546,7 @@ func decodeBalance_v0(pushes [][]byte) (*Balance, error) { // Wallet is information necessary to create an asset.Wallet. type Wallet struct { AssetID uint32 + Type string Settings map[string]string Balance *Balance EncryptedPW []byte @@ -554,11 +555,12 @@ type Wallet struct { // Encode encodes the Wallet to a versioned blob. func (w *Wallet) Encode() []byte { - return dbBytes{0}. + return versionedBytes(1). AddData(uint32Bytes(w.AssetID)). AddData(config.Data(w.Settings)). AddData(w.EncryptedPW). - AddData([]byte(w.Address)) + AddData([]byte(w.Address)). + AddData([]byte(w.Type)) } // DecodeWallet decodes the versioned blob to a *Wallet. The Balance is NOT set; @@ -571,21 +573,31 @@ func DecodeWallet(b []byte) (*Wallet, error) { switch ver { case 0: return decodeWallet_v0(pushes) + case 1: + return decodeWallet_v1(pushes) } return nil, fmt.Errorf("unknown DecodeWallet version %d", ver) } func decodeWallet_v0(pushes [][]byte) (*Wallet, error) { - if len(pushes) != 4 { - return nil, fmt.Errorf("decodeWallet_v0: expected 4 pushes, got %d", len(pushes)) + // Add a push for wallet type. + pushes = append(pushes, []byte("")) + return decodeWallet_v1(pushes) +} + +func decodeWallet_v1(pushes [][]byte) (*Wallet, error) { + if len(pushes) != 5 { + return nil, fmt.Errorf("decodeWallet_v1: expected 5 pushes, got %d", len(pushes)) } - idB, settingsB, keyB, addressB := pushes[0], pushes[1], pushes[2], pushes[3] + idB, settingsB, keyB := pushes[0], pushes[1], pushes[2] + addressB, typeB := pushes[3], pushes[4] settings, err := config.Parse(settingsB) if err != nil { return nil, fmt.Errorf("unable to decode wallet settings") } return &Wallet{ AssetID: intCoder.Uint32(idB), + Type: string(typeB), Settings: settings, EncryptedPW: keyB, Address: string(addressB), @@ -602,7 +614,9 @@ func (w *Wallet) SID() string { return strconv.Itoa(int(w.AssetID)) } -type dbBytes = encode.BuildyBytes +func versionedBytes(v byte) encode.BuildyBytes { + return encode.BuildyBytes{v} +} var uint64Bytes = encode.Uint64Bytes var uint32Bytes = encode.Uint32Bytes @@ -616,7 +630,7 @@ type AccountBackup struct { // encodeDEXAccount serializes the details needed to backup a dex account. func encodeDEXAccount(acct *AccountInfo) []byte { - return dbBytes{1}. + return versionedBytes(1). AddData([]byte(acct.Host)). AddData(acct.LegacyEncKey). AddData(acct.DEXPubKey.SerializeCompressed()). @@ -655,7 +669,7 @@ func decodeDEXAccount(acctB []byte) (*AccountInfo, error) { // Serialize encodes an account backup as bytes. func (ab *AccountBackup) Serialize() []byte { - backup := dbBytes{0}.AddData(ab.KeyParams) + backup := versionedBytes(0).AddData(ab.KeyParams) for _, acct := range ab.Accounts { backup = backup.AddData(encodeDEXAccount(acct)) } @@ -852,7 +866,7 @@ func decodeNotification_v1(pushes [][]byte) (*Notification, error) { // Encode encodes the Notification to a versioned blob. func (n *Notification) Encode() []byte { - return dbBytes{1}. + return versionedBytes(1). AddData([]byte(n.NoteType)). AddData([]byte(n.SubjectText)). AddData([]byte(n.DetailText)). diff --git a/client/rpcserver/handlers.go b/client/rpcserver/handlers.go index 02d24179f6..cb10a3519c 100644 --- a/client/rpcserver/handlers.go +++ b/client/rpcserver/handlers.go @@ -169,6 +169,7 @@ func handleNewWallet(s *RPCServer, params *RawParams) *msgjson.ResponsePayload { } // Wallet does not exist yet. Try to create it. err = s.core.CreateWallet(form.appPass, form.walletPass, &core.WalletForm{ + Type: form.walletType, AssetID: form.assetID, Config: form.config, }) @@ -704,7 +705,7 @@ var helpMsgs = map[string]helpMsg{ }, newWalletRoute: { pwArgsShort: `"appPass" "walletPass"`, - argsShort: `assetID ("path" "settings")`, + argsShort: `assetID walletType ("path" "settings")`, cmdSummary: `Connect to a new wallet.`, pwArgsLong: `Password Args: appPass (string): The DEX client password. @@ -712,6 +713,7 @@ var helpMsgs = map[string]helpMsg{ argsLong: `Args: assetID (int): The asset's BIP-44 registered coin index. e.g. 42 for DCR. See https://github.com/satoshilabs/slips/blob/master/slip-0044.md + walletType (string): The wallet type. path (string): Optional. The path to a configuration file. settings (string): A JSON-encoded string->string mapping of additional configuration settings. These settings take precedence over any settings diff --git a/client/rpcserver/handlers_test.go b/client/rpcserver/handlers_test.go index 4814f4f157..babca9867c 100644 --- a/client/rpcserver/handlers_test.go +++ b/client/rpcserver/handlers_test.go @@ -23,6 +23,9 @@ import ( func verifyResponse(payload *msgjson.ResponsePayload, res interface{}, wantErrCode int) error { if wantErrCode != -1 { + if payload.Error == nil { + return errors.New("no error") + } if payload.Error.Code != wantErrCode { return errors.New("wrong error code") } @@ -267,6 +270,7 @@ func TestHandleNewWallet(t *testing.T) { PWArgs: []encode.PassBytes{pw, pw}, Args: []string{ "42", + "rpc", "username=tacotime", `{"field":"value"}`, }, @@ -275,6 +279,7 @@ func TestHandleNewWallet(t *testing.T) { PWArgs: []encode.PassBytes{pw, pw}, Args: []string{ "42", + "rpc", "username=tacotime", `{"field": value"}`, }, @@ -322,6 +327,7 @@ func TestHandleNewWallet(t *testing.T) { } r := &RPCServer{core: tc, wsServer: wsServer} payload := handleNewWallet(r, test.params) + res := "" if err := verifyResponse(payload, &res, test.wantErrCode); err != nil { t.Fatalf("%s: %v", test.name, err) diff --git a/client/rpcserver/types.go b/client/rpcserver/types.go index ed4f34ef73..04a326de6a 100644 --- a/client/rpcserver/types.go +++ b/client/rpcserver/types.go @@ -102,6 +102,7 @@ type openWalletForm struct { // newWalletForm is information necessary to create a new wallet. type newWalletForm struct { assetID uint32 + walletType string config map[string]string walletPass encode.PassBytes appPass encode.PassBytes @@ -236,27 +237,29 @@ func parseLoginArgs(params *RawParams) (encode.PassBytes, error) { } func parseNewWalletArgs(params *RawParams) (*newWalletForm, error) { - if err := checkNArgs(params, []int{2}, []int{1, 3}); err != nil { + if err := checkNArgs(params, []int{2}, []int{2, 4}); err != nil { return nil, err } assetID, err := checkUIntArg(params.Args[0], "assetID", 32) if err != nil { return nil, err } + req := &newWalletForm{ appPass: params.PWArgs[0], + walletType: params.Args[1], walletPass: params.PWArgs[1], assetID: uint32(assetID), } - if len(params.Args) > 1 { - req.config, err = config.Parse([]byte(params.Args[1])) + if len(params.Args) > 2 { + req.config, err = config.Parse([]byte(params.Args[2])) if err != nil { return nil, fmt.Errorf("config parse error: %v", err) } } - if len(params.Args) > 2 { + if len(params.Args) > 3 { cfg := make(map[string]string) - err := json.Unmarshal([]byte(params.Args[2]), &cfg) + err := json.Unmarshal([]byte(params.Args[3]), &cfg) if err != nil { return nil, fmt.Errorf("JSON parse error: %v", err) } diff --git a/client/rpcserver/types_test.go b/client/rpcserver/types_test.go index 123aedb0d3..8e2ac12078 100644 --- a/client/rpcserver/types_test.go +++ b/client/rpcserver/types_test.go @@ -78,6 +78,7 @@ func TestParseNewWalletArgs(t *testing.T) { pwArgs := []encode.PassBytes{pw, pw} args := []string{ id, + "spv", "rpclisten=127.0.0.0", } return &RawParams{PWArgs: pwArgs, Args: args} diff --git a/client/webserver/api.go b/client/webserver/api.go index 387d78b832..78854651ff 100644 --- a/client/webserver/api.go +++ b/client/webserver/api.go @@ -130,6 +130,7 @@ func (s *WebServer) apiNewWallet(w http.ResponseWriter, r *http.Request) { // Wallet does not exist yet. Try to create it. err = s.core.CreateWallet(pass, form.Pass, &core.WalletForm{ AssetID: form.AssetID, + Type: form.WalletType, Config: form.Config, }) if err != nil { @@ -505,11 +506,12 @@ func (s *WebServer) apiWalletSettings(w http.ResponseWriter, r *http.Request) { func (s *WebServer) apiDefaultWalletCfg(w http.ResponseWriter, r *http.Request) { form := &struct { AssetID uint32 `json:"assetID"` + Type string `json:"type"` }{} if !readPost(w, r, form) { return } - cfg, err := s.core.AutoWalletConfig(form.AssetID) + cfg, err := s.core.AutoWalletConfig(form.AssetID, form.Type) if err != nil { s.writeAPIError(w, fmt.Errorf("error getting wallet config: %w", err)) return @@ -607,8 +609,9 @@ func (s *WebServer) apiChangeAppPass(w http.ResponseWriter, r *http.Request) { // apiReconfig sets new configuration details for the wallet. func (s *WebServer) apiReconfig(w http.ResponseWriter, r *http.Request) { form := &struct { - AssetID uint32 `json:"assetID"` - Config map[string]string `json:"config"` + AssetID uint32 `json:"assetID"` + WalletType string `json:"walletType"` + Config map[string]string `json:"config"` // newWalletPW json field should be omitted in case caller isn't interested // in setting new password, passing null JSON value will cause an unmarshal // error. @@ -626,8 +629,11 @@ func (s *WebServer) apiReconfig(w http.ResponseWriter, r *http.Request) { return } // Update wallet settings. - err = s.core.ReconfigureWallet(pass, form.NewWalletPW, form.AssetID, - form.Config) + err = s.core.ReconfigureWallet(pass, form.NewWalletPW, &core.WalletForm{ + AssetID: form.AssetID, + Config: form.Config, + Type: form.WalletType, + }) if err != nil { s.writeAPIError(w, fmt.Errorf("reconfig error: %w", err)) return diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index f79752939e..c80a772ec2 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -400,14 +400,18 @@ type TCore struct { // TDriver implements the interface required of all exchange wallets. type TDriver struct{} -func (*TDriver) Open(*asset.WalletConfig, dex.Logger, dex.Network) (asset.Wallet, error) { - return nil, nil +func (drv *TDriver) Exists(walletType, dataDir string, settings map[string]string, net dex.Network) (bool, error) { + return true, nil } -func (*TDriver) Create(*asset.CreateWalletParams) error { +func (drv *TDriver) Create(*asset.CreateWalletParams) error { return nil } +func (*TDriver) Open(*asset.WalletConfig, dex.Logger, dex.Network) (asset.Wallet, error) { + return nil, nil +} + func (*TDriver) DecodeCoinID(coinID []byte) (string, error) { return asset.DecodeCoinID(0, coinID) // btc decoder } @@ -454,7 +458,10 @@ func (c *TCore) InitializeClient(pw, seed []byte) error { return nil } func (c *TCore) GetDEXConfig(host string, certI interface{}) (*core.Exchange, error) { - return tExchanges[host], nil + if xc := tExchanges[host]; xc != nil { + return xc, nil + } + return tExchanges[firstDEX], nil } // DiscoverAccount - use secondDEX = "thisdexwithalongname.com" to get paid = true. @@ -1022,8 +1029,6 @@ var winfos = map[uint32]*asset.WalletInfo{ 2: ltc.WalletInfo, 42: dcr.WalletInfo, 22: { - Name: "Monacoin", - ConfigOpts: configOpts, UnitInfo: dex.UnitInfo{ AtomicUnit: "atoms", Conventional: dex.Denomination{ @@ -1031,10 +1036,21 @@ var winfos = map[uint32]*asset.WalletInfo{ ConversionFactor: 1e8, }, }, + Name: "Monacoin", + AvailableWallets: []*asset.WalletDefinition{ + { + Type: "1", + Tab: "Native", + Seeded: true, + }, + { + Type: "2", + Tab: "External", + ConfigOpts: configOpts, + }, + }, }, 3: { - Name: "Dogecoin", - ConfigOpts: configOpts, UnitInfo: dex.UnitInfo{ AtomicUnit: "atoms", Conventional: dex.Denomination{ @@ -1042,10 +1058,12 @@ var winfos = map[uint32]*asset.WalletInfo{ ConversionFactor: 1e8, }, }, + Name: "Dogecoin", + AvailableWallets: []*asset.WalletDefinition{{ + ConfigOpts: configOpts, + }}, }, 28: { - Name: "Vertcoin", - ConfigOpts: configOpts, UnitInfo: dex.UnitInfo{ AtomicUnit: "Sats", Conventional: dex.Denomination{ @@ -1053,17 +1071,10 @@ var winfos = map[uint32]*asset.WalletInfo{ ConversionFactor: 1e8, }, }, - }, - 141: { - Name: "Komodo", - ConfigOpts: configOpts, - UnitInfo: dex.UnitInfo{ - AtomicUnit: "Sats", - Conventional: dex.Denomination{ - Unit: "KMD", - ConversionFactor: 1e8, - }, - }, + Name: "Vertcoin", + AvailableWallets: []*asset.WalletDefinition{{ + ConfigOpts: configOpts, + }}, }, } @@ -1088,6 +1099,7 @@ func (c *TCore) walletState(assetID uint32) *core.WalletState { Balance: c.balances[assetID], Units: winfos[assetID].UnitInfo.AtomicUnit, Encrypted: true, + Synced: true, } } @@ -1174,8 +1186,8 @@ func (c *TCore) WalletSettings(assetID uint32) (map[string]string, error) { return c.wallets[assetID].settings, nil } -func (c *TCore) ReconfigureWallet(aPW, nPW []byte, assetID uint32, cfg map[string]string) error { - c.wallets[assetID].settings = cfg +func (c *TCore) ReconfigureWallet(aPW, nPW []byte, form *core.WalletForm) error { + c.wallets[form.AssetID].settings = form.Config return nil } @@ -1202,7 +1214,7 @@ func (c *TCore) User() *core.User { return user } -func (c *TCore) AutoWalletConfig(assetID uint32) (map[string]string, error) { +func (c *TCore) AutoWalletConfig(assetID uint32, walletType string) (map[string]string, error) { return map[string]string{ "username": "tacotime", "password": "abc123", @@ -1213,13 +1225,12 @@ func (c *TCore) SupportedAssets() map[uint32]*core.SupportedAsset { c.mtx.RLock() defer c.mtx.RUnlock() return map[uint32]*core.SupportedAsset{ - 0: mkSupportedAsset("btc", c.wallets[0], c.balances[0]), - 42: mkSupportedAsset("dcr", c.wallets[42], c.balances[42]), - 2: mkSupportedAsset("ltc", c.wallets[2], c.balances[2]), - 22: mkSupportedAsset("mona", c.wallets[22], c.balances[22]), - 3: mkSupportedAsset("doge", c.wallets[3], c.balances[3]), - 28: mkSupportedAsset("vtc", c.wallets[28], c.balances[28]), - 141: mkSupportedAsset("kmd", c.wallets[141], c.balances[141]), + 0: mkSupportedAsset("btc", c.wallets[0], c.balances[0]), + 42: mkSupportedAsset("dcr", c.wallets[42], c.balances[42]), + 2: mkSupportedAsset("ltc", c.wallets[2], c.balances[2]), + 22: mkSupportedAsset("mona", c.wallets[22], c.balances[22]), + 3: mkSupportedAsset("doge", c.wallets[3], c.balances[3]), + 28: mkSupportedAsset("vtc", c.wallets[28], c.balances[28]), } } diff --git a/client/webserver/locales/en-us.go b/client/webserver/locales/en-us.go index 61f3b78bf4..f98944b54a 100644 --- a/client/webserver/locales/en-us.go +++ b/client/webserver/locales/en-us.go @@ -177,4 +177,13 @@ var EnUS = map[string]string{ "fee_price_header": "Fee", "fee_confs_header": "Confs", "SetupWallet": "Setup Wallet", + "waiting_wallet": "waiting for the wallet to sync", + "syncing": "syncing", + "Insufficient funds": "Insufficient funds", + "balance_wait": `It's time to pay your registration fee, but your new wallet doesn't have the balance yet. + Send at least plus some more for transaction fees + to your deposit address.`, + "Current Balance": "Current Balance", + "refreshing_in": `refreshing in 15 seconds`, + "change the wallet type": "change the wallet type", } diff --git a/client/webserver/site/src/css/forms.scss b/client/webserver/site/src/css/forms.scss index c4b2d6d4ed..d97a35055c 100644 --- a/client/webserver/site/src/css/forms.scss +++ b/client/webserver/site/src/css/forms.scss @@ -38,3 +38,42 @@ table.marketstable { border: 1px solid #686767; } } + +.wallet-tabs { + width: 100%; + display: flex; + justify-content: flex-start; + align-items: stretch; + + .wtab { + padding: 8px 20px; + font-size: 15px; + border-style: solid solid none solid; + border-radius: 5px 5px 0 0; + border-width: 1px; + border-color: #aaa; + opacity: 0.8; + background-color: $light_bg_1; + position: relative; + z-index: 150; + } + + .wtab:not(:first-child) { + border-left-style: none; + } + + .wtab.selected { + opacity: 1; + top: 1px; + background-color: $light_bg_2; + cursor: default; + } + + .wtab:not(.selected) { + cursor: pointer; + + &:hover { + opacity: 1; + } + } +} diff --git a/client/webserver/site/src/css/forms_dark.scss b/client/webserver/site/src/css/forms_dark.scss index 809c64872e..4411a6f0a0 100644 --- a/client/webserver/site/src/css/forms_dark.scss +++ b/client/webserver/site/src/css/forms_dark.scss @@ -3,3 +3,14 @@ body.dark { color: #a1a1a1; } } + +.wallet-tabs { + .wtab { + background-color: $dark_bg_1; + border-color: #333; + } + + .wtab.selected { + background-color: $dark_bg_2; + } +} diff --git a/client/webserver/site/src/css/main.scss b/client/webserver/site/src/css/main.scss index e63022449c..a471082c55 100644 --- a/client/webserver/site/src/css/main.scss +++ b/client/webserver/site/src/css/main.scss @@ -30,6 +30,11 @@ button { outline: none; } +select { + font-family: inherit; + padding: 5px 10px; +} + // bootstrap override .form-check-input { margin-top: 0.4em; @@ -99,8 +104,6 @@ button:focus { .card { width: 400px; - background-color: $light_bg_1; - border: 1px solid #aaa; border-radius: 3px; position: relative; @@ -372,6 +375,10 @@ div.popup-notes { background-color: $light_bg_1; } +.border1 { + border: 1px solid #aaa; +} + .bg2 { background-color: $light_bg_2; } @@ -410,7 +417,8 @@ hr.dashed { background-color: #7775; } -div.form-closer { +div.form-closer, +div.corner-bttn { position: absolute; right: 0; top: 0; @@ -448,3 +456,7 @@ div.border1 { #submitDEXAddr { max-height: 3em; } + +#depoAddr { + user-select: all; +} diff --git a/client/webserver/site/src/css/main_dark.scss b/client/webserver/site/src/css/main_dark.scss index e773f0aa5b..d105f51452 100644 --- a/client/webserver/site/src/css/main_dark.scss +++ b/client/webserver/site/src/css/main_dark.scss @@ -2,11 +2,6 @@ body.dark { background-color: #13202b; color: $font-color-dark; - .card { - background-color: $dark_bg_1; - border-color: #333; - } - header.maintop { background-color: black; border-bottom-style: none; @@ -37,6 +32,10 @@ body.dark { background-color: black; } + .border1 { + border: 1px solid #333; + } + .bg1 { background-color: $dark_bg_1; } diff --git a/client/webserver/site/src/html/forms.tmpl b/client/webserver/site/src/html/forms.tmpl index 3ce540512c..db3b7817de 100644 --- a/client/webserver/site/src/html/forms.tmpl +++ b/client/webserver/site/src/html/forms.tmpl @@ -1,11 +1,11 @@ {{define "walletConfigTemplates"}} -