diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 46d7ed48a2..254da073d3 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -1192,8 +1192,8 @@ func newUnconnectedWallet(cfg *BTCCloneCFG, walletCfg *WalletConfig) (*baseWalle useLegacyBalance: cfg.LegacyBalance, balanceFunc: cfg.BalanceFunc, segwit: cfg.Segwit, - initTxSize: uint64(initTxSize), - initTxSizeBase: uint64(initTxSizeBase), + initTxSize: initTxSize, + initTxSizeBase: initTxSizeBase, signNonSegwit: nonSegwitSigner, localFeeRate: cfg.FeeEstimator, externalFeeRate: cfg.ExternalFeeEstimator, @@ -4794,8 +4794,8 @@ func (btc *baseWallet) MakeBondTx(ver uint16, amt, feeRate uint64, lockTime time 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) + if btc.IsDust(txOut, feeRate) { + return nil, nil, fmt.Errorf("bond output value of %d (fee rate %d) is dust", amt, feeRate) } baseTx.AddTxOut(txOut) @@ -4951,7 +4951,7 @@ func (btc *baseWallet) makeBondRefundTxV0(txid *chainhash.Hash, vout uint32, amt } redeemTxOut := wire.NewTxOut(int64(amt-fee), redeemPkScript) if btc.IsDust(redeemTxOut, feeRate) { // hard to imagine - return nil, fmt.Errorf("bond redeem output is dust") + return nil, fmt.Errorf("bond redeem output (amt = %d, feeRate = %d, outputSize = %d) is dust", amt, feeRate, redeemTxOut.SerializeSize()) } msgTx.AddTxOut(redeemTxOut) diff --git a/client/asset/zec/zec.go b/client/asset/zec/zec.go index f9ef1e6c26..a74e102cd5 100644 --- a/client/asset/zec/zec.go +++ b/client/asset/zec/zec.go @@ -711,14 +711,15 @@ func (w *zecWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes, uint6 spents = []*btc.Output{op} } else if useSplit { // No shielded split needed. Should we do a split to avoid overlock. - baggage := dexzec.TxFeesZIP317(inputsSize, 2*dexbtc.P2PKHOutputSize+1, 0, 0, 0, 0) - excess := sum - dexzec.RequiredOrderFunds(ord.Value, 1, dexbtc.RedeemP2PKHInputSize, ord.MaxSwapCount) - if baggage >= excess { + splitTxFees := dexzec.TxFeesZIP317(inputsSize, 2*dexbtc.P2PKHOutputSize+1, 0, 0, 0, 0) + requiredForOrderWithoutSplit := dexzec.RequiredOrderFunds(ord.Value, uint64(len(coins)), inputsSize, ord.MaxSwapCount) + excessWithoutSplit := sum - requiredForOrderWithoutSplit + if splitTxFees >= excessWithoutSplit { w.log.Debugf("Skipping split transaction because cost is greater than potential over-lock. "+ - "%s > %s", btcutil.Amount(baggage), btcutil.Amount(excess)) + "%s > %s", btcutil.Amount(splitTxFees), btcutil.Amount(excessWithoutSplit)) } else { splitOutputVal := dexzec.RequiredOrderFunds(ord.Value, 1, dexbtc.RedeemP2PKHInputSize, ord.MaxSwapCount) - transparentSplitFees = baggage + transparentSplitFees = splitTxFees baseTx, _, _, err := w.fundedTx(spents) if err != nil { return nil, nil, 0, fmt.Errorf("fundedTx error: %w", err) @@ -782,7 +783,6 @@ func (w *zecWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes, uint6 if err != nil { return nil, nil, 0, newError(errLockUnspent, "LockUnspent error: %w", err) } - return coins, redeemScripts, shieldedSplitFees + transparentSplitFees, nil } diff --git a/client/webserver/site/src/js/markets.ts b/client/webserver/site/src/js/markets.ts index 35d5264fd5..3ac622aae4 100644 --- a/client/webserver/site/src/js/markets.ts +++ b/client/webserver/site/src/js/markets.ts @@ -3291,7 +3291,6 @@ class BalanceWidget { side.parentBal = balTmpl.bal } } - addRow(intl.prep(intl.ID_AVAILABLE), bal.available, asset.unitInfo) addRow(intl.prep(intl.ID_LOCKED), bal.locked + bal.contractlocked + bal.bondlocked, asset.unitInfo) addRow(intl.prep(intl.ID_IMMATURE), bal.immature, asset.unitInfo) diff --git a/dex/networks/btc/script.go b/dex/networks/btc/script.go index 317c983bbb..f9cb08a6ff 100644 --- a/dex/networks/btc/script.go +++ b/dex/networks/btc/script.go @@ -413,12 +413,12 @@ func IsDust(txOut *wire.TxOut, minRelayTxFee uint64) bool { } // IsDustVal is like IsDust but only takes the txSize, amount and if segwit. -func IsDustVal(txSize, value, minRelayTxFee uint64, segwit bool) bool { - totalSize := txSize + 41 +func IsDustVal(txOutSize, value, minRelayTxFee uint64, segwit bool) bool { + totalSize := txOutSize + 41 if segwit { // This function is taken from btcd, but noting here that we are not // rounding up and probably should be. - totalSize += (107 / witnessWeight) + totalSize += (107 / witnessWeight) // + 26 } else { totalSize += 107 } diff --git a/dex/networks/dcr/script.go b/dex/networks/dcr/script.go index a83069ad71..27360ad2b6 100644 --- a/dex/networks/dcr/script.go +++ b/dex/networks/dcr/script.go @@ -631,8 +631,8 @@ func IsDust(txOut *wire.TxOut, minRelayTxFee uint64) bool { // IsDustVal is like IsDust but it only needs the size of the serialized output // and its amount. -func IsDustVal(sz, amt, minRelayTxFee uint64) bool { - totalSize := sz + 165 +func IsDustVal(txOutSize, amt, minRelayTxFee uint64) bool { + totalSize := txOutSize + 165 return amt/(3*totalSize) < minRelayTxFee } diff --git a/dex/testing/dcrdex/harness.sh b/dex/testing/dcrdex/harness.sh index 19b9105320..c29f07a300 100755 --- a/dex/testing/dcrdex/harness.sh +++ b/dex/testing/dcrdex/harness.sh @@ -301,6 +301,11 @@ if [[ -n ${NODERELAY} ]]; then DCR_CONFIG_PATH="${RELAY_CONF_PATH}" fi +# For BTC, the min bond size that avoids the dust filter is fee_rate * 330 +# To avoid the refund tx output being dust, add fee_rate * 118 +# So the total min bond size is = fee_rate * 448 +# Using maxFeeRate of 100, this means min bond size of 44800. + cat << EOF >> "./markets.json" } ], @@ -311,9 +316,6 @@ cat << EOF >> "./markets.json" "maxFeeRate": 10, "swapConf": 1, "configPath": "${DCR_CONFIG_PATH}", - "regConfs": 1, - "regFee": 100000000, - "regXPub": "spubVWKGn9TGzyo7M4b5xubB5UV4joZ5HBMNBmMyGvYEaoZMkSxVG4opckpmQ26E85iHg8KQxrSVTdex56biddqtXBerG9xMN8Dvb3eNQVFFwpE", "bondAmt": 50000000, "bondConfs": 1, "nodeRelayID": "${DCR_NODERELAY_ID}" @@ -324,10 +326,7 @@ cat << EOF >> "./markets.json" "maxFeeRate": 100, "swapConf": 1, "configPath": "${BTC_CONFIG_PATH}", - "regConfs": 2, - "regFee": 20000000, - "regXPub": "vpub5SLqN2bLY4WeZJ9SmNJHsyzqVKreTXD4ZnPC22MugDNcjhKX5xNX9QiQWcE4SSRzVWyHWUihpKRT7hckDGNzVc69wSX2JPcfGeNiT5c2XZy", - "bondAmt": 10000, + "bondAmt": 50000, "bondConfs": 1, "nodeRelayID": "${BTC_NODERELAY_ID}" EOF diff --git a/server/asset/btc/btc.go b/server/asset/btc/btc.go index 52e731a3db..1cc862eea4 100644 --- a/server/asset/btc/btc.go +++ b/server/asset/btc/btc.go @@ -17,6 +17,7 @@ import ( "time" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/calc" "decred.org/dcrdex/dex/config" dexbtc "decred.org/dcrdex/dex/networks/btc" "decred.org/dcrdex/server/account" @@ -514,6 +515,11 @@ func (btc *Backend) FundingCoin(_ context.Context, coinID []byte, redeemScript [ return utxo, nil } +func (*Backend) ValidateOrderFunding(swapVal, valSum, _, inputsSize, maxSwaps uint64, nfo *dex.Asset) bool { + reqVal := calc.RequiredOrderFunds(swapVal, inputsSize, maxSwaps, nfo) + return valSum >= reqVal +} + // ValidateCoinID attempts to decode the coinID. func (btc *Backend) ValidateCoinID(coinID []byte) (string, error) { txid, vout, err := decodeCoinID(coinID) diff --git a/server/asset/common.go b/server/asset/common.go index 234d2c9dae..c86fa9c3c2 100644 --- a/server/asset/common.go +++ b/server/asset/common.go @@ -100,6 +100,8 @@ type OutputTracker interface { // with non-standard pkScripts or scripts that require zero signatures to // redeem must return an error. FundingCoin(ctx context.Context, coinID []byte, redeemScript []byte) (FundingCoin, error) + // ValidateOrderFunding validates that the supplied utxos are enough to fund an order. + ValidateOrderFunding(swapVal, valSum, inputCount, inputsSize, maxSwaps uint64, nfo *dex.Asset) bool } // AccountBalancer is implemented by backends for account-based blockchains. diff --git a/server/asset/dcr/dcr.go b/server/asset/dcr/dcr.go index b0ddfbd9d9..b9dce320cd 100644 --- a/server/asset/dcr/dcr.go +++ b/server/asset/dcr/dcr.go @@ -18,6 +18,7 @@ import ( "time" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/calc" dexdcr "decred.org/dcrdex/dex/networks/dcr" "decred.org/dcrdex/server/account" "decred.org/dcrdex/server/asset" @@ -575,6 +576,11 @@ func ValidateXPub(xpub string) error { return nil } +func (*Backend) ValidateOrderFunding(swapVal, valSum, _, inputsSize, maxSwaps uint64, nfo *dex.Asset) bool { + reqVal := calc.RequiredOrderFunds(swapVal, inputsSize, maxSwaps, nfo) + return valSum >= reqVal +} + // ValidateCoinID attempts to decode the coinID. func (dcr *Backend) ValidateCoinID(coinID []byte) (string, error) { txid, vout, err := decodeCoinID(coinID) diff --git a/server/asset/zec/zec.go b/server/asset/zec/zec.go index 96f114287d..bf48d3822b 100644 --- a/server/asset/zec/zec.go +++ b/server/asset/zec/zec.go @@ -169,6 +169,11 @@ func (be *ZECBackend) FeeRate(context.Context) (uint64, error) { return dexzec.LegacyFeeRate, nil } +func (*ZECBackend) ValidateOrderFunding(swapVal, valSum, inputCount, inputsSize, maxSwaps uint64, _ *dex.Asset) bool { + reqVal := dexzec.RequiredOrderFunds(swapVal, inputCount, inputsSize, maxSwaps) + return valSum >= reqVal +} + func (be *ZECBackend) ValidateFeeRate(ci asset.Coin, reqFeeRate uint64) bool { c, is := ci.(interface { Fees() uint64 diff --git a/server/market/orderrouter.go b/server/market/orderrouter.go index 5f3c049114..2882a9f8b7 100644 --- a/server/market/orderrouter.go +++ b/server/market/orderrouter.go @@ -473,6 +473,11 @@ func (r *OrderRouter) processTrade(oRecord *orderRecord, tunnel MarketTunnel, as // Funding coins are from a utxo-based asset. Need to find them. + funder, is := assets.funding.Backend.(asset.OutputTracker) + if !is { + return msgjson.NewError(msgjson.RPCInternal, "internal error") + } + // Validate coin IDs and prepare some strings for debug logging. coinStrs := make([]string, 0, len(coins)) for _, coinID := range trade.Coins { @@ -563,13 +568,12 @@ func (r *OrderRouter) processTrade(oRecord *orderRecord, tunnel MarketTunnel, as } // Calculate the fees and check that the utxo sum is enough. - var reqVal uint64 + var swapVal uint64 if sell { - reqVal = calc.RequiredOrderFunds(trade.Quantity, uint64(spendSize), lots, &fundingAsset.Asset) + swapVal = trade.Quantity } else { if rate > 0 { // limit buy - quoteQty := calc.BaseToQuote(rate, trade.Quantity) - reqVal = calc.RequiredOrderFunds(quoteQty, uint64(spendSize), lots, &assets.quote.Asset) + swapVal = calc.BaseToQuote(rate, trade.Quantity) } else { // This is a market buy order, so the quantity gets special handling. // 1. The quantity is in units of the quote asset. @@ -580,18 +584,16 @@ func (r *OrderRouter) processTrade(oRecord *orderRecord, tunnel MarketTunnel, as } buyBuffer := tunnel.MarketBuyBuffer() lotWithBuffer := uint64(float64(lotSize) * buyBuffer) - minReq := matcher.BaseToQuote(midGap, lotWithBuffer) - if trade.Quantity < minReq { - errStr := fmt.Sprintf("order quantity does not satisfy market buy buffer. %d < %d. midGap = %d", trade.Quantity, minReq, midGap) + swapVal = matcher.BaseToQuote(midGap, lotWithBuffer) + if trade.Quantity < swapVal { + errStr := fmt.Sprintf("order quantity does not satisfy market buy buffer. %d < %d. midGap = %d", trade.Quantity, swapVal, midGap) return false, msgjson.NewError(msgjson.FundingError, errStr) } - reqVal = calc.RequiredOrderFunds(minReq, uint64(spendSize), 1, &assets.quote.Asset) } - } - if valSum < reqVal { - return false, msgjson.NewError(msgjson.FundingError, - fmt.Sprintf("not enough funds. need at least %d, got %d", reqVal, valSum)) + + if !funder.ValidateOrderFunding(swapVal, valSum, uint64(len(trade.Coins)), uint64(spendSize), lots, &assets.funding.Asset) { + return false, msgjson.NewError(msgjson.FundingError, "failed funding validation") } return false, nil diff --git a/server/market/routers_test.go b/server/market/routers_test.go index 605d1d7607..d4b80816d3 100644 --- a/server/market/routers_test.go +++ b/server/market/routers_test.go @@ -395,6 +395,7 @@ type TBackend struct { syncedErr error confsMinus2 int64 invalidFeeRate bool + unfunded bool } func tNewUTXOBackend() *tUTXOBackend { @@ -488,6 +489,10 @@ func (b *tUTXOBackend) VerifyUnspentCoin(_ context.Context, coinID []byte) error return err } +func (b *tUTXOBackend) ValidateOrderFunding(swapVal, valSum, inputCount, inputsSize, maxSwaps uint64, nfo *dex.Asset) bool { + return !b.unfunded +} + type tAccountBackend struct { *TBackend bal uint64 @@ -1434,7 +1439,9 @@ func testPrefixTrade(prefix *msgjson.Prefix, trade *msgjson.Trade, fundingAsset, // Not enough funding trade.Coins = ogUTXOs[:1] + fundingAsset.unfunded = true checkCode("unfunded", msgjson.FundingError) + fundingAsset.unfunded = false trade.Coins = ogUTXOs // Invalid address