From e4e5e28126f499570c26a110602526d1e69ac799 Mon Sep 17 00:00:00 2001 From: martonp Date: Tue, 21 Feb 2023 18:08:03 -0500 Subject: [PATCH 1/8] {client,server}/btc: BTC Fidelity Bonds This enbales BTC fidelity bonds on both the client and the server. The implementation is almost identical to DCR. Noteably, on the client, the `sendToAddress` wallet API is no longer used becuase sends need to respect the bond reserves. Instead, the transaciton is constructed in the client and passed to the wallet through `sendRawTransaction`. An `UnsignedCoinID` field is added to the asset.Bond struct in order to support BTC wallets that have segwit turned off. --- client/asset/bch/bch.go | 1 - client/asset/btc/btc.go | 849 +++++++++++++++++++++++----- client/asset/btc/btc_test.go | 240 ++++++-- client/asset/btc/coin_selection.go | 212 +++++++ client/asset/btc/electrum.go | 38 -- client/asset/btc/electrum_client.go | 173 ------ client/asset/btc/rpcclient.go | 41 -- client/asset/btc/simnet_test.go | 238 ++++++++ client/asset/btc/spv_test.go | 90 +-- client/asset/btc/spv_wrapper.go | 131 +---- client/asset/btc/wallet.go | 1 - client/asset/dcr/dcr.go | 18 +- client/asset/doge/doge.go | 1 - client/asset/estimation.go | 3 +- client/asset/interface.go | 11 +- client/asset/zec/zec.go | 1 - client/core/bond.go | 31 +- client/db/types.go | 75 ++- dex/networks/btc/script.go | 225 ++++++++ dex/networks/dcr/script.go | 4 +- dex/testing/dcrdex/harness.sh | 5 +- server/asset/btc/btc.go | 172 ++++++ server/auth/registrar.go | 7 - 23 files changed, 1849 insertions(+), 718 deletions(-) create mode 100644 client/asset/btc/coin_selection.go create mode 100644 client/asset/btc/simnet_test.go diff --git a/client/asset/bch/bch.go b/client/asset/bch/bch.go index 49a7410420..c66ca0b23d 100644 --- a/client/asset/bch/bch.go +++ b/client/asset/bch/bch.go @@ -198,7 +198,6 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) InitTxSizeBase: dexbtc.InitTxSizeBase, InitTxSize: dexbtc.InitTxSize, LegacyBalance: cfg.Type != walletTypeSPV, - LegacySendToAddr: true, // Bitcoin Cash uses the Cash Address encoding, which is Bech32, but not // indicative of segwit. We provide a custom encoder and decode to go // to/from a btcutil.Address and a string. diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index a0c4319a44..6f6713fc26 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -36,6 +36,7 @@ import ( "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/wallet" + "github.com/decred/dcrd/dcrec/secp256k1/v4" "github.com/decred/dcrd/rpcclient/v7" ) @@ -219,8 +220,7 @@ func CommonConfigOpts(symbol string /* upper-case */, withApiFallback bool) []*a "necessary. Otherwise, excess funds may be reserved to fund the order " + "until the first swap contract is broadcast during match settlement, " + "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.", + "mining fees are paid.", IsBoolean: true, DefaultValue: false, }, @@ -388,9 +388,6 @@ type BTCCloneCFG struct { ManualMedianTime bool // OmitRPCOptionsArg is for clones that don't take an options argument. OmitRPCOptionsArg bool - // LegacySendToAddr sents legacy raw tx which does not have positional fee - // rate param. - LegacySendToAddr bool } // outPoint is the hash and output index of a transaction output. @@ -815,6 +812,23 @@ type baseWallet struct { findRedemptionMtx sync.RWMutex findRedemptionQueue map[outPoint]*findRedemptionReq + + reservesMtx sync.RWMutex // frequent reads for balance, infrequent updates + // bondReservesEnforced is used to reserve unspent amounts for upcoming bond + // transactions, including projected transaction fees, and does not include + // amounts that are currently locked in unspent bonds, which are in + // bondReservesUsed. When bonds are created, bondReservesEnforced is + // decremented and bondReservesUsed are incremented; when bonds are + // refunded, the reverse. bondReservesEnforced may become negative during + // the unbonding process. + bondReservesEnforced int64 // set by ReserveBondFunds, modified by bondSpent and bondLocked + bondReservesUsed uint64 // set by RegisterUnspent, modified by bondSpent and bondLocked + // When bondReservesEnforced is non-zero, bondReservesNominal is the + // cumulative of all ReserveBondFunds and RegisterUnspent input amounts, + // with no fee padding. It includes the future and live (currently unspent) + // bond amounts. This amount only changes via ReserveBondFunds, and it is + // used to recognize when all reserves have been released. + bondReservesNominal int64 // only set by ReserveBondFunds } func (w *baseWallet) fallbackFeeRate() uint64 { @@ -873,6 +887,7 @@ var _ asset.LogFiler = (*ExchangeWalletSPV)(nil) var _ asset.Recoverer = (*ExchangeWalletSPV)(nil) var _ asset.PeerManager = (*ExchangeWalletSPV)(nil) var _ asset.TxFeeEstimator = (*intermediaryWallet)(nil) +var _ asset.Bonder = (*baseWallet)(nil) // RecoveryCfg is the information that is transferred from the old wallet // to the new one when the wallet is recovered. @@ -1090,7 +1105,6 @@ func newRPCWallet(requester RawRequester, cfg *BTCCloneCFG, parsedCfg *RPCWallet chainParams: cfg.ChainParams, omitAddressType: cfg.OmitAddressType, legacySignTx: cfg.LegacySignTxRPC, - legacySendToAddr: cfg.LegacySendToAddr, booleanGetBlock: cfg.BooleanGetBlockRPC, unlockSpends: cfg.UnlockSpends, deserializeTx: btc.deserializeTx, @@ -1363,7 +1377,17 @@ func (btc *baseWallet) Reconfigure(ctx context.Context, cfg *asset.WalletConfig, return false, err } - btc.cfgV.Store(newCfg) // probably won't matter if restart/reinit required + oldCfg := btc.cfgV.Swap(newCfg).(*baseWalletConfig) + if oldCfg.feeRateLimit != newCfg.feeRateLimit { + // Adjust the bond reserves fee buffer, if enforcing. + btc.reservesMtx.Lock() + if btc.bondReservesNominal != 0 { + btc.bondReservesEnforced += int64(bondsFeeBuffer(btc.segwit, newCfg.feeRateLimit)) - + int64(bondsFeeBuffer(btc.segwit, oldCfg.feeRateLimit)) + } + btc.reservesMtx.Unlock() + } + return restart, nil } @@ -1439,9 +1463,7 @@ func (btc *baseWallet) OwnsDepositAddress(address string) (bool, error) { return btc.node.ownsAddress(addr) } -// Balance returns the total available funds in the wallet. Part of the -// asset.Wallet interface. -func (btc *baseWallet) Balance() (*asset.Balance, error) { +func (btc *baseWallet) balance() (*asset.Balance, error) { if btc.useLegacyBalance || btc.zecStyleBalance { return btc.legacyBalance() } @@ -1458,9 +1480,49 @@ func (btc *baseWallet) Balance() (*asset.Balance, error) { Available: toSatoshi(balances.Mine.Trusted) - locked, Immature: toSatoshi(balances.Mine.Immature + balances.Mine.Untrusted), Locked: locked, + Other: make(map[string]uint64), }, nil } +// Balance should return the total available funds in the wallet. +func (btc *baseWallet) Balance() (*asset.Balance, error) { + bal, err := btc.balance() + if err != nil { + return nil, err + } + + reserves := btc.reserves() + if reserves > bal.Available { + btc.log.Warnf("Available balance is below configured reserves: %f < %f", + toBTC(bal.Available), toBTC(reserves)) + bal.Other["Reserves Deficit"] = reserves - bal.Available + reserves = bal.Available + } + bal.Other["Bond Reserves (locked)"] = reserves + bal.Available -= reserves + bal.Locked += reserves + + return bal, nil +} + +func bondsFeeBuffer(segwit bool, highFeeRate uint64) uint64 { + const inputCount uint64 = 12 // plan for lots of inputs + var largeBondTxSize uint64 + if segwit { + largeBondTxSize = dexbtc.MinimumTxOverhead + dexbtc.P2WSHOutputSize + 1 + dexbtc.BondPushDataSize + + dexbtc.P2WPKHOutputSize + inputCount*dexbtc.RedeemP2WPKHInputSize + } else { + largeBondTxSize = dexbtc.MinimumTxOverhead + dexbtc.P2SHOutputSize + 1 + dexbtc.BondPushDataSize + + dexbtc.P2PKHOutputSize + inputCount*dexbtc.RedeemP2PKHInputSize + } + + // Normally we can plan on just 2 parallel "tracks" (single bond overlap + // when bonds are expired and waiting to refund) but that may increase + // temporarily if target tier is adjusted up. + const parallelTracks uint64 = 4 + return parallelTracks * largeBondTxSize * highFeeRate +} + // legacyBalance is used for clones that are < node version 0.18 and so don't // have 'getbalances'. func (btc *baseWallet) legacyBalance() (*asset.Balance, error) { @@ -1648,7 +1710,7 @@ func (btc *baseWallet) maxOrder(lotSize, feeSuggestion, maxFeeRate uint64) (utxo lots := avail / (lotSize + basicFee) for lots > 0 { est, _, _, err := btc.estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate, - utxos, btc.useSplitTx(), 1.0) + utxos, true, 1.0) // The only failure mode of estimateSwap -> btc.fund is when there is // not enough funds, so if an error is encountered, count down the lots // and repeat until we have enough. @@ -1676,9 +1738,7 @@ func (btc *baseWallet) PreSwap(req *asset.PreSwapForm) (*asset.PreSwap, error) { // Start with the maxOrder at the default configuration. This gets us the // utxo set, the network fee rate, and the wallet's maximum order size. The // utxo set can then be used repeatedly in estimateSwap at virtually zero - // cost since there are no more RPC calls. The utxo set is only used once - // right now, but when order-time options are implemented, the utxos will be - // used to calculate option availability and fees. + // cost since there are no more RPC calls. utxos, maxEst, err := btc.maxOrder(req.LotSize, req.FeeSuggestion, req.MaxFeeRate) if err != nil { return nil, err @@ -1710,23 +1770,21 @@ func (btc *baseWallet) PreSwap(req *asset.PreSwapForm) (*asset.PreSwap, error) { est, _, _, err := btc.estimateSwap(req.Lots, req.LotSize, req.FeeSuggestion, req.MaxFeeRate, utxos, split, bump) if err != nil { - return nil, fmt.Errorf("estimation failed: %v", err) + btc.log.Warnf("estimateSwap failure: %v", err) } - var opts []*asset.OrderOption - - // Only offer the split option for standing orders. - if !req.Immediate { - if splitOpt := btc.splitOption(req, utxos, bump); splitOpt != nil { - opts = append(opts, splitOpt) - } - } + // Always offer the split option, even for non-standing orders since + // immediately spendable change many be desirable regardless. + opts := []*asset.OrderOption{btc.splitOption(req, utxos, bump)} // Figure out what our maximum available fee bump is, within our 2x hard // limit. var maxBump float64 var maxBumpEst *asset.SwapEstimate for maxBump = 2.0; maxBump > 1.01; maxBump -= 0.1 { + if est == nil { + break + } tryEst, splitUsed, _, err := btc.estimateSwap(req.Lots, req.LotSize, req.FeeSuggestion, req.MaxFeeRate, utxos, split, maxBump) // If the split used wasn't the configured value, this option is not @@ -1779,7 +1837,7 @@ func (btc *baseWallet) PreSwap(req *asset.PreSwapForm) (*asset.PreSwap, error) { } return &asset.PreSwap{ - Estimate: est, + Estimate: est, // may be nil so we can present options, which in turn affect estimate feasibility Options: opts, }, nil } @@ -1841,42 +1899,41 @@ func (btc *baseWallet) SingleLotSwapFees(form *asset.PreSwapForm) (fees uint64, // splitOption constructs an *asset.OrderOption with customized text based on the // difference in fees between the configured and test split condition. func (btc *baseWallet) splitOption(req *asset.PreSwapForm, utxos []*compositeUTXO, bump float64) *asset.OrderOption { + opt := &asset.OrderOption{ + ConfigOption: asset.ConfigOption{ + Key: splitKey, + DisplayName: "Pre-size Funds", + IsBoolean: true, + DefaultValue: btc.useSplitTx(), // not nil interface + ShowByDefault: true, + }, + Boolean: &asset.BooleanConfig{}, + } + noSplitEst, _, noSplitLocked, err := btc.estimateSwap(req.Lots, req.LotSize, req.FeeSuggestion, req.MaxFeeRate, utxos, false, bump) if err != nil { btc.log.Errorf("estimateSwap (no split) error: %v", err) - return nil + opt.Boolean.Reason = fmt.Sprintf("estimate without a split failed with \"%v\"", err) + return opt // utility and overlock report unavailable, but show the option } splitEst, splitUsed, splitLocked, err := btc.estimateSwap(req.Lots, req.LotSize, req.FeeSuggestion, req.MaxFeeRate, utxos, true, bump) if err != nil { btc.log.Errorf("estimateSwap (with split) error: %v", err) - return nil + opt.Boolean.Reason = fmt.Sprintf("estimate with a split failed with \"%v\"", err) + return opt // utility and overlock report unavailable, but show the option } symbol := strings.ToUpper(btc.symbol) - opt := &asset.OrderOption{ - ConfigOption: asset.ConfigOption{ - Key: splitKey, - DisplayName: "Pre-size Funds", - IsBoolean: true, - DefaultValue: false, // not nil interface - ShowByDefault: true, - }, - Boolean: &asset.BooleanConfig{}, - } - if !splitUsed || splitLocked >= noSplitLocked { // locked check should be redundant opt.Boolean.Reason = fmt.Sprintf("avoids no %s overlock for this order (ignored)", symbol) opt.Description = fmt.Sprintf("A split transaction for this order avoids no %s overlock, "+ "but adds additional fees.", symbol) + opt.DefaultValue = false return opt // not enabled by default, but explain why } - // Since it is usable, apply the user's default value, and set the - // reason and description. - opt.DefaultValue = btc.useSplitTx() - overlock := noSplitLocked - splitLocked pctChange := (float64(splitEst.RealisticWorstCase)/float64(noSplitEst.RealisticWorstCase) - 1) * 100 if pctChange > 1 { @@ -1900,6 +1957,7 @@ func (btc *baseWallet) estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate uin for _, utxo := range utxos { avail += utxo.amount } + reserves := btc.reserves() // If there is a fee bump, the networkFeeRate can be higher than the // MaxFeeRate @@ -1911,40 +1969,40 @@ func (btc *baseWallet) estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate uin } val := lots * lotSize - // This enough func does not account for a split transaction at the start, + // The orderEnough func does not account for a split transaction at the start, // so it is possible that funding for trySplit would actually choose more // UTXOs. Actual order funding accounts for this. For this estimate, we will // just not use a split tx if the split-adjusted required funds exceeds the // total value of the UTXO selected with this enough closure. - enough := func(inputsSize, inputsVal uint64) bool { - reqFunds := calc.RequiredOrderFundsAlt(val, inputsSize, lots, btc.initTxSizeBase, - btc.initTxSize, bumpedMaxRate) // no +splitMaxFees so this is accurate without split - return inputsVal >= reqFunds - } - - sum, inputsSize, _, _, _, _, err := fund(utxos, enough) + sum, _, inputsSize, _, _, _, _, err := tryFund(utxos, + orderEnough(val, lots, bumpedMaxRate, btc.initTxSizeBase, btc.initTxSize, btc.segwit, trySplit)) if err != nil { return nil, false, 0, fmt.Errorf("error funding swap value %s: %w", amount(val), err) } - reqFunds := calc.RequiredOrderFundsAlt(val, uint64(inputsSize), lots, - btc.initTxSizeBase, btc.initTxSize, bumpedMaxRate) // same as in enough func - maxFees := reqFunds - val + digestInputs := func(inputsSize uint64) (reqFunds, maxFees, estHighFees, estLowFees uint64) { + reqFunds = calc.RequiredOrderFundsAlt(val, inputsSize, lots, + btc.initTxSizeBase, btc.initTxSize, bumpedMaxRate) // same as in enough func + maxFees = reqFunds - val - estHighFunds := calc.RequiredOrderFundsAlt(val, uint64(inputsSize), lots, - btc.initTxSizeBase, btc.initTxSize, bumpedNetRate) - estHighFees := estHighFunds - val + estHighFunds := calc.RequiredOrderFundsAlt(val, inputsSize, lots, + btc.initTxSizeBase, btc.initTxSize, bumpedNetRate) + estHighFees = estHighFunds - val + + estLowFunds := calc.RequiredOrderFundsAlt(val, inputsSize, 1, + btc.initTxSizeBase, btc.initTxSize, bumpedNetRate) // best means single multi-lot match, even better than batch + estLowFees = estLowFunds - val + return + } - estLowFunds := calc.RequiredOrderFundsAlt(val, uint64(inputsSize), 1, - btc.initTxSizeBase, btc.initTxSize, bumpedNetRate) // best means single multi-lot match, even better than batch - estLowFees := estLowFunds - val + reqFunds, maxFees, estHighFees, estLowFees := digestInputs(inputsSize) // Math for split transactions is a little different. if trySplit { - _, splitMaxFees := btc.splitBaggageFees(bumpedMaxRate) - _, splitFees := btc.splitBaggageFees(bumpedNetRate) + _, splitMaxFees := btc.splitBaggageFees(bumpedMaxRate, false) + _, splitFees := btc.splitBaggageFees(bumpedNetRate, false) reqTotal := reqFunds + splitMaxFees // ~ rather than actually fund()ing again - if reqTotal <= sum { + if reqTotal <= sum && sum-reqTotal >= reserves { return &asset.SwapEstimate{ Lots: lots, Value: val, @@ -1955,6 +2013,20 @@ func (btc *baseWallet) estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate uin } } + if sum > avail-reserves { + if trySplit { + return nil, false, 0, errors.New("eats bond reserves") + } + + kept := leastOverFund(reserves, utxos) + utxos := utxoSetDiff(utxos, kept) + sum, _, inputsSize, _, _, _, _, err = tryFund(utxos, orderEnough(val, lots, bumpedMaxRate, btc.initTxSizeBase, btc.initTxSize, btc.segwit, false)) + if err != nil { + return nil, false, 0, fmt.Errorf("error funding swap value %s: %w", amount(val), err) + } + _, maxFees, estHighFees, estLowFees = digestInputs(inputsSize) + } + return &asset.SwapEstimate{ Lots: lots, Value: val, @@ -2091,40 +2163,38 @@ func (btc *baseWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes, er return nil, nil, fmt.Errorf("error parsing swap options: %w", err) } - btc.fundingMtx.Lock() // before getting spendable utxos from wallet - defer btc.fundingMtx.Unlock() // after we update the map and lock in the wallet - - utxos, _, avail, err := btc.spendableUTXOs(0) - if err != nil { - return nil, nil, fmt.Errorf("error parsing unspent outputs: %w", err) - } - if avail < ord.Value { - return nil, nil, fmt.Errorf("insufficient funds. %s requested, %s available", - ordValStr, amount(avail)) - } - bumpedMaxRate, err := calcBumpedRate(ord.MaxFeeRate, customCfg.FeeBump) if err != nil { btc.log.Errorf("calcBumpRate error: %v", err) } - enough := func(inputsSize, inputsVal uint64) bool { - reqFunds := calc.RequiredOrderFundsAlt(ord.Value, inputsSize, ord.MaxSwapCount, - btc.initTxSizeBase, btc.initTxSize, bumpedMaxRate) - return inputsVal >= reqFunds - } - - sum, size, coins, fundingCoins, redeemScripts, spents, err := fund(utxos, enough) - if err != nil { - return nil, nil, fmt.Errorf("error funding swap value of %s: %w", amount(ord.Value), err) - } - + // If a split is not requested, but is forced, create an extra output from + // the split tx to help avoid a forced split in subsequent orders. + var extraSplitOutput uint64 useSplit := btc.useSplitTx() if customCfg.Split != nil { useSplit = *customCfg.Split } - if useSplit && !ord.Immediate { + reserves := btc.reserves() + minConfs := uint32(0) + coins, fundingCoins, spents, redeemScripts, inputsSize, sum, err := btc.fund(reserves, minConfs, true, + orderEnough(ord.Value, ord.MaxSwapCount, bumpedMaxRate, btc.initTxSizeBase, btc.initTxSize, btc.segwit, useSplit)) + if err != nil { + if !useSplit && reserves > 0 { + // Force a split if funding failure may be due to reserves. + btc.log.Infof("Retrying order funding with a forced split transaction to help respect reserves.") + useSplit = true + coins, fundingCoins, spents, redeemScripts, inputsSize, sum, err = btc.fund(reserves, minConfs, true, + orderEnough(ord.Value, ord.MaxSwapCount, bumpedMaxRate, btc.initTxSizeBase, btc.initTxSize, btc.segwit, useSplit)) + extraSplitOutput = reserves + bondsFeeBuffer(btc.segwit, btc.feeRateLimit()) + } + if err != nil { + return nil, nil, fmt.Errorf("error funding swap value of %s: %w", amount(ord.Value), err) + } + } + + if useSplit { // We apply the bumped fee rate to the split transaction when the // PreSwap is created, so we use that bumped rate here too. // But first, check that it's within bounds. @@ -2146,39 +2216,93 @@ func (btc *baseWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes, er } splitCoins, split, err := btc.split(ord.Value, ord.MaxSwapCount, spents, - uint64(size), fundingCoins, splitFeeRate, bumpedMaxRate) + inputsSize, fundingCoins, splitFeeRate, bumpedMaxRate, extraSplitOutput) if err != nil { + if err := btc.ReturnCoins(coins); err != nil { + btc.log.Errorf("Error returning coins: %v", err) + } return nil, nil, err } else if split { + fmt.Printf("original coins: %s, split coins %s\n", coins, splitCoins) return splitCoins, []dex.Bytes{nil}, nil // no redeem script required for split tx output } - return splitCoins, redeemScripts, nil // splitCoins == coins + return coins, redeemScripts, nil // splitCoins == coins } btc.log.Infof("Funding %s %s order with coins %v worth %s", ordValStr, btc.symbol, coins, amount(sum)) - err = btc.node.lockUnspent(false, spents) + return coins, redeemScripts, nil +} + +func (btc *baseWallet) fundInternal(keep uint64, minConfs uint32, lockUnspents bool, + enough func(size, sum uint64) (bool, uint64)) ( + coins asset.Coins, fundingCoins map[outPoint]*utxo, spents []*output, redeemScripts []dex.Bytes, size, sum uint64, err error) { + utxos, _, avail, err := btc.spendableUTXOs(minConfs) if err != nil { - return nil, nil, fmt.Errorf("LockUnspent error: %w", err) + return nil, nil, nil, nil, 0, 0, fmt.Errorf("error getting spendable utxos: %w", err) } - for pt, utxo := range fundingCoins { - btc.fundingCoins[pt] = utxo + if keep > 0 { + kept := leastOverFund(keep, utxos) + btc.log.Debugf("Setting aside %v BTC in %d UTXOs to respect the %v BTC reserved amount", + toBTC(sumUTXOs(kept)), len(kept), toBTC(keep)) + utxosPruned := utxoSetDiff(utxos, kept) + sum, _, size, coins, fundingCoins, redeemScripts, spents, err = tryFund(utxosPruned, enough) + if err != nil { + btc.log.Debugf("Unable to fund order with UTXOs set aside (%v), trying again with full UTXO set.", err) + } + } + if len(spents) == 0 { // either keep is zero or it failed with utxosPruned + // Without utxos set aside for keep, we have to consider any spendable + // change (extra) that the enough func grants us. + var extra uint64 + sum, extra, size, coins, fundingCoins, redeemScripts, spents, err = tryFund(utxos, enough) + if err != nil { + return nil, nil, nil, nil, 0, 0, err + } + if avail-sum+extra < keep { + return nil, nil, nil, nil, 0, 0, asset.ErrInsufficientBalance + } + // else we got lucky with the legacy funding approach and there was + // either available unspent or the enough func granted spendable change. + if keep > 0 && extra > 0 { + btc.log.Debugf("Funding succeeded with %v BTC in spendable change.", toBTC(extra)) + } } - return coins, redeemScripts, nil + if lockUnspents { + err = btc.node.lockUnspent(false, spents) + if err != nil { + return nil, nil, nil, nil, 0, 0, fmt.Errorf("LockUnspent error: %w", err) + } + for pt, utxo := range fundingCoins { + btc.fundingCoins[pt] = utxo + } + } + + return coins, fundingCoins, spents, redeemScripts, size, sum, err +} + +func (btc *baseWallet) fund(keep uint64, minConfs uint32, lockUnspents bool, + enough func(size, sum uint64) (bool, uint64)) ( + coins asset.Coins, fundingCoins map[outPoint]*utxo, spents []*output, redeemScripts []dex.Bytes, size, sum uint64, err error) { + + btc.fundingMtx.Lock() + defer btc.fundingMtx.Unlock() + + return btc.fundInternal(keep, minConfs, lockUnspents, enough) } -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) { +func tryFund(utxos []*compositeUTXO, + enough func(uint64, uint64) (bool, uint64)) ( + sum, extra, size uint64, coins asset.Coins, fundingCoins map[outPoint]*utxo, redeemScripts []dex.Bytes, spents []*output, err error) { fundingCoins = make(map[outPoint]*utxo) isEnoughWith := func(unspent *compositeUTXO) bool { - 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 + ok, _ := enough(size+uint64(unspent.input.VBytes()), sum+unspent.amount) + return ok } addUTXO := func(unspent *compositeUTXO) { @@ -2187,7 +2311,7 @@ func fund(utxos []*compositeUTXO, enough func(uint64, uint64) bool) ( coins = append(coins, op) redeemScripts = append(redeemScripts, unspent.redeemScript) spents = append(spents, op) - size += unspent.input.VBytes() + size += uint64(unspent.input.VBytes()) fundingCoins[op.pt] = unspent.utxo sum += v } @@ -2225,6 +2349,7 @@ func fund(utxos []*compositeUTXO, enough func(uint64, uint64) bool) ( // No need to check idx == len(okUTXOs). We already verified that the last // utxo passes above. addUTXO(okUTXOs[idx]) + _, extra = enough(size, sum) return true } } @@ -2232,7 +2357,7 @@ func fund(utxos []*compositeUTXO, enough func(uint64, uint64) bool) ( // 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. "+ + return 0, 0, 0, nil, nil, nil, nil, fmt.Errorf("not enough to cover requested funds. "+ "%s available in %d UTXOs", amount(sum), len(coins)) } } @@ -2260,7 +2385,7 @@ func fund(utxos []*compositeUTXO, enough func(uint64, uint64) bool) ( // would already have an output of just the right size, and that would be // recognized here. func (btc *baseWallet) split(value uint64, lots uint64, outputs []*output, inputsSize uint64, - fundingCoins map[outPoint]*utxo, suggestedFeeRate, bumpedMaxRate uint64) (asset.Coins, bool, error) { + fundingCoins map[outPoint]*utxo, suggestedFeeRate, bumpedMaxRate, extraOutput uint64) (asset.Coins, bool, error) { var err error defer func() { @@ -2278,7 +2403,7 @@ func (btc *baseWallet) split(value uint64, lots uint64, outputs []*output, input // Calculate the extra fees associated with the additional inputs, outputs, // and transaction overhead, and compare to the excess that would be locked. - swapInputSize, baggage := btc.splitBaggageFees(bumpedMaxRate) + swapInputSize, baggage := btc.splitBaggageFees(bumpedMaxRate, extraOutput > 0) var coinSum uint64 coins := make(asset.Coins, 0, len(outputs)) @@ -2317,6 +2442,18 @@ func (btc *baseWallet) split(value uint64, lots uint64, outputs []*output, input } baseTx.AddTxOut(wire.NewTxOut(int64(reqFunds), splitScript)) + if extraOutput > 0 { + addr, err := btc.node.changeAddress() + if err != nil { + return nil, false, fmt.Errorf("error creating split transaction address: %w", err) + } + splitScript, err := txscript.PayToAddrScript(addr) + if err != nil { + return nil, false, fmt.Errorf("error creating split tx script: %w", err) + } + baseTx.AddTxOut(wire.NewTxOut(int64(extraOutput), splitScript)) + } + // Grab a change address. changeAddr, err := btc.node.changeAddress() if err != nil { @@ -2324,9 +2461,9 @@ func (btc *baseWallet) split(value uint64, lots uint64, outputs []*output, input } // Sign, add change, and send the transaction. - msgTx, err := btc.sendWithReturn(baseTx, changeAddr, coinSum, reqFunds, suggestedFeeRate) + msgTx, err := btc.sendWithReturn(baseTx, changeAddr, coinSum, reqFunds+extraOutput, suggestedFeeRate) if err != nil { - return nil, false, err + return nil, false, fmt.Errorf("error sending tx: %w", err) } txHash := btc.hashTx(msgTx) @@ -2340,6 +2477,12 @@ func (btc *baseWallet) split(value uint64, lots uint64, outputs []*output, input amount: reqFunds, }} + // Unlock spent coins + returnErr := btc.ReturnCoins(coins) + if returnErr != nil { + btc.log.Errorf("error unlocking spent coins: %v", err) + } + btc.log.Infof("Funding %s %s order with split output coin %v from original coins %v", valueStr, btc.symbol, op, coins) btc.log.Infof("Sent split transaction %s to accommodate swap of size %s %s + fees = %s", @@ -2351,13 +2494,19 @@ func (btc *baseWallet) split(value uint64, lots uint64, outputs []*output, input } // splitBaggageFees is the fees associated with adding a split transaction. -func (btc *baseWallet) splitBaggageFees(maxFeeRate uint64) (swapInputSize, baggage uint64) { +func (btc *baseWallet) splitBaggageFees(maxFeeRate uint64, extraOutput bool) (swapInputSize, baggage uint64) { if btc.segwit { baggage = maxFeeRate * splitTxBaggageSegwit + if extraOutput { + baggage += maxFeeRate * dexbtc.P2WPKHOutputSize + } swapInputSize = dexbtc.RedeemP2WPKHInputTotalSize return } baggage = maxFeeRate * splitTxBaggage + if extraOutput { + baggage += maxFeeRate * dexbtc.P2PKHOutputSize + } swapInputSize = dexbtc.RedeemP2PKHInputSize return } @@ -2551,25 +2700,33 @@ func (btc *baseWallet) Locked() bool { return btc.node.locked() } -// fundedTx creates and returns a new MsgTx with the provided coins as inputs. -func (btc *baseWallet) fundedTx(coins asset.Coins) (*wire.MsgTx, uint64, []outPoint, error) { - baseTx := wire.NewMsgTx(btc.txVersion()) +func (btc *baseWallet) addInputsToTx(tx *wire.MsgTx, coins asset.Coins) (uint64, []outPoint, error) { var totalIn uint64 // Add the funding utxos. pts := make([]outPoint, 0, len(coins)) for _, coin := range coins { op, err := btc.convertCoin(coin) if err != nil { - return nil, 0, nil, fmt.Errorf("error converting coin: %w", err) + return 0, nil, fmt.Errorf("error converting coin: %w", err) } if op.value == 0 { - return nil, 0, nil, fmt.Errorf("zero-valued output detected for %s:%d", op.txHash(), op.vout()) + return 0, nil, fmt.Errorf("zero-valued output detected for %s:%d", op.txHash(), op.vout()) } totalIn += op.value txIn := wire.NewTxIn(op.wireOutPoint(), []byte{}, nil) - baseTx.AddTxIn(txIn) + tx.AddTxIn(txIn) pts = append(pts, op.pt) } + return totalIn, pts, nil +} + +// fundedTx creates and returns a new MsgTx with the provided coins as inputs. +func (btc *baseWallet) fundedTx(coins asset.Coins) (*wire.MsgTx, uint64, []outPoint, error) { + baseTx := wire.NewMsgTx(btc.txVersion()) + totalIn, pts, err := btc.addInputsToTx(baseTx, coins) + if err != nil { + return nil, 0, nil, err + } return baseTx, totalIn, pts, nil } @@ -2767,12 +2924,7 @@ func (btc *baseWallet) signedAccelerationTx(previousTxs []*GetTransactionResult, var additionalInputs asset.Coins if fundsRequired > orderChange.value { // If change not enough, need to use other UTXOs. - utxos, _, _, err := btc.spendableUTXOs(1) - if err != nil { - return makeError(err) - } - - _, _, additionalInputs, _, _, _, err = fund(utxos, func(inputSize, inputsVal uint64) bool { + enough := func(inputSize, inputsVal uint64) (bool, uint64) { txSize := dexbtc.MinimumTxOverhead + inputSize // add the order change as an input @@ -2791,8 +2943,12 @@ func (btc *baseWallet) signedAccelerationTx(previousTxs []*GetTransactionResult, } totalFees := additionalFeesRequired + txSize*newFeeRate - return totalFees+requiredForRemainingSwaps <= inputsVal+orderChange.value - }) + totalReq := requiredForRemainingSwaps + totalFees + totalVal := inputsVal + orderChange.value + return totalReq <= totalVal, totalVal - totalReq + } + minConfs := uint32(1) + additionalInputs, _, _, _, _, _, err = btc.fundInternal(btc.reserves(), minConfs, false, enough) if err != nil { return makeError(fmt.Errorf("failed to fund acceleration tx: %w", err)) } @@ -2876,6 +3032,7 @@ func accelerateOrder(btc *baseWallet, swapCoins, accelerationCoins []dex.Bytes, if err != nil { return nil, "", err } + _, err = btc.broadcastTx(signedTx) if err != nil { return nil, "", err @@ -4125,24 +4282,50 @@ func (btc *baseWallet) send(address string, val uint64, feeRate uint64, subtract if err != nil { return nil, 0, 0, fmt.Errorf("PayToAddrScript error: %w", err) } - txHash, err := btc.node.sendToAddress(address, val, feeRate, subtract) + + baseSize := dexbtc.MinimumTxOverhead + if btc.segwit { + baseSize += dexbtc.P2WPKHOutputSize * 2 + } else { + baseSize += dexbtc.P2PKHOutputSize * 2 + } + + btc.fundingMtx.Lock() + defer btc.fundingMtx.Unlock() + + enough := sendEnough(val, feeRate, subtract, uint64(baseSize), btc.segwit, true) + minConfs := uint32(0) + coins, _, _, _, inputsSize, _, err := btc.fundInternal(btc.reserves(), minConfs, false, enough) if err != nil { - return nil, 0, 0, fmt.Errorf("SendToAddress error: %w", err) + return nil, 0, 0, fmt.Errorf("error funding transaction: %w", err) } - txRaw, _, err := btc.rawWalletTx(txHash) + + fundedTx, totalIn, _, err := btc.fundedTx(coins) if err != nil { - return nil, 0, 0, fmt.Errorf("failed to locate new wallet transaction %v: %w", txHash, err) + return nil, 0, 0, fmt.Errorf("error adding inputs to transaction: %w", err) + } + + fees := feeRate * (inputsSize + uint64(baseSize)) + var toSend uint64 + if subtract { + toSend = val - fees + } else { + toSend = val } - tx, err := btc.deserializeTx(txRaw) + fundedTx.AddTxOut(wire.NewTxOut(int64(toSend), pay2script)) + + changeAddr, err := btc.node.changeAddress() if err != nil { - return nil, 0, 0, fmt.Errorf("error decoding transaction: %w", err) + return nil, 0, 0, fmt.Errorf("error creating change address: %w", err) } - for vout, txOut := range tx.TxOut { - if bytes.Equal(txOut.PkScript, pay2script) { - return txHash, uint32(vout), uint64(txOut.Value), nil - } + + msgTx, err := btc.sendWithReturn(fundedTx, changeAddr, totalIn, toSend, feeRate) + if err != nil { + return nil, 0, 0, err } - return nil, 0, 0, fmt.Errorf("failed to locate transaction vout") + + txHash := msgTx.TxHash() + return &txHash, 0, toSend, nil } // SwapConfirmations gets the number of confirmations for the specified swap @@ -4739,6 +4922,411 @@ func (btc *intermediaryWallet) EstimateSendTxFee(address string, sendAmount, fee return fee, isValidAddress, nil } +func (btc *baseWallet) reserves() uint64 { + btc.reservesMtx.RLock() + defer btc.reservesMtx.RUnlock() + if r := btc.bondReservesEnforced; r > 0 { + return uint64(r) + } + if btc.bondReservesNominal == 0 { // disabled + return 0 + } + // When enforced is negative, we're unbonding. If nominal is still positive, + // we're partially unbonding and we need to report the remaining reserves + // after excess is unbonded, offsetting the negative enforced amount. This + // is the relatively small fee buffer. + if int64(btc.bondReservesUsed) == btc.bondReservesNominal { + return uint64(-btc.bondReservesEnforced) + } + + return 0 +} + +// bondLocked reduces reserves, increases bonded (used) amount. +func (btc *baseWallet) bondLocked(amt uint64) (reserved int64, unspent uint64) { + btc.reservesMtx.Lock() + defer btc.reservesMtx.Unlock() + e0 := btc.bondReservesEnforced + btc.bondReservesEnforced -= int64(amt) + btc.bondReservesUsed += amt + btc.log.Tracef("bondLocked (%v): enforced %v ==> %v (with bonded = %v / nominal = %v)", + toBTC(amt), toBTC(e0), toBTC(btc.bondReservesEnforced), + toBTC(btc.bondReservesUsed), toBTC(btc.bondReservesNominal)) + return btc.bondReservesEnforced, btc.bondReservesUsed +} + +// bondSpent increases enforce reserves, decreases bonded amount. When the +// tracked unspent amount is reduced to zero, this clears the enforced amount +// (just the remaining fee buffer). +func (btc *baseWallet) bondSpent(amt uint64) (reserved int64, unspent uint64) { + btc.reservesMtx.Lock() + defer btc.reservesMtx.Unlock() + + if amt <= btc.bondReservesUsed { + btc.bondReservesUsed -= amt + } else { + btc.log.Errorf("bondSpent: live bonds accounting error, spending bond worth %v with %v known live (zeroing!)", + amt, btc.bondReservesUsed) + btc.bondReservesUsed = 0 + } + + if btc.bondReservesNominal == 0 { // disabled + return btc.bondReservesEnforced, btc.bondReservesUsed // return 0, ... + } + + e0 := btc.bondReservesEnforced + btc.bondReservesEnforced += int64(amt) + + btc.log.Tracef("bondSpent (%v): enforced %v ==> %v (with bonded = %v / nominal = %v)", + toBTC(amt), toBTC(e0), toBTC(btc.bondReservesEnforced), + toBTC(btc.bondReservesUsed), toBTC(btc.bondReservesNominal)) + return btc.bondReservesEnforced, btc.bondReservesUsed +} + +// RegisterUnspent +func (btc *baseWallet) RegisterUnspent(inBonds uint64) { + btc.reservesMtx.Lock() + defer btc.reservesMtx.Unlock() + btc.log.Tracef("RegisterUnspent(%v) changing unspent in bonds: %v => %v", + toBTC(inBonds), toBTC(btc.bondReservesUsed), toBTC(btc.bondReservesUsed+inBonds)) + btc.bondReservesUsed += inBonds + // This method should be called before ReserveBondFunds, prior to login on + // application initialization (if there are existing for this asset bonds). + // The nominal counter is not modified until ReserveBondFunds is called. + if btc.bondReservesNominal != 0 { + btc.log.Warnf("BUG: RegisterUnspent called with existing nominal reserves of %v BTC", + toBTC(btc.bondReservesNominal)) + } +} + +// ReserveBondFunds +func (btc *baseWallet) ReserveBondFunds(future int64, respectBalance bool) bool { + btc.reservesMtx.Lock() + defer btc.reservesMtx.Unlock() + + defer func(enforced0, used0, nominal0 int64) { + btc.log.Tracef("ReserveBondFunds(%v, %v): enforced = %v / bonded = %v / nominal = %v "+ + " ==> enforced = %v / bonded = %v / nominal = %v", + toBTC(future), respectBalance, + toBTC(enforced0), toBTC(used0), toBTC(nominal0), + toBTC(btc.bondReservesEnforced), toBTC(btc.bondReservesUsed), toBTC(uint64(btc.bondReservesNominal))) + }(btc.bondReservesEnforced, int64(btc.bondReservesUsed), btc.bondReservesNominal) + + // For the reserves initialization, add the fee buffer. + var feeBuffer uint64 + if btc.bondReservesNominal == 0 { // enabling, add a fee buffer + feeBuffer = bondsFeeBuffer(btc.segwit, btc.feeRateLimit()) + } + enforcedDelta := future + int64(feeBuffer) + + // How much of that is covered by the available balance, when increasing + // reserves via + if respectBalance && future > 0 { + bal, err := btc.balance() + if err != nil { + btc.log.Errorf("Failed to retrieve balance: %v") + return false + } + if int64(bal.Available) < btc.bondReservesEnforced+enforcedDelta { + return false + } + } + + if btc.bondReservesNominal == 0 { // enabling, add any previously-registered unspent + btc.log.Debugf("Re-enabling reserves with %v in existing unspent bonds (added to nominal).", toBTC(btc.bondReservesUsed)) + btc.bondReservesNominal += int64(btc.bondReservesUsed) + } + btc.bondReservesNominal += future + btc.bondReservesEnforced += enforcedDelta + + // When disabling/zeroing reserves, wipe the fee buffer too. If there are + // unspent bonds, this will be done in bondSpent when the last one is spent. + if btc.bondReservesNominal <= 0 { // nominal should not go negative though + btc.log.Infof("Nominal reserves depleted -- clearing enforced reserves!") + btc.bondReservesEnforced = 0 + btc.bondReservesNominal = 0 + } + + return true +} + +// MakeBondTx creates a time-locked fidelity bond transaction. The V0 +// transaction has two required outputs: +// +// Output 0 is a the time-locked bond output of type P2SH with the provided +// value. The redeem script looks similar to the refund path of an atomic swap +// script, but with a pubkey hash: +// +// OP_CHECKLOCKTIMEVERIFY OP_DROP OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG +// +// The pubkey referenced by the script is provided by the caller. +// +// Output 1 is a DEX Account commitment. This is an OP_RETURN output that +// references the provided account ID. +// +// OP_RETURN <2-byte version> <32-byte account ID> <4-byte locktime> <20-byte pubkey hash> +// +// Having the account ID in the raw allows the txn alone to identify the account +// without the bond output's redeem script. +// +// Output 2 is change, if any. +// +// The bond output's redeem script, which is needed to spend the bond output, is +// returned as the Data field of the Bond. The bond output pays to a pubkeyhash +// script for a wallet address. Bond.RedeemTx is a backup transaction that +// spends the bond output after lockTime passes, paying to an address for the +// current underlying wallet; the bond private key should normally be used to +// author a new transaction paying to a new address instead. +func (btc *baseWallet) MakeBondTx(ver uint16, amt, feeRate uint64, lockTime time.Time, bondKey *secp256k1.PrivateKey, acctID []byte) (*asset.Bond, func(), error) { + if ver != 0 { + return nil, nil, errors.New("only version 0 bonds supported") + } + if until := time.Until(lockTime); until >= 365*12*time.Hour /* ~6 months */ { + return nil, nil, fmt.Errorf("that lock time is nuts: %v", lockTime) + } else if until < 0 { + return nil, nil, fmt.Errorf("that lock time is already passed: %v", lockTime) + } + + pk := bondKey.PubKey().SerializeCompressed() + pkh := btcutil.Hash160(pk) + + feeRate = btc.feeRateWithFallback(feeRate) + baseTx := wire.NewMsgTx(wire.TxVersion) + + // TL output. + lockTimeSec := lockTime.Unix() + if lockTimeSec >= dexbtc.MaxCLTVScriptNum || lockTimeSec <= 0 { + return nil, nil, fmt.Errorf("invalid lock time %v", lockTime) + } + bondScript, err := dexbtc.MakeBondScript(ver, uint32(lockTimeSec), pkh) + if err != nil { + return nil, nil, fmt.Errorf("failed to build bond output redeem script: %w", err) + } + pkScript, err := btc.scriptHashScript(bondScript) + if err != nil { + return nil, nil, fmt.Errorf("error constructing p2sh script: %v", err) + } + txOut := wire.NewTxOut(int64(amt), pkScript) + if dexbtc.IsDust(txOut, feeRate) { + return nil, nil, fmt.Errorf("bond output value of %d is dust", amt) + } + baseTx.AddTxOut(txOut) + + // Acct ID commitment and bond details output, v0. The integers are encoded + // with big-endian byte order and a fixed number of bytes, unlike in Script, + // for natural visual inspection of the version and lock time. + pushData := make([]byte, 2+len(acctID)+4+20) + var offset int + binary.BigEndian.PutUint16(pushData[offset:], ver) + offset += 2 + copy(pushData[offset:], acctID[:]) + offset += len(acctID) + binary.BigEndian.PutUint32(pushData[offset:], uint32(lockTimeSec)) + offset += 4 + copy(pushData[offset:], pkh) + commitPkScript, err := txscript.NewScriptBuilder(). + AddOp(txscript.OP_RETURN). + AddData(pushData). + Script() + if err != nil { + return nil, nil, fmt.Errorf("failed to build acct commit output script: %w", err) + } + acctOut := wire.NewTxOut(0, commitPkScript) // value zero + baseTx.AddTxOut(acctOut) + + baseSize := uint32(baseTx.SerializeSize()) + if btc.segwit { + baseSize += dexbtc.P2WPKHOutputSize + } else { + baseSize += dexbtc.P2PKHOutputSize + } + + coins, _, _, _, _, _, err := btc.fund(0, 0, true, sendEnough(amt, feeRate, true, uint64(baseSize), btc.segwit, true)) + if err != nil { + return nil, nil, fmt.Errorf("failed to fund bond tx: %w", err) + } + + // Reduce the reserves counter now that utxos are explicitly allocated. When + // the bond is refunded and we pay back into our wallet, we will increase + // the reserves counter. + newReserves, unspent := btc.bondLocked(amt) // nominal, not spent amount + btc.log.Debugf("New bond reserves (new post) = %f BTC with %f in unspent bonds", + toBTC(newReserves), toBTC(unspent)) // decrement and report new + + abandon := func() { // if caller does not broadcast, or we fail in this method + newReserves, unspent = btc.bondSpent(amt) + btc.log.Debugf("New bond reserves (abandoned post) = %f BTC with %f in unspent bonds", + toBTC(newReserves), toBTC(unspent)) // increment/restore and report new + err := btc.ReturnCoins(coins) + if err != nil { + btc.log.Errorf("error returning coins for unused bond tx: %v", coins) + } + } + + var success bool + defer func() { + if !success { + abandon() + } + }() + + totalIn, _, err := btc.addInputsToTx(baseTx, coins) + if err != nil { + return nil, nil, fmt.Errorf("failed to add inputs to bond tx: %w", err) + } + + changeAddr, err := btc.node.changeAddress() + if err != nil { + return nil, nil, fmt.Errorf("error creating change address: %w", err) + } + signedTx, _, _, err := btc.signTxAndAddChange(baseTx, changeAddr, totalIn, amt, feeRate) + if err != nil { + return nil, nil, fmt.Errorf("failed to sign bond tx: %w", err) + } + + txid := signedTx.TxHash() + unsignedTxid := baseTx.TxHash() + + signedTxBytes, err := serializeMsgTx(signedTx) + if err != nil { + return nil, nil, err + } + unsignedTxBytes, err := serializeMsgTx(baseTx) + if err != nil { + return nil, nil, err + } + + // Prep the redeem / refund tx. + redeemMsgTx, err := btc.makeBondRefundTxV0(&txid, 0, amt, bondScript, bondKey, feeRate) + if err != nil { + return nil, nil, fmt.Errorf("unable to create bond redemption tx: %w", err) + } + redeemTx, err := serializeMsgTx(redeemMsgTx) + if err != nil { + return nil, nil, fmt.Errorf("failed to serialize bond redemption tx: %w", err) + } + + bond := &asset.Bond{ + Version: ver, + AssetID: BipID, + Amount: amt, + CoinID: toCoinID(&txid, 0), + UnsignedCoinID: toCoinID(&unsignedTxid, 0), + Data: bondScript, + SignedTx: signedTxBytes, + UnsignedTx: unsignedTxBytes, + RedeemTx: redeemTx, + } + success = true + + return bond, abandon, nil +} + +func (btc *baseWallet) makeBondRefundTxV0(txid *chainhash.Hash, vout uint32, amt uint64, + script []byte, priv *secp256k1.PrivateKey, feeRate uint64) (*wire.MsgTx, error) { + lockTime, pkhPush, err := dexbtc.ExtractBondDetailsV0(0, script) + if err != nil { + return nil, err + } + + pk := priv.PubKey().SerializeCompressed() + pkh := btcutil.Hash160(pk) + if !bytes.Equal(pkh, pkhPush) { + return nil, fmt.Errorf("incorrect private key to spend the bond output") + } + + msgTx := wire.NewMsgTx(btc.txVersion()) + // Transaction LockTime must be <= spend time, and >= the CLTV lockTime, so + // we use exactly the CLTV's value. This limits the CLTV value to 32-bits. + msgTx.LockTime = lockTime + bondPrevOut := wire.NewOutPoint(txid, vout) + txIn := wire.NewTxIn(bondPrevOut, []byte{}, nil) + txIn.Sequence = wire.MaxTxInSequenceNum - 1 // not finalized, do not disable cltv + msgTx.AddTxIn(txIn) + + // Calculate fees and add the refund output. + var outputSize int + if btc.segwit { + outputSize = dexbtc.P2WPKHOutputSize + } else { + outputSize = dexbtc.P2PKHOutputSize + } + redeemSize := msgTx.SerializeSize() + dexbtc.RedeemBondSigScriptSize + outputSize + fee := feeRate * uint64(redeemSize) + if fee > amt { + return nil, fmt.Errorf("irredeemable bond at fee rate %d atoms/byte", feeRate) + } + + // Add the refund output. + redeemAddr, err := btc.node.changeAddress() + if err != nil { + return nil, fmt.Errorf("error creating change address: %w", err) + } + redeemPkScript, err := txscript.PayToAddrScript(redeemAddr) + if err != nil { + return nil, fmt.Errorf("error creating pubkey script: %w", err) + } + redeemTxOut := wire.NewTxOut(int64(amt-fee), redeemPkScript) + if dexbtc.IsDust(redeemTxOut, feeRate) { // hard to imagine + return nil, fmt.Errorf("redeem output is dust") + } + msgTx.AddTxOut(redeemTxOut) + + if btc.segwit { + sigHashes := txscript.NewTxSigHashes(msgTx, new(txscript.CannedPrevOutputFetcher)) + sig, err := txscript.RawTxInWitnessSignature(msgTx, sigHashes, 0, int64(amt), + script, txscript.SigHashAll, priv) + if err != nil { + return nil, err + } + txIn.Witness = dexbtc.RefundBondScriptSegwit(script, sig, pk) + } else { + sig, err := btc.signNonSegwit(msgTx, 0, script, txscript.SigHashAll, priv, []int64{int64(amt)}, [][]byte{script}) + if err != nil { + return nil, err + } + txIn.SignatureScript, err = dexbtc.RefundBondScript(script, sig, pk) + if err != nil { + return nil, fmt.Errorf("RefundBondScript: %w", err) + } + } + + return msgTx, nil +} + +// RefundBond refunds a bond output to a new wallet address given the redeem +// script and private key. After broadcasting, the output paying to the wallet +// is returned. +func (btc *baseWallet) RefundBond(ctx context.Context, ver uint16, coinID, script []byte, amt uint64, privKey *secp256k1.PrivateKey) (asset.Coin, error) { + if ver != 0 { + return nil, errors.New("only version 0 bonds supported") + } + txHash, vout, err := decodeCoinID(coinID) + if err != nil { + return nil, err + } + feeRate := btc.targetFeeRateWithFallback(2, 0) + + msgTx, err := btc.makeBondRefundTxV0(txHash, vout, amt, script, privKey, feeRate) + if err != nil { + return nil, err + } + + newReserves, unspent := btc.bondSpent(amt) + btc.log.Debugf("New bond reserves (new refund of %f BTC) = %f BTC with %f in unspent bonds", + toBTC(amt), toBTC(newReserves), toBTC(unspent)) + + _, err = btc.node.sendRawTransaction(msgTx) + if err != nil { + newReserves, unspent = btc.bondLocked(amt) // assume it didn't really send :/ + btc.log.Debugf("New bond reserves (failed refund broadcast) = %f BTC with %f in unspent bonds", + toBTC(newReserves), toBTC(unspent)) // increment/restore and report new + return nil, fmt.Errorf("error sending refund bond transaction: %w", err) + } + + return newOutput(txHash, 0, uint64(msgTx.TxOut[0].Value)), nil +} + type utxo struct { txHash *chainhash.Hash vout uint32 @@ -4846,6 +5434,7 @@ func (btc *baseWallet) lockedSats() (uint64, error) { var sum uint64 btc.fundingMtx.Lock() defer btc.fundingMtx.Unlock() + for _, rpcOP := range lockedOutpoints { txHash, err := chainhash.NewHashFromStr(rpcOP.TxID) if err != nil { @@ -4958,7 +5547,7 @@ func decodeCoinID(coinID dex.Bytes) (*chainhash.Hash, uint32, error) { } // toBTC returns a float representation in conventional units for the sats. -func toBTC(v uint64) float64 { +func toBTC[V uint64 | int64](v V) float64 { return btcutil.Amount(v).ToBTC() } diff --git a/client/asset/btc/btc_test.go b/client/asset/btc/btc_test.go index 641ad857ea..5863f12d79 100644 --- a/client/asset/btc/btc_test.go +++ b/client/asset/btc/btc_test.go @@ -1,4 +1,4 @@ -//go:build !spvlive +//go:build !spvlive && !harness package btc @@ -1395,9 +1395,10 @@ func TestFundEdges(t *testing.T) { var feeReduction uint64 = swapSize * tBTC.MaxFeeRate estFeeReduction := swapSize * feeSuggestion - checkMaxOrder(t, wallet, lots-1, swapVal-tLotSize, backingFees-feeReduction, - totalBytes*feeSuggestion-estFeeReduction, - bestCaseBytes*feeSuggestion) + splitFees := splitTxBaggage * tBTC.MaxFeeRate + checkMaxOrder(t, wallet, lots-1, swapVal-tLotSize, backingFees+splitFees-feeReduction, + (totalBytes+splitTxBaggage)*feeSuggestion-estFeeReduction, + (bestCaseBytes+splitTxBaggage)*feeSuggestion) _, _, err := wallet.FundOrder(ord) if err == nil { @@ -1617,9 +1618,10 @@ func TestFundEdgesSegwit(t *testing.T) { var feeReduction uint64 = swapSize * tBTC.MaxFeeRate estFeeReduction := swapSize * feeSuggestion - checkMaxOrder(t, wallet, lots-1, swapVal-tLotSize, backingFees-feeReduction, - totalBytes*feeSuggestion-estFeeReduction, - bestCaseBytes*feeSuggestion) + splitFees := splitTxBaggageSegwit * tBTC.MaxFeeRate + checkMaxOrder(t, wallet, lots-1, swapVal-tLotSize, backingFees+splitFees-feeReduction, + (totalBytes+splitTxBaggageSegwit)*feeSuggestion-estFeeReduction, + (bestCaseBytes+splitTxBaggageSegwit)*feeSuggestion) _, _, err := wallet.FundOrder(ord) if err == nil { @@ -2387,69 +2389,203 @@ func testSender(t *testing.T, senderType tSenderType, segwit bool, walletType st } } + node.signFunc = func(tx *wire.MsgTx) { + signFunc(tx, 0, wallet.segwit) + } + addr := btcAddr(segwit) - fee := float64(1) // BTC node.setTxFee = true 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.getTransactionMap = map[string]*GetTransactionResult{ - "any": { - BlockHash: blockHash.String(), - BlockIndex: blockHeight, - Hex: txB, - }} + changeAddr := btcAddr(segwit) + node.changeAddr = changeAddr.String() - unspents := []*ListUnspentResult{{ - TxID: txHash.String(), - Address: addr.String(), - Amount: 100, - Confirmations: 1, - Vout: vout, - ScriptPubKey: pkScript, - SafePtr: boolPtr(true), - Spendable: true, - }} - node.listUnspent = unspents + expectedFees := func(numInputs int) uint64 { + txSize := dexbtc.MinimumTxOverhead + if segwit { + txSize += (dexbtc.P2WPKHOutputSize * 2) + (numInputs * dexbtc.RedeemP2WPKHInputTotalSize) + } else { + txSize += (dexbtc.P2PKHOutputSize * 2) + (numInputs * dexbtc.RedeemP2PKHInputSize) + } + return uint64(txSize * feeSuggestion) + } - node.signFunc = func(tx *wire.MsgTx) { - signFunc(tx, 0, wallet.segwit) + expectedSentVal := func(sendVal, fees uint64) uint64 { + if senderType == tSendSender { + return sendVal + } + return sendVal - fees } - _, err := sender(addr.String(), toSatoshi(fee)) - if err != nil { - t.Fatalf("send error: %v", err) + expectedChangeVal := func(totalInput, sendVal, fees uint64) uint64 { + if senderType == tSendSender { + return totalInput - sendVal - fees + } + return totalInput - sendVal } - // SendToAddress error - node.sendToAddressErr = tErr - _, err = sender(addr.String(), 1e8) - if err == nil { - t.Fatalf("no error for SendToAddress error: %v", err) + requiredVal := func(sendVal, fees uint64) uint64 { + if senderType == tSendSender { + return sendVal + fees + } + return sendVal } - node.sendToAddressErr = nil - // GetTransaction error - node.getTransactionErr = tErr - _, err = sender(addr.String(), 1e8) - if err == nil { - t.Fatalf("no error for gettransaction error: %v", err) + type test struct { + name string + val uint64 + unspents []*ListUnspentResult + bondReservesEnforced int64 + + expectedInputs []*outPoint + expectSentVal uint64 + expectChange uint64 + expectErr bool + } + tests := []test{ + { + name: "plenty of funds", + val: toSatoshi(5), + unspents: []*ListUnspentResult{{ + TxID: txHash.String(), + Address: addr.String(), + Amount: 100, + Confirmations: 1, + Vout: 0, + ScriptPubKey: pkScript, + SafePtr: boolPtr(true), + Spendable: true, + }}, + expectedInputs: []*outPoint{ + {txHash: txHash, + vout: 0}, + }, + expectSentVal: expectedSentVal(toSatoshi(5), expectedFees(1)), + expectChange: expectedChangeVal(toSatoshi(100), toSatoshi(5), expectedFees(1)), + }, + { + name: "just enough change for bond reserves", + val: toSatoshi(5), + unspents: []*ListUnspentResult{{ + TxID: txHash.String(), + Address: addr.String(), + Amount: 5.2, + Confirmations: 1, + Vout: 0, + ScriptPubKey: pkScript, + SafePtr: boolPtr(true), + Spendable: true, + }}, + expectedInputs: []*outPoint{ + {txHash: txHash, + vout: 0}, + }, + expectSentVal: expectedSentVal(toSatoshi(5), expectedFees(1)), + expectChange: expectedChangeVal(toSatoshi(5.2), toSatoshi(5), expectedFees(1)), + bondReservesEnforced: int64(expectedChangeVal(toSatoshi(5.2), toSatoshi(5), expectedFees(1))), + }, + { + name: "not enough change for bond reserves", + val: toSatoshi(5), + unspents: []*ListUnspentResult{{ + TxID: txHash.String(), + Address: addr.String(), + Amount: 5.2, + Confirmations: 1, + Vout: 0, + ScriptPubKey: pkScript, + SafePtr: boolPtr(true), + Spendable: true, + }}, + expectedInputs: []*outPoint{ + {txHash: txHash, + vout: 0}, + }, + bondReservesEnforced: int64(expectedChangeVal(toSatoshi(5.2), toSatoshi(5), expectedFees(1))) + 1, + expectErr: true, + }, + { + name: "1 satoshi less than needed", + val: toSatoshi(5), + unspents: []*ListUnspentResult{{ + TxID: txHash.String(), + Address: addr.String(), + Amount: toBTC(requiredVal(toSatoshi(5), expectedFees(1)) - 1), + Confirmations: 1, + Vout: 0, + ScriptPubKey: pkScript, + SafePtr: boolPtr(true), + Spendable: true, + }}, + expectErr: true, + }, + { + name: "exact amount needed", + val: toSatoshi(5), + unspents: []*ListUnspentResult{{ + TxID: txHash.String(), + Address: addr.String(), + Amount: toBTC(requiredVal(toSatoshi(5), expectedFees(1))), + Confirmations: 1, + Vout: 0, + ScriptPubKey: pkScript, + SafePtr: boolPtr(true), + Spendable: true, + }}, + expectedInputs: []*outPoint{ + {txHash: txHash, + vout: 0}, + }, + expectSentVal: expectedSentVal(toSatoshi(5), expectedFees(1)), + expectChange: 0, + }, } - node.getTransactionErr = nil - // good again - _, err = sender(addr.String(), toSatoshi(fee)) - if err != nil { - t.Fatalf("Send error afterwards: %v", err) + for _, test := range tests { + node.listUnspent = test.unspents + wallet.bondReservesEnforced = test.bondReservesEnforced + + _, err := sender(addr.String(), test.val) + if test.expectErr { + if err == nil { + t.Fatalf("%s: no error for expected error", test.name) + } + continue + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + tx := node.sentRawTx + if len(test.expectedInputs) != len(tx.TxIn) { + t.Fatalf("expected %d inputs, got %d", len(test.expectedInputs), len(tx.TxIn)) + } + + for i, input := range tx.TxIn { + if input.PreviousOutPoint.Hash != test.expectedInputs[i].txHash || + input.PreviousOutPoint.Index != test.expectedInputs[i].vout { + t.Fatalf("expected input %d to be %v, got %v", i, test.expectedInputs[i], input.PreviousOutPoint) + } + } + + if test.expectChange > 0 && len(tx.TxOut) != 2 { + t.Fatalf("expected 2 outputs, got %d", len(tx.TxOut)) + } + if test.expectChange == 0 && len(tx.TxOut) != 1 { + t.Fatalf("expected 2 outputs, got %d", len(tx.TxOut)) + } + + if tx.TxOut[0].Value != int64(test.expectSentVal) { + t.Fatalf("expected sent value to be %d, got %d", test.expectSentVal, tx.TxOut[0].Value) + } + + if test.expectChange > 0 && tx.TxOut[1].Value != int64(test.expectChange) { + t.Fatalf("expected change value to be %d, got %d", test.expectChange, tx.TxOut[1].Value) + } } } diff --git a/client/asset/btc/coin_selection.go b/client/asset/btc/coin_selection.go new file mode 100644 index 0000000000..0e36882f62 --- /dev/null +++ b/client/asset/btc/coin_selection.go @@ -0,0 +1,212 @@ +// 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 ( + "math/rand" + "sort" + "time" + + "decred.org/dcrdex/dex/calc" + dexbtc "decred.org/dcrdex/dex/networks/btc" +) + +// sendEnough generates a function that can be used as the enough argument to +// the fund method when creating transactions to send funds. If fees are to be +// subtracted from the inputs, set subtract so that the required amount excludes +// the transaction fee. If change from the transaction should be considered +// immediately available (not mixing), set reportChange to indicate this and the +// returned enough func will return a non-zero excess value. Otherwise, the +// enough func will always return 0, leaving only unselected UTXOs to cover any +// required reserves. +func sendEnough(amt, feeRate uint64, subtract bool, baseTxSize uint64, segwit, reportChange bool) func(inputSize, sum uint64) (bool, uint64) { + return func(inputSize, sum uint64) (bool, uint64) { + txFee := (baseTxSize + inputSize) * feeRate + req := amt + if !subtract { // add the fee to required + req += txFee + } + if sum < req { + return false, 0 + } + excess := sum - req + if !reportChange || dexbtc.IsDustVal(dexbtc.P2PKHOutputSize, excess, feeRate, segwit) { + excess = 0 + } + return true, excess + } +} + +// orderEnough generates a function that can be used as the enough argument to +// the fund method. If change from a split transaction will be created AND +// immediately available (not mixing), set reportChange to indicate this and the +// returned enough func will return a non-zero excess value reflecting this +// potential spit tx change. Otherwise, the enough func will always return 0, +// leaving only unselected UTXOs to cover any required reserves. +func orderEnough(val, lots, feeRate, initTxSizeBase, initTxSize uint64, segwit, reportChange bool) func(inputsSize, sum uint64) (bool, uint64) { + return func(inputsSize, sum uint64) (bool, uint64) { + reqFunds := calc.RequiredOrderFundsAlt(val, inputsSize, lots, initTxSizeBase, initTxSize, feeRate) + if sum >= reqFunds { + excess := sum - reqFunds + if !reportChange || dexbtc.IsDustVal(dexbtc.P2PKHOutputSize, excess, feeRate, segwit) { + excess = 0 + } + return true, excess + } + return false, 0 + } +} + +func sumUTXOs(set []*compositeUTXO) (tot uint64) { + for _, utxo := range set { + tot += utxo.amount + } + return tot +} + +// subsetWithLeastSumGreaterThan attempts to select the subset of UTXOs with +// the smallest total value greater than amt. It does this by making +// 1000 random selections and returning the best one. Each selection +// involves two passes over the UTXOs. The first pass randomly selects +// each UTXO with 50% probability. Then, the second pass selects any +// unused UTXOs until the total value is greater than or equal to amt. +func subsetWithLeastSumGreaterThan(amt uint64, utxos []*compositeUTXO) []*compositeUTXO { + best := uint64(1 << 62) + var bestIncluded []bool + bestNumIncluded := 0 + + rnd := rand.New(rand.NewSource(time.Now().Unix())) + + shuffledUTXOs := make([]*compositeUTXO, len(utxos)) + copy(shuffledUTXOs, utxos) + rnd.Shuffle(len(shuffledUTXOs), func(i, j int) { + shuffledUTXOs[i], shuffledUTXOs[j] = shuffledUTXOs[j], shuffledUTXOs[i] + }) + + included := make([]bool, len(utxos)) + const iterations = 1000 + +searchLoop: + for nRep := 0; nRep < iterations; nRep++ { + var nTotal uint64 + var numIncluded int + + for nPass := 0; nPass < 2; nPass++ { + for i := 0; i < len(shuffledUTXOs); i++ { + var use bool + if nPass == 0 { + use = rnd.Int63()&1 == 1 + } else { + use = !included[i] + } + if use { + included[i] = true + numIncluded++ + nTotal += shuffledUTXOs[i].amount + if nTotal >= amt { + if nTotal < best || (nTotal == best && numIncluded < bestNumIncluded) { + best = nTotal + if bestIncluded == nil { + bestIncluded = make([]bool, len(shuffledUTXOs)) + } + copy(bestIncluded, included) + bestNumIncluded = numIncluded + } + if nTotal == amt { + break searchLoop + } + included[i] = false + nTotal -= shuffledUTXOs[i].amount + numIncluded-- + } + } + } + } + for i := 0; i < len(included); i++ { + included[i] = false + } + } + + if bestIncluded == nil { + return nil + } + + set := make([]*compositeUTXO, 0, len(shuffledUTXOs)) + for i, inc := range bestIncluded { + if inc { + set = append(set, shuffledUTXOs[i]) + } + } + + return set +} + +// leastOverFund attempts to pick a subset of the provided UTXOs to reach the +// required amount with the objective of minimizing the total amount of the +// selected UTXOs. This is different from the objective used when funding +// orders, which is to minimize the number of UTXOs (to minimize fees). +// +// The UTXOs MUST be sorted in ascending order (smallest first, largest last)! +// +// This begins by partitioning the slice before the smallest single UTXO that is +// large enough to fully fund the requested amount, if it exists. If the smaller +// set is insufficient, the single largest UTXO is returned. If instead the set +// of smaller UTXOs has enough total value, it will search for a subset that +// reaches the amount with least over-funding (see subsetWithLeastSumGreaterThan). +// If that subset has less combined value than the single +// sufficiently-large UTXO (if it exists), the subset will be returned, +// otherwise the single UTXO will be returned. +// +// If the provided UTXO set has less combined value than the requested amount a +// nil slice is returned. +func leastOverFund(amt uint64, utxos []*compositeUTXO) []*compositeUTXO { + if amt == 0 || sumUTXOs(utxos) < amt { + return nil + } + + // Partition - smallest UTXO that is large enough to fully fund, and the set + // of smaller ones. + idx := sort.Search(len(utxos), func(i int) bool { + return utxos[i].amount >= amt + }) + var small []*compositeUTXO + var single *compositeUTXO // only return this if smaller ones would use more + if idx == len(utxos) { // no one is enough + small = utxos + } else { + small = utxos[:idx] + single = utxos[idx] + } + + // Find a subset of the small UTXO set with smallest combined amount. + var set []*compositeUTXO + if sumUTXOs(small) >= amt { + set = subsetWithLeastSumGreaterThan(amt, small) + } else if single != nil { + return []*compositeUTXO{single} + } + + // Return the small UTXO subset if it is less than the single big UTXO. + if single != nil && single.amount < sumUTXOs(set) { + return []*compositeUTXO{single} + } + return set +} + +// utxoSetDiff performs the setdiff(set,sub) of two UTXO sets. That is, any +// UTXOs that are both sets are removed from the first. The comparison is done +// *by pointer*, with no regard to the values of the compositeUTXO elements. +func utxoSetDiff(set, sub []*compositeUTXO) []*compositeUTXO { + var availUTXOs []*compositeUTXO +avail: + for _, utxo := range set { + for _, kept := range sub { + if utxo == kept { // by pointer + continue avail + } + } + availUTXOs = append(availUTXOs, utxo) + } + return availUTXOs +} diff --git a/client/asset/btc/electrum.go b/client/asset/btc/electrum.go index bd5f5ad942..da0f230e10 100644 --- a/client/asset/btc/electrum.go +++ b/client/asset/btc/electrum.go @@ -4,7 +4,6 @@ package btc import ( - "bytes" "context" "errors" "fmt" @@ -18,7 +17,6 @@ import ( "decred.org/dcrdex/dex/config" dexbtc "decred.org/dcrdex/dex/networks/btc" "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/txscript" ) // ExchangeWalletElectrum is the asset.Wallet for an external Electrum wallet. @@ -29,7 +27,6 @@ type ExchangeWalletElectrum struct { var _ asset.Wallet = (*ExchangeWalletElectrum)(nil) var _ asset.FeeRater = (*ExchangeWalletElectrum)(nil) -var _ asset.Sweeper = (*ExchangeWalletElectrum)(nil) // ElectrumWallet creates a new ExchangeWalletElectrum for the provided // configuration, which must contain the necessary details for accessing the @@ -141,41 +138,6 @@ func (btc *ExchangeWalletElectrum) Connect(ctx context.Context) (*sync.WaitGroup return wg, nil } -// Sweep sends all the funds in the wallet to an address. -func (btc *ExchangeWalletElectrum) Sweep(address string, feeSuggestion uint64) (asset.Coin, error) { - addr, err := btc.decodeAddr(address, btc.chainParams) - if err != nil { - return nil, fmt.Errorf("address decode error: %w", err) - } - pkScript, err := txscript.PayToAddrScript(addr) - if err != nil { - return nil, fmt.Errorf("PayToAddrScript error: %w", err) - } - - txRaw, err := btc.ew.sweep(btc.ew.ctx, address, feeSuggestion) - if err != nil { - return nil, err - } - - msgTx, err := btc.deserializeTx(txRaw) - if err != nil { - return nil, err - } - txHash := msgTx.TxHash() - for vout, txOut := range msgTx.TxOut { - if bytes.Equal(txOut.PkScript, pkScript) { - return newOutput(&txHash, uint32(vout), uint64(txOut.Value)), nil - } - } - - // Well, the txn is sent, so let's at least direct the user to the txid even - // though we failed to find the output with the expected pkScript. Perhaps - // the Electrum wallet generated a slightly different pkScript for the - // provided address. - btc.log.Warnf("Generated tx does not seem to contain an output to %v!", address) - return newOutput(&txHash, 0, 0 /* ! */), nil -} - func (btc *ExchangeWalletElectrum) walletFeeRate(ctx context.Context, confTarget uint64) (uint64, error) { satPerKB, err := btc.ew.wallet.FeeRate(ctx, int64(confTarget)) if err != nil { diff --git a/client/asset/btc/electrum_client.go b/client/asset/btc/electrum_client.go index 7a41ecc3f4..5cf105ad54 100644 --- a/client/asset/btc/electrum_client.go +++ b/client/asset/btc/electrum_client.go @@ -1111,179 +1111,6 @@ func (ew *electrumWallet) tryRemoveLocalTx(ctx context.Context, txid string) { } } -func (ew *electrumWallet) sendWithSubtract(ctx context.Context, address string, value, feeRate uint64) (*chainhash.Hash, error) { - pw, unlocked := ew.pass() // check first to spare some RPCs if locked - if !unlocked { - return nil, errors.New("wallet locked") - } - addr, err := ew.decodeAddr(address, ew.chainParams) - if err != nil { - return nil, err - } - pkScript, err := txscript.PayToAddrScript(addr) - if err != nil { - return nil, err - } - - unfundedTxSize := dexbtc.MinimumTxOverhead + dexbtc.P2WPKHOutputSize /* change */ + - dexbtc.TxOutOverhead + uint64(len(pkScript)) // send-to address - - unspents, err := ew.listUnspent() - if err != nil { - return nil, fmt.Errorf("error listing unspent outputs: %w", err) - } - utxos, _, _, err := convertUnspent(0, unspents, ew.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: %w", 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) - fromCoins := make([]string, 0, len(fundingCoins)) - for op := range fundingCoins { - // wireOP := wire.NewOutPoint(&op.txHash, op.vout) - // txIn := wire.NewTxIn(wireOP, []byte{}, nil) - // tx.AddTxIn(txIn) - fromCoins = append(fromCoins, op.String()) - } - - // To get Electrum to pick a change address, we use payTo with the - // from_coins option and an absolute fee. - txRaw, err := ew.wallet.PayToFromCoinsAbsFee(ctx, pw, fromCoins, address, toBTC(send), toBTC(fees)) - if err != nil { - return nil, err - } - // Do some sanity checks on the generated txn: (a) only spend chosen funding - // coins, (b) must pay to specified address in desired amount. - msgTx, err := ew.deserializeTx(txRaw) - if err != nil { - return nil, err - } - for _, txIn := range msgTx.TxIn { - op := outPoint{txIn.PreviousOutPoint.Hash, txIn.PreviousOutPoint.Index} - if _, found := fundingCoins[op]; !found { - return nil, fmt.Errorf("prevout %v was not specified but was spent", op) - } - } - var foundOut bool - for i, txOut := range msgTx.TxOut { - if bytes.Equal(txOut.PkScript, pkScript) { - if txOut.Value != int64(send) { - return nil, fmt.Errorf("output %v paid %v not %v", i, txOut.Value, send) - } - foundOut = true - break - } - } - if !foundOut { - return nil, fmt.Errorf("no output paying to %v was found", address) - } - - txid, err := ew.wallet.Broadcast(ctx, txRaw) - if err != nil { - ew.tryRemoveLocalTx(ew.ctx, msgTx.TxHash().String()) - return nil, err - } - return chainhash.NewHashFromStr(txid) // hope this doesn't error because it's already sent - - /* Cannot request change addresses from Electrum! - - change := extra - fees - changeAddr, err := ew.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) { // TODO: use a customizable isDust function e.g. (*baseWallet).IsDust - // 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) - - tx, err = ew.signTx(tx) - if err != nil { - return nil, fmt.Errorf("signing error: %w", err) - } - - return ew.sendRawTransaction(tx) - */ -} - -// part of the btc.Wallet interface -func (ew *electrumWallet) sendToAddress(address string, value, feeRate uint64, subtract bool) (*chainhash.Hash, error) { - if subtract { - return ew.sendWithSubtract(ew.ctx, address, value, feeRate) - } - - txRaw, err := ew.wallet.PayTo(ew.ctx, ew.walletPass(), address, toBTC(value), float64(feeRate)) - if err != nil { - return nil, err - } - msgTx, err := ew.deserializeTx(txRaw) - if err != nil { - return nil, err - } - txid, err := ew.wallet.Broadcast(ew.ctx, txRaw) - if err != nil { - ew.tryRemoveLocalTx(ew.ctx, msgTx.TxHash().String()) - return nil, err - } - return chainhash.NewHashFromStr(txid) -} - -func (ew *electrumWallet) sweep(ctx context.Context, address string, feeRate uint64) ([]byte, error) { - txRaw, err := ew.wallet.Sweep(ctx, ew.walletPass(), address, float64(feeRate)) - if err != nil { - return nil, err - } - msgTx, err := ew.deserializeTx(txRaw) - if err != nil { - return nil, err - } - _, err = ew.wallet.Broadcast(ctx, txRaw) - if err != nil { - ew.tryRemoveLocalTx(ctx, msgTx.TxHash().String()) - return nil, err - } - - return txRaw, nil -} - func (ew *electrumWallet) outPointAddress(ctx context.Context, txid string, vout uint32) (string, error) { txRaw, err := ew.wallet.GetRawTransaction(ctx, txid) if err != nil { diff --git a/client/asset/btc/rpcclient.go b/client/asset/btc/rpcclient.go index ee5be850fe..0d3551246b 100644 --- a/client/asset/btc/rpcclient.go +++ b/client/asset/btc/rpcclient.go @@ -119,7 +119,6 @@ type rpcCore struct { legacyValidateAddressRPC bool manualMedianTime bool omitRPCOptionsArg bool - legacySendToAddr bool } func (c *rpcCore) requester() RawRequester { @@ -801,46 +800,6 @@ func (wc *rpcClient) locked() bool { return time.Unix(*walletInfo.UnlockedUntil, 0).Before(time.Now()) } -// sendToAddress sends the amount to the address. feeRate is in units of -// sats/byte. If there is not a fee rate positional param, it is used -// legacySendToAddress instead. -func (wc *rpcClient) sendToAddress(address string, value, feeRate uint64, subtract bool) (*chainhash.Hash, error) { - // 1e-5 = 1e-8 for satoshis * 1000 for kB. - if wc.rpcCore.legacySendToAddr { - return wc.legacySendToAddress(address, value, feeRate, subtract) - } - var txid string - coinValue := btcutil.Amount(value).ToBTC() - params := anylist{address, coinValue, "dcrdex", "", subtract, nil, nil, nil, nil, feeRate} - err := wc.call(methodSendToAddress, params, &txid) - if err != nil { - return nil, err - } - return chainhash.NewHashFromStr(txid) -} - -// legacySendToAddress sends the amount to the address. Sets fee rate calling -// methodSetTxFee. -func (wc *rpcClient) legacySendToAddress(address string, value, feeRate uint64, subtract bool) (*chainhash.Hash, error) { - var success bool - // 1e-5 = 1e-8 for satoshis * 1000 for kB. - err := wc.call(methodSetTxFee, anylist{float64(feeRate) / 1e5}, &success) - if err != nil { - return nil, fmt.Errorf("error setting transaction fee: %w", err) - } - if !success { - return nil, fmt.Errorf("failed to set transaction fee") - } - var txid string - // Last boolean argument is to subtract the fee from the amount. - coinValue := btcutil.Amount(value).ToBTC() - err = wc.call(methodSendToAddress, anylist{address, coinValue, "dcrdex", "", subtract}, &txid) - if err != nil { - return nil, err - } - return chainhash.NewHashFromStr(txid) -} - // sendTxFeeEstimator returns the fee required to send tx using the provided // feeRate. func (wc *rpcClient) estimateSendTxFee(tx *wire.MsgTx, feeRate uint64, subtract bool) (txfee uint64, err error) { diff --git a/client/asset/btc/simnet_test.go b/client/asset/btc/simnet_test.go new file mode 100644 index 0000000000..61c9be8bce --- /dev/null +++ b/client/asset/btc/simnet_test.go @@ -0,0 +1,238 @@ +//go:build harness + +package btc + +// Simnet tests expect the BTC test harness to be running. + +import ( + "bytes" + "context" + "fmt" + "math/rand" + "os" + "os/exec" + "os/user" + "path/filepath" + "testing" + "time" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/config" + dexbtc "decred.org/dcrdex/dex/networks/btc" + "github.com/btcsuite/btcd/btcutil" + "github.com/decred/dcrd/dcrec/secp256k1/v4" +) + +var ( + tLogger dex.Logger + tCtx context.Context + tLotSize uint64 = 1e7 + tRateStep uint64 = 100 + tBTC = &dex.Asset{ + ID: 0, + Symbol: "btc", + Version: version, + SwapSize: dexbtc.InitTxSize, + SwapSizeBase: dexbtc.InitTxSizeBase, + MaxFeeRate: 10, + SwapConf: 1, + } +) + +func mineAlpha() error { + return exec.Command("tmux", "send-keys", "-t", "btc-harness:0", "./mine-alpha 1", "C-m").Run() +} + +func mineBeta() error { + return exec.Command("tmux", "send-keys", "-t", "btc-harness:0", "./mine-beta 1", "C-m").Run() +} + +func tBackend(t *testing.T, name string, blkFunc func(string, error)) (*ExchangeWalletAccelerator, *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", "btc", name, name+".conf") + settings, err := config.Parse(cfgPath) + if err != nil { + t.Fatalf("error reading config options: %v", err) + } + // settings["account"] = "default" + walletCfg := &asset.WalletConfig{ + Settings: settings, + TipChange: func(err error) { + blkFunc(name, err) + }, + PeersChange: func(num uint32, err error) { + t.Logf("peer count = %d, err = %v", num, err) + }, + } + var backend asset.Wallet + backend, err = NewWallet(walletCfg, tLogger, dex.Simnet) + if err != nil { + t.Fatalf("error creating backend: %v", err) + } + cm := dex.NewConnectionMaster(backend) + err = cm.Connect(tCtx) + if err != nil { + t.Fatalf("error connecting backend: %v", err) + } + return backend.(*ExchangeWalletAccelerator), cm +} + +type testRig struct { + backends map[string]*ExchangeWalletAccelerator + connectionMasters map[string]*dex.ConnectionMaster +} + +func newTestRig(t *testing.T, blkFunc func(string, error)) *testRig { + t.Helper() + rig := &testRig{ + backends: make(map[string]*ExchangeWalletAccelerator), + connectionMasters: make(map[string]*dex.ConnectionMaster, 3), + } + rig.backends["alpha"], rig.connectionMasters["alpha"] = tBackend(t, "alpha", blkFunc) + rig.backends["beta"], rig.connectionMasters["beta"] = tBackend(t, "beta", blkFunc) + return rig +} + +func (rig *testRig) alpha() *ExchangeWalletAccelerator { + return rig.backends["alpha"] +} +func (rig *testRig) beta() *ExchangeWalletAccelerator { + return rig.backends["beta"] +} +func (rig *testRig) close(t *testing.T) { + t.Helper() + for name, cm := range rig.connectionMasters { + closed := make(chan struct{}) + go func() { + cm.Disconnect() + close(closed) + }() + select { + case <-closed: + case <-time.NewTimer(time.Second).C: + t.Fatalf("failed to disconnect from %s", name) + } + } +} + +func randBytes(l int) []byte { + b := make([]byte, l) + rand.Read(b) + return b +} + +func waitNetwork() { + time.Sleep(time.Second * 3 / 2) +} + +func TestMain(m *testing.M) { + tLogger = dex.StdOutLogger("TEST", dex.LevelTrace) + var shutdown func() + tCtx, shutdown = context.WithCancel(context.Background()) + doIt := func() int { + defer shutdown() + return m.Run() + } + os.Exit(doIt()) +} + +func TestMakeBondTx(t *testing.T) { + rig := newTestRig(t, func(name string, err error) { + tLogger.Infof("%s has reported a new block, error = %v", name, err) + }) + defer rig.close(t) + + // Get a private key for the bond script. This would come from the client's + // HD key chain. + priv, err := secp256k1.GeneratePrivateKey() + if err != nil { + t.Fatal(err) + } + pubkey := priv.PubKey() + + acctID := randBytes(32) + fee := uint64(10_2030_4050) // ~10.2 DCR + const bondVer = 0 + + wallet := rig.alpha() + + // Unlock the wallet to sign the tx and get keys. + err = wallet.Unlock([]byte("abc")) + if err != nil { + t.Fatalf("error unlocking beta wallet: %v", err) + } + + lockTime := time.Now().Add(10 * time.Second) + bond, _, err := wallet.MakeBondTx(bondVer, fee, 10, lockTime, priv, acctID) + if err != nil { + t.Fatal(err) + } + coinhash, _, err := decodeCoinID(bond.CoinID) + if err != nil { + t.Fatalf("decodeCoinID: %v", err) + } + t.Logf("bond txid %v\n", coinhash) + t.Logf("signed tx: %x\n", bond.SignedTx) + t.Logf("unsigned tx: %x\n", bond.UnsignedTx) + t.Logf("bond script: %x\n", bond.Data) + t.Logf("redeem tx: %x\n", bond.RedeemTx) + _, err = msgTxFromBytes(bond.SignedTx) + if err != nil { + t.Fatalf("invalid bond tx: %v", err) + } + + pkh := btcutil.Hash160(pubkey.SerializeCompressed()) + + lockTimeUint, pkhPush, err := dexbtc.ExtractBondDetailsV0(0, bond.Data) + if err != nil { + t.Fatalf("ExtractBondDetailsV0: %v", err) + } + if !bytes.Equal(pkh, pkhPush) { + t.Fatalf("mismatching pubkeyhash in bond script and signature (%x != %x)", pkh, pkhPush) + } + + if lockTime.Unix() != int64(lockTimeUint) { + t.Fatalf("mismatching locktimes (%d != %d)", lockTime.Unix(), lockTimeUint) + } + lockTimePush := time.Unix(int64(lockTimeUint), 0) + t.Logf("lock time in bond script: %v", lockTimePush) + + sendBondTx, err := wallet.SendTransaction(bond.SignedTx) + if err != nil { + t.Fatalf("RefundBond: %v", err) + } + sendBondTxid, _, err := decodeCoinID(sendBondTx) + if err != nil { + t.Fatalf("decodeCoinID: %v", err) + } + t.Logf("sendBondTxid: %v\n", sendBondTxid) + + waitNetwork() // wait for alpha to see the txn + mineAlpha() + waitNetwork() // wait for beta to see the new block (bond must be mined for RefundBond) + + var expired bool + for !expired { + expired, err = wallet.LockTimeExpired(tCtx, lockTime) + if err != nil { + t.Fatalf("LocktimeExpired: %v", err) + } + if expired { + break + } + fmt.Println("bond still not expired") + time.Sleep(15 * time.Second) + } + + refundCoin, err := wallet.RefundBond(context.Background(), bondVer, bond.CoinID, + bond.Data, bond.Amount, priv) + if err != nil { + t.Fatalf("RefundBond: %v", err) + } + t.Logf("refundCoin: %v\n", refundCoin) +} diff --git a/client/asset/btc/spv_test.go b/client/asset/btc/spv_test.go index 14cc49d975..ab740fcc6b 100644 --- a/client/asset/btc/spv_test.go +++ b/client/asset/btc/spv_test.go @@ -1,4 +1,4 @@ -//go:build !spvlive +//go:build !spvlive && !harness // This code is available on the terms of the project LICENSE.md file, // also available online at https://blueoakcouncil.org/license/1.0.0. @@ -15,7 +15,6 @@ import ( "decred.org/dcrdex/client/asset" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/encode" - dexbtc "decred.org/dcrdex/dex/networks/btc" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" @@ -697,93 +696,6 @@ func TestGetTxOut(t *testing.T) { } } -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.RedeemP2WPKHInputTotalSize - 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, - SafePtr: boolPtr(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") - } -} - func TestTryBlocksWithNotifier(t *testing.T) { defaultWalletBlockAllowance := walletBlockAllowance defaultBlockTicker := blockTicker diff --git a/client/asset/btc/spv_wrapper.go b/client/asset/btc/spv_wrapper.go index 24089d8a56..7febc69364 100644 --- a/client/asset/btc/spv_wrapper.go +++ b/client/asset/btc/spv_wrapper.go @@ -44,7 +44,6 @@ import ( "github.com/btcsuite/btcd/btcutil/psbt" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/btcsuite/btcwallet/wallet" @@ -749,121 +748,6 @@ func (w *spvWallet) Lock() error { 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 := w.decodeAddr(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) - if dexbtc.IsDust(wireOP, feeRate) { - return nil, errors.New("output value is dust") - } - - // converting sats/vB -> sats/kvB - feeRateAmt := btcutil.Amount(feeRate * 1e3) - tx, err := w.wallet.SendOutputs([]*wire.TxOut{wireOP}, nil, w.acctNum, 0, - feeRateAmt, wallet.CoinSelectionLargest, "") - 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: %w", 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) - if dexbtc.IsDust(wireOP, feeRate) { - return nil, errors.New("output value is dust") - } - tx.AddTxOut(wireOP) - - if err := w.wallet.SignTx(tx); err != nil { - return nil, fmt.Errorf("signing error: %w", err) - } - - return w.sendRawTransaction(tx) -} - // estimateSendTxFee callers should provide at least one output value. func (w *spvWallet) estimateSendTxFee(tx *wire.MsgTx, feeRate uint64, subtract bool) (fee uint64, err error) { minTxSize := uint64(tx.SerializeSize()) @@ -872,16 +756,6 @@ func (w *spvWallet) estimateSendTxFee(tx *wire.MsgTx, feeRate uint64, subtract b sendAmount += uint64(txOut.Value) } - // If subtract is true, select enough inputs for sendAmount. Fees will be taken - // from the sendAmount. If not, select enough inputs to cover minimum fees. - enough := func(inputsSize, sum uint64) bool { - if subtract { - return sum >= sendAmount - } - minFee := (minTxSize + inputsSize) * feeRate - return sum >= sendAmount+minFee - } - unspents, err := w.listUnspent() if err != nil { return 0, fmt.Errorf("error listing unspent outputs: %w", err) @@ -892,12 +766,13 @@ func (w *spvWallet) estimateSendTxFee(tx *wire.MsgTx, feeRate uint64, subtract b return 0, fmt.Errorf("error converting unspent outputs: %w", err) } - sum, inputsSize, _, _, _, _, err := fund(utxos, enough) + enough := sendEnough(sendAmount, feeRate, subtract, minTxSize, true, false) + sum, _, inputsSize, _, _, _, _, err := tryFund(utxos, enough) if err != nil { return 0, err } - txSize := minTxSize + uint64(inputsSize) + txSize := minTxSize + inputsSize estFee := feeRate * txSize remaining := sum - sendAmount diff --git a/client/asset/btc/wallet.go b/client/asset/btc/wallet.go index 9e33a5a715..ecb2937622 100644 --- a/client/asset/btc/wallet.go +++ b/client/asset/btc/wallet.go @@ -38,7 +38,6 @@ type Wallet interface { privKeyForAddress(addr string) (*btcec.PrivateKey, error) walletUnlock(pw []byte) error walletLock() error - sendToAddress(address string, value, feeRate uint64, subtract bool) (*chainhash.Hash, error) locked() bool syncStatus() (*syncStatus, error) peerCount() (uint32, error) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 1f3105c407..4290d78991 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -3701,6 +3701,7 @@ func (dcr *ExchangeWallet) MakeBondTx(ver uint16, amt, feeRate uint64, lockTime return nil, nil, err } txid := signedTx.TxHash() // spentAmt := amt + fees + unsignedTxid := baseTx.TxHash() signedTxBytes, err := signedTx.Bytes() if err != nil { @@ -3722,14 +3723,15 @@ func (dcr *ExchangeWallet) MakeBondTx(ver uint16, amt, feeRate uint64, lockTime } bond := &asset.Bond{ - Version: ver, - AssetID: BipID, - Amount: amt, - CoinID: toCoinID(&txid, 0), - Data: bondScript, - SignedTx: signedTxBytes, - UnsignedTx: unsignedTxBytes, - RedeemTx: redeemTx, + Version: ver, + AssetID: BipID, + Amount: amt, + CoinID: toCoinID(&txid, 0), + UnsignedCoinID: toCoinID(&unsignedTxid, 0), + Data: bondScript, + SignedTx: signedTxBytes, + UnsignedTx: unsignedTxBytes, + RedeemTx: redeemTx, } success = true diff --git a/client/asset/doge/doge.go b/client/asset/doge/doge.go index 30cefa0ed7..a59e6a7cc8 100644 --- a/client/asset/doge/doge.go +++ b/client/asset/doge/doge.go @@ -171,7 +171,6 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) InitTxSizeBase: dexbtc.InitTxSizeBase, OmitAddressType: true, LegacySignTxRPC: true, - LegacySendToAddr: true, LegacyValidateAddressRPC: true, BooleanGetBlockRPC: true, SingularWallet: true, diff --git a/client/asset/estimation.go b/client/asset/estimation.go index 0a885a5332..9455853e74 100644 --- a/client/asset/estimation.go +++ b/client/asset/estimation.go @@ -72,8 +72,7 @@ type PreSwapForm struct { RedeemAssetID uint32 } -// PreSwap is a SwapEstimate returned from Wallet.PreSwap. The struct will be -// expanded in in-progress work to accommodate order-time options. +// PreSwap is a SwapEstimate returned from Wallet.PreSwap. type PreSwap struct { Estimate *SwapEstimate `json:"estimate"` Options []*OrderOption `json:"options"` diff --git a/client/asset/interface.go b/client/asset/interface.go index 2a1d0a28d3..ba36872101 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -784,11 +784,12 @@ type PeerManager interface { // the corresponding signed transaction, and a final request is made once the // bond is fully confirmed. The caller should manage the private key. type Bond struct { - Version uint16 - AssetID uint32 - Amount uint64 - CoinID []byte - Data []byte // additional data to interpret the bond e.g. redeem script, bond contract, etc. + Version uint16 + AssetID uint32 + Amount uint64 + UnsignedCoinID []byte + CoinID []byte + Data []byte // additional data to interpret the bond e.g. redeem script, bond contract, etc. // SignedTx and UnsignedTx are the opaque (raw bytes) signed and unsigned // bond creation transactions, in whatever encoding and funding scheme for // this asset and wallet. The unsigned one is used to pre-validate this bond diff --git a/client/asset/zec/zec.go b/client/asset/zec/zec.go index f54834db46..475fa0691e 100644 --- a/client/asset/zec/zec.go +++ b/client/asset/zec/zec.go @@ -179,7 +179,6 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, net dex.Network) (ass InitTxSizeBase: dexzec.InitTxSizeBase, OmitAddressType: true, LegacySignTxRPC: true, - LegacySendToAddr: true, NumericGetRawRPC: true, LegacyValidateAddressRPC: true, SingularWallet: true, diff --git a/client/core/bond.go b/client/core/bond.go index ca20737b5d..95def3ed8a 100644 --- a/client/core/bond.go +++ b/client/core/bond.go @@ -512,7 +512,7 @@ func (c *Core) preValidateBond(dc *dexConnection, bond *asset.Bond) error { return fmt.Errorf("account keys not decrypted") } - assetID, bondCoin := bond.AssetID, bond.CoinID + assetID, bondCoin := bond.AssetID, bond.UnsignedCoinID bondCoinStr := coinIDString(assetID, bondCoin) // Pre-validate with the raw bytes of the unsigned tx and our account @@ -536,8 +536,8 @@ func (c *Core) preValidateBond(dc *dexConnection, bond *asset.Bond) error { c.log.Warnf("prevalidatebond: DEX signature validation error: %v", err) } if !bytes.Equal(preBondRes.BondID, bondCoin) { - return fmt.Errorf("server reported bond coin ID %v, expected %v", bondCoinStr, - coinIDString(assetID, preBondRes.BondID)) + return fmt.Errorf("server reported bond coin ID %v, expected %v", coinIDString(assetID, preBondRes.BondID), + bondCoinStr) } if preBondRes.Amount != bond.Amount { @@ -582,8 +582,8 @@ func (c *Core) postBond(dc *dexConnection, bond *asset.Bond) (*msgjson.PostBondR c.log.Warnf("postbond: DEX signature validation error: %v", err) } if !bytes.Equal(postBondRes.BondID, bondCoin) { - return nil, fmt.Errorf("server reported bond coin ID %v, expected %v", bondCoinStr, - coinIDString(assetID, postBondRes.BondID)) + return nil, fmt.Errorf("server reported bond coin ID %v, expected %v", coinIDString(assetID, postBondRes.BondID), + bondCoinStr) } return postBondRes, nil @@ -1206,16 +1206,17 @@ func (c *Core) makeAndPostBond(dc *dexConnection, acctExists bool, wallet *xcWal // Store the account and bond info. dbBond := &db.Bond{ - Version: bond.Version, - AssetID: bond.AssetID, - CoinID: bond.CoinID, - UnsignedTx: bond.UnsignedTx, - SignedTx: bond.SignedTx, - Data: bond.Data, - Amount: amt, - LockTime: uint64(lockTime.Unix()), - KeyIndex: keyIndex, - RefundTx: bond.RedeemTx, + Version: bond.Version, + AssetID: bond.AssetID, + UnsignedCoinID: bond.UnsignedCoinID, + CoinID: bond.CoinID, + UnsignedTx: bond.UnsignedTx, + SignedTx: bond.SignedTx, + Data: bond.Data, + Amount: amt, + LockTime: uint64(lockTime.Unix()), + KeyIndex: keyIndex, + RefundTx: bond.RedeemTx, // Confirmed and Refunded are false (new bond tx) } diff --git a/client/db/types.go b/client/db/types.go index 6042d30081..481bffc80b 100644 --- a/client/db/types.go +++ b/client/db/types.go @@ -116,7 +116,7 @@ func (b *Bond) UniqueID() []byte { // Encode serialized the Bond. Confirmed and Refund are not included. func (b *Bond) Encode() []byte { - return versionedBytes(1). + return versionedBytes(2). AddData(uint16Bytes(b.Version)). AddData(uint32Bytes(b.AssetID)). AddData(b.CoinID). @@ -126,7 +126,8 @@ func (b *Bond) Encode() []byte { AddData(uint64Bytes(b.Amount)). AddData(uint64Bytes(b.LockTime)). AddData(uint32Bytes(b.KeyIndex)). - AddData(b.RefundTx) + AddData(b.RefundTx). + AddData(b.UnsignedCoinID) // Confirmed and Refunded are not part of the encoding. } @@ -141,6 +142,8 @@ func DecodeBond(b []byte) (*Bond, error) { return decodeBond_v0(pushes) case 1: return decodeBond_v1(pushes) + case 2: + return decodeBond_v2(pushes) } return nil, fmt.Errorf("unknown Bond version %d", ver) } @@ -159,38 +162,64 @@ func decodeBond_v0(pushes [][]byte) (*Bond, error) { // privKey := pushes[8] // in v0, so we will use the refundTx to handle this unreleased revision without deleting out DB files refundTx := pushes[9] return &Bond{ - Version: intCoder.Uint16(ver), - AssetID: intCoder.Uint32(assetIDB), - CoinID: coinID, - UnsignedTx: utx, - SignedTx: stx, - Data: data, - Amount: intCoder.Uint64(amtB), - LockTime: intCoder.Uint64(lockTimeB), - KeyIndex: math.MaxUint32, // special - RefundTx: refundTx, + Version: intCoder.Uint16(ver), + AssetID: intCoder.Uint32(assetIDB), + UnsignedCoinID: coinID, + CoinID: coinID, + UnsignedTx: utx, + SignedTx: stx, + Data: data, + Amount: intCoder.Uint64(amtB), + LockTime: intCoder.Uint64(lockTimeB), + KeyIndex: math.MaxUint32, // special + RefundTx: refundTx, }, nil } func decodeBond_v1(pushes [][]byte) (*Bond, error) { if len(pushes) != 10 { - return nil, fmt.Errorf("decodeBond_v0: expected 10 data pushes, got %d", len(pushes)) + return nil, fmt.Errorf("decodeBond_v1: expected 10 data pushes, got %d", len(pushes)) + } + ver, assetIDB, coinID := pushes[0], pushes[1], pushes[2] + utx, stx := pushes[3], pushes[4] + data, amtB, lockTimeB := pushes[5], pushes[6], pushes[7] + keyIndex, refundTx := pushes[8], pushes[9] + return &Bond{ + Version: intCoder.Uint16(ver), + AssetID: intCoder.Uint32(assetIDB), + UnsignedCoinID: coinID, + CoinID: coinID, + UnsignedTx: utx, + SignedTx: stx, + Data: data, + Amount: intCoder.Uint64(amtB), + LockTime: intCoder.Uint64(lockTimeB), + KeyIndex: intCoder.Uint32(keyIndex), + RefundTx: refundTx, + }, nil +} + +func decodeBond_v2(pushes [][]byte) (*Bond, error) { + if len(pushes) != 10 { + return nil, fmt.Errorf("decodeBond_v2: expected 11 data pushes, got %d", len(pushes)) } ver, assetIDB, coinID := pushes[0], pushes[1], pushes[2] utx, stx := pushes[3], pushes[4] data, amtB, lockTimeB := pushes[5], pushes[6], pushes[7] keyIndex, refundTx := pushes[8], pushes[9] + unsignedCoinID := pushes[10] return &Bond{ - Version: intCoder.Uint16(ver), - AssetID: intCoder.Uint32(assetIDB), - CoinID: coinID, - UnsignedTx: utx, - SignedTx: stx, - Data: data, - Amount: intCoder.Uint64(amtB), - LockTime: intCoder.Uint64(lockTimeB), - KeyIndex: intCoder.Uint32(keyIndex), - RefundTx: refundTx, + Version: intCoder.Uint16(ver), + AssetID: intCoder.Uint32(assetIDB), + UnsignedCoinID: unsignedCoinID, + CoinID: coinID, + UnsignedTx: utx, + SignedTx: stx, + Data: data, + Amount: intCoder.Uint64(amtB), + LockTime: intCoder.Uint64(lockTimeB), + KeyIndex: intCoder.Uint32(keyIndex), + RefundTx: refundTx, }, nil } diff --git a/dex/networks/btc/script.go b/dex/networks/btc/script.go index f7b346e2c8..b8004f7146 100644 --- a/dex/networks/btc/script.go +++ b/dex/networks/btc/script.go @@ -8,16 +8,27 @@ import ( "crypto/sha256" "encoding/binary" "encoding/hex" + "errors" "fmt" "decred.org/dcrdex/dex" + "decred.org/dcrdex/server/account" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + dcrtxscript "github.com/decred/dcrd/txscript/v4" ) const ( + // MaxCLTVScriptNum is the largest usable value for a CLTV lockTime. This + // will actually be stored in a 5-byte ScriptNum since they have a sign bit, + // however, it is not 2^39-1 since the spending transaction's nLocktime is + // an unsigned 32-bit integer and it must be at least the CLTV value. This + // establishes a maximum lock time of February 7, 2106. Any later requires + // using a block height instead of a unix epoch time stamp. + MaxCLTVScriptNum = 1<<32 - 1 // 0xffff_ffff a.k.a. 2^32-1 + // SecretHashSize is the byte-length of the hash of the secret key used in an // atomic swap. SecretHashSize = 32 @@ -235,6 +246,21 @@ const ( (SegwitMarkerAndFlagWeight+RedeemP2WPKHInputWitnessWeight+(witnessWeight-1))/witnessWeight witnessWeight = 4 // github.com/btcsuite/btcd/blockchain.WitnessScaleFactor + + // BondScriptSize is the maximum size of a DEX time-locked fidelity bond + // output script to which a bond P2SH pays: + // OP_DATA_4/5 (4/5 bytes lockTime) OP_CHECKLOCKTIMEVERIFY OP_DROP OP_DUP OP_HASH160 OP_DATA_20 (20-byte pubkey hash160) OP_EQUALVERIFY OP_CHECKSIG + BondScriptSize = 1 + 5 + 1 + 1 + 1 + 1 + 1 + 20 + 1 + 1 // 33 + + // RedeemBondSigScriptSize is the worst case size of a fidelity bond + // signature script that spends a bond output. It includes a signature, a + // compressed pubkey, and the bond script. Each of said data pushes use an + // OP_DATA_ code. + RedeemBondSigScriptSize = 1 + DERSigLength + 1 + 33 + 1 + BondScriptSize // 142 + + // BondPushDataSize is the size of the nulldata in a bond commitment output: + // OP_RETURN + BondPushDataSize = 2 + account.HashSize + 4 + 20 ) // BTCScriptType holds details about a pubkey script and possibly it's redeem @@ -601,6 +627,205 @@ func RefundP2SHContract(contract, sig, pubkey []byte) ([]byte, error) { Script() } +// OP_RETURN +func extractBondCommitDataV0(pushData []byte) (acct account.AccountID, lockTime uint32, pubkeyHash [20]byte, err error) { + if len(pushData) < 2 { + err = errors.New("invalid data") + return + } + ver := binary.BigEndian.Uint16(pushData) + if ver != 0 { + err = fmt.Errorf("unexpected bond commitment version %d, expected 0", ver) + return + } + + if len(pushData) != BondPushDataSize { + err = fmt.Errorf("invalid bond commitment output script length: %d", len(pushData)) + return + } + + pushData = pushData[2:] // pop off ver + + copy(acct[:], pushData) + pushData = pushData[account.HashSize:] + + lockTime = binary.BigEndian.Uint32(pushData) + pushData = pushData[4:] + + copy(pubkeyHash[:], pushData) + + return +} + +// ExtractBondCommitDataV0 parses a v0 bond commitment output script. This is +// the OP_RETURN output, not the P2SH bond output. Use ExtractBondDetailsV0 to +// parse the P2SH bond output's redeem script. +// +// If the decoded commitment data indicates a version other than 0, an error is +// returned. +func ExtractBondCommitDataV0(scriptVer uint16, pkScript []byte) (acct account.AccountID, lockTime uint32, pubkeyHash [20]byte, err error) { + tokenizer := txscript.MakeScriptTokenizer(scriptVer, pkScript) + if !tokenizer.Next() { + err = tokenizer.Err() + return + } + + if tokenizer.Opcode() != txscript.OP_RETURN { + err = errors.New("not a null data output") + return + } + + if !tokenizer.Next() { + err = tokenizer.Err() + return + } + + pushData := tokenizer.Data() + acct, lockTime, pubkeyHash, err = extractBondCommitDataV0(pushData) + if err != nil { + return + } + + if !tokenizer.Done() { + err = errors.New("script has extra opcodes") + return + } + + return +} + +// MakeBondScript constructs a versioned bond output script for the provided +// lock time and pubkey hash. Only version 0 is supported at present. The lock +// time must be less than 2^32-1 so that it uses at most 5 bytes. The lockTime +// is also required to use at least 4 bytes (time stamp, not block time). +func MakeBondScript(ver uint16, lockTime uint32, pubkeyHash []byte) ([]byte, error) { + if ver != 0 { + return nil, errors.New("only version 0 bonds supported") + } + if lockTime >= MaxCLTVScriptNum { // == should be OK, but let's not + return nil, errors.New("invalid lock time") + } + lockTimeInt64 := int64(lockTime) + if len(pubkeyHash) != 20 { + return nil, errors.New("invalid pubkey hash") + } + return txscript.NewScriptBuilder(). + AddInt64(lockTimeInt64). + AddOp(txscript.OP_CHECKLOCKTIMEVERIFY). + AddOp(txscript.OP_DROP). + AddOp(txscript.OP_DUP). + AddOp(txscript.OP_HASH160). + AddData(pubkeyHash). + AddOp(txscript.OP_EQUALVERIFY). + AddOp(txscript.OP_CHECKSIG). + Script() +} + +// RefundBondScript builds the signature script to refund a time-locked fidelity +// bond in a P2SH output paying to the provided P2PKH bondScript. +func RefundBondScript(bondScript, sig, pubkey []byte) ([]byte, error) { + return txscript.NewScriptBuilder(). + AddData(sig). + AddData(pubkey). + AddData(bondScript). + Script() +} + +// RefundBondScript builds the signature script to refund a time-locked fidelity +// bond in a P2WSH output paying to the provided P2PKH bondScript. +func RefundBondScriptSegwit(bondScript, sig, pubkey []byte) [][]byte { + return [][]byte{ + sig, + pubkey, + bondScript, + } +} + +// ExtractBondDetailsV0 validates the provided bond redeem script, extracting +// the lock time and pubkey. The V0 format of the script must be as follows: +// +// OP_CHECKLOCKTIMEVERIFY OP_DROP OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG +// +// The script version refers to the pkScript version, not bond version, which +// pertains to DEX's version of the bond script. +func ExtractBondDetailsV0(scriptVersion uint16, bondScript []byte) (lockTime uint32, pkh []byte, err error) { + type templateMatch struct { + expectInt bool + maxIntBytes int + opcode byte + extractedInt int64 + extractedData []byte + } + var template = [...]templateMatch{ + {expectInt: true, maxIntBytes: 5}, // extractedInt + {opcode: txscript.OP_CHECKLOCKTIMEVERIFY}, + {opcode: txscript.OP_DROP}, + {opcode: txscript.OP_DUP}, + {opcode: txscript.OP_HASH160}, + {opcode: txscript.OP_DATA_20}, // extractedData + {opcode: txscript.OP_EQUALVERIFY}, + {opcode: txscript.OP_CHECKSIG}, + } + + var templateOffset int + tokenizer := txscript.MakeScriptTokenizer(scriptVersion, bondScript) + for tokenizer.Next() { + if templateOffset >= len(template) { + return 0, nil, errors.New("too many script elements") + } + + op, data := tokenizer.Opcode(), tokenizer.Data() + tplEntry := &template[templateOffset] + if tplEntry.expectInt { + switch { + case data != nil: + val, err := dcrtxscript.MakeScriptNum(data, tplEntry.maxIntBytes) + if err != nil { + return 0, nil, err + } + tplEntry.extractedInt = int64(val) + case dcrtxscript.IsSmallInt(op): // not expected for our lockTimes, but it is an integer + tplEntry.extractedInt = int64(dcrtxscript.AsSmallInt(op)) + default: + return 0, nil, errors.New("expected integer") + } + } else { + if op != tplEntry.opcode { + return 0, nil, fmt.Errorf("expected opcode %v, got %v", tplEntry.opcode, op) + } + + tplEntry.extractedData = data + } + + templateOffset++ + } + if err := tokenizer.Err(); err != nil { + return 0, nil, err + } + if !tokenizer.Done() || templateOffset != len(template) { + return 0, nil, errors.New("incorrect script length") + } + + // The script matches in structure. Now validate the two pushes. + + lockTime64 := template[0].extractedInt + if lockTime64 <= 0 { // || lockTime64 > MaxCLTVScriptNum { + return 0, nil, fmt.Errorf("invalid locktime %d", lockTime64) + } + lockTime = uint32(lockTime64) + + const pubkeyHashLen = 20 + bondPubKeyHash := template[5].extractedData + if len(bondPubKeyHash) != pubkeyHashLen { + err = errors.New("missing or invalid pubkeyhash data") + return + } + pkh = make([]byte, pubkeyHashLen) + copy(pkh, bondPubKeyHash) + + return +} + // MsgTxVBytes returns the transaction's virtual size, which accounts for the // segwit input weighting. func MsgTxVBytes(msgTx *wire.MsgTx) uint64 { diff --git a/dex/networks/dcr/script.go b/dex/networks/dcr/script.go index 6c6403e647..bfdb623311 100644 --- a/dex/networks/dcr/script.go +++ b/dex/networks/dcr/script.go @@ -120,13 +120,13 @@ const ( // BondScriptSize is the maximum size of a DEX time-locked fidelity bond // output script to which a bond P2SH pays: // OP_DATA_4/5 (4/5 bytes lockTime) OP_CHECKLOCKTIMEVERIFY OP_DROP OP_DUP OP_HASH160 OP_DATA_20 (20-byte pubkey hash160) OP_EQUALVERIFY OP_CHECKSIG - BondScriptSize = 1 + 5 + 1 + 1 + 1 + 1 + 1 + 20 + 1 + 1 // 32 + BondScriptSize = 1 + 5 + 1 + 1 + 1 + 1 + 1 + 20 + 1 + 1 // 33 // RedeemBondSigScriptSize is the worst case size of a fidelity bond // signature script that spends a bond output. It includes a signature, a // compressed pubkey, and the bond script. Each of said data pushes use an // OP_DATA_ code. - RedeemBondSigScriptSize = 1 + DERSigLength + 1 + pubkeyLength + 1 + BondScriptSize // 141 + RedeemBondSigScriptSize = 1 + DERSigLength + 1 + pubkeyLength + 1 + BondScriptSize // 142 // BondPushDataSize is the size of the nulldata in a bond commitment output: // OP_RETURN diff --git a/dex/testing/dcrdex/harness.sh b/dex/testing/dcrdex/harness.sh index fc29262eed..76287bb9f5 100755 --- a/dex/testing/dcrdex/harness.sh +++ b/dex/testing/dcrdex/harness.sh @@ -187,7 +187,10 @@ cat << EOF >> "./markets.json" "configPath": "${TEST_ROOT}/btc/alpha/alpha.conf", "regConfs": 2, "regFee": 20000000, - "regXPub": "vpub5SLqN2bLY4WeZJ9SmNJHsyzqVKreTXD4ZnPC22MugDNcjhKX5xNX9QiQWcE4SSRzVWyHWUihpKRT7hckDGNzVc69wSX2JPcfGeNiT5c2XZy" + "regXPub": "vpub5SLqN2bLY4WeZJ9SmNJHsyzqVKreTXD4ZnPC22MugDNcjhKX5xNX9QiQWcE4SSRzVWyHWUihpKRT7hckDGNzVc69wSX2JPcfGeNiT5c2XZy", + "bondAmt": 10000000, + "bondConfs": 1 + EOF if [ $LTC_ON -eq 0 ]; then diff --git a/server/asset/btc/btc.go b/server/asset/btc/btc.go index ff11b5f93e..385060c9f1 100644 --- a/server/asset/btc/btc.go +++ b/server/asset/btc/btc.go @@ -19,7 +19,10 @@ import ( "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/config" dexbtc "decred.org/dcrdex/dex/networks/btc" + "decred.org/dcrdex/server/account" "decred.org/dcrdex/server/asset" + srvdex "decred.org/dcrdex/server/dex" + "github.com/btcsuite/btcd/blockchain" "github.com/btcsuite/btcd/btcjson" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg" @@ -90,6 +93,7 @@ const ( BipID = 0 assetName = "btc" immatureTransactionError = dex.ErrorKind("immature output") + BondVersion = 0 ) func netParams(network dex.Network) (*chaincfg.Params, error) { @@ -156,6 +160,7 @@ type Backend struct { // Check that Backend satisfies the Backend interface. var _ asset.Backend = (*Backend)(nil) +var _ srvdex.Bonder = (*Backend)(nil) // NewBackend is the exported constructor by which the DEX will import the // backend. The configPath can be an empty string, in which case the standard @@ -509,6 +514,173 @@ func (btc *Backend) VerifyUnspentCoin(_ context.Context, coinID []byte) error { return nil } +// ParseBondTx performs basic validation of a serialized time-locked fidelity +// bond transaction given the bond's P2SH or P2WSH redeem script. +// +// The transaction must have at least two outputs: out 0 pays to a P2SH address +// (the bond), and out 1 is a nulldata output that commits to an account ID. +// There may also be a change output. +// +// Returned: The bond's coin ID (i.e. encoded UTXO) of the bond output. The bond +// output's amount and PWSH/P2WSH address. The lockTime and pubkey hash data pushes +// from the script. The account ID from the second output is also returned. +// +// Properly formed transactions: +// +// 1. The bond output (vout 0) must be a P2SH/P2WSH output. +// 2. The bond's redeem script must be of the form: +// OP_CHECKLOCKTIMEVERIFY OP_DROP OP_DUP OP_HASH160 OP_EQUALVERIFY OP_CHECKSIG +// 3. The null data output (vout 1) must have a 58-byte data push (ver | account ID | lockTime | pubkeyHash). +// 4. The transaction must have a zero locktime and expiry. +// 5. All inputs must have the max sequence num set (finalized). +// 6. The transaction must pass the checks in the +// blockchain.CheckTransactionSanity function. +func ParseBondTx(ver uint16, rawTx []byte, chainParams *chaincfg.Params) (bondCoinID []byte, amt int64, bondAddr string, + bondPubKeyHash []byte, lockTime int64, acct account.AccountID, err error) { + if ver != BondVersion { + err = errors.New("only version 0 bonds supported") + return + } + msgTx := wire.NewMsgTx(wire.TxVersion) + if err = msgTx.Deserialize(bytes.NewReader(rawTx)); err != nil { + return + } + + if msgTx.LockTime != 0 { + err = errors.New("transaction locktime not zero") + return + } + if err = blockchain.CheckTransactionSanity(btcutil.NewTx(msgTx)); err != nil { + return + } + + if len(msgTx.TxOut) < 2 { + err = fmt.Errorf("expected at least 2 outputs, found %d", len(msgTx.TxOut)) + return + } + + for _, txIn := range msgTx.TxIn { + if txIn.Sequence != wire.MaxTxInSequenceNum { + err = errors.New("input has non-max sequence number") + return + } + } + + // Fidelity bond (output 0) + bondOut := msgTx.TxOut[0] + scriptHash := dexbtc.ExtractScriptHash(bondOut.PkScript) + if scriptHash == nil { + err = fmt.Errorf("bad bond pkScript") + return + } + + acctCommitOut := msgTx.TxOut[1] + acct, lock, pkh, err := dexbtc.ExtractBondCommitDataV0(0, acctCommitOut.PkScript) + if err != nil { + err = fmt.Errorf("invalid bond commitment output: %w", err) + return + } + + // Reconstruct and check the bond redeem script. + bondScript, err := dexbtc.MakeBondScript(ver, lock, pkh[:]) + if err != nil { + err = fmt.Errorf("failed to build bond output redeem script: %w", err) + return + } + + // Check that the script hash extracted from output 0 is what is expected + // based on the information in the account commitment. + // P2WSH uses sha256, while P2SH uses ripemd160(sha256). + var expectedScriptHash []byte + if len(scriptHash) == 32 { + hash := sha256.Sum256(bondScript) + expectedScriptHash = hash[:] + } else { + expectedScriptHash = btcutil.Hash160(bondScript) + } + if !bytes.Equal(expectedScriptHash, scriptHash) { + err = fmt.Errorf("script hash check failed for output 0 of %s", msgTx.TxHash()) + return + } + + _, addrs, _, err := dexbtc.ExtractScriptData(bondOut.PkScript, chainParams) + if err != nil { + err = fmt.Errorf("error extracting addresses from bond output: %w", err) + return + } + + txid := msgTx.TxHash() + bondCoinID = toCoinID(&txid, 0) + amt = bondOut.Value + bondAddr = addrs[0] // don't convert address, must match type we specified + lockTime = int64(lock) + bondPubKeyHash = pkh[:] + + return +} + +// BondVer returns the latest supported bond version. +func (dcr *Backend) BondVer() uint16 { + return BondVersion +} + +// ParseBondTx makes the package-level ParseBondTx pure function accessible via +// a Backend instance. This performs basic validation of a serialized +// time-locked fidelity bond transaction given the bond's P2SH redeem script. +func (btc *Backend) ParseBondTx(ver uint16, rawTx []byte) (bondCoinID []byte, amt int64, bondAddr string, + bondPubKeyHash []byte, lockTime int64, acct account.AccountID, err error) { + return ParseBondTx(ver, rawTx, btc.chainParams) +} + +// BondCoin locates a bond transaction output, validates the entire transaction, +// and returns the amount, encoded lockTime and account ID, and the +// confirmations of the transaction. It is a CoinNotFoundError if the +// transaction output is spent. +func (btc *Backend) BondCoin(ctx context.Context, ver uint16, coinID []byte) (amt, lockTime, confs int64, acct account.AccountID, err error) { + txHash, vout, errCoin := decodeCoinID(coinID) + if errCoin != nil { + err = fmt.Errorf("error decoding coin ID %x: %w", coinID, errCoin) + return + } + + verboseTx, err := btc.node.GetRawTransactionVerbose(txHash) + if err != nil { + if isTxNotFoundErr(err) { + err = asset.CoinNotFoundError + } + return + } + + if int(vout) > len(verboseTx.Vout)-1 { + err = fmt.Errorf("invalid output index for tx with %d outputs", len(verboseTx.Vout)) + return + } + + confs = int64(verboseTx.Confirmations) + + rawTx, err := hex.DecodeString(verboseTx.Hex) // ParseBondTx will deserialize to msgTx, so just get the bytes + if err != nil { + err = fmt.Errorf("failed to decode transaction %s: %w", txHash, err) + return + } + + txOut, err := btc.node.GetTxOut(txHash, vout, true) // check regular tree first + if err != nil { + if isTxNotFoundErr(err) { // should be txOut==nil, but checking anyway + err = asset.CoinNotFoundError + return + } + return + } + if txOut == nil { // spent == invalid bond + err = asset.CoinNotFoundError + return + } + + _, amt, _, _, lockTime, acct, err = ParseBondTx(ver, rawTx, btc.chainParams) + return +} + // FeeCoin gets the recipient address, value, and confirmations of a transaction // output encoded by the given coinID. A non-nil error is returned if the // output's pubkey script is not a P2WPKH requiring a single ECDSA-secp256k1 diff --git a/server/auth/registrar.go b/server/auth/registrar.go index e60953e978..593c69f003 100644 --- a/server/auth/registrar.go +++ b/server/auth/registrar.go @@ -73,9 +73,6 @@ func (auth *AuthManager) handlePreValidateBond(conn comms.Link, msg *msgjson.Mes if !ok { return msgjson.NewError(msgjson.BondError, "only DCR bonds supported presently") } - if assetID != 42 { // Temporary! need to update tier computations for different bond increment/amounts! - return msgjson.NewError(msgjson.BondError, "only DCR bonds supported presently") - } // Create an account.Account from the provided pubkey. acct, err := account.NewAccountFromPubKey(preBond.AcctPubKey) @@ -158,15 +155,11 @@ func (auth *AuthManager) handlePostBond(conn comms.Link, msg *msgjson.Message) * return msgjson.NewError(msgjson.BondError, "error parsing postbond request") } - // TODO: allow different assets for bond, switching parse functions, etc. assetID := postBond.AssetID bondAsset, ok := auth.bondAssets[assetID] if !ok { return msgjson.NewError(msgjson.BondError, "only DCR bonds supported presently") } - if assetID != 42 { // Temporary! need to update tier computations for different bond increment/amounts! - return msgjson.NewError(msgjson.BondError, "only DCR bonds supported presently") - } // Create an account.Account from the provided pubkey. acct, err := account.NewAccountFromPubKey(postBond.AcctPubKey) From d5b37f2c3b1bbf9c8d57dd78bc7fff8326937d12 Mon Sep 17 00:00:00 2001 From: martonp Date: Fri, 3 Mar 2023 13:38:32 -0500 Subject: [PATCH 2/8] Server signs unsigned tx in PreValidateBondResult --- client/asset/btc/btc.go | 18 +++++----- client/asset/dcr/dcr.go | 18 +++++----- client/core/bond.go | 34 +++++++----------- client/db/types.go | 75 ++++++++++++---------------------------- dex/msgjson/types.go | 6 ++-- server/auth/registrar.go | 3 +- 6 files changed, 55 insertions(+), 99 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 6f6713fc26..1780edba94 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -5185,7 +5185,6 @@ func (btc *baseWallet) MakeBondTx(ver uint16, amt, feeRate uint64, lockTime time } txid := signedTx.TxHash() - unsignedTxid := baseTx.TxHash() signedTxBytes, err := serializeMsgTx(signedTx) if err != nil { @@ -5207,15 +5206,14 @@ func (btc *baseWallet) MakeBondTx(ver uint16, amt, feeRate uint64, lockTime time } bond := &asset.Bond{ - Version: ver, - AssetID: BipID, - Amount: amt, - CoinID: toCoinID(&txid, 0), - UnsignedCoinID: toCoinID(&unsignedTxid, 0), - Data: bondScript, - SignedTx: signedTxBytes, - UnsignedTx: unsignedTxBytes, - RedeemTx: redeemTx, + Version: ver, + AssetID: BipID, + Amount: amt, + CoinID: toCoinID(&txid, 0), + Data: bondScript, + SignedTx: signedTxBytes, + UnsignedTx: unsignedTxBytes, + RedeemTx: redeemTx, } success = true diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 4290d78991..1f3105c407 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -3701,7 +3701,6 @@ func (dcr *ExchangeWallet) MakeBondTx(ver uint16, amt, feeRate uint64, lockTime return nil, nil, err } txid := signedTx.TxHash() // spentAmt := amt + fees - unsignedTxid := baseTx.TxHash() signedTxBytes, err := signedTx.Bytes() if err != nil { @@ -3723,15 +3722,14 @@ func (dcr *ExchangeWallet) MakeBondTx(ver uint16, amt, feeRate uint64, lockTime } bond := &asset.Bond{ - Version: ver, - AssetID: BipID, - Amount: amt, - CoinID: toCoinID(&txid, 0), - UnsignedCoinID: toCoinID(&unsignedTxid, 0), - Data: bondScript, - SignedTx: signedTxBytes, - UnsignedTx: unsignedTxBytes, - RedeemTx: redeemTx, + Version: ver, + AssetID: BipID, + Amount: amt, + CoinID: toCoinID(&txid, 0), + Data: bondScript, + SignedTx: signedTxBytes, + UnsignedTx: unsignedTxBytes, + RedeemTx: redeemTx, } success = true diff --git a/client/core/bond.go b/client/core/bond.go index 95def3ed8a..143cbc2561 100644 --- a/client/core/bond.go +++ b/client/core/bond.go @@ -512,14 +512,11 @@ func (c *Core) preValidateBond(dc *dexConnection, bond *asset.Bond) error { return fmt.Errorf("account keys not decrypted") } - assetID, bondCoin := bond.AssetID, bond.UnsignedCoinID - bondCoinStr := coinIDString(assetID, bondCoin) - // Pre-validate with the raw bytes of the unsigned tx and our account // pubkey. preBond := &msgjson.PreValidateBond{ AcctPubKey: pkBytes, - AssetID: assetID, + AssetID: bond.AssetID, Version: bond.Version, RawTx: bond.UnsignedTx, } @@ -531,13 +528,9 @@ func (c *Core) preValidateBond(dc *dexConnection, bond *asset.Bond) error { } // Check the response signature. - err = dc.acct.checkSig(preBondRes.Serialize(), preBondRes.Sig) + err = dc.acct.checkSig(append(preBondRes.Serialize(), bond.UnsignedTx...), preBondRes.Sig) if err != nil { - c.log.Warnf("prevalidatebond: DEX signature validation error: %v", err) - } - if !bytes.Equal(preBondRes.BondID, bondCoin) { - return fmt.Errorf("server reported bond coin ID %v, expected %v", coinIDString(assetID, preBondRes.BondID), - bondCoinStr) + return fmt.Errorf("preValidateBond: DEX signature validation error: %v", err) } if preBondRes.Amount != bond.Amount { @@ -1206,17 +1199,16 @@ func (c *Core) makeAndPostBond(dc *dexConnection, acctExists bool, wallet *xcWal // Store the account and bond info. dbBond := &db.Bond{ - Version: bond.Version, - AssetID: bond.AssetID, - UnsignedCoinID: bond.UnsignedCoinID, - CoinID: bond.CoinID, - UnsignedTx: bond.UnsignedTx, - SignedTx: bond.SignedTx, - Data: bond.Data, - Amount: amt, - LockTime: uint64(lockTime.Unix()), - KeyIndex: keyIndex, - RefundTx: bond.RedeemTx, + Version: bond.Version, + AssetID: bond.AssetID, + CoinID: bond.CoinID, + UnsignedTx: bond.UnsignedTx, + SignedTx: bond.SignedTx, + Data: bond.Data, + Amount: amt, + LockTime: uint64(lockTime.Unix()), + KeyIndex: keyIndex, + RefundTx: bond.RedeemTx, // Confirmed and Refunded are false (new bond tx) } diff --git a/client/db/types.go b/client/db/types.go index 481bffc80b..6042d30081 100644 --- a/client/db/types.go +++ b/client/db/types.go @@ -116,7 +116,7 @@ func (b *Bond) UniqueID() []byte { // Encode serialized the Bond. Confirmed and Refund are not included. func (b *Bond) Encode() []byte { - return versionedBytes(2). + return versionedBytes(1). AddData(uint16Bytes(b.Version)). AddData(uint32Bytes(b.AssetID)). AddData(b.CoinID). @@ -126,8 +126,7 @@ func (b *Bond) Encode() []byte { AddData(uint64Bytes(b.Amount)). AddData(uint64Bytes(b.LockTime)). AddData(uint32Bytes(b.KeyIndex)). - AddData(b.RefundTx). - AddData(b.UnsignedCoinID) + AddData(b.RefundTx) // Confirmed and Refunded are not part of the encoding. } @@ -142,8 +141,6 @@ func DecodeBond(b []byte) (*Bond, error) { return decodeBond_v0(pushes) case 1: return decodeBond_v1(pushes) - case 2: - return decodeBond_v2(pushes) } return nil, fmt.Errorf("unknown Bond version %d", ver) } @@ -162,64 +159,38 @@ func decodeBond_v0(pushes [][]byte) (*Bond, error) { // privKey := pushes[8] // in v0, so we will use the refundTx to handle this unreleased revision without deleting out DB files refundTx := pushes[9] return &Bond{ - Version: intCoder.Uint16(ver), - AssetID: intCoder.Uint32(assetIDB), - UnsignedCoinID: coinID, - CoinID: coinID, - UnsignedTx: utx, - SignedTx: stx, - Data: data, - Amount: intCoder.Uint64(amtB), - LockTime: intCoder.Uint64(lockTimeB), - KeyIndex: math.MaxUint32, // special - RefundTx: refundTx, + Version: intCoder.Uint16(ver), + AssetID: intCoder.Uint32(assetIDB), + CoinID: coinID, + UnsignedTx: utx, + SignedTx: stx, + Data: data, + Amount: intCoder.Uint64(amtB), + LockTime: intCoder.Uint64(lockTimeB), + KeyIndex: math.MaxUint32, // special + RefundTx: refundTx, }, nil } func decodeBond_v1(pushes [][]byte) (*Bond, error) { if len(pushes) != 10 { - return nil, fmt.Errorf("decodeBond_v1: expected 10 data pushes, got %d", len(pushes)) - } - ver, assetIDB, coinID := pushes[0], pushes[1], pushes[2] - utx, stx := pushes[3], pushes[4] - data, amtB, lockTimeB := pushes[5], pushes[6], pushes[7] - keyIndex, refundTx := pushes[8], pushes[9] - return &Bond{ - Version: intCoder.Uint16(ver), - AssetID: intCoder.Uint32(assetIDB), - UnsignedCoinID: coinID, - CoinID: coinID, - UnsignedTx: utx, - SignedTx: stx, - Data: data, - Amount: intCoder.Uint64(amtB), - LockTime: intCoder.Uint64(lockTimeB), - KeyIndex: intCoder.Uint32(keyIndex), - RefundTx: refundTx, - }, nil -} - -func decodeBond_v2(pushes [][]byte) (*Bond, error) { - if len(pushes) != 10 { - return nil, fmt.Errorf("decodeBond_v2: expected 11 data pushes, got %d", len(pushes)) + return nil, fmt.Errorf("decodeBond_v0: expected 10 data pushes, got %d", len(pushes)) } ver, assetIDB, coinID := pushes[0], pushes[1], pushes[2] utx, stx := pushes[3], pushes[4] data, amtB, lockTimeB := pushes[5], pushes[6], pushes[7] keyIndex, refundTx := pushes[8], pushes[9] - unsignedCoinID := pushes[10] return &Bond{ - Version: intCoder.Uint16(ver), - AssetID: intCoder.Uint32(assetIDB), - UnsignedCoinID: unsignedCoinID, - CoinID: coinID, - UnsignedTx: utx, - SignedTx: stx, - Data: data, - Amount: intCoder.Uint64(amtB), - LockTime: intCoder.Uint64(lockTimeB), - KeyIndex: intCoder.Uint32(keyIndex), - RefundTx: refundTx, + Version: intCoder.Uint16(ver), + AssetID: intCoder.Uint32(assetIDB), + CoinID: coinID, + UnsignedTx: utx, + SignedTx: stx, + Data: data, + Amount: intCoder.Uint64(amtB), + LockTime: intCoder.Uint64(lockTimeB), + KeyIndex: intCoder.Uint32(keyIndex), + RefundTx: refundTx, }, nil } diff --git a/dex/msgjson/types.go b/dex/msgjson/types.go index df21664872..1c38c6672f 100644 --- a/dex/msgjson/types.go +++ b/dex/msgjson/types.go @@ -1041,18 +1041,16 @@ type PreValidateBondResult struct { AssetID uint32 `json:"assetID"` Amount uint64 `json:"amount"` Expiry uint64 `json:"expiry"` // not locktime, but time when bond expires for dex - BondID Bytes `json:"bondID"` } // Serialize serializes the PreValidateBondResult data for the signature. func (pbr *PreValidateBondResult) Serialize() []byte { - sz := len(pbr.AccountID) + 4 + 8 + 8 + len(pbr.BondID) + sz := len(pbr.AccountID) + 4 + 8 + 8 b := make([]byte, 0, sz) b = append(b, pbr.AccountID...) b = append(b, uint32Bytes(pbr.AssetID)...) b = append(b, uint64Bytes(pbr.Amount)...) - b = append(b, uint64Bytes(pbr.Expiry)...) - return append(b, pbr.BondID...) + return append(b, uint64Bytes(pbr.Expiry)...) } // PostBond requests that server accept a confirmed bond payment, specified by diff --git a/server/auth/registrar.go b/server/auth/registrar.go index 593c69f003..463617b915 100644 --- a/server/auth/registrar.go +++ b/server/auth/registrar.go @@ -123,9 +123,8 @@ func (auth *AuthManager) handlePreValidateBond(conn comms.Link, msg *msgjson.Mes AssetID: assetID, Amount: uint64(amt), Expiry: uint64(expireTime.Unix()), - BondID: bondCoinID, } - auth.Sign(preBondRes) + preBondRes.SetSig(auth.SignMsg(append(preBondRes.Serialize(), preBond.RawTx...))) resp, err := msgjson.NewResponse(msg.ID, preBondRes, nil) if err != nil { // shouldn't be possible From a9ffb39fc4fd3cc6980ce6610debf1af687bd70d Mon Sep 17 00:00:00 2001 From: martonp Date: Fri, 3 Mar 2023 13:38:52 -0500 Subject: [PATCH 3/8] Removing references to mixing in comments --- client/asset/btc/coin_selection.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/asset/btc/coin_selection.go b/client/asset/btc/coin_selection.go index 0e36882f62..7b65043362 100644 --- a/client/asset/btc/coin_selection.go +++ b/client/asset/btc/coin_selection.go @@ -16,10 +16,10 @@ import ( // the fund method when creating transactions to send funds. If fees are to be // subtracted from the inputs, set subtract so that the required amount excludes // the transaction fee. If change from the transaction should be considered -// immediately available (not mixing), set reportChange to indicate this and the -// returned enough func will return a non-zero excess value. Otherwise, the -// enough func will always return 0, leaving only unselected UTXOs to cover any -// required reserves. +// immediately available, set reportChange to indicate this and the returned +// enough func will return a non-zero excess value. Otherwise, the enough func +// will always return 0, leaving only unselected UTXOs to cover any required +// reserves. func sendEnough(amt, feeRate uint64, subtract bool, baseTxSize uint64, segwit, reportChange bool) func(inputSize, sum uint64) (bool, uint64) { return func(inputSize, sum uint64) (bool, uint64) { txFee := (baseTxSize + inputSize) * feeRate @@ -40,10 +40,10 @@ func sendEnough(amt, feeRate uint64, subtract bool, baseTxSize uint64, segwit, r // orderEnough generates a function that can be used as the enough argument to // the fund method. If change from a split transaction will be created AND -// immediately available (not mixing), set reportChange to indicate this and the -// returned enough func will return a non-zero excess value reflecting this -// potential spit tx change. Otherwise, the enough func will always return 0, -// leaving only unselected UTXOs to cover any required reserves. +// immediately available, set reportChange to indicate this and the returned +// enough func will return a non-zero excess value reflecting this potential +// spit tx change. Otherwise, the enough func will always return 0, leaving +// only unselected UTXOs to cover any required reserves. func orderEnough(val, lots, feeRate, initTxSizeBase, initTxSize uint64, segwit, reportChange bool) func(inputsSize, sum uint64) (bool, uint64) { return func(inputsSize, sum uint64) (bool, uint64) { reqFunds := calc.RequiredOrderFundsAlt(val, inputsSize, lots, initTxSizeBase, initTxSize, feeRate) From 04ca26ec91eee5007020b9eef50e4ad6879dffaa Mon Sep 17 00:00:00 2001 From: martonp Date: Mon, 20 Mar 2023 18:24:38 -0400 Subject: [PATCH 4/8] Fix support for btc clones. --- client/asset/bch/bch.go | 1 + client/asset/btc/btc.go | 5 ++++- client/asset/doge/doge.go | 1 + client/asset/ltc/ltc.go | 1 + client/asset/zec/zec.go | 1 + dex/testing/dcrdex/harness.sh | 8 ++++++-- 6 files changed, 14 insertions(+), 3 deletions(-) diff --git a/client/asset/bch/bch.go b/client/asset/bch/bch.go index c66ca0b23d..80f15243a1 100644 --- a/client/asset/bch/bch.go +++ b/client/asset/bch/bch.go @@ -216,6 +216,7 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) // then, they modified it from the old Bitcoin Core estimatefee by // removing the confirmation target argument. FeeEstimator: estimateFee, + AssetID: BipID, } switch cfg.Type { diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 1780edba94..c209a98941 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -388,6 +388,8 @@ type BTCCloneCFG struct { ManualMedianTime bool // OmitRPCOptionsArg is for clones that don't take an options argument. OmitRPCOptionsArg bool + // AssetID is the asset ID of the clone. + AssetID uint32 } // outPoint is the hash and output index of a transaction output. @@ -1042,6 +1044,7 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, net dex.Network) (ass // FeeEstimator must default to rpcFeeRate if not set, but set a // specific external estimator: ExternalFeeEstimator: externalFeeEstimator, + AssetID: BipID, } switch cfg.Type { @@ -5207,7 +5210,7 @@ func (btc *baseWallet) MakeBondTx(ver uint16, amt, feeRate uint64, lockTime time bond := &asset.Bond{ Version: ver, - AssetID: BipID, + AssetID: btc.cloneParams.AssetID, Amount: amt, CoinID: toCoinID(&txid, 0), Data: bondScript, diff --git a/client/asset/doge/doge.go b/client/asset/doge/doge.go index a59e6a7cc8..b20c244550 100644 --- a/client/asset/doge/doge.go +++ b/client/asset/doge/doge.go @@ -179,6 +179,7 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) FeeEstimator: estimateFee, ExternalFeeEstimator: fetchExternalFee, BlockDeserializer: dexdoge.DeserializeBlock, + AssetID: BipID, } return btc.BTCCloneWallet(cloneCFG) diff --git a/client/asset/ltc/ltc.go b/client/asset/ltc/ltc.go index 0259160c5c..876768c222 100644 --- a/client/asset/ltc/ltc.go +++ b/client/asset/ltc/ltc.go @@ -192,6 +192,7 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) InitTxSize: dexbtc.InitTxSizeSegwit, InitTxSizeBase: dexbtc.InitTxSizeBaseSegwit, BlockDeserializer: dexltc.DeserializeBlockBytes, + AssetID: BipID, } switch cfg.Type { diff --git a/client/asset/zec/zec.go b/client/asset/zec/zec.go index 475fa0691e..1bb3d1a98d 100644 --- a/client/asset/zec/zec.go +++ b/client/asset/zec/zec.go @@ -219,6 +219,7 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, net dex.Network) (ass // https://github.com/zcash/zcash/pull/6005 ManualMedianTime: true, OmitRPCOptionsArg: true, + AssetID: BipID, } var err error diff --git a/dex/testing/dcrdex/harness.sh b/dex/testing/dcrdex/harness.sh index 76287bb9f5..82b4fad2fa 100755 --- a/dex/testing/dcrdex/harness.sh +++ b/dex/testing/dcrdex/harness.sh @@ -201,7 +201,9 @@ if [ $LTC_ON -eq 0 ]; then "network": "simnet", "maxFeeRate": 20, "swapConf": 2, - "configPath": "${TEST_ROOT}/ltc/alpha/alpha.conf" + "configPath": "${TEST_ROOT}/ltc/alpha/alpha.conf", + "bondAmt": 10000000, + "bondConfs": 1 EOF fi @@ -213,7 +215,9 @@ if [ $BCH_ON -eq 0 ]; then "network": "simnet", "maxFeeRate": 20, "swapConf": 2, - "configPath": "${TEST_ROOT}/bch/alpha/alpha.conf" + "configPath": "${TEST_ROOT}/bch/alpha/alpha.conf", + "bondAmt": 10000000, + "bondConfs": 1 EOF fi From d6f5aacc5a852f0548f17de454e1c6952a7387ea Mon Sep 17 00:00:00 2001 From: martonp Date: Mon, 20 Mar 2023 19:04:38 -0400 Subject: [PATCH 5/8] Implement BondsFeeBuffer --- client/asset/btc/btc.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index c209a98941..73e0765e8b 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -5328,6 +5328,13 @@ func (btc *baseWallet) RefundBond(ctx context.Context, ver uint16, coinID, scrip return newOutput(txHash, 0, uint64(msgTx.TxOut[0].Value)), nil } +// BondsFeeBuffer suggests how much extra may be required for the transaction +// fees part of required bond reserves when bond rotation is enabled. +func (btc *baseWallet) BondsFeeBuffer() uint64 { + // 150% of the fee buffer portion of the reserves. + return 15 * bondsFeeBuffer(btc.segwit, btc.feeRateLimit()) / 10 +} + type utxo struct { txHash *chainhash.Hash vout uint32 From 4d5c1cc7fa25b46bcb3c4565f69d25f93c0f8071 Mon Sep 17 00:00:00 2001 From: martonp Date: Thu, 23 Mar 2023 13:02:40 -0400 Subject: [PATCH 6/8] Updates based on reviews --- client/asset/btc/btc.go | 64 +++++++++++++++++++++++++++-- client/asset/btc/electrum_client.go | 3 -- client/asset/dcr/dcr.go | 2 +- server/auth/registrar.go | 4 +- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 73e0765e8b..2c0a980cbc 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -2018,7 +2018,7 @@ func (btc *baseWallet) estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate uin if sum > avail-reserves { if trySplit { - return nil, false, 0, errors.New("eats bond reserves") + return nil, false, 0, errors.New("balance too low to both fund order and maintain bond reserves") } kept := leastOverFund(reserves, utxos) @@ -4986,7 +4986,15 @@ func (btc *baseWallet) bondSpent(amt uint64) (reserved int64, unspent uint64) { return btc.bondReservesEnforced, btc.bondReservesUsed } -// RegisterUnspent +// RegisterUnspent should be called once for every configured DEX with existing +// unspent bond amounts, prior to login, which is when reserves for future bonds +// are then added given the actual account tier, target tier, and this combined +// existing bonds amount. This must be used before ReserveBondFunds, which +// begins reserves enforcement provided a future amount that may be required +// before the existing bonds are refunded. No reserves enforcement is enabled +// until ReserveBondFunds is called, even with a future value of 0. A wallet +// that is not enforcing reserves, but which has unspent bonds should use this +// method to facilitate switching to the wallet for bonds in future. func (btc *baseWallet) RegisterUnspent(inBonds uint64) { btc.reservesMtx.Lock() defer btc.reservesMtx.Unlock() @@ -5002,7 +5010,57 @@ func (btc *baseWallet) RegisterUnspent(inBonds uint64) { } } -// ReserveBondFunds +// ReserveBondFunds increases the bond reserves to accommodate a certain nominal +// amount of future bonds, or reduces the amount if a negative value is +// provided. If indicated, updating the reserves will require sufficient +// available balance, otherwise reserves will be adjusted regardless and the +// funds are pre-reserved. This returns false if the available balance was +// insufficient iff the caller requested it be respected, otherwise it always +// returns true (success). +// +// The reserves enabled with this method are enforced when funding transactions +// (e.g. regular withdraws/sends or funding orders), and deducted from available +// balance. Amounts may be reserved beyond the available balance, but only the +// amount that is offset by the available balance is reflected in the locked +// balance category. Like funds locked in swap contracts, the caller must +// supplement balance reporting with known bond amounts. However, via +// RegisterUnspent, the wallet is made aware of pre-existing unspent bond +// amounts (cumulative) that will eventually be spent with RefundBond. +// +// If this wallet is enforcing reserves (this method has been called, even with +// a future value of zero), when new bonds are created the nominal bond amount +// is deducted from the enforced reserves; when bonds are spent with RefundBond, +// the nominal bond amount is added back into the enforced reserves. That is, +// when there are no active bonds, the locked balance category will reflect the +// entire amount requested with ReserveBondFunds (plus a fee buffer, see below), +// and when bonds are created with MakeBondTx, the locked amount decreases since +// that portion of the reserves are now held in inaccessible UTXOs, the amounts +// of which the caller tracks independently. When spent with RefundBond, that +// same *nominal* bond value is added back to the enforced reserves amount. +// +// The amounts requested for bond reserves should be the nominal amounts of the +// bonds, but the reserved amount reflected in the locked balance category will +// include a considerable buffer for transaction fees. Therefore when the full +// amount of the reserves are presently locked in unspent bonds, the locked +// balance will include this fee buffer while the wallet is enforcing reserves. +// +// Until this method is called, reserves enforcement is disabled, and any +// unspent bonds registered with RegisterUnspent do not go into the enforced +// reserves when spent. In this way, all Bonder wallets remain aware of the +// total nominal value of unspent bonds even if the wallet is not presently +// being used to maintain a target bonding amount that necessitates reserves +// enforcement. +// +// A negative value may be provided to reduce allocated reserves. When the +// amount is reduced by the same amount it was previously increased by both +// ReserveBondFunds and RegisterUnspent, reserves enforcement including fee +// padding is disabled. Consider the following example: on startup, .2 BTC of +// existing unspent bonds are registered via RegisterUnspent, then on login and +// auth with the relevant DEX host, .4 BTC of future bond reserves are requested +// with ReserveBondFunds to maintain a configured target tier given the current +// tier and amounts of the existing unspent bonds. To disable reserves, the +// client would call ReserveBondFunds with -.6 BTC, which the wallet's internal +// accounting recognizes as complete removal of the reserves. func (btc *baseWallet) ReserveBondFunds(future int64, respectBalance bool) bool { btc.reservesMtx.Lock() defer btc.reservesMtx.Unlock() diff --git a/client/asset/btc/electrum_client.go b/client/asset/btc/electrum_client.go index 5cf105ad54..b54f85dbe2 100644 --- a/client/asset/btc/electrum_client.go +++ b/client/asset/btc/electrum_client.go @@ -52,9 +52,6 @@ type electrumWalletClient interface { CheckAddress(ctx context.Context, addr string) (valid, mine bool, err error) SignTx(ctx context.Context, walletPass string, psbtB64 string) ([]byte, error) GetPrivateKeys(ctx context.Context, walletPass, addr string) (string, error) - PayTo(ctx context.Context, walletPass string, addr string, amtBTC float64, feeRate float64) ([]byte, error) - PayToFromCoinsAbsFee(ctx context.Context, walletPass string, fromCoins []string, addr string, amtBTC float64, absFee float64) ([]byte, error) - Sweep(ctx context.Context, walletPass string, addr string, feeRate float64) ([]byte, error) GetWalletTxConfs(ctx context.Context, txid string) (int, error) // shortcut if owned GetRawTransaction(ctx context.Context, txid string) ([]byte, error) // wallet method GetAddressHistory(ctx context.Context, addr string) ([]*electrum.GetAddressHistoryResult, error) diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 1f3105c407..81fab5f1be 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -1485,7 +1485,7 @@ func (dcr *ExchangeWallet) estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate if sum > avail-reserves { // no split means no change available for reserves if trySplit { // if we already tried with a split, that's the best we can do - return nil, false, 0, errors.New("eats bond reserves") + return nil, false, 0, errors.New("balance too low to both fund order and maintain bond reserves") } // Like the fund() method, try with some utxos taken out of the mix for // reserves, as precise in value as possible. diff --git a/server/auth/registrar.go b/server/auth/registrar.go index 463617b915..795fd765bf 100644 --- a/server/auth/registrar.go +++ b/server/auth/registrar.go @@ -71,7 +71,7 @@ func (auth *AuthManager) handlePreValidateBond(conn comms.Link, msg *msgjson.Mes assetID := preBond.AssetID bondAsset, ok := auth.bondAssets[assetID] if !ok { - return msgjson.NewError(msgjson.BondError, "only DCR bonds supported presently") + return msgjson.NewError(msgjson.BondError, "%s does not support bonds", dex.BipIDSymbol(assetID)) } // Create an account.Account from the provided pubkey. @@ -157,7 +157,7 @@ func (auth *AuthManager) handlePostBond(conn comms.Link, msg *msgjson.Message) * assetID := postBond.AssetID bondAsset, ok := auth.bondAssets[assetID] if !ok { - return msgjson.NewError(msgjson.BondError, "only DCR bonds supported presently") + return msgjson.NewError(msgjson.BondError, "%s does not support bonds", dex.BipIDSymbol(assetID)) } // Create an account.Account from the provided pubkey. From 458f70fe7ba41011f18f98f372068cc204e78789 Mon Sep 17 00:00:00 2001 From: martonp Date: Sat, 25 Mar 2023 12:18:44 -0400 Subject: [PATCH 7/8] Chappjc review --- client/asset/btc/btc.go | 10 +++++----- client/asset/interface.go | 11 +++++------ dex/msgjson/types.go | 2 ++ server/asset/btc/btc.go | 12 ++++++++---- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 2c0a980cbc..8b51062176 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -5304,14 +5304,14 @@ func (btc *baseWallet) makeBondRefundTxV0(txid *chainhash.Hash, vout uint32, amt msgTx.AddTxIn(txIn) // Calculate fees and add the refund output. - var outputSize int + size := btc.calcTxSize(msgTx) if btc.segwit { - outputSize = dexbtc.P2WPKHOutputSize + witnessVBytes := (dexbtc.RedeemBondSigScriptSize + 2 + 3) / 4 + size += uint64(witnessVBytes) + dexbtc.P2WPKHOutputSize } else { - outputSize = dexbtc.P2PKHOutputSize + size += dexbtc.RedeemBondSigScriptSize + dexbtc.P2PKHOutputSize } - redeemSize := msgTx.SerializeSize() + dexbtc.RedeemBondSigScriptSize + outputSize - fee := feeRate * uint64(redeemSize) + fee := feeRate * size if fee > amt { return nil, fmt.Errorf("irredeemable bond at fee rate %d atoms/byte", feeRate) } diff --git a/client/asset/interface.go b/client/asset/interface.go index ba36872101..2a1d0a28d3 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -784,12 +784,11 @@ type PeerManager interface { // the corresponding signed transaction, and a final request is made once the // bond is fully confirmed. The caller should manage the private key. type Bond struct { - Version uint16 - AssetID uint32 - Amount uint64 - UnsignedCoinID []byte - CoinID []byte - Data []byte // additional data to interpret the bond e.g. redeem script, bond contract, etc. + Version uint16 + AssetID uint32 + Amount uint64 + CoinID []byte + Data []byte // additional data to interpret the bond e.g. redeem script, bond contract, etc. // SignedTx and UnsignedTx are the opaque (raw bytes) signed and unsigned // bond creation transactions, in whatever encoding and funding scheme for // this asset and wallet. The unsigned one is used to pre-validate this bond diff --git a/dex/msgjson/types.go b/dex/msgjson/types.go index 1c38c6672f..8030c7eeb5 100644 --- a/dex/msgjson/types.go +++ b/dex/msgjson/types.go @@ -1036,6 +1036,8 @@ func (pb *PreValidateBond) Serialize() []byte { // PreValidateBondResult is the response to the client's PreValidateBond // request. type PreValidateBondResult struct { + // Signature is the result of signing the serialized PreValidateBondResult + // concatenated with the RawTx. Signature AccountID Bytes `json:"accountID"` AssetID uint32 `json:"assetID"` diff --git a/server/asset/btc/btc.go b/server/asset/btc/btc.go index 385060c9f1..844ae791bf 100644 --- a/server/asset/btc/btc.go +++ b/server/asset/btc/btc.go @@ -522,7 +522,7 @@ func (btc *Backend) VerifyUnspentCoin(_ context.Context, coinID []byte) error { // There may also be a change output. // // Returned: The bond's coin ID (i.e. encoded UTXO) of the bond output. The bond -// output's amount and PWSH/P2WSH address. The lockTime and pubkey hash data pushes +// output's amount and P2SH/P2WSH address. The lockTime and pubkey hash data pushes // from the script. The account ID from the second output is also returned. // // Properly formed transactions: @@ -535,7 +535,7 @@ func (btc *Backend) VerifyUnspentCoin(_ context.Context, coinID []byte) error { // 5. All inputs must have the max sequence num set (finalized). // 6. The transaction must pass the checks in the // blockchain.CheckTransactionSanity function. -func ParseBondTx(ver uint16, rawTx []byte, chainParams *chaincfg.Params) (bondCoinID []byte, amt int64, bondAddr string, +func ParseBondTx(ver uint16, rawTx []byte, chainParams *chaincfg.Params, segwit bool) (bondCoinID []byte, amt int64, bondAddr string, bondPubKeyHash []byte, lockTime int64, acct account.AccountID, err error) { if ver != BondVersion { err = errors.New("only version 0 bonds supported") @@ -573,6 +573,10 @@ func ParseBondTx(ver uint16, rawTx []byte, chainParams *chaincfg.Params) (bondCo err = fmt.Errorf("bad bond pkScript") return } + if !segwit && len(scriptHash) == 32 { + err = fmt.Errorf("%s backend does not support segwit bonds", chainParams.Name) + return + } acctCommitOut := msgTx.TxOut[1] acct, lock, pkh, err := dexbtc.ExtractBondCommitDataV0(0, acctCommitOut.PkScript) @@ -629,7 +633,7 @@ func (dcr *Backend) BondVer() uint16 { // time-locked fidelity bond transaction given the bond's P2SH redeem script. func (btc *Backend) ParseBondTx(ver uint16, rawTx []byte) (bondCoinID []byte, amt int64, bondAddr string, bondPubKeyHash []byte, lockTime int64, acct account.AccountID, err error) { - return ParseBondTx(ver, rawTx, btc.chainParams) + return ParseBondTx(ver, rawTx, btc.chainParams, btc.segwit) } // BondCoin locates a bond transaction output, validates the entire transaction, @@ -677,7 +681,7 @@ func (btc *Backend) BondCoin(ctx context.Context, ver uint16, coinID []byte) (am return } - _, amt, _, _, lockTime, acct, err = ParseBondTx(ver, rawTx, btc.chainParams) + _, amt, _, _, lockTime, acct, err = ParseBondTx(ver, rawTx, btc.chainParams, btc.segwit) return } From 021d9a401a3a9bded0f079d7741625242310df57 Mon Sep 17 00:00:00 2001 From: martonp Date: Wed, 29 Mar 2023 10:29:18 -0400 Subject: [PATCH 8/8] Enforce segwit vs non-segwit on server. --- server/asset/btc/btc.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/server/asset/btc/btc.go b/server/asset/btc/btc.go index 844ae791bf..c097d9ee35 100644 --- a/server/asset/btc/btc.go +++ b/server/asset/btc/btc.go @@ -573,8 +573,19 @@ func ParseBondTx(ver uint16, rawTx []byte, chainParams *chaincfg.Params, segwit err = fmt.Errorf("bad bond pkScript") return } - if !segwit && len(scriptHash) == 32 { - err = fmt.Errorf("%s backend does not support segwit bonds", chainParams.Name) + switch len(scriptHash) { + case 32: + if !segwit { + err = fmt.Errorf("%s backend does not support segwit bonds", chainParams.Name) + return + } + case 20: + if segwit { + err = fmt.Errorf("%s backend requires segwit bonds", chainParams.Name) + return + } + default: + err = fmt.Errorf("unexpected script hash length %d", len(scriptHash)) return }