From 436d31f5929f0613d73415b7e6bd65ae29772b19 Mon Sep 17 00:00:00 2001 From: buck54321 Date: Mon, 26 Aug 2024 06:22:04 -0500 Subject: [PATCH] zec: fix zcash multisplit (#2931) * fix zcash multisplit * bump rpc version * don't use btc.GetTransactionResult --- client/asset/btc/btc.go | 9 +- client/asset/btc/coinmanager.go | 6 +- client/asset/zec/regnet_test.go | 246 ++++++++++++++++ client/asset/zec/shielded_rpc.go | 4 +- client/asset/zec/transparent_rpc.go | 17 +- client/asset/zec/zec.go | 425 ++++++++------------------- client/asset/zec/zec_test.go | 24 +- client/cmd/testbinance/main.go | 4 + client/cmd/testbinance/wallets.go | 26 +- client/mm/exchange_adaptor.go | 14 + client/mm/libxc/binance_live_test.go | 7 +- dex/testing/dcrdex/genmarkets.sh | 4 +- 12 files changed, 440 insertions(+), 346 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index e02e3e391d..c1a50142b5 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -2626,7 +2626,7 @@ func (btc *baseWallet) submitMultiSplitTx(fundingCoins asset.Coins, spents []*Ou Address: outputAddresses[i].String(), } } - btc.cm.LockOutputs(locks) + btc.cm.LockUTXOs(locks) btc.node.lockUnspent(false, ops) var totalOut uint64 @@ -2760,8 +2760,7 @@ func (btc *baseWallet) fundMultiWithSplit(keep, maxLock uint64, values []*asset. } } - btc.cm.LockOutputs(locks) - + btc.cm.LockUTXOs(locks) btc.node.lockUnspent(false, spents) return coins, redeemScripts, splitFees, nil @@ -3360,7 +3359,7 @@ func accelerateOrder(btc *baseWallet, swapCoins, accelerationCoins []dex.Bytes, // Log it as a fundingCoin, since it is expected that this will be // chained into further matches. - btc.cm.LockOutputs([]*UTxO{{ + btc.cm.LockUTXOs([]*UTxO{{ TxHash: newChange.txHash(), Vout: newChange.vout(), Address: newChange.String(), @@ -3880,7 +3879,7 @@ func (btc *baseWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin, ui }) } - btc.cm.LockOutputs(locks) + btc.cm.LockUTXOs(locks) btc.cm.UnlockOutPoints(pts) return receipts, changeCoin, fees, nil diff --git a/client/asset/btc/coinmanager.go b/client/asset/btc/coinmanager.go index e467322f9e..43d9e6e106 100644 --- a/client/asset/btc/coinmanager.go +++ b/client/asset/btc/coinmanager.go @@ -527,8 +527,10 @@ func (c *CoinManager) FundingCoins(ids []dex.Bytes) (asset.Coins, error) { return coins, nil } -// LockOutputs locks the specified utxos. -func (c *CoinManager) LockOutputs(utxos []*UTxO) { +// LockUTXOs locks the specified utxos. +// TODO: Move lockUnspent calls into this method instead of the caller doing it +// at every callsite, and because that's what we do with unlocking. +func (c *CoinManager) LockUTXOs(utxos []*UTxO) { c.mtx.Lock() for _, utxo := range utxos { c.lockedOutputs[NewOutPoint(utxo.TxHash, utxo.Vout)] = utxo diff --git a/client/asset/zec/regnet_test.go b/client/asset/zec/regnet_test.go index 6445170a3f..419949426e 100644 --- a/client/asset/zec/regnet_test.go +++ b/client/asset/zec/regnet_test.go @@ -8,14 +8,22 @@ import ( "context" "encoding/json" "fmt" + "os" + "os/exec" "os/user" "path/filepath" "testing" + "time" + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/client/asset/btc" "decred.org/dcrdex/client/asset/btc/livetest" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/config" + dexbtc "decred.org/dcrdex/dex/networks/btc" dexzec "decred.org/dcrdex/dex/networks/zec" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/decred/dcrd/rpcclient/v8" ) @@ -152,3 +160,241 @@ func testDeserializeBlocks(t *testing.T, port string, upgradeHeights ...int64) { ver-- } } + +func TestMultiSplit(t *testing.T) { + log := dex.StdOutLogger("T", dex.LevelTrace) + c := make(chan asset.WalletNotification, 16) + tmpDir, _ := os.MkdirTemp("", "") + defer os.RemoveAll(tmpDir) + walletCfg := &asset.WalletConfig{ + Type: walletTypeRPC, + Settings: map[string]string{ + "txsplit": "true", + "rpcuser": "user", + "rpcpassword": "pass", + "regtest": "1", + "rpcport": "33770", + }, + Emit: asset.NewWalletEmitter(c, BipID, log), + PeersChange: func(u uint32, err error) { + log.Info("peers changed", u, err) + }, + DataDir: tmpDir, + } + wi, err := NewWallet(walletCfg, log, dex.Simnet) + if err != nil { + t.Fatalf("Error making new wallet: %v", err) + } + w := wi.(*zecWallet) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + for { + select { + case n := <-c: + log.Infof("wallet note emitted: %+v", n) + case <-ctx.Done(): + return + } + } + }() + + cm := dex.NewConnectionMaster(w) + if err := cm.ConnectOnce(ctx); err != nil { + t.Fatalf("Error connecting wallet: %v", err) + } + + // Unlock all transparent outputs. + if ops, err := listLockUnspent(w, log); err != nil { + t.Fatalf("Error listing unspent outputs: %v", err) + } else if len(ops) > 0 { + coins := make([]*btc.Output, len(ops)) + for i, op := range ops { + txHash, _ := chainhash.NewHashFromStr(op.TxID) + coins[i] = btc.NewOutput(txHash, op.Vout, 0) + } + if err := lockUnspent(w, true, coins); err != nil { + t.Fatalf("Error unlocking coins") + } + log.Info("Unlocked %d transparent outputs", len(ops)) + } + + bals, err := w.balances() + if err != nil { + t.Fatalf("Error getting wallet balance: %v", err) + } + + var v0, v1 uint64 = 1e8, 2e8 + orderReq0, orderReq1 := dexzec.RequiredOrderFunds(v0, 1, dexbtc.RedeemP2PKHInputSize, 1), dexzec.RequiredOrderFunds(v1, 1, dexbtc.RedeemP2PKHInputSize, 2) + + tAddr := func() string { + addr, err := transparentAddressString(w) + if err != nil { + t.Fatalf("Error getting transparent address: %v", err) + } + return addr + } + + // Send everything to a transparent address. + unspents, err := listUnspent(w) + if err != nil { + t.Fatalf("listUnspent error: %v", err) + } + fees := dexzec.TxFeesZIP317(1+(dexbtc.RedeemP2PKHInputSize*uint64(len(unspents))), 1+(3*dexbtc.P2PKHOutputSize), 0, 0, 0, uint64(bals.orchard.noteCount)) + netBal := bals.available() - fees + changeVal := netBal - orderReq0 - orderReq1 + + recips := []*zSendManyRecipient{ + {Address: tAddr(), Amount: btcutil.Amount(orderReq0).ToBTC()}, + {Address: tAddr(), Amount: btcutil.Amount(orderReq1).ToBTC()}, + {Address: tAddr(), Amount: btcutil.Amount(changeVal).ToBTC()}, + } + + txHash, err := w.sendManyShielded(recips) + if err != nil { + t.Fatalf("sendManyShielded error: %v", err) + } + + log.Infof("z_sendmany successful. txid = %s", txHash) + + // Could be orchard notes. Mature them. + if err := mineAlpha(ctx); err != nil { + t.Fatalf("Error mining a block: %v", err) + } + + // All funds should be transparent now. + multiFund := &asset.MultiOrder{ + Version: version, + Values: []*asset.MultiOrderValue{ + {Value: v0, MaxSwapCount: 1}, + {Value: v1, MaxSwapCount: 2}, + }, + Options: map[string]string{"multisplit": "true"}, + } + + checkFundMulti := func(expSplit bool) { + t.Helper() + coinSets, _, fundingFees, err := w.FundMultiOrder(multiFund, 0) + if err != nil { + t.Fatalf("FundMultiOrder error: %v", err) + } + + if len(coinSets) != 2 || len(coinSets[0]) != 1 || len(coinSets[1]) != 1 { + t.Fatalf("Expected 2 coin sets of len 1 each, got %+v", coinSets) + } + + coin0, coin1 := coinSets[0][0], coinSets[1][0] + + if err := w.cm.ReturnCoins(asset.Coins{coin0, coin1}); err != nil { + t.Fatalf("ReturnCoins error: %v", err) + } + + if coin0.Value() != orderReq0 { + t.Fatalf("coin 0 had insufficient value: %d < %d", coin0.Value(), orderReq0) + } + + if coin1.Value() < orderReq1 { + t.Fatalf("coin 1 had insufficient value: %d < %d", coin1.Value(), orderReq1) + } + + // Should be no split tx. + split := fundingFees > 0 + if split != expSplit { + t.Fatalf("Expected split %t, got %t", expSplit, split) + } + + log.Infof("Coin 0: %s", coin0) + log.Infof("Coin 1: %s", coin1) + log.Infof("Funding fees: %d", fundingFees) + } + + checkFundMulti(false) // no split + + // Could be orchard notes. Mature them. + if err := mineAlpha(ctx); err != nil { + t.Fatalf("Error mining a block: %v", err) + } + + // Send everything to a single transparent address to test for a + // fully-transparent split tx. + splitFees := dexzec.TxFeesZIP317(1+(3*dexbtc.RedeemP2PKHInputSize), 1+dexbtc.P2PKHOutputSize, 0, 0, 0, 0) + netBal -= splitFees + txHash, err = w.sendOneShielded(ctx, tAddr(), netBal, NoPrivacy) + if err != nil { + t.Fatalf("sendOneShielded(transparent) error: %v", err) + } + log.Infof("Sent all to transparent with tx %s", txHash) + + // Could be orchard notes. Mature them. + if err := mineAlpha(ctx); err != nil { + t.Fatalf("Error mining a block: %v", err) + } + + checkFundMulti(true) // fully-transparent split + + // Could be orchard notes. Mature them. + if err := mineAlpha(ctx); err != nil { + t.Fatalf("Error mining a block: %v", err) + } + + // Send everything to a shielded address. + addrRes, err := zGetAddressForAccount(w, shieldedAcctNumber, []string{transparentAddressType, orchardAddressType}) + if err != nil { + t.Fatalf("zGetAddressForAccount error: %v", err) + } + receivers, err := zGetUnifiedReceivers(w, addrRes.Address) + if err != nil { + t.Fatalf("zGetUnifiedReceivers error: %v", err) + } + orchardAddr := receivers.Orchard + + bals, err = w.balances() + if err != nil { + t.Fatalf("Error getting wallet balance: %v", err) + } + unspents, err = listUnspent(w) + if err != nil { + t.Fatalf("listUnspent error: %v", err) + } + + splitFees = dexzec.TxFeesZIP317(1+(dexbtc.RedeemP2PKHInputSize*uint64(len(unspents))), 1, 0, 0, 0, uint64(bals.orchard.noteCount)) + netBal = bals.available() - splitFees + + txHash, err = w.sendOneShielded(ctx, orchardAddr, netBal, NoPrivacy) + if err != nil { + t.Fatalf("sendManyShielded error: %v", err) + } + log.Infof("sendOneShielded(shielded) successful. txid = %s", txHash) + + // Could be orchard notes. Mature them. + if err := mineAlpha(ctx); err != nil { + t.Fatalf("Error mining a block: %v", err) + } + + checkFundMulti(true) // shielded split + + cancel() + cm.Wait() +} + +func mineAlpha(ctx context.Context) error { + // Wait for txs to propagate + select { + case <-time.After(time.Second * 5): + case <-ctx.Done(): + return ctx.Err() + } + // Mine + if err := exec.Command("tmux", "send-keys", "-t", "zec-harness:4", "./mine-alpha 1", "C-m").Run(); err != nil { + return err + } + // Wait for blocks to propagate + select { + case <-time.After(time.Second * 5): + case <-ctx.Done(): + return ctx.Err() + } + return nil +} diff --git a/client/asset/zec/shielded_rpc.go b/client/asset/zec/shielded_rpc.go index 44546bdec1..baa03b9acc 100644 --- a/client/asset/zec/shielded_rpc.go +++ b/client/asset/zec/shielded_rpc.go @@ -153,8 +153,8 @@ const ( // z_sendmany "fromaddress" [{"address":... ,"amount":...},...] ( minconf ) ( fee ) ( privacyPolicy ) func zSendMany(c rpcCaller, fromAddress string, recips []*zSendManyRecipient, priv privacyPolicy) (operationID string, err error) { - const minConf, fee = 1, 0.00001 - return operationID, c.CallRPC(methodZSendMany, []any{fromAddress, recips, minConf, fee, priv}, &operationID) + const minConf = 1 + return operationID, c.CallRPC(methodZSendMany, []any{fromAddress, recips, minConf, nil, priv}, &operationID) } type opResult struct { diff --git a/client/asset/zec/transparent_rpc.go b/client/asset/zec/transparent_rpc.go index f53b792be6..2771e657c9 100644 --- a/client/asset/zec/transparent_rpc.go +++ b/client/asset/zec/transparent_rpc.go @@ -43,8 +43,19 @@ type zTx struct { blockHash *chainhash.Hash } +type GetTransactionResult struct { + Confirmations int64 `json:"confirmations"` + BlockHash string `json:"blockhash"` + // BlockIndex int64 `json:"blockindex"` // unused, consider commenting + BlockTime uint64 `json:"blocktime"` + TxID string `json:"txid"` + Time uint64 `json:"time"` + TimeReceived uint64 `json:"timereceived"` + Bytes dex.Bytes `json:"hex"` +} + func getTransaction(c rpcCaller, txHash *chainhash.Hash) (*zTx, error) { - var tx btc.GetTransactionResult + var tx GetTransactionResult if err := c.CallRPC("gettransaction", []any{txHash.String()}, &tx); err != nil { return nil, err } @@ -245,8 +256,8 @@ func getRPCBlockHeader(c rpcCaller, blockHash *chainhash.Hash) (*btc.BlockHeader return blkHeader, nil } -func getWalletTransaction(c rpcCaller, txHash *chainhash.Hash) (*btc.GetTransactionResult, error) { - var tx btc.GetTransactionResult +func getWalletTransaction(c rpcCaller, txHash *chainhash.Hash) (*GetTransactionResult, error) { + var tx GetTransactionResult err := c.CallRPC("gettransaction", []any{txHash.String()}, &tx) if err != nil { if btc.IsTxNotFoundErr(err) { diff --git a/client/asset/zec/zec.go b/client/asset/zec/zec.go index e01c0d8a1d..e1e4bb329b 100644 --- a/client/asset/zec/zec.go +++ b/client/asset/zec/zec.go @@ -46,7 +46,7 @@ const ( // structure. defaultFee = 10 defaultFeeRateLimit = 1000 - minNetworkVersion = 5040250 // v5.4.2 + minNetworkVersion = 5090150 // v5.9.1 walletTypeRPC = "zcashdRPC" // defaultConfTarget is the amount of confirmations required to consider @@ -138,7 +138,6 @@ var ( DefaultConfigPath: dexbtc.SystemConfigPath("zcash"), ConfigOpts: configOpts, NoAuth: true, - MultiFundingOpts: btc.MultiFundingOpts, }}, } @@ -513,7 +512,22 @@ func (w *zecWallet) prepareRedemptionFinder() { w.rf = btc.NewRedemptionFinder( w.log, func(h *chainhash.Hash) (*btc.GetTransactionResult, error) { - return getWalletTransaction(w, h) + tx, err := getWalletTransaction(w, h) + if err != nil { + return nil, err + } + if tx.Confirmations < 0 { + tx.Confirmations = 0 + } + return &btc.GetTransactionResult{ + Confirmations: uint64(tx.Confirmations), + BlockHash: tx.BlockHash, + BlockTime: tx.BlockTime, + TxID: tx.TxID, + Time: tx.Time, + TimeReceived: tx.TimeReceived, + Bytes: tx.Bytes, + }, nil }, func(h *chainhash.Hash) (int32, error) { return getBlockHeight(w, h) @@ -1336,7 +1350,11 @@ type balanceBreakdown struct { type balances struct { orchard *balanceBreakdown sapling uint64 - transparent uint64 + transparent *balanceBreakdown +} + +func (b *balances) available() uint64 { + return b.orchard.avail + b.transparent.avail } func (w *zecWallet) balances() (*balances, error) { @@ -1358,8 +1376,11 @@ func (w *zecWallet) balances() (*balances, error) { maturing: zeroConf.Orchard - mature.Orchard, noteCount: noteCounts.Orchard, }, - sapling: zeroConf.Sapling, - transparent: zeroConf.Transparent, + sapling: zeroConf.Sapling, + transparent: &balanceBreakdown{ + avail: mature.Transparent, + maturing: zeroConf.Transparent - mature.Transparent, + }, }, nil } @@ -1559,8 +1580,11 @@ func (w *zecWallet) ConfirmRedemption(coinID dex.Bytes, redemption *asset.Redemp // mempool for a long amount of time, possibly requiring some action by // us to get it unstuck. if err == nil { + if tx.Confirmations < 0 { + tx.Confirmations = 0 + } return &asset.ConfirmRedemptionStatus{ - Confs: tx.Confirmations, + Confs: uint64(tx.Confirmations), Req: requiredRedeemConfirms, CoinID: coinID, }, nil @@ -1652,19 +1676,6 @@ func (w *zecWallet) DepositAddress() (string, error) { } -func (w *zecWallet) transparentAddress() (string, error) { - addrRes, err := zGetAddressForAccount(w, shieldedAcctNumber, []string{transparentAddressType, orchardAddressType}) - if err != nil { - return "", err - } - receivers, err := zGetUnifiedReceivers(w, addrRes.Address) - if err != nil { - return "", err - } - return receivers.Transparent, nil - -} - func (w *zecWallet) NewAddress() (string, error) { return w.DepositAddress() } @@ -1685,7 +1696,7 @@ func (w *zecWallet) FundMultiOrder(mo *asset.MultiOrder, maxLock uint64) (coins if v.MaxSwapCount == 0 { return nil, nil, 0, fmt.Errorf("cannot fund zero-lot order") } - req := dexzec.RequiredOrderFunds(v.Value, 1, swapInputSize+1, v.MaxSwapCount) + req := dexzec.RequiredOrderFunds(v.Value, 1, swapInputSize, v.MaxSwapCount) totalRequiredForOrders += req } @@ -1701,273 +1712,115 @@ func (w *zecWallet) FundMultiOrder(mo *asset.MultiOrder, maxLock uint64) (coins return nil, nil, 0, newError(errInsufficientBalance, "insufficient funds. %d < %d", bal.Available, totalRequiredForOrders) } - customCfg, err := decodeFundMultiSettings(mo.Options) - if err != nil { - return nil, nil, 0, fmt.Errorf("error decoding options: %w", err) - } - - return w.fundMulti(maxLock, mo.Values, mo.FeeSuggestion, mo.MaxFeeRate, customCfg.Split) -} - -func (w *zecWallet) fundMulti(maxLock uint64, values []*asset.MultiOrderValue, splitTxFeeRate, maxFeeRate uint64, allowSplit bool) ([]asset.Coins, [][]dex.Bytes, uint64, error) { reserves := w.reserves.Load() - coins, redeemScripts, fundingCoins, spents, err := w.cm.FundMultiBestEffort(reserves, maxLock, values, maxFeeRate, allowSplit) + const multiSplitAllowed = true + + coins, redeemScripts, fundingCoins, spents, err := w.cm.FundMultiBestEffort(reserves, maxLock, mo.Values, mo.MaxFeeRate, multiSplitAllowed) if err != nil { return nil, nil, 0, codedError(errFunding, err) } - if len(coins) == len(values) || !allowSplit { + if len(coins) == len(mo.Values) { w.cm.LockOutputsMap(fundingCoins) lockUnspent(w, false, spents) return coins, redeemScripts, 0, nil } - return w.fundMultiWithSplit(reserves, maxLock, values) -} - -// 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 (w *zecWallet) fundMultiWithSplit( - keep, maxLock uint64, - values []*asset.MultiOrderValue, -) ([]asset.Coins, [][]dex.Bytes, uint64, error) { - - utxos, _, avail, err := w.cm.SpendableUTXOs(0) - if err != nil { - return nil, nil, 0, fmt.Errorf("error getting spendable utxos: %w", err) - } - - canFund, splitCoins, splitSpents := w.fundMultiSplitTx(values, utxos, 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][]*btc.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 := w.cm.OrderWithLeastOverFund(maxLock-totalFunded, 0, remainingOrders, remainingUTXOs) - if orderIndex == -1 { - break - } - totalFunded += btc.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 = btc.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 := w.fundMultiSplitTx(newRemainingOrders, remainingUTXOs, 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 = w.submitMultiSplitTx(splitCoins, splitSpents, remainingOrders) - 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([]*btc.Output, 0, len(values)) - - var splitIndex int - locks := make([]*btc.UTxO, 0) - 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 { - output := btc.NewOutput(unspent.TxHash, unspent.Vout, unspent.Amount) - locks = append(locks, &btc.UTxO{ - TxHash: unspent.TxHash, - Vout: unspent.Vout, - Amount: unspent.Amount, - Address: unspent.Address, - }) - coins[i][j] = output - spents = append(spents, output) - redeemScripts[i][j] = unspent.RedeemScript - } - } else { - coins[i] = splitOutputCoins[splitIndex] - redeemScripts[i] = []dex.Bytes{nil} - splitIndex++ - } - } - - w.cm.LockOutputs(locks) - - lockUnspent(w, false, spents) - - return coins, redeemScripts, splitFees, nil -} - -func (w *zecWallet) submitMultiSplitTx(fundingCoins asset.Coins, spents []*btc.Output, orders []*asset.MultiOrderValue) ([]asset.Coins, uint64, error) { - baseTx, totalIn, _, err := w.fundedTx(spents) - if err != nil { - return nil, 0, err - } - - // DRAFT TODO: Should we lock these without locking with CoinManager? - lockUnspent(w, false, spents) - var success bool + recips := make([]*zSendManyRecipient, len(mo.Values)) + addrs := make([]string, len(mo.Values)) + orderReqs := make([]uint64, len(mo.Values)) + var txWasBroadcast bool defer func() { - if !success { - lockUnspent(w, true, spents) + if txWasBroadcast || len(addrs) == 0 { + return } + w.ar.ReturnAddresses(addrs) }() - - requiredForOrders, totalRequired := w.fundsRequiredForMultiOrders(orders, uint64(len(spents)), dexbtc.RedeemP2WPKHInputTotalSize) - - outputAddresses := make([]btcutil.Address, len(orders)) - for i, req := range requiredForOrders { - outputAddr, err := transparentAddress(w, w.addrParams, w.btcParams) - if err != nil { - return nil, 0, err - } - outputAddresses[i] = outputAddr - script, err := txscript.PayToAddrScript(outputAddr) + for i, v := range mo.Values { + addr, err := w.recyclableAddress() if err != nil { - return nil, 0, err + return nil, nil, 0, fmt.Errorf("error getting address for split tx: %v", err) } - baseTx.AddTxOut(wire.NewTxOut(int64(req), script)) + orderReqs[i] = dexzec.RequiredOrderFunds(v.Value, 1, dexbtc.RedeemP2PKHInputSize, v.MaxSwapCount) + addrs[i] = addr + recips[i] = &zSendManyRecipient{Address: addr, Amount: btcutil.Amount(orderReqs[i]).ToBTC()} } - changeAddr, err := transparentAddress(w, w.addrParams, w.btcParams) - if err != nil { - return nil, 0, err - } - tx, err := w.sendWithReturn(baseTx, changeAddr, totalIn, totalRequired) + txHash, err := w.sendManyShielded(recips) if err != nil { - return nil, 0, err + return nil, nil, 0, fmt.Errorf("sendManyShielded error: %w", err) } - txHash := tx.TxHash() - coins := make([]asset.Coins, len(orders)) - ops := make([]*btc.Output, len(orders)) - locks := make([]*btc.UTxO, len(coins)) - for i := range coins { - coins[i] = asset.Coins{btc.NewOutput(&txHash, uint32(i), uint64(tx.TxOut[i].Value))} - ops[i] = btc.NewOutput(&txHash, uint32(i), uint64(tx.TxOut[i].Value)) - locks[i] = &btc.UTxO{ - TxHash: &txHash, - Vout: uint32(i), - Amount: uint64(tx.TxOut[i].Value), - Address: outputAddresses[i].String(), - } - } - w.cm.LockOutputs(locks) - lockUnspent(w, false, ops) - - var totalOut uint64 - for _, txOut := range tx.TxOut { - totalOut += uint64(txOut.Value) + tx, err := getTransaction(w, txHash) + if err != nil { + return nil, nil, 0, fmt.Errorf("error retreiving split transaction %s: %w", txHash, err) } - fee := totalIn - totalOut - w.addTxToHistory(&asset.WalletTransaction{ - Type: asset.Split, - ID: txHash.String(), - Fees: fee, - }, &txHash, true) + txWasBroadcast = true - success = true - return coins, fee, nil -} + fundingFees = tx.RequiredTxFeesZIP317() -func (w *zecWallet) fundsRequiredForMultiOrders(orders []*asset.MultiOrderValue, inputCount, inputsSize uint64) ([]uint64, uint64) { - requiredForOrders := make([]uint64, len(orders)) - var totalRequired uint64 + txOuts := make(map[uint32]*wire.TxOut, len(mo.Values)) + for vout, txOut := range tx.TxOut { + txOuts[uint32(vout)] = txOut + } - for i, value := range orders { - req := dexzec.RequiredOrderFunds(value.Value, inputCount, inputsSize, value.MaxSwapCount) - requiredForOrders[i] = req - totalRequired += req + coins = make([]asset.Coins, len(mo.Values)) + utxos := make([]*btc.UTxO, len(mo.Values)) + ops := make([]*btc.Output, len(mo.Values)) + redeemScripts = make([][]dex.Bytes, len(mo.Values)) +next: + for i, v := range mo.Values { + orderReq := orderReqs[i] + for vout, txOut := range txOuts { + if uint64(txOut.Value) == orderReq { + _, addrs, _, err := txscript.ExtractPkScriptAddrs(txOut.PkScript, w.btcParams) + if err != nil { + return nil, nil, 0, fmt.Errorf("error extracting addresses error: %w", err) + } + if len(addrs) != 1 { + return nil, nil, 0, fmt.Errorf("unexpected multi-sig (%d)", len(addrs)) + } + addr := addrs[0] + addrStr, err := dexzec.EncodeAddress(addr, w.addrParams) + if err != nil { + return nil, nil, 0, fmt.Errorf("error encoding Zcash transparent address: %w", err) + } + utxos[i] = &btc.UTxO{ + TxHash: txHash, + Vout: vout, + Address: addrStr, + Amount: orderReq, + } + ops[i] = btc.NewOutput(txHash, vout, orderReq) + coins[i] = asset.Coins{ops[i]} + redeemScripts[i] = []dex.Bytes{nil} + delete(txOuts, vout) + continue next + } + } + return nil, nil, 0, fmt.Errorf("failed to find output coin for multisplit value %s at index %d", btcutil.Amount(v.Value), i) + } + w.cm.LockUTXOs(utxos) + if err := lockUnspent(w, false, ops); err != nil { + return nil, nil, 0, fmt.Errorf("error locking unspents: %w", err) } - return requiredForOrders, totalRequired + return coins, redeemScripts, fundingFees, nil } -// 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 (w *zecWallet) fundMultiSplitTx( - orders []*asset.MultiOrderValue, - utxos []*btc.CompositeUTXO, - keep, maxLock uint64, -) (bool, asset.Coins, []*btc.Output) { - - _, totalOutputRequired := w.fundsRequiredForMultiOrders(orders, uint64(len(utxos)), dexbtc.RedeemP2PKHInputSize) - - outputsSize := uint64(dexbtc.P2WPKHOutputSize) * uint64(len(utxos)+1) - // splitTxSizeWithoutInputs := dexbtc.MinimumTxOverhead + outputsSize - - enough := func(inputCount, inputsSize, sum uint64) (bool, uint64) { - fees := dexzec.TxFeesZIP317(inputsSize+uint64(wire.VarIntSerializeSize(inputCount)), outputsSize, 0, 0, 0, 0) - req := totalOutputRequired + fees - return sum >= req, sum - req - } - - fundSplitCoins, _, spents, _, inputsSize, _, err := w.cm.FundWithUTXOs(utxos, keep, false, enough) +func (w *zecWallet) sendManyShielded(recips []*zSendManyRecipient) (*chainhash.Hash, error) { + lastAddr, err := w.lastShieldedAddress() if err != nil { - return false, nil, nil + return nil, err } - if maxLock > 0 { - fees := dexzec.TxFeesZIP317(inputsSize+uint64(wire.VarIntSerializeSize(uint64(len(spents)))), outputsSize, 0, 0, 0, 0) - if totalOutputRequired+fees > maxLock { - return false, nil, nil - } + operationID, err := zSendMany(w, lastAddr, recips, NoPrivacy) + if err != nil { + return nil, fmt.Errorf("z_sendmany error: %w", err) } - return true, fundSplitCoins, spents + return w.awaitSendManyOperation(w.ctx, w, operationID) } func (w *zecWallet) Info() *asset.WalletInfo { @@ -2384,12 +2237,15 @@ func (w *zecWallet) TransactionConfirmations(ctx context.Context, txID string) ( if err != nil { return 0, fmt.Errorf("error decoding txid %q: %w", txID, err) } - res, err := getWalletTransaction(w, txHash) + tx, err := getWalletTransaction(w, txHash) if err != nil { return 0, err } + if tx.Confirmations < 0 { + tx.Confirmations = 0 + } - return uint32(res.Confirmations), nil + return uint32(tx.Confirmations), nil } // send the value to the address, with the given fee rate. If subtract is true, @@ -2462,6 +2318,7 @@ func (w *zecWallet) sendWithReturn(baseTx *dexzec.Tx, addr btcutil.Address, tota } func (w *zecWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, sigs []dex.Bytes, err error) { + op, err := btc.ConvertCoin(coin) if err != nil { return nil, nil, fmt.Errorf("error converting coin: %w", err) @@ -2639,7 +2496,7 @@ func (w *zecWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin, uint6 w.log.Errorf("Failed to stringify address %v (default encoding): %v", changeAddr, err) addrStr = changeAddr.String() // may or may not be able to retrieve the private keys for the next swap! } - w.cm.LockOutputs([]*btc.UTxO{{ + w.cm.LockUTXOs([]*btc.UTxO{{ TxHash: &change.Pt.TxHash, Vout: change.Pt.Vout, Address: addrStr, @@ -2670,6 +2527,9 @@ func (w *zecWallet) SwapConfirmations(_ context.Context, id dex.Bytes, contract } return 0, false, newError(errNoTx, "gettransaction error; %w", err) } + if tx.Confirmations < 0 { + tx.Confirmations = 0 + } return uint32(tx.Confirmations), true, nil } @@ -2780,8 +2640,8 @@ func (w *zecWallet) Balance() (*asset.Balance, error) { } bal := &asset.Balance{ - Available: bals.orchard.avail + bals.transparent, - Immature: bals.orchard.maturing, + Available: bals.orchard.avail + bals.transparent.avail, + Immature: bals.orchard.maturing + bals.transparent.maturing, Locked: locked, Other: make(map[asset.BalanceCategory]asset.CustomBalance), } @@ -3017,51 +2877,6 @@ func (w *zecWallet) listSinceBlock(start int64) ([]btcjson.ListTransactionsResul return res, nil } -func toAtoms(v float64) uint64 { - return uint64(math.Round(v * 1e8)) -} - -func (w *zecWallet) markTxAsSubmitted(txHash *chainhash.Hash) { - txHistoryDB := w.txDB() - if txHistoryDB == nil { - return - } - - err := txHistoryDB.MarkTxAsSubmitted(txHash.String()) - if err != nil { - w.log.Errorf("failed to mark tx as submitted in tx history db: %v", err) - } - - w.pendingTxsMtx.Lock() - wt, found := w.pendingTxs[*txHash] - w.pendingTxsMtx.Unlock() - - if !found { - w.log.Errorf("Transaction %s not found in pending txs", txHash) - return - } - - wt.Submitted = true - - w.emit.TransactionNote(wt.WalletTransaction, true) -} - -func (w *zecWallet) removeTxFromHistory(txHash *chainhash.Hash) { - txHistoryDB := w.txDB() - if txHistoryDB == nil { - return - } - - w.pendingTxsMtx.Lock() - delete(w.pendingTxs, *txHash) - w.pendingTxsMtx.Unlock() - - err := txHistoryDB.RemoveTx(txHash.String()) - if err != nil { - w.log.Errorf("failed to remove tx from tx history db: %v", err) - } -} - func (w *zecWallet) addTxToHistory(wt *asset.WalletTransaction, txHash *chainhash.Hash, submitted bool, skipNotes ...bool) { txHistoryDB := w.txDB() if txHistoryDB == nil { @@ -3655,7 +3470,7 @@ func (w *zecWallet) syncTxHistory(tip uint64) { } var updated bool - if gtr.blockHash != nil { + if gtr.blockHash != nil && *gtr.blockHash != (chainhash.Hash{}) { block, _, err := getVerboseBlockHeader(w, gtr.blockHash) if err != nil { w.log.Errorf("Error getting block height for %s: %v", gtr.blockHash, err) diff --git a/client/asset/zec/zec_test.go b/client/asset/zec/zec_test.go index fb5b057fe3..7795d32073 100644 --- a/client/asset/zec/zec_test.go +++ b/client/asset/zec/zec_test.go @@ -446,7 +446,7 @@ func TestFundOrder(t *testing.T) { TxID: tTxID, Address: "tmH2m5fi5yY3Qg2GpGwcCrnnoD4wp944RMJ", Amount: float64(littleFunds) / 1e8, - Confirmations: 0, + Confirmations: 1, ScriptPubKey: tP2PKH, Spendable: true, Solvable: true, @@ -510,7 +510,7 @@ func TestFundOrder(t *testing.T) { TxID: tTxID, Address: "tmH2m5fi5yY3Qg2GpGwcCrnnoD4wp944RMJ", Amount: float64(lottaFunds) / 1e8, - Confirmations: 0, + Confirmations: 1, Vout: 1, ScriptPubKey: tP2PKH, Spendable: true, @@ -1470,7 +1470,7 @@ func TestFundMultiOrder(t *testing.T) { MaxSwapCount: 1, }}, } - req := tLotSize + dexzec.TxFeesZIP317(dexbtc.RedeemP2PKHInputSize+1, dexbtc.P2SHOutputSize+1, 0, 0, 0, 0) + req := dexzec.RequiredOrderFunds(tLotSize, 1, dexbtc.RedeemP2PKHInputSize, 1) // maxLock too low if _, _, _, err := w.FundMultiOrder(mo, 1); !errorHasCode(err, errMaxLock) { @@ -1512,25 +1512,19 @@ func TestFundMultiOrder(t *testing.T) { t.Fatalf("wrong error for listunspent error: %v", err) } - // got enough + // Enough without split. unspent := &btc.ListUnspentResult{ - ScriptPubKey: tP2PKH, - Amount: float64(req) / 1e8, + ScriptPubKey: tP2PKH, + Amount: float64(req) / 1e8, + TxID: tTxID, + Confirmations: 1, + Spendable: true, } unspents := []*btc.ListUnspentResult{unspent} - queueBalance() - cl.queueResponse("listunspent", unspents) - if _, _, _, err := w.FundMultiOrder(mo, maxLock); err != nil { - t.Fatalf("error for simple path: %v", err) - } - // Enough without split. queueBalance() cl.queueResponse("listunspent", unspents) if _, _, _, err := w.FundMultiOrder(mo, maxLock); err != nil { t.Fatalf("error for simple path: %v", err) } - - // DRAFT TODO: Test with split - } diff --git a/client/cmd/testbinance/main.go b/client/cmd/testbinance/main.go index 4f241baaf1..563615f311 100644 --- a/client/cmd/testbinance/main.go +++ b/client/cmd/testbinance/main.go @@ -60,6 +60,7 @@ var ( makeMarket("dcr", "btc"), makeMarket("eth", "btc"), makeMarket("dcr", "usdc"), + makeMarket("zec", "btc"), }, } @@ -68,6 +69,7 @@ var ( makeCoinInfo("ETH", "ETH", true, true, 0.00035, 0.008), makeCoinInfo("DCR", "DCR", true, true, 0.00001000, 0.05), makeCoinInfo("USDC", "MATIC", true, true, 0.01000, 10), + makeCoinInfo("ZEC", "ZEC", true, true, 0.00500000, 0.01000000), } coinpapAssets = []*fiatrates.CoinpaprikaAsset{ @@ -75,6 +77,7 @@ var ( makeCoinpapAsset(42, "dcr", "Decred"), makeCoinpapAsset(60, "eth", "Ethereum"), makeCoinpapAsset(966001, "usdc.polygon", "USDC"), + makeCoinpapAsset(133, "zec", "Zcash"), } initialBalances = []*bntypes.Balance{ @@ -82,6 +85,7 @@ var ( makeBalance("dcr", 10000), makeBalance("eth", 5), makeBalance("usdc", 1152), + makeBalance("zec", 10000), } ) diff --git a/client/cmd/testbinance/wallets.go b/client/cmd/testbinance/wallets.go index 31ad6a480f..68b9b7a8c8 100644 --- a/client/cmd/testbinance/wallets.go +++ b/client/cmd/testbinance/wallets.go @@ -36,18 +36,26 @@ type utxoWallet struct { func newUtxoWallet(ctx context.Context, symbol string) (*utxoWallet, error) { symbol = strings.ToLower(symbol) dir := filepath.Join(dextestDir, symbol, "harness-ctl") - ctx, cancel := context.WithTimeout(ctx, time.Second*5) - defer cancel() - cmd := exec.CommandContext(ctx, "./alpha", "getnewaddress") - cmd.Dir = dir - addr, err := cmd.CombinedOutput() - if err != nil { - return nil, fmt.Errorf("getnewaddress error with output = %q, err = %v", string(addr), err) + var addr string + switch symbol { + case "zec": + addr = "tmEgW8c44RQQfft9FHXnqGp8XEcQQSRcUXD" + default: + ctx, cancel := context.WithTimeout(ctx, time.Second*5) + defer cancel() + cmd := exec.CommandContext(ctx, "./alpha", "getnewaddress") + cmd.Dir = dir + addrB, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("getnewaddress error with output = %q, err = %v", string(addrB), err) + } + addr = string(addrB) } + return &utxoWallet{ symbol: symbol, dir: dir, - addr: strings.TrimSpace(string(addr)), + addr: strings.TrimSpace(addr), }, nil } @@ -180,7 +188,7 @@ func (w *evmWallet) Send(ctx context.Context, addr, coin string, amt float64) (s func newWallet(ctx context.Context, symbol string) (w Wallet, err error) { switch strings.ToLower(symbol) { - case "btc", "dcr": + case "btc", "dcr", "zec": w, err = newUtxoWallet(ctx, symbol) case "eth", "matic", "polygon": w, err = newEvmWallet(ctx, symbol) diff --git a/client/mm/exchange_adaptor.go b/client/mm/exchange_adaptor.go index 7abbb6336d..c73776cdaf 100644 --- a/client/mm/exchange_adaptor.go +++ b/client/mm/exchange_adaptor.go @@ -5,6 +5,7 @@ package mm import ( "context" + "encoding/json" "errors" "fmt" "math" @@ -1826,6 +1827,19 @@ func (u *unifiedExchangeAdaptor) withdraw(ctx context.Context, assetID uint32, a return err } + // Pull transparent address out of unified address. There may be a different + // field "exchangeAddress" once we add support for the new special encoding + // required on binance global for zec and firo. + if strings.HasPrefix(addr, "unified:") { + var addrs struct { + Transparent string `json:"transparent"` + } + if err := json.Unmarshal([]byte(addr[len("unified:"):]), &addrs); err != nil { + return fmt.Errorf("error decoding unified address %q: %v", addr, err) + } + addr = addrs.Transparent + } + u.balancesMtx.Lock() withdrawalID, err := u.CEX.Withdraw(ctx, assetID, amount, addr) if err != nil { diff --git a/client/mm/libxc/binance_live_test.go b/client/mm/libxc/binance_live_test.go index 54e1e8230e..109d83c7c1 100644 --- a/client/mm/libxc/binance_live_test.go +++ b/client/mm/libxc/binance_live_test.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "os/user" + "strings" "sync" "testing" "time" @@ -357,15 +358,15 @@ func TestGetCoinInfo(t *testing.T) { if !coinLookup[c.Coin] { continue } - networks := make([]string, 0) + networkMins := make([]string, 0) for _, n := range c.NetworkList { if !n.DepositEnable || !n.WithdrawEnable { fmt.Printf("%s on network %s not withdrawing and/or depositing. withdraw = %t, deposit = %t\n", c.Coin, n.Network, n.WithdrawEnable, n.DepositEnable) } - networks = append(networks, n.Network) + networkMins = append(networkMins, fmt.Sprintf("{net: %s, min_withdraw: %.8f, withdraw_fee: %.8f}", n.Network, n.WithdrawMin, n.WithdrawFee)) } - fmt.Printf("%q networks: %+v \n", c.Coin, networks) + fmt.Printf("%q network mins: %+v \n", c.Coin, strings.Join(networkMins, ", ")) } } diff --git a/dex/testing/dcrdex/genmarkets.sh b/dex/testing/dcrdex/genmarkets.sh index 29d3fc869d..e534152af7 100755 --- a/dex/testing/dcrdex/genmarkets.sh +++ b/dex/testing/dcrdex/genmarkets.sh @@ -239,8 +239,8 @@ if [ $ZEC_ON -eq 0 ]; then { "base": "ZEC_simnet", "quote": "BTC_simnet", - "lotSize": 1000000, - "rateStep": 100000, + "lotSize": 100000000, + "rateStep": 10, "epochDuration": ${EPOCH_DURATION}, "marketBuyBuffer": 1.2, "parcelSize": 5