Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

multi: order fee estimation #958

Merged
merged 5 commits into from
Feb 25, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 144 additions & 57 deletions client/asset/btc/btc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't look like the second return is ever utilized. It will be in the future?

Copy link
Member Author

@buck54321 buck54321 Feb 12, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It will be in the future?

Yes. Split transactions will be an order-time option for utxo-based blockchains, so we'll need to know which path estimateSwap took.


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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the best case useful for?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All we can really say before matching is that the fees will (realistically) fall in a range between the best and worst case. Best case provides the lower limit. Worst cast provides the upper limit.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to expand on this a little, the worst case is probably the one I would pay attention to the most, but for users who place large orders, they may want to collect statistics over time to better estimate where their risk falls between these two points.

From a UI perspective, this will allow us to offer some additional clarity on fees instead of just showing the worst-case value.

},
}, nil
}

// FundOrder selects coins for use in an order. The coins will be locked, and
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
Expand All @@ -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)
Expand All @@ -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
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -1337,17 +1423,18 @@ 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)
fee := feeRate * size
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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
Expand Down
Loading