From 5394ceaa2a0e69518cabb2a3ae4fdb2164a6a08e Mon Sep 17 00:00:00 2001 From: buck54321 Date: Thu, 25 Feb 2021 14:33:35 -0600 Subject: [PATCH] multi: 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. (client/asset.SwapEstimate) Similarly, the redemption estimate has high and low estimates evaluated for the worst and best settlement sequences (1 tx per lot vs 1 tx for all). (client/asset.RedeemEstimate) --- client/asset/btc/btc.go | 201 ++++++++++++++------ client/asset/btc/btc_test.go | 215 +++++++++++++++++---- client/asset/btc/livetest/livetest.go | 4 +- client/asset/dcr/dcr.go | 169 ++++++++++++----- client/asset/dcr/dcr_test.go | 161 +++++++++++++--- client/asset/dcr/simnet_test.go | 4 +- client/asset/interface.go | 96 ++++++++-- client/core/bookie.go | 48 ++--- client/core/core.go | 239 ++++++++++++++++++------ client/core/core_test.go | 187 +++++++++++++++++- 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 | 17 ++ client/orderbook/orderbook_test.go | 103 ++++------ client/webserver/api.go | 8 +- client/webserver/live_test.go | 49 +++-- client/webserver/site/src/js/markets.js | 22 ++- client/webserver/webserver.go | 4 +- client/webserver/webserver_test.go | 7 +- 21 files changed, 1344 insertions(+), 566 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index a7f7f97407..ede52e5683 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -696,72 +696,155 @@ 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, 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, 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, nil, fmt.Errorf("error parsing unspent outputs: %w", err) } // Start by attempting max lots with no fees. 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, networkFeeRate, est, nil + } + return utxos, 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. 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, feeRate, maxEst, err := btc.maxOrder(req.LotSize, req.AssetConfig) + if err != nil { + return nil, err + } + if maxEst.Lots < req.Lots { + return nil, fmt.Errorf("%d lots available for %d-lot order", maxEst.Lots, req.Lots) + } - // No split transaction. - return &asset.OrderEstimate{ - Lots: lots, - Value: val, - MaxFees: maxFees, - EstimatedFees: estFees, - Locked: sum, - }, nil + // 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) } - 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 + // Worst is req.Lots transactions, each with one input and one output. + var worst uint64 = dexbtc.MinimumTxOverhead * 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.TxInOverhead + (dexbtc.RedeemSwapSigScriptSize+2+3)/4 + outputSize = dexbtc.P2WPKHOutputSize + } else { - size += dexbtc.RedeemSwapSigScriptSize + dexbtc.P2PKHOutputSize + inputSize = dexbtc.TxInOverhead + 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 @@ -916,7 +999,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 +1029,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 +1044,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 +1061,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 +1281,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 +1385,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 +1423,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 +1434,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 +1453,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 +1462,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 +1486,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..adf20421b9 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,11 +946,11 @@ 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 + // 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 = 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 @@ -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") + } + setFunds(minReq) + + node.estFeeErr = tErr + _, err = wallet.PreSwap(form) + if err == nil { + t.Fatalf("no PreSwap error for estimatesmartfee error") + } + 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..562c85f31e 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 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, 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() + + utxos, err = dcr.spendableUTXOs() if err != nil { - return 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 { @@ -704,51 +713,119 @@ func (dcr *ExchangeWallet) MaxOrder(lotSize uint64, nfo *dex.Asset) (*asset.Orde // Start by attempting max lots with no fees. 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, networkFeeRate, est, nil + } + + return nil, 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) + } - // No split transaction. - return &asset.OrderEstimate{ - Lots: lots, - Value: val, - MaxFees: maxFees, - EstimatedFees: estFees, - Locked: sum, - }, nil + val := lots * lotSize + sum, inputsSize, _, _, _, err := dcr.tryFund(utxos, orderEnough(val, lots, nfo)) + 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.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. 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, feeRate, maxEst, err := dcr.maxOrder(req.LotSize, req.AssetConfig) + if err != nil { + return nil, err + } + if maxEst.Lots < req.Lots { + return nil, fmt.Errorf("%d lots available for %d-lot order", maxEst.Lots, req.Lots) } - return &asset.OrderEstimate{}, nil + + // 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) + } + + 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 + // Worst is req.Lots transactions, each with one input and one output. + 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{ + RealisticWorstCase: worst * feeRate, + RealisticBestCase: best * feeRate, + }, + }, nil } // orderEnough generates a function that can be used as the enough argument to @@ -1289,13 +1366,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 +1402,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 +1419,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 +1441,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..965b5eaf77 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") + } + node.unspent[0].Amount = float64(minReq) / 1e8 + + node.estFeeErr = tErr + _, err = wallet.PreSwap(form) + if err == nil { + t.Fatalf("no PreSwap error for estimatesmartfee error") + } + 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..f19f6ba5b3 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 a pre-swap estimate for the specified order size. + PreSwap(*PreSwapForm) (*PreSwap, error) + // 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. 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 + // 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 + // 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 + // 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 +} + +// 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/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 7fb1fd452c..38c3f5b99b 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -24,6 +24,7 @@ import ( "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" @@ -85,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. @@ -750,6 +751,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] + c.connMtx.RUnlock() + connected := found && atomic.LoadUint32(&dc.connected) == 1 + 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. @@ -1049,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(), } @@ -2329,7 +2366,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,24 +2377,24 @@ 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: 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 &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) { +func (c *Core) MaxSell(host string, base, quote uint32) (*MaxOrderEstimate, error) { baseAsset, _, baseWallet, quoteWallet, err := c.marketWallets(host, base, quote) if err != nil { return nil, err @@ -2367,18 +2404,35 @@ 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. + 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: maxSell.Lots, + }) + if err != nil { + return nil, fmt.Errorf("%s PreRedeem error: %v", unbip(quote), err) + } + + return &MaxOrderEstimate{ + Swap: maxSell, + Redeem: preRedeem.Estimate, }, nil } @@ -2630,33 +2684,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 := dc.bookie(marketName(form.Base, form.Quote)) + if book == nil { + 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) @@ -2746,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. @@ -3884,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) @@ -3929,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 @@ -3989,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" @@ -4011,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 a3c24097b6..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 } @@ -564,6 +565,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 +627,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 +670,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 @@ -2159,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 @@ -5988,3 +5996,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..55113a787e 100644 --- a/client/orderbook/orderbook.go +++ b/client/orderbook/orderbook.go @@ -558,3 +558,20 @@ 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. 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) + } + 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..70041d4149 100644 --- a/client/webserver/site/src/js/markets.js +++ b/client/webserver/site/src/js/markets.js @@ -169,8 +169,8 @@ export default class MarketsPage extends BasePage { this.drawChartLines() }) bind(page.maxOrd, 'click', () => { - if (this.isSell()) page.lotField.value = this.market.maxSell.lots - else page.lotField.value = this.market.maxBuys[this.adjustedRate()].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() }) @@ -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) {