diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 60d7ed5f93..16c7515587 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -976,7 +976,6 @@ var _ asset.Bonder = (*baseWallet)(nil) var _ asset.Authenticator = (*ExchangeWalletSPV)(nil) var _ asset.Authenticator = (*ExchangeWalletFullNode)(nil) var _ asset.Authenticator = (*ExchangeWalletAccelerator)(nil) -var _ asset.MultiOrderFunder = (*baseWallet)(nil) var _ asset.AddressReturner = (*baseWallet)(nil) // RecoveryCfg is the information that is transferred from the old wallet diff --git a/client/asset/dcr/coin_selection.go b/client/asset/dcr/coin_selection.go index 16bb15f19a..c6a16174d5 100644 --- a/client/asset/dcr/coin_selection.go +++ b/client/asset/dcr/coin_selection.go @@ -4,6 +4,7 @@ package dcr import ( + "math" "math/rand" "sort" "time" @@ -63,6 +64,22 @@ func orderEnough(val, lots, feeRate uint64, reportChange bool) func(sum uint64, } } +// reserveEnough generates a function that can be used as the enough argument +// to the fund method. The function returns true if sum is greater than equal +// to amt. +func reserveEnough(amt uint64) func(sum uint64, size uint32, unspent *compositeUTXO) (bool, uint64) { + return func(sum uint64, _ uint32, unspent *compositeUTXO) (bool, uint64) { + return sum+toAtoms(unspent.rpc.Amount) >= amt, 0 + } +} + +func sumUTXOSize(set []*compositeUTXO) (tot uint32) { + for _, utxo := range set { + tot += utxo.input.Size() + } + return tot +} + func sumUTXOs(set []*compositeUTXO) (tot uint64) { for _, utxo := range set { tot += toAtoms(utxo.rpc.Amount) @@ -76,7 +93,7 @@ func sumUTXOs(set []*compositeUTXO) (tot uint64) { // involves two passes over the UTXOs. The first pass randomly selects // each UTXO with 50% probability. Then, the second pass selects any // unused UTXOs until the total value is greater than or equal to amt. -func subsetWithLeastSumGreaterThan(amt uint64, utxos []*compositeUTXO) []*compositeUTXO { +func subsetWithLeastOverFund(enough func(uint64, uint32, *compositeUTXO) (bool, uint64), maxFund uint64, utxos []*compositeUTXO) []*compositeUTXO { best := uint64(1 << 62) var bestIncluded []bool bestNumIncluded := 0 @@ -92,9 +109,9 @@ func subsetWithLeastSumGreaterThan(amt uint64, utxos []*compositeUTXO) []*compos included := make([]bool, len(utxos)) const iterations = 1000 -searchLoop: for nRep := 0; nRep < iterations; nRep++ { var nTotal uint64 + var totalSize uint32 var numIncluded int for nPass := 0; nPass < 2; nPass++ { @@ -108,9 +125,13 @@ searchLoop: if use { included[i] = true numIncluded++ + totalBefore := nTotal + sizeBefore := totalSize nTotal += toAtoms(shuffledUTXOs[i].rpc.Amount) - if nTotal >= amt { - if nTotal < best || (nTotal == best && numIncluded < bestNumIncluded) { + totalSize += shuffledUTXOs[i].input.Size() + + if e, _ := enough(totalBefore, sizeBefore, shuffledUTXOs[i]); e { + if nTotal < best || (nTotal == best && numIncluded < bestNumIncluded) && nTotal <= maxFund { best = nTotal if bestIncluded == nil { bestIncluded = make([]bool, len(shuffledUTXOs)) @@ -118,12 +139,11 @@ searchLoop: copy(bestIncluded, included) bestNumIncluded = numIncluded } - if nTotal == amt { - break searchLoop - } + included[i] = false - nTotal -= toAtoms(shuffledUTXOs[i].rpc.Amount) numIncluded-- + nTotal -= toAtoms(shuffledUTXOs[i].rpc.Amount) + totalSize -= shuffledUTXOs[i].input.Size() } } } @@ -165,37 +185,78 @@ searchLoop: // // If the provided UTXO set has less combined value than the requested amount a // nil slice is returned. -func leastOverFund(amt uint64, utxos []*compositeUTXO) []*compositeUTXO { - if amt == 0 || sumUTXOs(utxos) < amt { - return nil +func leastOverFund(enough func(sum uint64, size uint32, unspent *compositeUTXO) (bool, uint64), utxos []*compositeUTXO) []*compositeUTXO { + return leastOverFundWithLimit(enough, math.MaxUint64, utxos) +} + +// enoughWithoutAdditional is used to utilize an "enough" function with a set +// of UTXOs when we are not looking to add another UTXO to the set, just +// check if the current set if UTXOs is enough. +func enoughWithoutAdditional(enough func(sum uint64, size uint32, unspent *compositeUTXO) (bool, uint64), utxos []*compositeUTXO) bool { + if len(utxos) == 0 { + return false + } + + if len(utxos) == 1 { + e, _ := enough(0, 0, utxos[0]) + return e + } + + valueWithoutLast := sumUTXOs(utxos[:len(utxos)-1]) + sizeWithoutLast := sumUTXOSize(utxos[:len(utxos)-1]) + + e, _ := enough(valueWithoutLast, sizeWithoutLast, utxos[len(utxos)-1]) + return e +} + +// leastOverFundWithLimit is the same as leastOverFund, but with an additional +// maxFund parameter. The total value of the returned UTXOs will not exceed +// maxFund. +func leastOverFundWithLimit(enough func(sum uint64, size uint32, unspent *compositeUTXO) (bool, uint64), maxFund uint64, utxos []*compositeUTXO) []*compositeUTXO { + // Remove the UTXOs that are larger than maxFund + var smallEnoughUTXOs []*compositeUTXO + idx := sort.Search(len(utxos), func(i int) bool { + utxo := utxos[i] + return toAtoms(utxo.rpc.Amount) > maxFund + }) + if idx == len(utxos) { + smallEnoughUTXOs = utxos + } else { + smallEnoughUTXOs = utxos[:idx] } // Partition - smallest UTXO that is large enough to fully fund, and the set // of smaller ones. - idx := sort.Search(len(utxos), func(i int) bool { - return toAtoms(utxos[i].rpc.Amount) >= amt + idx = sort.Search(len(smallEnoughUTXOs), func(i int) bool { + utxo := smallEnoughUTXOs[i] + e, _ := enough(0, 0, utxo) + return e }) var small []*compositeUTXO - var single *compositeUTXO // only return this if smaller ones would use more - if idx == len(utxos) { // no one is enough - small = utxos + var single *compositeUTXO // only return this if smaller ones would use more + if idx == len(smallEnoughUTXOs) { // no one is enough + small = smallEnoughUTXOs } else { - small = utxos[:idx] - single = utxos[idx] + small = smallEnoughUTXOs[:idx] + single = smallEnoughUTXOs[idx] } - // Find a subset of the small UTXO set with smallest combined amount. var set []*compositeUTXO - if sumUTXOs(small) >= amt { - set = subsetWithLeastSumGreaterThan(amt, small) - } else if single != nil { - return []*compositeUTXO{single} + if !enoughWithoutAdditional(enough, small) { + if single != nil { + return []*compositeUTXO{single} + } else { + return nil + } + } else { + set = subsetWithLeastOverFund(enough, maxFund, small) } // Return the small UTXO subset if it is less than the single big UTXO. if single != nil && toAtoms(single.rpc.Amount) < sumUTXOs(set) { return []*compositeUTXO{single} } + return set } diff --git a/client/asset/dcr/coin_selection_test.go b/client/asset/dcr/coin_selection_test.go index 2f07e07163..3c5134299c 100644 --- a/client/asset/dcr/coin_selection_test.go +++ b/client/asset/dcr/coin_selection_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + dexdcr "decred.org/dcrdex/dex/networks/dcr" walletjson "decred.org/dcrwallet/v3/rpc/jsonrpc/types" ) @@ -14,7 +15,8 @@ func Test_leastOverFund(t *testing.T) { amt := uint64(10e8) newU := func(amt float64) *compositeUTXO { return &compositeUTXO{ - rpc: &walletjson.ListUnspentResult{Amount: amt}, + rpc: &walletjson.ListUnspentResult{Amount: amt}, + input: &dexdcr.SpendInfo{}, } } tests := []struct { @@ -80,7 +82,7 @@ func Test_leastOverFund(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := leastOverFund(amt, tt.utxos) + got := leastOverFund(reserveEnough(amt), tt.utxos) sort.Slice(got, func(i int, j int) bool { return got[i].rpc.Amount < got[j].rpc.Amount }) @@ -113,7 +115,8 @@ func Fuzz_leastOverFund(f *testing.F) { newU := func(amt float64) *compositeUTXO { return &compositeUTXO{ - rpc: &walletjson.ListUnspentResult{Amount: amt}, + rpc: &walletjson.ListUnspentResult{Amount: amt}, + input: &dexdcr.SpendInfo{}, } } @@ -140,7 +143,7 @@ func Fuzz_leastOverFund(f *testing.F) { utxos[i] = newU(v) } startTime := time.Now() - leastOverFund(amt*1e8, utxos) + leastOverFund(reserveEnough(amt*1e8), utxos) totalDuration += time.Since(startTime) totalUTXO += int64(n) }) @@ -157,12 +160,13 @@ func BenchmarkLeastOverFund(b *testing.B) { rpc: &walletjson.ListUnspentResult{ Amount: float64(1+rnd.Int31n(100)) / float64(1e8), }, + input: &dexdcr.SpendInfo{}, } utxos[i] = utxo } b.ResetTimer() for n := 0; n < b.N; n++ { - leastOverFund(10_000, utxos) + leastOverFund(reserveEnough(10_000), utxos) } } diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index 7bc1d446a0..50f5e4222c 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -248,10 +248,12 @@ var ( }, }, } - swapFeeBumpKey = "swapfeebump" - splitKey = "swapsplit" - redeemFeeBumpFee = "redeemfeebump" - client http.Client + swapFeeBumpKey = "swapfeebump" + splitKey = "swapsplit" + multiSplitKey = "multisplit" + multiSplitBufferKey = "multisplitbuffer" + redeemFeeBumpFee = "redeemfeebump" + client http.Client ) // outPoint is the hash and output index of a transaction output. @@ -1520,7 +1522,7 @@ func (dcr *ExchangeWallet) estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate } // Like the fund() method, try with some utxos taken out of the mix for // reserves, as precise in value as possible. - kept := leastOverFund(reserves, utxos) + kept := leastOverFund(reserveEnough(reserves), utxos) utxos = utxoSetDiff(utxos, kept) sum, _, inputsSize, _, _, _, err = tryFund(utxos, orderEnough(val, lots, bumpedMaxRate, false)) if err != nil { // no joy with the reduced set @@ -1943,34 +1945,561 @@ func (dcr *ExchangeWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes return coins, redeemScripts, 0, nil } -// fund finds coins for the specified value. A function is provided that can -// check whether adding the provided output would be enough to satisfy the -// needed value. Preference is given to selecting coins with 1 or more confs, -// falling back to 0-conf coins where there are not enough 1+ confs coins. If -// change should not be considered "kept" (e.g. no preceding split txn, or -// mixing sends change to umixed account where it is unusable for reserves), -// caller should return 0 extra from enough func. -func (dcr *ExchangeWallet) fund(keep uint64, // leave utxos for this reserve amt - enough func(sum uint64, size uint32, unspent *compositeUTXO) (bool, uint64)) ( - coins asset.Coins, redeemScripts []dex.Bytes, sum, size uint64, err error) { +// fundMultiOptions are the possible order options when calling FundMultiOrder. +type fundMultiOptions struct { + // Split, if true, and multi-order cannot be funded with the existing UTXOs + // in the wallet without going over the maxLock limit, a split transaction + // will be created with one output per order. + // + // Use the multiSplitKey const defined above in the options map to set this option. + Split *bool + // SplitBuffer, if set, will instruct the wallet to add a buffer onto each + // output of the multi-order split transaction (if the split is needed). + // SplitBuffer is defined as a percentage of the output. If a .1 BTC output + // is required for an order and SplitBuffer is set to 5, a .105 BTC output + // will be created. + // + // The motivation for this is to assist market makers in having to do the + // least amount of splits as possible. It is useful when BTC is the quote + // asset on a market, and the price is increasing. During a market maker's + // operation, it will frequently have to cancel and replace orders as the + // rate moves. If BTC is the quote asset on a market, and the rate has + // lightly increased, the market maker will need to lock slightly more of + // the quote asset for the same amount of lots of the base asset. If there + // is no split buffer, this may necessitate a new split transaction. + // + // Use the multiSplitBufferKey const defined above in the options map to set this. + SplitBuffer *uint64 +} - // Keep a consistent view of spendable and locked coins in the wallet and - // the fundingCoins map to make this safe for concurrent use. - dcr.fundingMtx.Lock() // before listing unspents in wallet - defer dcr.fundingMtx.Unlock() // hold until lockFundingCoins (wallet and map) +func decodeFundMultiOptions(options map[string]string) (*fundMultiOptions, error) { + opts := new(fundMultiOptions) + if options == nil { + return opts, nil + } + + if split, ok := options[multiSplitKey]; ok { + b, err := strconv.ParseBool(split) + if err != nil { + return nil, fmt.Errorf("error parsing split option: %w", err) + } + opts.Split = &b + } + if splitBuffer, ok := options[multiSplitBufferKey]; ok { + b, err := strconv.ParseUint(splitBuffer, 10, 64) + if err != nil { + return nil, fmt.Errorf("error parsing split buffer option: %w", err) + } + opts.SplitBuffer = &b + } + + return opts, nil +} + +// orderWithLeastOverFund returns the index of the order from a slice of orders +// that requires the least over-funding without using more than maxLock. It +// also returns the UTXOs that were used to fund the order. If none can be +// funded without using more than maxLock, -1 is returned. +func (dcr *ExchangeWallet) orderWithLeastOverFund(maxLock, feeRate uint64, orders []*asset.MultiOrderValue, utxos []*compositeUTXO) (orderIndex int, leastOverFundingUTXOs []*compositeUTXO) { + minOverFund := uint64(math.MaxUint64) + orderIndex = -1 + for i, value := range orders { + enough := orderEnough(value.Value, value.MaxSwapCount, feeRate, false) + var fundingUTXOs []*compositeUTXO + if maxLock > 0 { + fundingUTXOs = leastOverFundWithLimit(enough, maxLock, utxos) + } else { + fundingUTXOs = leastOverFund(enough, utxos) + } + if len(fundingUTXOs) == 0 { + continue + } + sum := sumUTXOs(fundingUTXOs) + overFund := sum - value.Value + if overFund < minOverFund { + minOverFund = overFund + orderIndex = i + leastOverFundingUTXOs = fundingUTXOs + } + } + return +} + +// fundsRequiredForMultiOrders returns an slice of the required funds for each +// of a slice of orders and the total required funds. +func (dcr *ExchangeWallet) fundsRequiredForMultiOrders(orders []*asset.MultiOrderValue, feeRate, splitBuffer uint64) ([]uint64, uint64) { + requiredForOrders := make([]uint64, len(orders)) + var totalRequired uint64 + + for i, value := range orders { + req := calc.RequiredOrderFundsAlt(value.Value, dexdcr.P2PKHInputSize, value.MaxSwapCount, + dexdcr.InitTxSizeBase, dexdcr.InitTxSize, feeRate) + req = req * (100 + splitBuffer) / 100 + requiredForOrders[i] = req + totalRequired += req + } + + return requiredForOrders, totalRequired +} + +// fundMultiBestEffort makes a best effort to fund every order. If it is not +// possible, it returns coins for the orders that could be funded. The coins +// that fund each order are returned in the same order as the values that were +// passed in. If a split is allowed and all orders cannot be funded, nil slices +// are returned. +func (dcr *ExchangeWallet) fundMultiBestEffort(keep, maxLock uint64, values []*asset.MultiOrderValue, + maxFeeRate uint64, splitAllowed bool) ([]asset.Coins, [][]dex.Bytes, []*fundingCoin, error) { utxos, err := dcr.spendableUTXOs() if err != nil { - return nil, nil, 0, 0, err + return nil, nil, nil, fmt.Errorf("error getting spendable utxos: %w", err) + } + + var avail uint64 + for _, utxo := range utxos { + avail += toAtoms(utxo.rpc.Amount) + } + + fundAllOrders := func() [][]*compositeUTXO { + indexToFundingCoins := make(map[int][]*compositeUTXO, len(values)) + remainingUTXOs := utxos + remainingOrders := values + remainingIndexes := make([]int, len(values)) + for i := range remainingIndexes { + remainingIndexes[i] = i + } + var totalFunded uint64 + for range values { + orderIndex, fundingUTXOs := dcr.orderWithLeastOverFund(maxLock-totalFunded, maxFeeRate, remainingOrders, remainingUTXOs) + if orderIndex == -1 { + return nil + } + totalFunded += sumUTXOs(fundingUTXOs) + if totalFunded > avail-keep { + return nil + } + newRemainingOrders := make([]*asset.MultiOrderValue, 0, len(remainingOrders)-1) + newRemainingIndexes := make([]int, 0, len(remainingOrders)-1) + for j := range remainingOrders { + if j != orderIndex { + newRemainingOrders = append(newRemainingOrders, remainingOrders[j]) + newRemainingIndexes = append(newRemainingIndexes, remainingIndexes[j]) + } + } + indexToFundingCoins[remainingIndexes[orderIndex]] = fundingUTXOs + remainingOrders = newRemainingOrders + remainingIndexes = newRemainingIndexes + remainingUTXOs = utxoSetDiff(remainingUTXOs, fundingUTXOs) + } + allFundingUTXOs := make([][]*compositeUTXO, len(values)) + for i := range values { + allFundingUTXOs[i] = indexToFundingCoins[i] + } + return allFundingUTXOs + } + + fundInOrder := func(orderedValues []*asset.MultiOrderValue) [][]*compositeUTXO { + allFundingUTXOs := make([][]*compositeUTXO, 0, len(orderedValues)) + remainingUTXOs := utxos + var totalFunded uint64 + for _, value := range orderedValues { + enough := orderEnough(value.Value, value.MaxSwapCount, maxFeeRate, false) + + var fundingUTXOs []*compositeUTXO + if maxLock > 0 { + if maxLock < totalFunded { + // Should never happen unless there is a bug in leastOverFundWithLimit + dcr.log.Errorf("maxLock < totalFunded. %d < %d", maxLock, totalFunded) + return allFundingUTXOs + } + fundingUTXOs = leastOverFundWithLimit(enough, maxLock-totalFunded, remainingUTXOs) + } else { + fundingUTXOs = leastOverFund(enough, remainingUTXOs) + } + if len(fundingUTXOs) == 0 { + return allFundingUTXOs + } + totalFunded += sumUTXOs(fundingUTXOs) + if totalFunded > avail-keep { + return allFundingUTXOs + } + allFundingUTXOs = append(allFundingUTXOs, fundingUTXOs) + remainingUTXOs = utxoSetDiff(remainingUTXOs, fundingUTXOs) + } + return allFundingUTXOs + } + + returnValues := func(allFundingUTXOs [][]*compositeUTXO) (coins []asset.Coins, redeemScripts [][]dex.Bytes, fundingCoins []*fundingCoin, err error) { + coins = make([]asset.Coins, len(allFundingUTXOs)) + fundingCoins = make([]*fundingCoin, 0, len(allFundingUTXOs)) + redeemScripts = make([][]dex.Bytes, len(allFundingUTXOs)) + for i, fundingUTXOs := range allFundingUTXOs { + coins[i] = make(asset.Coins, len(fundingUTXOs)) + redeemScripts[i] = make([]dex.Bytes, len(fundingUTXOs)) + for j, output := range fundingUTXOs { + txHash, err := chainhash.NewHashFromStr(output.rpc.TxID) + if err != nil { + return nil, nil, nil, fmt.Errorf("error decoding txid: %w", err) + } + coins[i][j] = newOutput(txHash, output.rpc.Vout, toAtoms(output.rpc.Amount), output.rpc.Tree) + fundingCoins = append(fundingCoins, &fundingCoin{ + op: newOutput(txHash, output.rpc.Vout, toAtoms(output.rpc.Amount), output.rpc.Tree), + addr: output.rpc.Address, + }) + redeemScript, err := hex.DecodeString(output.rpc.RedeemScript) + if err != nil { + return nil, nil, nil, fmt.Errorf("error decoding redeem script for %s, script = %s: %w", + txHash, output.rpc.RedeemScript, err) + } + redeemScripts[i][j] = redeemScript + } + } + return + } + + // Attempt to fund all orders by selecting the order that requires the least + // over funding, removing the funding utxos from the set of available utxos, + // and continuing until all orders are funded. + allFundingUTXOs := fundAllOrders() + if allFundingUTXOs != nil { + return returnValues(allFundingUTXOs) + } + + // Return nil if a split is allowed. There is no need to fund in priority + // order if a split will be done regardless. + if splitAllowed { + return returnValues([][]*compositeUTXO{}) + } + + // If could not fully fund, fund as much as possible in the priority + // order. + allFundingUTXOs = fundInOrder(values) + return returnValues(allFundingUTXOs) +} + +// fundMultiSplitTx uses the utxos provided and attempts to fund a multi-split +// transaction to fund each of the orders. If successful, it returns the +// funding coins and outputs. +func (dcr *ExchangeWallet) fundMultiSplitTx(orders []*asset.MultiOrderValue, utxos []*compositeUTXO, + splitTxFeeRate, maxFeeRate, splitBuffer, keep, maxLock uint64) (bool, asset.Coins, []*fundingCoin) { + _, totalOutputRequired := dcr.fundsRequiredForMultiOrders(orders, maxFeeRate, splitBuffer) + + var splitTxSizeWithoutInputs uint32 = dexdcr.MsgTxOverhead + numOutputs := len(orders) + if keep > 0 { + numOutputs++ + } + splitTxSizeWithoutInputs += uint32(dexdcr.P2PKHOutputSize * numOutputs) + + enough := func(sum uint64, size uint32, utxo *compositeUTXO) (bool, uint64) { + totalSum := sum + toAtoms(utxo.rpc.Amount) + totalSize := size + utxo.input.Size() + splitTxFee := uint64(splitTxSizeWithoutInputs+totalSize) * splitTxFeeRate + req := totalOutputRequired + splitTxFee + return totalSum >= req, totalSum - req + } + + var avail uint64 + for _, utxo := range utxos { + avail += toAtoms(utxo.rpc.Amount) + } + + fundSplitCoins, _, spents, _, inputsSize, err := dcr.fundInternalWithUTXOs(utxos, keep, enough, false) + if err != nil { + return false, nil, nil + } + + if maxLock > 0 { + totalSize := inputsSize + uint64(splitTxSizeWithoutInputs) + if totalOutputRequired+(totalSize*splitTxFeeRate) > maxLock { + return false, nil, nil + } + } + + return true, fundSplitCoins, spents +} + +// submitMultiSplitTx creates a multi-split transaction using fundingCoins with +// one output for each order, and submits it to the network. +func (dcr *ExchangeWallet) submitMultiSplitTx(fundingCoins asset.Coins, spents []*fundingCoin, orders []*asset.MultiOrderValue, + maxFeeRate, splitTxFeeRate, splitBuffer uint64) ([]asset.Coins, uint64, error) { + baseTx := wire.NewMsgTx() + _, err := dcr.addInputCoins(baseTx, fundingCoins) + if err != nil { + return nil, 0, err + } + + cfg := dcr.config() + getAddr := func() (stdaddr.Address, error) { + if cfg.tradingAccount != "" { + return dcr.wallet.ExternalAddress(dcr.ctx, cfg.tradingAccount) + } + return dcr.wallet.InternalAddress(dcr.ctx, cfg.primaryAcct) + } + + requiredForOrders, _ := dcr.fundsRequiredForMultiOrders(orders, maxFeeRate, splitBuffer) + outputAddresses := make([]stdaddr.Address, len(orders)) + for i, req := range requiredForOrders { + outputAddr, err := getAddr() + if err != nil { + return nil, 0, err + } + outputAddresses[i] = outputAddr + payScriptVer, payScript := outputAddr.PaymentScript() + txOut := newTxOut(int64(req), payScriptVer, payScript) + baseTx.AddTxOut(txOut) + } + + tx, err := dcr.sendWithReturn(baseTx, splitTxFeeRate, -1) + if err != nil { + return nil, 0, err + } + + coins := make([]asset.Coins, len(orders)) + fcs := make([]*fundingCoin, len(orders)) + for i := range coins { + coins[i] = asset.Coins{newOutput(tx.CachedTxHash(), uint32(i), uint64(tx.TxOut[i].Value), wire.TxTreeRegular)} + fcs[i] = &fundingCoin{ + op: newOutput(tx.CachedTxHash(), uint32(i), uint64(tx.TxOut[i].Value), wire.TxTreeRegular), + addr: outputAddresses[i].String(), + } + } + dcr.lockFundingCoins(fcs) + + var totalOut uint64 + for _, txOut := range tx.TxOut { + totalOut += uint64(txOut.Value) + } + + var totalIn uint64 + for _, txIn := range fundingCoins { + totalIn += txIn.Value() + } + + return coins, totalIn - totalOut, nil +} + +// fundMultiWithSplit creates a split transaction to fund multiple orders. It +// attempts to fund as many of the orders as possible without a split transaction, +// and only creates a split transaction for the remaining orders. This is only +// called after it has been determined that all of the orders cannot be funded +// without a split transaction. +func (dcr *ExchangeWallet) fundMultiWithSplit(keep, maxLock uint64, values []*asset.MultiOrderValue, + splitTxFeeRate, maxFeeRate, splitBuffer uint64) ([]asset.Coins, [][]dex.Bytes, uint64, error) { + utxos, err := dcr.spendableUTXOs() + if err != nil { + return nil, nil, 0, fmt.Errorf("error getting spendable utxos: %w", err) + } + + var avail uint64 + for _, utxo := range utxos { + avail += toAtoms(utxo.rpc.Amount) + } + + canFund, splitCoins, splitSpents := dcr.fundMultiSplitTx(values, utxos, splitTxFeeRate, maxFeeRate, splitBuffer, keep, maxLock) + if !canFund { + return nil, nil, 0, fmt.Errorf("cannot fund all with split") + } + + remainingUTXOs := utxos + remainingOrders := values + + // The return values must be in the same order as the values that were + // passed in, so we keep track of the original indexes here. + indexToFundingCoins := make(map[int][]*compositeUTXO, len(values)) + remainingIndexes := make([]int, len(values)) + for i := range remainingIndexes { + remainingIndexes[i] = i + } + + var totalFunded uint64 + + // Find each of the orders that can be funded without being included + // in the split transaction. + for range values { + // First find the order the can be funded with the least overlock. + // If there is no order that can be funded without going over the + // maxLock limit, or not leaving enough for bond reserves, then all + // of the remaining orders must be funded with the split transaction. + orderIndex, fundingUTXOs := dcr.orderWithLeastOverFund(maxLock-totalFunded, maxFeeRate, remainingOrders, remainingUTXOs) + if orderIndex == -1 { + break + } + totalFunded += sumUTXOs(fundingUTXOs) + if totalFunded > avail-keep { + break + } + + newRemainingOrders := make([]*asset.MultiOrderValue, 0, len(remainingOrders)-1) + newRemainingIndexes := make([]int, 0, len(remainingOrders)-1) + for j := range remainingOrders { + if j != orderIndex { + newRemainingOrders = append(newRemainingOrders, remainingOrders[j]) + newRemainingIndexes = append(newRemainingIndexes, remainingIndexes[j]) + } + } + remainingUTXOs = utxoSetDiff(remainingUTXOs, fundingUTXOs) + + // Then we make sure that a split transaction can be created for + // any remaining orders without using the utxos returned by + // orderWithLeastOverFund. + if len(newRemainingOrders) > 0 { + canFund, newSplitCoins, newSpents := dcr.fundMultiSplitTx(newRemainingOrders, remainingUTXOs, + splitTxFeeRate, maxFeeRate, splitBuffer, keep, maxLock-totalFunded) + if !canFund { + break + } + splitCoins = newSplitCoins + splitSpents = newSpents + } + + indexToFundingCoins[remainingIndexes[orderIndex]] = fundingUTXOs + remainingOrders = newRemainingOrders + remainingIndexes = newRemainingIndexes + } + + var splitOutputCoins []asset.Coins + var splitFees uint64 + + // This should always be true, otherwise this function would not have been + // called. + if len(remainingOrders) > 0 { + splitOutputCoins, splitFees, err = dcr.submitMultiSplitTx(splitCoins, + splitSpents, remainingOrders, maxFeeRate, splitTxFeeRate, splitBuffer) + if err != nil { + return nil, nil, 0, fmt.Errorf("error creating split transaction: %w", err) + } + } + + coins := make([]asset.Coins, len(values)) + redeemScripts := make([][]dex.Bytes, len(values)) + spents := make([]*fundingCoin, 0, len(values)) + + var splitIndex int + + for i := range values { + if fundingUTXOs, ok := indexToFundingCoins[i]; ok { + coins[i] = make(asset.Coins, len(fundingUTXOs)) + redeemScripts[i] = make([]dex.Bytes, len(fundingUTXOs)) + for j, unspent := range fundingUTXOs { + txHash, err := chainhash.NewHashFromStr(unspent.rpc.TxID) + if err != nil { + return nil, nil, 0, fmt.Errorf("error decoding txid from rpc server %s: %w", unspent.rpc.TxID, err) + } + output := newOutput(txHash, unspent.rpc.Vout, toAtoms(unspent.rpc.Amount), unspent.rpc.Tree) + coins[i][j] = output + fc := &fundingCoin{ + op: output, + addr: unspent.rpc.Address, + } + spents = append(spents, fc) + redeemScript, err := hex.DecodeString(unspent.rpc.RedeemScript) + if err != nil { + return nil, nil, 0, fmt.Errorf("error decoding redeem script for %s, script = %s: %w", + txHash, unspent.rpc.RedeemScript, err) + } + redeemScripts[i][j] = redeemScript + } + } else { + coins[i] = splitOutputCoins[splitIndex] + redeemScripts[i] = []dex.Bytes{nil} + splitIndex++ + } + } + + dcr.lockFundingCoins(spents) + + return coins, redeemScripts, splitFees, nil +} + +// fundMulti first attempts to fund each of the orders with with the available +// UTXOs. If a split is not allowed, it will fund the orders that it was able +// to fund. If splitting is allowed, a split transaction will be created to fund +// all of the orders. +func (dcr *ExchangeWallet) fundMulti(maxLock uint64, values []*asset.MultiOrderValue, splitTxFeeRate, maxFeeRate uint64, allowSplit bool, splitBuffer uint64) ([]asset.Coins, [][]dex.Bytes, uint64, error) { + dcr.fundingMtx.Lock() + defer dcr.fundingMtx.Unlock() + + reserves := dcr.reserves() + + coins, redeemScripts, fundingCoins, err := dcr.fundMultiBestEffort(reserves, maxLock, values, maxFeeRate, allowSplit) + if err != nil { + return nil, nil, 0, err + } + if len(coins) == len(values) || !allowSplit { + dcr.lockFundingCoins(fundingCoins) + return coins, redeemScripts, 0, nil + } + + return dcr.fundMultiWithSplit(reserves, maxLock, values, splitTxFeeRate, maxFeeRate, splitBuffer) +} + +func (dcr *ExchangeWallet) FundMultiOrder(mo *asset.MultiOrder, maxLock uint64) (coins []asset.Coins, redeemScripts [][]dex.Bytes, fundingFees uint64, err error) { + var totalRequiredForOrders uint64 + for _, value := range mo.Values { + if value.Value == 0 { + return nil, nil, 0, fmt.Errorf("cannot fund value = 0") + } + if value.MaxSwapCount == 0 { + return nil, nil, 0, fmt.Errorf("cannot fund zero-lot order") + } + req := calc.RequiredOrderFundsAlt(value.Value, dexdcr.P2PKHInputSize, value.MaxSwapCount, + dexdcr.InitTxSizeBase, dexdcr.InitTxSize, mo.MaxFeeRate) + totalRequiredForOrders += req + } + + if maxLock < totalRequiredForOrders && maxLock != 0 { + return nil, nil, 0, fmt.Errorf("maxLock < totalRequiredForOrders (%d < %d)", maxLock, totalRequiredForOrders) + } + + if mo.FeeSuggestion > mo.MaxFeeRate { + return nil, nil, 0, fmt.Errorf("fee suggestion %d > max fee rate %d", mo.FeeSuggestion, mo.MaxFeeRate) + } + + cfg := dcr.config() + if cfg.feeRateLimit < mo.MaxFeeRate { + return nil, nil, 0, fmt.Errorf( + "%v: server's max fee rate %v higher than configured fee rate limit %v", + dex.BipIDSymbol(BipID), mo.MaxFeeRate, cfg.feeRateLimit) + } + + bal, err := dcr.Balance() + if err != nil { + return nil, nil, 0, fmt.Errorf("error getting balance: %w", err) + } + if bal.Available < totalRequiredForOrders { + return nil, nil, 0, fmt.Errorf("insufficient funds. %d < %d", bal.Available, totalRequiredForOrders) + } + + customCfg, err := decodeFundMultiOptions(mo.Options) + if err != nil { + return nil, nil, 0, fmt.Errorf("error decoding options: %w", err) + } + + var useSplit bool + var splitBuffer uint64 + if customCfg.Split != nil { + useSplit = *customCfg.Split + } + if useSplit && customCfg.SplitBuffer != nil { + splitBuffer = *customCfg.SplitBuffer } + return dcr.fundMulti(maxLock, mo.Values, mo.FeeSuggestion, mo.MaxFeeRate, useSplit, splitBuffer) +} + +// fundOrder finds coins from a set of UTXOs for a specified value. This method +// is the same as "fund", except the UTXOs must be passed in, and fundingMtx +// must be held by the caller. +func (dcr *ExchangeWallet) fundInternalWithUTXOs(utxos []*compositeUTXO, keep uint64, // leave utxos for this reserve amt + enough func(sum uint64, size uint32, unspent *compositeUTXO) (bool, uint64), lock bool) ( + coins asset.Coins, redeemScripts []dex.Bytes, spents []*fundingCoin, sum, size uint64, err error) { avail := sumUTXOs(utxos) if keep > avail { // skip utxo selection if we can't possibly make reserves - return nil, nil, 0, 0, asset.ErrInsufficientBalance + return nil, nil, nil, 0, 0, asset.ErrInsufficientBalance } var sz uint32 - var spents []*fundingCoin // First take some UTXOs out of the mix for any keep amount. Select these // with the objective of being as close to the amount as possible, unlike @@ -1979,7 +2508,7 @@ func (dcr *ExchangeWallet) fund(keep uint64, // leave utxos for this reserve amt // mitigate subsequent order funding failure due to reserves because we know // this order will leave behind sufficient UTXOs without relying on change. if keep > 0 { - kept := leastOverFund(keep, utxos) + kept := leastOverFund(reserveEnough(keep), utxos) dcr.log.Debugf("Setting aside %v DCR in %d UTXOs to respect the %v DCR reserved amount", toDCR(sumUTXOs(kept)), len(kept), toDCR(keep)) utxosPruned := utxoSetDiff(utxos, kept) @@ -1994,10 +2523,10 @@ func (dcr *ExchangeWallet) fund(keep uint64, // leave utxos for this reserve amt var extra uint64 sum, extra, sz, coins, spents, redeemScripts, err = tryFund(utxos, enough) if err != nil { - return nil, nil, 0, 0, err + return nil, nil, nil, 0, 0, err } if avail-sum+extra < keep { - return nil, nil, 0, 0, asset.ErrInsufficientBalance + return nil, nil, nil, 0, 0, asset.ErrInsufficientBalance } // else we got lucky with the legacy funding approach and there was // either available unspent or the enough func granted spendable change. @@ -2006,11 +2535,38 @@ func (dcr *ExchangeWallet) fund(keep uint64, // leave utxos for this reserve amt } } - err = dcr.lockFundingCoins(spents) + if lock { + err = dcr.lockFundingCoins(spents) + if err != nil { + return nil, nil, nil, 0, 0, err + } + } + return coins, redeemScripts, spents, sum, uint64(sz), nil +} + +// fund finds coins for the specified value. A function is provided that can +// check whether adding the provided output would be enough to satisfy the +// needed value. Preference is given to selecting coins with 1 or more confs, +// falling back to 0-conf coins where there are not enough 1+ confs coins. If +// change should not be considered "kept" (e.g. no preceding split txn, or +// mixing sends change to umixed account where it is unusable for reserves), +// caller should return 0 extra from enough func. +func (dcr *ExchangeWallet) fund(keep uint64, // leave utxos for this reserve amt + enough func(sum uint64, size uint32, unspent *compositeUTXO) (bool, uint64)) ( + coins asset.Coins, redeemScripts []dex.Bytes, sum, size uint64, err error) { + + // Keep a consistent view of spendable and locked coins in the wallet and + // the fundingCoins map to make this safe for concurrent use. + dcr.fundingMtx.Lock() // before listing unspents in wallet + defer dcr.fundingMtx.Unlock() // hold until lockFundingCoins (wallet and map) + + utxos, err := dcr.spendableUTXOs() if err != nil { return nil, nil, 0, 0, err } - return coins, redeemScripts, sum, uint64(sz), nil + + coins, redeemScripts, _, sum, size, err = dcr.fundInternalWithUTXOs(utxos, keep, enough, true) + return coins, redeemScripts, sum, size, err } // spendableUTXOs generates a slice of spendable *compositeUTXO. @@ -2287,6 +2843,21 @@ func (dcr *ExchangeWallet) lockFundingCoins(fCoins []*fundingCoin) error { return nil } +func (dcr *ExchangeWallet) unlockFundingCoins(fCoins []*fundingCoin) error { + wireOPs := make([]*wire.OutPoint, 0, len(fCoins)) + for _, c := range fCoins { + wireOPs = append(wireOPs, wire.NewOutPoint(c.op.txHash(), c.op.vout(), c.op.tree)) + } + err := dcr.wallet.LockUnspent(dcr.ctx, true, wireOPs) + if err != nil { + return err + } + for _, c := range fCoins { + delete(dcr.fundingCoins, c.op.pt) + } + return nil +} + // ReturnCoins unlocks coins. This would be necessary in the case of a canceled // order. Coins belonging to the tradingAcct, if configured, are transferred to // the unmixed account with the exception of unspent split tx outputs which are diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index 8f8ff6e492..678a388bb8 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -13,6 +13,8 @@ import ( "math" "math/rand" "os" + "sort" + "strings" "sync" "testing" "time" @@ -21,6 +23,7 @@ import ( "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/calc" "decred.org/dcrdex/dex/config" + "decred.org/dcrdex/dex/encode" dexdcr "decred.org/dcrdex/dex/networks/dcr" "decred.org/dcrwallet/v3/rpc/client/dcrwallet" walletjson "decred.org/dcrwallet/v3/rpc/jsonrpc/types" @@ -456,7 +459,11 @@ func (c *tRPCClient) GetBalanceMinConf(_ context.Context, account string, minCon func (c *tRPCClient) LockUnspent(_ context.Context, unlock bool, ops []*wire.OutPoint) error { if unlock == false { - c.lockedCoins = ops + if c.lockedCoins == nil { + c.lockedCoins = ops + } else { + c.lockedCoins = append(c.lockedCoins, ops...) + } } return c.lockUnspentErr } @@ -1152,6 +1159,1209 @@ func checkSwapEstimate(t *testing.T, est *asset.SwapEstimate, lots, swapVal, max } } +func TestFundMultiOrder(t *testing.T) { + wallet, node, shutdown := tNewWallet() + defer shutdown() + + maxFeeRate := uint64(80) + feeSuggestion := uint64(60) + + txIDs := make([]string, 0, 5) + txHashes := make([]chainhash.Hash, 0, 5) + + addresses := []string{ + tPKHAddr.String(), + tPKHAddr.String(), + tPKHAddr.String(), + "bcrt1q4fjzhnum2krkurhg55xtadzyf76f8waqj26d0e", + "bcrt1qqnl9fhnms6gpmlmrwnsq36h4rqxwd3m4plkcw4", + } + scriptPubKeys := []string{ + hex.EncodeToString(tP2PKHScript), + hex.EncodeToString(tP2PKHScript), + hex.EncodeToString(tP2PKHScript), + "0014aa642bcf9b55876e0ee8a50cbeb4444fb493bba0", + "001404fe54de7b86901dff6374e008eaf5180ce6c775", + } + for i := 0; i < 5; i++ { + txIDs = append(txIDs, hex.EncodeToString(encode.RandomBytes(32))) + h, _ := chainhash.NewHashFromStr(txIDs[i]) + txHashes = append(txHashes, *h) + } + + expectedSplitFee := func(numInputs, numOutputs uint64) uint64 { + inputSize := uint64(dexdcr.P2PKHInputSize) + outputSize := uint64(dexdcr.P2PKHOutputSize) + return (dexdcr.MsgTxOverhead + numInputs*inputSize + numOutputs*outputSize) * feeSuggestion + } + + requiredForOrder := func(value, maxSwapCount uint64) int64 { + inputSize := uint64(dexdcr.P2PKHInputSize) + return int64(calc.RequiredOrderFundsAlt(value, inputSize, maxSwapCount, + dexdcr.InitTxSizeBase, dexdcr.InitTxSize, maxFeeRate)) + } + + type test struct { + name string + multiOrder *asset.MultiOrder + allOrNothing bool + maxLock uint64 + utxos []walletjson.ListUnspentResult + bondReservesEnforced int64 + balance uint64 + + // if expectedCoins is nil, all the coins are from + // the split output. If any of the coins are nil, + // than that output is from the split output. + expectedCoins []asset.Coins + expectedRedeemScripts [][]dex.Bytes + expectSendRawTx bool + expectedSplitFee uint64 + expectedInputs []*wire.TxIn + expectedOutputs []*wire.TxOut + expectedChange uint64 + expectedLockedCoins []*wire.OutPoint + expectErr bool + } + + tests := []*test{ + { // "split not allowed, utxos like split previously done" + name: "split not allowed, utxos like split previously done", + multiOrder: &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{ + { + Value: 1e6, + MaxSwapCount: 1, + }, + { + Value: 2e6, + MaxSwapCount: 2, + }, + }, + MaxFeeRate: maxFeeRate, + FeeSuggestion: feeSuggestion, + Options: map[string]string{ + "swapsplit": "false", + }, + }, + utxos: []walletjson.ListUnspentResult{ + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[0], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[0], + Address: addresses[0], + Amount: 19e5 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[1], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[1], + Address: addresses[1], + Amount: 35e5 / 1e8, + Vout: 0, + }, + }, + balance: 35e5, + expectedCoins: []asset.Coins{ + {newOutput(&txHashes[0], 0, 19e5, wire.TxTreeRegular)}, + {newOutput(&txHashes[1], 0, 35e5, wire.TxTreeRegular)}, + }, + expectedRedeemScripts: [][]dex.Bytes{ + {nil}, + {nil}, + }, + }, + { // "split not allowed, require multiple utxos per order" + name: "split not allowed, require multiple utxos per order", + multiOrder: &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{ + { + Value: 1e6, + MaxSwapCount: 1, + }, + { + Value: 2e6, + MaxSwapCount: 2, + }, + }, + MaxFeeRate: maxFeeRate, + FeeSuggestion: feeSuggestion, + Options: map[string]string{ + "swapsplit": "false", + }, + }, + utxos: []walletjson.ListUnspentResult{ + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[0], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[0], + Address: addresses[0], + Amount: 6e5 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[1], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[1], + Address: addresses[1], + Amount: 5e5 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[2], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[2], + Address: addresses[2], + Amount: 22e5 / 1e8, + Vout: 0, + }, + }, + balance: 33e5, + expectedCoins: []asset.Coins{ + {newOutput(&txHashes[0], 0, 6e5, wire.TxTreeRegular), newOutput(&txHashes[1], 0, 5e5, wire.TxTreeRegular)}, + {newOutput(&txHashes[2], 0, 22e5, wire.TxTreeRegular)}, + }, + expectedRedeemScripts: [][]dex.Bytes{ + {nil, nil}, + {nil}, + }, + expectedLockedCoins: []*wire.OutPoint{ + {txHashes[0], 0, wire.TxTreeRegular}, + {txHashes[1], 0, wire.TxTreeRegular}, + {txHashes[2], 0, wire.TxTreeRegular}, + }, + }, + { // "split not allowed, can only fund first order and respect maxLock" + name: "split not allowed, can only fund first order and respect maxLock", + multiOrder: &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{ + { + Value: 1e6, + MaxSwapCount: 1, + }, + { + Value: 2e6, + MaxSwapCount: 2, + }, + }, + MaxFeeRate: maxFeeRate, + FeeSuggestion: feeSuggestion, + Options: map[string]string{ + "swapsplit": "false", + }, + }, + maxLock: 32e5, + utxos: []walletjson.ListUnspentResult{ + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[2], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[2], + Address: addresses[2], + Amount: 1e6 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[0], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[0], + Address: addresses[0], + Amount: 11e5 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[1], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[1], + Address: addresses[1], + Amount: 25e5 / 1e8, + Vout: 0, + }, + }, + balance: 46e5, + expectedCoins: []asset.Coins{ + {newOutput(&txHashes[0], 0, 11e5, wire.TxTreeRegular)}, + }, + expectedRedeemScripts: [][]dex.Bytes{ + {nil}, + }, + expectedLockedCoins: []*wire.OutPoint{ + {txHashes[0], 0, wire.TxTreeRegular}, + }, + }, + { // "split not allowed, can only fund first order and respect bond reserves" + name: "no split allowed, can only fund first order and respect bond reserves", + multiOrder: &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{ + { + Value: 1e6, + MaxSwapCount: 1, + }, + { + Value: 2e6, + MaxSwapCount: 2, + }, + }, + MaxFeeRate: maxFeeRate, + FeeSuggestion: feeSuggestion, + Options: map[string]string{ + multiSplitKey: "false", + }, + }, + maxLock: 46e5, + bondReservesEnforced: 12e5, + utxos: []walletjson.ListUnspentResult{ + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[2], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[2], + Address: addresses[2], + Amount: 1e6 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[0], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[0], + Address: addresses[0], + Amount: 11e5 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[1], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[1], + Address: addresses[1], + Amount: 25e5 / 1e8, + Vout: 0, + }, + }, + balance: 46e5, + expectedCoins: []asset.Coins{ + {newOutput(&txHashes[0], 0, 11e5, wire.TxTreeRegular)}, + }, + expectedRedeemScripts: [][]dex.Bytes{ + {nil}, + }, + expectedLockedCoins: []*wire.OutPoint{ + {txHashes[0], 0, wire.TxTreeRegular}, + }, + }, + { // "split not allowed, need to fund in increasing order" + name: "no split, need to fund in increasing order", + multiOrder: &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{ + { + Value: 2e6, + MaxSwapCount: 2, + }, + { + Value: 11e5, + MaxSwapCount: 1, + }, + { + Value: 9e5, + MaxSwapCount: 1, + }, + }, + MaxFeeRate: maxFeeRate, + FeeSuggestion: feeSuggestion, + Options: map[string]string{ + multiSplitKey: "false", + }, + }, + maxLock: 50e5, + utxos: []walletjson.ListUnspentResult{ + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[0], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[0], + Address: addresses[0], + Amount: 11e5 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[1], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[1], + Address: addresses[1], + Amount: 13e5 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[2], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[2], + Address: addresses[2], + Amount: 26e5 / 1e8, + Vout: 0, + }, + }, + balance: 50e5, + expectedCoins: []asset.Coins{ + {newOutput(&txHashes[2], 0, 26e5, wire.TxTreeRegular)}, + {newOutput(&txHashes[1], 0, 13e5, wire.TxTreeRegular)}, + {newOutput(&txHashes[0], 0, 11e5, wire.TxTreeRegular)}, + }, + expectedRedeemScripts: [][]dex.Bytes{ + {nil}, + {nil}, + {nil}, + }, + expectedLockedCoins: []*wire.OutPoint{ + {txHashes[0], 0, wire.TxTreeRegular}, + {txHashes[1], 0, wire.TxTreeRegular}, + {txHashes[2], 0, wire.TxTreeRegular}, + }, + }, + { // "split allowed, no split required" + name: "split allowed, no split required", + multiOrder: &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{ + { + Value: 1e6, + MaxSwapCount: 1, + }, + { + Value: 2e6, + MaxSwapCount: 2, + }, + }, + MaxFeeRate: maxFeeRate, + FeeSuggestion: feeSuggestion, + Options: map[string]string{ + multiSplitKey: "true", + }, + }, + allOrNothing: false, + maxLock: 43e5, + utxos: []walletjson.ListUnspentResult{ + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[2], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[2], + Address: addresses[2], + Amount: 1e6 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[0], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[0], + Address: addresses[0], + Amount: 11e5 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[1], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[1], + Address: addresses[1], + Amount: 22e5 / 1e8, + Vout: 0, + }, + }, + balance: 43e5, + expectedCoins: []asset.Coins{ + {newOutput(&txHashes[0], 0, 11e5, wire.TxTreeRegular)}, + {newOutput(&txHashes[1], 0, 22e5, wire.TxTreeRegular)}, + }, + expectedRedeemScripts: [][]dex.Bytes{ + {nil}, + {nil}, + }, + expectedLockedCoins: []*wire.OutPoint{ + {txHashes[0], 0, wire.TxTreeRegular}, + {txHashes[1], 0, wire.TxTreeRegular}, + }, + }, + { // "split allowed, can fund both with split" + name: "split allowed, can fund both with split", + multiOrder: &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{ + { + Value: 15e5, + MaxSwapCount: 2, + }, + { + Value: 15e5, + MaxSwapCount: 2, + }, + }, + MaxFeeRate: maxFeeRate, + FeeSuggestion: feeSuggestion, + Options: map[string]string{ + multiSplitKey: "true", + }, + }, + utxos: []walletjson.ListUnspentResult{ + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[0], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[0], + Address: addresses[0], + Amount: 1e6 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[1], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[1], + Address: addresses[1], + Amount: (2*float64(requiredForOrder(15e5, 2)) + float64(expectedSplitFee(2, 2)) - 1e6) / 1e8, + Vout: 0, + }, + }, + maxLock: 2*uint64(requiredForOrder(15e5, 2)) + expectedSplitFee(2, 2), + balance: 2*uint64(requiredForOrder(15e5, 2)) + expectedSplitFee(2, 2), + expectSendRawTx: true, + expectedInputs: []*wire.TxIn{ + { + PreviousOutPoint: wire.OutPoint{ + Hash: txHashes[1], + Index: 0, + }, + }, + { + PreviousOutPoint: wire.OutPoint{ + Hash: txHashes[0], + Index: 0, + }, + }, + }, + expectedOutputs: []*wire.TxOut{ + wire.NewTxOut(requiredForOrder(15e5, 2), []byte{}), + wire.NewTxOut(requiredForOrder(15e5, 2), []byte{}), + }, + expectedSplitFee: expectedSplitFee(2, 2), + expectedRedeemScripts: [][]dex.Bytes{ + {nil}, + {nil}, + }, + }, + { // "split allowed, cannot fund both with split" + name: "split allowed, cannot fund both with split", + multiOrder: &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{ + { + Value: 15e5, + MaxSwapCount: 2, + }, + { + Value: 15e5, + MaxSwapCount: 2, + }, + }, + MaxFeeRate: maxFeeRate, + FeeSuggestion: feeSuggestion, + Options: map[string]string{ + multiSplitKey: "true", + }, + }, + utxos: []walletjson.ListUnspentResult{ + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[0], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[0], + Address: addresses[0], + Amount: 1e6 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[1], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[1], + Address: addresses[1], + Amount: (2*float64(requiredForOrder(15e5, 2)) + float64(expectedSplitFee(2, 2)) - 1e6) / 1e8, + Vout: 0, + }, + }, + maxLock: 2*uint64(requiredForOrder(15e5, 2)) + expectedSplitFee(2, 2) - 1, + balance: 2*uint64(requiredForOrder(15e5, 2)) + expectedSplitFee(2, 2) - 1, + expectErr: true, + }, + { // "can fund both with split and respect maxLock" + name: "can fund both with split and respect maxLock", + multiOrder: &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{ + { + Value: 15e5, + MaxSwapCount: 2, + }, + { + Value: 15e5, + MaxSwapCount: 2, + }, + }, + MaxFeeRate: maxFeeRate, + FeeSuggestion: feeSuggestion, + Options: map[string]string{ + multiSplitKey: "true", + }, + }, + utxos: []walletjson.ListUnspentResult{ + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[0], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[0], + Address: addresses[0], + Amount: float64(50e5) / 1e8, + Vout: 0, + }, + }, + balance: 50e5, + maxLock: 2*uint64(requiredForOrder(15e5, 2)) + expectedSplitFee(1, 2), + expectSendRawTx: true, + expectedInputs: []*wire.TxIn{ + { + PreviousOutPoint: wire.OutPoint{ + Hash: txHashes[0], + Index: 0, + }, + }, + }, + expectedOutputs: []*wire.TxOut{ + wire.NewTxOut(requiredForOrder(15e5, 2), []byte{}), + wire.NewTxOut(requiredForOrder(15e5, 2), []byte{}), + }, + expectedChange: 50e5 - (2*uint64(requiredForOrder(15e5, 2)) + expectedSplitFee(1, 3)), + expectedSplitFee: expectedSplitFee(1, 3), + expectedRedeemScripts: [][]dex.Bytes{ + {nil}, + {nil}, + }, + }, + { // "cannot fund both with split and respect maxLock" + name: "cannot fund both with split and respect maxLock", + multiOrder: &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{ + { + Value: 15e5, + MaxSwapCount: 2, + }, + { + Value: 15e5, + MaxSwapCount: 2, + }, + }, + MaxFeeRate: maxFeeRate, + FeeSuggestion: feeSuggestion, + Options: map[string]string{ + multiSplitKey: "true", + }, + }, + utxos: []walletjson.ListUnspentResult{ + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[0], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[0], + Address: addresses[0], + Amount: float64(50e5) / 1e8, + Vout: 0, + }, + }, + balance: 50e5, + maxLock: 2*uint64(requiredForOrder(15e5, 2)) + expectedSplitFee(1, 2) - 1, + expectErr: true, + }, + { // "split allowed, can fund both with split with bond reserves" + name: "split allowed, can fund both with split with bond reserves", + multiOrder: &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{ + { + Value: 15e5, + MaxSwapCount: 2, + }, + { + Value: 15e5, + MaxSwapCount: 2, + }, + }, + MaxFeeRate: maxFeeRate, + FeeSuggestion: feeSuggestion, + Options: map[string]string{ + multiSplitKey: "true", + }, + }, + bondReservesEnforced: 2e6, + utxos: []walletjson.ListUnspentResult{ + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[0], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[0], + Address: addresses[0], + Amount: (2*float64(requiredForOrder(15e5, 2)) + 2e6 + float64(expectedSplitFee(1, 3))) / 1e8, + Vout: 0, + }, + }, + balance: 2e6 + 2*uint64(requiredForOrder(15e5, 2)) + expectedSplitFee(1, 3), + maxLock: 2e6 + 2*uint64(requiredForOrder(15e5, 2)) + expectedSplitFee(1, 3), + expectSendRawTx: true, + expectedInputs: []*wire.TxIn{ + { + PreviousOutPoint: wire.OutPoint{ + Hash: txHashes[0], + Index: 0, + }, + }, + }, + expectedOutputs: []*wire.TxOut{ + wire.NewTxOut(requiredForOrder(15e5, 2), []byte{}), + wire.NewTxOut(requiredForOrder(15e5, 2), []byte{}), + }, + expectedChange: 2e6, + expectedSplitFee: expectedSplitFee(1, 3), + expectedRedeemScripts: [][]dex.Bytes{ + {nil}, + {nil}, + }, + }, + { // "split allowed, cannot fund both with split and keep and bond reserves" + name: "split allowed, cannot fund both with split and keep and bond reserves", + multiOrder: &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{ + { + Value: 15e5, + MaxSwapCount: 2, + }, + { + Value: 15e5, + MaxSwapCount: 2, + }, + }, + MaxFeeRate: maxFeeRate, + FeeSuggestion: feeSuggestion, + Options: map[string]string{ + multiSplitKey: "true", + }, + }, + bondReservesEnforced: 2e6, + utxos: []walletjson.ListUnspentResult{ + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[0], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[0], + Address: addresses[0], + Amount: ((2*float64(requiredForOrder(15e5, 2)) + 2e6 + float64(expectedSplitFee(1, 3))) / 1e8) - 1/1e8, + Vout: 0, + }, + }, + balance: 2e6 + 2*uint64(requiredForOrder(15e5, 2)) + expectedSplitFee(1, 3) - 1, + maxLock: 2e6 + 2*uint64(requiredForOrder(15e5, 2)) + expectedSplitFee(1, 3) - 1, + expectErr: true, + }, + { // "split with buffer" + name: "split with buffer", + multiOrder: &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{ + { + Value: 15e5, + MaxSwapCount: 2, + }, + { + Value: 15e5, + MaxSwapCount: 2, + }, + }, + MaxFeeRate: maxFeeRate, + FeeSuggestion: feeSuggestion, + Options: map[string]string{ + multiSplitKey: "true", + multiSplitBufferKey: "10", + }, + }, + utxos: []walletjson.ListUnspentResult{ + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[0], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[0], + Address: addresses[0], + Amount: (2*float64(requiredForOrder(15e5, 2)*110/100) + float64(expectedSplitFee(1, 2))) / 1e8, + Vout: 0, + }, + }, + balance: 2*uint64(requiredForOrder(15e5, 2)*110/100) + expectedSplitFee(1, 2), + maxLock: 2*uint64(requiredForOrder(15e5, 2)*110/100) + expectedSplitFee(1, 2), + expectSendRawTx: true, + expectedInputs: []*wire.TxIn{ + { + PreviousOutPoint: wire.OutPoint{ + Hash: txHashes[0], + Index: 0, + }, + }, + }, + expectedOutputs: []*wire.TxOut{ + wire.NewTxOut(requiredForOrder(15e5, 2)*110/100, []byte{}), + wire.NewTxOut(requiredForOrder(15e5, 2)*110/100, []byte{}), + }, + expectedSplitFee: expectedSplitFee(1, 2), + expectedRedeemScripts: [][]dex.Bytes{ + {nil}, + {nil}, + }, + }, + { // "split, maxLock too low to fund buffer" + name: "split, maxLock too low to fund buffer", + multiOrder: &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{ + { + Value: 15e5, + MaxSwapCount: 2, + }, + { + Value: 15e5, + MaxSwapCount: 2, + }, + }, + MaxFeeRate: maxFeeRate, + FeeSuggestion: feeSuggestion, + Options: map[string]string{ + multiSplitKey: "true", + multiSplitBufferKey: "10", + }, + }, + utxos: []walletjson.ListUnspentResult{ + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[0], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[0], + Address: addresses[0], + Amount: (2*float64(requiredForOrder(15e5, 2)*110/100) + float64(expectedSplitFee(1, 2))) / 1e8, + Vout: 0, + }, + }, + balance: 2*uint64(requiredForOrder(15e5, 2)*110/100) + expectedSplitFee(1, 2), + maxLock: 2*uint64(requiredForOrder(15e5, 2)*110/100) + expectedSplitFee(1, 2) - 1, + expectErr: true, + }, + { // "only one order needs a split, rest can be funded without" + name: "only one order needs a split, rest can be funded without", + multiOrder: &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{ + { + Value: 1e6, + MaxSwapCount: 2, + }, + { + Value: 1e6, + MaxSwapCount: 2, + }, + { + Value: 1e6, + MaxSwapCount: 2, + }, + }, + MaxFeeRate: maxFeeRate, + FeeSuggestion: feeSuggestion, + Options: map[string]string{ + multiSplitKey: "true", + }, + }, + utxos: []walletjson.ListUnspentResult{ + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[0], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[0], + Address: addresses[0], + Amount: 12e5 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[1], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[1], + Address: addresses[1], + Amount: 12e5 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[2], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[2], + Address: addresses[2], + Amount: 120e5 / 1e8, + Vout: 0, + }, + }, + maxLock: 50e5, + balance: 144e5, + expectSendRawTx: true, + expectedInputs: []*wire.TxIn{ + { + PreviousOutPoint: wire.OutPoint{ + Hash: txHashes[2], + Index: 0, + }, + }, + }, + expectedOutputs: []*wire.TxOut{ + wire.NewTxOut(requiredForOrder(1e6, 2), []byte{}), + wire.NewTxOut(120e5-requiredForOrder(1e6, 2)-int64(expectedSplitFee(1, 2)), []byte{}), + }, + expectedSplitFee: expectedSplitFee(1, 2), + expectedRedeemScripts: [][]dex.Bytes{ + {nil}, + {nil}, + {nil}, + }, + expectedCoins: []asset.Coins{ + {newOutput(&txHashes[0], 0, 12e5, wire.TxTreeRegular)}, + {newOutput(&txHashes[1], 0, 12e5, wire.TxTreeRegular)}, + nil, + }, + }, + { // "only one order needs a split due to bond reserves, rest funded without" + name: "only one order needs a split, rest can be funded without", + multiOrder: &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{ + { + Value: 1e6, + MaxSwapCount: 2, + }, + { + Value: 1e6, + MaxSwapCount: 2, + }, + { + Value: 1e6, + MaxSwapCount: 2, + }, + }, + MaxFeeRate: maxFeeRate, + FeeSuggestion: feeSuggestion, + Options: map[string]string{ + multiSplitKey: "true", + }, + }, + utxos: []walletjson.ListUnspentResult{ + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[0], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[0], + Address: addresses[0], + Amount: 12e5 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[1], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[1], + Address: addresses[1], + Amount: 12e5 / 1e8, + Vout: 0, + }, + { + Confirmations: 1, + Spendable: true, + TxID: txIDs[2], + Account: tAcctName, + ScriptPubKey: scriptPubKeys[2], + Address: addresses[2], + Amount: 120e5 / 1e8, + Vout: 0, + }, + }, + maxLock: 0, + bondReservesEnforced: 1e6, + balance: 144e5, + expectSendRawTx: true, + expectedInputs: []*wire.TxIn{ + { + PreviousOutPoint: wire.OutPoint{ + Hash: txHashes[2], + Index: 0, + }, + }, + }, + expectedOutputs: []*wire.TxOut{ + wire.NewTxOut(requiredForOrder(1e6, 2), []byte{}), + wire.NewTxOut(120e5-requiredForOrder(1e6, 2)-int64(expectedSplitFee(1, 2)), []byte{}), + }, + expectedSplitFee: expectedSplitFee(1, 2), + expectedRedeemScripts: [][]dex.Bytes{ + {nil}, + {nil}, + {nil}, + }, + expectedCoins: []asset.Coins{ + {newOutput(&txHashes[0], 0, 12e5, wire.TxTreeRegular)}, + {newOutput(&txHashes[1], 0, 12e5, wire.TxTreeRegular)}, + nil, + }, + }, + } + + for _, test := range tests { + node.unspent = test.utxos + node.changeAddr = tPKHAddr + node.signFunc = func(msgTx *wire.MsgTx) (*wire.MsgTx, bool, error) { + return signFunc(msgTx, dexdcr.P2PKHSigScriptSize) + } + node.sentRawTx = nil + node.lockedCoins = nil + node.balanceResult = &walletjson.GetBalanceResult{ + Balances: []walletjson.GetAccountBalanceResult{ + { + AccountName: tAcctName, + Spendable: toDCR(test.balance), + }, + }, + } + wallet.fundingCoins = make(map[outPoint]*fundingCoin) + wallet.bondReservesEnforced = test.bondReservesEnforced + + allCoins, _, splitFee, err := wallet.FundMultiOrder(test.multiOrder, test.maxLock) + if test.expectErr { + if err == nil { + t.Fatalf("%s: no error returned", test.name) + } + if strings.Contains(err.Error(), "insufficient funds") { + t.Fatalf("%s: unexpected insufficient funds error", test.name) + } + continue + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + if !test.expectSendRawTx { // no split + if node.sentRawTx != nil { + t.Fatalf("%s: unexpected transaction sent", test.name) + } + if len(allCoins) != len(test.expectedCoins) { + t.Fatalf("%s: expected %d coins, got %d", test.name, len(test.expectedCoins), len(allCoins)) + } + for i := range allCoins { + if len(allCoins[i]) != len(test.expectedCoins[i]) { + t.Fatalf("%s: expected %d coins in set %d, got %d", test.name, len(test.expectedCoins[i]), i, len(allCoins[i])) + } + actual := allCoins[i] + expected := test.expectedCoins[i] + sort.Slice(actual, func(i, j int) bool { + return bytes.Compare(actual[i].ID(), actual[j].ID()) < 0 + }) + sort.Slice(expected, func(i, j int) bool { + return bytes.Compare(expected[i].ID(), expected[j].ID()) < 0 + }) + for j := range actual { + if !bytes.Equal(actual[j].ID(), expected[j].ID()) { + t.Fatalf("%s: unexpected coin in set %d. expected %s, got %s", test.name, i, expected[j].ID(), actual[j].ID()) + } + if actual[j].Value() != expected[j].Value() { + t.Fatalf("%s: unexpected coin value in set %d. expected %d, got %d", test.name, i, expected[j].Value(), actual[j].Value()) + } + } + } + } else { // expectSplit + if node.sentRawTx == nil { + t.Fatalf("%s: SendRawTransaction not called", test.name) + } + if len(node.sentRawTx.TxIn) != len(test.expectedInputs) { + t.Fatalf("%s: expected %d inputs, got %d", test.name, len(test.expectedInputs), len(node.sentRawTx.TxIn)) + } + for i, actualIn := range node.sentRawTx.TxIn { + expectedIn := test.expectedInputs[i] + if !bytes.Equal(actualIn.PreviousOutPoint.Hash[:], expectedIn.PreviousOutPoint.Hash[:]) { + t.Fatalf("%s: unexpected input %d hash. expected %s, got %s", test.name, i, expectedIn.PreviousOutPoint.Hash, actualIn.PreviousOutPoint.Hash) + } + if actualIn.PreviousOutPoint.Index != expectedIn.PreviousOutPoint.Index { + t.Fatalf("%s: unexpected input %d index. expected %d, got %d", test.name, i, expectedIn.PreviousOutPoint.Index, actualIn.PreviousOutPoint.Index) + } + } + expectedNumOutputs := len(test.expectedOutputs) + if test.expectedChange > 0 { + expectedNumOutputs++ + } + if len(node.sentRawTx.TxOut) != expectedNumOutputs { + t.Fatalf("%s: expected %d outputs, got %d", test.name, expectedNumOutputs, len(node.sentRawTx.TxOut)) + } + + for i, expectedOut := range test.expectedOutputs { + actualOut := node.sentRawTx.TxOut[i] + if actualOut.Value != expectedOut.Value { + t.Fatalf("%s: unexpected output %d value. expected %d, got %d", test.name, i, expectedOut.Value, actualOut.Value) + } + } + if test.expectedChange > 0 { + actualOut := node.sentRawTx.TxOut[len(node.sentRawTx.TxOut)-1] + if uint64(actualOut.Value) != test.expectedChange { + t.Fatalf("%s: unexpected change value. expected %d, got %d", test.name, test.expectedChange, actualOut.Value) + } + } + + if len(test.multiOrder.Values) != len(allCoins) { + t.Fatalf("%s: expected %d coins, got %d", test.name, len(test.multiOrder.Values), len(allCoins)) + } + splitTxID := node.sentRawTx.TxHash() + + // This means all coins are split outputs + if test.expectedCoins == nil { + for i, actualCoin := range allCoins { + actualOut := actualCoin[0].(*output) + expectedOut := node.sentRawTx.TxOut[i] + if uint64(expectedOut.Value) != actualOut.value { + t.Fatalf("%s: unexpected output %d value. expected %d, got %d", test.name, i, expectedOut.Value, actualOut.value) + } + if !bytes.Equal(actualOut.pt.txHash[:], splitTxID[:]) { + t.Fatalf("%s: unexpected output %d txid. expected %s, got %s", test.name, i, splitTxID, actualOut.pt.txHash) + } + } + } else { + var splitTxOutputIndex int + for i := range allCoins { + actual := allCoins[i] + expected := test.expectedCoins[i] + + // This means the coins are the split outputs + if expected == nil { + actualOut := actual[0].(*output) + expectedOut := node.sentRawTx.TxOut[splitTxOutputIndex] + if uint64(expectedOut.Value) != actualOut.value { + t.Fatalf("%s: unexpected output %d value. expected %d, got %d", test.name, i, expectedOut.Value, actualOut.value) + } + if !bytes.Equal(actualOut.pt.txHash[:], splitTxID[:]) { + t.Fatalf("%s: unexpected output %d txid. expected %s, got %s", test.name, i, splitTxID, actualOut.pt.txHash) + } + splitTxOutputIndex++ + continue + } + + if len(actual) != len(expected) { + t.Fatalf("%s: expected %d coins in set %d, got %d", test.name, len(test.expectedCoins[i]), i, len(allCoins[i])) + } + sort.Slice(actual, func(i, j int) bool { + return bytes.Compare(actual[i].ID(), actual[j].ID()) < 0 + }) + sort.Slice(expected, func(i, j int) bool { + return bytes.Compare(expected[i].ID(), expected[j].ID()) < 0 + }) + for j := range actual { + if !bytes.Equal(actual[j].ID(), expected[j].ID()) { + t.Fatalf("%s: unexpected coin in set %d. expected %s, got %s", test.name, i, expected[j].ID(), actual[j].ID()) + } + if actual[j].Value() != expected[j].Value() { + t.Fatalf("%s: unexpected coin value in set %d. expected %d, got %d", test.name, i, expected[j].Value(), actual[j].Value()) + } + } + } + } + + // Each split output should be locked + if len(node.lockedCoins) != len(allCoins) { + t.Fatalf("%s: expected %d locked coins, got %d", test.name, len(allCoins), len(node.lockedCoins)) + } + + } + + // Check that the right coins are locked and in the fundingCoins map + var totalNumCoins int + for _, coins := range allCoins { + totalNumCoins += len(coins) + } + if totalNumCoins != len(wallet.fundingCoins) { + t.Fatalf("%s: expected %d funding coins in wallet, got %d", test.name, totalNumCoins, len(wallet.fundingCoins)) + } + //totalNumCoins += len(test.expectedInputs) + if totalNumCoins != len(node.lockedCoins) { + t.Fatalf("%s: expected %d locked coins, got %d", test.name, totalNumCoins, len(node.lockedCoins)) + } + lockedCoins := make(map[wire.OutPoint]interface{}) + for _, coin := range node.lockedCoins { + lockedCoins[*coin] = true + } + checkLockedCoin := func(txHash chainhash.Hash, vout uint32) { + if _, ok := lockedCoins[wire.OutPoint{Hash: txHash, Index: vout, Tree: wire.TxTreeRegular}]; !ok { + t.Fatalf("%s: expected locked coin %s:%d not found", test.name, txHash, vout) + } + } + checkFundingCoin := func(txHash chainhash.Hash, vout uint32) { + if _, ok := wallet.fundingCoins[outPoint{txHash: txHash, vout: vout}]; !ok { + t.Fatalf("%s: expected locked coin %s:%d not found in wallet", test.name, txHash, vout) + } + } + for _, coins := range allCoins { + for _, coin := range coins { + // decode coin to output + out := coin.(*output) + checkLockedCoin(out.pt.txHash, out.pt.vout) + checkFundingCoin(out.pt.txHash, out.pt.vout) + } + } + //for _, expectedIn := range test.expectedInputs { + // checkLockedCoin(expectedIn.PreviousOutPoint.Hash, expectedIn.PreviousOutPoint.Index) + //} + + if test.expectedSplitFee != splitFee { + t.Fatalf("%s: unexpected split fee. expected %d, got %d", test.name, test.expectedSplitFee, splitFee) + } + } +} + func TestFundEdges(t *testing.T) { wallet, node, shutdown := tNewWallet() defer shutdown() diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index 49009aa4ab..73e09b1108 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -447,8 +447,6 @@ var _ asset.DynamicSwapper = (*ETHWallet)(nil) var _ asset.DynamicSwapper = (*TokenWallet)(nil) var _ asset.Authenticator = (*ETHWallet)(nil) var _ asset.TokenApprover = (*TokenWallet)(nil) -var _ asset.MultiOrderFunder = (*ETHWallet)(nil) -var _ asset.MultiOrderFunder = (*TokenWallet)(nil) type baseWallet struct { // The asset subsystem starts with Connect(ctx). This ctx will be initialized diff --git a/client/asset/eth/eth_test.go b/client/asset/eth/eth_test.go index bffd0a2073..88580daf98 100644 --- a/client/asset/eth/eth_test.go +++ b/client/asset/eth/eth_test.go @@ -1472,10 +1472,6 @@ func TestFundMultiOrder(t *testing.T) { func testFundMultiOrder(t *testing.T, assetID uint32) { w, eth, node, shutdown := tassetWallet(assetID) - multiFunder, ok := w.(asset.MultiOrderFunder) - if !ok { - t.Fatalf("wallet does not implement MultiOrderFunder") - } defer shutdown() @@ -1692,7 +1688,7 @@ func testFundMultiOrder(t *testing.T, assetID uint32) { eth.lockedFunds.initiateReserves = 0 eth.baseWallet.wallets[BipID].lockedFunds.initiateReserves = 0 - allCoins, redeemScripts, _, err := multiFunder.FundMultiOrder(test.multiOrder, test.maxLock) + allCoins, redeemScripts, _, err := w.FundMultiOrder(test.multiOrder, test.maxLock) if test.expectErr { if err == nil { t.Fatalf("%s: expected error but did not get one", test.name) diff --git a/client/asset/interface.go b/client/asset/interface.go index 6cf96191bf..d02c799cd5 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -499,11 +499,10 @@ type Wallet interface { SingleLotSwapFees(version uint32, feeRate uint64, options map[string]string) (uint64, error) // SingleLotRedeemFees returns the fees for a redeem transaction for a single lot. SingleLotRedeemFees(version uint32, feeRate uint64, options map[string]string) (uint64, error) -} - -// MultiOrderFunder is a wallet that can fund multiple orders at once. -// This will be part of the Wallet interface once all wallets support it. -type MultiOrderFunder interface { + // FundMultiOrder funds multiple orders at once. The return values will + // be in the same order as the passed orders. If less values are returned + // than the number of orders, then the orders at the end of the list were + // not about to be funded. FundMultiOrder(ord *MultiOrder, maxLock uint64) (coins []Coins, redeemScripts [][]dex.Bytes, fundingFees uint64, err error) } diff --git a/client/core/core.go b/client/core/core.go index 8d2bf71c04..4d18c71b19 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -6143,11 +6143,6 @@ func (c *Core) prepareMultiTradeRequests(pw []byte, form *MultiTradeForm) ([]*tr } fromWallet, toWallet := wallets.fromWallet, wallets.toWallet - multiFunder, is := fromWallet.Wallet.(asset.MultiOrderFunder) - if !is { - return nil, newError(orderParamsErr, "fromWallet is not a MultiOrderFunder") - } - for _, trade := range form.Placements { if trade.Rate == 0 { return nil, newError(orderParamsErr, "zero rate is invalid") @@ -6184,7 +6179,7 @@ func (c *Core) prepareMultiTradeRequests(pw []byte, form *MultiTradeForm) ([]*tr }) } - allCoins, allRedeemScripts, fundingFees, err := multiFunder.FundMultiOrder(&asset.MultiOrder{ + allCoins, allRedeemScripts, fundingFees, err := fromWallet.FundMultiOrder(&asset.MultiOrder{ Version: assetConfigs.fromAsset.Version, Values: orderValues, MaxFeeRate: assetConfigs.fromAsset.MaxFeeRate,