From 79e87a0f21df9e1f8f32b3fc88c6725e1d591dc7 Mon Sep 17 00:00:00 2001 From: buck54321 Date: Sat, 23 Jan 2021 04:25:07 -0600 Subject: [PATCH 1/5] order fee estimation An order fee estimate consists of 3 values for the swap and 2 for the redemption. The swap has a max possible fees (MaxFeeRate), and both high and low estimates corresponding to the best and worst case settlement sequences, but at the prevailing network fee rate. Similarly, the redemption estimate has high and low estimates evaluated for the worst and best settlemetn sequences (1 tx per lot vs 1 tx for all). --- client/asset/btc/btc.go | 198 +++++++++++++++------- client/asset/btc/btc_test.go | 215 ++++++++++++++++++++---- client/asset/btc/livetest/livetest.go | 4 +- client/asset/dcr/dcr.go | 165 +++++++++++++----- client/asset/dcr/dcr_test.go | 161 +++++++++++++++--- client/asset/dcr/simnet_test.go | 4 +- client/asset/interface.go | 96 +++++++++-- client/core/core.go | 211 ++++++++++++++++++----- client/core/core_test.go | 180 +++++++++++++++++++- client/core/trade.go | 4 +- client/core/types.go | 21 ++- client/orderbook/bookside.go | 175 ++++++++----------- client/orderbook/bookside_test.go | 176 ++++++++++--------- client/orderbook/orderbook.go | 15 ++ client/orderbook/orderbook_test.go | 103 ++++-------- client/webserver/api.go | 8 +- client/webserver/live_test.go | 49 ++++-- client/webserver/site/src/js/markets.js | 20 ++- client/webserver/webserver.go | 4 +- client/webserver/webserver_test.go | 7 +- go.sum | 8 + 21 files changed, 1304 insertions(+), 520 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index a7f7f97407..b426b9c8e2 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -696,72 +696,149 @@ func (btc *ExchangeWallet) feeRateWithFallback(confTarget uint64) uint64 { // estimate based on current network conditions, and will be <= the fees // associated with nfo.MaxFeeRate. For quote assets, the caller will have to // calculate lotSize based on a rate conversion from the base asset's lot size. -func (btc *ExchangeWallet) MaxOrder(lotSize uint64, nfo *dex.Asset) (*asset.OrderEstimate, error) { +func (btc *ExchangeWallet) MaxOrder(lotSize uint64, nfo *dex.Asset) (*asset.SwapEstimate, error) { + _, _, _, maxEst, err := btc.maxOrder(lotSize, nfo) + return maxEst, err +} + +// maxOrder gets the estimate for MaxOrder, and also returns the +// []*compositeUTXO to be used for further order estimation without additional +// calls to listunspent. +func (btc *ExchangeWallet) maxOrder(lotSize uint64, nfo *dex.Asset) (utxos []*compositeUTXO, lots, feeRate uint64, est *asset.SwapEstimate, err error) { networkFeeRate, err := btc.feeRate(1) if err != nil { - return nil, fmt.Errorf("error getting network fee estimate: %w", err) + return nil, 0, 0, nil, fmt.Errorf("error getting network fee estimate: %w", err) } btc.fundingMtx.RLock() utxos, _, avail, err := btc.spendableUTXOs(0) btc.fundingMtx.RUnlock() if err != nil { - return nil, fmt.Errorf("error parsing unspent outputs: %w", err) + return nil, 0, networkFeeRate, nil, fmt.Errorf("error parsing unspent outputs: %w", err) } // Start by attempting max lots with no fees. - lots := avail / lotSize + lots = avail / lotSize for lots > 0 { - val := lots * lotSize - sum, size, _, _, _, _, err := btc.fund(val, lots, utxos, nfo) - // The only failure mode of 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. + est, _, err := btc.estimateSwap(lots, lotSize, networkFeeRate, utxos, nfo, btc.useSplitTx) + // 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. if err != nil { lots-- continue } - reqFunds := calc.RequiredOrderFunds(val, uint64(size), lots, nfo) - maxFees := reqFunds - val - estFunds := calc.RequiredOrderFundsAlt(val, uint64(size), lots, nfo.SwapSizeBase, nfo.SwapSize, networkFeeRate) - estFees := estFunds - val - // Math for split transactions is a little different. - if btc.useSplitTx { - _, extraFees := btc.splitBaggageFees(nfo.MaxFeeRate) - _, extraEstFees := btc.splitBaggageFees(networkFeeRate) - if avail >= reqFunds+extraFees { - return &asset.OrderEstimate{ - Lots: lots, - Value: val, - MaxFees: maxFees + extraFees, - EstimatedFees: estFees + extraEstFees, - Locked: val + maxFees + extraFees, - }, nil - } - } + return utxos, lots, networkFeeRate, est, nil + } + return utxos, 0, networkFeeRate, &asset.SwapEstimate{}, nil +} + +// PreSwap get order estimates based on the available funds and the wallet +// configuration. +func (btc *ExchangeWallet) PreSwap(req *asset.PreSwapForm) (*asset.PreSwap, error) { + // Start with the maxOrder at the default configuration. + utxos, maxLots, feeRate, _, err := btc.maxOrder(req.LotSize, req.AssetConfig) + if err != nil { + return nil, err + } + if maxLots < req.Lots { + return nil, fmt.Errorf("%d lots available for %d-lot order", maxLots, req.Lots) + } - // No split transaction. - return &asset.OrderEstimate{ - Lots: lots, - Value: val, - MaxFees: maxFees, - EstimatedFees: estFees, - Locked: sum, - }, nil + // Get the estimate at using the current configuration + est, _, err := btc.estimateSwap(req.Lots, req.LotSize, feeRate, utxos, req.AssetConfig, btc.useSplitTx) + if err != nil { + return nil, fmt.Errorf("estimation failed: %v", err) } - return &asset.OrderEstimate{}, nil + + return &asset.PreSwap{ + Estimate: est, + }, nil } -// RedemptionFees is an estimate of the redemption fees for a 1-swap redemption. -func (btc *ExchangeWallet) RedemptionFees() (uint64, error) { +// estimateSwap prepares an *asset.SwapEstimate. +func (btc *ExchangeWallet) estimateSwap(lots, lotSize, networkFeeRate uint64, utxos []*compositeUTXO, + nfo *dex.Asset, trySplit bool) (*asset.SwapEstimate, bool /*split used*/, error) { + + var avail uint64 + for _, utxo := range utxos { + avail += utxo.amount + } + + val := lots * lotSize + + sum, inputsSize, _, _, _, _, err := btc.fund(val, lots, utxos, nfo) + if err != nil { + return nil, false, err + } + + reqFunds := calc.RequiredOrderFundsAlt(val, uint64(inputsSize), lots, nfo.SwapSizeBase, nfo.SwapSize, nfo.MaxFeeRate) + maxFees := reqFunds - val + + estHighFunds := calc.RequiredOrderFundsAlt(val, uint64(inputsSize), lots, nfo.SwapSizeBase, nfo.SwapSize, networkFeeRate) + estHighFees := estHighFunds - val + + estLowFunds := calc.RequiredOrderFundsAlt(val, uint64(inputsSize), 1, nfo.SwapSizeBase, nfo.SwapSize, networkFeeRate) + if btc.segwit { + estLowFunds += dexbtc.P2WSHOutputSize * (lots - 1) * networkFeeRate + } else { + estLowFunds += dexbtc.P2SHOutputSize * (lots - 1) * networkFeeRate + } + + estLowFees := estLowFunds - val + + // Math for split transactions is a little different. + if trySplit { + _, extraMaxFees := btc.splitBaggageFees(nfo.MaxFeeRate) + _, splitFees := btc.splitBaggageFees(networkFeeRate) + + if avail >= reqFunds+extraMaxFees { + return &asset.SwapEstimate{ + Lots: lots, + Value: val, + MaxFees: maxFees + extraMaxFees, + RealisticBestCase: estLowFees + splitFees, + RealisticWorstCase: estHighFees + splitFees, + Locked: val + maxFees + extraMaxFees, + }, true, nil + } + } + + return &asset.SwapEstimate{ + Lots: lots, + Value: val, + MaxFees: maxFees, + RealisticBestCase: estLowFees, + RealisticWorstCase: estHighFees, + Locked: sum, + }, false, nil +} + +// PreRedeem generates an estimate of the range of redemption fees that could +// be assessed. +func (btc *ExchangeWallet) PreRedeem(req *asset.PreRedeemForm) (*asset.PreRedeem, error) { feeRate := btc.feeRateWithFallback(btc.redeemConfTarget) - var size uint64 = dexbtc.MinimumTxOverhead + dexbtc.TxInOverhead + dexbtc.TxOutOverhead + // Best is one transaction with req.Lots inputs and 1 output. + var best uint64 = dexbtc.MinimumTxOverhead + dexbtc.TxInOverhead + dexbtc.TxOutOverhead + // Worst is req.Lots transactions, each with one input and one output. + var worst uint64 = best * req.Lots + var inputSize, outputSize uint64 if btc.segwit { // Add the marker and flag weight here. - var witnessVBytes uint64 = (dexbtc.RedeemSwapSigScriptSize + 2 + 3) / 4 - size += witnessVBytes + dexbtc.P2WPKHOutputSize + inputSize = (dexbtc.RedeemSwapSigScriptSize + 2 + 3) / 4 + outputSize = dexbtc.P2WPKHOutputSize + } else { - size += dexbtc.RedeemSwapSigScriptSize + dexbtc.P2PKHOutputSize + inputSize = dexbtc.RedeemSwapSigScriptSize + outputSize = dexbtc.P2PKHOutputSize } - return size * feeRate, nil + best += inputSize*req.Lots + outputSize + worst += (inputSize + outputSize) * req.Lots + + return &asset.PreRedeem{ + Estimate: &asset.RedeemEstimate{ + RealisticWorstCase: worst * feeRate, + RealisticBestCase: best * feeRate, + }, + }, nil } // FundOrder selects coins for use in an order. The coins will be locked, and @@ -840,6 +917,7 @@ func (btc *ExchangeWallet) fund(val, lots uint64, utxos []*compositeUTXO, nfo *d isEnoughWith := func(unspent *compositeUTXO) bool { reqFunds := calc.RequiredOrderFunds(val, uint64(size+unspent.input.VBytes()), lots, nfo) + // reqFunds := calc.RequiredOrderFunds(val, uint64(size+unspent.input.VBytes()), lots, nfo) return sum+unspent.amount >= reqFunds } @@ -916,7 +994,9 @@ func (btc *ExchangeWallet) fund(val, lots uint64, utxos []*compositeUTXO, nfo *d // order is canceled partially filled, and then the remainder resubmitted. We // would already have an output of just the right size, and that would be // recognized here. -func (btc *ExchangeWallet) split(value uint64, lots uint64, outputs []*output, inputsSize uint64, fundingCoins map[outPoint]*utxo, nfo *dex.Asset) (asset.Coins, bool, error) { +func (btc *ExchangeWallet) split(value uint64, lots uint64, outputs []*output, + inputsSize uint64, fundingCoins map[outPoint]*utxo, nfo *dex.Asset) (asset.Coins, bool, error) { + var err error defer func() { if err != nil { @@ -944,7 +1024,7 @@ func (btc *ExchangeWallet) split(value uint64, lots uint64, outputs []*output, i valueStr := amount(value).String() - excess := coinSum - calc.RequiredOrderFunds(value, inputsSize, lots, nfo) + excess := coinSum - calc.RequiredOrderFundsAlt(value, inputsSize, lots, nfo.SwapSizeBase, nfo.SwapSize, nfo.MaxFeeRate) if baggage > excess { btc.log.Debugf("Skipping split transaction because cost is greater than potential over-lock. "+ "%s > %s", amount(baggage), amount(excess)) @@ -959,7 +1039,7 @@ func (btc *ExchangeWallet) split(value uint64, lots uint64, outputs []*output, i return nil, false, fmt.Errorf("error creating split transaction address: %w", err) } - reqFunds := calc.RequiredOrderFunds(value, swapInputSize, lots, nfo) + reqFunds := calc.RequiredOrderFundsAlt(value, swapInputSize, lots, nfo.SwapSizeBase, nfo.SwapSize, nfo.MaxFeeRate) baseTx, _, _, err := btc.fundedTx(coins) splitScript, err := txscript.PayToAddrScript(addr) @@ -976,18 +1056,18 @@ func (btc *ExchangeWallet) split(value uint64, lots uint64, outputs []*output, i // This must fund swaps, so don't under-pay. TODO: get and use a fee rate // from server, and have server check fee rate on unconf funding coins. - feeRate, err := btc.feeRate(1) + estFeeRate, err := btc.feeRate(1) if err != nil { // Fallback fee rate is NO GOOD here. return nil, false, fmt.Errorf("unable to get optimal fee rate for pre-split transaction "+ "(disable the pre-size option or wait until your wallet is ready): %w", err) } - if feeRate > nfo.MaxFeeRate { - feeRate = nfo.MaxFeeRate + if estFeeRate > nfo.MaxFeeRate { + estFeeRate = nfo.MaxFeeRate } // Sign, add change, and send the transaction. - msgTx, _, _, err := btc.sendWithReturn(baseTx, changeAddr, coinSum, reqFunds, feeRate) + msgTx, _, _, err := btc.sendWithReturn(baseTx, changeAddr, coinSum, reqFunds, estFeeRate) if err != nil { return nil, false, err } @@ -1196,6 +1276,7 @@ func (btc *ExchangeWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin if err != nil { return nil, nil, 0, err } + // Add the contract outputs. // TODO: Make P2WSH contract and P2WPKH change outputs instead of // legacy/non-segwit swap contracts pkScripts. @@ -1299,14 +1380,14 @@ func (btc *ExchangeWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin } // Redeem sends the redemption transaction, completing the atomic swap. -func (btc *ExchangeWallet) Redeem(redemptions []*asset.Redemption) ([]dex.Bytes, asset.Coin, uint64, error) { +func (btc *ExchangeWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Coin, uint64, error) { // Create a transaction that spends the referenced contract. msgTx := wire.NewMsgTx(wire.TxVersion) var totalIn uint64 var contracts [][]byte var addresses []btcutil.Address var values []uint64 - for _, r := range redemptions { + for _, r := range form.Redemptions { cinfo, ok := r.Spends.(*auditInfo) if !ok { return nil, nil, 0, fmt.Errorf("Redemption contract info of wrong type") @@ -1337,10 +1418,10 @@ func (btc *ExchangeWallet) Redeem(redemptions []*asset.Redemption) ([]dex.Bytes, size := dexbtc.MsgTxVBytes(msgTx) if btc.segwit { // Add the marker and flag weight here. - witnessVBytes := (dexbtc.RedeemSwapSigScriptSize*uint64(len(redemptions)) + 2 + 3) / 4 + witnessVBytes := (dexbtc.RedeemSwapSigScriptSize*uint64(len(form.Redemptions)) + 2 + 3) / 4 size += witnessVBytes + dexbtc.P2WPKHOutputSize } else { - size += dexbtc.RedeemSwapSigScriptSize*uint64(len(redemptions)) + dexbtc.P2PKHOutputSize + size += dexbtc.RedeemSwapSigScriptSize*uint64(len(form.Redemptions)) + dexbtc.P2PKHOutputSize } feeRate := btc.feeRateWithFallback(btc.redeemConfTarget) @@ -1348,6 +1429,7 @@ func (btc *ExchangeWallet) Redeem(redemptions []*asset.Redemption) ([]dex.Bytes, if fee > totalIn { return nil, nil, 0, fmt.Errorf("redeem tx not worth the fees") } + // Send the funds back to the exchange wallet. redeemAddr, err := btc.node.ChangeAddress() if err != nil { @@ -1366,7 +1448,7 @@ func (btc *ExchangeWallet) Redeem(redemptions []*asset.Redemption) ([]dex.Bytes, if btc.segwit { sigHashes := txscript.NewTxSigHashes(msgTx) - for i, r := range redemptions { + for i, r := range form.Redemptions { contract := contracts[i] redeemSig, redeemPubKey, err := btc.createWitnessSig(msgTx, i, contract, addresses[i], values[i], sigHashes) if err != nil { @@ -1375,7 +1457,7 @@ func (btc *ExchangeWallet) Redeem(redemptions []*asset.Redemption) ([]dex.Bytes, msgTx.TxIn[i].Witness = dexbtc.RedeemP2WSHContract(contract, redeemSig, redeemPubKey, r.Secret) } } else { - for i, r := range redemptions { + for i, r := range form.Redemptions { contract := contracts[i] redeemSig, redeemPubKey, err := btc.createSig(msgTx, i, contract, addresses[i]) if err != nil { @@ -1399,8 +1481,8 @@ func (btc *ExchangeWallet) Redeem(redemptions []*asset.Redemption) ([]dex.Bytes, "expected %s, got %s", *txHash, checkHash) } // Log the change output. - coinIDs := make([]dex.Bytes, 0, len(redemptions)) - for i := range redemptions { + coinIDs := make([]dex.Bytes, 0, len(form.Redemptions)) + for i := range form.Redemptions { coinIDs = append(coinIDs, toCoinID(txHash, uint32(i))) } return coinIDs, newOutput(txHash, 0, uint64(txOut.Value)), fee, nil diff --git a/client/asset/btc/btc_test.go b/client/asset/btc/btc_test.go index 335470ad0b..4077ea8ec2 100644 --- a/client/asset/btc/btc_test.go +++ b/client/asset/btc/btc_test.go @@ -117,6 +117,7 @@ type tRPCClient struct { mpVerboseTxs map[string]*btcjson.TxRawResult rawVerboseErr error lockedCoins []*RPCOutpoint + estFeeErr error } func newTRPCClient() *tRPCClient { @@ -175,6 +176,9 @@ func (c *tRPCClient) RawRequest(_ context.Context, method string, params []json. // TODO: handle methodGetBlockHash, methodGetRawMempool and add actual tests // to cover them. case methodEstimateSmartFee: + if c.estFeeErr != nil { + return nil, c.estFeeErr + } optimalRate := float64(optimalFeeRate) * 1e-5 // ~0.00024 return json.Marshal(&btcjson.EstimateSmartFeeResult{ Blocks: 2, @@ -591,7 +595,7 @@ func TestAvailableFund(t *testing.T) { } node.rawRes[methodLockUnspent] = []byte(`true`) - // Fund a little bit, with unsafe littleOrder. + // Fund a little bit, with unsafe littleUTXO. littleUTXO.Safe = false littleUTXO.Confirmations = 0 node.rawRes[methodListUnspent] = mustMarshal(t, unspents) @@ -607,8 +611,9 @@ func TestAvailableFund(t *testing.T) { t.Fatalf("expected spendable of value %d, got %d", lottaFunds, v) } - // Now with safe unconfirmed littleOrder. + // Now with safe confirmed littleUTXO. littleUTXO.Safe = true + littleUTXO.Confirmations = 2 node.rawRes[methodListUnspent] = mustMarshal(t, unspents) spendables, _, err = wallet.FundOrder(ord) if err != nil { @@ -618,9 +623,10 @@ func TestAvailableFund(t *testing.T) { t.Fatalf("expected 1 spendable, got %d", len(spendables)) } v = spendables[0].Value() - if v != lottaFunds { // picks the bigger output because it is confirmed + if v != littleFunds { t.Fatalf("expected spendable of value %d, got %d", littleFunds, v) } + // Return/unlock the reserved coins to avoid warning in subsequent tests // about fundingCoins map containing the coins already. i.e. // "Known order-funding coin %v returned by listunspent" @@ -883,26 +889,34 @@ func TestFundingCoins(t *testing.T) { ensureGood() } -func checkMaxOrder(t *testing.T, wallet *ExchangeWallet, lots, swapVal, maxFees, estFees, locked uint64) { +func checkMaxOrder(t *testing.T, wallet *ExchangeWallet, lots, swapVal, maxFees, estWorstCase, estBestCase, locked uint64) { t.Helper() maxOrder, err := wallet.MaxOrder(tBTC.LotSize, tBTC) if err != nil { t.Fatalf("MaxOrder error: %v", err) } - if maxOrder.Lots != lots { - t.Fatalf("MaxOrder has wrong Lots. wanted %d, got %d", lots, maxOrder.Lots) + checkSwapEstimate(t, maxOrder, lots, swapVal, maxFees, estWorstCase, estBestCase, locked) +} + +func checkSwapEstimate(t *testing.T, est *asset.SwapEstimate, lots, swapVal, maxFees, estWorstCase, estBestCase, locked uint64) { + t.Helper() + if est.Lots != lots { + t.Fatalf("Estimate has wrong Lots. wanted %d, got %d", lots, est.Lots) + } + if est.Value != swapVal { + t.Fatalf("Estimate has wrong Value. wanted %d, got %d", swapVal, est.Value) } - if maxOrder.Value != swapVal { - t.Fatalf("MaxOrder has wrong Value. wanted %d, got %d", swapVal, maxOrder.Value) + if est.MaxFees != maxFees { + t.Fatalf("Estimate has wrong MaxFees. wanted %d, got %d", maxFees, est.MaxFees) } - if maxOrder.MaxFees != maxFees { - t.Fatalf("MaxOrder has wrong MaxFees. wanted %d, got %d", maxFees, maxOrder.MaxFees) + if est.RealisticWorstCase != estWorstCase { + t.Fatalf("Estimate has wrong RealisticWorstCase. wanted %d, got %d", estWorstCase, est.RealisticWorstCase) } - if maxOrder.EstimatedFees != estFees { - t.Fatalf("MaxOrder has wrong EstimatedFees. wanted %d, got %d", estFees, maxOrder.EstimatedFees) + if est.RealisticBestCase != estBestCase { + t.Fatalf("Estimate has wrong RealisticBestCase. wanted %d, got %d", estBestCase, est.RealisticBestCase) } - if maxOrder.Locked != locked { - t.Fatalf("MaxOrder has wrong Locked. wanted %d, got %d", locked, maxOrder.Locked) + if est.Locked != locked { + t.Fatalf("Estimate has wrong Locked. wanted %d, got %d", locked, est.Locked) } } @@ -917,8 +931,9 @@ func TestFundEdges(t *testing.T) { node.rawRes[methodLockUnspent] = []byte(`true`) var estFeeRate = optimalFeeRate + 1 // +1 added in feeRate - checkMax := func(lots, swapVal, maxFees, estFees, locked uint64) { - checkMaxOrder(t, wallet, lots, swapVal, maxFees, estFees, locked) + checkMax := func(lots, swapVal, maxFees, estWorstCase, estBestCase, locked uint64) { + t.Helper() + checkMaxOrder(t, wallet, lots, swapVal, maxFees, estWorstCase, estBestCase, locked) } // Base Fees @@ -931,10 +946,10 @@ func TestFundEdges(t *testing.T) { // swap_size_base: 76 bytes (225 - 149 p2pkh input) (InitTxSizeBase) // lot_size: 1e6 // swap_value: 1e7 - // lots = swap_size / lot_size = 10 - // total_bytes = first_swap_size + chained_swap_sizes + // lots = swap_value / lot_size = 10 // chained_swap_sizes = (lots - 1) * swap_size // first_swap_size = swap_size_base + backing_bytes + // total_bytes = first_swap_size + chained_swap_sizes // total_bytes = swap_size_base + backing_bytes + (lots - 1) * swap_size // base_tx_bytes = total_bytes - backing_bytes // base_tx_bytes = (lots - 1) * swap_size + swap_size_base = 9 * 225 + 76 = 2101 @@ -943,9 +958,13 @@ func TestFundEdges(t *testing.T) { // backing_fees: 149 * fee_rate(34 atoms/byte) = 5066 atoms // total_bytes = base_tx_bytes + backing_bytes = 2101 + 149 = 2250 // total_fees: base_fees + backing_fees = 71434 + 5066 = 76500 atoms - // OR total_bytes * fee_rate = 2510 * 34 = 76500 + // OR total_bytes * fee_rate = 2250 * 34 = 76500 + // base_best_case_bytes = swap_size_base + (lots - 1) * swap_output_size (P2SHOutputSize) + backing_bytes + // = 76 + 9*32 + 149 = 513 const swapSize = 225 const totalBytes = 2250 + const bestCaseBytes = 513 + const swapOutputSize = 32 // (P2SHOutputSize) backingFees := uint64(totalBytes) * tBTC.MaxFeeRate // total_bytes * fee_rate p2pkhUnspent := &ListUnspentResult{ TxID: tTxID, @@ -967,7 +986,8 @@ func TestFundEdges(t *testing.T) { var feeReduction uint64 = swapSize * tBTC.MaxFeeRate estFeeReduction := swapSize * estFeeRate - checkMax(lots-1, swapVal-tBTC.LotSize, backingFees-feeReduction, totalBytes*estFeeRate-estFeeReduction, swapVal+backingFees-1) + checkMax(lots-1, swapVal-tBTC.LotSize, backingFees-feeReduction, totalBytes*estFeeRate-estFeeReduction, + (bestCaseBytes-swapOutputSize)*estFeeRate, swapVal+backingFees-1) _, _, err = wallet.FundOrder(ord) if err == nil { @@ -977,7 +997,7 @@ func TestFundEdges(t *testing.T) { p2pkhUnspent.Amount = float64(swapVal+backingFees) / 1e8 node.rawRes[methodListUnspent] = mustMarshal(t, unspents) - checkMax(lots, swapVal, backingFees, totalBytes*estFeeRate, swapVal+backingFees) + checkMax(lots, swapVal, backingFees, totalBytes*estFeeRate, bestCaseBytes*estFeeRate, swapVal+backingFees) _, _, err = wallet.FundOrder(ord) if err != nil { @@ -1009,7 +1029,7 @@ func TestFundEdges(t *testing.T) { p2pkhUnspent.Amount = float64(v) / 1e8 node.rawRes[methodListUnspent] = mustMarshal(t, unspents) - checkMax(lots, swapVal, backingFees, (totalBytes+splitTxBaggage)*estFeeRate, v) + checkMax(lots, swapVal, backingFees, (totalBytes+splitTxBaggage)*estFeeRate, (bestCaseBytes+splitTxBaggage)*estFeeRate, v) coins, _, err = wallet.FundOrder(ord) if err != nil { @@ -1132,8 +1152,9 @@ func TestFundEdgesSegwit(t *testing.T) { node.rawRes[methodLockUnspent] = mustMarshal(t, true) var estFeeRate = optimalFeeRate + 1 // +1 added in feeRateWithFallback - checkMax := func(lots, swapVal, maxFees, estFees, locked uint64) { - checkMaxOrder(t, wallet, lots, swapVal, maxFees, estFees, locked) + checkMax := func(lots, swapVal, maxFees, estWorstCase, estBestCase, locked uint64) { + t.Helper() + checkMaxOrder(t, wallet, lots, swapVal, maxFees, estWorstCase, estBestCase, locked) } // Base Fees @@ -1158,8 +1179,12 @@ func TestFundEdgesSegwit(t *testing.T) { // total_bytes = base_tx_bytes + backing_bytes = 1461 + 69 = 1530 // total_fees: base_fees + backing_fees = 49674 + 2346 = 52020 atoms // OR total_bytes * fee_rate = 1530 * 34 = 52020 + // base_best_case_bytes = swap_size_base + (lots - 1) * swap_output_size (P2SHOutputSize) + backing_bytes + // = 84 + 9*43 + 69 = 540 const swapSize = 153 const totalBytes = 1530 + const bestCaseBytes = 540 + const swapOutputSize = 43 // (P2WSHOutputSize) backingFees := uint64(totalBytes) * tBTC.MaxFeeRate // total_bytes * fee_rate p2wpkhUnspent := &ListUnspentResult{ TxID: tTxID, @@ -1181,7 +1206,8 @@ func TestFundEdgesSegwit(t *testing.T) { var feeReduction uint64 = swapSize * tBTC.MaxFeeRate estFeeReduction := swapSize * estFeeRate - checkMax(lots-1, swapVal-tBTC.LotSize, backingFees-feeReduction, totalBytes*estFeeRate-estFeeReduction, swapVal+backingFees-1) + checkMax(lots-1, swapVal-tBTC.LotSize, backingFees-feeReduction, totalBytes*estFeeRate-estFeeReduction, + (bestCaseBytes-swapOutputSize)*estFeeRate, swapVal+backingFees-1) _, _, err = wallet.FundOrder(ord) if err == nil { @@ -1191,7 +1217,7 @@ func TestFundEdgesSegwit(t *testing.T) { p2wpkhUnspent.Amount = float64(swapVal+backingFees) / 1e8 node.rawRes[methodListUnspent] = mustMarshal(t, unspents) - checkMax(lots, swapVal, backingFees, totalBytes*estFeeRate, swapVal+backingFees) + checkMax(lots, swapVal, backingFees, totalBytes*estFeeRate, bestCaseBytes*estFeeRate, swapVal+backingFees) _, _, err = wallet.FundOrder(ord) if err != nil { @@ -1221,7 +1247,7 @@ func TestFundEdgesSegwit(t *testing.T) { p2wpkhUnspent.Amount = float64(v) / 1e8 node.rawRes[methodListUnspent] = mustMarshal(t, unspents) - checkMax(lots, swapVal, backingFees, (totalBytes+splitTxBaggageSegwit)*estFeeRate, v) + checkMax(lots, swapVal, backingFees, (totalBytes+splitTxBaggageSegwit)*estFeeRate, (bestCaseBytes+splitTxBaggageSegwit)*estFeeRate, v) coins, _, err = wallet.FundOrder(ord) if err != nil { @@ -1418,7 +1444,11 @@ func testRedeem(t *testing.T, segwit bool) { node.rawRes[methodChangeAddress] = mustMarshal(t, addrStr) node.rawRes[methodPrivKeyForAddress] = mustMarshal(t, wif.String()) - _, _, feesPaid, err := wallet.Redeem([]*asset.Redemption{redemption}) + redemptions := &asset.RedeemForm{ + Redemptions: []*asset.Redemption{redemption}, + } + + _, _, feesPaid, err := wallet.Redeem(redemptions) if err != nil { t.Fatalf("redeem error: %v", err) } @@ -1431,7 +1461,7 @@ func testRedeem(t *testing.T, segwit bool) { // No audit info redemption.Spends = nil - _, _, _, err = wallet.Redeem([]*asset.Redemption{redemption}) + _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for nil AuditInfo") } @@ -1439,7 +1469,7 @@ func testRedeem(t *testing.T, segwit bool) { // Spoofing AuditInfo is not allowed. redemption.Spends = &TAuditInfo{} - _, _, _, err = wallet.Redeem([]*asset.Redemption{redemption}) + _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for spoofed AuditInfo") } @@ -1447,7 +1477,7 @@ func testRedeem(t *testing.T, segwit bool) { // Wrong secret hash redemption.Secret = randBytes(32) - _, _, _, err = wallet.Redeem([]*asset.Redemption{redemption}) + _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for wrong secret") } @@ -1455,7 +1485,7 @@ func testRedeem(t *testing.T, segwit bool) { // too low of value ci.output.value = 200 - _, _, _, err = wallet.Redeem([]*asset.Redemption{redemption}) + _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for redemption not worth the fees") } @@ -1463,7 +1493,7 @@ func testRedeem(t *testing.T, segwit bool) { // Change address error node.rawErr[methodChangeAddress] = tErr - _, _, _, err = wallet.Redeem([]*asset.Redemption{redemption}) + _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for change address error") } @@ -1471,7 +1501,7 @@ func testRedeem(t *testing.T, segwit bool) { // Missing priv key error node.rawErr[methodPrivKeyForAddress] = tErr - _, _, _, err = wallet.Redeem([]*asset.Redemption{redemption}) + _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for missing private key") } @@ -1479,7 +1509,7 @@ func testRedeem(t *testing.T, segwit bool) { // Send error node.sendErr = tErr - _, _, _, err = wallet.Redeem([]*asset.Redemption{redemption}) + _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for send error") } @@ -1489,7 +1519,7 @@ func testRedeem(t *testing.T, segwit bool) { var h chainhash.Hash h[0] = 0x01 node.sendHash = &h - _, _, _, err = wallet.Redeem([]*asset.Redemption{redemption}) + _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for wrong return hash") } @@ -2286,3 +2316,116 @@ func TestSyncStatus(t *testing.T) { t.Fatalf("progress out of range. Expected 0.5, got %.2f", progress) } } + +func TestPreSwap(t *testing.T) { + wallet, node, shutdown, err := tNewWallet(false) + defer shutdown() + if err != nil { + t.Fatal(err) + } + + // See math from TestFundEdges. 10 lots with max fee rate of 34 sats/vbyte. + + swapVal := uint64(1e7) + lots := swapVal / tBTC.LotSize // 10 lots + + const swapSize = 225 + const totalBytes = 2250 + const bestCaseBytes = 513 + var estFeeRate = optimalFeeRate + 1 // +1 added in feeRate + + backingFees := uint64(totalBytes) * tBTC.MaxFeeRate // total_bytes * fee_rate + + minReq := swapVal + backingFees + + p2pkhUnspent := &ListUnspentResult{ + TxID: tTxID, + Address: tP2PKHAddr, + Confirmations: 5, + ScriptPubKey: tP2PKH, + Spendable: true, + Solvable: true, + Safe: true, + } + unspents := []*ListUnspentResult{p2pkhUnspent} + + setFunds := func(v uint64) { + p2pkhUnspent.Amount = float64(v) / 1e8 + node.rawRes[methodListUnspent] = mustMarshal(t, unspents) + } + + form := &asset.PreSwapForm{ + LotSize: tBTC.LotSize, + Lots: lots, + AssetConfig: tBTC, + Immediate: false, + } + + setFunds(minReq) + + // Initial success. + preSwap, err := wallet.PreSwap(form) + if err != nil { + t.Fatalf("PreSwap error: %v", err) + } + + maxFees := totalBytes * tBTC.MaxFeeRate + estHighFees := totalBytes * estFeeRate + estLowFees := bestCaseBytes * estFeeRate + checkSwapEstimate(t, preSwap.Estimate, lots, swapVal, maxFees, estHighFees, estLowFees, minReq) + + // Too little funding is an error. + setFunds(minReq - 1) + _, err = wallet.PreSwap(form) + if err == nil { + t.Fatalf("no PreSwap error for not enough funds: %v", err) + } + setFunds(minReq) + + node.estFeeErr = tErr + _, err = wallet.PreSwap(form) + if err == nil { + t.Fatalf("no PreSwap error for not enough funds: %v", err) + } + node.estFeeErr = nil + + // Success again. + _, err = wallet.PreSwap(form) + if err != nil { + t.Fatalf("PreSwap error: %v", err) + } +} + +func TestPreRedeem(t *testing.T) { + wallet, _, shutdown, _ := tNewWallet(false) + defer shutdown() + + nonSegRedeem, err := wallet.PreRedeem(&asset.PreRedeemForm{ + LotSize: 123456, // Doesn't actually matter + Lots: 5, + }) + // Shouldn't actually be any path to error. + if err != nil { + t.Fatalf("PreRedeem non-segwit error: %v", err) + } + + wallet.segwit = true + + segwitRedeem, err := wallet.PreRedeem(&asset.PreRedeemForm{ + LotSize: 123456, + Lots: 5, + }) + if err != nil { + t.Fatalf("PreRedeem segwit error: %v", err) + } + + // Just a couple of sanity checks. + if nonSegRedeem.Estimate.RealisticBestCase >= nonSegRedeem.Estimate.RealisticWorstCase { + t.Fatalf("best case > worst case") + } + + if segwitRedeem.Estimate.RealisticWorstCase >= nonSegRedeem.Estimate.RealisticWorstCase { + t.Fatalf("segwit > non-segwit") + } + +} diff --git a/client/asset/btc/livetest/livetest.go b/client/asset/btc/livetest/livetest.go index 1925f8b01e..da7e525008 100644 --- a/client/asset/btc/livetest/livetest.go +++ b/client/asset/btc/livetest/livetest.go @@ -305,7 +305,9 @@ func Run(t *testing.T, newWallet WalletConstructor, address string, dexAsset *de makeRedemption(contractValue*2, receipts[1], secretKey2), } - _, _, _, err = rig.alpha().Redeem(redemptions) + _, _, _, err = rig.alpha().Redeem(&asset.RedeemForm{ + Redemptions: redemptions, + }) if err != nil { t.Fatalf("redemption error: %v", err) } diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 71a7885074..20c0d86111 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -687,14 +687,23 @@ func (a amount) String() string { // estimate based on current network conditions, and will be <= the fees // associated with nfo.MaxFeeRate. For quote assets, the caller will have to // calculate lotSize based on a rate conversion from the base asset's lot size. -func (dcr *ExchangeWallet) MaxOrder(lotSize uint64, nfo *dex.Asset) (*asset.OrderEstimate, error) { +func (dcr *ExchangeWallet) MaxOrder(lotSize uint64, nfo *dex.Asset) (*asset.SwapEstimate, error) { + _, _, _, est, err := dcr.maxOrder(lotSize, nfo) + return est, err +} + +// maxOrder gets the estimate for MaxOrder, and also returns the +// []*compositeUTXO to be used for further order estimation without additional +// calls to listunspent. +func (dcr *ExchangeWallet) maxOrder(lotSize uint64, nfo *dex.Asset) (utxos []*compositeUTXO, lots, feeRate uint64, est *asset.SwapEstimate, err error) { networkFeeRate, err := dcr.feeRate(1) if err != nil { - return nil, fmt.Errorf("error getting network fee estimate: %w", err) + return nil, 0, 0, nil, fmt.Errorf("error getting network fee estimate: %w", err) } - utxos, err := dcr.spendableUTXOs() + + utxos, err = dcr.spendableUTXOs() if err != nil { - return nil, fmt.Errorf("error parsing unspent outputs: %w", err) + return nil, 0, 0, nil, fmt.Errorf("error parsing unspent outputs: %w", err) } var avail uint64 for _, utxo := range utxos { @@ -702,53 +711,117 @@ func (dcr *ExchangeWallet) MaxOrder(lotSize uint64, nfo *dex.Asset) (*asset.Orde } // Start by attempting max lots with no fees. - lots := avail / lotSize + lots = avail / lotSize for lots > 0 { - val := lots * lotSize - sum, size, _, _, _, err := dcr.tryFund(utxos, orderEnough(val, lots, nfo)) - // The only failure mode of dcr.tryFund is when there is not enough funds, - // so if an error is encountered, count down the lots and repeat until - // we have enough. + est, _, err := dcr.estimateSwap(lots, lotSize, networkFeeRate, utxos, nfo, dcr.useSplitTx) + // The only failure mode of estimateSwap -> dcr.fund is when there is + // not enough funds, so if an error is encountered, count down the lots + // and repeat until we have enough. if err != nil { lots-- continue } - reqFunds := calc.RequiredOrderFunds(val, uint64(size), lots, nfo) - maxFees := reqFunds - val - estFunds := calc.RequiredOrderFundsAlt(val, uint64(size), lots, nfo.SwapSizeBase, nfo.SwapSize, networkFeeRate) - estFees := estFunds - val - // Math for split transactions is a little different. - if dcr.useSplitTx { - extraFees := splitTxBaggage * nfo.MaxFeeRate - if avail >= reqFunds+extraFees { - return &asset.OrderEstimate{ - Lots: lots, - Value: val, - MaxFees: maxFees + extraFees, - EstimatedFees: estFees + (splitTxBaggage * networkFeeRate), - Locked: val + maxFees + extraFees, - }, nil - } + return utxos, lots, networkFeeRate, est, nil + } + + return nil, 0, 0, &asset.SwapEstimate{}, nil +} + +// estimateSwap prepares an *asset.SwapEstimate. +func (dcr *ExchangeWallet) estimateSwap(lots, lotSize, networkFeeRate uint64, utxos []*compositeUTXO, + nfo *dex.Asset, trySplit bool) (*asset.SwapEstimate, bool /*split used*/, error) { + + var avail uint64 + for _, utxo := range utxos { + avail += toAtoms(utxo.rpc.Amount) + } + + val := lots * lotSize + sum, inputsSize, _, _, _, err := dcr.tryFund(utxos, orderEnough(val, lots, nfo)) + + // The only failure mode of dcr.tryFund is when there is not enough funds, + // so if an error is encountered, count down the lots and repeat until + // we have enough. + if err != nil { + return nil, false, err + } + reqFunds := calc.RequiredOrderFunds(val, uint64(inputsSize), lots, nfo) + maxFees := reqFunds - val + + estHighFunds := calc.RequiredOrderFundsAlt(val, uint64(inputsSize), lots, nfo.SwapSizeBase, nfo.SwapSize, networkFeeRate) + estHighFees := estHighFunds - val + + estLowFunds := calc.RequiredOrderFundsAlt(val, uint64(inputsSize), 1, nfo.SwapSizeBase, nfo.SwapSize, networkFeeRate) + estLowFunds += dexdcr.P2SHOutputSize * (lots - 1) * networkFeeRate + estLowFees := estLowFunds - val + + // Math for split transactions is a little different. + if dcr.useSplitTx { + extraFees := splitTxBaggage * nfo.MaxFeeRate + splitFees := splitTxBaggage * networkFeeRate + if avail >= reqFunds+extraFees { + return &asset.SwapEstimate{ + Lots: lots, + Value: val, + MaxFees: maxFees + extraFees, + RealisticBestCase: estLowFees + splitFees, + RealisticWorstCase: estHighFees + splitFees, + Locked: val + maxFees + extraFees, + }, true, nil } + } - // No split transaction. - return &asset.OrderEstimate{ - Lots: lots, - Value: val, - MaxFees: maxFees, - EstimatedFees: estFees, - Locked: sum, - }, nil + // No split transaction. + return &asset.SwapEstimate{ + Lots: lots, + Value: val, + MaxFees: maxFees, + RealisticBestCase: estLowFees, + RealisticWorstCase: estHighFees, + Locked: sum, + }, false, nil +} + +// PreSwap get order estimates based on the available funds and the wallet +// configuration. +func (dcr *ExchangeWallet) PreSwap(req *asset.PreSwapForm) (*asset.PreSwap, error) { + // Start with the maxOrder at the default configuration. + utxos, maxLots, feeRate, _, err := dcr.maxOrder(req.LotSize, req.AssetConfig) + if err != nil { + return nil, err } - return &asset.OrderEstimate{}, nil + if maxLots < req.Lots { + return nil, fmt.Errorf("%d lots available for %d-lot order", maxLots, req.Lots) + } + + // Get the estimate at using the current configuration + est, _, err := dcr.estimateSwap(req.Lots, req.LotSize, feeRate, utxos, req.AssetConfig, dcr.useSplitTx) + if err != nil { + return nil, fmt.Errorf("estimation failed: %v", err) + } + + return &asset.PreSwap{ + Estimate: est, + }, nil } -// RedemptionFees is an estimate of the redemption fees for a 1-swap redemption. -func (dcr *ExchangeWallet) RedemptionFees() (uint64, error) { +// PreRedeem generates an estimate of the range of redemption fees that could +// be assessed. +func (dcr *ExchangeWallet) PreRedeem(req *asset.PreRedeemForm) (*asset.PreRedeem, error) { feeRate := dcr.feeRateWithFallback(dcr.redeemConfTarget) - var size uint64 = dexdcr.MsgTxOverhead + dexdcr.TxInOverhead + dexdcr.TxOutOverhead + - dexdcr.RedeemSwapSigScriptSize + dexdcr.P2PKHOutputSize - return size * feeRate, nil + // Best is one transaction with req.Lots inputs and 1 output. + var best uint64 = dexdcr.MsgTxOverhead + dexdcr.TxInOverhead + dexdcr.TxOutOverhead + // Worst is req.Lots transactions, each with one input and one output. + var worst uint64 = best * req.Lots + best += dexdcr.RedeemSwapSigScriptSize*req.Lots + dexdcr.P2PKHOutputSize + worst += (dexdcr.RedeemSwapSigScriptSize + dexdcr.P2PKHOutputSize) * req.Lots + + return &asset.PreRedeem{ + Estimate: &asset.RedeemEstimate{ + RealisticWorstCase: worst * feeRate, + RealisticBestCase: best * feeRate, + }, + }, nil } // orderEnough generates a function that can be used as the enough argument to @@ -1289,13 +1362,13 @@ func (dcr *ExchangeWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin // Redeem sends the redemption transaction, which may contain more than one // redemption. -func (dcr *ExchangeWallet) Redeem(redemptions []*asset.Redemption) ([]dex.Bytes, asset.Coin, uint64, error) { +func (dcr *ExchangeWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Coin, uint64, error) { // Create a transaction that spends the referenced contract. msgTx := wire.NewMsgTx() var totalIn uint64 var contracts [][]byte var addresses []dcrutil.Address - for _, r := range redemptions { + for _, r := range form.Redemptions { cinfo, ok := r.Spends.(*auditInfo) if !ok { return nil, nil, 0, fmt.Errorf("Redemption contract info of wrong type") @@ -1325,7 +1398,7 @@ func (dcr *ExchangeWallet) Redeem(redemptions []*asset.Redemption) ([]dex.Bytes, } // Calculate the size and the fees. - size := msgTx.SerializeSize() + dexdcr.RedeemSwapSigScriptSize*len(redemptions) + dexdcr.P2PKHOutputSize + size := msgTx.SerializeSize() + dexdcr.RedeemSwapSigScriptSize*len(form.Redemptions) + dexdcr.P2PKHOutputSize feeRate := dcr.feeRateWithFallback(dcr.redeemConfTarget) fee := feeRate * uint64(size) if fee > totalIn { @@ -1342,7 +1415,7 @@ func (dcr *ExchangeWallet) Redeem(redemptions []*asset.Redemption) ([]dex.Bytes, } msgTx.AddTxOut(txOut) // Sign the inputs. - for i, r := range redemptions { + for i, r := range form.Redemptions { contract := contracts[i] redeemSig, redeemPubKey, err := dcr.createSig(msgTx, i, contract, addresses[i]) if err != nil { @@ -1364,8 +1437,8 @@ func (dcr *ExchangeWallet) Redeem(redemptions []*asset.Redemption) ([]dex.Bytes, return nil, nil, 0, fmt.Errorf("redemption sent, but received unexpected transaction ID back from RPC server. "+ "expected %s, got %s", *txHash, checkHash) } - coinIDs := make([]dex.Bytes, 0, len(redemptions)) - for i := range redemptions { + coinIDs := make([]dex.Bytes, 0, len(form.Redemptions)) + for i := range form.Redemptions { coinIDs = append(coinIDs, toCoinID(txHash, uint32(i))) } diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index db19e3e846..9a591994d9 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -214,6 +214,7 @@ type tRPCClient struct { listLockedErr error blockchainInfo *chainjson.GetBlockChainInfoResult blockchainInfoErr error + estFeeErr error } func defaultSignFunc(tx *wire.MsgTx) (*wire.MsgTx, bool, error) { return tx, true, nil } @@ -237,6 +238,9 @@ func newTRPCClient() *tRPCClient { } func (c *tRPCClient) EstimateSmartFee(_ context.Context, confirmations int64, mode chainjson.EstimateSmartFeeMode) (float64, error) { + if c.estFeeErr != nil { + return 0, c.estFeeErr + } optimalRate := float64(optimalFeeRate) * 1e-5 // fmt.Println((float64(optimalFeeRate)*1e-5)-0.00022) return optimalRate, nil // optimalFeeRate: 22 atoms/byte = 0.00022 DCR/KB * 1e8 atoms/DCR * 1e-3 KB/Byte @@ -938,26 +942,33 @@ func TestFundingCoins(t *testing.T) { ensureGood() } -func checkMaxOrder(t *testing.T, wallet *ExchangeWallet, lots, swapVal, maxFees, estFees, locked uint64) { +func checkMaxOrder(t *testing.T, wallet *ExchangeWallet, lots, swapVal, maxFees, estWorstCase, estBestCase, locked uint64) { t.Helper() maxOrder, err := wallet.MaxOrder(tDCR.LotSize, tDCR) if err != nil { t.Fatalf("MaxOrder error: %v", err) } - if maxOrder.Lots != lots { - t.Fatalf("MaxOrder has wrong Lots. wanted %d, got %d", lots, maxOrder.Lots) + checkSwapEstimate(t, maxOrder, lots, swapVal, maxFees, estWorstCase, estBestCase, locked) +} + +func checkSwapEstimate(t *testing.T, est *asset.SwapEstimate, lots, swapVal, maxFees, estWorstCase, estBestCase, locked uint64) { + if est.Lots != lots { + t.Fatalf("MaxOrder has wrong Lots. wanted %d, got %d", lots, est.Lots) + } + if est.Value != swapVal { + t.Fatalf("est has wrong Value. wanted %d, got %d", swapVal, est.Value) } - if maxOrder.Value != swapVal { - t.Fatalf("MaxOrder has wrong Value. wanted %d, got %d", swapVal, maxOrder.Value) + if est.MaxFees != maxFees { + t.Fatalf("est has wrong MaxFees. wanted %d, got %d", maxFees, est.MaxFees) } - if maxOrder.MaxFees != maxFees { - t.Fatalf("MaxOrder has wrong MaxFees. wanted %d, got %d", maxFees, maxOrder.MaxFees) + if est.RealisticWorstCase != estWorstCase { + t.Fatalf("MaxOrder has wrong RealisticWorstCase. wanted %d, got %d", estWorstCase, est.RealisticWorstCase) } - if maxOrder.EstimatedFees != estFees { - t.Fatalf("MaxOrder has wrong EstimatedFees. wanted %d, got %d", estFees, maxOrder.EstimatedFees) + if est.RealisticBestCase != estBestCase { + t.Fatalf("MaxOrder has wrong RealisticBestCase. wanted %d, got %d", estBestCase, est.RealisticBestCase) } - if maxOrder.Locked != locked { - t.Fatalf("MaxOrder has wrong Locked. wanted %d, got %d", locked, maxOrder.Locked) + if est.Locked != locked { + t.Fatalf("MaxOrder has wrong Locked. wanted %d, got %d", locked, est.Locked) } } @@ -971,8 +982,8 @@ func TestFundEdges(t *testing.T) { lots := swapVal / tDCR.LotSize var estFeeRate uint64 = optimalFeeRate + 1 // +1 added in feeRate - checkMax := func(lots, swapVal, maxFees, estFees, locked uint64) { - checkMaxOrder(t, wallet, lots, swapVal, maxFees, estFees, locked) + checkMax := func(lots, swapVal, maxFees, estWorstCase, estBestCase, locked uint64) { + checkMaxOrder(t, wallet, lots, swapVal, maxFees, estWorstCase, estBestCase, locked) } // Swap fees @@ -990,8 +1001,12 @@ func TestFundEdges(t *testing.T) { // total_bytes = base_tx_bytes + backing_bytes = 2344 + 166 = 2510 // total_fees: base_fees + backing_fees = 56256 + 3984 = 60240 atoms // OR total_bytes * fee_rate = 2510 * 24 = 60240 + // base_best_case_bytes = swap_size_base + (lots - 1) * swap_output_size (P2SHOutputSize) + backing_bytes + // = 85 + 9*34 + 166 = 557 const swapSize = 251 const totalBytes = 2510 + const bestCaseBytes = 557 + const swapOutputSize = 34 fees := uint64(totalBytes) * tDCR.MaxFeeRate p2pkhUnspent := walletjson.ListUnspentResult{ TxID: tTxID, @@ -1011,7 +1026,8 @@ func TestFundEdges(t *testing.T) { var feeReduction uint64 = swapSize * tDCR.MaxFeeRate estFeeReduction := swapSize * estFeeRate - checkMax(lots-1, swapVal-tDCR.LotSize, fees-feeReduction, totalBytes*estFeeRate-estFeeReduction, swapVal+fees-1) + checkMax(lots-1, swapVal-tDCR.LotSize, fees-feeReduction, totalBytes*estFeeRate-estFeeReduction, + (bestCaseBytes-swapOutputSize)*estFeeRate, swapVal+fees-1) _, _, err = wallet.FundOrder(ord) if err == nil { @@ -1021,7 +1037,7 @@ func TestFundEdges(t *testing.T) { p2pkhUnspent.Amount = float64(swapVal+fees) / 1e8 node.unspent = []walletjson.ListUnspentResult{p2pkhUnspent} - checkMax(lots, swapVal, fees, totalBytes*estFeeRate, swapVal+fees) + checkMax(lots, swapVal, fees, totalBytes*estFeeRate, bestCaseBytes*estFeeRate, swapVal+fees) _, _, err = wallet.FundOrder(ord) if err != nil { @@ -1050,7 +1066,7 @@ func TestFundEdges(t *testing.T) { v = swapVal + fees node.unspent[0].Amount = float64(v) / 1e8 - checkMax(lots, swapVal, fees, (totalBytes+splitTxBaggage)*estFeeRate, v) + checkMax(lots, swapVal, fees, (totalBytes+splitTxBaggage)*estFeeRate, (bestCaseBytes+splitTxBaggage)*estFeeRate, v) coins, _, err = wallet.FundOrder(ord) if err != nil { @@ -1263,7 +1279,11 @@ func TestRedeem(t *testing.T) { t.Fatalf("NewWIF error: %v", err) } - _, _, feesPaid, err := wallet.Redeem([]*asset.Redemption{redemption}) + redemptions := &asset.RedeemForm{ + Redemptions: []*asset.Redemption{redemption}, + } + + _, _, feesPaid, err := wallet.Redeem(redemptions) if err != nil { t.Fatalf("redeem error: %v", err) } @@ -1276,7 +1296,7 @@ func TestRedeem(t *testing.T) { // No audit info redemption.Spends = nil - _, _, _, err = wallet.Redeem([]*asset.Redemption{redemption}) + _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for nil AuditInfo") } @@ -1284,7 +1304,7 @@ func TestRedeem(t *testing.T) { // Spoofing AuditInfo is not allowed. redemption.Spends = &TAuditInfo{} - _, _, _, err = wallet.Redeem([]*asset.Redemption{redemption}) + _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for spoofed AuditInfo") } @@ -1292,7 +1312,7 @@ func TestRedeem(t *testing.T) { // Wrong secret hash redemption.Secret = randBytes(32) - _, _, _, err = wallet.Redeem([]*asset.Redemption{redemption}) + _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for wrong secret") } @@ -1300,7 +1320,7 @@ func TestRedeem(t *testing.T) { // too low of value ci.output.value = 200 - _, _, _, err = wallet.Redeem([]*asset.Redemption{redemption}) + _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for redemption not worth the fees") } @@ -1308,7 +1328,7 @@ func TestRedeem(t *testing.T) { // Change address error node.changeAddrErr = tErr - _, _, _, err = wallet.Redeem([]*asset.Redemption{redemption}) + _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for change address error") } @@ -1316,7 +1336,7 @@ func TestRedeem(t *testing.T) { // Missing priv key error node.privWIFErr = tErr - _, _, _, err = wallet.Redeem([]*asset.Redemption{redemption}) + _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for missing private key") } @@ -1324,7 +1344,7 @@ func TestRedeem(t *testing.T) { // Send error node.sendRawErr = tErr - _, _, _, err = wallet.Redeem([]*asset.Redemption{redemption}) + _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for send error") } @@ -1334,7 +1354,7 @@ func TestRedeem(t *testing.T) { var h chainhash.Hash h[0] = 0x01 node.sendRawHash = &h - _, _, _, err = wallet.Redeem([]*asset.Redemption{redemption}) + _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for wrong return hash") } @@ -2084,3 +2104,94 @@ func TestSyncStatus(t *testing.T) { t.Fatalf("progress out of range. Expected 0.5, got %.2f", progress) } } + +func TestPreSwap(t *testing.T) { + wallet, node, shutdown, _ := tNewWallet() + defer shutdown() + + // See math from TestFundEdges. 10 lots with max fee rate of 34 sats/vbyte. + + swapVal := uint64(1e8) + lots := swapVal / tDCR.LotSize // 10 lots + + const swapSize = 251 + const totalBytes = 2510 + const bestCaseBytes = 557 + var estFeeRate = optimalFeeRate + 1 // +1 added in feeRate + + backingFees := uint64(totalBytes) * tDCR.MaxFeeRate // total_bytes * fee_rate + + minReq := swapVal + backingFees + + fees := uint64(totalBytes) * tDCR.MaxFeeRate + p2pkhUnspent := walletjson.ListUnspentResult{ + TxID: tTxID, + Address: tPKHAddr.String(), + Account: wallet.acct, + Amount: float64(swapVal+fees-1) / 1e8, // one atom less than needed + Confirmations: 5, + ScriptPubKey: hex.EncodeToString(tP2PKHScript), + } + + node.unspent = []walletjson.ListUnspentResult{p2pkhUnspent} + + form := &asset.PreSwapForm{ + LotSize: tDCR.LotSize, + Lots: lots, + AssetConfig: tDCR, + Immediate: false, + } + + node.unspent[0].Amount = float64(minReq) / 1e8 + + // Initial success. + preSwap, err := wallet.PreSwap(form) + if err != nil { + t.Fatalf("PreSwap error: %v", err) + } + + maxFees := totalBytes * tDCR.MaxFeeRate + estHighFees := totalBytes * estFeeRate + estLowFees := bestCaseBytes * estFeeRate + checkSwapEstimate(t, preSwap.Estimate, lots, swapVal, maxFees, estHighFees, estLowFees, minReq) + + // Too little funding is an error. + node.unspent[0].Amount = float64(minReq-1) / 1e8 + _, err = wallet.PreSwap(form) + if err == nil { + t.Fatalf("no PreSwap error for not enough funds: %v", err) + } + node.unspent[0].Amount = float64(minReq) / 1e8 + + node.estFeeErr = tErr + _, err = wallet.PreSwap(form) + if err == nil { + t.Fatalf("no PreSwap error for not enough funds: %v", err) + } + node.estFeeErr = nil + + // Success again. + _, err = wallet.PreSwap(form) + if err != nil { + t.Fatalf("PreSwap error: %v", err) + } +} + +func TestPreRedeem(t *testing.T) { + wallet, _, shutdown, _ := tNewWallet() + defer shutdown() + + preRedeem, err := wallet.PreRedeem(&asset.PreRedeemForm{ + LotSize: 123456, // Doesn't actually matter + Lots: 5, + }) + // Shouldn't actually be any path to error. + if err != nil { + t.Fatalf("PreRedeem non-segwit error: %v", err) + } + + // Just a sanity check. + if preRedeem.Estimate.RealisticBestCase >= preRedeem.Estimate.RealisticWorstCase { + t.Fatalf("best case > worst case") + } +} diff --git a/client/asset/dcr/simnet_test.go b/client/asset/dcr/simnet_test.go index 37fc152145..9107d83c1c 100644 --- a/client/asset/dcr/simnet_test.go +++ b/client/asset/dcr/simnet_test.go @@ -329,7 +329,9 @@ func runTest(t *testing.T, splitTx bool) { makeRedemption(contractValue*2, receipts[1], secretKey2), } - _, _, _, err = rig.alpha().Redeem(redemptions) + _, _, _, err = rig.alpha().Redeem(&asset.RedeemForm{ + Redemptions: redemptions, + }) if err != nil { t.Fatalf("redemption error: %v", err) } diff --git a/client/asset/interface.go b/client/asset/interface.go index 5b7865faed..a4c76bba2a 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -88,10 +88,11 @@ type Wallet interface { // fees are an estimate based on current network conditions, and will be <= // the fees associated with the Asset.MaxFeeRate. For quote assets, lotSize // will be an estimate based on current market conditions. - MaxOrder(lotSize uint64, nfo *dex.Asset) (*OrderEstimate, error) - // RedemptionFees is an estimate of the redemption fees for a 1-swap - // redemption. - RedemptionFees() (uint64, error) + MaxOrder(lotSize uint64, nfo *dex.Asset) (*SwapEstimate, error) + // PreSwap gets an OrderEstimate for the specified order size(s). + PreSwap(*PreSwapForm) (*PreSwap, error) + // PreRedeem gets an OrderEstimate for the specified order size(s). + PreRedeem(*PreRedeemForm) (*PreRedeem, error) // ReturnCoins unlocks coins. This would be necessary in the case of a // canceled order. ReturnCoins(Coins) error @@ -108,7 +109,7 @@ type Wallet interface { Swap(*Swaps) (receipts []Receipt, changeCoin Coin, feesPaid uint64, err error) // Redeem sends the redemption transaction, which may contain more than one // redemption. The input coin IDs and the output Coin are returned. - Redeem(redeems []*Redemption) (ins []dex.Bytes, out Coin, feesPaid uint64, err error) + Redeem(redeems *RedeemForm) (ins []dex.Bytes, out Coin, feesPaid uint64, err error) // SignMessage signs the coin ID with the private key associated with the // specified Coin. A slice of pubkeys required to spend the Coin and a // signature for each pubkey are returned. @@ -266,6 +267,12 @@ type Redemption struct { Secret dex.Bytes } +// RedeemForm is a group of Redemptions. The struct will be +// expanded in in-progress work to accomondate order-time options. +type RedeemForm struct { + Redemptions []*Redemption +} + // Order is order details needed for FundOrder. type Order struct { // Value is the amount required to satisfy the order. The Value does not @@ -290,20 +297,81 @@ type Order struct { Immediate bool } -// OrderEstimate is an estimate of the fees and locked amounts associated with +// SwapEstimate is an estimate of the fees and locked amounts associated with // an order. -type OrderEstimate struct { +type SwapEstimate struct { // Lots is the number of lots in the order. - Lots uint64 + Lots uint64 `json:"lots"` // Value is the total value of the order. - Value uint64 + Value uint64 `json:"value"` // MaxFees is the maximum possible fees that can be assessed for the order's // swaps. - MaxFees uint64 - // EstimatedFees is an estimation of the fees that might be assessed for the - // order's swaps. - EstimatedFees uint64 + MaxFees uint64 `json:"maxFees"` + // RealisticWorstCase is an estimation of the fees that might be assessed in + // a worst-case scenario of 1 tx per 1 lot match, but at the prevailing fee + // rate estimate. + RealisticWorstCase uint64 `json:"realisticWorstCase"` + // RealisticBestCase is an estimation of the fees that might be assessed in + // a best-case scenario of 1 tx and 1 output for the entire order. + RealisticBestCase uint64 `json:"realisticBestCase"` // Locked is the amount that will be locked if this order is // subsequently placed. - Locked uint64 + Locked uint64 `json:"locked"` +} + +// RedeemEstimate is an estimate of the range of fees that might realistically +// be assessed to the redemption transaction. +type RedeemEstimate struct { + // RealisticBestCase is the best-case scenario fees of a single transaction + // with a match covering the entire order, at the prevailing fee rate. + RealisticBestCase uint64 `json:"realisticBestCase"` + // RealisticWorstCase is the worst-case scenario fees of all 1-lot matches, + // each with their own call to Redeem. + RealisticWorstCase uint64 `json:"realisticWorstCase"` +} + +// PreSwapForm can be used to get a swap fees estimate. +type PreSwapForm struct { + // LotSize is the lot size for the calculation. For quote assets, LotSize + // will can be based on either the user's limit order rate, or some measure + // of the current market conditions. + LotSize uint64 + // Lots is the number of lots in the order. + Lots uint64 + // AssetConfig is the dex's asset configuration info. + AssetConfig *dex.Asset + // Immediate should be set to true if this is for an order that is not a + // standing order, likely a market order or a limit order with immediate + // time-in-force. + Immediate bool +} + +// SwapOption is an OrderEstimate and it's associated ConfigOption. +type SwapOption struct { + ConfigOption + // Estimate is the OrderEstimate for an order with the specified + // option values. + Estimate *SwapEstimate +} + +// PreSwap is a SwapEstimate returned from Wallet.PreSwap. The struct will be +// expanded in in-progress work to accomondate order-time options. +type PreSwap struct { + Estimate *SwapEstimate `json:"estimate"` +} + +// PreRedeemForm can be used to get a redemption estimate. +type PreRedeemForm struct { + // LotSize is the lot size for the calculation. For quote assets, LotSize + // will can be based on either the user's limit order rate, or some measure + // of the current market conditions. + LotSize uint64 + // Lots is the number of lots in the order. + Lots uint64 +} + +// PreRedeem is an estimate of the fees for redemption. The struct will be +// expanded in in-progress work to accomondate order-time options. +type PreRedeem struct { + Estimate *RedeemEstimate `json:"estimate"` } diff --git a/client/core/core.go b/client/core/core.go index 7fb1fd452c..09bba903fb 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -20,6 +20,8 @@ import ( "sync/atomic" "time" + "decred.org/dcrdex/client/orderbook" + "decred.org/dcrdex/client/asset" "decred.org/dcrdex/client/comms" "decred.org/dcrdex/client/db" @@ -750,6 +752,42 @@ func (c *Core) tickAsset(dc *dexConnection, assetID uint32) assetMap { return updated } +// Get the *dexConnection and connection status for the the host. +func (c *Core) dex(addr string) (*dexConnection, bool, error) { + host, err := addrHost(addr) + if err != nil { + return nil, false, newError(addressParseErr, "error parsing address: %v", err) + } + + // Get the dexConnection and the dex.Asset for each asset. + c.connMtx.RLock() + dc, found := c.conns[host] + connected := found && dc.connected + c.connMtx.RUnlock() + if !found { + return nil, false, fmt.Errorf("unknown DEX %s", addr) + } + return dc, connected, nil +} + +// Get the *dexConnection for the the host. Return an error if the DEX is not +// connected. +func (c *Core) connectedDEX(addr string) (*dexConnection, error) { + dc, connected, err := c.dex(addr) + if err != nil { + return nil, err + } + + if dc.acct.locked() { + return nil, fmt.Errorf("cannot place order on a locked %s account. Are you logged in?", dc.acct.host) + } + + if !connected { + return nil, fmt.Errorf("currently disconnected from %s. Cannot place order", dc.acct.host) + } + return dc, nil +} + // setEpoch sets the epoch. If the passed epoch is greater than the highest // previously passed epoch, an epoch notification is sent to all subscribers and // true is returned. @@ -2329,7 +2367,7 @@ func (c *Core) marketWallets(host string, base, quote uint32) (ba, qa *dex.Asset // market. An order rate must be provided, since the number of lots available // for trading will vary based on the rate for a buy order (unlike a sell // order). -func (c *Core) MaxBuy(host string, base, quote uint32, rate uint64) (*OrderEstimate, error) { +func (c *Core) MaxBuy(host string, base, quote uint32, rate uint64) (*MaxOrderEstimate, error) { baseAsset, quoteAsset, baseWallet, quoteWallet, err := c.marketWallets(host, base, quote) if err != nil { return nil, err @@ -2340,25 +2378,25 @@ func (c *Core) MaxBuy(host string, base, quote uint32, rate uint64) (*OrderEstim if err != nil { return nil, fmt.Errorf("%s wallet MaxOrder error: %v", unbip(quote), err) } - buyRedemptionPer, err := baseWallet.RedemptionFees() + + preRedeem, err := baseWallet.PreRedeem(&asset.PreRedeemForm{ + LotSize: baseAsset.LotSize, + Lots: 1, + }) if err != nil { return nil, fmt.Errorf("%s RedemptionFees error: %v", unbip(base), err) } - return &OrderEstimate{ - Lots: maxBuy.Lots, - Value: maxBuy.Value, - MaxFees: maxBuy.MaxFees, - EstimatedFees: maxBuy.EstimatedFees, - Locked: maxBuy.Locked, - RedemptionFees: maxBuy.Lots * buyRedemptionPer, + return &MaxOrderEstimate{ + Swap: maxBuy, + Redeem: preRedeem.Estimate, }, nil } // MaxSell is the maximum-sized *OrderEstimate for a sell order on the specified // market. -func (c *Core) MaxSell(host string, base, quote uint32) (*OrderEstimate, error) { - baseAsset, _, baseWallet, quoteWallet, err := c.marketWallets(host, base, quote) +func (c *Core) MaxSell(host string, base, quote uint32) (*MaxOrderEstimate, error) { + baseAsset, quoteAsset, baseWallet, quoteWallet, err := c.marketWallets(host, base, quote) if err != nil { return nil, err } @@ -2367,18 +2405,33 @@ func (c *Core) MaxSell(host string, base, quote uint32) (*OrderEstimate, error) if err != nil { return nil, fmt.Errorf("%s wallet MaxOrder error: %v", unbip(base), err) } - sellRedemptionPer, err := quoteWallet.RedemptionFees() + + dc, _, err := c.dex(host) if err != nil { - return nil, fmt.Errorf("%s RedemptionFees error: %v", unbip(quote), err) + return nil, err } - return &OrderEstimate{ - Lots: maxSell.Lots, - Value: maxSell.Value, - MaxFees: maxSell.MaxFees, - EstimatedFees: maxSell.EstimatedFees, - Locked: maxSell.Locked, - RedemptionFees: maxSell.Lots * sellRedemptionPer, + // Estimate a quote-converted lot size. + lotSize := quoteAsset.LotSize + book, found := dc.books[marketName(base, quote)] + if found { + midGap, err := book.MidGap() + if err == nil { + lotSize = calc.BaseToQuote(midGap, baseAsset.LotSize) + } + } + + preRedeem, err := quoteWallet.PreRedeem(&asset.PreRedeemForm{ + LotSize: lotSize, + Lots: 1, + }) + if err != nil { + return nil, fmt.Errorf("%s RedemptionFees error: %v", unbip(base), err) + } + + return &MaxOrderEstimate{ + Swap: maxSell, + Redeem: preRedeem.Estimate, }, nil } @@ -2630,33 +2683,115 @@ func (c *Core) Withdraw(pw []byte, assetID uint32, value uint64, address string) return coin, nil } -// Trade is used to place a market or limit order. -func (c *Core) Trade(pw []byte, form *TradeForm) (*Order, error) { - // Check the user password. - crypter, err := c.encryptionKey(pw) +func (c *Core) PreOrder(form *TradeForm) (*OrderEstimate, error) { + dc, err := c.connectedDEX(form.Host) if err != nil { - return nil, fmt.Errorf("Trade password error: %w", err) + return nil, err } - host, err := addrHost(form.Host) + + wallets, err := c.walletSet(dc, form.Base, form.Quote, form.Sell) if err != nil { - return nil, newError(addressParseErr, "error parsing address: %v", err) + return nil, err } - // Get the dexConnection and the dex.Asset for each asset. - c.connMtx.RLock() - dc, found := c.conns[host] - connected := found && dc.connected - c.connMtx.RUnlock() - if !found { - return nil, fmt.Errorf("unknown DEX %s", form.Host) + // So here's the thing. Our assets thus far don't require the wallet to be + // unlocked to get order estimation (listunspent works on locked wallet), + // but if we run into an asset that breaks that assumption, we may need + // to require a password here before estimation. + + // We need the wallets to be connected. + if !wallets.fromWallet.connected() { + err := c.connectAndUpdateWallet(wallets.fromWallet) + if err != nil { + c.log.Errorf("Error connecting to %s wallet: %v", wallets.fromAsset.Symbol, err) + return nil, fmt.Errorf("Error connecting to %s wallet", wallets.fromAsset.Symbol) + } } - if dc.acct.locked() { - return nil, fmt.Errorf("cannot place order on a locked %s account. Are you logged in?", dc.acct.host) + if !wallets.toWallet.connected() { + err := c.connectAndUpdateWallet(wallets.toWallet) + if err != nil { + c.log.Errorf("Error connecting to %s wallet: %v", wallets.toAsset.Symbol, err) + return nil, fmt.Errorf("Error connecting to %s wallet", wallets.toAsset.Symbol) + } } - if !connected { - return nil, fmt.Errorf("currently disconnected from %s. Cannot place order", dc.acct.host) + // Fund the order and prepare the coins. + lotSize := wallets.baseAsset.LotSize + lots := form.Qty / lotSize + rate := form.Rate + + if !form.IsLimit { + // If this is a market order, we'll predict the fill price. + book, found := dc.books[marketName(form.Base, form.Quote)] + if !found { + return nil, fmt.Errorf("Cannot estimate market order without a synced book") + } + + var fills []*orderbook.Fill + var filled bool + if form.Sell { + fills, filled = book.BestFill(form.Sell, form.Qty) + } else { + fills, filled = book.BestFillMarketBuy(form.Qty, lotSize) + } + + if !filled { + return nil, fmt.Errorf("Market is too thin to estimate market order") + } + + // Get an average rate. + var qtySum, product uint64 + for _, fill := range fills { + product += fill.Quantity * fill.Rate + qtySum += fill.Quantity + } + rate = product / qtySum + if !form.Sell { + lots = qtySum / lotSize + } + } + + fromLotSize, toLotSize := lotSize, calc.BaseToQuote(rate, lotSize) + if !form.Sell { + fromLotSize, toLotSize = toLotSize, fromLotSize + } + + swapEstimate, err := wallets.fromWallet.PreSwap(&asset.PreSwapForm{ + LotSize: fromLotSize, + Lots: lots, + AssetConfig: wallets.fromAsset, + Immediate: (form.IsLimit && form.TifNow), + }) + if err != nil { + return nil, fmt.Errorf("error getting swap estimate: %v", err) + } + + redeemEstimate, err := wallets.toWallet.PreRedeem(&asset.PreRedeemForm{ + LotSize: toLotSize, + Lots: lots, + }) + if err != nil { + return nil, fmt.Errorf("error getting redemption estimate: %v", err) + } + + return &OrderEstimate{ + Swap: swapEstimate, + Redeem: redeemEstimate, + }, nil +} + +// Trade is used to place a market or limit order. +func (c *Core) Trade(pw []byte, form *TradeForm) (*Order, error) { + // Check the user password. + crypter, err := c.encryptionKey(pw) + if err != nil { + return nil, fmt.Errorf("Trade password error: %w", err) + } + + dc, err := c.connectedDEX(form.Host) + if err != nil { + return nil, err } corder, fromID, err := c.prepareTrackedTrade(dc, form, crypter) diff --git a/client/core/core_test.go b/client/core/core_test.go index a3c24097b6..51f82903a2 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -564,6 +564,8 @@ type TXCWallet struct { confsMtx sync.RWMutex confs map[string]uint32 confsErr map[string]error + preSwap *asset.PreSwap + preRedeem *asset.PreRedeem } func newTWallet(assetID uint32) (*xcWallet, *TXCWallet) { @@ -624,9 +626,14 @@ func (w *TXCWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes, error return w.fundingCoins, w.fundRedeemScripts, w.fundingCoinErr } -func (w *TXCWallet) MaxOrder(lotSize uint64, nfo *dex.Asset) (*asset.OrderEstimate, error) { +func (w *TXCWallet) MaxOrder(lotSize uint64, nfo *dex.Asset) (*asset.SwapEstimate, error) { return nil, nil } + +func (w *TXCWallet) PreSwap(*asset.PreSwapForm) (*asset.PreSwap, error) { return w.preSwap, nil } +func (w *TXCWallet) PreRedeem(*asset.PreRedeemForm) (*asset.PreRedeem, error) { + return w.preRedeem, nil +} func (w *TXCWallet) RedemptionFees() (uint64, error) { return 0, nil } func (w *TXCWallet) ReturnCoins(coins asset.Coins) error { @@ -662,7 +669,7 @@ func (w *TXCWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin, uint6 return w.swapReceipts, w.changeCoin, tSwapFeesPaid, nil } -func (w *TXCWallet) Redeem([]*asset.Redemption) ([]dex.Bytes, asset.Coin, uint64, error) { +func (w *TXCWallet) Redeem(*asset.RedeemForm) ([]dex.Bytes, asset.Coin, uint64, error) { defer func() { if w.redeemErrChan != nil { w.redeemErrChan <- w.redeemErr @@ -5988,3 +5995,172 @@ func TestParseCert(t *testing.T) { t.Fatalf("no cert returned from cert store") } } + +func TestPreOrder(t *testing.T) { + rig := newTestRig() + tCore := rig.core + + btcWallet, tBtcWallet := newTWallet(tBTC.ID) + tCore.wallets[tBTC.ID] = btcWallet + dcrWallet, tDcrWallet := newTWallet(tDCR.ID) + tCore.wallets[tDCR.ID] = dcrWallet + + var rate uint64 = 1e8 + quoteConvertedLotSize := calc.BaseToQuote(rate, tDCR.LotSize) + + book := newBookie(tLogger, func() {}) + rig.dc.books[tDcrBtcMktName] = book + + sellNote := &msgjson.BookOrderNote{ + OrderNote: msgjson.OrderNote{ + OrderID: encode.RandomBytes(32), + }, + TradeNote: msgjson.TradeNote{ + Side: msgjson.SellOrderNum, + Quantity: quoteConvertedLotSize * 10, + Time: uint64(time.Now().Unix()), + Rate: rate, + }, + } + + buyNote := *sellNote + buyNote.TradeNote.Quantity = tDCR.LotSize * 10 + buyNote.TradeNote.Side = msgjson.BuyOrderNum + + err := book.Sync(&msgjson.OrderBook{ + MarketID: tDcrBtcMktName, + Seq: 1, + Epoch: 1, + Orders: []*msgjson.BookOrderNote{sellNote, &buyNote}, + }) + if err != nil { + t.Fatalf("Sync error: %v", err) + } + + preSwap := &asset.PreSwap{ + Estimate: &asset.SwapEstimate{ + MaxFees: 1001, + Lots: 5, + RealisticBestCase: 15, + RealisticWorstCase: 20, + Locked: 25, + }, + } + + tBtcWallet.preSwap = preSwap + + preRedeem := &asset.PreRedeem{ + Estimate: &asset.RedeemEstimate{ + RealisticBestCase: 15, + RealisticWorstCase: 20, + }, + } + + tDcrWallet.preRedeem = preRedeem + + form := &TradeForm{ + Host: tDexHost, + Sell: false, + // IsLimit: true, + Base: tDCR.ID, + Quote: tBTC.ID, + Qty: quoteConvertedLotSize * 5, + Rate: rate, + } + preOrder, err := tCore.PreOrder(form) + if err != nil { + t.Fatalf("PreOrder market buy error: %v", err) + } + + compUint64 := func(tag string, a, b uint64) { + t.Helper() + if a != b { + t.Fatalf("%s: %d != %d", tag, a, b) + } + } + + est1, est2 := preSwap.Estimate, preOrder.Swap.Estimate + compUint64("MaxFees", est1.MaxFees, est2.MaxFees) + compUint64("RealisticWorstCase", est1.RealisticWorstCase, est2.RealisticWorstCase) + compUint64("RealisticBestCase", est1.RealisticBestCase, est2.RealisticBestCase) + compUint64("Locked", est1.Locked, est2.Locked) + + // Missing book is an error + delete(rig.dc.books, tDcrBtcMktName) + _, err = tCore.PreOrder(form) + if err == nil { + t.Fatalf("no error for market order with missing book") + } + rig.dc.books[tDcrBtcMktName] = book + + // Exercise the market sell path too. + form.Sell = true + _, err = tCore.PreOrder(form) + if err != nil { + t.Fatalf("PreOrder market sell error: %v", err) + } + + // Market orders have to have a market to make estimates. + book.Unbook(&msgjson.UnbookOrderNote{ + MarketID: tDcrBtcMktName, + OrderID: sellNote.OrderID, + }) + book.Unbook(&msgjson.UnbookOrderNote{ + MarketID: tDcrBtcMktName, + OrderID: buyNote.OrderID, + }) + _, err = tCore.PreOrder(form) + if err == nil { + t.Fatalf("no error for market order with empty market") + } + + // Limit orders have no such restriction. + form.IsLimit = true + _, err = tCore.PreOrder(form) + if err != nil { + t.Fatalf("PreOrder limit sell error: %v", err) + } + + // no DEX + delete(tCore.conns, rig.dc.acct.host) + _, err = tCore.PreOrder(form) + if err == nil { + t.Fatalf("no error for unknown DEX") + } + tCore.conns[rig.dc.acct.host] = rig.dc + + // no wallet + delete(tCore.wallets, tDCR.ID) + _, err = tCore.PreOrder(form) + if err == nil { + t.Fatalf("no error for missing wallet") + } + tCore.wallets[tDCR.ID] = dcrWallet + + // base wallet not connected + dcrWallet.hookedUp = false + tDcrWallet.connectErr = tErr + _, err = tCore.PreOrder(form) + if err == nil { + t.Fatalf("no error for unconnected base wallet") + } + dcrWallet.hookedUp = true + tDcrWallet.connectErr = nil + + // quote wallet not connected + btcWallet.hookedUp = false + tBtcWallet.connectErr = tErr + _, err = tCore.PreOrder(form) + if err == nil { + t.Fatalf("no error for unconnected quote wallet") + } + btcWallet.hookedUp = true + tBtcWallet.connectErr = nil + + // sucess again + _, err = tCore.PreOrder(form) + if err != nil { + t.Fatalf("PreOrder error after fixing everything: %v", err) + } + +} diff --git a/client/core/trade.go b/client/core/trade.go index ad03ce2dea..1faec8166a 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -1584,7 +1584,9 @@ func (c *Core) redeemMatchGroup(t *trackedTrade, matches []*matchTracker, errs * // Send the transaction. redeemWallet, redeemAsset := t.wallets.toWallet, t.wallets.toAsset // this is our redeem - coinIDs, outCoin, fees, err := redeemWallet.Redeem(redemptions) + coinIDs, outCoin, fees, err := redeemWallet.Redeem(&asset.RedeemForm{ + Redemptions: redemptions, + }) // If an error was encountered, fail all of the matches. A failed match will // not run again on during ticks. if err != nil { diff --git a/client/core/types.go b/client/core/types.go index 928125950f..4e95253b2f 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -776,16 +776,15 @@ func (c assetMap) merge(other assetMap) { } } -// OrderEstimate is an estimate of the fees and locked amounts associated with -// an order. This type differs from asset.OrderEstimate in the addition of the -// RedemptionFees field, which comes from the other asset's wallet. +// MaxOrderEstimate is an estimate of the fees and locked amounts associated +// with an order. +type MaxOrderEstimate struct { + Swap *asset.SwapEstimate `json:"swap"` + Redeem *asset.RedeemEstimate `json:"redeem"` +} + +// OrderEstimate is a Core.PreOrder estimate. type OrderEstimate struct { - Lots uint64 `json:"lots"` - Value uint64 `json:"value"` - MaxFees uint64 `json:"maxFees"` - EstimatedFees uint64 `json:"estimatedFees"` - Locked uint64 `json:"locked"` - // RedemptionFees are the fees associated with redeeming the order one - // lot at a time, using the wallet's redemption fee rate. - RedemptionFees uint64 `json:"redemptionFees"` + Swap *asset.PreSwap `json:"swap"` + Redeem *asset.PreRedeem `json:"redeem"` } diff --git a/client/orderbook/bookside.go b/client/orderbook/bookside.go index 0f6b2d8a18..7beeb7145e 100644 --- a/client/orderbook/bookside.go +++ b/client/orderbook/bookside.go @@ -9,6 +9,7 @@ import ( "sort" "sync" + "decred.org/dcrdex/dex/calc" "decred.org/dcrdex/dex/order" ) @@ -20,10 +21,10 @@ const ( descending ) -// fill represents an order fill. -type fill struct { - match *Order - quantity uint64 +// Fill represents an order fill. +type Fill struct { + Rate uint64 + Quantity uint64 } // bookSide represents a side of the order book. @@ -139,125 +140,93 @@ func (d *bookSide) BestNOrders(n int) ([]*Order, bool) { count := n best := make([]*Order, 0) - // Fetch the best N orders per order preference. - switch d.orderPref { - case ascending: - for i := 0; i < len(d.rateIndex.Rates); i++ { - bin := d.bins[d.rateIndex.Rates[i]] + d.iterateOrders(func(ord *Order) bool { + if count == 0 { + return false + } + best = append(best, ord) + count-- + return true + }) - for idx := 0; idx < len(bin); idx++ { - if count == 0 { - break - } + return best, len(best) == n +} - best = append(best, bin[idx]) - count-- - } +// BestFill returns the best fill for the provided quantity. +func (d *bookSide) BestFill(qty uint64) ([]*Fill, bool) { + return d.bestFill(qty, false, 0) +} - if count == 0 { - break +func (d *bookSide) bestFill(quantity uint64, convert bool, lotSize uint64) ([]*Fill, bool) { + remainingQty := quantity + best := make([]*Fill, 0) + + d.iterateOrders(func(ord *Order) bool { + if remainingQty == 0 { + return false + } + + qty := ord.Quantity + if convert { + if calc.QuoteToBase(ord.Rate, remainingQty) < lotSize { + return false } + qty = calc.BaseToQuote(ord.Rate, ord.Quantity) } - case descending: - for i := len(d.rateIndex.Rates) - 1; i >= 0; i-- { - group := d.bins[d.rateIndex.Rates[i]] + var entry *Fill + if remainingQty < qty { + fillQty := remainingQty + if convert { + r := calc.QuoteToBase(ord.Rate, remainingQty) + fillQty = r - (r % lotSize) + // remainingQty -= calc.BaseToQuote(ord.Rate, ord.Quantity-fillQty) + } - for idx := 0; idx < len(group); idx++ { - if count == 0 { - break - } + // remainingQty almost certainly not zero for market buy orders, but + // set to zero to return filled=true to indicate that the order was + // exhausted before the book. + remainingQty = 0 - best = append(best, group[idx]) - count-- + entry = &Fill{ + Rate: ord.Rate, + Quantity: fillQty, } - - if count == 0 { - break + } else { + entry = &Fill{ + Rate: ord.Rate, + Quantity: ord.Quantity, } + remainingQty -= qty } - default: - panic(fmt.Sprintf("unknown order preference %v", d.orderPref)) - } + best = append(best, entry) + return true + }) - return best, len(best) == n + // Or maybe should return calc.QuoteToBase(ord.Rate, remainingQty) < lotSize + // when convert = true? + return best, remainingQty == 0 } -// BestFill returns the best fill for the provided quantity. -func (d *bookSide) BestFill(quantity uint64) ([]*fill, error) { - remainingQty := quantity - best := make([]*fill, 0) - - // Fetch the best fill for the provided quantity. - switch d.orderPref { - case ascending: - for i := 0; i < len(d.rateIndex.Rates); i++ { - bin := d.bins[d.rateIndex.Rates[i]] - - for idx := 0; idx < len(bin); idx++ { - if remainingQty == 0 { - break - } - - var entry *fill - if remainingQty < bin[idx].Quantity { - entry = &fill{ - match: bin[idx], - quantity: remainingQty, - } - remainingQty = 0 - } else { - entry = &fill{ - match: bin[idx], - quantity: bin[idx].Quantity, - } - remainingQty -= bin[idx].Quantity - } - - best = append(best, entry) - } +func (d *bookSide) idxCalculator() func(i int) int { + if d.orderPref == ascending { + return func(i int) int { return i } + } + binCount := len(d.rateIndex.Rates) + return func(i int) int { return binCount - 1 - i } +} - if remainingQty == 0 { - break - } - } +func (d *bookSide) iterateOrders(check func(*Order) bool) { + calcIdx := d.idxCalculator() - case descending: - for i := len(d.rateIndex.Rates) - 1; i >= 0; i-- { - bin := d.bins[d.rateIndex.Rates[i]] - - for idx := 0; idx < len(bin); idx++ { - if remainingQty == 0 { - break - } - - var entry *fill - if remainingQty < bin[idx].Quantity { - entry = &fill{ - match: bin[idx], - quantity: remainingQty, - } - remainingQty = 0 - } else { - entry = &fill{ - match: bin[idx], - quantity: bin[idx].Quantity, - } - remainingQty -= bin[idx].Quantity - } - - best = append(best, entry) - } + for i := 0; i < len(d.rateIndex.Rates); i++ { + bin := d.bins[d.rateIndex.Rates[calcIdx(i)]] - if remainingQty == 0 { + for idx := 0; idx < len(bin); idx++ { + if !check(bin[idx]) { break } } - - default: - return nil, fmt.Errorf("unknown order preference %v", d.orderPref) } - - return best, nil } diff --git a/client/orderbook/bookside_test.go b/client/orderbook/bookside_test.go index f6edc8be70..046314aa4f 100644 --- a/client/orderbook/bookside_test.go +++ b/client/orderbook/bookside_test.go @@ -605,8 +605,9 @@ func TestBookSideBestFill(t *testing.T) { side *bookSide quantity uint64 orderPref orderPreference - expected []*fill - wantErr bool + expected []*Fill + filled bool + marketBuy bool }{ { label: "Fetch best fill from buy book side sorted in ascending order", @@ -625,21 +626,21 @@ func TestBookSideBestFill(t *testing.T) { ascending, ), quantity: 9, - expected: []*fill{ + expected: []*Fill{ { - match: makeOrder([32]byte{'a'}, msgjson.BuyOrderNum, 5, 1, 2), - quantity: 5, + Rate: 1, + Quantity: 5, }, { - match: makeOrder([32]byte{'b'}, msgjson.BuyOrderNum, 3, 1, 5), - quantity: 3, + Rate: 1, + Quantity: 3, }, { - match: makeOrder([32]byte{'c'}, msgjson.BuyOrderNum, 1, 2, 2), - quantity: 1, + Rate: 2, + Quantity: 1, }, }, - wantErr: false, + filled: true, }, { label: "Fetch best fill from buy book side sorted in descending order", @@ -658,21 +659,21 @@ func TestBookSideBestFill(t *testing.T) { descending, ), quantity: 7, - expected: []*fill{ + expected: []*Fill{ { - match: makeOrder([32]byte{'c'}, msgjson.BuyOrderNum, 1, 2, 2), - quantity: 1, + Rate: 2, + Quantity: 1, }, { - match: makeOrder([32]byte{'d'}, msgjson.BuyOrderNum, 5, 2, 5), - quantity: 5, + Rate: 2, + Quantity: 5, }, { - match: makeOrder([32]byte{'a'}, msgjson.BuyOrderNum, 5, 1, 2), - quantity: 1, + Rate: 1, + Quantity: 1, }, }, - wantErr: false, + filled: true, }, { label: "Fetch best fill from sell book side sorted in ascending order", @@ -691,41 +692,40 @@ func TestBookSideBestFill(t *testing.T) { ascending, ), quantity: 0, - expected: []*fill{}, - wantErr: false, + expected: []*Fill{}, + filled: true, }, { label: "Fetch best fill from sell book side sorted in ascending order", side: makeBookSide( map[uint64][]*Order{ 1: { - makeOrder([32]byte{'a'}, msgjson.SellOrderNum, 5, 1, 2), + makeOrder([32]byte{'a'}, msgjson.SellOrderNum, 4, 1, 2), makeOrder([32]byte{'b'}, msgjson.SellOrderNum, 3, 1, 5), }, 2: { makeOrder([32]byte{'c'}, msgjson.SellOrderNum, 1, 2, 2), - makeOrder([32]byte{'d'}, msgjson.SellOrderNum, 5, 2, 5), }, }, makeRateIndex([]uint64{1, 2}), ascending, ), quantity: 9, - expected: []*fill{ + expected: []*Fill{ { - match: makeOrder([32]byte{'a'}, msgjson.SellOrderNum, 5, 1, 2), - quantity: 5, + Rate: 1, + Quantity: 4, }, { - match: makeOrder([32]byte{'b'}, msgjson.SellOrderNum, 3, 1, 5), - quantity: 3, + Rate: 1, + Quantity: 3, }, { - match: makeOrder([32]byte{'c'}, msgjson.SellOrderNum, 1, 2, 2), - quantity: 1, + Rate: 2, + Quantity: 1, }, }, - wantErr: false, + filled: false, }, { label: "Fetch best fill from sell book side sorted in descending order", @@ -744,81 +744,95 @@ func TestBookSideBestFill(t *testing.T) { descending, ), quantity: 50, - expected: []*fill{ + expected: []*Fill{ { - match: makeOrder([32]byte{'c'}, msgjson.SellOrderNum, 1, 2, 2), - quantity: 1, + Rate: 2, + Quantity: 1, }, { - match: makeOrder([32]byte{'d'}, msgjson.SellOrderNum, 5, 2, 5), - quantity: 5, + Rate: 2, + Quantity: 5, }, { - match: makeOrder([32]byte{'a'}, msgjson.SellOrderNum, 5, 1, 2), - quantity: 5, + Rate: 1, + Quantity: 5, }, { - match: makeOrder([32]byte{'b'}, msgjson.SellOrderNum, 3, 1, 5), - quantity: 3, + Rate: 1, + Quantity: 3, }, }, - wantErr: false, + filled: false, }, { - label: "Fetch best fill from sell book side sorted in unknown order", + label: "Fetch market buy fill", side: makeBookSide( map[uint64][]*Order{ - 1: { - makeOrder([32]byte{'a'}, msgjson.SellOrderNum, 5, 1, 2), - makeOrder([32]byte{'b'}, msgjson.SellOrderNum, 3, 1, 5), + 2e8: { + makeOrder([32]byte{'a'}, msgjson.SellOrderNum, 5, 2e8, 2), // 10 Quote asset + makeOrder([32]byte{'b'}, msgjson.SellOrderNum, 3, 2e8, 5), // 6 }, - 2: { - makeOrder([32]byte{'c'}, msgjson.SellOrderNum, 1, 2, 2), - makeOrder([32]byte{'d'}, msgjson.SellOrderNum, 5, 2, 5), + 3e8: { + makeOrder([32]byte{'c'}, msgjson.SellOrderNum, 1, 3e8, 2), // 3 + makeOrder([32]byte{'d'}, msgjson.SellOrderNum, 5, 3e8, 5), // 15 }, }, - makeRateIndex([]uint64{1, 2}), - 50, + makeRateIndex([]uint64{2e8, 3e8}), + ascending, ), - quantity: 3, - expected: nil, - wantErr: true, + quantity: 30, // 16 @ 2e8, 14 @ 3e8 + expected: []*Fill{ + { + Rate: 2e8, + Quantity: 5, + }, + { + Rate: 2e8, + Quantity: 3, + }, + { + Rate: 3e8, + Quantity: 1, + }, + { + Rate: 3e8, + Quantity: 3, // 11 remaining only covers 3 units at rate = 3. + }, + }, + marketBuy: true, + filled: true, }, } - for idx, tc := range tests { - best, err := tc.side.BestFill(tc.quantity) - if (err != nil) != tc.wantErr { - t.Fatalf("[BookSide.BestFill] #%d: error: %v, "+ - "wantErr: %v", idx+1, err, tc.wantErr) + for _, tc := range tests { + var best []*Fill + var filled bool + if tc.marketBuy { + best, filled = tc.side.bestFill(tc.quantity, true, 1) + } else { + best, filled = tc.side.BestFill(tc.quantity) } - if !tc.wantErr { - if len(best) != len(tc.expected) { - t.Fatalf("[BookSide.BestFill] #%d: expected best "+ - "order size of %d, got %d", idx+1, len(tc.expected), - len(best)) - } - - for i := 0; i < len(best); i++ { - if best[i].match.OrderID != tc.expected[i].match.OrderID { - t.Fatalf("[BookSide.BestFill] #%d: expected "+ - "order id %x at index of %d, got %x", idx+1, - tc.expected[i].match.OrderID[:], idx, - best[i].match.OrderID[:]) - } + if filled != tc.filled { + t.Fatalf("[BookSide.BestFill] %q: wrong fill. wanted %v, got %v", tc.label, tc.filled, filled) + } - if best[i].quantity != tc.expected[i].quantity { - t.Fatalf("[BookSide.BestFill] #%d: expected fill at "+ - "index %d to have quantity %d, got %d", idx+1, i, - tc.expected[i].quantity, best[i].quantity) - } + if len(best) != len(tc.expected) { + t.Fatalf("[BookSide.BestFill] %q: expected best "+ + "order size of %d, got %d", tc.label, len(tc.expected), + len(best)) + } - if best[i].match.Time != tc.expected[i].match.Time { - t.Fatalf("[BookSide.BestFill] #%d: expected "+ - "timestamp %d at index of %d, got %d", idx+1, - tc.expected[i].match.Time, idx, best[i].match.Time) - } + for i := 0; i < len(best); i++ { + if best[i].Rate != tc.expected[i].Rate { + t.Fatalf("[BookSide.BestFill] %q: expected fill at "+ + "index %d to have rate %d, got %d", tc.label, i, + tc.expected[i].Rate, best[i].Rate) + } + if best[i].Quantity != tc.expected[i].Quantity { + t.Fatalf("[BookSide.BestFill] %q: expected fill at "+ + "index %d to have quantity %d, got %d", tc.label, i, + tc.expected[i].Quantity, best[i].Quantity) } } } diff --git a/client/orderbook/orderbook.go b/client/orderbook/orderbook.go index 1815028b42..e19855b957 100644 --- a/client/orderbook/orderbook.go +++ b/client/orderbook/orderbook.go @@ -558,3 +558,18 @@ func (ob *OrderBook) MidGap() (uint64, error) { } return (s[0].Rate + b[0].Rate) / 2, nil } + +// BestFill is the best (rate, quantity) fill for an order of the type and +// quantity specified. +func (ob *OrderBook) BestFill(sell bool, qty uint64) ([]*Fill, bool) { + if sell { + return ob.buys.BestFill(qty) + } + return ob.sells.BestFill(qty) +} + +// BestFillMarketBuy is the best (rate, quantity) fill for a market buy order. +// The qty given will be in units of quote asset +func (ob *OrderBook) BestFillMarketBuy(qty, lotSize uint64) ([]*Fill, bool) { + return ob.sells.bestFill(qty, true, lotSize) +} diff --git a/client/orderbook/orderbook_test.go b/client/orderbook/orderbook_test.go index 0d7e52d71f..8e996c570d 100644 --- a/client/orderbook/orderbook_test.go +++ b/client/orderbook/orderbook_test.go @@ -3,7 +3,6 @@ package orderbook import ( "bytes" "encoding/hex" - "fmt" "testing" "decred.org/dcrdex/dex/msgjson" @@ -801,8 +800,7 @@ func TestOrderBookBestFill(t *testing.T) { orderBook *OrderBook qty uint64 side uint8 - expected []*fill - wantErr bool + expected []*Fill }{ { label: "Fetch best fill from buy side", @@ -820,25 +818,24 @@ func TestOrderBookBestFill(t *testing.T) { ), qty: 24, side: msgjson.BuyOrderNum, - expected: []*fill{ + expected: []*Fill{ { - match: makeOrder([32]byte{'e'}, msgjson.BuyOrderNum, 8, 4, 12), - quantity: 8, + Rate: 4, + Quantity: 8, }, { - match: makeOrder([32]byte{'d'}, msgjson.BuyOrderNum, 5, 3, 10), - quantity: 5, + Rate: 3, + Quantity: 5, }, { - match: makeOrder([32]byte{'c'}, msgjson.BuyOrderNum, 10, 2, 5), - quantity: 10, + Rate: 2, + Quantity: 10, }, { - match: makeOrder([32]byte{'b'}, msgjson.BuyOrderNum, 10, 1, 2), - quantity: 1, + Rate: 1, + Quantity: 1, }, }, - wantErr: false, }, { label: "Fetch best fill from empty buy side", @@ -856,8 +853,7 @@ func TestOrderBookBestFill(t *testing.T) { ), qty: 24, side: msgjson.BuyOrderNum, - expected: []*fill{}, - wantErr: false, + expected: []*Fill{}, }, { label: "Fetch best fill (order book total less than fill quantity) from buy side", @@ -873,84 +869,49 @@ func TestOrderBookBestFill(t *testing.T) { ), qty: 40, side: msgjson.BuyOrderNum, - expected: []*fill{ + expected: []*Fill{ { - match: makeOrder([32]byte{'c'}, msgjson.BuyOrderNum, 10, 2, 5), - quantity: 10, + Rate: 2, + Quantity: 10, }, { - match: makeOrder([32]byte{'b'}, msgjson.BuyOrderNum, 10, 1, 2), - quantity: 10, + Rate: 1, + Quantity: 10, }, }, - wantErr: false, - }, - { - label: "Fetch best fill from unsynced order book", - orderBook: makeOrderBook( - 2, - "ob", - []*Order{ - makeOrder([32]byte{'b'}, msgjson.SellOrderNum, 10, 1, 2), - makeOrder([32]byte{'c'}, msgjson.SellOrderNum, 10, 2, 5), - }, - make([]*cachedOrderNote, 0), - false, - ), - qty: 10, - side: msgjson.SellOrderNum, - expected: nil, - wantErr: true, }, } // bestFill returns the best fill for a quantity from the provided side. - bestFill := func(ob *OrderBook, qty uint64, side uint8) ([]*fill, error) { - if !ob.isSynced() { - return nil, fmt.Errorf("order book is not synced") - } - + bestFill := func(ob *OrderBook, qty uint64, side uint8) ([]*Fill, bool) { switch side { case msgjson.BuyOrderNum: return ob.buys.BestFill(qty) case msgjson.SellOrderNum: return ob.sells.BestFill(qty) - default: - return nil, fmt.Errorf("unknown side: %d", side) } + return nil, false } for idx, tc := range tests { - best, err := bestFill(tc.orderBook, tc.qty, tc.side) - if (err != nil) != tc.wantErr { - t.Fatalf("[OrderBook.BestFill] #%d: error: %v, wantErr: %v", - idx+1, err, tc.wantErr) + best, _ := bestFill(tc.orderBook, tc.qty, tc.side) + + if len(best) != len(tc.expected) { + t.Fatalf("[OrderBook.BestFill] #%d: expected best fill "+ + "size of %d, got %d", idx+1, len(tc.expected), len(best)) } - if !tc.wantErr { - if len(best) != len(tc.expected) { - t.Fatalf("[OrderBook.BestFill] #%d: expected best fill "+ - "size of %d, got %d", idx+1, len(tc.expected), len(best)) + for i := 0; i < len(best); i++ { + if best[i].Rate != tc.expected[i].Rate { + t.Fatalf("[OrderBook.BestFill] #%d: expected fill at "+ + "index %d to have rate %d, got %d", idx+1, i, + tc.expected[i].Rate, best[i].Rate) } - for i := 0; i < len(best); i++ { - if !bytes.Equal(best[i].match.OrderID[:], tc.expected[i].match.OrderID[:]) { - t.Fatalf("[OrderBook.BestFill] #%d: expected fill at "+ - "index %d to be %x, got %x", idx+1, i, - tc.expected[i].match.OrderID[:], best[i].match.OrderID[:]) - } - - if best[i].quantity != tc.expected[i].quantity { - t.Fatalf("[OrderBook.BestFill] #%d: expected fill at "+ - "index %d to have quantity %d, got %d", idx+1, i, - tc.expected[i].quantity, best[i].quantity) - } - - if best[i].match.Time != tc.expected[i].match.Time { - t.Fatalf("[OrderBook.BestFill] #%d: expected fill at "+ - "index %d to have match timestamp %d, got %d", idx+1, i, - tc.expected[i].match.Time, best[i].match.Time) - } + if best[i].Quantity != tc.expected[i].Quantity { + t.Fatalf("[OrderBook.BestFill] #%d: expected fill at "+ + "index %d to have quantity %d, got %d", idx+1, i, + tc.expected[i].Quantity, best[i].Quantity) } } } diff --git a/client/webserver/api.go b/client/webserver/api.go index 8db279efd3..2e550695e0 100644 --- a/client/webserver/api.go +++ b/client/webserver/api.go @@ -515,8 +515,8 @@ func (s *WebServer) apiMaxBuy(w http.ResponseWriter, r *http.Request) { return } resp := struct { - OK bool `json:"ok"` - MaxBuy *core.OrderEstimate `json:"maxBuy"` + OK bool `json:"ok"` + MaxBuy *core.MaxOrderEstimate `json:"maxBuy"` }{ OK: true, MaxBuy: maxBuy, @@ -540,8 +540,8 @@ func (s *WebServer) apiMaxSell(w http.ResponseWriter, r *http.Request) { return } resp := struct { - OK bool `json:"ok"` - MaxSell *core.OrderEstimate `json:"maxSell"` + OK bool `json:"ok"` + MaxSell *core.MaxOrderEstimate `json:"maxSell"` }{ OK: true, MaxSell: maxSell, diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index 033094eafe..4c24078dde 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -409,34 +409,49 @@ func (c *TCore) Orders(filter *core.OrderFilter) ([]*core.Order, error) { return cords, nil } -func (c *TCore) MaxBuy(host string, base, quote uint32, rate uint64) (*core.OrderEstimate, error) { +func (c *TCore) MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) { mktID, _ := dex.MarketName(base, quote) midGap, maxQty := getMarketStats(mktID) ord := randomOrder(rand.Float32() > 0.5, maxQty, midGap, gapWidthFactor*midGap, false) qty := toAtoms(ord.Qty) quoteQty := calc.BaseToQuote(rate, qty) - return &core.OrderEstimate{ - Lots: qty / tExchanges[host].Assets[base].LotSize, - Value: quoteQty, - MaxFees: quoteQty / 100, - EstimatedFees: quoteQty / 200, - Locked: quoteQty, - RedemptionFees: qty / 300, + return &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: qty / tExchanges[host].Assets[base].LotSize, + Value: quoteQty, + MaxFees: quoteQty / 100, + RealisticWorstCase: quoteQty / 200, + RealisticBestCase: quoteQty / 300, + Locked: quoteQty, + }, + Redeem: &asset.RedeemEstimate{ + RealisticWorstCase: qty / 300, + RealisticBestCase: qty / 400, + }, }, nil } -func (c *TCore) MaxSell(host string, base, quote uint32) (*core.OrderEstimate, error) { +func (c *TCore) MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error) { mktID, _ := dex.MarketName(base, quote) midGap, maxQty := getMarketStats(mktID) ord := randomOrder(rand.Float32() > 0.5, maxQty, midGap, gapWidthFactor*midGap, false) qty := toAtoms(ord.Qty) - return &core.OrderEstimate{ - Lots: qty / tExchanges[host].Assets[base].LotSize, - Value: qty, - MaxFees: qty / 100, - EstimatedFees: qty / 200, - Locked: qty, - RedemptionFees: 1, + + quoteQty := calc.BaseToQuote(toAtoms(midGap), qty) + + return &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: qty / tExchanges[host].Assets[base].LotSize, + Value: qty, + MaxFees: qty / 100, + RealisticWorstCase: qty / 200, + RealisticBestCase: qty / 300, + Locked: qty, + }, + Redeem: &asset.RedeemEstimate{ + RealisticWorstCase: quoteQty / 300, + RealisticBestCase: quoteQty / 400, + }, }, nil } @@ -1116,7 +1131,7 @@ func TestServer(t *testing.T) { numSells = 10 feedPeriod = 5000 * time.Millisecond initialize := false - register := false + register := true forceDisconnectWallet = true gapWidthFactor = 0.2 randomPokes = true diff --git a/client/webserver/site/src/js/markets.js b/client/webserver/site/src/js/markets.js index 1a408661cf..97bac4b8c2 100644 --- a/client/webserver/site/src/js/markets.js +++ b/client/webserver/site/src/js/markets.js @@ -170,7 +170,7 @@ export default class MarketsPage extends BasePage { }) bind(page.maxOrd, 'click', () => { if (this.isSell()) page.lotField.value = this.market.maxSell.lots - else page.lotField.value = this.market.maxBuys[this.adjustedRate()].lots + else page.lotField.value = this.market.maxBuys[this.adjustedRate()].swap.lots this.lotChanged() }) @@ -613,16 +613,19 @@ export default class MarketsPage extends BasePage { preSell () { const mkt = this.market const baseWallet = app.assets[mkt.base.id].wallet - if (baseWallet.available < mkt.baseCfg.lotSize) mkt.maxSell = { lots: 0, value: 0 } + if (baseWallet.available < mkt.baseCfg.lotSize) { + this.setMaxOrder(0, this.adjustedRate() / 1e8) + return + } if (mkt.maxSell) { - this.setMaxOrder(mkt.maxSell, this.adjustedRate() / 1e8) + this.setMaxOrder(mkt.maxSell.swap, this.adjustedRate() / 1e8) return } // We only fetch pre-sell once per balance update, so don't delay. this.schedulePreOrder('/api/maxsell', {}, 0, res => { mkt.maxSell = res.maxSell mkt.sellBalance = baseWallet.balance.available - this.setMaxOrder(res.maxSell, this.adjustedRate() / 1e8) + this.setMaxOrder(res.maxSell.swap, this.adjustedRate() / 1e8) }) } @@ -634,9 +637,12 @@ export default class MarketsPage extends BasePage { const rate = this.adjustedRate() const quoteWallet = app.assets[mkt.quote.id].wallet const aLot = mkt.baseCfg.lotSize * (rate / 1e8) - if (quoteWallet.balance.available < aLot) mkt.maxBuys[rate] = { lots: 0, value: 0 } + if (quoteWallet.balance.available < aLot) { + this.setMaxOrder(0, 1e8 / rate) + return + } if (mkt.maxBuys[rate]) { - this.setMaxOrder(mkt.maxBuys[rate], 1e8 / rate) + this.setMaxOrder(mkt.maxBuys[rate].swap, 1e8 / rate) return } // 0 delay for first fetch after balance update or market change, otherwise @@ -645,7 +651,7 @@ export default class MarketsPage extends BasePage { this.schedulePreOrder('/api/maxbuy', { rate: rate }, delay, res => { mkt.maxBuys[rate] = res.maxBuy mkt.buyBalance = app.assets[mkt.quote.id].wallet.balance.available - this.setMaxOrder(res.maxBuy, 1e8 / rate) + this.setMaxOrder(res.maxBuy.swap, 1e8 / rate) }) } diff --git a/client/webserver/webserver.go b/client/webserver/webserver.go index 7b978b3c9f..cf78425882 100644 --- a/client/webserver/webserver.go +++ b/client/webserver/webserver.go @@ -88,8 +88,8 @@ type clientCore interface { Logout() error Orders(*core.OrderFilter) ([]*core.Order, error) Order(oid dex.Bytes) (*core.Order, error) - MaxBuy(host string, base, quote uint32, rate uint64) (*core.OrderEstimate, error) - MaxSell(host string, base, quote uint32) (*core.OrderEstimate, error) + MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) + MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error) AccountExport(pw []byte, host string) (*core.Account, error) AccountImport(pw []byte, account core.Account) error } diff --git a/client/webserver/webserver_test.go b/client/webserver/webserver_test.go index 920c1d02dc..6e8eea5a66 100644 --- a/client/webserver/webserver_test.go +++ b/client/webserver/webserver_test.go @@ -139,10 +139,13 @@ func (c *TCore) Logout() error { return c.logoutErr } func (c *TCore) Orders(*core.OrderFilter) ([]*core.Order, error) { return nil, nil } func (c *TCore) Order(oid dex.Bytes) (*core.Order, error) { return nil, nil } -func (c *TCore) MaxBuy(host string, base, quote uint32, rate uint64) (*core.OrderEstimate, error) { +func (c *TCore) MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) { return nil, nil } -func (c *TCore) MaxSell(host string, base, quote uint32) (*core.OrderEstimate, error) { +func (c *TCore) MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error) { + return nil, nil +} +func (c *TCore) PreOrder(*core.TradeForm) (*core.OrderEstimate, error) { return nil, nil } func (c *TCore) AccountExport(pw []byte, host string) (*core.Account, error) { diff --git a/go.sum b/go.sum index 9c23ac4aba..74466c631e 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,5 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +decred.org/cspp v0.3.0 h1:2AkSsWzA7HIMZImfw0gT82Gdp8OXIM4NsBn7vna22uE= decred.org/cspp v0.3.0/go.mod h1:UygjYilC94dER3BEU65Zzyoqy9ngJfWCD2rdJqvUs2A= decred.org/dcrwallet v1.6.0-rc4 h1:5IT6mFa+2YMqenu6aE2LetD0N8QSUVFyAFl205PvIIE= decred.org/dcrwallet v1.6.0-rc4/go.mod h1:lsrNbuKxkPGeHXPufxNTckwQopCEDz0r3t0a8JCKAmU= @@ -35,9 +36,11 @@ github.com/dchest/siphash v1.2.1 h1:4cLinnzVJDKxTCl9B01807Yiy+W7ZzVHj/KIroQRvT4= github.com/dchest/siphash v1.2.1/go.mod h1:q+IRvb2gOSrUnYoPqHiyHXS0FOBBOdl6tONBlVnOnt4= github.com/decred/base58 v1.0.3 h1:KGZuh8d1WEMIrK0leQRM47W85KqCAdl2N+uagbctdDI= github.com/decred/base58 v1.0.3/go.mod h1:pXP9cXCfM2sFLb2viz2FNIdeMWmZDBKG3ZBYbiSM78E= +github.com/decred/dcrd/addrmgr v1.2.0 h1:wN9bvDFHOus1J4s3KUtnsyiebNoi6w5R6INbP+JH0gI= github.com/decred/dcrd/addrmgr v1.2.0/go.mod h1:QlZF9vkzwYh0qs25C76SAFZBRscjETga/K28GEE6qIc= github.com/decred/dcrd/blockchain/stake/v3 v3.0.0 h1:vr0o0ICjuEzg1End6YtBfwgDuPkg+FYIwGVEz18kFg0= github.com/decred/dcrd/blockchain/stake/v3 v3.0.0/go.mod h1:5GIUwsrHQCJauacgCegIR6t92SaeVi28Qls/BLN9vOw= +github.com/decred/dcrd/blockchain/standalone/v2 v2.0.0 h1:9gUuH0u/IZNPWBK9K3CxgAWPG7nTqVSsZefpGY4Okns= github.com/decred/dcrd/blockchain/standalone/v2 v2.0.0/go.mod h1:t2qaZ3hNnxHZ5kzVJDgW5sp47/8T5hYJt7SR+/JtRhI= github.com/decred/dcrd/blockchain/v3 v3.0.2/go.mod h1:LD5VA95qdb+DlRiPI8VLBimDqvlDCAJsidZ5oD6nc/U= github.com/decred/dcrd/certgen v1.1.1 h1:MYPG5jCysnbF4OiJ1++YumFEu2p/MsM/zxmmqC9mVFg= @@ -46,6 +49,7 @@ github.com/decred/dcrd/chaincfg/chainhash v1.0.2 h1:rt5Vlq/jM3ZawwiacWjPa+smINyL github.com/decred/dcrd/chaincfg/chainhash v1.0.2/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60= github.com/decred/dcrd/chaincfg/v3 v3.0.0 h1:+TFbu7ZmvBwM+SZz5mrj6cun9ts/6DAL5sqnsaFBHGQ= github.com/decred/dcrd/chaincfg/v3 v3.0.0/go.mod h1:EspyubQ7D2w6tjP7rBGDIE7OTbuMgBjR2F2kZFnh31A= +github.com/decred/dcrd/connmgr/v3 v3.0.0 h1:Z0fWa3PYIzaxAM5SHTC+wqJVHgoWwP4TSDTvNek/PGY= github.com/decred/dcrd/connmgr/v3 v3.0.0/go.mod h1:cPI43Aggp1lOhrVG75eJ3c3BwuFx0NhT77FK34ky+ak= github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= @@ -117,10 +121,12 @@ github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.1-0.20200711081900-c17162fe8fd7 h1:Ug59miTxVKVg5Oi2S5uHlKOIV5jBx4Hb2u0jIxxDaSs= github.com/jessevdk/go-flags v1.4.1-0.20200711081900-c17162fe8fd7/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jrick/bitset v1.0.0 h1:Ws0PXV3PwXqWK2n7Vz6idCdrV/9OrBXgHEJi27ZB9Dw= github.com/jrick/bitset v1.0.0/go.mod h1:ZOYB5Uvkla7wIEY4FEssPVi3IQXa02arznRaYaAEPe4= github.com/jrick/logrotate v1.0.0 h1:lQ1bL/n9mBNeIXoTUoYRlK4dHuNJVofX9oWqBtPnSzI= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/jrick/wsrpc/v2 v2.3.2/go.mod h1:XPYs8BnRWl99lCvXRM5SLpZmTPqWpSOPkDIqYTwDPfU= +github.com/jrick/wsrpc/v2 v2.3.4 h1:+GzRtp/TyXaSB61pN92lIAVyvdVv0RSqniIEB/rPx1Q= github.com/jrick/wsrpc/v2 v2.3.4/go.mod h1:XPYs8BnRWl99lCvXRM5SLpZmTPqWpSOPkDIqYTwDPfU= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= @@ -211,10 +217,12 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55 h1:gSJIx1SDwno+2ElGhA4+qG2zF97qiUzTM+rQ0klBOcE= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.32.0 h1:zWTV+LMdc3kaiJMSTOFz2UgSBgx8RNQoTGiZu3fR9S0= google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= From 6df11e5259d45b900ae7ebba76999fc66e7c2db8 Mon Sep 17 00:00:00 2001 From: buck54321 Date: Tue, 2 Feb 2021 14:58:26 -0600 Subject: [PATCH 2/5] fix math --- client/asset/btc/btc.go | 8 ++++---- client/asset/dcr/dcr.go | 10 ++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index b426b9c8e2..617213cfb2 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -817,17 +817,17 @@ func (btc *ExchangeWallet) estimateSwap(lots, lotSize, networkFeeRate uint64, ut func (btc *ExchangeWallet) PreRedeem(req *asset.PreRedeemForm) (*asset.PreRedeem, error) { feeRate := btc.feeRateWithFallback(btc.redeemConfTarget) // Best is one transaction with req.Lots inputs and 1 output. - var best uint64 = dexbtc.MinimumTxOverhead + dexbtc.TxInOverhead + dexbtc.TxOutOverhead + var best uint64 = dexbtc.MinimumTxOverhead // Worst is req.Lots transactions, each with one input and one output. - var worst uint64 = best * req.Lots + var worst uint64 = dexbtc.MinimumTxOverhead * req.Lots var inputSize, outputSize uint64 if btc.segwit { // Add the marker and flag weight here. - inputSize = (dexbtc.RedeemSwapSigScriptSize + 2 + 3) / 4 + inputSize = dexbtc.TxInOverhead + (dexbtc.RedeemSwapSigScriptSize+2+3)/4 outputSize = dexbtc.P2WPKHOutputSize } else { - inputSize = dexbtc.RedeemSwapSigScriptSize + inputSize = dexbtc.TxInOverhead + dexbtc.RedeemSwapSigScriptSize outputSize = dexbtc.P2PKHOutputSize } best += inputSize*req.Lots + outputSize diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 20c0d86111..bc68154235 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -810,11 +810,13 @@ func (dcr *ExchangeWallet) PreSwap(req *asset.PreSwapForm) (*asset.PreSwap, erro func (dcr *ExchangeWallet) PreRedeem(req *asset.PreRedeemForm) (*asset.PreRedeem, error) { feeRate := dcr.feeRateWithFallback(dcr.redeemConfTarget) // Best is one transaction with req.Lots inputs and 1 output. - var best uint64 = dexdcr.MsgTxOverhead + dexdcr.TxInOverhead + dexdcr.TxOutOverhead + var best uint64 = dexdcr.MsgTxOverhead // Worst is req.Lots transactions, each with one input and one output. - var worst uint64 = best * req.Lots - best += dexdcr.RedeemSwapSigScriptSize*req.Lots + dexdcr.P2PKHOutputSize - worst += (dexdcr.RedeemSwapSigScriptSize + dexdcr.P2PKHOutputSize) * req.Lots + var worst uint64 = dexdcr.MsgTxOverhead * req.Lots + var inputSize uint64 = dexdcr.TxInOverhead + dexdcr.RedeemSwapSigScriptSize + var outputSize uint64 = dexdcr.P2PKHOutputSize + best += inputSize*req.Lots + outputSize + worst += (inputSize + outputSize) * req.Lots return &asset.PreRedeem{ Estimate: &asset.RedeemEstimate{ From 4b100b111ca19534325cddc78d1b88e0711d3b1f Mon Sep 17 00:00:00 2001 From: buck54321 Date: Fri, 12 Feb 2021 15:22:26 -0600 Subject: [PATCH 3/5] review followup --- client/asset/btc/btc.go | 13 +++++--- client/asset/btc/btc_test.go | 8 ++--- client/asset/dcr/dcr.go | 14 ++++---- client/asset/dcr/dcr_test.go | 4 +-- client/asset/interface.go | 12 +++---- client/core/bookie.go | 48 +++++++++++---------------- client/core/core.go | 62 +++++++++++++++++------------------ client/core/core_test.go | 7 ++-- client/orderbook/orderbook.go | 6 ++-- 9 files changed, 87 insertions(+), 87 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 617213cfb2..3c6f0ed4cc 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -713,7 +713,7 @@ func (btc *ExchangeWallet) maxOrder(lotSize uint64, nfo *dex.Asset) (utxos []*co utxos, _, avail, err := btc.spendableUTXOs(0) btc.fundingMtx.RUnlock() if err != nil { - return nil, 0, networkFeeRate, nil, fmt.Errorf("error parsing unspent outputs: %w", err) + return nil, 0, 0, nil, fmt.Errorf("error parsing unspent outputs: %w", err) } // Start by attempting max lots with no fees. lots = avail / lotSize @@ -734,7 +734,13 @@ func (btc *ExchangeWallet) maxOrder(lotSize uint64, nfo *dex.Asset) (utxos []*co // PreSwap get order estimates based on the available funds and the wallet // configuration. func (btc *ExchangeWallet) PreSwap(req *asset.PreSwapForm) (*asset.PreSwap, error) { - // Start with the maxOrder at the default configuration. + // 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. utxos, maxLots, feeRate, _, err := btc.maxOrder(req.LotSize, req.AssetConfig) if err != nil { return nil, err @@ -743,7 +749,7 @@ func (btc *ExchangeWallet) PreSwap(req *asset.PreSwapForm) (*asset.PreSwap, erro return nil, fmt.Errorf("%d lots available for %d-lot order", maxLots, req.Lots) } - // Get the estimate at using the current configuration + // Get the estimate for the requested number of lots. est, _, err := btc.estimateSwap(req.Lots, req.LotSize, feeRate, utxos, req.AssetConfig, btc.useSplitTx) if err != nil { return nil, fmt.Errorf("estimation failed: %v", err) @@ -917,7 +923,6 @@ func (btc *ExchangeWallet) fund(val, lots uint64, utxos []*compositeUTXO, nfo *d isEnoughWith := func(unspent *compositeUTXO) bool { reqFunds := calc.RequiredOrderFunds(val, uint64(size+unspent.input.VBytes()), lots, nfo) - // reqFunds := calc.RequiredOrderFunds(val, uint64(size+unspent.input.VBytes()), lots, nfo) return sum+unspent.amount >= reqFunds } diff --git a/client/asset/btc/btc_test.go b/client/asset/btc/btc_test.go index 4077ea8ec2..adf20421b9 100644 --- a/client/asset/btc/btc_test.go +++ b/client/asset/btc/btc_test.go @@ -947,10 +947,10 @@ func TestFundEdges(t *testing.T) { // lot_size: 1e6 // swap_value: 1e7 // lots = swap_value / lot_size = 10 + // total_bytes = first_swap_size + chained_swap_sizes // chained_swap_sizes = (lots - 1) * swap_size // first_swap_size = swap_size_base + backing_bytes - // total_bytes = first_swap_size + chained_swap_sizes - // total_bytes = swap_size_base + backing_bytes + (lots - 1) * swap_size + // total_bytes = swap_size_base + backing_bytes + (lots - 1) * swap_size // base_tx_bytes = total_bytes - backing_bytes // base_tx_bytes = (lots - 1) * swap_size + swap_size_base = 9 * 225 + 76 = 2101 // base_fees = base_tx_bytes * fee_rate = 2101 * 34 = 71434 @@ -2378,14 +2378,14 @@ func TestPreSwap(t *testing.T) { setFunds(minReq - 1) _, err = wallet.PreSwap(form) if err == nil { - t.Fatalf("no PreSwap error for not enough funds: %v", err) + t.Fatalf("no PreSwap error for not enough funds") } setFunds(minReq) node.estFeeErr = tErr _, err = wallet.PreSwap(form) if err == nil { - t.Fatalf("no PreSwap error for not enough funds: %v", err) + t.Fatalf("no PreSwap error for estimatesmartfee error") } node.estFeeErr = nil diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index bc68154235..0a6a065613 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -738,10 +738,6 @@ func (dcr *ExchangeWallet) estimateSwap(lots, lotSize, networkFeeRate uint64, ut val := lots * lotSize sum, inputsSize, _, _, _, err := dcr.tryFund(utxos, orderEnough(val, lots, nfo)) - - // The only failure mode of dcr.tryFund is when there is not enough funds, - // so if an error is encountered, count down the lots and repeat until - // we have enough. if err != nil { return nil, false, err } @@ -785,7 +781,13 @@ func (dcr *ExchangeWallet) estimateSwap(lots, lotSize, networkFeeRate uint64, ut // PreSwap get order estimates based on the available funds and the wallet // configuration. func (dcr *ExchangeWallet) PreSwap(req *asset.PreSwapForm) (*asset.PreSwap, error) { - // Start with the maxOrder at the default configuration. + // 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. utxos, maxLots, feeRate, _, err := dcr.maxOrder(req.LotSize, req.AssetConfig) if err != nil { return nil, err @@ -794,7 +796,7 @@ func (dcr *ExchangeWallet) PreSwap(req *asset.PreSwapForm) (*asset.PreSwap, erro return nil, fmt.Errorf("%d lots available for %d-lot order", maxLots, req.Lots) } - // Get the estimate at using the current configuration + // Get the estimate for the requested number of lots. est, _, err := dcr.estimateSwap(req.Lots, req.LotSize, feeRate, utxos, req.AssetConfig, dcr.useSplitTx) if err != nil { return nil, fmt.Errorf("estimation failed: %v", err) diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index 9a591994d9..965b5eaf77 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -2159,14 +2159,14 @@ func TestPreSwap(t *testing.T) { node.unspent[0].Amount = float64(minReq-1) / 1e8 _, err = wallet.PreSwap(form) if err == nil { - t.Fatalf("no PreSwap error for not enough funds: %v", err) + t.Fatalf("no PreSwap error for not enough funds") } node.unspent[0].Amount = float64(minReq) / 1e8 node.estFeeErr = tErr _, err = wallet.PreSwap(form) if err == nil { - t.Fatalf("no PreSwap error for not enough funds: %v", err) + t.Fatalf("no PreSwap error for estimatesmartfee error") } node.estFeeErr = nil diff --git a/client/asset/interface.go b/client/asset/interface.go index a4c76bba2a..f19f6ba5b3 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -89,9 +89,9 @@ type Wallet interface { // the fees associated with the Asset.MaxFeeRate. For quote assets, lotSize // will be an estimate based on current market conditions. MaxOrder(lotSize uint64, nfo *dex.Asset) (*SwapEstimate, error) - // PreSwap gets an OrderEstimate for the specified order size(s). + // PreSwap gets a pre-swap estimate for the specified order size. PreSwap(*PreSwapForm) (*PreSwap, error) - // PreRedeem gets an OrderEstimate for the specified order size(s). + // PreRedeem gets a pre-redeem estimate for the specified order size. PreRedeem(*PreRedeemForm) (*PreRedeem, error) // ReturnCoins unlocks coins. This would be necessary in the case of a // canceled order. @@ -333,8 +333,8 @@ type RedeemEstimate struct { // PreSwapForm can be used to get a swap fees estimate. type PreSwapForm struct { // LotSize is the lot size for the calculation. For quote assets, LotSize - // will can be based on either the user's limit order rate, or some measure - // of the current market conditions. + // should be based on either the user's limit order rate, or some measure + // of the current market rate. LotSize uint64 // Lots is the number of lots in the order. Lots uint64 @@ -363,8 +363,8 @@ type PreSwap struct { // PreRedeemForm can be used to get a redemption estimate. type PreRedeemForm struct { // LotSize is the lot size for the calculation. For quote assets, LotSize - // will can be based on either the user's limit order rate, or some measure - // of the current market conditions. + // should be based on either the user's limit order rate, or some measure + // of the current market rate. LotSize uint64 // Lots is the number of lots in the order. Lots uint64 diff --git a/client/core/bookie.go b/client/core/bookie.go index 56e311b772..fb4f85e449 100644 --- a/client/core/bookie.go +++ b/client/core/bookie.go @@ -166,6 +166,13 @@ func (b *bookie) book() *OrderBook { } } +// bookie gets the bookie for the market, if it exists, else nil. +func (dc *dexConnection) bookie(marketID string) *bookie { + dc.booksMtx.RLock() + defer dc.booksMtx.RUnlock() + return dc.books[marketID] +} + // syncBook subscribes to the order book and returns the book and a BookFeed to // receive order book updates. The BookFeed must be Close()d when it is no // longer in use. Use stopBook to unsubscribed and clean up the feed. @@ -379,11 +386,8 @@ func handleBookOrderMsg(_ *Core, dc *dexConnection, msg *msgjson.Message) error return fmt.Errorf("book order note unmarshal error: %w", err) } - dc.booksMtx.RLock() - defer dc.booksMtx.RUnlock() - - book, ok := dc.books[note.MarketID] - if !ok { + book := dc.bookie(note.MarketID) + if book == nil { return fmt.Errorf("no order book found with market id '%v'", note.MarketID) } @@ -483,11 +487,8 @@ func handleTradeSuspensionMsg(c *Core, dc *dexConnection, msg *msgjson.Message) } // Clear the book and unbook/revoke own orders. - dc.booksMtx.RLock() - defer dc.booksMtx.RUnlock() - - book, ok := dc.books[sp.MarketID] - if !ok { + book := dc.bookie(sp.MarketID) + if book == nil { return fmt.Errorf("no order book found with market id '%s'", sp.MarketID) } @@ -622,11 +623,8 @@ func handleUnbookOrderMsg(_ *Core, dc *dexConnection, msg *msgjson.Message) erro return fmt.Errorf("unbook order note unmarshal error: %w", err) } - dc.booksMtx.RLock() - defer dc.booksMtx.RUnlock() - - book, ok := dc.books[note.MarketID] - if !ok { + book := dc.bookie(note.MarketID) + if book == nil { return fmt.Errorf("no order book found with market id %q", note.MarketID) } @@ -653,11 +651,8 @@ func handleUpdateRemainingMsg(_ *Core, dc *dexConnection, msg *msgjson.Message) return fmt.Errorf("book order note unmarshal error: %w", err) } - dc.booksMtx.RLock() - defer dc.booksMtx.RUnlock() - - book, ok := dc.books[note.MarketID] - if !ok { + book := dc.bookie(note.MarketID) + if book == nil { return fmt.Errorf("no order book found with market id '%v'", note.MarketID) } @@ -684,10 +679,8 @@ func handleEpochReportMsg(_ *Core, dc *dexConnection, msg *msgjson.Message) erro if err != nil { return fmt.Errorf("epoch report note unmarshal error: %w", err) } - dc.booksMtx.RLock() - defer dc.booksMtx.RUnlock() - book, ok := dc.books[note.MarketID] - if !ok { + book := dc.bookie(note.MarketID) + if book == nil { return fmt.Errorf("no order book found with market id '%v'", note.MarketID) } @@ -707,11 +700,8 @@ func handleEpochOrderMsg(c *Core, dc *dexConnection, msg *msgjson.Message) error return fmt.Errorf("epoch order note unmarshal error: %w", err) } - dc.booksMtx.RLock() - defer dc.booksMtx.RUnlock() - - book, ok := dc.books[note.MarketID] - if !ok { + book := dc.bookie(note.MarketID) + if book == nil { return fmt.Errorf("no order book found with market id %q", note.MarketID) } diff --git a/client/core/core.go b/client/core/core.go index 09bba903fb..38c3f5b99b 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -20,12 +20,11 @@ import ( "sync/atomic" "time" - "decred.org/dcrdex/client/orderbook" - "decred.org/dcrdex/client/asset" "decred.org/dcrdex/client/comms" "decred.org/dcrdex/client/db" "decred.org/dcrdex/client/db/bolt" + "decred.org/dcrdex/client/orderbook" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" "decred.org/dcrdex/dex/config" @@ -87,7 +86,7 @@ type dexConnection struct { epochMtx sync.RWMutex epoch map[string]uint64 // connected is a best guess on the ws connection status. - connected bool + connected uint32 regConfMtx sync.RWMutex regConfirms *uint32 // nil regConfirms means no pending registration. @@ -762,8 +761,8 @@ func (c *Core) dex(addr string) (*dexConnection, bool, error) { // Get the dexConnection and the dex.Asset for each asset. c.connMtx.RLock() dc, found := c.conns[host] - connected := found && dc.connected c.connMtx.RUnlock() + connected := found && atomic.LoadUint32(&dc.connected) == 1 if !found { return nil, false, fmt.Errorf("unknown DEX %s", addr) } @@ -1087,7 +1086,7 @@ func (c *Core) exchangeMap() map[string]*Exchange { Markets: dc.marketMap(), Assets: assets, FeePending: dc.acct.feePending(), - Connected: dc.connected, + Connected: atomic.LoadUint32(&dc.connected) == 1, ConfsRequired: requiredConfs, RegConfirms: dc.getRegConfirms(), } @@ -2381,10 +2380,10 @@ func (c *Core) MaxBuy(host string, base, quote uint32, rate uint64) (*MaxOrderEs preRedeem, err := baseWallet.PreRedeem(&asset.PreRedeemForm{ LotSize: baseAsset.LotSize, - Lots: 1, + Lots: maxBuy.Lots, }) if err != nil { - return nil, fmt.Errorf("%s RedemptionFees error: %v", unbip(base), err) + return nil, fmt.Errorf("%s PreRedeem error: %v", unbip(base), err) } return &MaxOrderEstimate{ @@ -2396,7 +2395,7 @@ func (c *Core) MaxBuy(host string, base, quote uint32, rate uint64) (*MaxOrderEs // MaxSell is the maximum-sized *OrderEstimate for a sell order on the specified // market. func (c *Core) MaxSell(host string, base, quote uint32) (*MaxOrderEstimate, error) { - baseAsset, quoteAsset, baseWallet, quoteWallet, err := c.marketWallets(host, base, quote) + baseAsset, _, baseWallet, quoteWallet, err := c.marketWallets(host, base, quote) if err != nil { return nil, err } @@ -2412,21 +2411,23 @@ func (c *Core) MaxSell(host string, base, quote uint32) (*MaxOrderEstimate, erro } // Estimate a quote-converted lot size. - lotSize := quoteAsset.LotSize - book, found := dc.books[marketName(base, quote)] - if found { - midGap, err := book.MidGap() - if err == nil { - lotSize = calc.BaseToQuote(midGap, baseAsset.LotSize) - } + mktID := marketName(base, quote) + book := dc.bookie(mktID) + if book == nil { + return nil, fmt.Errorf("no book synced for %s at %s", mktID, host) } + midGap, err := book.MidGap() + if err != nil { + return nil, fmt.Errorf("error calculating market rate for %s at %s: %v", mktID, host, err) + } + lotSize := calc.BaseToQuote(midGap, baseAsset.LotSize) preRedeem, err := quoteWallet.PreRedeem(&asset.PreRedeemForm{ LotSize: lotSize, - Lots: 1, + Lots: maxSell.Lots, }) if err != nil { - return nil, fmt.Errorf("%s RedemptionFees error: %v", unbip(base), err) + return nil, fmt.Errorf("%s PreRedeem error: %v", unbip(quote), err) } return &MaxOrderEstimate{ @@ -2723,8 +2724,8 @@ func (c *Core) PreOrder(form *TradeForm) (*OrderEstimate, error) { if !form.IsLimit { // If this is a market order, we'll predict the fill price. - book, found := dc.books[marketName(form.Base, form.Quote)] - if !found { + book := dc.bookie(marketName(form.Base, form.Quote)) + if book == nil { return nil, fmt.Errorf("Cannot estimate market order without a synced book") } @@ -2881,8 +2882,8 @@ func (c *Core) prepareTrackedTrade(dc *dexConnection, form *TradeForm, crypter e // the knowledge that such an estimate means that the specified amount // might not all be available for matching once fees are considered. lots = 1 - book, found := dc.books[mktID] - if found { + book := dc.bookie(mktID) + if book != nil { midGap, err := book.MidGap() // An error is only returned when there are no orders on the book. // In that case, fall back to the 1 lot estimate for now. @@ -4019,7 +4020,7 @@ func (c *Core) connectDEX(acctInfo *db.AccountInfo) (*dexConnection, error) { trades: make(map[order.OrderID]*trackedTrade), notify: c.notify, epoch: epochMap, - connected: true, + connected: 1, } c.log.Debugf("Broadcast timeout = %v, ticking every %v", bTimeout, dc.tickInterval) @@ -4064,9 +4065,7 @@ func (c *Core) handleReconnect(host string) { resubMkt := func(mkt *market) { // Locate any bookie for this market. - dc.booksMtx.Lock() - defer dc.booksMtx.Unlock() - booky := dc.books[mkt.name] + booky := dc.bookie(mkt.name) if booky == nil { // Was not previously subscribed with the server for this market. return @@ -4124,7 +4123,11 @@ func (c *Core) handleReconnect(host string) { func (c *Core) handleConnectEvent(host string, connected bool) { c.connMtx.Lock() if dc, found := c.conns[host]; found { - dc.connected = connected + var v uint32 + if connected { + v = 1 + } + atomic.StoreUint32(&dc.connected, v) } c.connMtx.Unlock() statusStr := "connected" @@ -4146,11 +4149,8 @@ func handleMatchProofMsg(c *Core, dc *dexConnection, msg *msgjson.Message) error // Expire the epoch dc.setEpoch(note.MarketID, note.Epoch+1) - dc.booksMtx.RLock() - defer dc.booksMtx.RUnlock() - - book, ok := dc.books[note.MarketID] - if !ok { + book := dc.bookie(note.MarketID) + if book == nil { return fmt.Errorf("no order book found with market id %q", note.MarketID) } diff --git a/client/core/core_test.go b/client/core/core_test.go index 51f82903a2..c50938b646 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -15,6 +15,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "testing" "time" @@ -202,7 +203,7 @@ func testDexConnection() (*dexConnection, *TWebsocket, *dexAccount) { notify: func(Notification) {}, trades: make(map[order.OrderID]*trackedTrade), epoch: map[string]uint64{tDcrBtcMktName: 0}, - connected: true, + connected: 1, }, conn, acct } @@ -2166,12 +2167,12 @@ func TestTrade(t *testing.T) { rig.dc.acct.unlock(rig.crypter) // DEX not connected - rig.dc.connected = false + atomic.StoreUint32(&rig.dc.connected, 0) _, err = tCore.Trade(tPW, form) if err == nil { t.Fatalf("no error for disconnected dex") } - rig.dc.connected = true + atomic.StoreUint32(&rig.dc.connected, 1) // No base asset form.Base = 12345 diff --git a/client/orderbook/orderbook.go b/client/orderbook/orderbook.go index e19855b957..55113a787e 100644 --- a/client/orderbook/orderbook.go +++ b/client/orderbook/orderbook.go @@ -560,7 +560,9 @@ func (ob *OrderBook) MidGap() (uint64, error) { } // BestFill is the best (rate, quantity) fill for an order of the type and -// quantity specified. +// quantity specified. BestFill should be used when the exact quantity of base asset +// is known, i.e. limit orders and market sell orders. For market buy orders, +// use BestFillMarketBuy. func (ob *OrderBook) BestFill(sell bool, qty uint64) ([]*Fill, bool) { if sell { return ob.buys.BestFill(qty) @@ -569,7 +571,7 @@ func (ob *OrderBook) BestFill(sell bool, qty uint64) ([]*Fill, bool) { } // BestFillMarketBuy is the best (rate, quantity) fill for a market buy order. -// The qty given will be in units of quote asset +// The qty given will be in units of quote asset. func (ob *OrderBook) BestFillMarketBuy(qty, lotSize uint64) ([]*Fill, bool) { return ob.sells.bestFill(qty, true, lotSize) } From ad6c43b5c5ca9174eaad1421f2a44d9454cb0e65 Mon Sep 17 00:00:00 2001 From: buck54321 Date: Fri, 12 Feb 2021 16:23:50 -0600 Subject: [PATCH 4/5] cleanup --- client/asset/btc/btc.go | 20 ++++++++++---------- client/asset/dcr/dcr.go | 24 ++++++++++++------------ client/webserver/site/src/js/markets.js | 2 +- 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 3c6f0ed4cc..ede52e5683 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -697,26 +697,26 @@ func (btc *ExchangeWallet) feeRateWithFallback(confTarget uint64) uint64 { // associated with nfo.MaxFeeRate. For quote assets, the caller will have to // calculate lotSize based on a rate conversion from the base asset's lot size. func (btc *ExchangeWallet) MaxOrder(lotSize uint64, nfo *dex.Asset) (*asset.SwapEstimate, error) { - _, _, _, maxEst, err := btc.maxOrder(lotSize, nfo) + _, _, maxEst, err := btc.maxOrder(lotSize, nfo) return maxEst, err } // maxOrder gets the estimate for MaxOrder, and also returns the // []*compositeUTXO to be used for further order estimation without additional // calls to listunspent. -func (btc *ExchangeWallet) maxOrder(lotSize uint64, nfo *dex.Asset) (utxos []*compositeUTXO, lots, feeRate uint64, est *asset.SwapEstimate, err error) { +func (btc *ExchangeWallet) maxOrder(lotSize uint64, nfo *dex.Asset) (utxos []*compositeUTXO, feeRate uint64, est *asset.SwapEstimate, err error) { networkFeeRate, err := btc.feeRate(1) if err != nil { - return nil, 0, 0, nil, fmt.Errorf("error getting network fee estimate: %w", err) + return nil, 0, nil, fmt.Errorf("error getting network fee estimate: %w", err) } btc.fundingMtx.RLock() utxos, _, avail, err := btc.spendableUTXOs(0) btc.fundingMtx.RUnlock() if err != nil { - return nil, 0, 0, nil, fmt.Errorf("error parsing unspent outputs: %w", err) + return nil, 0, nil, fmt.Errorf("error parsing unspent outputs: %w", err) } // Start by attempting max lots with no fees. - lots = avail / lotSize + lots := avail / lotSize for lots > 0 { est, _, err := btc.estimateSwap(lots, lotSize, networkFeeRate, utxos, nfo, btc.useSplitTx) // The only failure mode of estimateSwap -> btc.fund is when there is @@ -726,9 +726,9 @@ func (btc *ExchangeWallet) maxOrder(lotSize uint64, nfo *dex.Asset) (utxos []*co lots-- continue } - return utxos, lots, networkFeeRate, est, nil + return utxos, networkFeeRate, est, nil } - return utxos, 0, networkFeeRate, &asset.SwapEstimate{}, nil + return utxos, networkFeeRate, &asset.SwapEstimate{}, nil } // PreSwap get order estimates based on the available funds and the wallet @@ -741,12 +741,12 @@ func (btc *ExchangeWallet) PreSwap(req *asset.PreSwapForm) (*asset.PreSwap, erro // 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. - utxos, maxLots, feeRate, _, err := btc.maxOrder(req.LotSize, req.AssetConfig) + utxos, feeRate, maxEst, err := btc.maxOrder(req.LotSize, req.AssetConfig) if err != nil { return nil, err } - if maxLots < req.Lots { - return nil, fmt.Errorf("%d lots available for %d-lot order", maxLots, req.Lots) + if maxEst.Lots < req.Lots { + return nil, fmt.Errorf("%d lots available for %d-lot order", maxEst.Lots, req.Lots) } // Get the estimate for the requested number of lots. diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 0a6a065613..562c85f31e 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -688,22 +688,22 @@ func (a amount) String() string { // associated with nfo.MaxFeeRate. For quote assets, the caller will have to // calculate lotSize based on a rate conversion from the base asset's lot size. func (dcr *ExchangeWallet) MaxOrder(lotSize uint64, nfo *dex.Asset) (*asset.SwapEstimate, error) { - _, _, _, est, err := dcr.maxOrder(lotSize, nfo) + _, _, est, err := dcr.maxOrder(lotSize, nfo) return est, err } // maxOrder gets the estimate for MaxOrder, and also returns the -// []*compositeUTXO to be used for further order estimation without additional -// calls to listunspent. -func (dcr *ExchangeWallet) maxOrder(lotSize uint64, nfo *dex.Asset) (utxos []*compositeUTXO, lots, feeRate uint64, est *asset.SwapEstimate, err error) { +// []*compositeUTXO and network fee rate to be used for further order estimation +// without additional calls to listunspent. +func (dcr *ExchangeWallet) maxOrder(lotSize uint64, nfo *dex.Asset) (utxos []*compositeUTXO, feeRate uint64, est *asset.SwapEstimate, err error) { networkFeeRate, err := dcr.feeRate(1) if err != nil { - return nil, 0, 0, nil, fmt.Errorf("error getting network fee estimate: %w", err) + return nil, 0, nil, fmt.Errorf("error getting network fee estimate: %w", err) } utxos, err = dcr.spendableUTXOs() if err != nil { - return nil, 0, 0, nil, fmt.Errorf("error parsing unspent outputs: %w", err) + return nil, 0, nil, fmt.Errorf("error parsing unspent outputs: %w", err) } var avail uint64 for _, utxo := range utxos { @@ -711,7 +711,7 @@ func (dcr *ExchangeWallet) maxOrder(lotSize uint64, nfo *dex.Asset) (utxos []*co } // Start by attempting max lots with no fees. - lots = avail / lotSize + lots := avail / lotSize for lots > 0 { est, _, err := dcr.estimateSwap(lots, lotSize, networkFeeRate, utxos, nfo, dcr.useSplitTx) // The only failure mode of estimateSwap -> dcr.fund is when there is @@ -721,10 +721,10 @@ func (dcr *ExchangeWallet) maxOrder(lotSize uint64, nfo *dex.Asset) (utxos []*co lots-- continue } - return utxos, lots, networkFeeRate, est, nil + return utxos, networkFeeRate, est, nil } - return nil, 0, 0, &asset.SwapEstimate{}, nil + return nil, 0, &asset.SwapEstimate{}, nil } // estimateSwap prepares an *asset.SwapEstimate. @@ -788,12 +788,12 @@ func (dcr *ExchangeWallet) PreSwap(req *asset.PreSwapForm) (*asset.PreSwap, erro // 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. - utxos, maxLots, feeRate, _, err := dcr.maxOrder(req.LotSize, req.AssetConfig) + utxos, feeRate, maxEst, err := dcr.maxOrder(req.LotSize, req.AssetConfig) if err != nil { return nil, err } - if maxLots < req.Lots { - return nil, fmt.Errorf("%d lots available for %d-lot order", maxLots, req.Lots) + if maxEst.Lots < req.Lots { + return nil, fmt.Errorf("%d lots available for %d-lot order", maxEst.Lots, req.Lots) } // Get the estimate for the requested number of lots. diff --git a/client/webserver/site/src/js/markets.js b/client/webserver/site/src/js/markets.js index 97bac4b8c2..70041d4149 100644 --- a/client/webserver/site/src/js/markets.js +++ b/client/webserver/site/src/js/markets.js @@ -169,7 +169,7 @@ export default class MarketsPage extends BasePage { this.drawChartLines() }) bind(page.maxOrd, 'click', () => { - if (this.isSell()) page.lotField.value = this.market.maxSell.lots + if (this.isSell()) page.lotField.value = this.market.maxSell.swap.lots else page.lotField.value = this.market.maxBuys[this.adjustedRate()].swap.lots this.lotChanged() }) From 1eff08bdd90b1fd223a9cfd2bcbc12b2ff785890 Mon Sep 17 00:00:00 2001 From: Jonathan Chappelow Date: Thu, 25 Feb 2021 14:30:31 -0600 Subject: [PATCH 5/5] go1.16 mod tidy --- go.sum | 8 -------- 1 file changed, 8 deletions(-) diff --git a/go.sum b/go.sum index 74466c631e..9c23ac4aba 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,4 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -decred.org/cspp v0.3.0 h1:2AkSsWzA7HIMZImfw0gT82Gdp8OXIM4NsBn7vna22uE= decred.org/cspp v0.3.0/go.mod h1:UygjYilC94dER3BEU65Zzyoqy9ngJfWCD2rdJqvUs2A= decred.org/dcrwallet v1.6.0-rc4 h1:5IT6mFa+2YMqenu6aE2LetD0N8QSUVFyAFl205PvIIE= decred.org/dcrwallet v1.6.0-rc4/go.mod h1:lsrNbuKxkPGeHXPufxNTckwQopCEDz0r3t0a8JCKAmU= @@ -36,11 +35,9 @@ github.com/dchest/siphash v1.2.1 h1:4cLinnzVJDKxTCl9B01807Yiy+W7ZzVHj/KIroQRvT4= github.com/dchest/siphash v1.2.1/go.mod h1:q+IRvb2gOSrUnYoPqHiyHXS0FOBBOdl6tONBlVnOnt4= github.com/decred/base58 v1.0.3 h1:KGZuh8d1WEMIrK0leQRM47W85KqCAdl2N+uagbctdDI= github.com/decred/base58 v1.0.3/go.mod h1:pXP9cXCfM2sFLb2viz2FNIdeMWmZDBKG3ZBYbiSM78E= -github.com/decred/dcrd/addrmgr v1.2.0 h1:wN9bvDFHOus1J4s3KUtnsyiebNoi6w5R6INbP+JH0gI= github.com/decred/dcrd/addrmgr v1.2.0/go.mod h1:QlZF9vkzwYh0qs25C76SAFZBRscjETga/K28GEE6qIc= github.com/decred/dcrd/blockchain/stake/v3 v3.0.0 h1:vr0o0ICjuEzg1End6YtBfwgDuPkg+FYIwGVEz18kFg0= github.com/decred/dcrd/blockchain/stake/v3 v3.0.0/go.mod h1:5GIUwsrHQCJauacgCegIR6t92SaeVi28Qls/BLN9vOw= -github.com/decred/dcrd/blockchain/standalone/v2 v2.0.0 h1:9gUuH0u/IZNPWBK9K3CxgAWPG7nTqVSsZefpGY4Okns= github.com/decred/dcrd/blockchain/standalone/v2 v2.0.0/go.mod h1:t2qaZ3hNnxHZ5kzVJDgW5sp47/8T5hYJt7SR+/JtRhI= github.com/decred/dcrd/blockchain/v3 v3.0.2/go.mod h1:LD5VA95qdb+DlRiPI8VLBimDqvlDCAJsidZ5oD6nc/U= github.com/decred/dcrd/certgen v1.1.1 h1:MYPG5jCysnbF4OiJ1++YumFEu2p/MsM/zxmmqC9mVFg= @@ -49,7 +46,6 @@ github.com/decred/dcrd/chaincfg/chainhash v1.0.2 h1:rt5Vlq/jM3ZawwiacWjPa+smINyL github.com/decred/dcrd/chaincfg/chainhash v1.0.2/go.mod h1:BpbrGgrPTr3YJYRN3Bm+D9NuaFd+zGyNeIKgrhCXK60= github.com/decred/dcrd/chaincfg/v3 v3.0.0 h1:+TFbu7ZmvBwM+SZz5mrj6cun9ts/6DAL5sqnsaFBHGQ= github.com/decred/dcrd/chaincfg/v3 v3.0.0/go.mod h1:EspyubQ7D2w6tjP7rBGDIE7OTbuMgBjR2F2kZFnh31A= -github.com/decred/dcrd/connmgr/v3 v3.0.0 h1:Z0fWa3PYIzaxAM5SHTC+wqJVHgoWwP4TSDTvNek/PGY= github.com/decred/dcrd/connmgr/v3 v3.0.0/go.mod h1:cPI43Aggp1lOhrVG75eJ3c3BwuFx0NhT77FK34ky+ak= github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= @@ -121,12 +117,10 @@ github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jessevdk/go-flags v1.4.1-0.20200711081900-c17162fe8fd7 h1:Ug59miTxVKVg5Oi2S5uHlKOIV5jBx4Hb2u0jIxxDaSs= github.com/jessevdk/go-flags v1.4.1-0.20200711081900-c17162fe8fd7/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= -github.com/jrick/bitset v1.0.0 h1:Ws0PXV3PwXqWK2n7Vz6idCdrV/9OrBXgHEJi27ZB9Dw= github.com/jrick/bitset v1.0.0/go.mod h1:ZOYB5Uvkla7wIEY4FEssPVi3IQXa02arznRaYaAEPe4= github.com/jrick/logrotate v1.0.0 h1:lQ1bL/n9mBNeIXoTUoYRlK4dHuNJVofX9oWqBtPnSzI= github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= github.com/jrick/wsrpc/v2 v2.3.2/go.mod h1:XPYs8BnRWl99lCvXRM5SLpZmTPqWpSOPkDIqYTwDPfU= -github.com/jrick/wsrpc/v2 v2.3.4 h1:+GzRtp/TyXaSB61pN92lIAVyvdVv0RSqniIEB/rPx1Q= github.com/jrick/wsrpc/v2 v2.3.4/go.mod h1:XPYs8BnRWl99lCvXRM5SLpZmTPqWpSOPkDIqYTwDPfU= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= @@ -217,12 +211,10 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55 h1:gSJIx1SDwno+2ElGhA4+qG2zF97qiUzTM+rQ0klBOcE= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.32.0 h1:zWTV+LMdc3kaiJMSTOFz2UgSBgx8RNQoTGiZu3fR9S0= google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=