From 281e6bcb000f3fa90be3d759a34d061fdc474284 Mon Sep 17 00:00:00 2001 From: buck54321 Date: Sun, 19 Nov 2023 07:33:07 -0600 Subject: [PATCH] client/asset/zec: separate Zcash wallet (#2553) * Separate the Zcash wallet Separates the Zcash wallet. Reuse redemption finding and utxo management. Enabled shielded-split funding of orders. --- client/asset/btc/btc.go | 1972 +++--------- client/asset/btc/btc_test.go | 229 +- client/asset/btc/coin_selection.go | 70 +- client/asset/btc/coin_selection_test.go | 108 +- client/asset/btc/coinmanager.go | 686 +++++ client/asset/btc/electrum.go | 82 +- client/asset/btc/electrum_client.go | 101 +- client/asset/btc/electrum_test.go | 4 +- client/asset/btc/redemption_finder.go | 540 ++++ client/asset/btc/rpcclient.go | 220 +- client/asset/btc/spv_test.go | 20 +- client/asset/btc/spv_wrapper.go | 74 +- client/asset/btc/types.go | 196 ++ client/asset/btc/wallet.go | 28 +- client/asset/btc/wallettypes.go | 2 +- client/asset/dash/dash.go | 10 - client/asset/firo/firo.go | 17 +- client/asset/zec/errors.go | 79 + client/asset/zec/regnet_test.go | 92 +- client/asset/zec/shielded_rpc.go | 68 +- client/asset/zec/shielded_test.go | 2 +- client/asset/zec/transparent_rpc.go | 367 +++ client/asset/zec/zec.go | 2666 +++++++++++++++-- client/asset/zec/zec_test.go | 1567 ++++++++++ client/core/core_test.go | 5 +- client/core/simnet_trade.go | 17 +- client/core/trade.go | 3 +- client/webserver/site/src/js/markets.ts | 3 +- dex/networks/btc/script.go | 8 +- dex/networks/dcr/script.go | 4 +- dex/networks/zec/addr.go | 2 +- dex/networks/zec/block.go | 12 + dex/networks/zec/block_test.go | 54 +- dex/networks/zec/script.go | 56 + dex/networks/zec/test-data/header_1624455.dat | Bin 0 -> 1487 bytes dex/networks/zec/tx.go | 15 + dex/testing/dcrdex/harness.sh | 21 +- dex/testing/zec/harness.sh | 31 +- server/asset/btc/btc.go | 31 +- server/asset/btc/btc_test.go | 2 +- server/asset/btc/tx.go | 25 +- server/asset/btc/utxo.go | 12 + server/asset/common.go | 6 +- server/asset/dcr/dcr.go | 10 +- server/asset/dcr/dcr_test.go | 4 +- server/asset/dcr/utxo.go | 4 + server/asset/eth/eth.go | 5 +- server/asset/eth/eth_test.go | 6 +- server/asset/zec/zec.go | 41 +- server/market/orderrouter.go | 38 +- server/market/routers_test.go | 38 +- server/swap/swap.go | 10 +- server/swap/swap_test.go | 2 +- 53 files changed, 7210 insertions(+), 2455 deletions(-) create mode 100644 client/asset/btc/coinmanager.go create mode 100644 client/asset/btc/redemption_finder.go create mode 100644 client/asset/btc/types.go create mode 100644 client/asset/zec/errors.go create mode 100644 client/asset/zec/transparent_rpc.go create mode 100644 client/asset/zec/zec_test.go create mode 100644 dex/networks/zec/script.go create mode 100644 dex/networks/zec/test-data/header_1624455.dat diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 92a0474a98..6109e06abf 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -16,7 +16,6 @@ import ( "net/http" "os" "path/filepath" - "sort" "strconv" "strings" "sync" @@ -378,10 +377,6 @@ type BTCCloneCFG struct { // PrivKeyFunc is an optional function to get a private key for an address // from the wallet. If not given the usual dumpprivkey RPC will be used. PrivKeyFunc func(addr string) (*btcec.PrivateKey, error) - // AddrFunc is an optional function to produce new addresses. If AddrFunc - // is provided, the regular getnewaddress and getrawchangeaddress methods - // will not be used, and AddrFunc will be used instead. - AddrFunc func() (btcutil.Address, error) // AddressDecoder is an optional argument that can decode an address string // into btcutil.Address. If AddressDecoder is not supplied, // btcutil.DecodeAddress will be used. @@ -417,9 +412,6 @@ type BTCCloneCFG struct { // BooleanGetBlockRPC causes the RPC client to use a boolean second argument // for the getblock endpoint, instead of Bitcoin's numeric. BooleanGetBlockRPC bool - // NumericGetRawRPC uses a numeric boolean indicator for the - // getrawtransaction RPC. - NumericGetRawRPC bool // LegacyValidateAddressRPC uses the validateaddress endpoint instead of // getaddressinfo in order to discover ownership of an address. LegacyValidateAddressRPC bool @@ -429,10 +421,6 @@ type BTCCloneCFG struct { // UnlockSpends manually unlocks outputs as they are spent. Most assets will // unlock wallet outputs automatically as they are spent. UnlockSpends bool - // ConstantDustLimit is used if an asset enforces a dust limit (minimum - // output value) that doesn't depend on the serialized size of the output. - // If ConstantDustLimit is zero, dexbtc.IsDust is used. - ConstantDustLimit uint64 // TxDeserializer is an optional function used to deserialize a transaction. TxDeserializer func([]byte) (*wire.MsgTx, error) // TxSerializer is an optional function used to serialize a transaction. @@ -442,149 +430,24 @@ type BTCCloneCFG struct { // TxSizeCalculator is an optional function that will be used to calculate // the size of a transaction. TxSizeCalculator func(*wire.MsgTx) uint64 + // NumericGetRawRPC uses a numeric boolean indicator for the + // getrawtransaction RPC. + NumericGetRawRPC bool // TxVersion is an optional function that returns a version to use for // new transactions. TxVersion func() int32 // ManualMedianTime causes the median time to be calculated manually. ManualMedianTime bool + // ConstantDustLimit is used if an asset enforces a dust limit (minimum + // output value) that doesn't depend on the serialized size of the output. + // If ConstantDustLimit is zero, dexbtc.IsDust is used. + ConstantDustLimit uint64 // OmitRPCOptionsArg is for clones that don't take an options argument. OmitRPCOptionsArg bool // AssetID is the asset ID of the clone. AssetID uint32 } -// outPoint is the hash and output index of a transaction output. -type outPoint struct { - txHash chainhash.Hash - vout uint32 -} - -// newOutPoint is the constructor for a new outPoint. -func newOutPoint(txHash *chainhash.Hash, vout uint32) outPoint { - return outPoint{ - txHash: *txHash, - vout: vout, - } -} - -// String is a string representation of the outPoint. -func (pt outPoint) String() string { - return pt.txHash.String() + ":" + strconv.Itoa(int(pt.vout)) -} - -// output is information about a transaction output. output satisfies the -// asset.Coin interface. -type output struct { - pt outPoint - value uint64 -} - -// newOutput is the constructor for an output. -func newOutput(txHash *chainhash.Hash, vout uint32, value uint64) *output { - return &output{ - pt: newOutPoint(txHash, vout), - value: value, - } -} - -// Value returns the value of the output. Part of the asset.Coin interface. -func (op *output) Value() uint64 { - return op.value -} - -// ID is the output's coin ID. Part of the asset.Coin interface. For BTC, the -// coin ID is 36 bytes = 32 bytes tx hash + 4 bytes big-endian vout. -func (op *output) ID() dex.Bytes { - return toCoinID(op.txHash(), op.vout()) -} - -// String is a string representation of the coin. -func (op *output) String() string { - return op.pt.String() -} - -// txHash returns the pointer of the wire.OutPoint's Hash. -func (op *output) txHash() *chainhash.Hash { - return &op.pt.txHash -} - -// vout returns the wire.OutPoint's Index. -func (op *output) vout() uint32 { - return op.pt.vout -} - -// wireOutPoint creates and returns a new *wire.OutPoint for the output. -func (op *output) wireOutPoint() *wire.OutPoint { - return wire.NewOutPoint(op.txHash(), op.vout()) -} - -// auditInfo is information about a swap contract on that blockchain. -type auditInfo struct { - output *output - recipient btcutil.Address // caution: use stringAddr, not the Stringer - contract []byte - secretHash []byte - expiration time.Time -} - -// Expiration returns the expiration time of the contract, which is the earliest -// time that a refund can be issued for an un-redeemed contract. -func (ci *auditInfo) Expiration() time.Time { - return ci.expiration -} - -// Coin returns the output as an asset.Coin. -func (ci *auditInfo) Coin() asset.Coin { - return ci.output -} - -// Contract is the contract script. -func (ci *auditInfo) Contract() dex.Bytes { - return ci.contract -} - -// SecretHash is the contract's secret hash. -func (ci *auditInfo) SecretHash() dex.Bytes { - return ci.secretHash -} - -// swapReceipt is information about a swap contract that was broadcast by this -// wallet. Satisfies the asset.Receipt interface. -type swapReceipt struct { - output *output - contract []byte - signedRefund []byte - expiration time.Time -} - -// Expiration is the time that the contract will expire, allowing the user to -// issue a refund transaction. Part of the asset.Receipt interface. -func (r *swapReceipt) Expiration() time.Time { - return r.expiration -} - -// Contract is the contract script. Part of the asset.Receipt interface. -func (r *swapReceipt) Contract() dex.Bytes { - return r.contract -} - -// Coin is the output information as an asset.Coin. Part of the asset.Receipt -// interface. -func (r *swapReceipt) Coin() asset.Coin { - return r.output -} - -// String provides a human-readable representation of the contract's Coin. -func (r *swapReceipt) String() string { - return r.output.String() -} - -// SignedRefund is a signed refund script that can be used to return -// funds to the user in the case a contract expires. -func (r *swapReceipt) SignedRefund() dex.Bytes { - return r.signedRefund -} - // RPCConfig adds a wallet name to the basic configuration. type RPCConfig struct { dexbtc.RPCConfig `ini:",extends"` @@ -892,36 +755,31 @@ type baseWallet struct { localFeeRate func(context.Context, RawRequester, uint64) (uint64, error) externalFeeRate func(context.Context, dex.Network) (uint64, error) decodeAddr dexbtc.AddressDecoder - deserializeTx func([]byte) (*wire.MsgTx, error) - serializeTx func(*wire.MsgTx) ([]byte, error) - calcTxSize func(*wire.MsgTx) uint64 - hashTx func(*wire.MsgTx) *chainhash.Hash - stringAddr dexbtc.AddressStringer - txVersion func() int32 - Network dex.Network - ctx context.Context // the asset subsystem starts with Connect(ctx) + + deserializeTx func([]byte) (*wire.MsgTx, error) + serializeTx func(*wire.MsgTx) ([]byte, error) + calcTxSize func(*wire.MsgTx) uint64 + hashTx func(*wire.MsgTx) *chainhash.Hash + + stringAddr dexbtc.AddressStringer + + txVersion func() int32 + + Network dex.Network + ctx context.Context // the asset subsystem starts with Connect(ctx) // TODO: remove currentTip and the mutex, and make it local to the // watchBlocks->reportNewTip call stack. The tests are reliant on current // internals, so this will take a little work. tipMtx sync.RWMutex - currentTip *block + currentTip *BlockVector - // Coins returned by Fund are cached for quick reference. - fundingMtx sync.RWMutex - fundingCoins map[outPoint]*utxo + cm *CoinManager - findRedemptionMtx sync.RWMutex - findRedemptionQueue map[outPoint]*findRedemptionReq + rf *RedemptionFinder bondReserves atomic.Uint64 - recycledAddrMtx sync.Mutex - // recycledAddrs are returned, unused redemption addresses. We track these - // to avoid issues with the gap policy. - recycledAddrs map[string]struct{} - recyclePath string - pendingTxsMtx sync.RWMutex pendingTxs map[chainhash.Hash]*extendedWalletTx @@ -932,6 +790,8 @@ type baseWallet struct { txHistoryDB atomic.Value // txDB txHistoryDBPath string + + ar *AddressRecycler } func (w *baseWallet) fallbackFeeRate() uint64 { @@ -950,6 +810,10 @@ func (w *baseWallet) useSplitTx() bool { return w.cfgV.Load().(*baseWalletConfig).useSplitTx } +func (w *baseWallet) UseSplitTx() bool { + return w.useSplitTx() +} + func (w *baseWallet) apiFeeFallback() bool { return w.cfgV.Load().(*baseWalletConfig).apiFeeFallback } @@ -1086,43 +950,6 @@ func (btc *ExchangeWalletSPV) LogFilePath() string { return btc.spvNode.logFilePath() } -type block struct { - height int64 - hash chainhash.Hash -} - -// findRedemptionReq represents a request to find a contract's redemption, -// which is added to the findRedemptionQueue with the contract outpoint as -// key. -type findRedemptionReq struct { - outPt outPoint - blockHash *chainhash.Hash - blockHeight int32 - resultChan chan *findRedemptionResult - pkScript []byte - contractHash []byte -} - -func (req *findRedemptionReq) fail(s string, a ...any) { - req.success(&findRedemptionResult{err: fmt.Errorf(s, a...)}) - -} - -func (req *findRedemptionReq) success(res *findRedemptionResult) { - select { - case req.resultChan <- res: - default: - // In-case two separate threads find a result. - } -} - -// findRedemptionResult models the result of a find redemption attempt. -type findRedemptionResult struct { - redemptionCoinID dex.Bytes - secret dex.Bytes - err error -} - func parseChainParams(net dex.Network) (*chaincfg.Params, error) { switch net { case dex.Mainnet: @@ -1230,39 +1057,41 @@ func newRPCWallet(requester RawRequester, cfg *BTCCloneCFG, parsedCfg *RPCWallet } core := &rpcCore{ - rpcConfig: &parsedCfg.RPCConfig, - cloneParams: cfg, - segwit: cfg.Segwit, - decodeAddr: btc.decodeAddr, - stringAddr: btc.stringAddr, - deserializeBlock: blockDeserializer, - legacyRawSends: cfg.LegacyRawFeeLimit, - minNetworkVersion: cfg.MinNetworkVersion, - log: cfg.Logger.SubLogger("RPC"), - chainParams: cfg.ChainParams, - omitAddressType: cfg.OmitAddressType, - legacySignTx: cfg.LegacySignTxRPC, - booleanGetBlock: cfg.BooleanGetBlockRPC, - unlockSpends: cfg.UnlockSpends, - deserializeTx: btc.deserializeTx, - serializeTx: btc.serializeTx, - hashTx: btc.hashTx, - numericGetRawTxRPC: cfg.NumericGetRawRPC, + rpcConfig: &parsedCfg.RPCConfig, + cloneParams: cfg, + segwit: cfg.Segwit, + decodeAddr: btc.decodeAddr, + stringAddr: btc.stringAddr, + deserializeBlock: blockDeserializer, + legacyRawSends: cfg.LegacyRawFeeLimit, + minNetworkVersion: cfg.MinNetworkVersion, + log: cfg.Logger.SubLogger("RPC"), + chainParams: cfg.ChainParams, + omitAddressType: cfg.OmitAddressType, + legacySignTx: cfg.LegacySignTxRPC, + booleanGetBlock: cfg.BooleanGetBlockRPC, + unlockSpends: cfg.UnlockSpends, + + deserializeTx: btc.deserializeTx, + serializeTx: btc.serializeTx, + hashTx: btc.hashTx, + numericGetRawTxRPC: cfg.NumericGetRawRPC, + manualMedianTime: cfg.ManualMedianTime, + legacyValidateAddressRPC: cfg.LegacyValidateAddressRPC, - manualMedianTime: cfg.ManualMedianTime, omitRPCOptionsArg: cfg.OmitRPCOptionsArg, - addrFunc: cfg.AddrFunc, - connectFunc: cfg.ConnectFunc, privKeyFunc: cfg.PrivKeyFunc, } core.requesterV.Store(requester) node := newRPCClient(core) - btc.node = node - return &intermediaryWallet{ + btc.setNode(node) + w := &intermediaryWallet{ baseWallet: btc, txFeeEstimator: node, tipRedeemer: node, - }, nil + } + w.prepareRedemptionFinder() + return w, nil } func decodeAddress(addr string, params *chaincfg.Params) (btcutil.Address, error) { @@ -1298,7 +1127,7 @@ func newUnconnectedWallet(cfg *BTCCloneCFG, walletCfg *WalletConfig) (*baseWalle nonSegwitSigner = cfg.NonSegwitSigner } - initTxSize := cfg.InitTxSize + initTxSize := uint64(cfg.InitTxSize) if initTxSize == 0 { if cfg.Segwit { initTxSize = dexbtc.InitTxSizeSegwit @@ -1307,7 +1136,7 @@ func newUnconnectedWallet(cfg *BTCCloneCFG, walletCfg *WalletConfig) (*baseWalle } } - initTxSizeBase := cfg.InitTxSizeBase + initTxSizeBase := uint64(cfg.InitTxSizeBase) if initTxSizeBase == 0 { if cfg.Segwit { initTxSizeBase = dexbtc.InitTxSizeBaseSegwit @@ -1346,53 +1175,42 @@ func newUnconnectedWallet(cfg *BTCCloneCFG, walletCfg *WalletConfig) (*baseWalle txVersion = func() int32 { return wire.TxVersion } } - w := &baseWallet{ - symbol: cfg.Symbol, - chainParams: cfg.ChainParams, - cloneParams: cfg, - log: cfg.Logger, - emit: cfg.WalletCFG.Emit, - peersChange: cfg.WalletCFG.PeersChange, - fundingCoins: make(map[outPoint]*utxo), - findRedemptionQueue: make(map[outPoint]*findRedemptionReq), - minNetworkVersion: cfg.MinNetworkVersion, - dustLimit: cfg.ConstantDustLimit, - useLegacyBalance: cfg.LegacyBalance, - balanceFunc: cfg.BalanceFunc, - segwit: cfg.Segwit, - initTxSize: uint64(initTxSize), - initTxSizeBase: uint64(initTxSizeBase), - signNonSegwit: nonSegwitSigner, - localFeeRate: cfg.FeeEstimator, - externalFeeRate: cfg.ExternalFeeEstimator, - decodeAddr: addrDecoder, - stringAddr: addrStringer, - walletInfo: cfg.WalletInfo, - deserializeTx: txDeserializer, - serializeTx: txSerializer, - hashTx: txHasher, - calcTxSize: txSizeCalculator, - txVersion: txVersion, - Network: cfg.Network, - pendingTxs: make(map[chainhash.Hash]*extendedWalletTx), - txHistoryDBPath: filepath.Join(walletDir, "txhistory.db"), - recyclePath: filepath.Join(walletDir, "recycled-addrs.txt"), + addressRecyler, err := NewAddressRecycler(filepath.Join(walletDir, "recycled-addrs.txt"), cfg.Logger) + if err != nil { + return nil, err } - w.cfgV.Store(baseCfg) - // Try to load any cached unused redemption addresses. - b, err := os.ReadFile(w.recyclePath) - if err != nil && !errors.Is(err, os.ErrNotExist) { - return nil, fmt.Errorf("error looking for recycled address file: %w", err) - } - addrs := strings.Split(string(b), "\n") - w.recycledAddrs = make(map[string]struct{}, len(addrs)) - for _, addr := range addrs { - if addr == "" { - continue - } - w.recycledAddrs[addr] = struct{}{} + w := &baseWallet{ + symbol: cfg.Symbol, + chainParams: cfg.ChainParams, + cloneParams: cfg, + log: cfg.Logger, + emit: cfg.WalletCFG.Emit, + peersChange: cfg.WalletCFG.PeersChange, + minNetworkVersion: cfg.MinNetworkVersion, + dustLimit: cfg.ConstantDustLimit, + useLegacyBalance: cfg.LegacyBalance, + balanceFunc: cfg.BalanceFunc, + segwit: cfg.Segwit, + initTxSize: initTxSize, + initTxSizeBase: initTxSizeBase, + signNonSegwit: nonSegwitSigner, + localFeeRate: cfg.FeeEstimator, + externalFeeRate: cfg.ExternalFeeEstimator, + decodeAddr: addrDecoder, + stringAddr: addrStringer, + walletInfo: cfg.WalletInfo, + deserializeTx: txDeserializer, + serializeTx: txSerializer, + hashTx: txHasher, + calcTxSize: txSizeCalculator, + txVersion: txVersion, + Network: cfg.Network, + pendingTxs: make(map[chainhash.Hash]*extendedWalletTx), + txHistoryDBPath: filepath.Join(walletDir, "txhistory.db"), + ar: addressRecyler, } + w.cfgV.Store(baseCfg) // Default to the BTC RPC estimator (see LTC). Consumers can use // noLocalFeeRate or a similar dummy function to power feeRate() requests @@ -1444,16 +1262,16 @@ func OpenSPVWallet(cfg *BTCCloneCFG, walletConstructor BTCWalletConstructor) (*E acctName: defaultAcctName, dir: filepath.Join(cfg.WalletCFG.DataDir, cfg.ChainParams.Name), txBlocks: make(map[chainhash.Hash]*hashEntry), - checkpoints: make(map[outPoint]*scanCheckpoint), + checkpoints: make(map[OutPoint]*scanCheckpoint), log: cfg.Logger.SubLogger("SPV"), - tipChan: make(chan *block, 8), + tipChan: make(chan *BlockVector, 8), decodeAddr: btc.decodeAddr, } spvw.wallet = walletConstructor(spvw.dir, spvw.cfg, spvw.chainParams, spvw.log) - btc.node = spvw + btc.setNode(spvw) - return &ExchangeWalletSPV{ + w := &ExchangeWalletSPV{ intermediaryWallet: &intermediaryWallet{ baseWallet: btc, txFeeEstimator: spvw, @@ -1462,7 +1280,64 @@ func OpenSPVWallet(cfg *BTCCloneCFG, walletConstructor BTCWalletConstructor) (*E }, authAddOn: &authAddOn{spvw}, spvNode: spvw, - }, nil + } + w.prepareRedemptionFinder() + return w, nil +} + +func (btc *baseWallet) setNode(node Wallet) { + btc.node = node + btc.cm = NewCoinManager( + btc.log, + btc.chainParams, + func(val, lots, maxFeeRate uint64, reportChange bool) EnoughFunc { + return orderEnough(val, lots, maxFeeRate, btc.initTxSizeBase, btc.initTxSize, btc.segwit, reportChange) + }, + func() ([]*ListUnspentResult, error) { // list + return node.listUnspent() + }, + func(unlock bool, ops []*Output) error { // lock + return node.lockUnspent(unlock, ops) + }, + func() ([]*RPCOutpoint, error) { // listLocked + return node.listLockUnspent() + }, + func(txHash *chainhash.Hash, vout uint32) (*wire.TxOut, error) { + txRaw, _, err := btc.rawWalletTx(txHash) + if err != nil { + return nil, err + } + msgTx, err := btc.deserializeTx(txRaw) + if err != nil { + btc.log.Warnf("Invalid transaction %v (%x): %v", txHash, txRaw, err) + return nil, nil + } + if vout >= uint32(len(msgTx.TxOut)) { + btc.log.Warnf("Invalid vout %d for %v", vout, txHash) + return nil, nil + } + return msgTx.TxOut[vout], nil + }, + func(addr btcutil.Address) (string, error) { + return btc.stringAddr(addr, btc.chainParams) + }, + ) +} + +func (btc *intermediaryWallet) prepareRedemptionFinder() { + btc.rf = NewRedemptionFinder( + btc.log, + btc.tipRedeemer.getWalletTransaction, + btc.tipRedeemer.getBlockHeight, + btc.tipRedeemer.getBlock, + btc.tipRedeemer.getBlockHeader, + btc.hashTx, + btc.deserializeTx, + btc.tipRedeemer.getBestBlockHeight, + btc.tipRedeemer.searchBlockForRedemptions, + btc.tipRedeemer.getBlockHash, + btc.tipRedeemer.findRedemptionsInMempool, + ) } // Info returns basic information about the wallet and asset. @@ -1496,12 +1371,12 @@ func (btc *baseWallet) connect(ctx context.Context) (*sync.WaitGroup, error) { return nil, fmt.Errorf("fee estimation method not found. Are you configured for the correct RPC?") } - bestBlock := &block{bestBlockHdr.Height, *bestBlockHash} - btc.log.Infof("Connected wallet with current best block %v (%d)", bestBlock.hash, bestBlock.height) + bestBlock := &BlockVector{bestBlockHdr.Height, *bestBlockHash} + btc.log.Infof("Connected wallet with current best block %v (%d)", bestBlock.Hash, bestBlock.Height) btc.tipMtx.Lock() btc.currentTip = bestBlock btc.tipMtx.Unlock() - atomic.StoreInt64(&btc.tipAtConnect, btc.currentTip.height) + atomic.StoreInt64(&btc.tipAtConnect, btc.currentTip.Height) if txHistoryDB := btc.txDB(); txHistoryDB != nil { pendingTxs, err := txHistoryDB.getPendingTxs() @@ -1538,7 +1413,7 @@ func (btc *baseWallet) connect(ctx context.Context) (*sync.WaitGroup, error) { go func() { defer wg.Done() <-ctx.Done() - btc.writeRecycledAddrsToFile() + btc.ar.WriteRecycledAddrsToFile() }() return &wg, nil @@ -1556,7 +1431,7 @@ func (btc *intermediaryWallet) Connect(ctx context.Context) (*sync.WaitGroup, er go func() { defer wg.Done() btc.watchBlocks(ctx) - btc.cancelRedemptionSearches() + btc.rf.CancelRedemptionSearches() }() wg.Add(1) go func() { @@ -1566,18 +1441,6 @@ func (btc *intermediaryWallet) Connect(ctx context.Context) (*sync.WaitGroup, er return wg, nil } -func (btc *baseWallet) cancelRedemptionSearches() { - // Close all open channels for contract redemption searches - // to prevent leakages and ensure goroutines that are started - // to wait on these channels end gracefully. - btc.findRedemptionMtx.Lock() - for contractOutpoint, req := range btc.findRedemptionQueue { - req.fail("shutting down") - delete(btc.findRedemptionQueue, contractOutpoint) - } - btc.findRedemptionMtx.Unlock() -} - // Reconfigure attempts to reconfigure the wallet. func (btc *baseWallet) Reconfigure(ctx context.Context, cfg *asset.WalletConfig, currentAddress string) (restart bool, err error) { // See what the node says. @@ -1614,9 +1477,9 @@ func (btc *baseWallet) IsDust(txOut *wire.TxOut, minRelayTxFee uint64) bool { return dexbtc.IsDust(txOut, minRelayTxFee) } -// getBlockchainInfoResult models the data returned from the getblockchaininfo +// GetBlockchainInfoResult models the data returned from the getblockchaininfo // command. -type getBlockchainInfoResult struct { +type GetBlockchainInfoResult struct { Chain string `json:"chain"` Blocks int64 `json:"blocks"` Headers int64 `json:"headers"` @@ -1630,7 +1493,7 @@ type getBlockchainInfoResult struct { InitialBlockDownloadComplete *bool `json:"initial_block_download_complete"` } -func (r *getBlockchainInfoResult) syncing() bool { +func (r *GetBlockchainInfoResult) Syncing() bool { if r.InitialBlockDownloadComplete != nil && *r.InitialBlockDownloadComplete { return false } @@ -1802,7 +1665,7 @@ func (btc *baseWallet) feeRate(confTarget uint64) (uint64, error) { if feeRate <= 0 || feeRate > btc.feeRateLimit() { // but fetcher shouldn't return <= 0 without error return 0, fmt.Errorf("external fee rate %v exceeds configured limit", feeRate) } - btc.log.Debugf("Retrieved fee rate from external API: %v", feeRate) + btc.log.Tracef("Retrieved fee rate from external API: %v", feeRate) return feeRate, nil } @@ -1908,16 +1771,14 @@ func (btc *baseWallet) MaxOrder(ord *asset.MaxOrderForm) (*asset.SwapEstimate, e } // maxOrder gets the estimate for MaxOrder, and also returns the -// []*compositeUTXO to be used for further order estimation without additional +// []*CompositeUTXO to be used for further order estimation without additional // calls to listunspent. -func (btc *baseWallet) maxOrder(lotSize, feeSuggestion, maxFeeRate uint64) (utxos []*compositeUTXO, est *asset.SwapEstimate, err error) { +func (btc *baseWallet) maxOrder(lotSize, feeSuggestion, maxFeeRate uint64) (utxos []*CompositeUTXO, est *asset.SwapEstimate, err error) { if lotSize == 0 { return nil, nil, errors.New("cannot divide by lotSize zero") } - btc.fundingMtx.RLock() - utxos, _, avail, err := btc.spendableUTXOs(0) - btc.fundingMtx.RUnlock() + utxos, _, avail, err := btc.cm.SpendableUTXOs(0) if err != nil { return nil, nil, fmt.Errorf("error parsing unspent outputs: %w", err) } @@ -2092,7 +1953,7 @@ func (btc *baseWallet) SingleLotSwapRefundFees(_ uint32, feeSuggestion uint64, u // splitOption constructs an *asset.OrderOption with customized text based on the // difference in fees between the configured and test split condition. -func (btc *baseWallet) splitOption(req *asset.PreSwapForm, utxos []*compositeUTXO, bump float64) *asset.OrderOption { +func (btc *baseWallet) splitOption(req *asset.PreSwapForm, utxos []*CompositeUTXO, bump float64) *asset.OrderOption { opt := &asset.OrderOption{ ConfigOption: asset.ConfigOption{ Key: splitKey, @@ -2144,12 +2005,12 @@ func (btc *baseWallet) splitOption(req *asset.PreSwapForm, utxos []*compositeUTX } // estimateSwap prepares an *asset.SwapEstimate. -func (btc *baseWallet) estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate uint64, utxos []*compositeUTXO, +func (btc *baseWallet) estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate uint64, utxos []*CompositeUTXO, trySplit bool, feeBump float64) (*asset.SwapEstimate, bool /*split used*/, uint64 /*amt locked*/, error) { var avail uint64 for _, utxo := range utxos { - avail += utxo.amount + avail += utxo.Amount } reserves := btc.bondReserves.Load() @@ -2168,7 +2029,7 @@ func (btc *baseWallet) estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate uin // UTXOs. Actual order funding accounts for this. For this estimate, we will // just not use a split tx if the split-adjusted required funds exceeds the // total value of the UTXO selected with this enough closure. - sum, _, inputsSize, _, _, _, _, err := tryFund(utxos, + sum, _, inputsSize, _, _, _, _, err := TryFund(utxos, orderEnough(val, lots, bumpedMaxRate, btc.initTxSizeBase, btc.initTxSize, btc.segwit, trySplit)) if err != nil { return nil, false, 0, fmt.Errorf("error funding swap value %s: %w", amount(val), err) @@ -2212,8 +2073,8 @@ func (btc *baseWallet) estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate uin return nil, false, 0, errors.New("balance too low to both fund order and maintain bond reserves") } kept := leastOverFund(reserveEnough(reserves), utxos) - utxos := utxoSetDiff(utxos, kept) - sum, _, inputsSize, _, _, _, _, err = tryFund(utxos, orderEnough(val, lots, bumpedMaxRate, btc.initTxSizeBase, btc.initTxSize, btc.segwit, false)) + utxos := UTxOSetDiff(utxos, kept) + sum, _, inputsSize, _, _, _, _, err = TryFund(utxos, orderEnough(val, lots, bumpedMaxRate, btc.initTxSizeBase, btc.initTxSize, btc.segwit, false)) if err != nil { return nil, false, 0, fmt.Errorf("error funding swap value %s: %w", amount(val), err) } @@ -2366,14 +2227,14 @@ func (btc *baseWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes, ui reserves := btc.bondReserves.Load() minConfs := uint32(0) - coins, fundingCoins, spents, redeemScripts, inputsSize, sum, err := btc.fund(reserves, minConfs, true, + coins, fundingCoins, spents, redeemScripts, inputsSize, sum, err := btc.cm.Fund(reserves, minConfs, true, orderEnough(ord.Value, ord.MaxSwapCount, bumpedMaxRate, btc.initTxSizeBase, btc.initTxSize, btc.segwit, useSplit)) if err != nil { if !useSplit && reserves > 0 { // Force a split if funding failure may be due to reserves. btc.log.Infof("Retrying order funding with a forced split transaction to help respect reserves.") useSplit = true - coins, fundingCoins, spents, redeemScripts, inputsSize, sum, err = btc.fund(reserves, minConfs, true, + coins, fundingCoins, spents, redeemScripts, inputsSize, sum, err = btc.cm.Fund(reserves, minConfs, true, orderEnough(ord.Value, ord.MaxSwapCount, bumpedMaxRate, btc.initTxSizeBase, btc.initTxSize, btc.segwit, useSplit)) extraSplitOutput = reserves + btc.BondsFeeBuffer(ord.FeeSuggestion) } @@ -2423,111 +2284,9 @@ func (btc *baseWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes, ui return coins, redeemScripts, 0, nil } -func (btc *baseWallet) fundInternalWithUTXOs(utxos []*compositeUTXO, avail uint64, keep uint64, lockUnspents bool, - enough func(size, sum uint64) (bool, uint64)) ( - coins asset.Coins, fundingCoins map[outPoint]*utxo, spents []*output, redeemScripts []dex.Bytes, size, sum uint64, err error) { - - if keep > 0 { - kept := leastOverFund(reserveEnough(keep), utxos) - btc.log.Debugf("Setting aside %v BTC in %d UTXOs to respect the %v BTC reserved amount", - toBTC(sumUTXOs(kept)), len(kept), toBTC(keep)) - utxosPruned := utxoSetDiff(utxos, kept) - sum, _, size, coins, fundingCoins, redeemScripts, spents, err = tryFund(utxosPruned, enough) - if err != nil { - btc.log.Debugf("Unable to fund order with UTXOs set aside (%v), trying again with full UTXO set.", err) - } - } - if len(spents) == 0 { // either keep is zero or it failed with utxosPruned - // Without utxos set aside for keep, we have to consider any spendable - // change (extra) that the enough func grants us. - var extra uint64 - sum, extra, size, coins, fundingCoins, redeemScripts, spents, err = tryFund(utxos, enough) - if err != nil { - return nil, nil, nil, nil, 0, 0, err - } - if avail-sum+extra < keep { - return nil, 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. - if keep > 0 && extra > 0 { - btc.log.Debugf("Funding succeeded with %v BTC in spendable change.", toBTC(extra)) - } - } - - if lockUnspents { - err = btc.node.lockUnspent(false, spents) - if err != nil { - return nil, nil, nil, nil, 0, 0, fmt.Errorf("LockUnspent error: %w", err) - } - for pt, utxo := range fundingCoins { - btc.fundingCoins[pt] = utxo - } - } - - return coins, fundingCoins, spents, redeemScripts, size, sum, err -} - -func (btc *baseWallet) fundInternal(keep uint64, minConfs uint32, lockUnspents bool, - enough func(size, sum uint64) (bool, uint64)) ( - coins asset.Coins, fundingCoins map[outPoint]*utxo, spents []*output, redeemScripts []dex.Bytes, size, sum uint64, err error) { - utxos, _, avail, err := btc.spendableUTXOs(minConfs) - if err != nil { - return nil, nil, nil, nil, 0, 0, fmt.Errorf("error getting spendable utxos: %w", err) - } - - return btc.fundInternalWithUTXOs(utxos, avail, keep, lockUnspents, enough) -} - -func (btc *baseWallet) fund(keep uint64, minConfs uint32, lockUnspents bool, - enough func(size, sum uint64) (bool, uint64)) ( - coins asset.Coins, fundingCoins map[outPoint]*utxo, spents []*output, redeemScripts []dex.Bytes, size, sum uint64, err error) { - - btc.fundingMtx.Lock() - defer btc.fundingMtx.Unlock() - - return btc.fundInternal(keep, minConfs, lockUnspents, enough) -} - -// 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 (btc *baseWallet) 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, btc.initTxSizeBase, btc.initTxSize, btc.segwit, 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 (btc *baseWallet) fundsRequiredForMultiOrders(orders []*asset.MultiOrderValue, feeRate, splitBuffer uint64) ([]uint64, uint64) { - var swapInputSize uint64 - if btc.segwit { - swapInputSize = dexbtc.RedeemP2WPKHInputTotalSize - } else { - swapInputSize = dexbtc.RedeemP2PKHInputSize - } - +func (btc *baseWallet) fundsRequiredForMultiOrders(orders []*asset.MultiOrderValue, feeRate, splitBuffer, swapInputSize uint64) ([]uint64, uint64) { requiredForOrders := make([]uint64, len(orders)) var totalRequired uint64 @@ -2541,136 +2300,23 @@ func (btc *baseWallet) fundsRequiredForMultiOrders(orders []*asset.MultiOrderVal return requiredForOrders, totalRequired } -// fundMultiBestEffors 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 (btc *baseWallet) fundMultiBestEffort(keep, maxLock uint64, values []*asset.MultiOrderValue, - maxFeeRate uint64, splitAllowed bool) ([]asset.Coins, [][]dex.Bytes, map[outPoint]*utxo, []*output, error) { - utxos, _, avail, err := btc.spendableUTXOs(0) - if err != nil { - return nil, nil, nil, nil, fmt.Errorf("error getting spendable utxos: %w", err) - } - - 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 := btc.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, btc.initTxSizeBase, btc.initTxSize, btc.segwit, false) - - var fundingUTXOs []*compositeUTXO - if maxLock > 0 { - if maxLock < totalFunded { - // Should never happen unless there is a bug in leastOverFundWithLimit - btc.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 map[outPoint]*utxo, spents []*output, err error) { - coins = make([]asset.Coins, len(allFundingUTXOs)) - fundingCoins = make(map[outPoint]*utxo) - spents = make([]*output, 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 { - coins[i][j] = newOutput(output.txHash, output.vout, output.amount) - fundingCoins[outPoint{txHash: *output.txHash, vout: output.vout}] = &utxo{ - txHash: output.txHash, - vout: output.vout, - amount: output.amount, - address: output.address, - } - spents = append(spents, newOutput(output.txHash, output.vout, output.amount)) - redeemScripts[i][j] = output.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 (btc *baseWallet) fundMultiSplitTx(orders []*asset.MultiOrderValue, utxos []*compositeUTXO, - splitTxFeeRate, maxFeeRate, splitBuffer, keep, maxLock uint64) (bool, asset.Coins, []*output) { - _, totalOutputRequired := btc.fundsRequiredForMultiOrders(orders, maxFeeRate, splitBuffer) +func (btc *baseWallet) fundMultiSplitTx( + orders []*asset.MultiOrderValue, + utxos []*CompositeUTXO, + splitTxFeeRate, maxFeeRate uint64, + splitBuffer, keep, maxLock uint64, +) (bool, asset.Coins, []*Output) { + + var swapInputSize uint64 + if btc.segwit { + swapInputSize = dexbtc.RedeemP2WPKHInputTotalSize + } else { + swapInputSize = dexbtc.RedeemP2PKHInputSize + } + _, totalOutputRequired := btc.fundsRequiredForMultiOrders(orders, maxFeeRate, splitBuffer, swapInputSize) var splitTxSizeWithoutInputs uint64 = dexbtc.MinimumTxOverhead numOutputs := len(orders) @@ -2682,18 +2328,13 @@ func (btc *baseWallet) fundMultiSplitTx(orders []*asset.MultiOrderValue, utxos [ } else { splitTxSizeWithoutInputs += uint64(dexbtc.P2PKHOutputSize * numOutputs) } - enough := func(inputSize, sum uint64) (bool, uint64) { - splitTxFee := (splitTxSizeWithoutInputs + inputSize) * splitTxFeeRate + enough := func(_, inputsSize, sum uint64) (bool, uint64) { + splitTxFee := (splitTxSizeWithoutInputs + inputsSize) * splitTxFeeRate req := totalOutputRequired + splitTxFee return sum >= req, sum - req } - var avail uint64 - for _, utxo := range utxos { - avail += utxo.amount - } - - fundSplitCoins, _, spents, _, inputsSize, _, err := btc.fundInternalWithUTXOs(utxos, avail, keep, false, enough) + fundSplitCoins, _, spents, _, inputsSize, _, err := btc.cm.FundWithUTXOs(utxos, keep, false, enough) if err != nil { return false, nil, nil } @@ -2710,14 +2351,14 @@ func (btc *baseWallet) fundMultiSplitTx(orders []*asset.MultiOrderValue, utxos [ // submitMultiSplitTx creates a multi-split transaction using fundingCoins with // one output for each order, and submits it to the network. -func (btc *baseWallet) submitMultiSplitTx(fundingCoins asset.Coins, spents []*output, orders []*asset.MultiOrderValue, +func (btc *baseWallet) submitMultiSplitTx(fundingCoins asset.Coins, spents []*Output, orders []*asset.MultiOrderValue, maxFeeRate, splitTxFeeRate, splitBuffer uint64) ([]asset.Coins, uint64, error) { baseTx, totalIn, _, err := btc.fundedTx(fundingCoins) if err != nil { return nil, 0, err } - btc.node.lockUnspent(false, spents) + btc.cm.lockUnspent(false, spents) var success bool defer func() { if !success { @@ -2725,7 +2366,14 @@ func (btc *baseWallet) submitMultiSplitTx(fundingCoins asset.Coins, spents []*ou } }() - requiredForOrders, totalRequired := btc.fundsRequiredForMultiOrders(orders, maxFeeRate, splitBuffer) + var swapInputSize uint64 + if btc.segwit { + swapInputSize = dexbtc.RedeemP2WPKHInputTotalSize + } else { + swapInputSize = dexbtc.RedeemP2PKHInputSize + } + + requiredForOrders, totalRequired := btc.fundsRequiredForMultiOrders(orders, maxFeeRate, splitBuffer, swapInputSize) outputAddresses := make([]btcutil.Address, len(orders)) for i, req := range requiredForOrders { @@ -2752,17 +2400,19 @@ func (btc *baseWallet) submitMultiSplitTx(fundingCoins asset.Coins, spents []*ou txHash := btc.hashTx(tx) coins := make([]asset.Coins, len(orders)) - ops := make([]*output, len(orders)) + ops := make([]*Output, len(orders)) + locks := make([]*UTxO, len(coins)) for i := range coins { - coins[i] = asset.Coins{newOutput(txHash, uint32(i), uint64(tx.TxOut[i].Value))} - ops[i] = newOutput(txHash, uint32(i), uint64(tx.TxOut[i].Value)) - btc.fundingCoins[ops[i].pt] = &utxo{ - txHash: txHash, - vout: uint32(i), - amount: uint64(tx.TxOut[i].Value), - address: outputAddresses[i].String(), + coins[i] = asset.Coins{NewOutput(txHash, uint32(i), uint64(tx.TxOut[i].Value))} + ops[i] = NewOutput(txHash, uint32(i), uint64(tx.TxOut[i].Value)) + locks[i] = &UTxO{ + TxHash: txHash, + Vout: uint32(i), + Amount: uint64(tx.TxOut[i].Value), + Address: outputAddresses[i].String(), } } + btc.cm.LockOutputs(locks) btc.node.lockUnspent(false, ops) var totalOut uint64 @@ -2782,7 +2432,7 @@ func (btc *baseWallet) submitMultiSplitTx(fundingCoins asset.Coins, spents []*ou // without a split transaction. func (btc *baseWallet) fundMultiWithSplit(keep, maxLock uint64, values []*asset.MultiOrderValue, splitTxFeeRate, maxFeeRate, splitBuffer uint64) ([]asset.Coins, [][]dex.Bytes, uint64, error) { - utxos, _, avail, err := btc.spendableUTXOs(0) + utxos, _, avail, err := btc.cm.SpendableUTXOs(0) if err != nil { return nil, nil, 0, fmt.Errorf("error getting spendable utxos: %w", err) } @@ -2797,7 +2447,7 @@ func (btc *baseWallet) fundMultiWithSplit(keep, maxLock uint64, values []*asset. // 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)) + indexToFundingCoins := make(map[int][]*CompositeUTXO, len(values)) remainingIndexes := make([]int, len(values)) for i := range remainingIndexes { remainingIndexes[i] = i @@ -2812,11 +2462,11 @@ func (btc *baseWallet) fundMultiWithSplit(keep, maxLock uint64, values []*asset. // 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 := btc.orderWithLeastOverFund(maxLock-totalFunded, maxFeeRate, remainingOrders, remainingUTXOs) + orderIndex, fundingUTXOs := btc.cm.OrderWithLeastOverFund(maxLock-totalFunded, maxFeeRate, remainingOrders, remainingUTXOs) if orderIndex == -1 { break } - totalFunded += sumUTXOs(fundingUTXOs) + totalFunded += SumUTXOs(fundingUTXOs) if totalFunded > avail-keep { break } @@ -2829,7 +2479,7 @@ func (btc *baseWallet) fundMultiWithSplit(keep, maxLock uint64, values []*asset. newRemainingIndexes = append(newRemainingIndexes, remainingIndexes[j]) } } - remainingUTXOs = utxoSetDiff(remainingUTXOs, fundingUTXOs) + 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 @@ -2864,25 +2514,25 @@ func (btc *baseWallet) fundMultiWithSplit(keep, maxLock uint64, values []*asset. coins := make([]asset.Coins, len(values)) redeemScripts := make([][]dex.Bytes, len(values)) - spents := make([]*output, 0, len(values)) + spents := make([]*Output, 0, len(values)) var splitIndex int - + locks := make([]*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 := newOutput(unspent.txHash, unspent.vout, unspent.amount) - btc.fundingCoins[output.pt] = &utxo{ - txHash: unspent.txHash, - vout: unspent.vout, - amount: unspent.amount, - address: unspent.address, - } + output := NewOutput(unspent.TxHash, unspent.Vout, unspent.Amount) + locks = append(locks, &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 + redeemScripts[i][j] = unspent.RedeemScript } } else { coins[i] = splitOutputCoins[splitIndex] @@ -2891,6 +2541,8 @@ func (btc *baseWallet) fundMultiWithSplit(keep, maxLock uint64, values []*asset. } } + btc.cm.LockOutputs(locks) + btc.node.lockUnspent(false, spents) return coins, redeemScripts, splitFees, nil @@ -2901,19 +2553,14 @@ func (btc *baseWallet) fundMultiWithSplit(keep, maxLock uint64, values []*asset. // to fund. If splitting is allowed, a split transaction will be created to fund // all of the orders. func (btc *baseWallet) fundMulti(maxLock uint64, values []*asset.MultiOrderValue, splitTxFeeRate, maxFeeRate uint64, allowSplit bool, splitBuffer uint64) ([]asset.Coins, [][]dex.Bytes, uint64, error) { - btc.fundingMtx.Lock() - defer btc.fundingMtx.Unlock() - reserves := btc.bondReserves.Load() - coins, redeemScripts, fundingCoins, spents, err := btc.fundMultiBestEffort(reserves, maxLock, values, maxFeeRate, allowSplit) + coins, redeemScripts, fundingCoins, spents, err := btc.cm.FundMultiBestEffort(reserves, maxLock, values, maxFeeRate, allowSplit) if err != nil { return nil, nil, 0, err } if len(coins) == len(values) || !allowSplit { - for pt, fc := range fundingCoins { - btc.fundingCoins[pt] = fc - } + btc.cm.LockOutputsMap(fundingCoins) btc.node.lockUnspent(false, spents) return coins, redeemScripts, 0, nil } @@ -2921,77 +2568,6 @@ func (btc *baseWallet) fundMulti(maxLock uint64, values []*asset.MultiOrderValue return btc.fundMultiWithSplit(reserves, maxLock, values, splitTxFeeRate, maxFeeRate, splitBuffer) } -func tryFund(utxos []*compositeUTXO, - enough func(uint64, uint64) (bool, uint64)) ( - sum, extra, size uint64, coins asset.Coins, fundingCoins map[outPoint]*utxo, redeemScripts []dex.Bytes, spents []*output, err error) { - - fundingCoins = make(map[outPoint]*utxo) - - isEnoughWith := func(unspent *compositeUTXO) bool { - ok, _ := enough(size+uint64(unspent.input.VBytes()), sum+unspent.amount) - return ok - } - - addUTXO := func(unspent *compositeUTXO) { - v := unspent.amount - op := newOutput(unspent.txHash, unspent.vout, v) - coins = append(coins, op) - redeemScripts = append(redeemScripts, unspent.redeemScript) - spents = append(spents, op) - size += uint64(unspent.input.VBytes()) - fundingCoins[op.pt] = unspent.utxo - sum += v - } - - tryUTXOs := func(minconf uint32) bool { - sum, size = 0, 0 - coins, spents, redeemScripts = nil, nil, nil - fundingCoins = make(map[outPoint]*utxo) - - okUTXOs := make([]*compositeUTXO, 0, len(utxos)) // over-allocate - for _, cu := range utxos { - if cu.confs >= minconf { - okUTXOs = append(okUTXOs, cu) - } - } - - for { - // If there are none left, we don't have enough. - if len(okUTXOs) == 0 { - return false - } - - // Check if the largest output is too small. - lastUTXO := okUTXOs[len(okUTXOs)-1] - if !isEnoughWith(lastUTXO) { - addUTXO(lastUTXO) - okUTXOs = okUTXOs[0 : len(okUTXOs)-1] - continue - } - - // We only need one then. Find it. - idx := sort.Search(len(okUTXOs), func(i int) bool { - return isEnoughWith(okUTXOs[i]) - }) - // No need to check idx == len(okUTXOs). We already verified that the last - // utxo passes above. - addUTXO(okUTXOs[idx]) - _, extra = enough(size, sum) - return true - } - } - - // First try with confs>0, falling back to allowing 0-conf outputs. - if !tryUTXOs(1) { - if !tryUTXOs(0) { - return 0, 0, 0, nil, nil, nil, nil, fmt.Errorf("not enough to cover requested funds. "+ - "%s available in %d UTXOs", amount(sum), len(coins)) - } - } - - return -} - // split will send a split transaction and return the sized output. If the // split transaction is determined to be un-economical, it will not be sent, // there is no error, and the input coins will be returned unmodified, but an @@ -3011,17 +2587,15 @@ func tryFund(utxos []*compositeUTXO, // order is canceled partially filled, and then the remainder resubmitted. We // would already have an output of just the right size, and that would be // recognized here. -func (btc *baseWallet) split(value uint64, lots uint64, outputs []*output, inputsSize uint64, - fundingCoins map[outPoint]*utxo, suggestedFeeRate, bumpedMaxRate, extraOutput uint64) (asset.Coins, bool, uint64, error) { +func (btc *baseWallet) split(value uint64, lots uint64, outputs []*Output, inputsSize uint64, + fundingCoins map[OutPoint]*UTxO, suggestedFeeRate, bumpedMaxRate, extraOutput uint64) (asset.Coins, bool, uint64, error) { var err error defer func() { if err != nil { return } - for pt, fCoin := range fundingCoins { - btc.fundingCoins[pt] = fCoin - } + btc.cm.LockOutputsMap(fundingCoins) err = btc.node.lockUnspent(false, outputs) if err != nil { btc.log.Errorf("error locking unspent outputs: %v", err) @@ -3036,7 +2610,7 @@ func (btc *baseWallet) split(value uint64, lots uint64, outputs []*output, input coins := make(asset.Coins, 0, len(outputs)) for _, op := range outputs { coins = append(coins, op) - coinSum += op.value + coinSum += op.Val } valueStr := amount(value).String() @@ -3094,7 +2668,7 @@ func (btc *baseWallet) split(value uint64, lots uint64, outputs []*output, input } txHash := btc.hashTx(msgTx) - op := newOutput(txHash, 0, reqFunds) + op := NewOutput(txHash, 0, reqFunds) totalOut := reqFunds for i := 1; i < len(msgTx.TxOut); i++ { @@ -3103,12 +2677,11 @@ func (btc *baseWallet) split(value uint64, lots uint64, outputs []*output, input btc.addTxToHistory(asset.Split, txHash[:], 0, coinSum-totalOut, true) - // Need to save one funding coin (in the deferred function). - fundingCoins = map[outPoint]*utxo{op.pt: { - txHash: op.txHash(), - vout: op.vout(), - address: addrStr, - amount: reqFunds, + fundingCoins = map[OutPoint]*UTxO{op.Pt: { + TxHash: op.txHash(), + Vout: op.vout(), + Address: addrStr, + Amount: reqFunds, }} // Unlock spent coins @@ -3123,7 +2696,7 @@ func (btc *baseWallet) split(value uint64, lots uint64, outputs []*output, input op.txHash(), valueStr, btc.symbol, amount(reqFunds)) // Assign to coins so the deferred function will lock the output. - outputs = []*output{op} + outputs = []*Output{op} return asset.Coins{op}, true, coinSum - totalOut, nil } @@ -3148,38 +2721,7 @@ func (btc *baseWallet) splitBaggageFees(maxFeeRate uint64, extraOutput bool) (sw // ReturnCoins unlocks coins. This would be used in the case of a canceled or // partially filled order. Part of the asset.Wallet interface. func (btc *baseWallet) ReturnCoins(unspents asset.Coins) error { - if unspents == nil { // not just empty to make this harder to do accidentally - btc.log.Debugf("Returning all coins.") - btc.fundingMtx.Lock() - defer btc.fundingMtx.Unlock() - if err := btc.node.lockUnspent(true, nil); err != nil { - return err - } - btc.fundingCoins = make(map[outPoint]*utxo) - return nil - } - if len(unspents) == 0 { - return fmt.Errorf("cannot return zero coins") - } - - ops := make([]*output, 0, len(unspents)) - btc.log.Debugf("returning coins %s", unspents) - btc.fundingMtx.Lock() - defer btc.fundingMtx.Unlock() - for _, unspent := range unspents { - op, err := btc.convertCoin(unspent) - if err != nil { - return fmt.Errorf("error converting coin: %w", err) - } - ops = append(ops, op) - } - if err := btc.node.lockUnspent(true, ops); err != nil { - return err // could it have unlocked some of them? we may want to loop instead if that's the case - } - for _, op := range ops { - delete(btc.fundingCoins, op.pt) - } - return nil + return btc.cm.ReturnCoins(unspents) } // rawWalletTx gets the raw bytes of a transaction and the number of @@ -3199,123 +2741,14 @@ func (btc *baseWallet) rawWalletTx(hash *chainhash.Hash) ([]byte, uint32, error) if err != nil { return nil, 0, err } - return tx.Hex, uint32(tx.Confirmations), nil + return tx.Bytes, uint32(tx.Confirmations), nil } // FundingCoins gets funding coins for the coin IDs. The coins are locked. This // method might be called to reinitialize an order from data stored externally. // This method will only return funding coins, e.g. unspent transaction outputs. func (btc *baseWallet) FundingCoins(ids []dex.Bytes) (asset.Coins, error) { - // First check if we have the coins in cache. - coins := make(asset.Coins, 0, len(ids)) - notFound := make(map[outPoint]bool) - btc.fundingMtx.Lock() - defer btc.fundingMtx.Unlock() // stay locked until we update the map at the end - for _, id := range ids { - txHash, vout, err := decodeCoinID(id) - if err != nil { - return nil, err - } - pt := newOutPoint(txHash, vout) - fundingCoin, found := btc.fundingCoins[pt] - if found { - coins = append(coins, newOutput(txHash, vout, fundingCoin.amount)) - continue - } - notFound[pt] = true - } - if len(notFound) == 0 { - return coins, nil - } - - // Check locked outputs for not found coins. - lockedOutpoints, err := btc.node.listLockUnspent() - if err != nil { - return nil, err - } - - for _, rpcOP := range lockedOutpoints { - txHash, err := chainhash.NewHashFromStr(rpcOP.TxID) - if err != nil { - return nil, fmt.Errorf("error decoding txid from rpc server %s: %w", rpcOP.TxID, err) - } - pt := newOutPoint(txHash, rpcOP.Vout) - if !notFound[pt] { - continue // unrelated to the order - } - - txRaw, _, err := btc.rawWalletTx(txHash) - if err != nil { - return nil, err - } - msgTx, err := btc.deserializeTx(txRaw) - if err != nil { - btc.log.Warnf("Invalid transaction %v (%x): %v", txHash, txRaw, err) - continue - } - if rpcOP.Vout >= uint32(len(msgTx.TxOut)) { - btc.log.Warnf("Invalid vout %d for %v", rpcOP.Vout, txHash) - continue - } - txOut := msgTx.TxOut[rpcOP.Vout] - if txOut.Value <= 0 { - btc.log.Warnf("Invalid value %v for %v", txOut.Value, pt) - continue // try the listunspent output - } - _, addrs, _, err := txscript.ExtractPkScriptAddrs(txOut.PkScript, btc.chainParams) - if err != nil { - btc.log.Warnf("Invalid pkScript for %v: %v", pt, err) - continue - } - if len(addrs) != 1 { - btc.log.Warnf("pkScript for %v contains %d addresses instead of one", pt, len(addrs)) - continue - } - addrStr, err := btc.stringAddr(addrs[0], btc.chainParams) - if err != nil { - btc.log.Errorf("Failed to stringify address %v (default encoding): %v", addrs[0], err) - addrStr = addrs[0].String() // may or may not be able to retrieve the private keys by address! - } - utxo := &utxo{ - txHash: txHash, - vout: rpcOP.Vout, - address: addrStr, // for retrieving private key by address string - amount: uint64(txOut.Value), - } - coin := newOutput(txHash, rpcOP.Vout, uint64(txOut.Value)) - coins = append(coins, coin) - btc.fundingCoins[pt] = utxo - delete(notFound, pt) - if len(notFound) == 0 { - return coins, nil - } - } - - // Some funding coins still not found after checking locked outputs. - // Check wallet unspent outputs as last resort. Lock the coins if found. - _, utxoMap, _, err := btc.spendableUTXOs(0) - if err != nil { - return nil, err - } - coinsToLock := make([]*output, 0, len(notFound)) - for pt := range notFound { - utxo, found := utxoMap[pt] - if !found { - return nil, fmt.Errorf("funding coin not found: %s", pt.String()) - } - btc.fundingCoins[pt] = utxo.utxo - coin := newOutput(utxo.txHash, utxo.vout, utxo.amount) - coins = append(coins, coin) - coinsToLock = append(coinsToLock, coin) - delete(notFound, pt) - } - btc.log.Debugf("Locking funding coins that were unlocked %v", coinsToLock) - err = btc.node.lockUnspent(false, coinsToLock) - if err != nil { - return nil, err - } - - return coins, nil + return btc.cm.FundingCoins(ids) } // authAddOn implements the asset.Authenticator. @@ -3341,28 +2774,28 @@ func (a *authAddOn) Locked() bool { return a.w.locked() } -func (btc *baseWallet) addInputsToTx(tx *wire.MsgTx, coins asset.Coins) (uint64, []outPoint, error) { +func (btc *baseWallet) addInputsToTx(tx *wire.MsgTx, coins asset.Coins) (uint64, []OutPoint, error) { var totalIn uint64 // Add the funding utxos. - pts := make([]outPoint, 0, len(coins)) + pts := make([]OutPoint, 0, len(coins)) for _, coin := range coins { - op, err := btc.convertCoin(coin) + op, err := ConvertCoin(coin) if err != nil { return 0, nil, fmt.Errorf("error converting coin: %w", err) } - if op.value == 0 { + if op.Val == 0 { return 0, nil, fmt.Errorf("zero-valued output detected for %s:%d", op.txHash(), op.vout()) } - totalIn += op.value - txIn := wire.NewTxIn(op.wireOutPoint(), []byte{}, nil) + totalIn += op.Val + txIn := wire.NewTxIn(op.WireOutPoint(), []byte{}, nil) tx.AddTxIn(txIn) - pts = append(pts, op.pt) + pts = append(pts, op.Pt) } return totalIn, pts, nil } // fundedTx creates and returns a new MsgTx with the provided coins as inputs. -func (btc *baseWallet) fundedTx(coins asset.Coins) (*wire.MsgTx, uint64, []outPoint, error) { +func (btc *baseWallet) fundedTx(coins asset.Coins) (*wire.MsgTx, uint64, []OutPoint, error) { baseTx := wire.NewMsgTx(btc.txVersion()) totalIn, pts, err := btc.addInputsToTx(baseTx, coins) if err != nil { @@ -3373,13 +2806,13 @@ func (btc *baseWallet) fundedTx(coins asset.Coins) (*wire.MsgTx, uint64, []outPo // lookupWalletTxOutput looks up the value of a transaction output that is // spandable by this wallet, and creates an output. -func (btc *baseWallet) lookupWalletTxOutput(txHash *chainhash.Hash, vout uint32) (*output, error) { +func (btc *baseWallet) lookupWalletTxOutput(txHash *chainhash.Hash, vout uint32) (*Output, error) { getTxResult, err := btc.node.getWalletTransaction(txHash) if err != nil { return nil, err } - tx, err := btc.deserializeTx(getTxResult.Hex) + tx, err := btc.deserializeTx(getTxResult.Bytes) if err != nil { return nil, err } @@ -3389,7 +2822,7 @@ func (btc *baseWallet) lookupWalletTxOutput(txHash *chainhash.Hash, vout uint32) } value := tx.TxOut[vout].Value - return newOutput(txHash, vout, uint64(value)), nil + return NewOutput(txHash, vout, uint64(value)), nil } // getTransactions retrieves the transactions that created coins. The @@ -3424,20 +2857,20 @@ func (btc *baseWallet) getTxFee(tx *wire.MsgTx) (uint64, error) { if err != nil { return 0, err } - prevMsgTx, err := btc.deserializeTx(prevTx.Hex) + prevMsgTx, err := btc.deserializeTx(prevTx.Bytes) if err != nil { return 0, err } if len(prevMsgTx.TxOut) <= int(txIn.PreviousOutPoint.Index) { return 0, fmt.Errorf("tx %x references index %d output of %x, but it only has %d outputs", - tx.TxHash(), txIn.PreviousOutPoint.Index, prevMsgTx.TxHash(), len(prevMsgTx.TxOut)) + btc.hashTx(tx), txIn.PreviousOutPoint.Index, prevMsgTx.TxHash(), len(prevMsgTx.TxOut)) } in += uint64(prevMsgTx.TxOut[int(txIn.PreviousOutPoint.Index)].Value) } if in < out { return 0, fmt.Errorf("tx %x has value of inputs %d < value of outputs %d", - tx.TxHash(), in, out) + btc.hashTx(tx), in, out) } return in - out, nil @@ -3451,7 +2884,7 @@ func (btc *baseWallet) sizeAndFeesOfUnconfirmedTxs(txs []*GetTransactionResult) continue } - msgTx, err := btc.deserializeTx(tx.Hex) + msgTx, err := btc.deserializeTx(tx.Bytes) if err != nil { return 0, 0, err } @@ -3489,15 +2922,15 @@ func (btc *baseWallet) additionalFeesRequired(txs []*GetTransactionResult, newFe // changeCanBeAccelerated returns nil if the change can be accelerated, // otherwise it returns an error containing the reason why it cannot. -func (btc *baseWallet) changeCanBeAccelerated(change *output, remainingSwaps bool) error { +func (btc *baseWallet) changeCanBeAccelerated(change *Output, remainingSwaps bool) error { lockedUtxos, err := btc.node.listLockUnspent() if err != nil { return err } - changeTxHash := change.pt.txHash.String() + changeTxHash := change.Pt.TxHash.String() for _, utxo := range lockedUtxos { - if utxo.TxID == changeTxHash && utxo.Vout == change.pt.vout { + if utxo.TxID == changeTxHash && utxo.Vout == change.Pt.Vout { if !remainingSwaps { return errors.New("change locked by another order") } @@ -3511,7 +2944,7 @@ func (btc *baseWallet) changeCanBeAccelerated(change *output, remainingSwaps boo return err } for _, utxo := range utxos { - if utxo.TxID == changeTxHash && utxo.Vout == change.pt.vout { + if utxo.TxID == changeTxHash && utxo.Vout == change.Pt.Vout { return nil } } @@ -3527,8 +2960,8 @@ func (btc *baseWallet) changeCanBeAccelerated(change *output, remainingSwaps boo // requiredForRemainingSwaps is the amount of funds that are still required // to complete the order, so the change of the acceleration transaction must // contain at least that amount. -func (btc *baseWallet) signedAccelerationTx(previousTxs []*GetTransactionResult, orderChange *output, requiredForRemainingSwaps, newFeeRate uint64) (*wire.MsgTx, *output, uint64, error) { - makeError := func(err error) (*wire.MsgTx, *output, uint64, error) { +func (btc *baseWallet) signedAccelerationTx(previousTxs []*GetTransactionResult, orderChange *Output, requiredForRemainingSwaps, newFeeRate uint64) (*wire.MsgTx, *Output, uint64, error) { + makeError := func(err error) (*wire.MsgTx, *Output, uint64, error) { return nil, nil, 0, err } @@ -3563,9 +2996,9 @@ func (btc *baseWallet) signedAccelerationTx(previousTxs []*GetTransactionResult, fundsRequired := additionalFeesRequired + requiredForRemainingSwaps + txSize*newFeeRate var additionalInputs asset.Coins - if fundsRequired > orderChange.value { + if fundsRequired > orderChange.Val { // If change not enough, need to use other UTXOs. - enough := func(inputSize, inputsVal uint64) (bool, uint64) { + enough := func(_, inputSize, inputsVal uint64) (bool, uint64) { txSize := dexbtc.MinimumTxOverhead + inputSize // add the order change as an input @@ -3585,11 +3018,11 @@ func (btc *baseWallet) signedAccelerationTx(previousTxs []*GetTransactionResult, totalFees := additionalFeesRequired + txSize*newFeeRate totalReq := requiredForRemainingSwaps + totalFees - totalVal := inputsVal + orderChange.value + totalVal := inputsVal + orderChange.Val return totalReq <= totalVal, totalVal - totalReq } minConfs := uint32(1) - additionalInputs, _, _, _, _, _, err = btc.fundInternal(btc.bondReserves.Load(), minConfs, false, enough) + additionalInputs, _, _, _, _, _, err = btc.cm.Fund(btc.bondReserves.Load(), minConfs, false, enough) if err != nil { return makeError(fmt.Errorf("failed to fund acceleration tx: %w", err)) } @@ -3653,9 +3086,6 @@ func (btc *ExchangeWalletSPV) AccelerateOrder(swapCoins, accelerationCoins []dex } func accelerateOrder(btc *baseWallet, swapCoins, accelerationCoins []dex.Bytes, changeCoin dex.Bytes, requiredForRemainingSwaps, newFeeRate uint64) (asset.Coin, string, error) { - btc.fundingMtx.Lock() - defer btc.fundingMtx.Unlock() - changeTxHash, changeVout, err := decodeCoinID(changeCoin) if err != nil { return nil, "", err @@ -3683,7 +3113,7 @@ func accelerateOrder(btc *baseWallet, swapCoins, accelerationCoins []dex.Bytes, btc.addTxToHistory(asset.Acceleration, txHash[:], 0, fees, true) // Delete the old change from the cache - delete(btc.fundingCoins, newOutPoint(changeTxHash, changeVout)) + btc.cm.ReturnOutPoint(NewOutPoint(changeTxHash, changeVout)) if newChange == nil { return nil, txHash.String(), nil @@ -3696,7 +3126,7 @@ func accelerateOrder(btc *baseWallet, swapCoins, accelerationCoins []dex.Bytes, // changeCanBeAccelerated would have returned an error since this means // that the change was locked by another order. if requiredForRemainingSwaps > 0 { - err = btc.node.lockUnspent(false, []*output{newChange}) + err = btc.node.lockUnspent(false, []*Output{newChange}) if err != nil { // The transaction is already broadcasted, so don't fail now. btc.log.Errorf("failed to lock change output: %v", err) @@ -3704,12 +3134,12 @@ 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.fundingCoins[newChange.pt] = &utxo{ - txHash: newChange.txHash(), - vout: newChange.vout(), - address: newChange.String(), - amount: newChange.value, - } + btc.cm.LockOutputs([]*UTxO{{ + TxHash: newChange.txHash(), + Vout: newChange.vout(), + Address: newChange.String(), + Amount: newChange.Val, + }}) } // return nil error since tx is already broadcast, and core needs to update @@ -3734,9 +3164,6 @@ func (btc *ExchangeWalletSPV) AccelerationEstimate(swapCoins, accelerationCoins } func accelerationEstimate(btc *baseWallet, swapCoins, accelerationCoins []dex.Bytes, changeCoin dex.Bytes, requiredForRemainingSwaps, newFeeRate uint64) (uint64, error) { - btc.fundingMtx.RLock() - defer btc.fundingMtx.RUnlock() - previousTxs, err := btc.getTransactions(append(swapCoins, accelerationCoins...)) if err != nil { return 0, fmt.Errorf("failed to get transactions: %w", err) @@ -3871,22 +3298,20 @@ func (btc *baseWallet) maxAccelerationRate(changeVal, feesAlreadyPaid, orderTxVB } // If necessary, use as many additional utxos as needed - btc.fundingMtx.RLock() - utxos, _, _, err := btc.spendableUTXOs(1) - btc.fundingMtx.RUnlock() + utxos, _, _, err := btc.cm.SpendableUTXOs(1) if err != nil { return 0, err } for _, utxo := range utxos { - if utxo.input.NonStandardScript { + if utxo.Input.NonStandardScript { continue } txSize += dexbtc.TxInOverhead + - uint64(wire.VarIntSerializeSize(uint64(utxo.input.SigScriptSize))) + - uint64(utxo.input.SigScriptSize) - witnessSize += uint64(utxo.input.WitnessSize) - additionalUtxosVal += utxo.amount + uint64(wire.VarIntSerializeSize(uint64(utxo.Input.SigScriptSize))) + + uint64(utxo.Input.SigScriptSize) + witnessSize += uint64(utxo.Input.WitnessSize) + additionalUtxosVal += utxo.Amount if calcFeeRate() >= rateNeeded { return rateNeeded, nil } @@ -3952,7 +3377,7 @@ func preAccelerate(btc *baseWallet, swapCoins, accelerationCoins []dex.Bytes, ch // We must make sure that the wallet can fund an acceleration at least // the max suggestion, and if not, lower the max suggestion to the max // rate that the wallet can fund. - maxRate, err := btc.maxAccelerationRate(changeOutput.value, feesAlreadyPaid, existingTxSize, requiredForRemainingSwaps, maxSuggestion) + maxRate, err := btc.maxAccelerationRate(changeOutput.Val, feesAlreadyPaid, existingTxSize, requiredForRemainingSwaps, maxSuggestion) if err != nil { return makeError(err) } @@ -4156,7 +3581,7 @@ func (btc *baseWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin, ui // Prepare the receipts. receipts := make([]asset.Receipt, 0, swapCount) for i, contract := range swaps.Contracts { - output := newOutput(txHash, uint32(i), contract.Value) + output := NewOutput(txHash, uint32(i), contract.Value) refundAddr := refundAddrs[i] signedRefundTx, err := btc.refundTx(output.txHash(), output.vout(), contracts[i], contract.Value, refundAddr, swaps.FeeRate) @@ -4168,11 +3593,11 @@ func (btc *baseWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin, ui if err != nil { return nil, nil, 0, fmt.Errorf("error serializing refund tx: %w", err) } - receipts = append(receipts, &swapReceipt{ - output: output, - contract: contracts[i], - expiration: time.Unix(int64(contract.LockTime), 0).UTC(), - signedRefund: refundBuff.Bytes(), + receipts = append(receipts, &SwapReceipt{ + Output: output, + SwapContract: contracts[i], + ExpirationTime: time.Unix(int64(contract.LockTime), 0).UTC(), + SignedRefundBytes: refundBuff.Bytes(), }) } @@ -4190,12 +3615,11 @@ func (btc *baseWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin, ui changeCoin = change } - btc.fundingMtx.Lock() - defer btc.fundingMtx.Unlock() - if swaps.LockChange { + var locks []*UTxO + if change != nil && swaps.LockChange { // Lock the change output btc.log.Debugf("locking change coin %s", change) - err = btc.node.lockUnspent(false, []*output{change}) + err = btc.node.lockUnspent(false, []*Output{change}) if err != nil { // The swap transaction is already broadcasted, so don't fail now. btc.log.Errorf("failed to lock change output: %v", err) @@ -4209,18 +3633,16 @@ func (btc *baseWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin, ui // Log it as a fundingCoin, since it is expected that this will be // chained into further matches. - btc.fundingCoins[change.pt] = &utxo{ - txHash: change.txHash(), - vout: change.vout(), - address: addrStr, - amount: change.value, - } + locks = append(locks, &UTxO{ + TxHash: change.txHash(), + Vout: change.vout(), + Address: addrStr, + Amount: change.Val, + }) } - // Delete the UTXOs from the cache. - for _, pt := range pts { - delete(btc.fundingCoins, pt) - } + btc.cm.LockOutputs(locks) + btc.cm.UnlockOutPoints(pts) return receipts, changeCoin, fees, nil } @@ -4239,7 +3661,7 @@ func (btc *baseWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Coin, return nil, nil, 0, fmt.Errorf("no audit info") } - cinfo, err := btc.convertAuditInfo(r.Spends) + cinfo, err := ConvertAuditInfo(r.Spends, btc.decodeAddr, btc.chainParams) if err != nil { return nil, nil, 0, err } @@ -4262,10 +3684,10 @@ func (btc *baseWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Coin, prevScripts = append(prevScripts, pkScript) addresses = append(addresses, receiver) contracts = append(contracts, contract) - txIn := wire.NewTxIn(cinfo.output.wireOutPoint(), nil, nil) + txIn := wire.NewTxIn(cinfo.Output.WireOutPoint(), nil, nil) msgTx.AddTxIn(txIn) - values = append(values, int64(cinfo.output.value)) - totalIn += cinfo.output.value + values = append(values, int64(cinfo.Output.Val)) + totalIn += cinfo.Output.Val } // Calculate the size and the fees. @@ -4312,7 +3734,7 @@ func (btc *baseWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Coin, txOut := wire.NewTxOut(int64(totalIn-fee), pkScript) // One last check for dust. if btc.IsDust(txOut, feeRate) { - return nil, nil, 0, fmt.Errorf("redeem output is dust") + return nil, nil, 0, fmt.Errorf("swap redeem output is dust") } msgTx.AddTxOut(txOut) @@ -4354,14 +3776,14 @@ func (btc *baseWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Coin, // Log the change output. coinIDs := make([]dex.Bytes, 0, len(form.Redemptions)) for i := range form.Redemptions { - coinIDs = append(coinIDs, toCoinID(txHash, uint32(i))) + coinIDs = append(coinIDs, ToCoinID(txHash, uint32(i))) } - return coinIDs, newOutput(txHash, 0, uint64(txOut.Value)), fee, nil + return coinIDs, NewOutput(txHash, 0, uint64(txOut.Value)), fee, nil } -// convertAuditInfo converts from the common *asset.AuditInfo type to our +// ConvertAuditInfo converts from the common *asset.AuditInfo type to our // internal *auditInfo type. -func (btc *baseWallet) convertAuditInfo(ai *asset.AuditInfo) (*auditInfo, error) { +func ConvertAuditInfo(ai *asset.AuditInfo, decodeAddr dexbtc.AddressDecoder, chainParams *chaincfg.Params) (*AuditInfo, error) { if ai.Coin == nil { return nil, fmt.Errorf("no coin") } @@ -4371,14 +3793,14 @@ func (btc *baseWallet) convertAuditInfo(ai *asset.AuditInfo) (*auditInfo, error) return nil, err } - recip, err := btc.decodeAddr(ai.Recipient, btc.chainParams) + recip, err := decodeAddr(ai.Recipient, chainParams) if err != nil { return nil, err } - return &auditInfo{ - output: newOutput(txHash, vout, ai.Coin.Value()), // *output - recipient: recip, // btcutil.Address + return &AuditInfo{ + Output: NewOutput(txHash, vout, ai.Coin.Value()), // *Output + Recipient: recip, // btcutil.Address contract: ai.Contract, // []byte secretHash: ai.SecretHash, // []byte expiration: ai.Expiration, // time.Time @@ -4389,17 +3811,16 @@ func (btc *baseWallet) convertAuditInfo(ai *asset.AuditInfo) (*auditInfo, error) // specified unspent coin. A slice of pubkeys required to spend the coin and a // signature for each pubkey are returned. func (btc *baseWallet) SignMessage(coin asset.Coin, msg dex.Bytes) (pubkeys, sigs []dex.Bytes, err error) { - op, err := btc.convertCoin(coin) + op, err := ConvertCoin(coin) if err != nil { return nil, nil, fmt.Errorf("error converting coin: %w", err) } - btc.fundingMtx.RLock() - utxo := btc.fundingCoins[op.pt] - btc.fundingMtx.RUnlock() + utxo := btc.cm.LockedOutput(op.Pt) + if utxo == nil { return nil, nil, fmt.Errorf("no utxo found for %s", op) } - privKey, err := btc.node.privKeyForAddress(utxo.address) + privKey, err := btc.node.privKeyForAddress(utxo.Address) if err != nil { return nil, nil, err } @@ -4513,7 +3934,7 @@ func (btc *baseWallet) AuditContract(coinID, contract, txData dex.Bytes, rebroad } return &asset.AuditInfo{ - Coin: newOutput(txHash, vout, uint64(txOut.Value)), + Coin: NewOutput(txHash, vout, uint64(txOut.Value)), Recipient: addrStr, Contract: contract, SecretHash: secretHash, @@ -4522,7 +3943,7 @@ func (btc *baseWallet) AuditContract(coinID, contract, txData dex.Bytes, rebroad } // LockTimeExpired returns true if the specified locktime has expired, making it -// possible to redeem the locked coins. +// possible to refund the locked coins. func (btc *baseWallet) LockTimeExpired(_ context.Context, lockTime time.Time) (bool, error) { medianTime, err := btc.node.medianTime() // TODO: pass ctx if err != nil { @@ -4553,234 +3974,7 @@ func (btc *baseWallet) ContractLockTimeExpired(ctx context.Context, contract dex // This method blocks until the redemption is found, an error occurs or the // provided context is canceled. func (btc *intermediaryWallet) FindRedemption(ctx context.Context, coinID, _ dex.Bytes) (redemptionCoin, secret dex.Bytes, err error) { - txHash, vout, err := decodeCoinID(coinID) - if err != nil { - return nil, nil, fmt.Errorf("cannot decode contract coin id: %w", err) - } - - outPt := newOutPoint(txHash, vout) - - tx, err := btc.node.getWalletTransaction(txHash) - if err != nil { - return nil, nil, fmt.Errorf("error finding wallet transaction: %v", err) - } - - txOut, err := btc.txOutFromTxBytes(tx.Hex, vout) - if err != nil { - return nil, nil, err - } - pkScript := txOut.PkScript - - var blockHash *chainhash.Hash - if tx.BlockHash != "" { - blockHash, err = chainhash.NewHashFromStr(tx.BlockHash) - if err != nil { - return nil, nil, fmt.Errorf("error decoding block hash from string %q: %w", - tx.BlockHash, err) - } - } - - var blockHeight int32 - if blockHash != nil { - btc.log.Infof("FindRedemption - Checking block %v for swap %v", blockHash, outPt) - blockHeight, err = btc.checkRedemptionBlockDetails(outPt, blockHash, pkScript) - if err != nil { - return nil, nil, fmt.Errorf("checkRedemptionBlockDetails: op %v / block %q: %w", - outPt, tx.BlockHash, err) - } - } - - req := &findRedemptionReq{ - outPt: outPt, - blockHash: blockHash, - blockHeight: blockHeight, - resultChan: make(chan *findRedemptionResult, 1), - pkScript: pkScript, - contractHash: dexbtc.ExtractScriptHash(pkScript), - } - - if err := btc.queueFindRedemptionRequest(req); err != nil { - return nil, nil, fmt.Errorf("queueFindRedemptionRequest error for redemption %s: %w", outPt, err) - } - - go btc.tryRedemptionRequests(ctx, nil, []*findRedemptionReq{req}) - - var result *findRedemptionResult - select { - case result = <-req.resultChan: - if result == nil { - err = fmt.Errorf("unexpected nil result for redemption search for %s", outPt) - } - case <-ctx.Done(): - err = fmt.Errorf("context cancelled during search for redemption for %s", outPt) - } - - // If this contract is still in the findRedemptionQueue, remove from the queue - // to prevent further redemption search attempts for this contract. - btc.findRedemptionMtx.Lock() - delete(btc.findRedemptionQueue, outPt) - btc.findRedemptionMtx.Unlock() - - // result would be nil if ctx is canceled or the result channel is closed - // without data, which would happen if the redemption search is aborted when - // this ExchangeWallet is shut down. - if result != nil { - return result.redemptionCoinID, result.secret, result.err - } - return nil, nil, err -} - -// queueFindRedemptionRequest adds the *findRedemptionReq to the queue, erroring -// if there is already a request queued for this outpoint. -func (btc *baseWallet) queueFindRedemptionRequest(req *findRedemptionReq) error { - btc.findRedemptionMtx.Lock() - defer btc.findRedemptionMtx.Unlock() - if _, exists := btc.findRedemptionQueue[req.outPt]; exists { - return fmt.Errorf("duplicate find redemption request for %s", req.outPt) - } - btc.findRedemptionQueue[req.outPt] = req - return nil -} - -// tryRedemptionRequests searches all mainchain blocks with height >= startBlock -// for redemptions. -func (btc *intermediaryWallet) tryRedemptionRequests(ctx context.Context, startBlock *chainhash.Hash, reqs []*findRedemptionReq) { - undiscovered := make(map[outPoint]*findRedemptionReq, len(reqs)) - mempoolReqs := make(map[outPoint]*findRedemptionReq) - for _, req := range reqs { - // If there is no block hash yet, this request hasn't been mined, and a - // spending tx cannot have been mined. Only check mempool. - if req.blockHash == nil { - mempoolReqs[req.outPt] = req - continue - } - undiscovered[req.outPt] = req - } - - epicFail := func(s string, a ...any) { - errMsg := fmt.Sprintf(s, a...) - for _, req := range reqs { - req.fail(errMsg) - } - } - - // Only search up to the current tip. This does leave two unhandled - // scenarios worth mentioning. - // 1) A new block is mined during our search. In this case, we won't - // see the new block, but tryRedemptionRequests should be called again - // by the block monitoring loop. - // 2) A reorg happens, and this tip becomes orphaned. In this case, the - // worst that can happen is that a shorter chain will replace a longer - // one (extremely rare). Even in that case, we'll just log the error and - // exit the block loop. - tipHeight, err := btc.node.getBestBlockHeight() - if err != nil { - epicFail("tryRedemptionRequests getBestBlockHeight error: %v", err) - return - } - - // If a startBlock is provided at a higher height, use that as the starting - // point. - var iHash *chainhash.Hash - var iHeight int32 - if startBlock != nil { - h, err := btc.tipRedeemer.getBlockHeight(startBlock) - if err != nil { - epicFail("tryRedemptionRequests startBlock getBlockHeight error: %v", err) - return - } - iHeight = h - iHash = startBlock - } else { - iHeight = math.MaxInt32 - for _, req := range undiscovered { - if req.blockHash != nil && req.blockHeight < iHeight { - iHeight = req.blockHeight - iHash = req.blockHash - } - } - } - - // Helper function to check that the request hasn't been located in another - // thread and removed from queue already. - reqStillQueued := func(outPt outPoint) bool { - _, found := btc.findRedemptionQueue[outPt] - return found - } - - for iHeight <= tipHeight { - validReqs := make(map[outPoint]*findRedemptionReq, len(undiscovered)) - btc.findRedemptionMtx.RLock() - for outPt, req := range undiscovered { - if iHeight >= req.blockHeight && reqStillQueued(req.outPt) { - validReqs[outPt] = req - } - } - btc.findRedemptionMtx.RUnlock() - - if len(validReqs) == 0 { - iHeight++ - continue - } - - btc.log.Debugf("tryRedemptionRequests - Checking block %v for redemptions...", iHash) - discovered := btc.tipRedeemer.searchBlockForRedemptions(ctx, validReqs, *iHash) - for outPt, res := range discovered { - req, found := undiscovered[outPt] - if !found { - btc.log.Critical("Request not found in undiscovered map. This shouldn't be possible.") - continue - } - redeemTxID, redeemTxInput, _ := decodeCoinID(res.redemptionCoinID) - btc.log.Debugf("Found redemption %s:%d", redeemTxID, redeemTxInput) - req.success(res) - delete(undiscovered, outPt) - } - - if len(undiscovered) == 0 { - break - } - - iHeight++ - if iHeight <= tipHeight { - if iHash, err = btc.node.getBlockHash(int64(iHeight)); err != nil { - // This might be due to a reorg. Don't abandon yet, since - // tryRedemptionRequests will be tried again by the block - // monitor loop. - btc.log.Warn("error getting block hash for height %d: %v", iHeight, err) - return - } - } - } - - // Check mempool for any remaining undiscovered requests. - for outPt, req := range undiscovered { - mempoolReqs[outPt] = req - } - - if len(mempoolReqs) == 0 { - return - } - - // Do we really want to do this? Mempool could be huge. - searchDur := time.Minute * 5 - searchCtx, cancel := context.WithTimeout(ctx, searchDur) - defer cancel() - for outPt, res := range btc.tipRedeemer.findRedemptionsInMempool(searchCtx, mempoolReqs) { - req, ok := mempoolReqs[outPt] - if !ok { - btc.log.Errorf("findRedemptionsInMempool discovered outpoint not found") - continue - } - req.success(res) - } - if err := searchCtx.Err(); err != nil { - if errors.Is(err, context.DeadlineExceeded) { - btc.log.Errorf("mempool search exceeded %s time limit", searchDur) - } else { - btc.log.Error("mempool search was cancelled") - } - } + return btc.rf.FindRedemption(ctx, coinID) } // Refund revokes a contract. This can only be used after the time lock has @@ -4836,7 +4030,7 @@ func (btc *baseWallet) Refund(coinID, contract dex.Bytes, feeRate uint64) (dex.B } btc.addTxToHistory(asset.Refund, txHash[:], utxo.Value, fee, true) - return toCoinID(refundHash, 0), nil + return ToCoinID(refundHash, 0), nil } // refundTx creates and signs a contract`s refund transaction. If refundAddr is @@ -4951,26 +4145,20 @@ func (btc *baseWallet) RedemptionAddress() (string, error) { // A recyclable address is a redemption or refund address that may be recycled // if unused. If already recycled addresses are available, one will be returned. func (btc *baseWallet) recyclableAddress() (string, error) { - var recycledAddr string - btc.recycledAddrMtx.Lock() - for addr := range btc.recycledAddrs { - if owns, err := btc.OwnsDepositAddress(addr); owns { - delete(btc.recycledAddrs, addr) - recycledAddr = addr + var returns []string + defer btc.ar.ReturnAddresses(returns) + for { + addr := btc.ar.Address() + if addr == "" { break + } + if owns, err := btc.OwnsDepositAddress(addr); owns { + return addr, nil } else if err != nil { btc.log.Errorf("Error checking ownership of recycled address %q: %v", addr, err) - // Don't delete the address in case it's just a network error for - // an rpc wallet or something. - } else { // we don't own it - delete(btc.recycledAddrs, addr) + returns = append(returns, addr) } } - btc.recycledAddrMtx.Unlock() - if recycledAddr != "" { - return recycledAddr, nil - } - return btc.DepositAddress() } @@ -4992,26 +4180,14 @@ func (btc *baseWallet) ReturnRefundContracts(contracts [][]byte) { addrs = append(addrs, addr) } if len(addrs) > 0 { - btc.returnAddresses(addrs) + btc.ar.ReturnAddresses(addrs) } } // ReturnRedemptionAddress accepts a Wallet.RedemptionAddress() if the address // will not be used. func (btc *baseWallet) ReturnRedemptionAddress(addr string) { - btc.returnAddresses([]string{addr}) -} - -func (btc *baseWallet) returnAddresses(addrs []string) { - btc.recycledAddrMtx.Lock() - defer btc.recycledAddrMtx.Unlock() - for _, addr := range addrs { - if _, exists := btc.recycledAddrs[addr]; exists { - btc.log.Errorf("Returned address %q was already indexed", addr) - continue - } - btc.recycledAddrs[addr] = struct{}{} - } + btc.ar.ReturnAddresses([]string{addr}) } // NewAddress returns a new address from the wallet. This satisfies the @@ -5038,7 +4214,7 @@ func (btc *baseWallet) Withdraw(address string, value, feeRate uint64) (asset.Co if err != nil { return nil, err } - return newOutput(txHash, vout, sent), nil + return NewOutput(txHash, vout, sent), nil } // Send sends the exact value to the specified address. This is different from @@ -5049,7 +4225,7 @@ func (btc *baseWallet) Send(address string, value, feeRate uint64) (asset.Coin, if err != nil { return nil, err } - return newOutput(txHash, vout, sent), nil + return NewOutput(txHash, vout, sent), nil } // SendTransaction broadcasts a valid fully-signed transaction. @@ -5066,7 +4242,7 @@ func (btc *baseWallet) SendTransaction(rawTx []byte) ([]byte, error) { btc.markTxAsSubmitted(txHash[:]) - return toCoinID(txHash, 0), nil + return ToCoinID(txHash, 0), nil } // ValidateSecret checks that the secret satisfies the contract. @@ -5100,12 +4276,9 @@ func (btc *baseWallet) send(address string, val uint64, feeRate uint64, subtract baseSize += dexbtc.P2PKHOutputSize * 2 } - btc.fundingMtx.Lock() - defer btc.fundingMtx.Unlock() - enough := sendEnough(val, feeRate, subtract, uint64(baseSize), btc.segwit, true) minConfs := uint32(0) - coins, _, _, _, inputsSize, _, err := btc.fundInternal(btc.bondReserves.Load(), minConfs, false, enough) + coins, _, _, _, inputsSize, _, err := btc.cm.Fund(btc.bondReserves.Load(), minConfs, false, enough) if err != nil { return nil, 0, 0, fmt.Errorf("error funding transaction: %w", err) } @@ -5214,7 +4387,7 @@ func (btc *intermediaryWallet) watchBlocks(ctx context.Context) { ticker := time.NewTicker(blockTicker) defer ticker.Stop() - var walletBlock <-chan *block + var walletBlock <-chan *BlockVector if notifier, isNotifier := btc.node.(tipNotifier); isNotifier { walletBlock = notifier.tipFeed() } @@ -5222,7 +4395,7 @@ func (btc *intermediaryWallet) watchBlocks(ctx context.Context) { // A polledBlock is a block found during polling, but whose broadcast has // been queued in anticipation of a wallet notification. type polledBlock struct { - *block + *BlockVector queue *time.Timer } @@ -5248,18 +4421,18 @@ func (btc *intermediaryWallet) watchBlocks(ctx context.Context) { continue } - if queuedBlock != nil && *newTipHash == queuedBlock.block.hash { + if queuedBlock != nil && *newTipHash == queuedBlock.BlockVector.Hash { continue } btc.tipMtx.RLock() - sameTip := btc.currentTip.hash == *newTipHash + sameTip := btc.currentTip.Hash == *newTipHash btc.tipMtx.RUnlock() if sameTip { continue } - newTip := &block{newTipHdr.Height, *newTipHash} + newTip := &BlockVector{newTipHdr.Height, *newTipHash} // If the wallet is not offering tip reports, send this one right // away. @@ -5281,11 +4454,11 @@ func (btc *intermediaryWallet) watchBlocks(ctx context.Context) { blockAllowance *= 10 } queuedBlock = &polledBlock{ - block: newTip, + BlockVector: newTip, queue: time.AfterFunc(blockAllowance, func() { btc.log.Warnf("Reporting a block found in polling that the wallet apparently "+ "never reported: %d %s. If you see this message repeatedly, it may indicate "+ - "an issue with the wallet.", newTip.height, newTip.hash) + "an issue with the wallet.", newTip.Height, newTip.Hash) btc.reportNewTip(ctx, newTip) }), } @@ -5294,8 +4467,8 @@ func (btc *intermediaryWallet) watchBlocks(ctx context.Context) { // Tip reports from the wallet are always sent, and we'll clear any // queued polled block that would appear to be superceded by this one. case walletTip := <-walletBlock: - if queuedBlock != nil && walletTip.height >= queuedBlock.height { - if !queuedBlock.queue.Stop() && walletTip.hash == queuedBlock.hash { + if queuedBlock != nil && walletTip.Height >= queuedBlock.Height { + if !queuedBlock.queue.Stop() && walletTip.Hash == queuedBlock.Hash { continue } queuedBlock = nil @@ -5313,198 +4486,20 @@ func (btc *intermediaryWallet) watchBlocks(ctx context.Context) { } } -// prepareRedemptionRequestsForBlockCheck prepares a copy of the -// findRedemptionQueue, checking for missing block data along the way. -func (btc *intermediaryWallet) prepareRedemptionRequestsForBlockCheck() []*findRedemptionReq { - // Search for contract redemption in new blocks if there - // are contracts pending redemption. - btc.findRedemptionMtx.Lock() - defer btc.findRedemptionMtx.Unlock() - reqs := make([]*findRedemptionReq, 0, len(btc.findRedemptionQueue)) - for _, req := range btc.findRedemptionQueue { - // If the request doesn't have a block hash yet, check if we can get one - // now. - if req.blockHash == nil { - btc.trySetRedemptionRequestBlock(req) - } - reqs = append(reqs, req) - } - return reqs -} - // reportNewTip sets the currentTip. The tipChange callback function is invoked -// and a goroutine is started to check if any contracts in the -// findRedemptionQueue are redeemed in the new blocks. -func (btc *intermediaryWallet) reportNewTip(ctx context.Context, newTip *block) { +// and RedemptionFinder is informed of the new block. +func (btc *intermediaryWallet) reportNewTip(ctx context.Context, newTip *BlockVector) { btc.tipMtx.Lock() defer btc.tipMtx.Unlock() prevTip := btc.currentTip btc.currentTip = newTip - btc.log.Debugf("tip change: %d (%s) => %d (%s)", prevTip.height, prevTip.hash, newTip.height, newTip.hash) - btc.emit.TipChange(uint64(newTip.height)) - - go btc.checkPendingTxs(uint64(newTip.height)) - - reqs := btc.prepareRedemptionRequestsForBlockCheck() - // Redemption search would be compromised if the starting point cannot - // be determined, as searching just the new tip might result in blocks - // being omitted from the search operation. If that happens, cancel all - // find redemption requests in queue. - notifyFatalFindRedemptionError := func(s string, a ...any) { - for _, req := range reqs { - req.fail("tipChange handler - "+s, a...) - } - } - - var startPoint *block - // Check if the previous tip is still part of the mainchain (prevTip confs >= 0). - // Redemption search would typically resume from prevTipHeight + 1 unless the - // previous tip was re-orged out of the mainchain, in which case redemption - // search will resume from the mainchain ancestor of the previous tip. - prevTipHeader, isMainchain, err := btc.tipRedeemer.getBlockHeader(&prevTip.hash) - switch { - case err != nil: - // Redemption search cannot continue reliably without knowing if there - // was a reorg, cancel all find redemption requests in queue. - notifyFatalFindRedemptionError("getBlockHeader error for prev tip hash %s: %w", - prevTip.hash, err) - return - - case !isMainchain: - // The previous tip is no longer part of the mainchain. Crawl blocks - // backwards until finding a mainchain block. Start with the block - // that is the immediate ancestor to the previous tip. - ancestorBlockHash, err := chainhash.NewHashFromStr(prevTipHeader.PreviousBlockHash) - if err != nil { - notifyFatalFindRedemptionError("hash decode error for block %s: %w", prevTipHeader.PreviousBlockHash, err) - return - } - for { - aBlock, isMainchain, err := btc.tipRedeemer.getBlockHeader(ancestorBlockHash) - if err != nil { - notifyFatalFindRedemptionError("getBlockHeader error for block %s: %w", ancestorBlockHash, err) - return - } - if isMainchain { - // Found the mainchain ancestor of previous tip. - startPoint = &block{height: aBlock.Height, hash: *ancestorBlockHash} - btc.log.Debugf("reorg detected from height %d to %d", aBlock.Height, newTip.height) - break - } - if aBlock.Height == 0 { - // Crawled back to genesis block without finding a mainchain ancestor - // for the previous tip. Should never happen! - notifyFatalFindRedemptionError("no mainchain ancestor for orphaned block %s", prevTipHeader.Hash) - return - } - ancestorBlockHash, err = chainhash.NewHashFromStr(aBlock.PreviousBlockHash) - if err != nil { - notifyFatalFindRedemptionError("hash decode error for block %s: %w", prevTipHeader.PreviousBlockHash, err) - return - } - } - - case newTip.height-prevTipHeader.Height > 1: - // 2 or more blocks mined since last tip, start at prevTip height + 1. - afterPrivTip := prevTipHeader.Height + 1 - hashAfterPrevTip, err := btc.node.getBlockHash(afterPrivTip) - if err != nil { - notifyFatalFindRedemptionError("getBlockHash error for height %d: %w", afterPrivTip, err) - return - } - startPoint = &block{hash: *hashAfterPrevTip, height: afterPrivTip} - - default: - // Just 1 new block since last tip report, search the lone block. - startPoint = newTip - } - - if len(reqs) > 0 { - go btc.tryRedemptionRequests(ctx, &startPoint.hash, reqs) - } -} - -// trySetRedemptionRequestBlock should be called with findRedemptionMtx Lock'ed. -func (btc *intermediaryWallet) trySetRedemptionRequestBlock(req *findRedemptionReq) { - tx, err := btc.node.getWalletTransaction(&req.outPt.txHash) - if err != nil { - btc.log.Errorf("getWalletTransaction error for FindRedemption transaction: %v", err) - return - } - - if tx.BlockHash == "" { - return - } - blockHash, err := chainhash.NewHashFromStr(tx.BlockHash) - if err != nil { - btc.log.Errorf("error decoding block hash %q: %v", tx.BlockHash, err) - return - } - - blockHeight, err := btc.checkRedemptionBlockDetails(req.outPt, blockHash, req.pkScript) - if err != nil { - btc.log.Error(err) - return - } - // Don't update the findRedemptionReq, since the findRedemptionMtx only - // protects the map. - req = &findRedemptionReq{ - outPt: req.outPt, - blockHash: blockHash, - blockHeight: blockHeight, - resultChan: req.resultChan, - pkScript: req.pkScript, - contractHash: req.contractHash, - } - btc.findRedemptionQueue[req.outPt] = req -} - -// checkRedemptionBlockDetails retrieves the block at blockStr and checks that -// the provided pkScript matches the specified outpoint. The transaction's -// block height is returned. -func (btc *intermediaryWallet) checkRedemptionBlockDetails(outPt outPoint, blockHash *chainhash.Hash, pkScript []byte) (int32, error) { - blockHeight, err := btc.tipRedeemer.getBlockHeight(blockHash) - if err != nil { - return 0, fmt.Errorf("GetBlockHeight for redemption block %s error: %w", blockHash, err) - } - blk, err := btc.tipRedeemer.getBlock(*blockHash) - if err != nil { - return 0, fmt.Errorf("error retrieving redemption block %s: %w", blockHash, err) - } - - var tx *wire.MsgTx -out: - for _, iTx := range blk.Transactions { - if *btc.hashTx(iTx) == outPt.txHash { - tx = iTx - break out - } - } - if tx == nil { - return 0, fmt.Errorf("transaction %s not found in block %s", outPt.txHash, blockHash) - } - if uint32(len(tx.TxOut)) < outPt.vout+1 { - return 0, fmt.Errorf("no output %d in redemption transaction %s found in block %s", outPt.vout, outPt.txHash, blockHash) - } - if !bytes.Equal(tx.TxOut[outPt.vout].PkScript, pkScript) { - return 0, fmt.Errorf("pubkey script mismatch for redemption at %s", outPt) - } + btc.log.Debugf("tip change: %d (%s) => %d (%s)", prevTip.Height, prevTip.Hash, newTip.Height, newTip.Hash) + btc.emit.TipChange(uint64(newTip.Height)) - return blockHeight, nil -} + go btc.checkPendingTxs(uint64(newTip.Height)) -// convertCoin converts the asset.Coin to an output. -func (btc *baseWallet) convertCoin(coin asset.Coin) (*output, error) { - op, _ := coin.(*output) - if op != nil { - return op, nil - } - txHash, vout, err := decodeCoinID(coin.ID()) - if err != nil { - return nil, err - } - return newOutput(txHash, vout, coin.Value()), nil + btc.rf.ReportNewTip(ctx, prevTip, newTip) } // sendWithReturn sends the unsigned transaction with an added output (unless @@ -5524,9 +4519,9 @@ func (btc *baseWallet) sendWithReturn(baseTx *wire.MsgTx, addr btcutil.Address, // signTxAndAddChange signs the passed tx and adds a change output if the change // wouldn't be dust. Returns but does NOT broadcast the signed tx. func (btc *baseWallet) signTxAndAddChange(baseTx *wire.MsgTx, addr btcutil.Address, - totalIn, totalOut, feeRate uint64) (*wire.MsgTx, *output, uint64, error) { + totalIn, totalOut, feeRate uint64) (*wire.MsgTx, *Output, uint64, error) { - makeErr := func(s string, a ...any) (*wire.MsgTx, *output, uint64, error) { + makeErr := func(s string, a ...any) (*wire.MsgTx, *Output, uint64, error) { return nil, nil, 0, fmt.Errorf(s, a...) } @@ -5568,7 +4563,7 @@ func (btc *baseWallet) signTxAndAddChange(baseTx *wire.MsgTx, addr btcutil.Addre baseTx.AddTxOut(changeOutput) changeSize := btc.calcTxSize(baseTx) - vSize0 // may be dexbtc.P2WPKHOutputSize addrStr, _ := btc.stringAddr(addr, btc.chainParams) // just for logging - btc.log.Debugf("Change output size = %d, addr = %s", changeSize, addrStr) + btc.log.Tracef("Change output size = %d, addr = %s", changeSize, addrStr) vSize += changeSize fee := feeRate * vSize @@ -5627,9 +4622,9 @@ func (btc *baseWallet) signTxAndAddChange(baseTx *wire.MsgTx, addr btcutil.Addre "min rate = %d, actual fee rate = %d (%v for %v bytes), change = %v", sigCycles, txHash, feeRate, actualFeeRate, fee, vSize, changeAdded) - var change *output + var change *Output if changeAdded { - change = newOutput(txHash, uint32(changeIdx), uint64(changeOutput.Value)) + change = NewOutput(txHash, uint32(changeIdx), uint64(changeOutput.Value)) } return msgTx, change, fee, nil @@ -5648,20 +4643,6 @@ func (btc *baseWallet) broadcastTx(signedTx *wire.MsgTx) (*chainhash.Hash, error return txHash, nil } -// txOutFromTxBytes parses the specified *wire.TxOut from the serialized -// transaction. -func (btc *baseWallet) txOutFromTxBytes(txB []byte, vout uint32) (*wire.TxOut, error) { - msgTx, err := btc.deserializeTx(txB) - if err != nil { - return nil, fmt.Errorf("error decoding transaction bytes: %v", err) - } - - if len(msgTx.TxOut) <= int(vout) { - return nil, fmt.Errorf("no vout %d in tx %s", vout, btc.hashTx(msgTx)) - } - return msgTx.TxOut[vout], nil -} - // createSig creates and returns the serialized raw signature and compressed // pubkey for a transaction input signature. func (btc *baseWallet) createSig(tx *wire.MsgTx, idx int, pkScript []byte, addr btcutil.Address, vals []int64, pkScripts [][]byte) (sig, pubkey []byte, err error) { @@ -5813,8 +4794,8 @@ func (btc *baseWallet) MakeBondTx(ver uint16, amt, feeRate uint64, lockTime time return nil, nil, fmt.Errorf("error constructing p2sh script: %v", err) } txOut := wire.NewTxOut(int64(amt), pkScript) - if dexbtc.IsDust(txOut, feeRate) { - return nil, nil, fmt.Errorf("bond output value of %d is dust", amt) + if btc.IsDust(txOut, feeRate) { + return nil, nil, fmt.Errorf("bond output value of %d (fee rate %d) is dust", amt, feeRate) } baseTx.AddTxOut(txOut) @@ -5847,7 +4828,8 @@ func (btc *baseWallet) MakeBondTx(ver uint16, amt, feeRate uint64, lockTime time baseSize += dexbtc.P2PKHOutputSize } - coins, _, _, _, _, _, err := btc.fund(0, 0, true, sendEnough(amt, feeRate, true, uint64(baseSize), btc.segwit, true)) + const subtract = false + coins, _, _, _, _, _, err := btc.cm.Fund(0, 0, true, sendEnough(amt, feeRate, subtract, uint64(baseSize), btc.segwit, true)) if err != nil { return nil, nil, fmt.Errorf("failed to fund bond tx: %w", err) } @@ -5910,7 +4892,7 @@ func (btc *baseWallet) MakeBondTx(ver uint16, amt, feeRate uint64, lockTime time Version: ver, AssetID: btc.cloneParams.AssetID, Amount: amt, - CoinID: toCoinID(txid, 0), + CoinID: ToCoinID(txid, 0), Data: bondScript, SignedTx: signedTxBytes, UnsignedTx: unsignedTxBytes, @@ -5969,8 +4951,8 @@ func (btc *baseWallet) makeBondRefundTxV0(txid *chainhash.Hash, vout uint32, amt return nil, fmt.Errorf("error creating pubkey script: %w", err) } redeemTxOut := wire.NewTxOut(int64(amt-fee), redeemPkScript) - if dexbtc.IsDust(redeemTxOut, feeRate) { // hard to imagine - return nil, fmt.Errorf("redeem output is dust") + if btc.IsDust(redeemTxOut, feeRate) { // hard to imagine + return nil, fmt.Errorf("bond redeem output (amt = %d, feeRate = %d, outputSize = %d) is dust", amt, feeRate, redeemTxOut.SerializeSize()) } msgTx.AddTxOut(redeemTxOut) @@ -6030,7 +5012,7 @@ func (btc *baseWallet) RefundBond(ctx context.Context, ver uint16, coinID, scrip } btc.addTxToHistory(asset.RedeemBond, txID[:], int64(amt), fees, true) - return newOutput(txHash, 0, uint64(msgTx.TxOut[0].Value)), nil + return NewOutput(txHash, 0, uint64(msgTx.TxOut[0].Value)), nil } // BondsFeeBuffer suggests how much extra may be required for the transaction @@ -6325,104 +5307,6 @@ func (btc *ExchangeWalletSPV) TxHistory(n int, refID *dex.Bytes, past bool) ([]* return txHistoryDB.getTxs(n, refID, past) } -type utxo struct { - txHash *chainhash.Hash - vout uint32 - address string - amount uint64 -} - -// Combines utxo info with the spending input information. -type compositeUTXO struct { - *utxo - confs uint32 - redeemScript []byte - input *dexbtc.SpendInfo -} - -// spendableUTXOs filters the RPC utxos for those that are spendable with -// regards to the DEX's configuration, and considered safe to spend according to -// confirmations and coin source. The UTXOs will be sorted by ascending value. -// spendableUTXOs should only be called with the fundingMtx RLock'ed. -func (btc *baseWallet) spendableUTXOs(confs uint32) ([]*compositeUTXO, map[outPoint]*compositeUTXO, uint64, error) { - unspents, err := btc.node.listUnspent() - if err != nil { - return nil, nil, 0, err - } - - utxos, utxoMap, sum, err := convertUnspent(confs, unspents, btc.chainParams) - if err != nil { - return nil, nil, 0, err - } - var relock []*output - var i int - for _, utxo := range utxos { - // Guard against inconsistencies between the wallet's view of - // spendable unlocked UTXOs and ExchangeWallet's. e.g. User manually - // unlocked something or even restarted the wallet software. - pt := newOutPoint(utxo.txHash, utxo.vout) - if btc.fundingCoins[pt] != nil { - btc.log.Warnf("Known order-funding coin %s returned by listunspent!", pt) - delete(utxoMap, pt) - relock = append(relock, &output{pt, utxo.amount}) - } else { // in-place filter maintaining order - utxos[i] = utxo - i++ - } - } - if len(relock) > 0 { - if err = btc.node.lockUnspent(false, relock); err != nil { - btc.log.Errorf("Failed to re-lock funding coins with wallet: %v", err) - } - } - utxos = utxos[:i] - return utxos, utxoMap, sum, nil -} - -func convertUnspent(confs uint32, unspents []*ListUnspentResult, chainParams *chaincfg.Params) ([]*compositeUTXO, map[outPoint]*compositeUTXO, uint64, error) { - sort.Slice(unspents, func(i, j int) bool { return unspents[i].Amount < unspents[j].Amount }) - var sum uint64 - utxos := make([]*compositeUTXO, 0, len(unspents)) - utxoMap := make(map[outPoint]*compositeUTXO, len(unspents)) - for _, txout := range unspents { - if txout.Confirmations >= confs && txout.Safe() && txout.Spendable { - txHash, err := chainhash.NewHashFromStr(txout.TxID) - if err != nil { - return nil, nil, 0, fmt.Errorf("error decoding txid in ListUnspentResult: %w", err) - } - - nfo, err := dexbtc.InputInfo(txout.ScriptPubKey, txout.RedeemScript, chainParams) - if err != nil { - if errors.Is(err, dex.UnsupportedScriptError) { - continue - } - return nil, nil, 0, fmt.Errorf("error reading asset info: %w", err) - } - if nfo.ScriptType == dexbtc.ScriptUnsupported || nfo.NonStandardScript { - // InputInfo sets NonStandardScript for P2SH with non-standard - // redeem scripts. Don't return these since they cannot fund - // arbitrary txns. - continue - } - utxo := &compositeUTXO{ - utxo: &utxo{ - txHash: txHash, - vout: txout.Vout, - address: txout.Address, - amount: toSatoshi(txout.Amount), - }, - confs: txout.Confirmations, - redeemScript: txout.RedeemScript, - input: nfo, - } - utxos = append(utxos, utxo) - utxoMap[newOutPoint(txHash, txout.Vout)] = utxo - sum += toSatoshi(txout.Amount) - } - } - return utxos, utxoMap, sum, nil -} - // lockedSats is the total value of locked outputs, as locked with LockUnspent. func (btc *baseWallet) lockedSats() (uint64, error) { lockedOutpoints, err := btc.node.listLockUnspent() @@ -6430,25 +5314,22 @@ func (btc *baseWallet) lockedSats() (uint64, error) { return 0, err } var sum uint64 - btc.fundingMtx.Lock() - defer btc.fundingMtx.Unlock() - for _, rpcOP := range lockedOutpoints { txHash, err := chainhash.NewHashFromStr(rpcOP.TxID) if err != nil { return 0, err } - pt := newOutPoint(txHash, rpcOP.Vout) - utxo, found := btc.fundingCoins[pt] - if found { - sum += utxo.amount + pt := NewOutPoint(txHash, rpcOP.Vout) + utxo := btc.cm.LockedOutput(pt) + if utxo != nil { + sum += utxo.Amount continue } tx, err := btc.node.getWalletTransaction(txHash) if err != nil { return 0, err } - txOut, err := btc.txOutFromTxBytes(tx.Hex, rpcOP.Vout) + txOut, err := TxOutFromTxBytes(tx.Bytes, rpcOP.Vout, btc.deserializeTx, btc.hashTx) if err != nil { return 0, err } @@ -6480,14 +5361,15 @@ func toSatoshi(v float64) uint64 { return uint64(math.Round(v * conventionalConversionFactor)) } -// blockHeader is a partial btcjson.GetBlockHeaderVerboseResult with mediantime +// BlockHeader is a partial btcjson.GetBlockHeaderVerboseResult with mediantime // included. -type blockHeader struct { +type BlockHeader struct { Hash string `json:"hash"` Confirmations int64 `json:"confirmations"` Height int64 `json:"height"` Time int64 `json:"time"` PreviousBlockHash string `json:"previousblockhash"` + MedianTime int64 `json:"mediantime"` } // hashContract hashes the contract for use in a p2sh or p2wsh pubkey script. @@ -6537,8 +5419,8 @@ func scriptHashAddress(segwit bool, contract []byte, chainParams *chaincfg.Param return btcutil.NewAddressScriptHash(contract, chainParams) } -// toCoinID converts the tx hash and vout to a coin ID, as a []byte. -func toCoinID(txHash *chainhash.Hash, vout uint32) []byte { +// ToCoinID converts the tx hash and vout to a coin ID, as a []byte. +func ToCoinID(txHash *chainhash.Hash, vout uint32) []byte { coinID := make([]byte, chainhash.HashSize+4) copy(coinID[:chainhash.HashSize], txHash[:]) binary.BigEndian.PutUint32(coinID[chainhash.HashSize:], vout) @@ -6568,47 +5450,6 @@ func rawTxInSig(tx *wire.MsgTx, idx int, pkScript []byte, hashType txscript.SigH return txscript.RawTxInSignature(tx, idx, pkScript, txscript.SigHashAll, key) } -// findRedemptionsInTx searches the MsgTx for the redemptions for the specified -// swaps. -func findRedemptionsInTx(ctx context.Context, segwit bool, reqs map[outPoint]*findRedemptionReq, msgTx *wire.MsgTx, - chainParams *chaincfg.Params) (discovered map[outPoint]*findRedemptionResult) { - - return findRedemptionsInTxWithHasher(ctx, segwit, reqs, msgTx, chainParams, hashTx) -} - -func findRedemptionsInTxWithHasher(ctx context.Context, segwit bool, reqs map[outPoint]*findRedemptionReq, msgTx *wire.MsgTx, - chainParams *chaincfg.Params, hashTx func(*wire.MsgTx) *chainhash.Hash) (discovered map[outPoint]*findRedemptionResult) { - - discovered = make(map[outPoint]*findRedemptionResult, len(reqs)) - - for vin, txIn := range msgTx.TxIn { - if ctx.Err() != nil { - return discovered - } - poHash, poVout := txIn.PreviousOutPoint.Hash, txIn.PreviousOutPoint.Index - for outPt, req := range reqs { - if discovered[outPt] != nil { - continue - } - if outPt.txHash == poHash && outPt.vout == poVout { - // Match! - txHash := hashTx(msgTx) - secret, err := dexbtc.FindKeyPush(txIn.Witness, txIn.SignatureScript, req.contractHash[:], segwit, chainParams) - if err != nil { - req.fail("no secret extracted from redemption input %s:%d for swap output %s: %v", - txHash, vin, outPt, err) - continue - } - discovered[outPt] = &findRedemptionResult{ - redemptionCoinID: toCoinID(txHash, uint32(vin)), - secret: secret, - } - } - } - } - return -} - // prettyBTC prints a value as a float with up to 8 digits of precision, but // with trailing zeros and decimal points removed. func prettyBTC(v uint64) string { @@ -6757,16 +5598,67 @@ func (btc *baseWallet) ConfirmRedemption(coinID dex.Bytes, redemption *asset.Red }, nil } -// writeRecycledAddrsToFile writes the recycled address cache to file. -func (btc *baseWallet) writeRecycledAddrsToFile() { - btc.recycledAddrMtx.Lock() - addrs := make([]string, 0, len(btc.recycledAddrs)) - for addr := range btc.recycledAddrs { +type AddressRecycler struct { + recyclePath string + log dex.Logger + + mtx sync.Mutex + addrs map[string]struct{} +} + +func NewAddressRecycler(recyclePath string, log dex.Logger) (*AddressRecycler, error) { + // Try to load any cached unused redemption addresses. + b, err := os.ReadFile(recyclePath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("error looking for recycled address file: %w", err) + } + addrs := strings.Split(string(b), "\n") + recycledAddrs := make(map[string]struct{}, len(addrs)) + for _, addr := range addrs { + if addr == "" { + continue + } + recycledAddrs[addr] = struct{}{} + } + return &AddressRecycler{ + recyclePath: recyclePath, + log: log, + addrs: recycledAddrs, + }, nil +} + +// WriteRecycledAddrsToFile writes the recycled address cache to file. +func (a *AddressRecycler) WriteRecycledAddrsToFile() { + a.mtx.Lock() + addrs := make([]string, 0, len(a.addrs)) + for addr := range a.addrs { addrs = append(addrs, addr) } - btc.recycledAddrMtx.Unlock() + a.mtx.Unlock() contents := []byte(strings.Join(addrs, "\n")) - if err := os.WriteFile(btc.recyclePath, contents, 0600); err != nil { - btc.log.Errorf("Error writing recycled address file: %v", err) + if err := os.WriteFile(a.recyclePath, contents, 0600); err != nil { + a.log.Errorf("Error writing recycled address file: %v", err) + } +} + +func (a *AddressRecycler) Address() string { + a.mtx.Lock() + defer a.mtx.Unlock() + for addr := range a.addrs { + delete(a.addrs, addr) + return addr + } + return "" +} + +func (a *AddressRecycler) ReturnAddresses(addrs []string) { + a.mtx.Lock() + defer a.mtx.Unlock() + for _, addr := range addrs { + if _, exists := a.addrs[addr]; exists { + a.log.Errorf("Returned address %q was already indexed", addr) + continue + } + a.addrs[addr] = struct{}{} } } diff --git a/client/asset/btc/btc_test.go b/client/asset/btc/btc_test.go index 00ada51e7c..2fd90ae1a1 100644 --- a/client/asset/btc/btc_test.go +++ b/client/asset/btc/btc_test.go @@ -145,7 +145,7 @@ type testData struct { verboseBlocks map[string]*msgBlockWithHeight dbBlockForTx map[chainhash.Hash]*hashEntry mainchain map[int64]*chainhash.Hash - getBlockchainInfo *getBlockchainInfoResult + getBlockchainInfo *GetBlockchainInfoResult getBestBlockHashErr error mempoolTxs map[chainhash.Hash]*wire.MsgTx @@ -183,7 +183,7 @@ type testData struct { // spv fetchInputInfoTx *wire.MsgTx getCFilterScripts map[chainhash.Hash][][]byte - checkpoints map[outPoint]*scanCheckpoint + checkpoints map[OutPoint]*scanCheckpoint confs uint32 confsSpent bool confsErr error @@ -209,7 +209,7 @@ func newTestData() *testData { fetchInputInfoTx: dummyTx(), getCFilterScripts: make(map[chainhash.Hash][][]byte), confsErr: WalletTransactionNotFound, - checkpoints: make(map[outPoint]*scanCheckpoint), + checkpoints: make(map[OutPoint]*scanCheckpoint), tipChanged: make(chan asset.WalletNotification, 1), getTransactionMap: make(map[string]*GetTransactionResult), } @@ -402,7 +402,7 @@ func (c *tRawRequester) RawRequest(_ context.Context, method string, params []js // block may get modified concurrently, lock mtx before reading fields. c.blockchainMtx.RLock() defer c.blockchainMtx.RUnlock() - return json.Marshal(&blockHeader{ + return json.Marshal(&BlockHeader{ Hash: block.msgBlock.BlockHash().String(), Height: block.height, // Confirmations: block.Confirmations, @@ -670,19 +670,20 @@ func tNewWallet(segwit bool, walletType string) (*intermediaryWallet, *testData, cfg: &WalletConfig{}, wallet: &tBtcWallet{data}, cl: neutrinoClient, - tipChan: make(chan *block, 1), + tipChan: make(chan *BlockVector, 1), acctNum: 0, txBlocks: data.dbBlockForTx, checkpoints: data.checkpoints, log: cfg.Logger.SubLogger("SPV"), decodeAddr: btcutil.DecodeAddress, } - w.node = spvw + w.setNode(spvw) wallet = &intermediaryWallet{ baseWallet: w, txFeeEstimator: spvw, tipRedeemer: spvw, } + wallet.prepareRedemptionFinder() } } @@ -701,9 +702,9 @@ func tNewWallet(segwit bool, walletType string) (*intermediaryWallet, *testData, panic(err.Error()) } wallet.tipMtx.Lock() - wallet.currentTip = &block{ - height: data.GetBestBlockHeight(), - hash: *bestHash, + wallet.currentTip = &BlockVector{ + Height: data.GetBestBlockHeight(), + Hash: *bestHash, } wallet.tipMtx.Unlock() var wg sync.WaitGroup @@ -729,7 +730,7 @@ func mustMarshal(thing any) []byte { } func TestMain(m *testing.M) { - tLogger = dex.StdOutLogger("TEST", dex.LevelTrace) + tLogger = dex.StdOutLogger("TEST", dex.LevelCritical) var shutdown func() tCtx, shutdown = context.WithCancel(context.Background()) tTxHash, _ = chainhash.NewHashFromStr(tTxID) @@ -926,8 +927,8 @@ func testFundMultiOrder(t *testing.T, segwit bool, walletType string) { }, balance: 35e5, expectedCoins: []asset.Coins{ - {newOutput(txHashes[0], 0, 19e5)}, - {newOutput(txHashes[1], 0, 35e5)}, + {NewOutput(txHashes[0], 0, 19e5)}, + {NewOutput(txHashes[1], 0, 35e5)}, }, expectedRedeemScripts: [][]dex.Bytes{ {nil}, @@ -988,8 +989,8 @@ func testFundMultiOrder(t *testing.T, segwit bool, walletType string) { }, balance: 33e5, expectedCoins: []asset.Coins{ - {newOutput(txHashes[0], 0, 6e5), newOutput(txHashes[1], 0, 5e5)}, - {newOutput(txHashes[2], 0, 22e5)}, + {NewOutput(txHashes[0], 0, 6e5), NewOutput(txHashes[1], 0, 5e5)}, + {NewOutput(txHashes[2], 0, 22e5)}, }, expectedRedeemScripts: [][]dex.Bytes{ {nil, nil}, @@ -1055,7 +1056,7 @@ func testFundMultiOrder(t *testing.T, segwit bool, walletType string) { }, balance: 46e5, expectedCoins: []asset.Coins{ - {newOutput(txHashes[0], 0, 11e5)}, + {NewOutput(txHashes[0], 0, 11e5)}, }, expectedRedeemScripts: [][]dex.Bytes{ {nil}, @@ -1119,7 +1120,7 @@ func testFundMultiOrder(t *testing.T, segwit bool, walletType string) { }, balance: 46e5, expectedCoins: []asset.Coins{ - {newOutput(txHashes[0], 0, 11e5)}, + {NewOutput(txHashes[0], 0, 11e5)}, }, expectedRedeemScripts: [][]dex.Bytes{ {nil}, @@ -1186,9 +1187,9 @@ func testFundMultiOrder(t *testing.T, segwit bool, walletType string) { }, balance: 50e5, expectedCoins: []asset.Coins{ - {newOutput(txHashes[2], 0, 26e5)}, - {newOutput(txHashes[1], 0, 13e5)}, - {newOutput(txHashes[0], 0, 11e5)}, + {NewOutput(txHashes[2], 0, 26e5)}, + {NewOutput(txHashes[1], 0, 13e5)}, + {NewOutput(txHashes[0], 0, 11e5)}, }, expectedRedeemScripts: [][]dex.Bytes{ {nil}, @@ -1256,8 +1257,8 @@ func testFundMultiOrder(t *testing.T, segwit bool, walletType string) { }, balance: 43e5, expectedCoins: []asset.Coins{ - {newOutput(txHashes[0], 0, 11e5)}, - {newOutput(txHashes[1], 0, 22e5)}, + {NewOutput(txHashes[0], 0, 11e5)}, + {NewOutput(txHashes[1], 0, 22e5)}, }, expectedRedeemScripts: [][]dex.Bytes{ {nil}, @@ -1725,8 +1726,8 @@ func testFundMultiOrder(t *testing.T, segwit bool, walletType string) { {nil}, }, expectedCoins: []asset.Coins{ - {newOutput(txHashes[0], 0, 12e5)}, - {newOutput(txHashes[1], 0, 12e5)}, + {NewOutput(txHashes[0], 0, 12e5)}, + {NewOutput(txHashes[1], 0, 12e5)}, nil, }, }, @@ -1808,8 +1809,8 @@ func testFundMultiOrder(t *testing.T, segwit bool, walletType string) { {nil}, }, expectedCoins: []asset.Coins{ - {newOutput(txHashes[0], 0, 12e5)}, - {newOutput(txHashes[1], 0, 12e5)}, + {NewOutput(txHashes[0], 0, 12e5)}, + {NewOutput(txHashes[1], 0, 12e5)}, nil, }, }, @@ -1824,7 +1825,7 @@ func testFundMultiOrder(t *testing.T, segwit bool, walletType string) { Trusted: toBTC(test.balance), }, } - wallet.fundingCoins = make(map[outPoint]*utxo) + wallet.cm.lockedOutputs = make(map[OutPoint]*UTxO) wallet.bondReserves.Store(test.bondReserves) allCoins, _, splitFee, err := wallet.FundMultiOrder(test.multiOrder, test.maxLock) @@ -1914,13 +1915,13 @@ func testFundMultiOrder(t *testing.T, segwit bool, walletType string) { // This means all coins are split outputs if test.expectedCoins == nil { for i, actualCoin := range allCoins { - actualOut := actualCoin[0].(*output) + 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 uint64(expectedOut.Value) != actualOut.Val { + t.Fatalf("%s: unexpected output %d value. expected %d, got %d", test.name, i, expectedOut.Value, actualOut.Val) } - 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) + 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 { @@ -1931,13 +1932,13 @@ func testFundMultiOrder(t *testing.T, segwit bool, walletType string) { // This means the coins are the split outputs if expected == nil { - actualOut := actual[0].(*output) + 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 uint64(expectedOut.Value) != actualOut.Val { + t.Fatalf("%s: unexpected output %d value. expected %d, got %d", test.name, i, expectedOut.Value, actualOut.Val) } - 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) + 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 @@ -1975,8 +1976,8 @@ func testFundMultiOrder(t *testing.T, segwit bool, walletType string) { 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)) + if totalNumCoins != len(wallet.cm.lockedOutputs) { + t.Fatalf("%s: expected %d funding coins in wallet, got %d", test.name, totalNumCoins, len(wallet.cm.lockedOutputs)) } totalNumCoins += len(test.expectedInputs) if totalNumCoins != len(node.lockedCoins) { @@ -1992,16 +1993,16 @@ func testFundMultiOrder(t *testing.T, segwit bool, walletType string) { } } checkFundingCoin := func(txHash chainhash.Hash, vout uint32) { - if _, ok := wallet.fundingCoins[outPoint{txHash: txHash, vout: vout}]; !ok { + if _, ok := wallet.cm.lockedOutputs[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) + out := coin.(*Output) + checkLockedCoin(out.Pt.TxHash, out.Pt.Vout) + checkFundingCoin(out.Pt.TxHash, out.Pt.Vout) } } for _, expectedIn := range test.expectedInputs { @@ -2122,7 +2123,7 @@ func testAvailableFund(t *testing.T, segwit bool, walletType string) { "any": { BlockHash: blockHash.String(), BlockIndex: blockHeight, - Hex: txBuf.Bytes(), + Bytes: txBuf.Bytes(), }} bal, err = wallet.Balance() @@ -2236,6 +2237,7 @@ func testAvailableFund(t *testing.T, segwit bool, walletType string) { // Return/unlock the reserved coins to avoid warning in subsequent tests // about fundingCoins map containing the coins already. i.e. // "Known order-funding coin %v returned by listunspent" + _ = wallet.ReturnCoins(spendables) // Now with safe confirmed littleUTXO. @@ -2495,7 +2497,7 @@ func TestReturnCoins(t *testing.T) { // Test it with the local output type. coins := asset.Coins{ - newOutput(tTxHash, 0, 1), + NewOutput(tTxHash, 0, 1), } err := wallet.ReturnCoins(coins) if err != nil { @@ -2509,12 +2511,12 @@ func TestReturnCoins(t *testing.T) { } // nil unlocks all - wallet.fundingCoins[outPoint{*tTxHash, 0}] = &utxo{} + wallet.cm.lockedOutputs[OutPoint{*tTxHash, 0}] = &UTxO{} err = wallet.ReturnCoins(nil) if err != nil { t.Fatalf("error for nil coins: %v", err) } - if len(wallet.fundingCoins) != 0 { + if len(wallet.cm.lockedOutputs) != 0 { t.Errorf("all funding coins not unlocked") } @@ -2547,7 +2549,7 @@ func testFundingCoins(t *testing.T, segwit bool, walletType string) { tx0 := makeRawTx([]dex.Bytes{{0x01}, tP2PKH}, []*wire.TxIn{dummyInput()}) txHash0 := tx0.TxHash() _, _ = node.addRawTx(txBlockHeight, tx0) - coinID0 := toCoinID(&txHash0, vout0) + coinID0 := ToCoinID(&txHash0, vout0) // Make spendable (confs > 0) node.addRawTx(txBlockHeight+1, dummyTx()) @@ -2568,7 +2570,7 @@ func testFundingCoins(t *testing.T, segwit bool, walletType string) { tx1 := makeRawTx([]dex.Bytes{tP2PKH, {0x02}}, []*wire.TxIn{dummyInput()}) txHash1 := tx1.TxHash() _, _ = node.addRawTx(txBlockHeight, tx1) - coinID1 := toCoinID(&txHash1, vout1) + coinID1 := ToCoinID(&txHash1, vout1) // Make spendable (confs > 0) node.addRawTx(txBlockHeight+1, dummyTx()) @@ -2602,7 +2604,7 @@ func testFundingCoins(t *testing.T, segwit bool, walletType string) { ensureErr := func(tag string) { t.Helper() // Clear the cache. - wallet.fundingCoins = make(map[outPoint]*utxo) + wallet.cm.lockedOutputs = make(map[OutPoint]*UTxO) _, err := wallet.FundingCoins(coinIDs) if err == nil { t.Fatalf("%s: no error", tag) @@ -2636,11 +2638,11 @@ func testFundingCoins(t *testing.T, segwit bool, walletType string) { txRaw0, _ := serializeMsgTx(tx0) getTxRes0 := &GetTransactionResult{ - Hex: txRaw0, + Bytes: txRaw0, } txRaw1, _ := serializeMsgTx(tx1) getTxRes1 := &GetTransactionResult{ - Hex: txRaw1, + Bytes: txRaw1, } node.getTransactionMap = map[string]*GetTransactionResult{ @@ -3035,8 +3037,8 @@ func testSwap(t *testing.T, segwit bool, walletType string) { swapVal := toSatoshi(5) coins := asset.Coins{ - newOutput(tTxHash, 0, toSatoshi(3)), - newOutput(tTxHash, 0, toSatoshi(3)), + NewOutput(tTxHash, 0, toSatoshi(3)), + NewOutput(tTxHash, 0, toSatoshi(3)), } addrStr := tP2PKHAddr if segwit { @@ -3167,7 +3169,7 @@ func testRedeem(t *testing.T, segwit bool, walletType string) { secret, _, _, contract, addr, _, lockTime := makeSwapContract(segwit, time.Hour*12) - coin := newOutput(tTxHash, 0, swapVal) + coin := NewOutput(tTxHash, 0, swapVal) ci := &asset.AuditInfo{ Coin: coin, Contract: contract, @@ -3235,12 +3237,12 @@ func testRedeem(t *testing.T, segwit bool, walletType string) { redemption.Secret = secret // too low of value - coin.value = 200 + coin.Val = 200 _, _, _, err = wallet.Redeem(redemptions) if err == nil { t.Fatalf("no error for redemption not worth the fees") } - coin.value = swapVal + coin.Val = swapVal // Change address error node.changeAddrErr = tErr @@ -3299,9 +3301,9 @@ func testSignMessage(t *testing.T, segwit bool, walletType string) { signature := ecdsa.Sign(privKey, msgHash) sig := signature.Serialize() - pt := newOutPoint(tTxHash, vout) - utxo := &utxo{address: tP2PKHAddr} - wallet.fundingCoins[pt] = utxo + pt := NewOutPoint(tTxHash, vout) + utxo := &UTxO{Address: tP2PKHAddr} + wallet.cm.lockedOutputs[pt] = utxo node.privKeyForAddr = wif node.signMsgFunc = func(params []json.RawMessage) (json.RawMessage, error) { if len(params) != 2 { @@ -3327,7 +3329,7 @@ func testSignMessage(t *testing.T, segwit bool, walletType string) { return r, nil } - var coin asset.Coin = newOutput(tTxHash, vout, 5e7) + var coin asset.Coin = NewOutput(tTxHash, vout, 5e7) pubkeys, sigs, err := wallet.SignMessage(coin, msg) if err != nil { t.Fatalf("SignMessage error: %v", err) @@ -3346,12 +3348,12 @@ func testSignMessage(t *testing.T, segwit bool, walletType string) { } // Unknown UTXO - delete(wallet.fundingCoins, pt) + delete(wallet.cm.lockedOutputs, pt) _, _, err = wallet.SignMessage(coin, msg) if err == nil { t.Fatalf("no error for unknown utxo") } - wallet.fundingCoins[pt] = utxo + wallet.cm.lockedOutputs[pt] = utxo // dumpprivkey error node.privKeyForAddrErr = tErr @@ -3405,7 +3407,7 @@ func testAuditContract(t *testing.T, segwit bool, walletType string) { txHash := tx.TxHash() const vout = 0 - audit, err := wallet.AuditContract(toCoinID(&txHash, vout), contract, txData, false) + audit, err := wallet.AuditContract(ToCoinID(&txHash, vout), contract, txData, false) if err != nil { t.Fatalf("audit error: %v", err) } @@ -3429,7 +3431,7 @@ func testAuditContract(t *testing.T, segwit bool, walletType string) { pkh, _ := hex.DecodeString("c6a704f11af6cbee8738ff19fc28cdc70aba0b82") wrongAddr, _ := btcutil.NewAddressPubKeyHash(pkh, &chaincfg.MainNetParams) badContract, _ := txscript.PayToAddrScript(wrongAddr) - _, err = wallet.AuditContract(toCoinID(&txHash, vout), badContract, nil, false) + _, err = wallet.AuditContract(ToCoinID(&txHash, vout), badContract, nil, false) if err == nil { t.Fatalf("no error for wrong contract") } @@ -3479,7 +3481,7 @@ func testFindRedemption(t *testing.T, segwit bool, walletType string) { // Add the contract transaction. Put the pay-to-contract script at index 1. contractTx := makeRawTx([]dex.Bytes{otherScript, pkScript}, inputs) contractTxHash := contractTx.TxHash() - coinID := toCoinID(&contractTxHash, contractVout) + coinID := ToCoinID(&contractTxHash, contractVout) blockHash, _ := node.addRawTx(contractHeight, contractTx) txHex, err := makeTxHex([]dex.Bytes{otherScript, pkScript}, inputs) if err != nil { @@ -3488,7 +3490,7 @@ func testFindRedemption(t *testing.T, segwit bool, walletType string) { getTxRes := &GetTransactionResult{ BlockHash: blockHash.String(), BlockIndex: contractHeight, - Hex: txHex, + Bytes: txHex, } node.getTransactionMap = map[string]*GetTransactionResult{ "any": getTxRes} @@ -3503,9 +3505,9 @@ func testFindRedemption(t *testing.T, segwit bool, walletType string) { node.getCFilterScripts[*redeemBlockHash] = [][]byte{pkScript} // Update currentTip from "RPC". Normally run() would do this. - wallet.reportNewTip(tCtx, &block{ - hash: *redeemBlockHash, - height: contractHeight + 2, + wallet.reportNewTip(tCtx, &BlockVector{ + Hash: *redeemBlockHash, + Height: contractHeight + 2, }) // Check find redemption result. @@ -3598,12 +3600,12 @@ func testRefund(t *testing.T, segwit bool, walletType string) { const vout = 0 tx.TxOut[vout].Value = 1e8 txHash := tx.TxHash() - outPt := newOutPoint(&txHash, vout) + outPt := NewOutPoint(&txHash, vout) blockHash, _ := node.addRawTx(1, tx) node.getCFilterScripts[*blockHash] = [][]byte{pkScript} node.getTransactionErr = WalletTransactionNotFound - contractOutput := newOutput(&txHash, 0, 1e8) + contractOutput := NewOutput(&txHash, 0, 1e8) _, err = wallet.Refund(contractOutput.ID(), contract, feeSuggestion) if err != nil { t.Fatalf("refund error: %v", err) @@ -3634,7 +3636,7 @@ func testRefund(t *testing.T, segwit bool, walletType string) { node.txOutErr = nil // bad contract - badContractOutput := newOutput(tTxHash, 0, 1e8) + badContractOutput := NewOutput(tTxHash, 0, 1e8) badContract := randBytes(50) _, err = wallet.Refund(badContractOutput.ID(), badContract, feeSuggestion) if err == nil { @@ -3788,7 +3790,7 @@ func testSender(t *testing.T, senderType tSenderType, segwit bool, walletType st unspents []*ListUnspentResult bondReserves uint64 - expectedInputs []*outPoint + expectedInputs []*OutPoint expectSentVal uint64 expectChange uint64 expectErr bool @@ -3807,9 +3809,8 @@ func testSender(t *testing.T, senderType tSenderType, segwit bool, walletType st SafePtr: boolPtr(true), Spendable: true, }}, - expectedInputs: []*outPoint{ - {txHash: txHash, - vout: 0}, + expectedInputs: []*OutPoint{ + {TxHash: txHash, Vout: 0}, }, expectSentVal: expectedSentVal(toSatoshi(5), expectedFees(1)), expectChange: expectedChangeVal(toSatoshi(100), toSatoshi(5), expectedFees(1)), @@ -3827,9 +3828,9 @@ func testSender(t *testing.T, senderType tSenderType, segwit bool, walletType st SafePtr: boolPtr(true), Spendable: true, }}, - expectedInputs: []*outPoint{ - {txHash: txHash, - vout: 0}, + expectedInputs: []*OutPoint{ + {TxHash: txHash, + Vout: 0}, }, expectSentVal: expectedSentVal(toSatoshi(5), expectedFees(1)), expectChange: expectedChangeVal(toSatoshi(5.2), toSatoshi(5), expectedFees(1)), @@ -3848,9 +3849,8 @@ func testSender(t *testing.T, senderType tSenderType, segwit bool, walletType st SafePtr: boolPtr(true), Spendable: true, }}, - expectedInputs: []*outPoint{ - {txHash: txHash, - vout: 0}, + expectedInputs: []*OutPoint{ + {TxHash: txHash, Vout: 0}, }, bondReserves: expectedChangeVal(toSatoshi(5.2), toSatoshi(5), expectedFees(1)) + 1, expectErr: true, @@ -3883,9 +3883,8 @@ func testSender(t *testing.T, senderType tSenderType, segwit bool, walletType st SafePtr: boolPtr(true), Spendable: true, }}, - expectedInputs: []*outPoint{ - {txHash: txHash, - vout: 0}, + expectedInputs: []*OutPoint{ + {TxHash: txHash, Vout: 0}, }, expectSentVal: expectedSentVal(toSatoshi(5), expectedFees(1)), expectChange: 0, @@ -3913,8 +3912,8 @@ func testSender(t *testing.T, senderType tSenderType, segwit bool, walletType st } for i, input := range tx.TxIn { - if input.PreviousOutPoint.Hash != test.expectedInputs[i].txHash || - input.PreviousOutPoint.Index != test.expectedInputs[i].vout { + if input.PreviousOutPoint.Hash != test.expectedInputs[i].TxHash || + input.PreviousOutPoint.Index != test.expectedInputs[i].Vout { t.Fatalf("expected input %d to be %v, got %v", i, test.expectedInputs[i], input.PreviousOutPoint) } } @@ -4108,7 +4107,7 @@ func testConfirmations(t *testing.T, segwit bool, walletType string) { tx := makeRawTx([]dex.Bytes{pkScript}, []*wire.TxIn{dummyInput()}) blockHash, swapBlock := node.addRawTx(swapHeight, tx) txHash := tx.TxHash() - coinID := toCoinID(&txHash, 0) + coinID := ToCoinID(&txHash, 0) // Simulate a spending transaction, and advance the tip so that the swap // has two confirmations. spendingTx := dummyTx() @@ -4158,7 +4157,7 @@ func testConfirmations(t *testing.T, segwit bool, walletType string) { node.getTransactionMap = map[string]*GetTransactionResult{ "any": { BlockHash: blockHash.String(), - Hex: txB, + Bytes: txB, }} node.getCFilterScripts[*spendingBlockHash] = [][]byte{pkScript} @@ -4265,7 +4264,7 @@ func testSyncStatus(t *testing.T, segwit bool, walletType string) { defer shutdown() // full node - node.getBlockchainInfo = &getBlockchainInfoResult{ + node.getBlockchainInfo = &GetBlockchainInfoResult{ Headers: 100, Blocks: 99, // full node allowed to be synced when 1 block behind } @@ -4301,7 +4300,7 @@ func testSyncStatus(t *testing.T, segwit bool, walletType string) { node.blockchainMtx.Unlock() wallet.tipAtConnect = 100 - node.getBlockchainInfo = &getBlockchainInfoResult{ + node.getBlockchainInfo = &GetBlockchainInfoResult{ Headers: 200, Blocks: 150, } @@ -4476,7 +4475,7 @@ func testTryRedemptionRequests(t *testing.T, segwit bool, walletType string) { notRedeemed bool } - redeemReq := func(r *tRedeem) *findRedemptionReq { + redeemReq := func(r *tRedeem) *FindRedemptionReq { var swapBlockHash *chainhash.Hash var swapHeight int64 if r.swapHeight >= 0 { @@ -4509,15 +4508,15 @@ func testTryRedemptionRequests(t *testing.T, segwit bool, walletType string) { } } - req := &findRedemptionReq{ - outPt: newOutPoint(swapTxHash, swapVout), + req := &FindRedemptionReq{ + outPt: NewOutPoint(swapTxHash, swapVout), blockHash: swapBlockHash, blockHeight: int32(swapHeight), - resultChan: make(chan *findRedemptionResult, 1), + resultChan: make(chan *FindRedemptionResult, 1), pkScript: pkScript, contractHash: hashContract(segwit, contract), } - wallet.findRedemptionQueue[req.outPt] = req + wallet.rf.redemptions[req.outPt] = req return req } @@ -4638,7 +4637,7 @@ func testTryRedemptionRequests(t *testing.T, segwit bool, walletType string) { } node.truncateChains() - wallet.findRedemptionQueue = make(map[outPoint]*findRedemptionReq) + wallet.rf.redemptions = make(map[OutPoint]*FindRedemptionReq) node.blockchainMtx.Lock() node.getBestBlockHashErr = nil if tt.forcedErr { @@ -4658,12 +4657,12 @@ func testTryRedemptionRequests(t *testing.T, segwit bool, walletType string) { cancel() } - reqs := make([]*findRedemptionReq, 0, len(tt.redeems)) + reqs := make([]*FindRedemptionReq, 0, len(tt.redeems)) for _, redeem := range tt.redeems { reqs = append(reqs, redeemReq(redeem)) } - wallet.tryRedemptionRequests(ctx, startBlock, reqs) + wallet.rf.tryRedemptionRequests(ctx, startBlock, reqs) for i, req := range reqs { select { @@ -4791,7 +4790,7 @@ func testAccelerateOrder(t *testing.T, segwit bool, walletType string) { } node.getTransactionMap[txs[i].TxHash().String()] = &GetTransactionResult{ TxID: txs[i].TxHash().String(), - Hex: serializedTxs[i], + Bytes: serializedTxs[i], BlockHash: blockHash, Confirmations: confs[i]} @@ -4845,7 +4844,7 @@ func testAccelerateOrder(t *testing.T, segwit bool, walletType string) { }}, } fudingTxHex, _ := serializeMsgTx(&fundingTx) - node.getTransactionMap[fundingTx.TxHash().String()] = &GetTransactionResult{Hex: fudingTxHex, BlockHash: blockHash100.String()} + node.getTransactionMap[fundingTx.TxHash().String()] = &GetTransactionResult{Bytes: fudingTxHex, BlockHash: blockHash100.String()} txs[0] = &wire.MsgTx{ TxIn: []*wire.TxIn{{ @@ -4888,14 +4887,14 @@ func testAccelerateOrder(t *testing.T, segwit bool, walletType string) { hash := tx.TxHash() if i == 2 && addAcceleration { - accelerationCoins = append(accelerationCoins, toCoinID(&hash, 0)) + accelerationCoins = append(accelerationCoins, ToCoinID(&hash, 0)) } else { - toCoinID(&hash, 0) - swapCoins = append(swapCoins, toCoinID(&hash, 0)) + ToCoinID(&hash, 0) + swapCoins = append(swapCoins, ToCoinID(&hash, 0)) } if i == len(txs)-1 { - changeCoin = toCoinID(&hash, 0) + changeCoin = ToCoinID(&hash, 0) if addChangeToUnspent { node.listUnspent = append(node.listUnspent, &ListUnspentResult{ TxID: hash.String(), @@ -4952,7 +4951,7 @@ func testAccelerateOrder(t *testing.T, segwit bool, walletType string) { node.getTransactionMap[node.listUnspent[len(node.listUnspent)-1].TxID] = &GetTransactionResult{ TxID: tx.TxHash().String(), - Hex: unspentTxHex, + Bytes: unspentTxHex, BlockHash: blockHash, Confirmations: uint64(confs)} } @@ -4964,7 +4963,7 @@ func testAccelerateOrder(t *testing.T, segwit bool, walletType string) { if !found { t.Fatalf("tx id not found: %v", input.PreviousOutPoint.Hash.String()) } - inputTx, err := msgTxFromHex(inputGtr.Hex.String()) + inputTx, err := msgTxFromHex(inputGtr.Bytes.String()) if err != nil { t.Fatalf("failed to deserialize tx: %v", err) } @@ -5467,7 +5466,7 @@ func testGetTxFee(t *testing.T, segwit bool, walletType string) { node.getTransactionMap = map[string]*GetTransactionResult{ "any": { - Hex: txBytes, + Bytes: txBytes, }, } @@ -5752,7 +5751,7 @@ func TestReconfigure(t *testing.T) { shutdown() reconfigurer := &tReconfigurer{rpcClient: wallet.node.(*rpcClient)} - wallet.baseWallet.node = reconfigurer + wallet.baseWallet.setNode(reconfigurer) cfg := &asset.WalletConfig{ Settings: map[string]string{ @@ -5810,7 +5809,7 @@ func TestConfirmRedemption(t *testing.T) { secret, _, _, contract, addr, _, lockTime := makeSwapContract(segwit, time.Hour*12) - coin := newOutput(tTxHash, 0, swapVal) + coin := NewOutput(tTxHash, 0, swapVal) ci := &asset.AuditInfo{ Coin: coin, Contract: contract, @@ -5938,8 +5937,8 @@ func TestAddressRecycling(t *testing.T) { } checkAddrs := func(tag string, expAddrs ...string) { - memList := make([]string, 0, len(w.recycledAddrs)) - for addr := range w.recycledAddrs { + memList := make([]string, 0, len(w.ar.addrs)) + for addr := range w.ar.addrs { memList = append(memList, addr) } compareAddrLists(tag, expAddrs, memList) @@ -5988,8 +5987,8 @@ func TestAddressRecycling(t *testing.T) { // Check address loading. w.ReturnRefundContracts(contracts) - w.writeRecycledAddrsToFile() - b, _ := os.ReadFile(w.recyclePath) + w.ar.WriteRecycledAddrsToFile() + b, _ := os.ReadFile(w.ar.recyclePath) var fileAddrs []string for _, addr := range strings.Split(string(b), "\n") { if addr == "" { @@ -6000,7 +5999,7 @@ func TestAddressRecycling(t *testing.T) { compareAddrLists("filecheck", []string{addr1.String(), addr2.String()}, fileAddrs) otherW, _ := newUnconnectedWallet(w.cloneParams, &WalletConfig{}) - if len(otherW.recycledAddrs) != 2 { + if len(otherW.ar.addrs) != 2 { t.Fatalf("newly opened wallet didn't load recycled addrs") } diff --git a/client/asset/btc/coin_selection.go b/client/asset/btc/coin_selection.go index c6e931c746..c19c6af10a 100644 --- a/client/asset/btc/coin_selection.go +++ b/client/asset/btc/coin_selection.go @@ -21,8 +21,8 @@ import ( // enough func will return a non-zero excess value. Otherwise, the enough func // will always return 0, leaving only unselected UTXOs to cover any required // reserves. -func sendEnough(amt, feeRate uint64, subtract bool, baseTxSize uint64, segwit, reportChange bool) func(inputSize, sum uint64) (bool, uint64) { - return func(inputSize, sum uint64) (bool, uint64) { +func sendEnough(amt, feeRate uint64, subtract bool, baseTxSize uint64, segwit, reportChange bool) EnoughFunc { + return func(_, inputSize, sum uint64) (bool, uint64) { txFee := (baseTxSize + inputSize) * feeRate req := amt if !subtract { // add the fee to required @@ -45,8 +45,8 @@ func sendEnough(amt, feeRate uint64, subtract bool, baseTxSize uint64, segwit, r // enough func will return a non-zero excess value reflecting this potential // spit tx change. Otherwise, the enough func will always return 0, leaving // only unselected UTXOs to cover any required reserves. -func orderEnough(val, lots, feeRate, initTxSizeBase, initTxSize uint64, segwit, reportChange bool) func(inputsSize, sum uint64) (bool, uint64) { - return func(inputsSize, sum uint64) (bool, uint64) { +func orderEnough(val, lots, feeRate, initTxSizeBase, initTxSize uint64, segwit, reportChange bool) EnoughFunc { + return func(_, inputsSize, sum uint64) (bool, uint64) { reqFunds := calc.RequiredOrderFundsAlt(val, inputsSize, lots, initTxSizeBase, initTxSize, feeRate) if sum >= reqFunds { excess := sum - reqFunds @@ -62,22 +62,22 @@ func orderEnough(val, lots, feeRate, initTxSizeBase, initTxSize uint64, segwit, // 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) (bool, uint64) { - return func(_, sum uint64) (bool, uint64) { +func reserveEnough(amt uint64) EnoughFunc { + return func(_, _, sum uint64) (bool, uint64) { return sum >= amt, 0 } } -func sumUTXOSize(set []*compositeUTXO) (tot uint64) { +func sumUTXOSize(set []*CompositeUTXO) (tot uint64) { for _, utxo := range set { - tot += uint64(utxo.input.VBytes()) + tot += uint64(utxo.Input.VBytes()) } return tot } -func sumUTXOs(set []*compositeUTXO) (tot uint64) { +func SumUTXOs(set []*CompositeUTXO) (tot uint64) { for _, utxo := range set { - tot += utxo.amount + tot += utxo.Amount } return tot } @@ -88,14 +88,14 @@ 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 enough. -func subsetWithLeastOverFund(enough func(uint64, uint64) (bool, uint64), maxFund uint64, utxos []*compositeUTXO) []*compositeUTXO { +func subsetWithLeastOverFund(enough EnoughFunc, maxFund uint64, utxos []*CompositeUTXO) []*CompositeUTXO { best := uint64(1 << 62) var bestIncluded []bool bestNumIncluded := 0 rnd := rand.New(rand.NewSource(time.Now().Unix())) - shuffledUTXOs := make([]*compositeUTXO, len(utxos)) + shuffledUTXOs := make([]*CompositeUTXO, len(utxos)) copy(shuffledUTXOs, utxos) rnd.Shuffle(len(shuffledUTXOs), func(i, j int) { shuffledUTXOs[i], shuffledUTXOs[j] = shuffledUTXOs[j], shuffledUTXOs[i] @@ -120,9 +120,9 @@ func subsetWithLeastOverFund(enough func(uint64, uint64) (bool, uint64), maxFund if use { included[i] = true numIncluded++ - nTotal += shuffledUTXOs[i].amount - totalSize += uint64(shuffledUTXOs[i].input.VBytes()) - if e, _ := enough(totalSize, nTotal); e { + nTotal += shuffledUTXOs[i].Amount + totalSize += uint64(shuffledUTXOs[i].Input.VBytes()) + if e, _ := enough(uint64(numIncluded), totalSize, nTotal); e { if (nTotal < best || (nTotal == best && numIncluded < bestNumIncluded)) && nTotal <= maxFund { best = nTotal if bestIncluded == nil { @@ -132,8 +132,8 @@ func subsetWithLeastOverFund(enough func(uint64, uint64) (bool, uint64), maxFund bestNumIncluded = numIncluded } included[i] = false - nTotal -= shuffledUTXOs[i].amount - totalSize -= uint64(shuffledUTXOs[i].input.VBytes()) + nTotal -= shuffledUTXOs[i].Amount + totalSize -= uint64(shuffledUTXOs[i].Input.VBytes()) numIncluded-- } } @@ -148,7 +148,7 @@ func subsetWithLeastOverFund(enough func(uint64, uint64) (bool, uint64), maxFund return nil } - set := make([]*compositeUTXO, 0, len(shuffledUTXOs)) + set := make([]*CompositeUTXO, 0, len(shuffledUTXOs)) for i, inc := range bestIncluded { if inc { set = append(set, shuffledUTXOs[i]) @@ -176,19 +176,19 @@ func subsetWithLeastOverFund(enough func(uint64, uint64) (bool, uint64), maxFund // // If the provided UTXO set has less combined value than the requested amount a // nil slice is returned. -func leastOverFund(enough func(inputsSize, sum uint64) (bool, uint64), utxos []*compositeUTXO) []*compositeUTXO { +func leastOverFund(enough EnoughFunc, utxos []*CompositeUTXO) []*CompositeUTXO { return leastOverFundWithLimit(enough, math.MaxUint64, utxos) } // 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(inputsSize, sum uint64) (bool, uint64), maxFund uint64, utxos []*compositeUTXO) []*compositeUTXO { +func leastOverFundWithLimit(enough EnoughFunc, maxFund uint64, utxos []*CompositeUTXO) []*CompositeUTXO { // Remove the UTXOs that are larger than maxFund - var smallEnoughUTXOs []*compositeUTXO + var smallEnoughUTXOs []*CompositeUTXO idx := sort.Search(len(utxos), func(i int) bool { utxo := utxos[i] - return utxo.amount > maxFund + return utxo.Amount > maxFund }) if idx == len(utxos) { smallEnoughUTXOs = utxos @@ -200,11 +200,11 @@ func leastOverFundWithLimit(enough func(inputsSize, sum uint64) (bool, uint64), // of smaller ones. idx = sort.Search(len(smallEnoughUTXOs), func(i int) bool { utxo := smallEnoughUTXOs[i] - e, _ := enough(uint64(utxo.input.VBytes()), utxo.amount) + e, _ := enough(1, uint64(utxo.Input.VBytes()), utxo.Amount) return e }) - var small []*compositeUTXO - var single *compositeUTXO // only return this if smaller ones would use more + var small []*CompositeUTXO + var single *CompositeUTXO // only return this if smaller ones would use more if idx == len(smallEnoughUTXOs) { // no one is enough small = smallEnoughUTXOs } else { @@ -212,12 +212,12 @@ func leastOverFundWithLimit(enough func(inputsSize, sum uint64) (bool, uint64), single = smallEnoughUTXOs[idx] } - var set []*compositeUTXO - smallSetTotalValue := sumUTXOs(small) + var set []*CompositeUTXO + smallSetTotalValue := SumUTXOs(small) smallSetTotalSize := sumUTXOSize(small) - if e, _ := enough(smallSetTotalSize, smallSetTotalValue); !e { + if e, _ := enough(uint64(len(small)), smallSetTotalSize, smallSetTotalValue); !e { if single != nil { - return []*compositeUTXO{single} + return []*CompositeUTXO{single} } else { return nil } @@ -226,18 +226,18 @@ func leastOverFundWithLimit(enough func(inputsSize, sum uint64) (bool, uint64), } // Return the small UTXO subset if it is less than the single big UTXO. - if single != nil && single.amount < sumUTXOs(set) { - return []*compositeUTXO{single} + if single != nil && single.Amount < SumUTXOs(set) { + return []*CompositeUTXO{single} } return set } -// utxoSetDiff performs the setdiff(set,sub) of two UTXO sets. That is, any +// UTxOSetDiff performs the setdiff(set,sub) of two UTXO sets. That is, any // UTXOs that are both sets are removed from the first. The comparison is done -// *by pointer*, with no regard to the values of the compositeUTXO elements. -func utxoSetDiff(set, sub []*compositeUTXO) []*compositeUTXO { - var availUTXOs []*compositeUTXO +// *by pointer*, with no regard to the values of the CompositeUTXO elements. +func UTxOSetDiff(set, sub []*CompositeUTXO) []*CompositeUTXO { + var availUTXOs []*CompositeUTXO avail: for _, utxo := range set { for _, kept := range sub { diff --git a/client/asset/btc/coin_selection_test.go b/client/asset/btc/coin_selection_test.go index 266430162c..0c3f416433 100644 --- a/client/asset/btc/coin_selection_test.go +++ b/client/asset/btc/coin_selection_test.go @@ -11,75 +11,75 @@ import ( ) func Test_leastOverFund(t *testing.T) { - enough := func(_, sum uint64) (bool, uint64) { + enough := func(_, _, sum uint64) (bool, uint64) { return sum >= 10e8, 0 } - newU := func(amt float64) *compositeUTXO { - return &compositeUTXO{ - utxo: &utxo{ - amount: uint64(amt) * 1e8, + newU := func(amt float64) *CompositeUTXO { + return &CompositeUTXO{ + UTxO: &UTxO{ + Amount: uint64(amt) * 1e8, }, - input: &dexbtc.SpendInfo{}, + Input: &dexbtc.SpendInfo{}, } } tests := []struct { name string - utxos []*compositeUTXO - want []*compositeUTXO + utxos []*CompositeUTXO + want []*CompositeUTXO }{ { "1,3", - []*compositeUTXO{newU(1), newU(8), newU(9)}, - []*compositeUTXO{newU(1), newU(9)}, + []*CompositeUTXO{newU(1), newU(8), newU(9)}, + []*CompositeUTXO{newU(1), newU(9)}, }, { "1,2", - []*compositeUTXO{newU(1), newU(9)}, - []*compositeUTXO{newU(1), newU(9)}, + []*CompositeUTXO{newU(1), newU(9)}, + []*CompositeUTXO{newU(1), newU(9)}, }, { "1,2++", - []*compositeUTXO{newU(2), newU(9)}, - []*compositeUTXO{newU(2), newU(9)}, + []*CompositeUTXO{newU(2), newU(9)}, + []*CompositeUTXO{newU(2), newU(9)}, }, { "2,3++", - []*compositeUTXO{newU(0), newU(2), newU(9)}, - []*compositeUTXO{newU(2), newU(9)}, + []*CompositeUTXO{newU(0), newU(2), newU(9)}, + []*CompositeUTXO{newU(2), newU(9)}, }, { "3", - []*compositeUTXO{newU(0), newU(2), newU(10)}, - []*compositeUTXO{newU(10)}, + []*CompositeUTXO{newU(0), newU(2), newU(10)}, + []*CompositeUTXO{newU(10)}, }, { "subset", - []*compositeUTXO{newU(1), newU(9), newU(11)}, - []*compositeUTXO{newU(1), newU(9)}, + []*CompositeUTXO{newU(1), newU(9), newU(11)}, + []*CompositeUTXO{newU(1), newU(9)}, }, { "subset small bias", - []*compositeUTXO{newU(3), newU(6), newU(7)}, - []*compositeUTXO{newU(3), newU(7)}, + []*CompositeUTXO{newU(3), newU(6), newU(7)}, + []*CompositeUTXO{newU(3), newU(7)}, }, { "single exception", - []*compositeUTXO{newU(5), newU(7), newU(11)}, - []*compositeUTXO{newU(11)}, + []*CompositeUTXO{newU(5), newU(7), newU(11)}, + []*CompositeUTXO{newU(11)}, }, { "1 of 1", - []*compositeUTXO{newU(10)}, - []*compositeUTXO{newU(10)}, + []*CompositeUTXO{newU(10)}, + []*CompositeUTXO{newU(10)}, }, { "ok nil", - []*compositeUTXO{newU(1), newU(8)}, + []*CompositeUTXO{newU(1), newU(8)}, nil, }, { "ok", - []*compositeUTXO{newU(1)}, + []*CompositeUTXO{newU(1)}, nil, }, } @@ -87,7 +87,7 @@ func Test_leastOverFund(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got := leastOverFund(enough, tt.utxos) sort.Slice(got, func(i int, j int) bool { - return got[i].amount < got[j].amount + return got[i].Amount < got[j].Amount }) if !reflect.DeepEqual(got, tt.want) { t.Errorf("subset() = %v, want %v", got, tt.want) @@ -97,33 +97,33 @@ func Test_leastOverFund(t *testing.T) { } func Test_leastOverFundWithLimit(t *testing.T) { - enough := func(_, sum uint64) (bool, uint64) { + enough := func(_, _, sum uint64) (bool, uint64) { return sum >= 10e8, 0 } - newU := func(amt float64) *compositeUTXO { - return &compositeUTXO{ - utxo: &utxo{ - amount: uint64(amt) * 1e8, + newU := func(amt float64) *CompositeUTXO { + return &CompositeUTXO{ + UTxO: &UTxO{ + Amount: uint64(amt) * 1e8, }, - input: &dexbtc.SpendInfo{}, + Input: &dexbtc.SpendInfo{}, } } tests := []struct { name string limit uint64 - utxos []*compositeUTXO - want []*compositeUTXO + utxos []*CompositeUTXO + want []*CompositeUTXO }{ { "1,3", 10e8, - []*compositeUTXO{newU(1), newU(8), newU(9)}, - []*compositeUTXO{newU(1), newU(9)}, + []*CompositeUTXO{newU(1), newU(8), newU(9)}, + []*CompositeUTXO{newU(1), newU(9)}, }, { "max fund too low", 9e8, - []*compositeUTXO{newU(1), newU(8), newU(9)}, + []*CompositeUTXO{newU(1), newU(8), newU(9)}, nil, }, } @@ -131,7 +131,7 @@ func Test_leastOverFundWithLimit(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got := leastOverFundWithLimit(enough, tt.limit, tt.utxos) sort.Slice(got, func(i int, j int) bool { - return got[i].amount < got[j].amount + return got[i].Amount < got[j].Amount }) if !reflect.DeepEqual(got, tt.want) { t.Errorf("subset() = %v, want %v", got, tt.want) @@ -160,12 +160,12 @@ func Fuzz_leastOverFund(f *testing.F) { f.Add(seed.amt, seed.n) } - newU := func(amt float64) *compositeUTXO { - return &compositeUTXO{ - utxo: &utxo{ - amount: uint64(amt * 1e8), + newU := func(amt float64) *CompositeUTXO { + return &CompositeUTXO{ + UTxO: &UTxO{ + Amount: uint64(amt * 1e8), }, - input: &dexbtc.SpendInfo{}, + Input: &dexbtc.SpendInfo{}, } } @@ -177,7 +177,7 @@ func Fuzz_leastOverFund(f *testing.F) { t.Skip() } m := 2 * amt / uint64(n) - utxos := make([]*compositeUTXO, n) + utxos := make([]*CompositeUTXO, n) for i := range utxos { var v float64 if rand.Intn(2) > 0 { @@ -192,7 +192,7 @@ func Fuzz_leastOverFund(f *testing.F) { utxos[i] = newU(v) } startTime := time.Now() - enough := func(_, sum uint64) (bool, uint64) { + enough := func(_, _, sum uint64) (bool, uint64) { return sum >= amt*1e8, 0 } leastOverFund(enough, utxos) @@ -206,19 +206,19 @@ func Fuzz_leastOverFund(f *testing.F) { func BenchmarkLeastOverFund(b *testing.B) { // Same amounts every time. rnd := rand.New(rand.NewSource(1)) - utxos := make([]*compositeUTXO, 2_000) + utxos := make([]*CompositeUTXO, 2_000) for i := range utxos { - utxo := &compositeUTXO{ - utxo: &utxo{ - amount: uint64(rnd.Int31n(100) * 1e8), + utxo := &CompositeUTXO{ + UTxO: &UTxO{ + Amount: uint64(rnd.Int31n(100) * 1e8), }, - input: &dexbtc.SpendInfo{}, + Input: &dexbtc.SpendInfo{}, } utxos[i] = utxo } b.ResetTimer() for n := 0; n < b.N; n++ { - enough := func(_, sum uint64) (bool, uint64) { + enough := func(_, _, sum uint64) (bool, uint64) { return sum >= 10_000*1e8, 0 } leastOverFund(enough, utxos) diff --git a/client/asset/btc/coinmanager.go b/client/asset/btc/coinmanager.go new file mode 100644 index 0000000000..e467322f9e --- /dev/null +++ b/client/asset/btc/coinmanager.go @@ -0,0 +1,686 @@ +package btc + +import ( + "errors" + "fmt" + "math" + "sort" + "sync" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/dex" + dexbtc "decred.org/dcrdex/dex/networks/btc" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" +) + +// CompositeUTXO combines utxo info with the spending input information. +type CompositeUTXO struct { + *UTxO + Confs uint32 + RedeemScript []byte + Input *dexbtc.SpendInfo +} + +// EnoughFunc considers information about funding inputs and indicates whether +// it is enough to fund an order. EnoughFunc is bound to an order by the +// OrderFundingThresholder. +type EnoughFunc func(inputCount, inputsSize, sum uint64) (bool, uint64) + +// OrderEstimator is a function that accepts information about an order and +// estimates the total required funds needed for the order. +type OrderEstimator func(swapVal, inputCount, inputsSize, maxSwaps, feeRate uint64) uint64 + +// OrderFundingThresholder accepts information about an order and generates an +// EnoughFunc that can be used to test funding input combinations. +type OrderFundingThresholder func(val, lots, maxFeeRate uint64, reportChange bool) EnoughFunc + +// CoinManager provides utilities for working with unspent transaction outputs. +// In addition to translation to and from custom wallet types, there are +// CoinManager methods to help pick UTXOs for funding in various contexts. +type CoinManager struct { + // Coins returned by Fund are cached for quick reference. + mtx sync.RWMutex + log dex.Logger + + orderEnough OrderFundingThresholder + chainParams *chaincfg.Params + listUnspent func() ([]*ListUnspentResult, error) + lockUnspent func(unlock bool, ops []*Output) error + listLocked func() ([]*RPCOutpoint, error) + getTxOut func(txHash *chainhash.Hash, vout uint32) (*wire.TxOut, error) + stringAddr func(btcutil.Address) (string, error) + + lockedOutputs map[OutPoint]*UTxO +} + +func NewCoinManager( + log dex.Logger, + chainParams *chaincfg.Params, + orderEnough OrderFundingThresholder, + listUnspent func() ([]*ListUnspentResult, error), + lockUnspent func(unlock bool, ops []*Output) error, + listLocked func() ([]*RPCOutpoint, error), + getTxOut func(txHash *chainhash.Hash, vout uint32) (*wire.TxOut, error), + stringAddr func(btcutil.Address) (string, error), +) *CoinManager { + + return &CoinManager{ + log: log, + orderEnough: orderEnough, + chainParams: chainParams, + listUnspent: listUnspent, + lockUnspent: lockUnspent, + listLocked: listLocked, + getTxOut: getTxOut, + lockedOutputs: make(map[OutPoint]*UTxO), + stringAddr: stringAddr, + } +} + +// FundWithUTXOs attempts to find the best combination of UTXOs to satisfy the +// given EnoughFunc while respecting the specified keep reserves (if non-zero). +func (c *CoinManager) FundWithUTXOs( + utxos []*CompositeUTXO, + keep uint64, + lockUnspents bool, + enough EnoughFunc, +) (coins asset.Coins, fundingCoins map[OutPoint]*UTxO, spents []*Output, redeemScripts []dex.Bytes, size, sum uint64, err error) { + var avail uint64 + for _, utxo := range utxos { + avail += utxo.Amount + } + + c.mtx.Lock() + defer c.mtx.Unlock() + return c.fundWithUTXOs(utxos, avail, keep, lockUnspents, enough) +} + +func (c *CoinManager) fundWithUTXOs( + utxos []*CompositeUTXO, + avail, keep uint64, + lockUnspents bool, + enough EnoughFunc, +) (coins asset.Coins, fundingCoins map[OutPoint]*UTxO, spents []*Output, redeemScripts []dex.Bytes, size, sum uint64, err error) { + + if keep > 0 { + kept := leastOverFund(reserveEnough(keep), utxos) + c.log.Debugf("Setting aside %v BTC in %d UTXOs to respect the %v BTC reserved amount", + toBTC(SumUTXOs(kept)), len(kept), toBTC(keep)) + utxosPruned := UTxOSetDiff(utxos, kept) + sum, _, size, coins, fundingCoins, redeemScripts, spents, err = TryFund(utxosPruned, enough) + if err != nil { + c.log.Debugf("Unable to fund order with UTXOs set aside (%v), trying again with full UTXO set.", err) + } + } + if len(spents) == 0 { // either keep is zero or it failed with utxosPruned + // Without utxos set aside for keep, we have to consider any spendable + // change (extra) that the enough func grants us. + + var extra uint64 + sum, extra, size, coins, fundingCoins, redeemScripts, spents, err = TryFund(utxos, enough) + if err != nil { + return nil, nil, nil, nil, 0, 0, err + } + if avail-sum+extra < keep { + return nil, 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. + if keep > 0 && extra > 0 { + c.log.Debugf("Funding succeeded with %v BTC in spendable change.", toBTC(extra)) + } + } + + if lockUnspents { + err = c.lockUnspent(false, spents) + if err != nil { + return nil, nil, nil, nil, 0, 0, fmt.Errorf("LockUnspent error: %w", err) + } + for pt, utxo := range fundingCoins { + c.lockedOutputs[pt] = utxo + } + } + + return coins, fundingCoins, spents, redeemScripts, size, sum, err +} + +func (c *CoinManager) fund(keep uint64, minConfs uint32, lockUnspents bool, + enough func(_, size, sum uint64) (bool, uint64)) ( + coins asset.Coins, fundingCoins map[OutPoint]*UTxO, spents []*Output, redeemScripts []dex.Bytes, size, sum uint64, err error) { + utxos, _, avail, err := c.spendableUTXOs(minConfs) + if err != nil { + return nil, nil, nil, nil, 0, 0, fmt.Errorf("error getting spendable utxos: %w", err) + } + return c.fundWithUTXOs(utxos, avail, keep, lockUnspents, enough) +} + +// Fund attempts to satisfy the given EnoughFunc with all available UTXOs. For +// situations where Fund might be called repeatedly, the caller should instead +// do SpendableUTXOs and use the results in FundWithUTXOs. +func (c *CoinManager) Fund( + keep uint64, + minConfs uint32, + lockUnspents bool, + enough EnoughFunc, +) (coins asset.Coins, fundingCoins map[OutPoint]*UTxO, spents []*Output, redeemScripts []dex.Bytes, size, sum uint64, err error) { + + c.mtx.Lock() + defer c.mtx.Unlock() + + return c.fund(keep, minConfs, lockUnspents, enough) +} + +// 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 (c *CoinManager) 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 := c.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 +} + +// 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 (c *CoinManager) FundMultiBestEffort(keep, maxLock uint64, values []*asset.MultiOrderValue, + maxFeeRate uint64, splitAllowed bool) ([]asset.Coins, [][]dex.Bytes, map[OutPoint]*UTxO, []*Output, error) { + utxos, _, avail, err := c.SpendableUTXOs(0) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("error getting spendable utxos: %w", err) + } + + 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 := c.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 := c.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 + c.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 map[OutPoint]*UTxO, spents []*Output, err error) { + coins = make([]asset.Coins, len(allFundingUTXOs)) + fundingCoins = make(map[OutPoint]*UTxO) + spents = make([]*Output, 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 { + coins[i][j] = NewOutput(output.TxHash, output.Vout, output.Amount) + fundingCoins[OutPoint{TxHash: *output.TxHash, Vout: output.Vout}] = &UTxO{ + TxHash: output.TxHash, + Vout: output.Vout, + Amount: output.Amount, + Address: output.Address, + } + spents = append(spents, NewOutput(output.TxHash, output.Vout, output.Amount)) + redeemScripts[i][j] = output.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) +} + +// SpendableUTXOs filters the RPC utxos for those that are spendable with +// regards to the DEX's configuration, and considered safe to spend according to +// confirmations and coin source. The UTXOs will be sorted by ascending value. +// spendableUTXOs should only be called with the fundingMtx RLock'ed. +func (c *CoinManager) SpendableUTXOs(confs uint32) ([]*CompositeUTXO, map[OutPoint]*CompositeUTXO, uint64, error) { + c.mtx.RLock() + defer c.mtx.RUnlock() + return c.spendableUTXOs(confs) +} + +func (c *CoinManager) spendableUTXOs(confs uint32) ([]*CompositeUTXO, map[OutPoint]*CompositeUTXO, uint64, error) { + unspents, err := c.listUnspent() + if err != nil { + return nil, nil, 0, err + } + + utxos, utxoMap, sum, err := convertUnspent(confs, unspents, c.chainParams) + if err != nil { + return nil, nil, 0, err + } + + var relock []*Output + var i int + for _, utxo := range utxos { + // Guard against inconsistencies between the wallet's view of + // spendable unlocked UTXOs and ExchangeWallet's. e.g. User manually + // unlocked something or even restarted the wallet software. + pt := NewOutPoint(utxo.TxHash, utxo.Vout) + if c.lockedOutputs[pt] != nil { + c.log.Warnf("Known order-funding coin %s returned by listunspent!", pt) + delete(utxoMap, pt) + relock = append(relock, &Output{pt, utxo.Amount}) + } else { // in-place filter maintaining order + utxos[i] = utxo + i++ + } + } + if len(relock) > 0 { + if err = c.lockUnspent(false, relock); err != nil { + c.log.Errorf("Failed to re-lock funding coins with wallet: %v", err) + } + } + utxos = utxos[:i] + return utxos, utxoMap, sum, nil +} + +// ReturnCoins makes the locked utxos available for use again. +func (c *CoinManager) ReturnCoins(unspents asset.Coins) error { + if unspents == nil { // not just empty to make this harder to do accidentally + c.log.Debugf("Returning all coins.") + c.mtx.Lock() + defer c.mtx.Unlock() + if err := c.lockUnspent(true, nil); err != nil { + return err + } + c.lockedOutputs = make(map[OutPoint]*UTxO) + return nil + } + if len(unspents) == 0 { + return fmt.Errorf("cannot return zero coins") + } + + ops := make([]*Output, 0, len(unspents)) + c.log.Debugf("returning coins %s", unspents) + c.mtx.Lock() + defer c.mtx.Unlock() + for _, unspent := range unspents { + op, err := ConvertCoin(unspent) + if err != nil { + return fmt.Errorf("error converting coin: %w", err) + } + ops = append(ops, op) + } + if err := c.lockUnspent(true, ops); err != nil { + return err // could it have unlocked some of them? we may want to loop instead if that's the case + } + for _, op := range ops { + delete(c.lockedOutputs, op.Pt) + } + return nil +} + +// ReturnOutPoint makes the UTXO represented by the OutPoint available for use +// again. +func (c *CoinManager) ReturnOutPoint(pt OutPoint) error { + c.mtx.Lock() + defer c.mtx.Unlock() + if err := c.lockUnspent(true, []*Output{NewOutput(&pt.TxHash, pt.Vout, 0)}); err != nil { + return err // could it have unlocked some of them? we may want to loop instead if that's the case + } + delete(c.lockedOutputs, pt) + return nil +} + +// FundingCoins attempts to find the specified utxos and locks them. +func (c *CoinManager) FundingCoins(ids []dex.Bytes) (asset.Coins, error) { + // First check if we have the coins in cache. + coins := make(asset.Coins, 0, len(ids)) + notFound := make(map[OutPoint]bool) + c.mtx.Lock() + defer c.mtx.Unlock() // stay locked until we update the map at the end + for _, id := range ids { + txHash, vout, err := decodeCoinID(id) + if err != nil { + return nil, err + } + pt := NewOutPoint(txHash, vout) + fundingCoin, found := c.lockedOutputs[pt] + if found { + coins = append(coins, NewOutput(txHash, vout, fundingCoin.Amount)) + continue + } + notFound[pt] = true + } + if len(notFound) == 0 { + return coins, nil + } + + // Check locked outputs for not found coins. + lockedOutpoints, err := c.listLocked() + if err != nil { + return nil, err + } + + for _, rpcOP := range lockedOutpoints { + txHash, err := chainhash.NewHashFromStr(rpcOP.TxID) + if err != nil { + return nil, fmt.Errorf("error decoding txid from rpc server %s: %w", rpcOP.TxID, err) + } + pt := NewOutPoint(txHash, rpcOP.Vout) + if !notFound[pt] { + continue // unrelated to the order + } + + txOut, err := c.getTxOut(txHash, rpcOP.Vout) + if err != nil { + return nil, err + } + if txOut == nil { + continue + } + if txOut.Value <= 0 { + c.log.Warnf("Invalid value %v for %v", txOut.Value, pt) + continue // try the listunspent output + } + _, addrs, _, err := txscript.ExtractPkScriptAddrs(txOut.PkScript, c.chainParams) + if err != nil { + c.log.Warnf("Invalid pkScript for %v: %v", pt, err) + continue + } + if len(addrs) != 1 { + c.log.Warnf("pkScript for %v contains %d addresses instead of one", pt, len(addrs)) + continue + } + addrStr, err := c.stringAddr(addrs[0]) + if err != nil { + c.log.Errorf("Failed to stringify address %v (default encoding): %v", addrs[0], err) + addrStr = addrs[0].String() // may or may not be able to retrieve the private keys by address! + } + utxo := &UTxO{ + TxHash: txHash, + Vout: rpcOP.Vout, + Address: addrStr, // for retrieving private key by address string + Amount: uint64(txOut.Value), + } + coin := NewOutput(txHash, rpcOP.Vout, uint64(txOut.Value)) + coins = append(coins, coin) + c.lockedOutputs[pt] = utxo + delete(notFound, pt) + if len(notFound) == 0 { + return coins, nil + } + } + + // Some funding coins still not found after checking locked outputs. + // Check wallet unspent outputs as last resort. Lock the coins if found. + _, utxoMap, _, err := c.spendableUTXOs(0) + if err != nil { + return nil, err + } + coinsToLock := make([]*Output, 0, len(notFound)) + for pt := range notFound { + utxo, found := utxoMap[pt] + if !found { + return nil, fmt.Errorf("funding coin not found: %s", pt.String()) + } + c.lockedOutputs[pt] = utxo.UTxO + coin := NewOutput(utxo.TxHash, utxo.Vout, utxo.Amount) + coins = append(coins, coin) + coinsToLock = append(coinsToLock, coin) + delete(notFound, pt) + } + c.log.Debugf("Locking funding coins that were unlocked %v", coinsToLock) + err = c.lockUnspent(false, coinsToLock) + if err != nil { + return nil, err + } + return coins, nil +} + +// LockOutputs locks the specified utxos. +func (c *CoinManager) LockOutputs(utxos []*UTxO) { + c.mtx.Lock() + for _, utxo := range utxos { + c.lockedOutputs[NewOutPoint(utxo.TxHash, utxo.Vout)] = utxo + } + c.mtx.Unlock() +} + +// LockOutputs locks the utxos in the provided mapping. +func (c *CoinManager) LockOutputsMap(utxos map[OutPoint]*UTxO) { + c.mtx.Lock() + for pt, utxo := range utxos { + c.lockedOutputs[pt] = utxo + } + c.mtx.Unlock() +} + +// UnlockOutPoints unlocks the utxos represented by the provided outpoints. +func (c *CoinManager) UnlockOutPoints(pts []OutPoint) { + c.mtx.Lock() + for _, pt := range pts { + delete(c.lockedOutputs, pt) + } + c.mtx.Unlock() +} + +// LockedOutput returns the currently locked utxo represented by the provided +// outpoint, or nil if there is no record of the utxo in the local map. +func (c *CoinManager) LockedOutput(pt OutPoint) *UTxO { + c.mtx.Lock() + defer c.mtx.Unlock() + return c.lockedOutputs[pt] +} + +func convertUnspent(confs uint32, unspents []*ListUnspentResult, chainParams *chaincfg.Params) ([]*CompositeUTXO, map[OutPoint]*CompositeUTXO, uint64, error) { + sort.Slice(unspents, func(i, j int) bool { return unspents[i].Amount < unspents[j].Amount }) + var sum uint64 + utxos := make([]*CompositeUTXO, 0, len(unspents)) + utxoMap := make(map[OutPoint]*CompositeUTXO, len(unspents)) + for _, txout := range unspents { + if txout.Confirmations >= confs && txout.Safe() && txout.Spendable { + txHash, err := chainhash.NewHashFromStr(txout.TxID) + if err != nil { + return nil, nil, 0, fmt.Errorf("error decoding txid in ListUnspentResult: %w", err) + } + + nfo, err := dexbtc.InputInfo(txout.ScriptPubKey, txout.RedeemScript, chainParams) + if err != nil { + if errors.Is(err, dex.UnsupportedScriptError) { + continue + } + return nil, nil, 0, fmt.Errorf("error reading asset info: %w", err) + } + if nfo.ScriptType == dexbtc.ScriptUnsupported || nfo.NonStandardScript { + // InputInfo sets NonStandardScript for P2SH with non-standard + // redeem scripts. Don't return these since they cannot fund + // arbitrary txns. + continue + } + utxo := &CompositeUTXO{ + UTxO: &UTxO{ + TxHash: txHash, + Vout: txout.Vout, + Address: txout.Address, + Amount: toSatoshi(txout.Amount), + }, + Confs: txout.Confirmations, + RedeemScript: txout.RedeemScript, + Input: nfo, + } + utxos = append(utxos, utxo) + utxoMap[NewOutPoint(txHash, txout.Vout)] = utxo + sum += toSatoshi(txout.Amount) + } + } + return utxos, utxoMap, sum, nil +} + +func TryFund( + utxos []*CompositeUTXO, + enough EnoughFunc, +) ( + sum, extra, size uint64, + coins asset.Coins, + fundingCoins map[OutPoint]*UTxO, + redeemScripts []dex.Bytes, + spents []*Output, + err error, +) { + + fundingCoins = make(map[OutPoint]*UTxO) + + isEnoughWith := func(count int, unspent *CompositeUTXO) bool { + ok, _ := enough(uint64(count), size+uint64(unspent.Input.VBytes()), sum+unspent.Amount) + return ok + } + + addUTXO := func(unspent *CompositeUTXO) { + v := unspent.Amount + op := NewOutput(unspent.TxHash, unspent.Vout, v) + coins = append(coins, op) + redeemScripts = append(redeemScripts, unspent.RedeemScript) + spents = append(spents, op) + size += uint64(unspent.Input.VBytes()) + fundingCoins[op.Pt] = unspent.UTxO + sum += v + } + + tryUTXOs := func(minconf uint32) bool { + sum, size = 0, 0 + coins, spents, redeemScripts = nil, nil, nil + fundingCoins = make(map[OutPoint]*UTxO) + + okUTXOs := make([]*CompositeUTXO, 0, len(utxos)) // over-allocate + for _, cu := range utxos { + if cu.Confs >= minconf { + okUTXOs = append(okUTXOs, cu) + } + } + + for { + // If there are none left, we don't have enough. + if len(okUTXOs) == 0 { + return false + } + + // Check if the largest output is too small. + lastUTXO := okUTXOs[len(okUTXOs)-1] + if !isEnoughWith(1, lastUTXO) { + addUTXO(lastUTXO) + okUTXOs = okUTXOs[0 : len(okUTXOs)-1] + continue + } + + // We only need one then. Find it. + idx := sort.Search(len(okUTXOs), func(i int) bool { + return isEnoughWith(1, okUTXOs[i]) + }) + // No need to check idx == len(okUTXOs). We already verified that the last + // utxo passes above. + addUTXO(okUTXOs[idx]) + _, extra = enough(uint64(len(coins)), size, sum) + return true + } + } + + // First try with confs>0, falling back to allowing 0-conf outputs. + if !tryUTXOs(1) { + if !tryUTXOs(0) { + return 0, 0, 0, nil, nil, nil, nil, fmt.Errorf("not enough to cover requested funds. "+ + "%d available in %d UTXOs", amount(sum), len(coins)) + } + } + + return +} diff --git a/client/asset/btc/electrum.go b/client/asset/btc/electrum.go index 3a6851ab68..9344e49deb 100644 --- a/client/asset/btc/electrum.go +++ b/client/asset/btc/electrum.go @@ -24,6 +24,9 @@ type ExchangeWalletElectrum struct { *baseWallet *authAddOn ew *electrumWallet + + findRedemptionMtx sync.RWMutex + findRedemptionQueue map[OutPoint]*FindRedemptionReq } var _ asset.Wallet = (*ExchangeWalletElectrum)(nil) @@ -49,21 +52,20 @@ func ElectrumWallet(cfg *BTCCloneCFG) (*ExchangeWalletElectrum, error) { ewc := electrum.NewWalletClient(rpcCfg.RPCUser, rpcCfg.RPCPass, "http://"+rpcCfg.RPCBind, rpcCfg.WalletName) ew := newElectrumWallet(ewc, &electrumWalletConfig{ - params: cfg.ChainParams, - log: cfg.Logger.SubLogger("ELECTRUM"), - addrDecoder: cfg.AddressDecoder, - addrStringer: cfg.AddressStringer, - txDeserializer: cfg.TxDeserializer, - txSerializer: cfg.TxSerializer, - segwit: cfg.Segwit, - rpcCfg: rpcCfg, + params: cfg.ChainParams, + log: cfg.Logger.SubLogger("ELECTRUM"), + addrDecoder: cfg.AddressDecoder, + addrStringer: cfg.AddressStringer, + segwit: cfg.Segwit, + rpcCfg: rpcCfg, }) - btc.node = ew + btc.setNode(ew) eew := &ExchangeWalletElectrum{ - baseWallet: btc, - authAddOn: &authAddOn{btc.node}, - ew: ew, + baseWallet: btc, + authAddOn: &authAddOn{btc.node}, + ew: ew, + findRedemptionQueue: make(map[OutPoint]*FindRedemptionReq), } // In (*baseWallet).feeRate, use ExchangeWalletElectrum's walletFeeRate // override for localFeeRate. No externalFeeRate is required but will be @@ -138,6 +140,18 @@ func (btc *ExchangeWalletElectrum) Connect(ctx context.Context) (*sync.WaitGroup return wg, nil } +func (btc *ExchangeWalletElectrum) cancelRedemptionSearches() { + // Close all open channels for contract redemption searches + // to prevent leakages and ensure goroutines that are started + // to wait on these channels end gracefully. + btc.findRedemptionMtx.Lock() + for contractOutpoint, req := range btc.findRedemptionQueue { + req.fail("shutting down") + delete(btc.findRedemptionQueue, contractOutpoint) + } + btc.findRedemptionMtx.Unlock() +} + // walletFeeRate satisfies BTCCloneCFG.FeeEstimator. func (btc *ExchangeWalletElectrum) walletFeeRate(ctx context.Context, _ RawRequester, confTarget uint64) (uint64, error) { satPerKB, err := btc.ew.wallet.FeeRate(ctx, int64(confTarget)) @@ -152,8 +166,8 @@ func (btc *ExchangeWalletElectrum) walletFeeRate(ctx context.Context, _ RawReque // If not found, but otherwise without an error, a nil Hash will be returned // along with a nil error. Thus, both the error and the Hash should be checked. // This convention is only used since this is not part of the public API. -func (btc *ExchangeWalletElectrum) findRedemption(ctx context.Context, op outPoint, contractHash []byte) (*chainhash.Hash, uint32, []byte, error) { - msgTx, vin, err := btc.ew.findOutputSpender(ctx, &op.txHash, op.vout) +func (btc *ExchangeWalletElectrum) findRedemption(ctx context.Context, op OutPoint, contractHash []byte) (*chainhash.Hash, uint32, []byte, error) { + msgTx, vin, err := btc.ew.findOutputSpender(ctx, &op.TxHash, op.Vout) if err != nil { return nil, 0, nil, err } @@ -173,7 +187,7 @@ func (btc *ExchangeWalletElectrum) findRedemption(ctx context.Context, op outPoi func (btc *ExchangeWalletElectrum) tryRedemptionRequests(ctx context.Context) { btc.findRedemptionMtx.RLock() - reqs := make([]*findRedemptionReq, 0, len(btc.findRedemptionQueue)) + reqs := make([]*FindRedemptionReq, 0, len(btc.findRedemptionQueue)) for _, req := range btc.findRedemptionQueue { reqs = append(reqs, req) } @@ -188,8 +202,8 @@ func (btc *ExchangeWalletElectrum) tryRedemptionRequests(ctx context.Context) { if txHash == nil { continue // maybe next time } - req.success(&findRedemptionResult{ - redemptionCoinID: toCoinID(txHash, vin), + req.success(&FindRedemptionResult{ + redemptionCoinID: ToCoinID(txHash, vin), secret: secret, }) } @@ -212,18 +226,18 @@ func (btc *ExchangeWalletElectrum) FindRedemption(ctx context.Context, coinID, c // contractHash := dexbtc.ExtractScriptHash(txOut.PkScript) // Check once before putting this in the queue. - outPt := newOutPoint(txHash, vout) + outPt := NewOutPoint(txHash, vout) spendTxID, vin, secret, err := btc.findRedemption(ctx, outPt, contractHash) if err != nil { return nil, nil, err } if spendTxID != nil { - return toCoinID(spendTxID, vin), secret, nil + return ToCoinID(spendTxID, vin), secret, nil } - req := &findRedemptionReq{ + req := &FindRedemptionReq{ outPt: outPt, - resultChan: make(chan *findRedemptionResult, 1), + resultChan: make(chan *FindRedemptionResult, 1), contractHash: contractHash, // blockHash, blockHeight, and pkScript not used by this impl. blockHash: &chainhash.Hash{}, @@ -232,7 +246,7 @@ func (btc *ExchangeWalletElectrum) FindRedemption(ctx context.Context, coinID, c return nil, nil, err } - var result *findRedemptionResult + var result *FindRedemptionResult select { case result = <-req.resultChan: if result == nil { @@ -257,6 +271,16 @@ func (btc *ExchangeWalletElectrum) FindRedemption(ctx context.Context, coinID, c return nil, nil, err } +func (btc *ExchangeWalletElectrum) queueFindRedemptionRequest(req *FindRedemptionReq) error { + btc.findRedemptionMtx.Lock() + defer btc.findRedemptionMtx.Unlock() + if _, exists := btc.findRedemptionQueue[req.outPt]; exists { + return fmt.Errorf("duplicate find redemption request for %s", req.outPt) + } + btc.findRedemptionQueue[req.outPt] = req + return nil +} + // watchBlocks pings for new blocks and runs the tipChange callback function // when the block changes. func (btc *ExchangeWalletElectrum) watchBlocks(ctx context.Context) { @@ -264,7 +288,7 @@ func (btc *ExchangeWalletElectrum) watchBlocks(ctx context.Context) { ticker := time.NewTicker(electrumBlockTick) defer ticker.Stop() - bestBlock := func() (*block, error) { + bestBlock := func() (*BlockVector, error) { hdr, err := btc.node.getBestBlockHeader() if err != nil { return nil, fmt.Errorf("getBestBlockHeader: %v", err) @@ -273,13 +297,13 @@ func (btc *ExchangeWalletElectrum) watchBlocks(ctx context.Context) { if err != nil { return nil, fmt.Errorf("invalid best block hash %s: %v", hdr.Hash, err) } - return &block{hdr.Height, *hash}, nil + return &BlockVector{hdr.Height, *hash}, nil } currentTip, err := bestBlock() if err != nil { btc.log.Errorf("Failed to get best block: %v", err) - currentTip = new(block) // zero height and hash + currentTip = new(BlockVector) // zero height and hash } for { @@ -296,7 +320,7 @@ func (btc *ExchangeWalletElectrum) watchBlocks(ctx context.Context) { continue } - sameTip := currentTip.height == int64(stat.Height) + sameTip := currentTip.Height == int64(stat.Height) if sameTip { // Could have actually been a reorg to different block at same // height. We'll report a new tip block on the next block. @@ -312,10 +336,10 @@ func (btc *ExchangeWalletElectrum) watchBlocks(ctx context.Context) { continue } - btc.log.Debugf("tip change: %d (%s) => %d (%s)", currentTip.height, currentTip.hash, - newTip.height, newTip.hash) + btc.log.Debugf("tip change: %d (%s) => %d (%s)", currentTip.Height, currentTip.Hash, + newTip.Height, newTip.Hash) currentTip = newTip - btc.emit.TipChange(uint64(newTip.height)) + btc.emit.TipChange(uint64(newTip.Height)) go btc.tryRedemptionRequests(ctx) case <-ctx.Done(): diff --git a/client/asset/btc/electrum_client.go b/client/asset/btc/electrum_client.go index 67815df8b4..c9a657dbc0 100644 --- a/client/asset/btc/electrum_client.go +++ b/client/asset/btc/electrum_client.go @@ -68,23 +68,21 @@ type electrumNetworkClient interface { } type electrumWallet struct { - log dex.Logger - chainParams *chaincfg.Params - decodeAddr dexbtc.AddressDecoder - stringAddr dexbtc.AddressStringer - deserializeTx func([]byte) (*wire.MsgTx, error) - serializeTx func(*wire.MsgTx) ([]byte, error) - rpcCfg *RPCConfig // supports live reconfigure check - wallet electrumWalletClient - chainV atomic.Value // electrumNetworkClient - segwit bool + log dex.Logger + chainParams *chaincfg.Params + decodeAddr dexbtc.AddressDecoder + stringAddr dexbtc.AddressStringer + rpcCfg *RPCConfig // supports live reconfigure check + wallet electrumWalletClient + chainV atomic.Value // electrumNetworkClient + segwit bool // ctx is set on connect, and used in asset.Wallet and btc.Wallet interface // method implementations that have no ctx arg yet (refactoring TODO). ctx context.Context lockedOutpointsMtx sync.RWMutex - lockedOutpoints map[outPoint]struct{} + lockedOutpoints map[OutPoint]struct{} pwMtx sync.RWMutex pw string @@ -101,14 +99,12 @@ func (ew *electrumWallet) resetChain(cl electrumNetworkClient) { } type electrumWalletConfig struct { - params *chaincfg.Params - log dex.Logger - addrDecoder dexbtc.AddressDecoder - addrStringer dexbtc.AddressStringer - txDeserializer func([]byte) (*wire.MsgTx, error) - txSerializer func(*wire.MsgTx) ([]byte, error) - segwit bool // indicates if segwit addresses are expected from requests - rpcCfg *RPCConfig + params *chaincfg.Params + log dex.Logger + addrDecoder dexbtc.AddressDecoder + addrStringer dexbtc.AddressStringer + segwit bool // indicates if segwit addresses are expected from requests + rpcCfg *RPCConfig } func newElectrumWallet(ew electrumWalletClient, cfg *electrumWalletConfig) *electrumWallet { @@ -124,29 +120,18 @@ func newElectrumWallet(ew electrumWalletClient, cfg *electrumWalletConfig) *elec } } - txDeserializer := cfg.txDeserializer - if txDeserializer == nil { - txDeserializer = msgTxFromBytes - } - txSerializer := cfg.txSerializer - if txSerializer == nil { - txSerializer = serializeMsgTx - } - return &electrumWallet{ - log: cfg.log, - chainParams: cfg.params, - decodeAddr: addrDecoder, - stringAddr: addrStringer, - deserializeTx: txDeserializer, - serializeTx: txSerializer, - wallet: ew, - segwit: cfg.segwit, + log: cfg.log, + chainParams: cfg.params, + decodeAddr: addrDecoder, + stringAddr: addrStringer, + wallet: ew, + segwit: cfg.segwit, // TODO: remove this when all interface methods are given a Context. In // the meantime, init with a valid sentry context until connect(). ctx: context.TODO(), // chain is constructed after wallet connects to a server - lockedOutpoints: make(map[outPoint]struct{}), + lockedOutpoints: make(map[OutPoint]struct{}), rpcCfg: cfg.rpcCfg, } } @@ -376,7 +361,7 @@ func (ew *electrumWallet) reconfigure(cfg *asset.WalletConfig, currentAddress st // part of btc.Wallet interface func (ew *electrumWallet) sendRawTransaction(tx *wire.MsgTx) (*chainhash.Hash, error) { - b, err := ew.serializeTx(tx) + b, err := serializeMsgTx(tx) if err != nil { return nil, err } @@ -396,10 +381,10 @@ func (ew *electrumWallet) sendRawTransaction(tx *wire.MsgTx) (*chainhash.Hash, e if err != nil { return nil, err // well that sucks, it's already sent } - ops := make([]*output, len(tx.TxIn)) + ops := make([]*Output, len(tx.TxIn)) for i, txIn := range tx.TxIn { prevOut := txIn.PreviousOutPoint - ops[i] = &output{pt: newOutPoint(&prevOut.Hash, prevOut.Index)} + ops[i] = &Output{Pt: NewOutPoint(&prevOut.Hash, prevOut.Index)} } if err = ew.lockUnspent(true, ops); err != nil { ew.log.Errorf("Failed to unlock spent UTXOs: %v", err) @@ -464,7 +449,7 @@ func (ew *electrumWallet) getTxOutput(ctx context.Context, txHash *chainhash.Has } } - msgTx, err := ew.deserializeTx(txRaw) + msgTx, err := msgTxFromBytes(txRaw) if err != nil { return nil, 0, err } @@ -594,7 +579,7 @@ func (ew *electrumWallet) getBestBlockHeight() (int32, error) { } // part of btc.Wallet interface -func (ew *electrumWallet) getBestBlockHeader() (*blockHeader, error) { +func (ew *electrumWallet) getBestBlockHeader() (*BlockHeader, error) { inf, err := ew.wallet.GetInfo(ew.ctx) if err != nil { return nil, err @@ -605,7 +590,7 @@ func (ew *electrumWallet) getBestBlockHeader() (*blockHeader, error) { return nil, err } - header := &blockHeader{ + header := &BlockHeader{ Hash: hdr.BlockHash().String(), Height: inf.SyncHeight, Confirmations: 1, // it's the head @@ -710,20 +695,20 @@ func (ew *electrumWallet) listUnspent() ([]*ListUnspentResult, error) { } // part of btc.Wallet interface -func (ew *electrumWallet) lockUnspent(unlock bool, ops []*output) error { +func (ew *electrumWallet) lockUnspent(unlock bool, ops []*Output) error { eUnspent, err := ew.wallet.ListUnspent(ew.ctx) if err != nil { return err } - opMap := make(map[outPoint]struct{}, len(ops)) + opMap := make(map[OutPoint]struct{}, len(ops)) for _, op := range ops { - opMap[op.pt] = struct{}{} + opMap[op.Pt] = struct{}{} } // For the ones that appear in listunspent, use (un)freeze_utxo also. unspents: for _, utxo := range eUnspent { for op := range opMap { - if op.vout == utxo.PrevOutIdx && op.txHash.String() == utxo.PrevOutHash { + if op.Vout == utxo.PrevOutIdx && op.TxHash.String() == utxo.PrevOutHash { // FreezeUTXO and UnfreezeUTXO do not error when called // repeatedly for the same UTXO. if unlock { @@ -771,8 +756,8 @@ func (ew *electrumWallet) listLockedOutpoints() []*RPCOutpoint { locked := make([]*RPCOutpoint, 0, len(ew.lockedOutpoints)) for op := range ew.lockedOutpoints { locked = append(locked, &RPCOutpoint{ - TxID: op.txHash.String(), - Vout: op.vout, + TxID: op.TxHash.String(), + Vout: op.Vout, }) } return locked @@ -819,7 +804,7 @@ func (ew *electrumWallet) signTx(inTx *wire.MsgTx) (*wire.MsgTx, error) { if err != nil { return nil, err } - return ew.deserializeTx(signedB) + return msgTxFromBytes(signedB) } type hash160er interface { @@ -976,12 +961,12 @@ func (ew *electrumWallet) ownsAddress(addr btcutil.Address) (bool, error) { } // part of the btc.Wallet interface -func (ew *electrumWallet) syncStatus() (*syncStatus, error) { +func (ew *electrumWallet) syncStatus() (*SyncStatus, error) { info, err := ew.wallet.GetInfo(ew.ctx) if err != nil { return nil, err } - return &syncStatus{ + return &SyncStatus{ Target: int32(info.ServerHeight), Height: int32(info.SyncHeight), Syncing: !info.Connected || info.SyncHeight < info.ServerHeight, @@ -1019,8 +1004,8 @@ func (ew *electrumWallet) getWalletTransaction(txHash *chainhash.Hash) (*GetTran txRaw, confs, err := ew.checkWalletTx(txid) if err == nil && confs == 0 { return &GetTransactionResult{ - TxID: txid, - Hex: txRaw, + TxID: txid, + Bytes: txRaw, // Time/TimeReceived? now? needed? }, nil } // else we have to ask a server for the verbose response with block info @@ -1041,7 +1026,7 @@ func (ew *electrumWallet) getWalletTransaction(txHash *chainhash.Hash) (*GetTran TxID: txInfo.TxID, // txHash.String() Time: uint64(txInfo.Time), TimeReceived: uint64(txInfo.Time), - Hex: txRaw, + Bytes: txRaw, }, nil } @@ -1053,7 +1038,7 @@ func (ew *electrumWallet) swapConfirmations(txHash *chainhash.Hash, vout uint32, // Try the wallet first in case this is a wallet transaction (own swap). txRaw, confs, err := ew.checkWalletTx(txid) if err == nil { - msgTx, err := ew.deserializeTx(txRaw) + msgTx, err := msgTxFromBytes(txRaw) if err != nil { return 0, false, err } @@ -1104,7 +1089,7 @@ func (ew *electrumWallet) outPointAddress(ctx context.Context, txid string, vout if err != nil { return "", err } - msgTx, err := ew.deserializeTx(txRaw) + msgTx, err := msgTxFromBytes(txRaw) if err != nil { return "", err } @@ -1158,7 +1143,7 @@ func (ew *electrumWallet) findOutputSpender(ctx context.Context, txHash *chainha io.TxHash, addr, err) continue } - msgTx, err := ew.deserializeTx(txRaw) + msgTx, err := msgTxFromBytes(txRaw) if err != nil { ew.log.Warnf("Unable to decode transaction %v for address %v: %v", io.TxHash, addr, err) diff --git a/client/asset/btc/electrum_test.go b/client/asset/btc/electrum_test.go index 80de8ab3ab..ba658e66ef 100644 --- a/client/asset/btc/electrum_test.go +++ b/client/asset/btc/electrum_test.go @@ -106,7 +106,7 @@ func TestElectrumExchangeWallet(t *testing.T) { // contractHash, _ := hex.DecodeString("2b00eaeab6fc2f23bd96fc20fdc2b73a9e7510e03e8c7c66712c6a3f086c5e99") contractHash := sha256.Sum256(contract) wantSecret, _ := hex.DecodeString("aa8e04bb335da65d362b89ec0630dc76fd02ffaca783ae58cb712a2820f504ce") - foundTxHash, foundVin, secret, err := eew.findRedemption(ctx, newOutPoint(swapTxHash, swapVout), contractHash[:]) + foundTxHash, foundVin, secret, err := eew.findRedemption(ctx, NewOutPoint(swapTxHash, swapVout), contractHash[:]) if err != nil { t.Fatal(err) } @@ -121,7 +121,7 @@ func TestElectrumExchangeWallet(t *testing.T) { } // FindRedemption - redeemCoin, secretBytes, err := eew.FindRedemption(ctx, toCoinID(swapTxHash, swapVout), contract) + redeemCoin, secretBytes, err := eew.FindRedemption(ctx, ToCoinID(swapTxHash, swapVout), contract) if err != nil { t.Fatal(err) } diff --git a/client/asset/btc/redemption_finder.go b/client/asset/btc/redemption_finder.go new file mode 100644 index 0000000000..9277377893 --- /dev/null +++ b/client/asset/btc/redemption_finder.go @@ -0,0 +1,540 @@ +package btc + +import ( + "bytes" + "context" + "errors" + "fmt" + "math" + "sync" + "time" + + "decred.org/dcrdex/dex" + dexbtc "decred.org/dcrdex/dex/networks/btc" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" +) + +// FindRedemptionReq represents a request to find a contract's redemption, +// which is submitted to the RedemptionFinder. +type FindRedemptionReq struct { + outPt OutPoint + blockHash *chainhash.Hash + blockHeight int32 + resultChan chan *FindRedemptionResult + pkScript []byte + contractHash []byte +} + +func (req *FindRedemptionReq) fail(s string, a ...any) { + req.sendResult(&FindRedemptionResult{err: fmt.Errorf(s, a...)}) +} + +func (req *FindRedemptionReq) success(res *FindRedemptionResult) { + req.sendResult(res) +} + +func (req *FindRedemptionReq) sendResult(res *FindRedemptionResult) { + select { + case req.resultChan <- res: + default: + // In-case two separate threads find a result. + } +} + +// FindRedemptionResult models the result of a find redemption attempt. +type FindRedemptionResult struct { + redemptionCoinID dex.Bytes + secret dex.Bytes + err error +} + +// RedemptionFinder searches on-chain for the redemption of a swap transactions. +type RedemptionFinder struct { + mtx sync.RWMutex + log dex.Logger + redemptions map[OutPoint]*FindRedemptionReq + + getWalletTransaction func(txHash *chainhash.Hash) (*GetTransactionResult, error) + getBlockHeight func(*chainhash.Hash) (int32, error) + getBlock func(h chainhash.Hash) (*wire.MsgBlock, error) + getBlockHeader func(blockHash *chainhash.Hash) (hdr *BlockHeader, mainchain bool, err error) + hashTx func(*wire.MsgTx) *chainhash.Hash + deserializeTx func([]byte) (*wire.MsgTx, error) + getBestBlockHeight func() (int32, error) + searchBlockForRedemptions func(ctx context.Context, reqs map[OutPoint]*FindRedemptionReq, blockHash chainhash.Hash) (discovered map[OutPoint]*FindRedemptionResult) + getBlockHash func(blockHeight int64) (*chainhash.Hash, error) + findRedemptionsInMempool func(ctx context.Context, reqs map[OutPoint]*FindRedemptionReq) (discovered map[OutPoint]*FindRedemptionResult) +} + +func NewRedemptionFinder( + log dex.Logger, + getWalletTransaction func(txHash *chainhash.Hash) (*GetTransactionResult, error), + getBlockHeight func(*chainhash.Hash) (int32, error), + getBlock func(h chainhash.Hash) (*wire.MsgBlock, error), + getBlockHeader func(blockHash *chainhash.Hash) (hdr *BlockHeader, mainchain bool, err error), + hashTx func(*wire.MsgTx) *chainhash.Hash, + deserializeTx func([]byte) (*wire.MsgTx, error), + getBestBlockHeight func() (int32, error), + searchBlockForRedemptions func(ctx context.Context, reqs map[OutPoint]*FindRedemptionReq, blockHash chainhash.Hash) (discovered map[OutPoint]*FindRedemptionResult), + getBlockHash func(blockHeight int64) (*chainhash.Hash, error), + findRedemptionsInMempool func(ctx context.Context, reqs map[OutPoint]*FindRedemptionReq) (discovered map[OutPoint]*FindRedemptionResult), +) *RedemptionFinder { + return &RedemptionFinder{ + log: log, + getWalletTransaction: getWalletTransaction, + getBlockHeight: getBlockHeight, + getBlock: getBlock, + getBlockHeader: getBlockHeader, + hashTx: hashTx, + deserializeTx: deserializeTx, + getBestBlockHeight: getBestBlockHeight, + searchBlockForRedemptions: searchBlockForRedemptions, + getBlockHash: getBlockHash, + findRedemptionsInMempool: findRedemptionsInMempool, + redemptions: make(map[OutPoint]*FindRedemptionReq), + } +} + +func (r *RedemptionFinder) FindRedemption(ctx context.Context, coinID dex.Bytes) (redemptionCoin, secret dex.Bytes, err error) { + txHash, vout, err := decodeCoinID(coinID) + if err != nil { + return nil, nil, fmt.Errorf("cannot decode contract coin id: %w", err) + } + + outPt := NewOutPoint(txHash, vout) + + tx, err := r.getWalletTransaction(txHash) + if err != nil { + return nil, nil, fmt.Errorf("error finding wallet transaction: %v", err) + } + + txOut, err := TxOutFromTxBytes(tx.Bytes, vout, r.deserializeTx, r.hashTx) + if err != nil { + return nil, nil, err + } + pkScript := txOut.PkScript + + var blockHash *chainhash.Hash + if tx.BlockHash != "" { + blockHash, err = chainhash.NewHashFromStr(tx.BlockHash) + if err != nil { + return nil, nil, fmt.Errorf("error decoding block hash from string %q: %w", + tx.BlockHash, err) + } + } + + var blockHeight int32 + if blockHash != nil { + r.log.Infof("FindRedemption - Checking block %v for swap %v", blockHash, outPt) + blockHeight, err = r.checkRedemptionBlockDetails(outPt, blockHash, pkScript) + if err != nil { + return nil, nil, fmt.Errorf("checkRedemptionBlockDetails: op %v / block %q: %w", + outPt, tx.BlockHash, err) + } + } + + req := &FindRedemptionReq{ + outPt: outPt, + blockHash: blockHash, + blockHeight: blockHeight, + resultChan: make(chan *FindRedemptionResult, 1), + pkScript: pkScript, + contractHash: dexbtc.ExtractScriptHash(pkScript), + } + + if err := r.queueFindRedemptionRequest(req); err != nil { + return nil, nil, fmt.Errorf("queueFindRedemptionRequest error for redemption %s: %w", outPt, err) + } + + go r.tryRedemptionRequests(ctx, nil, []*FindRedemptionReq{req}) + + var result *FindRedemptionResult + select { + case result = <-req.resultChan: + if result == nil { + err = fmt.Errorf("unexpected nil result for redemption search for %s", outPt) + } + case <-ctx.Done(): + err = fmt.Errorf("context cancelled during search for redemption for %s", outPt) + } + + // If this contract is still tracked, remove from the queue to prevent + // further redemption search attempts for this contract. + r.mtx.Lock() + delete(r.redemptions, outPt) + r.mtx.Unlock() + + // result would be nil if ctx is canceled or the result channel is closed + // without data, which would happen if the redemption search is aborted when + // this ExchangeWallet is shut down. + if result != nil { + return result.redemptionCoinID, result.secret, result.err + } + return nil, nil, err +} + +func (r *RedemptionFinder) checkRedemptionBlockDetails(outPt OutPoint, blockHash *chainhash.Hash, pkScript []byte) (int32, error) { + blockHeight, err := r.getBlockHeight(blockHash) + if err != nil { + return 0, fmt.Errorf("GetBlockHeight for redemption block %s error: %w", blockHash, err) + } + blk, err := r.getBlock(*blockHash) + if err != nil { + return 0, fmt.Errorf("error retrieving redemption block %s: %w", blockHash, err) + } + + var tx *wire.MsgTx +out: + for _, iTx := range blk.Transactions { + if *r.hashTx(iTx) == outPt.TxHash { + tx = iTx + break out + } + } + if tx == nil { + return 0, fmt.Errorf("transaction %s not found in block %s", outPt.TxHash, blockHash) + } + if uint32(len(tx.TxOut)) < outPt.Vout+1 { + return 0, fmt.Errorf("no output %d in redemption transaction %s found in block %s", outPt.Vout, outPt.TxHash, blockHash) + } + if !bytes.Equal(tx.TxOut[outPt.Vout].PkScript, pkScript) { + return 0, fmt.Errorf("pubkey script mismatch for redemption at %s", outPt) + } + + return blockHeight, nil +} + +func (r *RedemptionFinder) queueFindRedemptionRequest(req *FindRedemptionReq) error { + r.mtx.Lock() + defer r.mtx.Unlock() + if _, exists := r.redemptions[req.outPt]; exists { + return fmt.Errorf("duplicate find redemption request for %s", req.outPt) + } + r.redemptions[req.outPt] = req + return nil +} + +// tryRedemptionRequests searches all mainchain blocks with height >= startBlock +// for redemptions. +func (r *RedemptionFinder) tryRedemptionRequests(ctx context.Context, startBlock *chainhash.Hash, reqs []*FindRedemptionReq) { + undiscovered := make(map[OutPoint]*FindRedemptionReq, len(reqs)) + mempoolReqs := make(map[OutPoint]*FindRedemptionReq) + for _, req := range reqs { + // If there is no block hash yet, this request hasn't been mined, and a + // spending tx cannot have been mined. Only check mempool. + if req.blockHash == nil { + mempoolReqs[req.outPt] = req + continue + } + undiscovered[req.outPt] = req + } + + epicFail := func(s string, a ...any) { + errMsg := fmt.Sprintf(s, a...) + for _, req := range reqs { + req.fail(errMsg) + } + } + + // Only search up to the current tip. This does leave two unhandled + // scenarios worth mentioning. + // 1) A new block is mined during our search. In this case, we won't + // see the new block, but tryRedemptionRequests should be called again + // by the block monitoring loop. + // 2) A reorg happens, and this tip becomes orphaned. In this case, the + // worst that can happen is that a shorter chain will replace a longer + // one (extremely rare). Even in that case, we'll just log the error and + // exit the block loop. + tipHeight, err := r.getBestBlockHeight() + if err != nil { + epicFail("tryRedemptionRequests getBestBlockHeight error: %v", err) + return + } + + // If a startBlock is provided at a higher height, use that as the starting + // point. + var iHash *chainhash.Hash + var iHeight int32 + if startBlock != nil { + h, err := r.getBlockHeight(startBlock) + if err != nil { + epicFail("tryRedemptionRequests startBlock getBlockHeight error: %v", err) + return + } + iHeight = h + iHash = startBlock + } else { + iHeight = math.MaxInt32 + for _, req := range undiscovered { + if req.blockHash != nil && req.blockHeight < iHeight { + iHeight = req.blockHeight + iHash = req.blockHash + } + } + } + + // Helper function to check that the request hasn't been located in another + // thread and removed from queue already. + reqStillQueued := func(outPt OutPoint) bool { + _, found := r.redemptions[outPt] + return found + } + + for iHeight <= tipHeight { + validReqs := make(map[OutPoint]*FindRedemptionReq, len(undiscovered)) + r.mtx.RLock() + for outPt, req := range undiscovered { + if iHeight >= req.blockHeight && reqStillQueued(req.outPt) { + validReqs[outPt] = req + } + } + r.mtx.RUnlock() + + if len(validReqs) == 0 { + iHeight++ + continue + } + + r.log.Debugf("tryRedemptionRequests - Checking block %v for redemptions...", iHash) + discovered := r.searchBlockForRedemptions(ctx, validReqs, *iHash) + for outPt, res := range discovered { + req, found := undiscovered[outPt] + if !found { + r.log.Critical("Request not found in undiscovered map. This shouldn't be possible.") + continue + } + redeemTxID, redeemTxInput, _ := decodeCoinID(res.redemptionCoinID) + r.log.Debugf("Found redemption %s:%d", redeemTxID, redeemTxInput) + req.success(res) + delete(undiscovered, outPt) + } + + if len(undiscovered) == 0 { + break + } + + iHeight++ + if iHeight <= tipHeight { + if iHash, err = r.getBlockHash(int64(iHeight)); err != nil { + // This might be due to a reorg. Don't abandon yet, since + // tryRedemptionRequests will be tried again by the block + // monitor loop. + r.log.Warn("error getting block hash for height %d: %v", iHeight, err) + return + } + } + } + + // Check mempool for any remaining undiscovered requests. + for outPt, req := range undiscovered { + mempoolReqs[outPt] = req + } + + if len(mempoolReqs) == 0 { + return + } + + // Do we really want to do this? Mempool could be huge. + searchDur := time.Minute * 5 + searchCtx, cancel := context.WithTimeout(ctx, searchDur) + defer cancel() + for outPt, res := range r.findRedemptionsInMempool(searchCtx, mempoolReqs) { + req, ok := mempoolReqs[outPt] + if !ok { + r.log.Errorf("findRedemptionsInMempool discovered outpoint not found") + continue + } + req.success(res) + } + if err := searchCtx.Err(); err != nil { + if errors.Is(err, context.DeadlineExceeded) { + r.log.Errorf("mempool search exceeded %s time limit", searchDur) + } else { + r.log.Error("mempool search was cancelled") + } + } +} + +// prepareRedemptionRequestsForBlockCheck prepares a copy of the currently +// tracked redemptions, checking for missing block data along the way. +func (r *RedemptionFinder) prepareRedemptionRequestsForBlockCheck() []*FindRedemptionReq { + // Search for contract redemption in new blocks if there + // are contracts pending redemption. + r.mtx.Lock() + defer r.mtx.Unlock() + reqs := make([]*FindRedemptionReq, 0, len(r.redemptions)) + for _, req := range r.redemptions { + // If the request doesn't have a block hash yet, check if we can get one + // now. + if req.blockHash == nil { + r.trySetRedemptionRequestBlock(req) + } + reqs = append(reqs, req) + } + return reqs +} + +// ReportNewTip sets the currentTip. The tipChange callback function is invoked +// and a goroutine is started to check if any contracts in the +// findRedemptionQueue are redeemed in the new blocks. +func (r *RedemptionFinder) ReportNewTip(ctx context.Context, prevTip, newTip *BlockVector) { + reqs := r.prepareRedemptionRequestsForBlockCheck() + // Redemption search would be compromised if the starting point cannot + // be determined, as searching just the new tip might result in blocks + // being omitted from the search operation. If that happens, cancel all + // find redemption requests in queue. + notifyFatalFindRedemptionError := func(s string, a ...any) { + for _, req := range reqs { + req.fail("tipChange handler - "+s, a...) + } + } + + var startPoint *BlockVector + // Check if the previous tip is still part of the mainchain (prevTip confs >= 0). + // Redemption search would typically resume from prevTipHeight + 1 unless the + // previous tip was re-orged out of the mainchain, in which case redemption + // search will resume from the mainchain ancestor of the previous tip. + prevTipHeader, isMainchain, err := r.getBlockHeader(&prevTip.Hash) + switch { + case err != nil: + // Redemption search cannot continue reliably without knowing if there + // was a reorg, cancel all find redemption requests in queue. + notifyFatalFindRedemptionError("getBlockHeader error for prev tip hash %s: %w", + prevTip.Hash, err) + return + + case !isMainchain: + // The previous tip is no longer part of the mainchain. Crawl blocks + // backwards until finding a mainchain block. Start with the block + // that is the immediate ancestor to the previous tip. + ancestorBlockHash, err := chainhash.NewHashFromStr(prevTipHeader.PreviousBlockHash) + if err != nil { + notifyFatalFindRedemptionError("hash decode error for block %s: %w", prevTipHeader.PreviousBlockHash, err) + return + } + for { + aBlock, isMainchain, err := r.getBlockHeader(ancestorBlockHash) + if err != nil { + notifyFatalFindRedemptionError("getBlockHeader error for block %s: %w", ancestorBlockHash, err) + return + } + if isMainchain { + // Found the mainchain ancestor of previous tip. + startPoint = &BlockVector{Height: aBlock.Height, Hash: *ancestorBlockHash} + r.log.Debugf("reorg detected from height %d to %d", aBlock.Height, newTip.Height) + break + } + if aBlock.Height == 0 { + // Crawled back to genesis block without finding a mainchain ancestor + // for the previous tip. Should never happen! + notifyFatalFindRedemptionError("no mainchain ancestor for orphaned block %s", prevTipHeader.Hash) + return + } + ancestorBlockHash, err = chainhash.NewHashFromStr(aBlock.PreviousBlockHash) + if err != nil { + notifyFatalFindRedemptionError("hash decode error for block %s: %w", prevTipHeader.PreviousBlockHash, err) + return + } + } + + case newTip.Height-prevTipHeader.Height > 1: + // 2 or more blocks mined since last tip, start at prevTip height + 1. + afterPrivTip := prevTipHeader.Height + 1 + hashAfterPrevTip, err := r.getBlockHash(afterPrivTip) + if err != nil { + notifyFatalFindRedemptionError("getBlockHash error for height %d: %w", afterPrivTip, err) + return + } + startPoint = &BlockVector{Hash: *hashAfterPrevTip, Height: afterPrivTip} + + default: + // Just 1 new block since last tip report, search the lone block. + startPoint = newTip + } + + if len(reqs) > 0 { + go r.tryRedemptionRequests(ctx, &startPoint.Hash, reqs) + } +} + +// trySetRedemptionRequestBlock should be called with findRedemptionMtx Lock'ed. +func (r *RedemptionFinder) trySetRedemptionRequestBlock(req *FindRedemptionReq) { + tx, err := r.getWalletTransaction(&req.outPt.TxHash) + if err != nil { + r.log.Errorf("getWalletTransaction error for FindRedemption transaction: %v", err) + return + } + + if tx.BlockHash == "" { + return + } + blockHash, err := chainhash.NewHashFromStr(tx.BlockHash) + if err != nil { + r.log.Errorf("error decoding block hash %q: %v", tx.BlockHash, err) + return + } + + blockHeight, err := r.checkRedemptionBlockDetails(req.outPt, blockHash, req.pkScript) + if err != nil { + r.log.Error(err) + return + } + // Don't update the FindRedemptionReq, since the findRedemptionMtx only + // protects the map. + req = &FindRedemptionReq{ + outPt: req.outPt, + blockHash: blockHash, + blockHeight: blockHeight, + resultChan: req.resultChan, + pkScript: req.pkScript, + contractHash: req.contractHash, + } + r.redemptions[req.outPt] = req +} + +func (r *RedemptionFinder) CancelRedemptionSearches() { + // Close all open channels for contract redemption searches + // to prevent leakages and ensure goroutines that are started + // to wait on these channels end gracefully. + r.mtx.Lock() + for contractOutpoint, req := range r.redemptions { + req.fail("shutting down") + delete(r.redemptions, contractOutpoint) + } + r.mtx.Unlock() +} + +func findRedemptionsInTxWithHasher(ctx context.Context, segwit bool, reqs map[OutPoint]*FindRedemptionReq, msgTx *wire.MsgTx, + chainParams *chaincfg.Params, hashTx func(*wire.MsgTx) *chainhash.Hash) (discovered map[OutPoint]*FindRedemptionResult) { + + discovered = make(map[OutPoint]*FindRedemptionResult, len(reqs)) + + for vin, txIn := range msgTx.TxIn { + if ctx.Err() != nil { + return discovered + } + poHash, poVout := txIn.PreviousOutPoint.Hash, txIn.PreviousOutPoint.Index + for outPt, req := range reqs { + if discovered[outPt] != nil { + continue + } + if outPt.TxHash == poHash && outPt.Vout == poVout { + // Match! + txHash := hashTx(msgTx) + secret, err := dexbtc.FindKeyPush(txIn.Witness, txIn.SignatureScript, req.contractHash[:], segwit, chainParams) + if err != nil { + req.fail("no secret extracted from redemption input %s:%d for swap output %s: %v", + txHash, vin, outPt, err) + continue + } + discovered[outPt] = &FindRedemptionResult{ + redemptionCoinID: ToCoinID(txHash, uint32(vin)), + secret: secret, + } + } + } + } + return +} diff --git a/client/asset/btc/rpcclient.go b/client/asset/btc/rpcclient.go index fc1ab741b3..510b1ab659 100644 --- a/client/asset/btc/rpcclient.go +++ b/client/asset/btc/rpcclient.go @@ -62,11 +62,11 @@ const ( methodFundRawTransaction = "fundrawtransaction" ) -// isTxNotFoundErr will return true if the error indicates that the requested +// IsTxNotFoundErr will return true if the error indicates that the requested // transaction is not known. The error must be dcrjson.RPCError with a numeric // code equal to btcjson.ErrRPCNoTxInfo. WARNING: This is specific to errors // from an RPC to a bitcoind (or clone) using dcrd's rpcclient! -func isTxNotFoundErr(err error) bool { +func IsTxNotFoundErr(err error) bool { // We are using dcrd's client with Bitcoin Core, so errors will be of type // dcrjson.RPCError, but numeric codes should come from btcjson. const errRPCNoTxInfo = int(btcjson.ErrRPCNoTxInfo) @@ -97,30 +97,31 @@ type RawRequester interface { type anylist []any type rpcCore struct { - rpcConfig *RPCConfig - cloneParams *BTCCloneCFG - requesterV atomic.Value // RawRequester - segwit bool - decodeAddr dexbtc.AddressDecoder - stringAddr dexbtc.AddressStringer - legacyRawSends bool - minNetworkVersion uint64 - log dex.Logger - chainParams *chaincfg.Params - omitAddressType bool - legacySignTx bool - booleanGetBlock bool - unlockSpends bool - deserializeTx func([]byte) (*wire.MsgTx, error) - serializeTx func(*wire.MsgTx) ([]byte, error) + rpcConfig *RPCConfig + cloneParams *BTCCloneCFG + requesterV atomic.Value // RawRequester + segwit bool + decodeAddr dexbtc.AddressDecoder + stringAddr dexbtc.AddressStringer + legacyRawSends bool + minNetworkVersion uint64 + log dex.Logger + chainParams *chaincfg.Params + omitAddressType bool + legacySignTx bool + booleanGetBlock bool + unlockSpends bool + + deserializeTx func([]byte) (*wire.MsgTx, error) + serializeTx func(*wire.MsgTx) ([]byte, error) + hashTx func(*wire.MsgTx) *chainhash.Hash + numericGetRawTxRPC bool + manualMedianTime bool + addrFunc func() (btcutil.Address, error) + deserializeBlock func([]byte) (*wire.MsgBlock, error) - hashTx func(*wire.MsgTx) *chainhash.Hash - numericGetRawTxRPC bool legacyValidateAddressRPC bool - manualMedianTime bool omitRPCOptionsArg bool - addrFunc func() (btcutil.Address, error) - connectFunc func() error privKeyFunc func(addr string) (*btcec.PrivateKey, error) } @@ -143,8 +144,8 @@ func newRPCClient(cfg *rpcCore) *rpcClient { return &rpcClient{rpcCore: cfg} } -// chainOK is for screening the chain field of the getblockchaininfo result. -func chainOK(net dex.Network, str string) bool { +// ChainOK is for screening the chain field of the getblockchaininfo result. +func ChainOK(net dex.Network, str string) bool { var chainStr string switch net { case dex.Mainnet: @@ -176,7 +177,7 @@ func (wc *rpcClient) connect(ctx context.Context, _ *sync.WaitGroup) error { if err != nil { return fmt.Errorf("getblockchaininfo error: %w", err) } - if !chainOK(wc.cloneParams.Network, chainInfo.Chain) { + if !ChainOK(wc.cloneParams.Network, chainInfo.Chain) { return errors.New("wrong net") } wiRes, err := wc.GetWalletInfo() @@ -191,11 +192,6 @@ func (wc *rpcClient) connect(ctx context.Context, _ *sync.WaitGroup) error { } wc.log.Debug("Using a descriptor wallet.") } - if wc.connectFunc != nil { - if err := wc.connectFunc(); err != nil { - return err - } - } return nil } @@ -227,10 +223,6 @@ func (wc *rpcClient) reconfigure(cfg *asset.WalletConfig, currentAddress string) // If the RPC configuration has changed, try to update the client. oldCfg := wc.rpcConfig if *newCfg != *oldCfg { - if wc.connectFunc != nil { - return true, nil // this asset needs a new rpcClient, then connect - } - cl, err := newRPCConnection(parsedCfg, wc.cloneParams.SingularWallet) if err != nil { return false, fmt.Errorf("error creating RPC client with new credentials: %v", err) @@ -244,7 +236,7 @@ func (wc *rpcClient) reconfigure(cfg *asset.WalletConfig, currentAddress string) method = methodValidateAddress } ai := new(GetAddressInfoResult) - if err := call(wc.ctx, cl, method, anylist{currentAddress}, ai); err != nil { + if err := Call(wc.ctx, cl, method, anylist{currentAddress}, ai); err != nil { return false, fmt.Errorf("error getting address info with new RPC client: %w", err) } else if !ai.IsMine { // If the wallet is in active use, check the supplied address. @@ -256,11 +248,11 @@ func (wc *rpcClient) reconfigure(cfg *asset.WalletConfig, currentAddress string) return true, nil } // else same wallet, skip full reconnect - chainInfo := new(getBlockchainInfoResult) - if err := call(wc.ctx, cl, methodGetBlockchainInfo, nil, chainInfo); err != nil { + chainInfo := new(GetBlockchainInfoResult) + if err := Call(wc.ctx, cl, methodGetBlockchainInfo, nil, chainInfo); err != nil { return false, fmt.Errorf("%s: %w", methodGetBlockchainInfo, err) } - if !chainOK(wc.cloneParams.Network, chainInfo.Chain) { + if !ChainOK(wc.cloneParams.Network, chainInfo.Chain) { return false, errors.New("wrong net") } @@ -281,7 +273,7 @@ func (wc *rpcClient) RawRequest(ctx context.Context, method string, params []jso // given parameters. func estimateSmartFee(ctx context.Context, rr RawRequester, confTarget uint64, mode *btcjson.EstimateSmartFeeMode) (*btcjson.EstimateSmartFeeResult, error) { res := new(btcjson.EstimateSmartFeeResult) - return res, call(ctx, rr, methodEstimateSmartFee, anylist{confTarget, mode}, res) + return res, Call(ctx, rr, methodEstimateSmartFee, anylist{confTarget, mode}, res) } // SendRawTransactionLegacy broadcasts the transaction with an additional legacy @@ -323,12 +315,12 @@ func (wc *rpcClient) sendRawTransaction(tx *wire.MsgTx) (txHash *chainhash.Hash, return txHash, nil } - // TODO: lockUnspent should really just take a []*outPoint, since it doesn't + // TODO: lockUnspent should really just take a []*OutPoint, since it doesn't // need the value. - ops := make([]*output, 0, len(tx.TxIn)) + ops := make([]*Output, 0, len(tx.TxIn)) for _, txIn := range tx.TxIn { prevOut := &txIn.PreviousOutPoint - ops = append(ops, &output{pt: newOutPoint(&prevOut.Hash, prevOut.Index)}) + ops = append(ops, &Output{Pt: NewOutPoint(&prevOut.Hash, prevOut.Index)}) } if err := wc.lockUnspent(true, ops); err != nil { wc.log.Warnf("error unlocking spent outputs: %v", err) @@ -400,7 +392,7 @@ func (wc *rpcClient) getBestBlockHash() (*chainhash.Hash, error) { } // getBestBlockHeight returns the height of the top mainchain block. -func (wc *rpcClient) getBestBlockHeader() (*blockHeader, error) { +func (wc *rpcClient) getBestBlockHeader() (*BlockHeader, error) { tipHash, err := wc.getBestBlockHash() if err != nil { return nil, err @@ -438,7 +430,17 @@ func (wc *rpcClient) medianTime() (stamp time.Time, err error) { return } if wc.manualMedianTime { - return calcMedianTime(wc, tipHash) + return CalcMedianTime(func(blockHash *chainhash.Hash) (stamp time.Time, prevHash *chainhash.Hash, err error) { + hdr, _, err := wc.getBlockHeader(blockHash) + if err != nil { + return + } + prevHash, err = chainhash.NewHashFromStr(hdr.PreviousBlockHash) + if err != nil { + return + } + return time.Unix(hdr.Time, 0), prevHash, nil + }, tipHash) } hdr, err := wc.getRPCBlockHeader(tipHash) if err != nil { @@ -498,7 +500,7 @@ func (wc *rpcClient) listUnspent() ([]*ListUnspentResult, error) { // lockUnspent locks and unlocks outputs for spending. An output that is part of // an order, but not yet spent, should be locked until spent or until the order // is canceled or fails. -func (wc *rpcClient) lockUnspent(unlock bool, ops []*output) error { +func (wc *rpcClient) lockUnspent(unlock bool, ops []*Output) error { var rpcops []*RPCOutpoint // To clear all, this must be nil->null, not empty slice. for _, op := range ops { rpcops = append(rpcops, &RPCOutpoint{ @@ -564,8 +566,6 @@ func (wc *rpcClient) changeAddress() (btcutil.Address, error) { var addrStr string var err error switch { - case wc.addrFunc != nil: - return wc.addrFunc() case wc.omitAddressType: err = wc.call(methodChangeAddress, nil, &addrStr) case wc.segwit: @@ -580,9 +580,6 @@ func (wc *rpcClient) changeAddress() (btcutil.Address, error) { } func (wc *rpcClient) externalAddress() (btcutil.Address, error) { - if wc.addrFunc != nil { - return wc.addrFunc() - } if wc.segwit { return wc.address("bech32") } @@ -817,7 +814,7 @@ func (wc *rpcClient) getWalletTransaction(txHash *chainhash.Hash) (*GetTransacti tx := new(GetTransactionResult) err := wc.call(methodGetTransaction, anylist{txHash.String()}, tx) if err != nil { - if isTxNotFoundErr(err) { + if IsTxNotFoundErr(err) { return nil, asset.CoinNotFoundError } return nil, err @@ -862,31 +859,20 @@ func (wc *rpcClient) estimateSendTxFee(tx *wire.MsgTx, feeRate uint64, subtract // 1e-5 = 1e-8 for satoshis * 1000 for kB. feeRateOption := float64(feeRate) / 1e5 - if wc.omitRPCOptionsArg { - var success bool - err := wc.call(methodSetTxFee, anylist{feeRateOption}, &success) - if err != nil { - return 0, fmt.Errorf("error setting transaction fee: %w", err) - } - if !success { - return 0, fmt.Errorf("failed to set transaction fee") - } - } else { - options := &btcjson.FundRawTransactionOpts{ - FeeRate: &feeRateOption, - } - if !wc.omitAddressType { - if wc.segwit { - options.ChangeType = &btcjson.ChangeTypeBech32 - } else { - options.ChangeType = &btcjson.ChangeTypeLegacy - } - } - if subtract { - options.SubtractFeeFromOutputs = []int{0} + options := &btcjson.FundRawTransactionOpts{ + FeeRate: &feeRateOption, + } + if !wc.omitAddressType { + if wc.segwit { + options.ChangeType = &btcjson.ChangeTypeBech32 + } else { + options.ChangeType = &btcjson.ChangeTypeLegacy } - args = append(args, options) } + if subtract { + options.SubtractFeeFromOutputs = []int{0} + } + args = append(args, options) var res struct { TxBytes dex.Bytes `json:"hex"` @@ -948,23 +934,16 @@ func (wc *rpcClient) ownsAddress(addr btcutil.Address) (bool, error) { return ai.IsMine, nil } -// syncStatus is the current synchronization state of the node. -type syncStatus struct { - Target int32 `json:"target"` - Height int32 `json:"height"` - Syncing bool `json:"syncing"` -} - // syncStatus is information about the blockchain sync status. -func (wc *rpcClient) syncStatus() (*syncStatus, error) { +func (wc *rpcClient) syncStatus() (*SyncStatus, error) { chainInfo, err := wc.getBlockchainInfo() if err != nil { return nil, fmt.Errorf("getblockchaininfo error: %w", err) } - return &syncStatus{ + return &SyncStatus{ Target: int32(chainInfo.Headers), Height: int32(chainInfo.Blocks), - Syncing: chainInfo.syncing(), + Syncing: chainInfo.Syncing(), }, nil } @@ -980,7 +959,7 @@ func (wc *rpcClient) swapConfirmations(txHash *chainhash.Hash, vout uint32, _ [] // Check wallet transactions. tx, err := wc.getWalletTransaction(txHash) if err != nil { - if isTxNotFoundErr(err) { + if IsTxNotFoundErr(err) { return 0, false, asset.CoinNotFoundError } return 0, false, err @@ -988,15 +967,9 @@ func (wc *rpcClient) swapConfirmations(txHash *chainhash.Hash, vout uint32, _ [] return uint32(tx.Confirmations), true, nil } -// rpcBlockHeader adds a MedianTime field to blockHeader. -type rpcBlockHeader struct { - blockHeader - MedianTime int64 `json:"mediantime"` -} - // getBlockHeader gets the *rpcBlockHeader for the specified block hash. -func (wc *rpcClient) getRPCBlockHeader(blockHash *chainhash.Hash) (*rpcBlockHeader, error) { - blkHeader := new(rpcBlockHeader) +func (wc *rpcClient) getRPCBlockHeader(blockHash *chainhash.Hash) (*BlockHeader, error) { + blkHeader := new(BlockHeader) err := wc.call(methodGetBlockHeader, anylist{blockHash.String(), true}, blkHeader) if err != nil { @@ -1009,14 +982,14 @@ func (wc *rpcClient) getRPCBlockHeader(blockHash *chainhash.Hash) (*rpcBlockHead // getBlockHeader gets the *blockHeader for the specified block hash. It also // returns a bool value to indicate whether this block is a part of main chain. // For orphaned blocks header.Confirmations is negative (typically -1). -func (wc *rpcClient) getBlockHeader(blockHash *chainhash.Hash) (header *blockHeader, mainchain bool, err error) { +func (wc *rpcClient) getBlockHeader(blockHash *chainhash.Hash) (header *BlockHeader, mainchain bool, err error) { hdr, err := wc.getRPCBlockHeader(blockHash) if err != nil { return nil, false, err } // RPC wallet must return negative confirmations number for orphaned blocks. mainchain = hdr.Confirmations >= 0 - return &hdr.blockHeader, mainchain, nil + return hdr, mainchain, nil } // getBlockHeight gets the mainchain height for the specified block. Returns @@ -1044,8 +1017,8 @@ func (wc *rpcClient) peerCount() (uint32, error) { } // getBlockchainInfo sends the getblockchaininfo request and returns the result. -func (wc *rpcClient) getBlockchainInfo() (*getBlockchainInfoResult, error) { - chainInfo := new(getBlockchainInfoResult) +func (wc *rpcClient) getBlockchainInfo() (*GetBlockchainInfoResult, error) { + chainInfo := new(GetBlockchainInfoResult) err := wc.call(methodGetBlockchainInfo, nil, chainInfo) if err != nil { return nil, err @@ -1072,11 +1045,25 @@ func (wc *rpcClient) getVersion() (uint64, uint64, error) { // findRedemptionsInMempool attempts to find spending info for the specified // contracts by searching every input of all txs in the mempool. -func (wc *rpcClient) findRedemptionsInMempool(ctx context.Context, reqs map[outPoint]*findRedemptionReq) (discovered map[outPoint]*findRedemptionResult) { +func (wc *rpcClient) findRedemptionsInMempool(ctx context.Context, reqs map[OutPoint]*FindRedemptionReq) (discovered map[OutPoint]*FindRedemptionResult) { + return FindRedemptionsInMempool(ctx, wc.log, reqs, wc.GetRawMempool, wc.GetRawTransaction, wc.segwit, wc.hashTx, wc.chainParams) +} + +func FindRedemptionsInMempool( + ctx context.Context, + log dex.Logger, + reqs map[OutPoint]*FindRedemptionReq, + getMempool func() ([]*chainhash.Hash, error), + getTx func(txHash *chainhash.Hash) (*wire.MsgTx, error), + segwit bool, + hashTx func(*wire.MsgTx) *chainhash.Hash, + chainParams *chaincfg.Params, + +) (discovered map[OutPoint]*FindRedemptionResult) { contractsCount := len(reqs) - wc.log.Debugf("finding redemptions for %d contracts in mempool", contractsCount) + log.Debugf("finding redemptions for %d contracts in mempool", contractsCount) - discovered = make(map[outPoint]*findRedemptionResult, len(reqs)) + discovered = make(map[OutPoint]*FindRedemptionResult, len(reqs)) var totalFound, totalCanceled int logAbandon := func(reason string) { @@ -1084,14 +1071,14 @@ func (wc *rpcClient) findRedemptionsInMempool(ctx context.Context, reqs map[outP // as they could be subsequently redeemed in some mined tx(s), // which would be captured when a new tip is reported. if totalFound+totalCanceled > 0 { - wc.log.Debugf("%d redemptions found, %d canceled out of %d contracts in mempool", + log.Debugf("%d redemptions found, %d canceled out of %d contracts in mempool", totalFound, totalCanceled, contractsCount) } - wc.log.Errorf("abandoning mempool redemption search for %d contracts because of %s", + log.Errorf("abandoning mempool redemption search for %d contracts because of %s", contractsCount-totalFound-totalCanceled, reason) } - mempoolTxs, err := wc.GetRawMempool() + mempoolTxs, err := getMempool() if err != nil { logAbandon(fmt.Sprintf("error retrieving transactions: %v", err)) return @@ -1101,12 +1088,12 @@ func (wc *rpcClient) findRedemptionsInMempool(ctx context.Context, reqs map[outP if ctx.Err() != nil { return nil } - tx, err := wc.GetRawTransaction(txHash) + tx, err := getTx(txHash) if err != nil { logAbandon(fmt.Sprintf("getrawtransaction error for tx hash %v: %v", txHash, err)) return } - newlyDiscovered := findRedemptionsInTxWithHasher(ctx, wc.segwit, reqs, tx, wc.chainParams, wc.hashTx) + newlyDiscovered := findRedemptionsInTxWithHasher(ctx, segwit, reqs, tx, chainParams, hashTx) for outPt, res := range newlyDiscovered { discovered[outPt] = res } @@ -1117,17 +1104,28 @@ func (wc *rpcClient) findRedemptionsInMempool(ctx context.Context, reqs map[outP // searchBlockForRedemptions attempts to find spending info for the specified // contracts by searching every input of all txs in the provided block range. -func (wc *rpcClient) searchBlockForRedemptions(ctx context.Context, reqs map[outPoint]*findRedemptionReq, blockHash chainhash.Hash) (discovered map[outPoint]*findRedemptionResult) { +func (wc *rpcClient) searchBlockForRedemptions(ctx context.Context, reqs map[OutPoint]*FindRedemptionReq, blockHash chainhash.Hash) (discovered map[OutPoint]*FindRedemptionResult) { msgBlock, err := wc.getBlock(blockHash) if err != nil { wc.log.Errorf("RPC GetBlock error: %v", err) return } + return SearchBlockForRedemptions(ctx, reqs, msgBlock, wc.segwit, wc.hashTx, wc.chainParams) +} + +func SearchBlockForRedemptions( + ctx context.Context, + reqs map[OutPoint]*FindRedemptionReq, + msgBlock *wire.MsgBlock, + segwit bool, + hashTx func(*wire.MsgTx) *chainhash.Hash, + chainParams *chaincfg.Params, +) (discovered map[OutPoint]*FindRedemptionResult) { - discovered = make(map[outPoint]*findRedemptionResult, len(reqs)) + discovered = make(map[OutPoint]*FindRedemptionResult, len(reqs)) for _, msgTx := range msgBlock.Transactions { - newlyDiscovered := findRedemptionsInTxWithHasher(ctx, wc.segwit, reqs, msgTx, wc.chainParams, wc.hashTx) + newlyDiscovered := findRedemptionsInTxWithHasher(ctx, segwit, reqs, msgTx, chainParams, hashTx) for outPt, res := range newlyDiscovered { discovered[outPt] = res } @@ -1139,10 +1137,10 @@ func (wc *rpcClient) searchBlockForRedemptions(ctx context.Context, reqs map[out // server via (*rpcclient.Client).RawRequest. If thing is non-nil, the result // will be marshaled into thing. func (wc *rpcClient) call(method string, args anylist, thing any) error { - return call(wc.ctx, wc.requester(), method, args, thing) + return Call(wc.ctx, wc.requester(), method, args, thing) } -func call(ctx context.Context, r RawRequester, method string, args anylist, thing any) error { +func Call(ctx context.Context, r RawRequester, method string, args anylist, thing any) error { params := make([]json.RawMessage, 0, len(args)) for i := range args { p, err := json.Marshal(args[i]) diff --git a/client/asset/btc/spv_test.go b/client/asset/btc/spv_test.go index 1319dbf40b..7ee690b320 100644 --- a/client/asset/btc/spv_test.go +++ b/client/asset/btc/spv_test.go @@ -237,7 +237,7 @@ func (c *tBtcWallet) WalletTransaction(txHash *chainhash.Hash) (*wtxmgr.TxDetail return nil, WalletTransactionNotFound } - tx, _ := msgTxFromBytes(txData.Hex) + tx, _ := msgTxFromBytes(txData.Bytes) blockHash, _ := chainhash.NewHashFromStr(txData.BlockHash) blk := c.getBlock(txData.BlockHash) @@ -263,7 +263,7 @@ func (c *tBtcWallet) WalletTransaction(txHash *chainhash.Hash) (*wtxmgr.TxDetail // in order to get accurate Fees and Amounts when calling GetWalletTransaction // when using the SPV wallet. if gtr := c.getTransactionMap[in.PreviousOutPoint.Hash.String()]; gtr != nil { - tx, _ := msgTxFromBytes(gtr.Hex) + tx, _ := msgTxFromBytes(gtr.Bytes) debitAmount = tx.TxOut[in.PreviousOutPoint.Index].Value } @@ -467,7 +467,7 @@ func TestSwapConfirmations(t *testing.T) { swapTx := makeRawTx([]dex.Bytes{pkScript}, []*wire.TxIn{dummyInput()}) swapTxHash := swapTx.TxHash() const vout = 0 - swapOutPt := newOutPoint(&swapTxHash, vout) + swapOutPt := NewOutPoint(&swapTxHash, vout) swapBlockHash, _ := node.addRawTx(swapHeight, swapTx) spendTx := dummyTx() @@ -511,7 +511,7 @@ func TestSwapConfirmations(t *testing.T) { "any": { BlockHash: swapBlockHash.String(), BlockIndex: swapHeight, - Hex: txB, + Bytes: txB, }} node.walletTxSpent = true checkSuccess("confirmations", swapConfs, true) @@ -622,7 +622,7 @@ func TestGetTxOut(t *testing.T) { const tipHeight = 20 tx := makeRawTx([]dex.Bytes{pkScript}, []*wire.TxIn{dummyInput()}) txHash := tx.TxHash() - outPt := newOutPoint(&txHash, vout) + outPt := NewOutPoint(&txHash, vout) blockHash, _ := node.addRawTx(blockHeight, tx) txB, _ := serializeMsgTx(tx) node.addRawTx(tipHeight, dummyTx()) @@ -646,7 +646,7 @@ func TestGetTxOut(t *testing.T) { node.getTransactionErr = nil node.getTransactionMap = map[string]*GetTransactionResult{"any": { BlockHash: blockHash.String(), - Hex: txB, + Bytes: txB, }} _, confs, err := spv.getTxOut(&txHash, vout, pkScript, generateTestBlockTime(blockHeight)) @@ -750,15 +750,15 @@ func TestTryBlocksWithNotifier(t *testing.T) { // (*spvWallet).getBestBlockHeader -> (*spvWallet).getBlockHeader -> (*testData).getBlock [verboseBlocks] var tipHeight int64 - addBlock := func() *block { // update the mainchain and verboseBlocks testData fields + addBlock := func() *BlockVector { // update the mainchain and verboseBlocks testData fields tipHeight++ h, _ := node.addRawTx(tipHeight, dummyTx()) - return &block{tipHeight, *h} + return &BlockVector{tipHeight, *h} } // Start with no blocks so that we're not synced. node.blockchainMtx.Lock() - node.getBlockchainInfo = &getBlockchainInfoResult{ + node.getBlockchainInfo = &GetBlockchainInfoResult{ Headers: 3, Blocks: 0, } @@ -792,7 +792,7 @@ func TestTryBlocksWithNotifier(t *testing.T) { // allowance. addBlock() node.blockchainMtx.Lock() - node.getBlockchainInfo = &getBlockchainInfoResult{ + node.getBlockchainInfo = &GetBlockchainInfoResult{ Headers: tipHeight, Blocks: tipHeight, } diff --git a/client/asset/btc/spv_wrapper.go b/client/asset/btc/spv_wrapper.go index b480e21b06..86e0f3ca9b 100644 --- a/client/asset/btc/spv_wrapper.go +++ b/client/asset/btc/spv_wrapper.go @@ -252,11 +252,11 @@ type spvWallet struct { txBlocks map[chainhash.Hash]*hashEntry checkpointMtx sync.Mutex - checkpoints map[outPoint]*scanCheckpoint + checkpoints map[OutPoint]*scanCheckpoint log dex.Logger - tipChan chan *block + tipChan chan *BlockVector syncTarget int32 lastPrenatalHeight int32 } @@ -289,7 +289,7 @@ func (w *spvWallet) reconfigure(cfg *asset.WalletConfig, currentAddress string) // tipFeed satisfies the tipNotifier interface, signaling that *spvWallet // will take precedence in sending block notifications. -func (w *spvWallet) tipFeed() <-chan *block { +func (w *spvWallet) tipFeed() <-chan *BlockVector { return w.tipChan } @@ -324,7 +324,7 @@ func (w *spvWallet) cacheCheckpoint(txHash *chainhash.Hash, vout uint32, res *fi } w.checkpointMtx.Lock() defer w.checkpointMtx.Unlock() - w.checkpoints[newOutPoint(txHash, vout)] = &scanCheckpoint{ + w.checkpoints[NewOutPoint(txHash, vout)] = &scanCheckpoint{ res: res, lastAccess: time.Now(), } @@ -334,7 +334,7 @@ func (w *spvWallet) cacheCheckpoint(txHash *chainhash.Hash, vout uint32, res *fi func (w *spvWallet) unvalidatedCheckpoint(txHash *chainhash.Hash, vout uint32) *filterScanResult { w.checkpointMtx.Lock() defer w.checkpointMtx.Unlock() - check, found := w.checkpoints[newOutPoint(txHash, vout)] + check, found := w.checkpoints[NewOutPoint(txHash, vout)] if !found { return nil } @@ -354,9 +354,9 @@ func (w *spvWallet) checkpoint(txHash *chainhash.Hash, vout uint32) *filterScanR if !w.blockIsMainchain(&res.checkpoint, -1) { // reorg detected, abandon the checkpoint. w.log.Debugf("abandoning checkpoint %s because checkpoint block %q is orphaned", - newOutPoint(txHash, vout), res.checkpoint) + NewOutPoint(txHash, vout), res.checkpoint) w.checkpointMtx.Lock() - delete(w.checkpoints, newOutPoint(txHash, vout)) + delete(w.checkpoints, NewOutPoint(txHash, vout)) w.checkpointMtx.Unlock() return nil } @@ -460,7 +460,7 @@ func (w *spvWallet) getChainStamp(blockHash *chainhash.Hash) (stamp time.Time, p // medianTime is the median time for the current best block. func (w *spvWallet) medianTime() (time.Time, error) { blk := w.wallet.SyncedTo() - return calcMedianTime(w, &blk.Hash) + return CalcMedianTime(w.getChainStamp, &blk.Hash) } // getChainHeight is only for confirmations since it does not reflect the wallet @@ -505,7 +505,7 @@ func (w *spvWallet) syncHeight() int32 { return maxHeight } -// syncStatus is information about the wallet's sync status. +// SyncStatus is information about the wallet's sync status. // // The neutrino wallet has a two stage sync: // 1. chain service fetching block headers and filter headers @@ -515,7 +515,7 @@ func (w *spvWallet) syncHeight() int32 { // the chain service sync stage that comes before the wallet has performed any // address recovery/rescan, and switch to the wallet's sync height when it // reports non-zero height. -func (w *spvWallet) syncStatus() (*syncStatus, error) { +func (w *spvWallet) syncStatus() (*SyncStatus, error) { // Chain service headers (block and filter) height. chainBlk, err := w.cl.BestBlock() if err != nil { @@ -532,7 +532,7 @@ func (w *spvWallet) syncStatus() (*syncStatus, error) { } var synced bool - var blk *block + var blk *BlockVector // Wallet address manager sync height. if chainBlk.Timestamp.After(w.wallet.Birthday()) { // After the wallet's birthday, the wallet address manager should begin @@ -543,23 +543,23 @@ func (w *spvWallet) syncStatus() (*syncStatus, error) { if walletBlock.Height == 0 { // The wallet is about to start its sync, so just return the last // chain service height prior to wallet birthday until it begins. - return &syncStatus{ + return &SyncStatus{ Target: target, Height: atomic.LoadInt32(&w.lastPrenatalHeight), Syncing: true, }, nil } - blk = &block{ - height: int64(walletBlock.Height), - hash: walletBlock.Hash, + blk = &BlockVector{ + Height: int64(walletBlock.Height), + Hash: walletBlock.Hash, } currentHeight = walletBlock.Height synced = currentHeight >= target // maybe && w.wallet.ChainSynced() } else { // Chain service still syncing. - blk = &block{ - height: int64(currentHeight), - hash: chainBlk.Hash, + blk = &BlockVector{ + Height: int64(currentHeight), + Hash: chainBlk.Hash, } atomic.StoreInt32(&w.lastPrenatalHeight, currentHeight) } @@ -568,9 +568,9 @@ func (w *spvWallet) syncStatus() (*syncStatus, error) { w.tipChan <- blk } - return &syncStatus{ + return &SyncStatus{ Target: target, - Height: int32(blk.height), + Height: int32(blk.Height), Syncing: !synced, }, nil } @@ -689,13 +689,13 @@ func (w *spvWallet) listUnspent() ([]*ListUnspentResult, error) { // lockUnspent locks and unlocks outputs for spending. An output that is part of // an order, but not yet spent, should be locked until spent or until the order // is canceled or fails. -func (w *spvWallet) lockUnspent(unlock bool, ops []*output) error { +func (w *spvWallet) lockUnspent(unlock bool, ops []*Output) error { switch { case unlock && len(ops) == 0: w.wallet.ResetLockedOutpoints() default: for _, op := range ops { - op := wire.OutPoint{Hash: op.pt.txHash, Index: op.pt.vout} + op := wire.OutPoint{Hash: op.Pt.TxHash, Index: op.Pt.Vout} if unlock { w.wallet.UnlockOutpoint(op) } else { @@ -779,7 +779,7 @@ func (w *spvWallet) estimateSendTxFee(tx *wire.MsgTx, feeRate uint64, subtract b } enough := sendEnough(sendAmount, feeRate, subtract, minTxSize, true, false) - sum, _, inputsSize, _, _, _, _, err := tryFund(utxos, enough) + sum, _, inputsSize, _, _, _, _, err := TryFund(utxos, enough) if err != nil { return 0, err } @@ -922,7 +922,7 @@ func (w *spvWallet) walletUnlock(pw []byte) error { // getBlockHeader gets the *blockHeader for the specified block hash. It also // returns a bool value to indicate whether this block is a part of main chain. // For orphaned blocks header.Confirmations is negative. -func (w *spvWallet) getBlockHeader(blockHash *chainhash.Hash) (header *blockHeader, mainchain bool, err error) { +func (w *spvWallet) getBlockHeader(blockHash *chainhash.Hash) (header *BlockHeader, mainchain bool, err error) { hdr, err := w.cl.GetBlockHeader(blockHash) if err != nil { return nil, false, err @@ -944,7 +944,7 @@ func (w *spvWallet) getBlockHeader(blockHash *chainhash.Hash) (header *blockHead confirmations = int64(confirms(blockHeight, tip.Height)) } - return &blockHeader{ + return &BlockHeader{ Hash: hdr.BlockHash().String(), Confirmations: confirmations, Height: int64(blockHeight), @@ -953,7 +953,7 @@ func (w *spvWallet) getBlockHeader(blockHash *chainhash.Hash) (header *blockHead }, mainchain, nil } -func (w *spvWallet) getBestBlockHeader() (*blockHeader, error) { +func (w *spvWallet) getBestBlockHeader() (*BlockHeader, error) { hash, err := w.getBestBlockHash() if err != nil { return nil, err @@ -1010,9 +1010,9 @@ func (w *spvWallet) connect(ctx context.Context, wg *sync.WaitGroup) (err error) } select { - case w.tipChan <- &block{ - hash: blk.Hash, - height: int64(blk.Height), + case w.tipChan <- &BlockVector{ + Hash: blk.Hash, + Height: int64(blk.Height), }: default: w.log.Warnf("tip report channel was blocking") @@ -1400,8 +1400,8 @@ search: if res.spend != nil && res.blockHash == nil { w.log.Warnf("A spending input (%s) was found during the scan but the output (%s) "+ "itself wasn't found. Was the startBlockHeight early enough?", - newOutPoint(&res.spend.txHash, res.spend.vin), - newOutPoint(&txHash, vout), + NewOutPoint(&res.spend.txHash, res.spend.vin), + NewOutPoint(&txHash, vout), ) return res, nil } @@ -1498,8 +1498,8 @@ func (w *spvWallet) matchPkScript(blockHash *chainhash.Hash, scripts [][]byte) ( // searchBlockForRedemptions attempts to find spending info for the specified // contracts by searching every input of all txs in the provided block range. -func (w *spvWallet) searchBlockForRedemptions(ctx context.Context, reqs map[outPoint]*findRedemptionReq, - blockHash chainhash.Hash) (discovered map[outPoint]*findRedemptionResult) { +func (w *spvWallet) searchBlockForRedemptions(ctx context.Context, reqs map[OutPoint]*FindRedemptionReq, + blockHash chainhash.Hash) (discovered map[OutPoint]*FindRedemptionResult) { // Just match all the scripts together. scripts := make([][]byte, 0, len(reqs)) @@ -1507,7 +1507,7 @@ func (w *spvWallet) searchBlockForRedemptions(ctx context.Context, reqs map[outP scripts = append(scripts, req.pkScript) } - discovered = make(map[outPoint]*findRedemptionResult, len(reqs)) + discovered = make(map[OutPoint]*FindRedemptionResult, len(reqs)) matchFound, err := w.matchPkScript(&blockHash, scripts) if err != nil { @@ -1527,7 +1527,7 @@ func (w *spvWallet) searchBlockForRedemptions(ctx context.Context, reqs map[outP } for _, msgTx := range block.MsgBlock().Transactions { - newlyDiscovered := findRedemptionsInTx(ctx, true, reqs, msgTx, w.chainParams) + newlyDiscovered := findRedemptionsInTxWithHasher(ctx, true, reqs, msgTx, w.chainParams, hashTx) for outPt, res := range newlyDiscovered { discovered[outPt] = res } @@ -1536,7 +1536,7 @@ func (w *spvWallet) searchBlockForRedemptions(ctx context.Context, reqs map[outP } // findRedemptionsInMempool is unsupported for SPV. -func (w *spvWallet) findRedemptionsInMempool(ctx context.Context, reqs map[outPoint]*findRedemptionReq) (discovered map[outPoint]*findRedemptionResult) { +func (w *spvWallet) findRedemptionsInMempool(ctx context.Context, reqs map[OutPoint]*FindRedemptionReq) (discovered map[OutPoint]*FindRedemptionResult) { return } @@ -1605,7 +1605,7 @@ func (w *spvWallet) getWalletTransaction(txHash *chainhash.Hash) (*GetTransactio ret := &GetTransactionResult{ TxID: txHash.String(), - Hex: txRaw, // 'Hex' field name is a lie, kinda + Bytes: txRaw, // 'Hex' field name is a lie, kinda Time: uint64(details.Received.Unix()), TimeReceived: uint64(details.Received.Unix()), } diff --git a/client/asset/btc/types.go b/client/asset/btc/types.go new file mode 100644 index 0000000000..d06e46397f --- /dev/null +++ b/client/asset/btc/types.go @@ -0,0 +1,196 @@ +package btc + +import ( + "fmt" + "strconv" + "time" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/dex" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" +) + +type UTxO struct { + TxHash *chainhash.Hash + Vout uint32 + Address string + Amount uint64 +} + +// OutPoint is the hash and output index of a transaction output. +type OutPoint struct { + TxHash chainhash.Hash + Vout uint32 +} + +// NewOutPoint is the constructor for a new OutPoint. +func NewOutPoint(txHash *chainhash.Hash, vout uint32) OutPoint { + return OutPoint{ + TxHash: *txHash, + Vout: vout, + } +} + +// String is a string representation of the outPoint. +func (pt OutPoint) String() string { + return pt.TxHash.String() + ":" + strconv.Itoa(int(pt.Vout)) +} + +// Output is information about a transaction Output. Output satisfies the +// asset.Coin interface. +type Output struct { + Pt OutPoint + Val uint64 +} + +// NewOutput is the constructor for an output. +func NewOutput(txHash *chainhash.Hash, vout uint32, value uint64) *Output { + return &Output{ + Pt: NewOutPoint(txHash, vout), + Val: value, + } +} + +// Value returns the value of the Output. Part of the asset.Coin interface. +func (op *Output) Value() uint64 { + return op.Val +} + +// ID is the Output's coin ID. Part of the asset.Coin interface. For BTC, the +// coin ID is 36 bytes = 32 bytes tx hash + 4 bytes big-endian vout. +func (op *Output) ID() dex.Bytes { + return ToCoinID(op.txHash(), op.vout()) +} + +// String is a string representation of the coin. +func (op *Output) String() string { + return op.Pt.String() +} + +// txHash returns the pointer of the wire.OutPoint's Hash. +func (op *Output) txHash() *chainhash.Hash { + return &op.Pt.TxHash +} + +// vout returns the wire.OutPoint's Index. +func (op *Output) vout() uint32 { + return op.Pt.Vout +} + +// WireOutPoint creates and returns a new *wire.OutPoint for the output. +func (op *Output) WireOutPoint() *wire.OutPoint { + return wire.NewOutPoint(op.txHash(), op.vout()) +} + +// ConvertCoin converts the asset.Coin to an Output. +func ConvertCoin(coin asset.Coin) (*Output, error) { + op, _ := coin.(*Output) + if op != nil { + return op, nil + } + txHash, vout, err := decodeCoinID(coin.ID()) + if err != nil { + return nil, err + } + return NewOutput(txHash, vout, coin.Value()), nil +} + +// AuditInfo is information about a swap contract on that blockchain. +type AuditInfo struct { + Output *Output + Recipient btcutil.Address // caution: use stringAddr, not the Stringer + contract []byte + secretHash []byte + expiration time.Time +} + +// Expiration returns the expiration time of the contract, which is the earliest +// time that a refund can be issued for an un-redeemed contract. +func (ci *AuditInfo) Expiration() time.Time { + return ci.expiration +} + +// Coin returns the output as an asset.Coin. +func (ci *AuditInfo) Coin() asset.Coin { + return ci.Output +} + +// Contract is the contract script. +func (ci *AuditInfo) Contract() dex.Bytes { + return ci.contract +} + +// SecretHash is the contract's secret hash. +func (ci *AuditInfo) SecretHash() dex.Bytes { + return ci.secretHash +} + +type BlockVector struct { + Height int64 + Hash chainhash.Hash +} + +// TxOutFromTxBytes parses the specified *wire.TxOut from the serialized +// transaction. +func TxOutFromTxBytes( + txB []byte, + vout uint32, + deserializeTx func([]byte) (*wire.MsgTx, error), + hashTx func(*wire.MsgTx) *chainhash.Hash, +) (*wire.TxOut, error) { + msgTx, err := deserializeTx(txB) + if err != nil { + return nil, fmt.Errorf("error decoding transaction bytes: %v", err) + } + + if len(msgTx.TxOut) <= int(vout) { + return nil, fmt.Errorf("no vout %d in tx %s", vout, hashTx(msgTx)) + } + return msgTx.TxOut[vout], nil +} + +// SwapReceipt is information about a swap contract that was broadcast by this +// wallet. Satisfies the asset.Receipt interface. +type SwapReceipt struct { + Output *Output + SwapContract []byte + SignedRefundBytes []byte + ExpirationTime time.Time +} + +// Expiration is the time that the contract will expire, allowing the user to +// issue a refund transaction. Part of the asset.Receipt interface. +func (r *SwapReceipt) Expiration() time.Time { + return r.ExpirationTime +} + +// Contract is the contract script. Part of the asset.Receipt interface. +func (r *SwapReceipt) Contract() dex.Bytes { + return r.SwapContract +} + +// Coin is the output information as an asset.Coin. Part of the asset.Receipt +// interface. +func (r *SwapReceipt) Coin() asset.Coin { + return r.Output +} + +// String provides a human-readable representation of the contract's Coin. +func (r *SwapReceipt) String() string { + return r.Output.String() +} + +// SignedRefund is a signed refund script that can be used to return +// funds to the user in the case a contract expires. +func (r *SwapReceipt) SignedRefund() dex.Bytes { + return r.SignedRefundBytes +} + +// SyncStatus is the current synchronization state of the node. +type SyncStatus struct { + Target int32 `json:"target"` + Height int32 `json:"height"` + Syncing bool `json:"syncing"` +} diff --git a/client/asset/btc/wallet.go b/client/asset/btc/wallet.go index 3e9b38c5cf..c09f01b711 100644 --- a/client/asset/btc/wallet.go +++ b/client/asset/btc/wallet.go @@ -28,7 +28,7 @@ type Wallet interface { medianTime() (time.Time, error) balances() (*GetBalancesResult, error) listUnspent() ([]*ListUnspentResult, error) // must not return locked coins - lockUnspent(unlock bool, ops []*output) error + lockUnspent(unlock bool, ops []*Output) error listLockUnspent() ([]*RPCOutpoint, error) changeAddress() (btcutil.Address, error) // warning: don't just use the Stringer if there's a "recode" function for a clone e.g. BCH externalAddress() (btcutil.Address, error) @@ -37,10 +37,10 @@ type Wallet interface { walletUnlock(pw []byte) error walletLock() error locked() bool - syncStatus() (*syncStatus, error) + syncStatus() (*SyncStatus, error) peerCount() (uint32, error) swapConfirmations(txHash *chainhash.Hash, vout uint32, contract []byte, startTime time.Time) (confs uint32, spent bool, err error) - getBestBlockHeader() (*blockHeader, error) + getBestBlockHeader() (*BlockHeader, error) ownsAddress(addr btcutil.Address) (bool, error) // this should probably just take a string getWalletTransaction(txHash *chainhash.Hash) (*GetTransactionResult, error) reconfigure(walletCfg *asset.WalletConfig, currentAddress string) (restartRequired bool, err error) @@ -53,10 +53,10 @@ type txLister interface { type tipRedemptionWallet interface { Wallet getBlockHeight(*chainhash.Hash) (int32, error) - getBlockHeader(blockHash *chainhash.Hash) (hdr *blockHeader, mainchain bool, err error) + getBlockHeader(blockHash *chainhash.Hash) (hdr *BlockHeader, mainchain bool, err error) getBlock(h chainhash.Hash) (*wire.MsgBlock, error) - searchBlockForRedemptions(ctx context.Context, reqs map[outPoint]*findRedemptionReq, blockHash chainhash.Hash) (discovered map[outPoint]*findRedemptionResult) - findRedemptionsInMempool(ctx context.Context, reqs map[outPoint]*findRedemptionReq) (discovered map[outPoint]*findRedemptionResult) + searchBlockForRedemptions(ctx context.Context, reqs map[OutPoint]*FindRedemptionReq, blockHash chainhash.Hash) (discovered map[OutPoint]*FindRedemptionResult) + findRedemptionsInMempool(ctx context.Context, reqs map[OutPoint]*FindRedemptionReq) (discovered map[OutPoint]*FindRedemptionResult) } type txFeeEstimator interface { @@ -73,30 +73,28 @@ type walletTxChecker interface { // tipNotifier can be implemented if the Wallet is able to provide a stream of // blocks as they are finished being processed. type tipNotifier interface { - tipFeed() <-chan *block + tipFeed() <-chan *BlockVector } +const medianTimeBlocks = 11 + // chainStamper is a source of the timestamp and the previous block hash for a // specified block. A chainStamper is used to manually calculate the median time // for a block. -type chainStamper interface { - getChainStamp(*chainhash.Hash) (stamp time.Time, prevHash *chainhash.Hash, err error) -} - -const medianTimeBlocks = 11 +type chainStamper func(*chainhash.Hash) (stamp time.Time, prevHash *chainhash.Hash, err error) -// calcMedianTime calculates the median time of the previous 11 block headers. +// CalcMedianTime calculates the median time of the previous 11 block headers. // The median time is used for validating time-locked transactions. See notes in // btcd/blockchain (*blockNode).CalcPastMedianTime() regarding incorrectly // calculated median time for blocks 1, 3, 5, 7, and 9. -func calcMedianTime(stamper chainStamper, blockHash *chainhash.Hash) (time.Time, error) { +func CalcMedianTime(stamper chainStamper, blockHash *chainhash.Hash) (time.Time, error) { timestamps := make([]int64, 0, medianTimeBlocks) zeroHash := chainhash.Hash{} h := blockHash for i := 0; i < medianTimeBlocks; i++ { - stamp, prevHash, err := stamper.getChainStamp(h) + stamp, prevHash, err := stamper(h) if err != nil { return time.Time{}, fmt.Errorf("BlockHeader error for hash %q: %v", h, err) } diff --git a/client/asset/btc/wallettypes.go b/client/asset/btc/wallettypes.go index b1babb39d8..e9e10a1dda 100644 --- a/client/asset/btc/wallettypes.go +++ b/client/asset/btc/wallettypes.go @@ -74,7 +74,7 @@ type GetTransactionResult struct { TxID string `json:"txid"` Time uint64 `json:"time"` TimeReceived uint64 `json:"timereceived"` - Hex dex.Bytes `json:"hex"` // []byte, although it marshals/unmarshals a hex string + Bytes dex.Bytes `json:"hex"` // []byte, although it marshals/unmarshals a hex string // BipReplaceable string `json:"bip125-replaceable"` // unused // Details []*WalletTxDetails `json:"details"` // unused, and nearly impossible to satisfy in a generic interface } diff --git a/client/asset/dash/dash.go b/client/asset/dash/dash.go index 30cb7f4680..368ce3403b 100644 --- a/client/asset/dash/dash.go +++ b/client/asset/dash/dash.go @@ -141,30 +141,20 @@ func newWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) InitTxSize: dexbtc.InitTxSize, InitTxSizeBase: dexbtc.InitTxSizeBase, PrivKeyFunc: nil, - AddrFunc: nil, AddressDecoder: nil, AddressStringer: nil, BlockDeserializer: nil, ArglessChangeAddrRPC: true, // getrawchangeaddress has No address-type arg NonSegwitSigner: nil, - ConnectFunc: nil, FeeEstimator: nil, // estimatesmartfee + getblockstats ExternalFeeEstimator: nil, OmitAddressType: true, // getnewaddress has No address-type arg LegacySignTxRPC: false, // Has signrawtransactionwithwallet RPC BooleanGetBlockRPC: false, // Use 0/1 for verbose param - NumericGetRawRPC: false, // getrawtransaction uses true/false LegacyValidateAddressRPC: false, // getaddressinfo SingularWallet: false, // wallet can have "" as a path but also a name like "gamma" UnlockSpends: false, ConstantDustLimit: 0, - TxDeserializer: nil, - TxSerializer: nil, - TxHasher: nil, - TxSizeCalculator: nil, - TxVersion: nil, - ManualMedianTime: false, - OmitRPCOptionsArg: false, AssetID: BipID, } diff --git a/client/asset/firo/firo.go b/client/asset/firo/firo.go index f8b13646f9..50521c8a2a 100644 --- a/client/asset/firo/firo.go +++ b/client/asset/firo/firo.go @@ -152,15 +152,14 @@ func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) InitTxSize: dexbtc.InitTxSize, InitTxSizeBase: dexbtc.InitTxSizeBase, LegacyBalance: cfg.Type == walletTypeRPC, - LegacyRawFeeLimit: true, // sendrawtransaction Has single arg allowhighfees - ArglessChangeAddrRPC: true, // getrawchangeaddress has No address-type arg - OmitAddressType: true, // getnewaddress has No address-type arg - LegacySignTxRPC: true, // No signrawtransactionwithwallet RPC - BooleanGetBlockRPC: true, // Use bool true/false text for verbose param - NumericGetRawRPC: false, // getrawtransaction uses either 0/1 Or true/false - LegacyValidateAddressRPC: true, // use validateaddress to read 'ismine' bool - SingularWallet: true, // one wallet/node - UnlockSpends: true, // Firo chain wallet does Not unlock coins after sendrawtransaction + LegacyRawFeeLimit: true, // sendrawtransaction Has single arg allowhighfees + ArglessChangeAddrRPC: true, // getrawchangeaddress has No address-type arg + OmitAddressType: true, // getnewaddress has No address-type arg + LegacySignTxRPC: true, // No signrawtransactionwithwallet RPC + BooleanGetBlockRPC: true, // Use bool true/false text for verbose param + LegacyValidateAddressRPC: true, // use validateaddress to read 'ismine' bool + SingularWallet: true, // one wallet/node + UnlockSpends: true, // Firo chain wallet does Not unlock coins after sendrawtransaction AssetID: BipID, FeeEstimator: estimateFee, ExternalFeeEstimator: fetchExternalFee, diff --git a/client/asset/zec/errors.go b/client/asset/zec/errors.go new file mode 100644 index 0000000000..de5abf84e7 --- /dev/null +++ b/client/asset/zec/errors.go @@ -0,0 +1,79 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package zec + +import ( + "errors" + "fmt" +) + +// Error codes here are used on the frontend. +const ( + errNoFundsRequested = iota + errBalanceRetrieval + errInsufficientBalance + errUTxORetrieval + errLockUnspent + errFunding + errShieldedFunding + errNoTx + errGetChainInfo + errGetNetInfo + errBadInput + errMaxLock + errNoteCounts +) + +// Error is an error code and a wrapped error. +type Error struct { + code int + err error +} + +// Error returns the error string. Satisfies the error interface. +func (e *Error) Error() string { + return e.err.Error() +} + +// Code returns the error code. +func (e *Error) Code() *int { + return &e.code +} + +// Unwrap returns the underlying wrapped error. +func (e *Error) Unwrap() error { + return e.err +} + +// newError is a constructor for a new Error. +func newError(code int, s string, a ...any) error { + return &Error{ + code: code, + err: fmt.Errorf(s, a...), // s may contain a %w verb to wrap an error + } +} + +// codedError converts the error to an Error with the specified code. +func codedError(code int, err error) error { + return &Error{ + code: code, + err: err, + } +} + +// errorHasCode checks whether the error is an Error and has the specified code. +func errorHasCode(err error, code int) bool { + var e *Error + return errors.As(err, &e) && e.code == code +} + +// UnwrapErr returns the result of calling the Unwrap method on err, +// until it returns a non-wrapped error. +func UnwrapErr(err error) error { + InnerErr := errors.Unwrap(err) + if InnerErr == nil { + return err + } + return UnwrapErr(InnerErr) +} diff --git a/client/asset/zec/regnet_test.go b/client/asset/zec/regnet_test.go index f61be4de35..c0e1280b1c 100644 --- a/client/asset/zec/regnet_test.go +++ b/client/asset/zec/regnet_test.go @@ -26,18 +26,6 @@ const ( testnetOverwinterActivationHeight = 207500 ) -var ( - tLotSize uint64 = 1e6 - tZEC = &dex.Asset{ - ID: BipID, - Symbol: "zec", - SwapSize: dexzec.InitTxSize, - SwapSizeBase: dexzec.InitTxSizeBase, - MaxFeeRate: 100, - SwapConf: 1, - } -) - func TestWallet(t *testing.T) { livetest.Run(t, &livetest.Config{ NewWallet: NewWallet, @@ -56,6 +44,14 @@ func TestWallet(t *testing.T) { // TestDeserializeTestnet must be run against a full RPC node. func TestDeserializeTestnetBlocks(t *testing.T) { + testDeserializeBlocks(t, "18232", testnetNU5ActivationHeight, testnetSaplingActivationHeight, testnetOverwinterActivationHeight) +} + +func TestDeserializeMainnetBlocks(t *testing.T) { + testDeserializeBlocks(t, "8232") +} + +func testDeserializeBlocks(t *testing.T, port string, upgradeHeights ...int64) { cfg := struct { RPCUser string `ini:"rpcuser"` RPCPass string `ini:"rpcpassword"` @@ -69,7 +65,7 @@ func TestDeserializeTestnetBlocks(t *testing.T) { cl, err := rpcclient.New(&rpcclient.ConnConfig{ HTTPPostMode: true, DisableTLS: true, - Host: "localhost:18232", + Host: "localhost:" + port, User: cfg.RPCUser, Pass: cfg.RPCPass, }, nil) @@ -85,21 +81,6 @@ func TestDeserializeTestnetBlocks(t *testing.T) { t.Fatalf("GetBestBlockHash error: %v", err) } - lastV4Block, err := cl.GetBlockHash(ctx, testnetNU5ActivationHeight-1) - if err != nil { - t.Fatalf("GetBlockHash(%d) error: %v", testnetNU5ActivationHeight-1, err) - } - - lastV3Block, err := cl.GetBlockHash(ctx, testnetSaplingActivationHeight-1) - if err != nil { - t.Fatalf("GetBlockHash(%d) error: %v", testnetSaplingActivationHeight-1, err) - } - - lastV2Block, err := cl.GetBlockHash(ctx, testnetOverwinterActivationHeight-1) - if err != nil { - t.Fatalf("GetBlockHash(%d) error: %v", testnetOverwinterActivationHeight-1, err) - } - mustMarshal := func(thing any) json.RawMessage { b, err := json.Marshal(thing) if err != nil { @@ -126,22 +107,24 @@ func TestDeserializeTestnetBlocks(t *testing.T) { t.Fatalf("Error deserializing %s: %v", hashStr, err) } - // for i, tx := range zecBlock.Transactions { - // switch { - // case tx.NActionsOrchard > 0 && tx.NOutputsSapling > 0: - // fmt.Printf("orchard + sapling shielded tx: %s:%d \n", hashStr, i) - // case tx.NActionsOrchard > 0: - // fmt.Printf("orchard shielded tx: %s:%d \n", hashStr, i) - // case tx.NOutputsSapling > 0 || tx.NSpendsSapling > 0: - // fmt.Printf("sapling shielded tx: %s:%d \n", hashStr, i) - // case tx.NJoinSplit > 0: - // fmt.Printf("joinsplit tx: %s:%d \n", hashStr, i) - // default: - // if i > 0 { - // fmt.Printf("unshielded tx: %s:%d \n", hashStr, i) - // } - // } - // } + for _, tx := range zecBlock.Transactions { + switch { + case tx.NActionsOrchard > 0: + fmt.Printf("Orchard transaction with nActionsOrchard = %d \n", tx.NActionsOrchard) + // case tx.NActionsOrchard > 0 && tx.NOutputsSapling > 0: + // fmt.Printf("orchard + sapling shielded tx: %s:%d \n", hashStr, i) + // case tx.NActionsOrchard > 0: + // fmt.Printf("orchard shielded tx: %s:%d \n", hashStr, i) + // case tx.NOutputsSapling > 0 || tx.NSpendsSapling > 0: + // fmt.Printf("sapling shielded tx: %s:%d \n", hashStr, i) + // case tx.NJoinSplit > 0: + // fmt.Printf("joinsplit tx: %s:%d \n", hashStr, i) + // default: + // if i > 0 { + // fmt.Printf("unshielded tx: %s:%d \n", hashStr, i) + // } + } + } hashStr = zecBlock.Header.PrevBlock.String() } @@ -151,15 +134,14 @@ func TestDeserializeTestnetBlocks(t *testing.T) { fmt.Println("Testing version 5 blocks") nBlocksFromHash(tipHash.String(), 1000) - // Test version 4 blocks. - fmt.Println("Testing version 4 blocks") - nBlocksFromHash(lastV4Block.String(), 1000) - - // Test version 3 blocks. - fmt.Println("Testing version 3 blocks") - nBlocksFromHash(lastV3Block.String(), 1000) - - // Test version 2 blocks. - fmt.Println("Testing version 2 blocks") - nBlocksFromHash(lastV2Block.String(), 1000) + ver := 4 + for _, upgradeHeight := range upgradeHeights { + lastVerBlock, err := cl.GetBlockHash(ctx, upgradeHeight-1) + if err != nil { + t.Fatalf("GetBlockHash(%d) error: %v", upgradeHeight-1, err) + } + fmt.Printf("Testing version %d blocks \n", ver) + nBlocksFromHash(lastVerBlock.String(), 1000) + ver-- + } } diff --git a/client/asset/zec/shielded_rpc.go b/client/asset/zec/shielded_rpc.go index 0ebdb7d73c..68b260bc14 100644 --- a/client/asset/zec/shielded_rpc.go +++ b/client/asset/zec/shielded_rpc.go @@ -17,6 +17,7 @@ const ( methodZSendMany = "z_sendmany" methodZValidateAddress = "z_validateaddress" methodZGetOperationResult = "z_getoperationresult" + methodZGetNotesCount = "z_getnotescount" ) type zListAccountsResult struct { @@ -66,17 +67,43 @@ func zGetUnifiedReceivers(c rpcCaller, unifiedAddr string) (receivers *unifiedRe return receivers, c.CallRPC(methodZListUnifiedReceivers, []any{unifiedAddr}, &receivers) } -func zGetBalanceForAccount(c rpcCaller, acct uint32) (uint64, error) { - const minConf = 1 +type valZat struct { // Ignoring similar fields for Sapling, Transparent... + ValueZat uint64 `json:"valueZat"` +} - var res struct { - Pools struct { - Orchard struct { // Ignoring similar fields for Sapling, Transparent... - ValueZat uint64 `json:"valueZat"` - } `json:"orchard"` - } `json:"pools"` - } - return res.Pools.Orchard.ValueZat, c.CallRPC(methodZGetBalanceForAccount, []any{acct, minConf}, &res) +type poolBalances struct { + Orchard uint64 `json:"orchard"` + Transparent uint64 `json:"transparent"` + Sapling uint64 `json:"sapling"` +} + +type zBalancePools struct { + Orchard valZat `json:"orchard"` + Transparent valZat `json:"transparent"` + Sapling valZat `json:"sapling"` +} + +type zAccountBalance struct { + Pools zBalancePools `json:"pools"` +} + +func zGetBalanceForAccount(c rpcCaller, acct uint32, confs int) (*poolBalances, error) { + var res zAccountBalance + return &poolBalances{ + Orchard: res.Pools.Orchard.ValueZat, + Transparent: res.Pools.Transparent.ValueZat, + Sapling: res.Pools.Sapling.ValueZat, + }, c.CallRPC(methodZGetBalanceForAccount, []any{acct, confs}, &res) +} + +type zNotesCount struct { + Sprout uint32 `json:"sprout"` + Sapling uint32 `json:"sapling"` + Orchard uint32 `json:"orchard"` +} + +func zGetNotesCount(c rpcCaller) (counts *zNotesCount, _ error) { + return counts, c.CallRPC(methodZGetNotesCount, []any{minOrchardConfs}, &counts) } type zValidateAddressResult struct { @@ -115,10 +142,11 @@ func singleSendManyRecipient(addr string, amt uint64) []*zSendManyRecipient { type privacyPolicy string const ( - FullPrivacy privacyPolicy = "FullPrivacy" - AllowRevealedRecipients privacyPolicy = "AllowRevealedRecipients" - AllowRevealedAmounts privacyPolicy = "AllowRevealedAmounts" - AllowRevealedSenders privacyPolicy = "AllowRevealedSenders" + FullPrivacy privacyPolicy = "FullPrivacy" + AllowRevealedRecipients privacyPolicy = "AllowRevealedRecipients" + AllowRevealedAmounts privacyPolicy = "AllowRevealedAmounts" + AllowRevealedSenders privacyPolicy = "AllowRevealedSenders" + AllowLinkingAccountAddresses privacyPolicy = "AllowLinkingAccountAddresses" ) // z_sendmany "fromaddress" [{"address":... ,"amount":...},...] ( minconf ) ( fee ) ( privacyPolicy ) @@ -127,6 +155,10 @@ func zSendMany(c rpcCaller, fromAddress string, recips []*zSendManyRecipient, pr return operationID, c.CallRPC(methodZSendMany, []any{fromAddress, recips, minConf, fee, priv}, &operationID) } +type opResult struct { + TxID string `json:"txid"` +} + type operationStatus struct { ID string `json:"id"` Status string `json:"status"` // "success", "failed", others? @@ -135,11 +167,9 @@ type operationStatus struct { Code int32 `json:"code"` Message string `json:"message"` } `json:"error" ` - Result *struct { - TxID string `json:"txid"` - } `json:"result"` - ExecutionSeconds float64 `json:"execution_secs"` - Method string `json:"method"` + Result *opResult `json:"result"` + ExecutionSeconds float64 `json:"execution_secs"` + Method string `json:"method"` Params struct { FromAddress string `json:"fromaddress"` Amounts []struct { diff --git a/client/asset/zec/shielded_test.go b/client/asset/zec/shielded_test.go index 599f566efa..61e9c8d842 100644 --- a/client/asset/zec/shielded_test.go +++ b/client/asset/zec/shielded_test.go @@ -135,7 +135,7 @@ func TestSendShielded(t *testing.T) { t.Fatalf("SendShielded error: %v", err) } - tAddr, err := w.NewAddress() + tAddr, err := w.DepositAddress() if err != nil { t.Fatalf("NewAddress error: %v", err) } diff --git a/client/asset/zec/transparent_rpc.go b/client/asset/zec/transparent_rpc.go new file mode 100644 index 0000000000..c8cb39e0a6 --- /dev/null +++ b/client/asset/zec/transparent_rpc.go @@ -0,0 +1,367 @@ +package zec + +import ( + "encoding/hex" + "errors" + "fmt" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/client/asset/btc" + "decred.org/dcrdex/dex" + dexzec "decred.org/dcrdex/dex/networks/zec" + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + "github.com/decred/dcrd/rpcclient/v8" +) + +func listUnspent(c rpcCaller) (res []*btc.ListUnspentResult, err error) { + const minConf = 0 + return res, c.CallRPC("listunspent", []any{minConf}, &res) +} + +func lockUnspent(c rpcCaller, unlock bool, ops []*btc.Output) error { + var rpcops []*btc.RPCOutpoint // To clear all, this must be nil->null, not empty slice. + for _, op := range ops { + rpcops = append(rpcops, &btc.RPCOutpoint{ + TxID: op.Pt.TxHash.String(), + Vout: op.Pt.Vout, + }) + } + var success bool + err := c.CallRPC("lockunspent", []any{unlock, rpcops}, &success) + if err == nil && !success { + return fmt.Errorf("lockunspent unsuccessful") + } + return err +} + +func getTransaction(c rpcCaller, txHash *chainhash.Hash) (*dexzec.Tx, error) { + var tx btc.GetTransactionResult + if err := c.CallRPC("gettransaction", []any{txHash.String()}, &tx); err != nil { + return nil, err + } + return dexzec.DeserializeTx(tx.Bytes) +} + +func getRawTransaction(c rpcCaller, txHash *chainhash.Hash) ([]byte, error) { + var txB dex.Bytes + return txB, c.CallRPC("getrawtransaction", []any{txHash.String()}, &txB) +} + +func signTxByRPC(c rpcCaller, inTx *dexzec.Tx) (*dexzec.Tx, error) { + txBytes, err := inTx.Bytes() + if err != nil { + return nil, fmt.Errorf("tx serialization error: %w", err) + } + res := new(btc.SignTxResult) + + err = c.CallRPC("signrawtransaction", []any{hex.EncodeToString(txBytes)}, res) + if err != nil { + return nil, fmt.Errorf("tx signing error: %w", err) + } + if !res.Complete { + sep := "" + errMsg := "" + for _, e := range res.Errors { + errMsg += e.Error + sep + sep = ";" + } + return nil, fmt.Errorf("signing incomplete. %d signing errors encountered: %s", len(res.Errors), errMsg) + } + outTx, err := dexzec.DeserializeTx(res.Hex) + if err != nil { + return nil, fmt.Errorf("error deserializing transaction response: %w", err) + } + return outTx, nil +} + +func callHashGetter(c rpcCaller, method string, args []any) (*chainhash.Hash, error) { + var txid string + err := c.CallRPC(method, args, &txid) + if err != nil { + return nil, err + } + return chainhash.NewHashFromStr(txid) +} + +func sendRawTransaction(c rpcCaller, tx *dexzec.Tx) (*chainhash.Hash, error) { + txB, err := tx.Bytes() + if err != nil { + return nil, err + } + return callHashGetter(c, "sendrawtransaction", []any{hex.EncodeToString(txB), false}) +} + +func dumpPrivKey(c rpcCaller, addr string) (*secp256k1.PrivateKey, error) { + var keyHex string + err := c.CallRPC("dumpprivkey", []any{addr}, &keyHex) + if err != nil { + return nil, err + } + wif, err := btcutil.DecodeWIF(keyHex) + if err != nil { + return nil, err + } + return wif.PrivKey, nil +} + +func listLockUnspent(c rpcCaller, log dex.Logger) ([]*btc.RPCOutpoint, error) { + var unspents []*btc.RPCOutpoint + err := c.CallRPC("listlockunspent", nil, &unspents) + if err != nil { + return nil, err + } + // This is quirky wallet software that does not unlock spent outputs, so + // we'll verify that each output is actually unspent. + var i int // for in-place filter + for _, utxo := range unspents { + var gtxo *btcjson.GetTxOutResult + err = c.CallRPC("gettxout", []any{utxo.TxID, utxo.Vout, true}, >xo) + if err != nil { + log.Warnf("gettxout(%v:%d): %v", utxo.TxID, utxo.Vout, err) + continue + } + if gtxo != nil { + unspents[i] = utxo // unspent, keep it + i++ + continue + } + // actually spent, unlock + var success bool + op := []*btc.RPCOutpoint{{ + TxID: utxo.TxID, + Vout: utxo.Vout, + }} + + err = c.CallRPC("lockunspent", []any{true, op}, &success) + if err != nil || !success { + log.Warnf("lockunspent(unlocking %v:%d): success = %v, err = %v", + utxo.TxID, utxo.Vout, success, err) + continue + } + log.Debugf("Unlocked spent outpoint %v:%d", utxo.TxID, utxo.Vout) + } + unspents = unspents[:i] + return unspents, nil +} + +func getTxOut(c rpcCaller, txHash *chainhash.Hash, index uint32) (*wire.TxOut, uint32, error) { + // Note that we pass to call pointer to a pointer (&res) so that + // json.Unmarshal can nil the pointer if the method returns the JSON null. + var res *btcjson.GetTxOutResult + if err := c.CallRPC("gettxout", []any{txHash.String(), index, true}, &res); err != nil { + return nil, 0, err + } + if res == nil { + return nil, 0, nil + } + outputScript, err := hex.DecodeString(res.ScriptPubKey.Hex) + if err != nil { + return nil, 0, err + } + return wire.NewTxOut(int64(toZats(res.Value)), outputScript), uint32(res.Confirmations), nil +} + +func getVersion(c rpcCaller) (uint64, uint64, error) { + r := &struct { + Version uint64 `json:"version"` + SubVersion string `json:"subversion"` + ProtocolVersion uint64 `json:"protocolversion"` + }{} + err := c.CallRPC("getnetworkinfo", nil, r) + if err != nil { + return 0, 0, err + } + return r.Version, r.ProtocolVersion, nil +} + +func getBlockchainInfo(c rpcCaller) (*btc.GetBlockchainInfoResult, error) { + chainInfo := new(btc.GetBlockchainInfoResult) + err := c.CallRPC("getblockchaininfo", nil, chainInfo) + if err != nil { + return nil, err + } + return chainInfo, nil +} + +func getWalletInfo(c rpcCaller) (*btc.GetWalletInfoResult, error) { + wi := new(btc.GetWalletInfoResult) + return wi, c.CallRPC("getwalletinfo", nil, wi) +} + +func getBestBlockHeader(c rpcCaller) (*btc.BlockHeader, error) { + tipHash, err := getBestBlockHash(c) + if err != nil { + return nil, err + } + hdr, _, err := getVerboseBlockHeader(c, tipHash) + return hdr, err +} + +func getBestBlockHash(c rpcCaller) (*chainhash.Hash, error) { + return callHashGetter(c, "getbestblockhash", nil) +} + +func getVerboseBlockHeader(c rpcCaller, blockHash *chainhash.Hash) (header *btc.BlockHeader, mainchain bool, err error) { + hdr, err := getRPCBlockHeader(c, blockHash) + if err != nil { + return nil, false, err + } + // RPC wallet must return negative confirmations number for orphaned blocks. + mainchain = hdr.Confirmations >= 0 + return hdr, mainchain, nil +} + +func getBlockHeader(c rpcCaller, blockHash *chainhash.Hash) (*wire.BlockHeader, error) { + var b dex.Bytes + err := c.CallRPC("getblockheader", []any{blockHash.String(), false}, &b) + if err != nil { + return nil, err + } + return dexzec.DeserializeBlockHeader(b) +} + +func getRPCBlockHeader(c rpcCaller, blockHash *chainhash.Hash) (*btc.BlockHeader, error) { + blkHeader := new(btc.BlockHeader) + err := c.CallRPC("getblockheader", []any{blockHash.String(), true}, blkHeader) + if err != nil { + return nil, err + } + return blkHeader, nil +} + +func getWalletTransaction(c rpcCaller, txHash *chainhash.Hash) (*btc.GetTransactionResult, error) { + var tx btc.GetTransactionResult + err := c.CallRPC("gettransaction", []any{txHash.String()}, &tx) + if err != nil { + if btc.IsTxNotFoundErr(err) { + return nil, asset.CoinNotFoundError + } + return nil, err + } + return &tx, nil +} + +func getBalance(c rpcCaller) (bal uint64, err error) { + return bal, c.CallRPC("getbalance", []any{"", 0 /* minConf */, false /* includeWatchOnly */, true /* inZats */}, &bal) +} + +type networkInfo struct { + Connections uint32 `json:"connections"` +} + +func peerCount(c rpcCaller) (uint32, error) { + var r networkInfo + err := c.CallRPC("getnetworkinfo", nil, &r) + if err != nil { + return 0, codedError(errGetNetInfo, err) + } + return r.Connections, nil +} + +func getBlockHeight(c rpcCaller, blockHash *chainhash.Hash) (int32, error) { + hdr, _, err := getVerboseBlockHeader(c, blockHash) + if err != nil { + return -1, err + } + if hdr.Height < 0 { + return -1, fmt.Errorf("block is not a mainchain block") + } + return int32(hdr.Height), nil +} + +func getBlock(c rpcCaller, h chainhash.Hash) (*dexzec.Block, error) { + var blkB dex.Bytes + err := c.CallRPC("getblock", []any{h.String(), int64(0)}, &blkB) + if err != nil { + return nil, err + } + + return dexzec.DeserializeBlock(blkB) +} + +// getBestBlockHeight returns the height of the top mainchain block. +func getBestBlockHeight(c rpcCaller) (int32, error) { + header, err := getBestBlockHeader(c) + if err != nil { + return -1, err + } + return int32(header.Height), nil +} + +func getBlockHash(c rpcCaller, blockHeight int64) (*chainhash.Hash, error) { + return callHashGetter(c, "getblockhash", []any{blockHeight}) +} + +func getRawMempool(c rpcCaller) ([]*chainhash.Hash, error) { + var mempool []string + err := c.CallRPC("getrawmempool", nil, &mempool) + if err != nil { + return nil, translateRPCCancelErr(err) + } + + // Convert received hex hashes to chainhash.Hash + hashes := make([]*chainhash.Hash, 0, len(mempool)) + for _, h := range mempool { + hash, err := chainhash.NewHashFromStr(h) + if err != nil { + return nil, err + } + hashes = append(hashes, hash) + } + return hashes, nil +} + +func getZecTransaction(c rpcCaller, txHash *chainhash.Hash) (*dexzec.Tx, error) { + txB, err := getRawTransaction(c, txHash) + if err != nil { + return nil, err + } + + return dexzec.DeserializeTx(txB) +} + +func getAddressInfo(c rpcCaller, addrStr string, method string) (*btc.GetAddressInfoResult, error) { + ai := new(btc.GetAddressInfoResult) + return ai, c.CallRPC(method, []any{addrStr}, ai) +} + +func ownsAddress(c rpcCaller, addrStr string) (bool, error) { + ai, err := getAddressInfo(c, addrStr, "validateaddress") + if err != nil { + return false, err + } + return ai.IsMine, nil +} + +func translateRPCCancelErr(err error) error { + if err == nil { + return nil + } + if errors.Is(err, rpcclient.ErrRequestCanceled) { + err = asset.ErrRequestTimeout + } + return err +} + +func getTxOutput(c rpcCaller, txHash *chainhash.Hash, index uint32) (*btcjson.GetTxOutResult, error) { + // Note that we pass to call pointer to a pointer (&res) so that + // json.Unmarshal can nil the pointer if the method returns the JSON null. + var res *btcjson.GetTxOutResult + return res, c.CallRPC("gettxout", []any{txHash.String(), index, true}, &res) +} + +func syncStatus(c rpcCaller) (*btc.SyncStatus, error) { + chainInfo, err := getBlockchainInfo(c) + if err != nil { + return nil, newError(errGetChainInfo, "getblockchaininfo error: %w", err) + } + return &btc.SyncStatus{ + Target: int32(chainInfo.Headers), + Height: int32(chainInfo.Blocks), + Syncing: chainInfo.Syncing(), + }, nil +} diff --git a/client/asset/zec/zec.go b/client/asset/zec/zec.go index 2874e70929..99b1e68062 100644 --- a/client/asset/zec/zec.go +++ b/client/asset/zec/zec.go @@ -4,16 +4,23 @@ package zec import ( + "bytes" "context" + "crypto/sha256" + "encoding/binary" "errors" "fmt" + "math" + "path/filepath" "strings" + "sync" "sync/atomic" "time" "decred.org/dcrdex/client/asset" "decred.org/dcrdex/client/asset/btc" "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/btcec/v2" @@ -23,6 +30,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" + "github.com/decred/dcrd/rpcclient/v8" ) const ( @@ -35,13 +43,27 @@ const ( minNetworkVersion = 5040250 // v5.4.2 walletTypeRPC = "zcashdRPC" - transparentAcctNumber = 0 - shieldedAcctNumber = 1 + // transparentAcctNumber = 0 + shieldedAcctNumber = 0 transparentAddressType = "p2pkh" orchardAddressType = "orchard" saplingAddressType = "sapling" unifiedAddressType = "unified" + + minOrchardConfs = 1 + // nActionsOrchardEstimate is used for tx fee estimation. Scanning 1000 + // previous blocks, only found 1 with a tx with > 6 nActionsOrchard. Most + // are 2. + nActionsOrchardEstimate = 6 + + blockTicker = time.Second + peerCountTicker = 5 * time.Second + + // requiredRedeemConfirms is the amount of confirms a redeem transaction + // needs before the trade is considered confirmed. The redeem is + // monitored until this number of confirms is reached. + requiredRedeemConfirms = 1 ) var ( @@ -92,238 +114,2351 @@ var ( IsBoolean: true, }, } - // WalletInfo defines some general information about a Zcash wallet. - WalletInfo = &asset.WalletInfo{ - Name: "Zcash", - Version: version, - SupportedVersions: []uint32{version}, - UnitInfo: dexzec.UnitInfo, - AvailableWallets: []*asset.WalletDefinition{{ - Type: walletTypeRPC, - Tab: "External", - Description: "Connect to zcashd", - DefaultConfigPath: dexbtc.SystemConfigPath("zcash"), - ConfigOpts: configOpts, - NoAuth: true, - }}, + // WalletInfo defines some general information about a Zcash wallet. + WalletInfo = &asset.WalletInfo{ + Name: "Zcash", + Version: version, + SupportedVersions: []uint32{version}, + UnitInfo: dexzec.UnitInfo, + AvailableWallets: []*asset.WalletDefinition{{ + Type: walletTypeRPC, + Tab: "External", + Description: "Connect to zcashd", + DefaultConfigPath: dexbtc.SystemConfigPath("zcash"), + ConfigOpts: configOpts, + NoAuth: true, + }}, + } +) + +func init() { + asset.Register(BipID, &Driver{}) +} + +// Driver implements asset.Driver. +type Driver struct{} + +// Open creates the ZEC exchange wallet. Start the wallet with its Run method. +func (d *Driver) Open(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) { + return NewWallet(cfg, logger, network) +} + +// DecodeCoinID creates a human-readable representation of a coin ID for +// Zcash. +func (d *Driver) DecodeCoinID(coinID []byte) (string, error) { + // Zcash shielded transactions don't have transparent outputs, so the coinID + // will just be the tx hash. + if len(coinID) == chainhash.HashSize { + var txHash chainhash.Hash + copy(txHash[:], coinID) + return txHash.String(), nil + } + // For transparent transactions, Zcash and Bitcoin have the same tx hash + // and output format. + return (&btc.Driver{}).DecodeCoinID(coinID) +} + +// Info returns basic information about the wallet and asset. +func (d *Driver) Info() *asset.WalletInfo { + return WalletInfo +} + +// WalletConfig are wallet-level configuration settings. +type WalletConfig struct { + UseSplitTx bool `ini:"txsplit"` + RedeemConfTarget uint64 `ini:"redeemconftarget"` + ActivelyUsed bool `ini:"special_activelyUsed"` // injected by core +} + +func newRPCConnection(cfg *dexbtc.RPCConfig) (*rpcclient.Client, error) { + return rpcclient.New(&rpcclient.ConnConfig{ + HTTPPostMode: true, + DisableTLS: true, + Host: cfg.RPCBind, + User: cfg.RPCUser, + Pass: cfg.RPCPass, + }, nil) +} + +// NewWallet is the exported constructor by which the DEX will import the +// exchange wallet. The wallet will shut down when the provided context is +// canceled. The configPath can be an empty string, in which case the standard +// system location of the zcashd config file is assumed. +func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, net dex.Network) (asset.Wallet, error) { + var btcParams *chaincfg.Params + var addrParams *dexzec.AddressParams + switch net { + case dex.Mainnet: + btcParams = dexzec.MainNetParams + addrParams = dexzec.MainNetAddressParams + case dex.Testnet: + btcParams = dexzec.TestNet4Params + addrParams = dexzec.TestNet4AddressParams + case dex.Regtest: + btcParams = dexzec.RegressionNetParams + addrParams = dexzec.RegressionNetAddressParams + default: + return nil, fmt.Errorf("unknown network ID %v", net) + } + + // Designate the clone ports. These will be overwritten by any explicit + // settings in the configuration file. + ports := dexbtc.NetPorts{ + Mainnet: "8232", + Testnet: "18232", + Simnet: "18232", + } + + var walletCfg WalletConfig + err := config.Unmapify(cfg.Settings, &walletCfg) + if err != nil { + return nil, err + } + + ar, err := btc.NewAddressRecycler(filepath.Join(cfg.DataDir, "recycled-addrs.txt"), logger) + if err != nil { + return nil, fmt.Errorf("error creating address recycler: %w", err) + } + + var rpcCfg dexbtc.RPCConfig + if err := config.Unmapify(cfg.Settings, &rpcCfg); err != nil { + return nil, fmt.Errorf("error reading settings: %w", err) + } + + if err := dexbtc.CheckRPCConfig(&rpcCfg, "Zcash", net, ports); err != nil { + return nil, fmt.Errorf("rpc config error: %v", err) + } + + cl, err := newRPCConnection(&rpcCfg) + if err != nil { + return nil, fmt.Errorf("error constructing rpc client: %w", err) + } + + zw := &zecWallet{ + peersChange: cfg.PeersChange, + emit: cfg.Emit, + log: logger, + net: net, + btcParams: btcParams, + addrParams: addrParams, + decodeAddr: func(addr string, net *chaincfg.Params) (btcutil.Address, error) { + return dexzec.DecodeAddress(addr, addrParams, btcParams) + }, + ar: ar, + node: cl, + } + zw.walletCfg.Store(&walletCfg) + zw.prepareCoinManager() + zw.prepareRedemptionFinder() + return zw, nil +} + +type rpcCaller interface { + CallRPC(method string, args []any, thing any) error +} + +// zecWallet is an asset.Wallet for Zcash. zecWallet is a shielded-first wallet. +// This has a number of implications. +// 1. zecWallet will, by default, keep funds in the shielded pool. +// 2. If you send funds with z_sendmany, the change will go to the shielded pool. +// This means that we cannot maintain a transparent pool at all, since this +// behavior is unavoidable in the Zcash API, so sending funds to ourselves, +// for instance, would probably result in sending more into shielded than +// we wanted. This does not apply to fully transparent transactions such as +// swaps and redemptions. +// 3. When funding is requested for an order, we will generally generate a +// "split transaction", but one that moves funds from the shielded pool to +// a transparent receiver. This will be default behavior for Zcash now, and +// cannot be disabled via configuration. +// 4. ... +type zecWallet struct { + // 64-bit atomic variables first. See + // https://golang.org/pkg/sync/atomic/#pkg-note-BUG + tipAtConnect int64 + + ctx context.Context + log dex.Logger + net dex.Network + lastAddress atomic.Value // "string" + btcParams *chaincfg.Params + addrParams *dexzec.AddressParams + node btc.RawRequester + ar *btc.AddressRecycler + rf *btc.RedemptionFinder + decodeAddr dexbtc.AddressDecoder + lastPeerCount uint32 + peersChange func(uint32, error) + emit *asset.WalletEmitter + walletCfg atomic.Value // *WalletConfig + + // Coins returned by Fund are cached for quick reference. + cm *btc.CoinManager + + tipMtx sync.RWMutex + currentTip *btc.BlockVector + + reserves atomic.Uint64 +} + +var _ asset.FeeRater = (*zecWallet)(nil) +var _ asset.Wallet = (*zecWallet)(nil) + +// DRAFT TODO: Implement LiveReconfigurer +// var _ asset.LiveReconfigurer = (*zecWallet)(nil) + +func (w *zecWallet) CallRPC(method string, args []any, thing any) error { + return btc.Call(w.ctx, w.node, method, args, thing) +} + +// FeeRate returns the asset standard fee rate for Zcash. +func (w *zecWallet) FeeRate() uint64 { + return dexzec.LegacyFeeRate +} + +func (w *zecWallet) Connect(ctx context.Context) (*sync.WaitGroup, error) { + w.ctx = ctx + var wg sync.WaitGroup + + if err := w.connectRPC(ctx); err != nil { + return nil, err + } + + fullTBalance, err := getBalance(w) + if err != nil { + return nil, fmt.Errorf("error getting account-wide t-balance: %w", err) + } + + const minConfs = 0 + acctBal, err := zGetBalanceForAccount(w, shieldedAcctNumber, minConfs) + if err != nil { + return nil, fmt.Errorf("error getting account balance: %w", err) + } + + locked, err := w.lockedZats() + if err != nil { + return nil, fmt.Errorf("error getting locked zats: %w", err) + } + + if acctBal.Transparent+locked != fullTBalance { + return nil, errors.New( + "there appears to be some transparent balance that is not in the zeroth account. " + + "To operate correctly, all balance must be in the zeroth account. " + + "Move all balance to the zeroth account to use the Zcash wallet", + ) + } + + // Initialize the best block. + bestBlockHdr, err := getBestBlockHeader(w) + if err != nil { + return nil, fmt.Errorf("error initializing best block: %w", err) + } + bestBlockHash, err := chainhash.NewHashFromStr(bestBlockHdr.Hash) + if err != nil { + return nil, fmt.Errorf("invalid best block hash from node: %v", err) + } + + bestBlock := &btc.BlockVector{Height: bestBlockHdr.Height, Hash: *bestBlockHash} + w.log.Infof("Connected wallet with current best block %v (%d)", bestBlock.Hash, bestBlock.Height) + w.tipMtx.Lock() + w.currentTip = bestBlock + w.tipMtx.Unlock() + atomic.StoreInt64(&w.tipAtConnect, w.currentTip.Height) + + wg.Add(1) + go func() { + defer wg.Done() + <-ctx.Done() + w.ar.WriteRecycledAddrsToFile() + }() + + wg.Add(1) + go func() { + defer wg.Done() + w.watchBlocks(ctx) + w.rf.CancelRedemptionSearches() + }() + wg.Add(1) + go func() { + defer wg.Done() + w.monitorPeers(ctx) + }() + return &wg, nil +} + +func (w *zecWallet) monitorPeers(ctx context.Context) { + ticker := time.NewTicker(peerCountTicker) + defer ticker.Stop() + for { + w.checkPeers() + + select { + case <-ticker.C: + case <-ctx.Done(): + return + } + } +} + +func (w *zecWallet) checkPeers() { + numPeers, err := peerCount(w) + if err != nil { + prevPeer := atomic.SwapUint32(&w.lastPeerCount, 0) + if prevPeer != 0 { + w.log.Errorf("Failed to get peer count: %v", err) + w.peersChange(0, err) + } + return + } + prevPeer := atomic.SwapUint32(&w.lastPeerCount, numPeers) + if prevPeer != numPeers { + w.peersChange(numPeers, nil) + } +} + +func (w *zecWallet) prepareCoinManager() { + w.cm = btc.NewCoinManager( + w.log, + w.btcParams, + func(val, lots, maxFeeRate uint64, reportChange bool) btc.EnoughFunc { // orderEnough + return func(inputCount, inputsSize, sum uint64) (bool, uint64) { + req := dexzec.RequiredOrderFunds(val, inputCount, inputsSize, lots) + if sum >= req { + excess := sum - req + if !reportChange || isDust(val, dexbtc.P2SHOutputSize) { + excess = 0 + } + return true, excess + } + return false, 0 + } + }, + func() ([]*btc.ListUnspentResult, error) { // list + return listUnspent(w) + }, + func(unlock bool, ops []*btc.Output) error { // lock + return lockUnspent(w, unlock, ops) + }, + func() ([]*btc.RPCOutpoint, error) { // listLocked + return listLockUnspent(w, w.log) + }, + func(txHash *chainhash.Hash, vout uint32) (*wire.TxOut, error) { + walletTx, err := getWalletTransaction(w, txHash) + if err != nil { + return nil, err + } + tx, err := dexzec.DeserializeTx(walletTx.Bytes) + if err != nil { + w.log.Warnf("Invalid transaction %v (%x): %v", txHash, walletTx.Bytes, err) + return nil, nil + } + if vout >= uint32(len(tx.TxOut)) { + w.log.Warnf("Invalid vout %d for %v", vout, txHash) + return nil, nil + } + return tx.TxOut[vout], nil + }, + func(addr btcutil.Address) (string, error) { + return dexzec.EncodeAddress(addr, w.addrParams) + }, + ) +} + +func (w *zecWallet) prepareRedemptionFinder() { + w.rf = btc.NewRedemptionFinder( + w.log, + func(h *chainhash.Hash) (*btc.GetTransactionResult, error) { + return getWalletTransaction(w, h) + }, + func(h *chainhash.Hash) (int32, error) { + return getBlockHeight(w, h) + }, + func(h chainhash.Hash) (*wire.MsgBlock, error) { + blk, err := getBlock(w, h) + if err != nil { + return nil, err + } + return &blk.MsgBlock, nil + }, + func(h *chainhash.Hash) (hdr *btc.BlockHeader, mainchain bool, err error) { + return getVerboseBlockHeader(w, h) + }, + hashTx, + deserializeTx, + func() (int32, error) { + return getBestBlockHeight(w) + }, + func(ctx context.Context, reqs map[btc.OutPoint]*btc.FindRedemptionReq, blockHash chainhash.Hash) (discovered map[btc.OutPoint]*btc.FindRedemptionResult) { + blk, err := getBlock(w, blockHash) + if err != nil { + w.log.Errorf("RPC GetBlock error: %v", err) + return + } + return btc.SearchBlockForRedemptions(ctx, reqs, &blk.MsgBlock, false, hashTx, w.btcParams) + }, + func(h int64) (*chainhash.Hash, error) { + return getBlockHash(w, h) + }, + func(ctx context.Context, reqs map[btc.OutPoint]*btc.FindRedemptionReq) (discovered map[btc.OutPoint]*btc.FindRedemptionResult) { + getRawMempool := func() ([]*chainhash.Hash, error) { + return getRawMempool(w) + } + getMsgTx := func(txHash *chainhash.Hash) (*wire.MsgTx, error) { + tx, err := getZecTransaction(w, txHash) + if err != nil { + return nil, err + } + return tx.MsgTx, nil + } + return btc.FindRedemptionsInMempool(ctx, w.log, reqs, getRawMempool, getMsgTx, false, hashTx, w.btcParams) + }, + ) +} + +func (w *zecWallet) connectRPC(ctx context.Context) error { + netVer, _, err := getVersion(w) + if err != nil { + return fmt.Errorf("error getting version: %w", err) + } + if netVer < minNetworkVersion { + return fmt.Errorf("reported node version %d is less than minimum %d", netVer, minNetworkVersion) + } + chainInfo, err := getBlockchainInfo(w) + if err != nil { + return fmt.Errorf("getblockchaininfo error: %w", err) + } + if !btc.ChainOK(w.net, chainInfo.Chain) { + return errors.New("wrong net") + } + // Make sure we have zeroth and first account or are able to create them. + accts, err := zListAccounts(w) + if err != nil { + return fmt.Errorf("error listing Zcash accounts: %w", err) + } + + createAccount := func(n uint32) error { + for _, acct := range accts { + if acct.Number == n { + return nil + } + } + acctNumber, err := zGetNewAccount(w) + if err != nil { + if strings.Contains(err.Error(), "zcashd-wallet-tool") { + return fmt.Errorf("account %d does not exist and cannot be created because wallet seed backup has not been acknowledged with the zcashd-wallet-tool utility", n) + } + return fmt.Errorf("error creating account %d: %w", n, err) + } + if acctNumber != n { + return fmt.Errorf("no account %d found and newly created account has unexpected account number %d", n, acctNumber) + } + return nil + } + if err := createAccount(shieldedAcctNumber); err != nil { + return err + } + return nil +} + +// watchBlocks pings for new blocks and runs the tipChange callback function +// when the block changes. +func (w *zecWallet) watchBlocks(ctx context.Context) { + ticker := time.NewTicker(blockTicker) + defer ticker.Stop() + + for { + select { + + // Poll for the block. If the wallet offers tip reports, delay reporting + // the tip to give the wallet a moment to request and scan block data. + case <-ticker.C: + newTipHdr, err := getBestBlockHeader(w) + if err != nil { + w.log.Errorf("failed to get best block header from node: %w", err) + continue + } + newTipHash, err := chainhash.NewHashFromStr(newTipHdr.Hash) + if err != nil { + w.log.Errorf("invalid best block hash from node: %v", err) + continue + } + + w.tipMtx.RLock() + sameTip := w.currentTip.Hash == *newTipHash + w.tipMtx.RUnlock() + if sameTip { + continue + } + + newTip := &btc.BlockVector{Height: newTipHdr.Height, Hash: *newTipHash} + w.reportNewTip(ctx, newTip) + + case <-ctx.Done(): + return + } + + // Ensure context cancellation takes priority before the next iteration. + if ctx.Err() != nil { + return + } + } +} + +// reportNewTip sets the currentTip. The tipChange callback function is invoked +// and a goroutine is started to check if any contracts in the +// findRedemptionQueue are redeemed in the new blocks. +func (w *zecWallet) reportNewTip(ctx context.Context, newTip *btc.BlockVector) { + w.tipMtx.Lock() + defer w.tipMtx.Unlock() + + prevTip := w.currentTip + w.currentTip = newTip + w.log.Debugf("tip change: %d (%s) => %d (%s)", prevTip.Height, prevTip.Hash, newTip.Height, newTip.Hash) + w.emit.TipChange(uint64(newTip.Height)) + + w.rf.ReportNewTip(ctx, prevTip, newTip) +} + +type swapOptions struct { + Split *bool `ini:"swapsplit"` + // DRAFT TODO: Strip swapfeebump from PreSwap results. + // FeeBump *float64 `ini:"swapfeebump"` +} + +func (w *zecWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes, uint64, error) { + ordValStr := btcutil.Amount(ord.Value).String() + w.log.Debugf("Attempting to fund Zcash order, maxFeeRate = %d, max swaps = %d", + ord.MaxFeeRate, ord.MaxSwapCount) + + if ord.Value == 0 { + return nil, nil, 0, newError(errNoFundsRequested, "cannot fund value = 0") + } + if ord.MaxSwapCount == 0 { + return nil, nil, 0, fmt.Errorf("cannot fund a zero-lot order") + } + + var customCfg swapOptions + err := config.Unmapify(ord.Options, &customCfg) + if err != nil { + return nil, nil, 0, fmt.Errorf("error parsing swap options: %w", err) + } + + useSplit := w.useSplitTx() + if customCfg.Split != nil { + useSplit = *customCfg.Split + } + + bals, err := w.balances() + if err != nil { + return nil, nil, 0, fmt.Errorf("balances error: %w", err) + } + + utxos, _, _, err := w.cm.SpendableUTXOs(0) + if err != nil { + return nil, nil, 0, newError(errFunding, "error listing utxos: %w", err) + } + + sum, size, shieldedSplitNeeded, shieldedSplitFees, coins, fundingCoins, redeemScripts, spents, err := w.fund( + ord.Value, ord.MaxSwapCount, utxos, bals.orchard, + ) + if err != nil { + return nil, nil, 0, err + } + + inputsSize := size + uint64(wire.VarIntSerializeSize(uint64(len(coins)))) + var transparentSplitFees uint64 + + if shieldedSplitNeeded > 0 { + acctAddr, err := w.lastShieldedAddress() + if err != nil { + return nil, nil, 0, fmt.Errorf("lastShieldedAddress error: %w", err) + } + + toAddrBTC, err := transparentAddress(w, w.addrParams, w.btcParams) + if err != nil { + return nil, nil, 0, fmt.Errorf("DepositAddress error: %w", err) + } + + toAddrStr, err := dexzec.EncodeAddress(toAddrBTC, w.addrParams) + if err != nil { + return nil, nil, 0, fmt.Errorf("EncodeAddress error: %w", err) + } + + pkScript, err := txscript.PayToAddrScript(toAddrBTC) + if err != nil { + return nil, nil, 0, fmt.Errorf("PayToAddrScript error: %w", err) + } + + txHash, err := w.sendOne(w.ctx, acctAddr, toAddrStr, shieldedSplitNeeded, AllowRevealedRecipients) + if err != nil { + return nil, nil, 0, fmt.Errorf("error sending shielded split tx: %w", err) + } + + tx, err := getTransaction(w, txHash) + if err != nil { + return nil, nil, 0, fmt.Errorf("error retreiving split transaction %s: %w", txHash, err) + } + + var splitOutput *wire.TxOut + var splitOutputIndex int + for vout, txOut := range tx.TxOut { + if txOut.Value >= int64(shieldedSplitNeeded) && bytes.Equal(txOut.PkScript, pkScript) { + splitOutput = txOut + splitOutputIndex = vout + break + } + } + if splitOutput == nil { + return nil, nil, 0, fmt.Errorf("split output of size %d not found in transaction %s", shieldedSplitNeeded, txHash) + } + + op := btc.NewOutput(txHash, uint32(splitOutputIndex), uint64(splitOutput.Value)) + fundingCoins = map[btc.OutPoint]*btc.UTxO{op.Pt: { + TxHash: &op.Pt.TxHash, + Vout: op.Pt.Vout, + Address: toAddrStr, + Amount: shieldedSplitNeeded, + }} + coins = []asset.Coin{op} + redeemScripts = []dex.Bytes{nil} + spents = []*btc.Output{op} + } else if useSplit { + // No shielded split needed. Should we do a split to avoid overlock. + splitTxFees := dexzec.TxFeesZIP317(inputsSize, 2*dexbtc.P2PKHOutputSize+1, 0, 0, 0, 0) + requiredForOrderWithoutSplit := dexzec.RequiredOrderFunds(ord.Value, uint64(len(coins)), inputsSize, ord.MaxSwapCount) + excessWithoutSplit := sum - requiredForOrderWithoutSplit + if splitTxFees >= excessWithoutSplit { + w.log.Debugf("Skipping split transaction because cost is greater than potential over-lock. "+ + "%s > %s", btcutil.Amount(splitTxFees), btcutil.Amount(excessWithoutSplit)) + } else { + splitOutputVal := dexzec.RequiredOrderFunds(ord.Value, 1, dexbtc.RedeemP2PKHInputSize, ord.MaxSwapCount) + transparentSplitFees = splitTxFees + baseTx, _, _, err := w.fundedTx(spents) + if err != nil { + return nil, nil, 0, fmt.Errorf("fundedTx error: %w", err) + } + + splitOutputAddr, err := transparentAddress(w, w.addrParams, w.btcParams) + if err != nil { + return nil, nil, 0, fmt.Errorf("transparentAddress (0) error: %w", err) + } + splitOutputScript, err := txscript.PayToAddrScript(splitOutputAddr) + if err != nil { + return nil, nil, 0, fmt.Errorf("split output addr PayToAddrScript error: %w", err) + } + baseTx.AddTxOut(wire.NewTxOut(int64(splitOutputVal), splitOutputScript)) + + changeAddr, err := transparentAddress(w, w.addrParams, w.btcParams) + if err != nil { + return nil, nil, 0, fmt.Errorf("transparentAddress (1) error: %w", err) + } + + splitTx, _, err := w.signTxAndAddChange(baseTx, changeAddr, sum, splitOutputVal, transparentSplitFees) + if err != nil { + return nil, nil, 0, fmt.Errorf("signTxAndAddChange error: %v", err) + } + + splitTxHash, err := sendRawTransaction(w, splitTx) + if err != nil { + return nil, nil, 0, fmt.Errorf("sendRawTransaction error: %w", err) + } + + if *splitTxHash != splitTx.TxHash() { + return nil, nil, 0, errors.New("split tx had unexpected hash") + } + + w.log.Debugf("Sent split tx spending %d outputs with a sum value of %d to get a sized output of value %d", + len(coins), sum, splitOutputVal) + + op := btc.NewOutput(splitTxHash, 0, splitOutputVal) + + addrStr, err := dexzec.EncodeAddress(splitOutputAddr, w.addrParams) + if err != nil { + return nil, nil, 0, fmt.Errorf("error stringing address %q: %w", splitOutputAddr, err) + } + + fundingCoins = map[btc.OutPoint]*btc.UTxO{op.Pt: { + TxHash: &op.Pt.TxHash, + Vout: op.Pt.Vout, + Address: addrStr, + Amount: splitOutputVal, + }} + coins = []asset.Coin{op} + redeemScripts = []dex.Bytes{nil} + spents = []*btc.Output{op} + } + } + + w.log.Debugf("Funding %s ZEC order with coins %v worth %s", ordValStr, coins, btcutil.Amount(sum)) + + w.cm.LockOutputsMap(fundingCoins) + err = lockUnspent(w, false, spents) + if err != nil { + return nil, nil, 0, newError(errLockUnspent, "LockUnspent error: %w", err) + } + return coins, redeemScripts, shieldedSplitFees + transparentSplitFees, nil +} + +// Redeem sends the redemption transaction, completing the atomic swap. +func (w *zecWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Coin, uint64, error) { + // Create a transaction that spends the referenced contract. + tx := dexzec.NewTxFromMsgTx(wire.NewMsgTx(dexzec.VersionNU5), dexzec.MaxExpiryHeight) + var totalIn uint64 + contracts := make([][]byte, 0, len(form.Redemptions)) + prevScripts := make([][]byte, 0, len(form.Redemptions)) + addresses := make([]btcutil.Address, 0, len(form.Redemptions)) + values := make([]int64, 0, len(form.Redemptions)) + var txInsSize uint64 + for _, r := range form.Redemptions { + if r.Spends == nil { + return nil, nil, 0, fmt.Errorf("no audit info") + } + + cinfo, err := btc.ConvertAuditInfo(r.Spends, w.decodeAddr, w.btcParams) + if err != nil { + return nil, nil, 0, fmt.Errorf("ConvertAuditInfo error: %w", err) + } + + // Extract the swap contract recipient and secret hash and check the secret + // hash against the hash of the provided secret. + contract := cinfo.Contract() + _, receiver, _, secretHash, err := dexbtc.ExtractSwapDetails(contract, false /* segwit */, w.btcParams) + if err != nil { + return nil, nil, 0, fmt.Errorf("error extracting swap addresses: %w", err) + } + checkSecretHash := sha256.Sum256(r.Secret) + if !bytes.Equal(checkSecretHash[:], secretHash) { + return nil, nil, 0, fmt.Errorf("secret hash mismatch") + } + pkScript, err := w.scriptHashScript(contract) + if err != nil { + return nil, nil, 0, fmt.Errorf("error constructs p2sh script: %v", err) + } + prevScripts = append(prevScripts, pkScript) + addresses = append(addresses, receiver) + contracts = append(contracts, contract) + txIn := wire.NewTxIn(cinfo.Output.WireOutPoint(), nil, nil) + tx.AddTxIn(txIn) + values = append(values, int64(cinfo.Output.Val)) + totalIn += cinfo.Output.Val + txInsSize = tx.SerializeSize() + } + + txInsSize += uint64(wire.VarIntSerializeSize(uint64(len(tx.TxIn)))) + txOutsSize := uint64(1 + dexbtc.P2PKHOutputSize) + fee := dexzec.TxFeesZIP317(txInsSize, txOutsSize, 0, 0, 0, 0) + + // Send the funds back to the exchange wallet. + redeemAddr, err := transparentAddress(w, w.addrParams, w.btcParams) + if err != nil { + return nil, nil, 0, fmt.Errorf("error getting new address from the wallet: %w", err) + } + pkScript, err := txscript.PayToAddrScript(redeemAddr) + if err != nil { + return nil, nil, 0, fmt.Errorf("error creating change script: %w", err) + } + val := totalIn - fee + txOut := wire.NewTxOut(int64(val), pkScript) + // One last check for dust. + if isDust(val, dexbtc.P2PKHOutputSize) { + return nil, nil, 0, fmt.Errorf("redeem output is dust") + } + tx.AddTxOut(txOut) + + for i, r := range form.Redemptions { + contract := contracts[i] + addr := addresses[i] + + addrStr, err := dexzec.EncodeAddress(addr, w.addrParams) + if err != nil { + return nil, nil, 0, fmt.Errorf("EncodeAddress error: %w", err) + } + + privKey, err := dumpPrivKey(w, addrStr) + if err != nil { + return nil, nil, 0, fmt.Errorf("dumpPrivKey error: %w", err) + } + defer privKey.Zero() + + redeemSig, err := signTx(tx.MsgTx, i, contract, txscript.SigHashAll, privKey, values, prevScripts) + if err != nil { + return nil, nil, 0, fmt.Errorf("tx signing error: %w", err) + } + redeemPubKey := privKey.PubKey().SerializeCompressed() + + tx.TxIn[i].SignatureScript, err = dexbtc.RedeemP2SHContract(contract, redeemSig, redeemPubKey, r.Secret) + if err != nil { + return nil, nil, 0, fmt.Errorf("RedeemP2SHContract error: %w", err) + } + } + + // Send the transaction. + txHash, err := sendRawTransaction(w, tx) + if err != nil { + return nil, nil, 0, fmt.Errorf("error sending tx: %w", err) + } + // Log the change output. + coinIDs := make([]dex.Bytes, 0, len(form.Redemptions)) + for i := range form.Redemptions { + coinIDs = append(coinIDs, btc.ToCoinID(txHash, uint32(i))) + } + return coinIDs, btc.NewOutput(txHash, 0, uint64(txOut.Value)), fee, nil +} + +// scriptHashAddress returns a new p2sh address. +func (w *zecWallet) scriptHashAddress(contract []byte) (btcutil.Address, error) { + return btcutil.NewAddressScriptHash(contract, w.btcParams) +} + +func (w *zecWallet) scriptHashScript(contract []byte) ([]byte, error) { + addr, err := w.scriptHashAddress(contract) + if err != nil { + return nil, err + } + return txscript.PayToAddrScript(addr) +} + +func (w *zecWallet) ReturnCoins(unspents asset.Coins) error { + return w.cm.ReturnCoins(unspents) +} + +func (w *zecWallet) MaxOrder(ord *asset.MaxOrderForm) (*asset.SwapEstimate, error) { + _, _, maxEst, err := w.maxOrder(ord.LotSize, ord.FeeSuggestion, ord.MaxFeeRate) + return maxEst, err +} + +func (w *zecWallet) maxOrder(lotSize, feeSuggestion, maxFeeRate uint64) (utxos []*btc.CompositeUTXO, bals *balances, est *asset.SwapEstimate, err error) { + if lotSize == 0 { + return nil, nil, nil, errors.New("cannot divide by lotSize zero") + } + + utxos, _, avail, err := w.cm.SpendableUTXOs(0) + if err != nil { + return nil, nil, nil, fmt.Errorf("error parsing unspent outputs: %w", err) + } + + bals, err = w.balances() + if err != nil { + return nil, nil, nil, fmt.Errorf("error getting current balance: %w", err) + } + + avail += bals.orchard.avail + + // Start by attempting max lots with a basic fee. + lots := avail / lotSize + for lots > 0 { + est, _, _, err := w.estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate, utxos, bals.orchard, true) + // The only failure mode of estimateSwap -> zec.fund is when there is + // not enough funds, so if an error is encountered, count down the lots + // and repeat until we have enough. + if err != nil { + lots-- + continue + } + return utxos, bals, est, nil + } + + return utxos, bals, &asset.SwapEstimate{}, nil +} + +// estimateSwap prepares an *asset.SwapEstimate. +func (w *zecWallet) estimateSwap( + lots, lotSize, feeSuggestion, maxFeeRate uint64, + utxos []*btc.CompositeUTXO, + orchardBal *balanceBreakdown, + trySplit bool, +) (*asset.SwapEstimate, bool /*split used*/, uint64 /*amt locked*/, error) { + + var avail uint64 + for _, utxo := range utxos { + avail += utxo.Amount + } + val := lots * lotSize + sum, inputsSize, shieldedSplitNeeded, shieldedSplitFees, coins, _, _, _, err := w.fund(val, lots, utxos, orchardBal) + if err != nil { + return nil, false, 0, fmt.Errorf("error funding swap value %s: %w", btcutil.Amount(val), err) + } + + if shieldedSplitNeeded > 0 { + // DRAFT TODO: Do we need to "lock" anything here? + const splitLocked = 0 + return &asset.SwapEstimate{ + Lots: lots, + Value: val, + MaxFees: shieldedSplitFees, + RealisticBestCase: shieldedSplitFees, + RealisticWorstCase: shieldedSplitFees, + }, true, splitLocked, nil + } + + digestInputs := func(inputsSize uint64, withSplit bool) (reqFunds, maxFees, estHighFees, estLowFees uint64) { + n := uint64(len(coins)) + + splitFees := shieldedSplitFees + if withSplit { + splitInputsSize := inputsSize + uint64(wire.VarIntSerializeSize(n)) + splitOutputsSize := uint64(2*dexbtc.P2PKHOutputSize + 1) + splitFees = dexzec.TxFeesZIP317(splitInputsSize, splitOutputsSize, 0, 0, 0, 0) + inputsSize = dexbtc.RedeemP2PKHInputSize + n = 1 + } + + firstSwapInputsSize := inputsSize + uint64(wire.VarIntSerializeSize(n)) + singleOutputSize := uint64(dexbtc.P2SHOutputSize+dexbtc.P2PKHOutputSize) + 1 + estLowFees = dexzec.TxFeesZIP317(firstSwapInputsSize, singleOutputSize, 0, 0, 0, 0) + + req := dexzec.RequiredOrderFunds(val, n, inputsSize, lots) + maxFees = req - val + splitFees + estHighFees = maxFees + return + } + + // Math for split transactions is a little different. + if trySplit { + baggage := dexzec.TxFeesZIP317(inputsSize, 2*dexbtc.P2PKHOutputSize+1, 0, 0, 0, 0) + excess := sum - dexzec.RequiredOrderFunds(val, uint64(len(coins)), inputsSize, lots) + if baggage >= excess { + reqFunds, maxFees, estHighFees, estLowFees := digestInputs(inputsSize, true) + return &asset.SwapEstimate{ + Lots: lots, + Value: val, + MaxFees: maxFees, + RealisticBestCase: estLowFees, + RealisticWorstCase: estHighFees, + }, true, reqFunds, nil + } + } + + _, maxFees, estHighFees, estLowFees := digestInputs(inputsSize, false) + return &asset.SwapEstimate{ + Lots: lots, + Value: val, + MaxFees: maxFees, + RealisticBestCase: estLowFees, + RealisticWorstCase: estHighFees, + }, false, sum, nil +} + +// PreSwap get order estimates and order options based on the available funds +// and user-selected options. +func (w *zecWallet) PreSwap(req *asset.PreSwapForm) (*asset.PreSwap, error) { + // Start with the maxOrder at the default configuration. This gets us the + // utxo set, the network fee rate, and the wallet's maximum order size. The + // utxo set can then be used repeatedly in estimateSwap at virtually zero + // cost since there are no more RPC calls. + utxos, bals, maxEst, err := w.maxOrder(req.LotSize, req.FeeSuggestion, req.MaxFeeRate) + if err != nil { + return nil, err + } + if maxEst.Lots < req.Lots { + return nil, fmt.Errorf("%d lots available for %d-lot order", maxEst.Lots, req.Lots) + } + + // Load the user's selected order-time options. + customCfg := new(swapOptions) + err = config.Unmapify(req.SelectedOptions, customCfg) + if err != nil { + return nil, fmt.Errorf("error parsing selected swap options: %w", err) + } + + // Parse the configured split transaction. + useSplit := w.useSplitTx() + if customCfg.Split != nil { + useSplit = *customCfg.Split + } + + // Always offer the split option, even for non-standing orders since + // immediately spendable change many be desirable regardless. + opts := []*asset.OrderOption{w.splitOption(req, utxos, bals.orchard)} + + est, _, _, err := w.estimateSwap(req.Lots, req.LotSize, req.FeeSuggestion, req.MaxFeeRate, utxos, bals.orchard, useSplit) + if err != nil { + return nil, err + } + return &asset.PreSwap{ + Estimate: est, // may be nil so we can present options, which in turn affect estimate feasibility + Options: opts, + }, nil +} + +// splitOption constructs an *asset.OrderOption with customized text based on the +// difference in fees between the configured and test split condition. +func (w *zecWallet) splitOption(req *asset.PreSwapForm, utxos []*btc.CompositeUTXO, orchardBal *balanceBreakdown) *asset.OrderOption { + opt := &asset.OrderOption{ + ConfigOption: asset.ConfigOption{ + Key: "swapsplit", + DisplayName: "Pre-size Funds", + IsBoolean: true, + DefaultValue: w.useSplitTx(), // not nil interface + ShowByDefault: true, + }, + Boolean: &asset.BooleanConfig{}, + } + + noSplitEst, _, noSplitLocked, err := w.estimateSwap(req.Lots, req.LotSize, + req.FeeSuggestion, req.MaxFeeRate, utxos, orchardBal, false) + if err != nil { + w.log.Errorf("estimateSwap (no split) error: %v", err) + opt.Boolean.Reason = fmt.Sprintf("estimate without a split failed with \"%v\"", err) + return opt // utility and overlock report unavailable, but show the option + } + splitEst, splitUsed, splitLocked, err := w.estimateSwap(req.Lots, req.LotSize, + req.FeeSuggestion, req.MaxFeeRate, utxos, orchardBal, true) + if err != nil { + w.log.Errorf("estimateSwap (with split) error: %v", err) + opt.Boolean.Reason = fmt.Sprintf("estimate with a split failed with \"%v\"", err) + return opt // utility and overlock report unavailable, but show the option + } + + if !splitUsed || splitLocked >= noSplitLocked { // locked check should be redundant + opt.Boolean.Reason = "avoids no ZEC overlock for this order (ignored)" + opt.Description = "A split transaction for this order avoids no ZEC overlock, " + + "but adds additional fees." + opt.DefaultValue = false + return opt // not enabled by default, but explain why + } + + overlock := noSplitLocked - splitLocked + pctChange := (float64(splitEst.RealisticWorstCase)/float64(noSplitEst.RealisticWorstCase) - 1) * 100 + if pctChange > 1 { + opt.Boolean.Reason = fmt.Sprintf("+%d%% fees, avoids %s ZEC overlock", int(math.Round(pctChange)), btcutil.Amount(overlock).String()) + } else { + opt.Boolean.Reason = fmt.Sprintf("+%.1f%% fees, avoids %s ZEC overlock", pctChange, btcutil.Amount(overlock).String()) + } + + xtraFees := splitEst.RealisticWorstCase - noSplitEst.RealisticWorstCase + opt.Description = fmt.Sprintf("Using a split transaction to prevent temporary overlock of %s ZEC, but for additional fees of %s ZEC", + btcutil.Amount(overlock).String(), btcutil.Amount(xtraFees).String()) + + return opt +} + +// SingleLotSwapRefundFees returns the fees for a swap and refund transaction +// for a single lot. +func (w *zecWallet) SingleLotSwapRefundFees(_ uint32, feeSuggestion uint64, useSafeTxSize bool) (swapFees uint64, refundFees uint64, err error) { + var numInputs uint64 + if useSafeTxSize { + numInputs = 12 + } else { + numInputs = 2 + } + + inputsSize := numInputs*dexbtc.RedeemP2PKHInputSize + 1 + outputsSize := uint64(dexbtc.P2PKHOutputSize + 1) + swapFees = dexzec.TxFeesZIP317(inputsSize, outputsSize, 0, 0, 0, 0) + refundFees = dexzec.TxFeesZIP317(dexbtc.RefundSigScriptSize+1, dexbtc.P2PKHOutputSize+1, 0, 0, 0, 0) + + return swapFees, refundFees, nil +} + +func (w *zecWallet) PreRedeem(form *asset.PreRedeemForm) (*asset.PreRedeem, error) { + return w.preRedeem(form.Lots, form.FeeSuggestion, form.SelectedOptions) +} + +func (w *zecWallet) preRedeem(numLots, _ uint64, options map[string]string) (*asset.PreRedeem, error) { + singleInputsSize := uint64(dexbtc.TxInOverhead + dexbtc.RedeemSwapSigScriptSize + 1) + singleOutputsSize := uint64(dexbtc.P2PKHOutputSize + 1) + singleMatchFees := dexzec.TxFeesZIP317(singleInputsSize, singleOutputsSize, 0, 0, 0, 0) + return &asset.PreRedeem{ + Estimate: &asset.RedeemEstimate{ + RealisticWorstCase: singleMatchFees * numLots, + RealisticBestCase: singleMatchFees, + }, + }, nil +} + +func (w *zecWallet) SingleLotRedeemFees(_ uint32, feeSuggestion uint64) (uint64, error) { + singleInputsSize := uint64(dexbtc.TxInOverhead + dexbtc.RedeemSwapSigScriptSize + 1) + singleOutputsSize := uint64(dexbtc.P2PKHOutputSize + 1) + return dexzec.TxFeesZIP317(singleInputsSize, singleOutputsSize, 0, 0, 0, 0), nil +} + +// FundingCoins gets funding coins for the coin IDs. The coins are locked. This +// method might be called to reinitialize an order from data stored externally. +// This method will only return funding coins, e.g. unspent transaction outputs. +func (w *zecWallet) FundingCoins(ids []dex.Bytes) (asset.Coins, error) { + return w.cm.FundingCoins(ids) +} + +func decodeCoinID(coinID dex.Bytes) (*chainhash.Hash, uint32, error) { + if len(coinID) != 36 { + return nil, 0, fmt.Errorf("coin ID wrong length. expected 36, got %d", len(coinID)) + } + var txHash chainhash.Hash + copy(txHash[:], coinID[:32]) + return &txHash, binary.BigEndian.Uint32(coinID[32:]), nil +} + +// fundedTx creates and returns a new MsgTx with the provided coins as inputs. +func (w *zecWallet) fundedTx(coins []*btc.Output) (*dexzec.Tx, uint64, []btc.OutPoint, error) { + baseTx := zecTx(wire.NewMsgTx(dexzec.VersionNU5)) + totalIn, pts, err := w.addInputsToTx(baseTx, coins) + if err != nil { + return nil, 0, nil, err + } + return baseTx, totalIn, pts, nil +} + +func (w *zecWallet) addInputsToTx(tx *dexzec.Tx, coins []*btc.Output) (uint64, []btc.OutPoint, error) { + var totalIn uint64 + // Add the funding utxos. + pts := make([]btc.OutPoint, 0, len(coins)) + for _, op := range coins { + totalIn += op.Val + txIn := wire.NewTxIn(op.WireOutPoint(), []byte{}, nil) + tx.AddTxIn(txIn) + pts = append(pts, op.Pt) + } + return totalIn, pts, nil +} + +// signTxAndAddChange signs the passed tx and adds a change output if the change +// wouldn't be dust. Returns but does NOT broadcast the signed tx. +func (w *zecWallet) signTxAndAddChange(baseTx *dexzec.Tx, addr btcutil.Address, totalIn, totalOut, fees uint64) (*dexzec.Tx, *btc.Output, error) { + + makeErr := func(s string, a ...any) (*dexzec.Tx, *btc.Output, error) { + return nil, nil, fmt.Errorf(s, a...) + } + + // Sign the transaction to get an initial size estimate and calculate whether + // a change output would be dust. + remaining := totalIn - totalOut + if fees > remaining { + b, _ := baseTx.Bytes() + return makeErr("not enough funds to cover minimum fee rate. %s < %s, raw tx: %x", + btcutil.Amount(totalIn), btcutil.Amount(fees+totalOut), b) + } + + // Create a change output. + changeScript, err := txscript.PayToAddrScript(addr) + if err != nil { + return makeErr("error creating change script: %v", err) + } + + changeIdx := len(baseTx.TxOut) + changeValue := remaining - fees + changeOutput := wire.NewTxOut(int64(changeValue), changeScript) + + // If the change is not dust, recompute the signed txn size and iterate on + // the fees vs. change amount. + changeAdded := !isDust(changeValue, dexbtc.P2PKHOutputSize) + if changeAdded { + // Add the change output. + baseTx.AddTxOut(changeOutput) + } else { + w.log.Debugf("Foregoing change worth up to %v because it is dust", changeOutput.Value) + } + + msgTx, err := signTxByRPC(w, baseTx) + if err != nil { + b, _ := baseTx.Bytes() + return makeErr("signing error: %v, raw tx: %x", err, b) + } + + txHash := msgTx.TxHash() + var change *btc.Output + if changeAdded { + change = btc.NewOutput(&txHash, uint32(changeIdx), uint64(changeOutput.Value)) + } + + return msgTx, change, nil +} + +type balanceBreakdown struct { + avail uint64 + maturing uint64 + noteCount uint32 +} + +type balances struct { + orchard *balanceBreakdown + sapling uint64 + transparent uint64 +} + +func (w *zecWallet) balances() (*balances, error) { + zeroConf, err := zGetBalanceForAccount(w, shieldedAcctNumber, 0) + if err != nil { + return nil, fmt.Errorf("z_getbalanceforaccount (0) error: %w", err) + } + mature, err := zGetBalanceForAccount(w, shieldedAcctNumber, minOrchardConfs) + if err != nil { + return nil, fmt.Errorf("z_getbalanceforaccount (3) error: %w", err) + } + noteCounts, err := zGetNotesCount(w) + if err != nil { + return nil, fmt.Errorf("z_getnotescount error: %w", err) + } + return &balances{ + orchard: &balanceBreakdown{ + avail: mature.Orchard, + maturing: zeroConf.Orchard - mature.Orchard, + noteCount: noteCounts.Orchard, + }, + sapling: zeroConf.Sapling, + transparent: zeroConf.Transparent, + }, nil +} + +func (w *zecWallet) fund( + val, maxSwapCount uint64, + utxos []*btc.CompositeUTXO, + orchardBal *balanceBreakdown, +) ( + sum, size, shieldedSplitNeeded, shieldedSplitFees uint64, + coins asset.Coins, + fundingCoins map[btc.OutPoint]*btc.UTxO, + redeemScripts []dex.Bytes, + spents []*btc.Output, + err error, +) { + + var shieldedAvail uint64 + reserves := w.reserves.Load() + nActionsOrchard := uint64(orchardBal.noteCount) + enough := func(inputCount, inputsSize, sum uint64) (bool, uint64) { + req := dexzec.RequiredOrderFunds(val, inputCount, inputsSize, maxSwapCount) + if sum >= req { + return true, sum - req + shieldedAvail + } + if shieldedAvail == 0 { + return false, 0 + } + shieldedFees := dexzec.TxFeesZIP317(inputsSize+uint64(wire.VarIntSerializeSize(inputCount)), dexbtc.P2PKHOutputSize+1, 0, 0, 0, nActionsOrchard) + req = dexzec.RequiredOrderFunds(val, 1, dexbtc.RedeemP2PKHInputSize, maxSwapCount) + if sum+shieldedAvail >= req+shieldedFees { + return true, sum + shieldedAvail - (req + shieldedFees) + } + return false, 0 + } + + coins, fundingCoins, spents, redeemScripts, size, sum, err = w.cm.FundWithUTXOs(utxos, reserves, false, enough) + if err == nil { + return + } + + shieldedAvail = orchardBal.avail + + if shieldedAvail >= reserves { + shieldedAvail -= reserves + reserves = 0 + } else { + reserves -= shieldedAvail + shieldedAvail = 0 + } + + if shieldedAvail == 0 { + err = codedError(errFunding, err) + return + } + + shieldedSplitNeeded = dexzec.RequiredOrderFunds(val, 1, dexbtc.RedeemP2PKHInputSize, maxSwapCount) + + // If we don't have any utxos see if a straight shielded split will get us there. + if len(utxos) == 0 { + // Can we do it with just the shielded balance? + shieldedSplitFees = dexzec.TxFeesZIP317(0, dexbtc.P2SHOutputSize+1, 0, 0, 0, nActionsOrchard) + req := val + shieldedSplitFees + if shieldedAvail < req { + // err is still the error from the last call to FundWithUTXOs + err = codedError(errShieldedFunding, err) + } else { + err = nil + } + return + } + + // Check with both transparent and orchard funds. (shieldedAvail has been + // set, which changes the behavior of enough. + coins, fundingCoins, spents, redeemScripts, size, sum, err = w.cm.FundWithUTXOs(utxos, reserves, false, enough) + if err != nil { + err = codedError(errFunding, err) + return + } + + req := dexzec.RequiredOrderFunds(val, uint64(len(coins)), size, maxSwapCount) + if req > sum { + txOutsSize := uint64(dexbtc.P2PKHOutputSize + 1) // 1 for varint + shieldedSplitFees = dexzec.TxFeesZIP317(size+uint64(wire.VarIntSerializeSize(uint64(len(coins)))), txOutsSize, 0, 0, 0, nActionsOrchard) + } + + if shieldedAvail+sum < shieldedSplitNeeded+shieldedSplitFees { + err = newError(errInsufficientBalance, "not enough to cover requested funds. "+ + "%d available in %d UTXOs, %d available from shielded (after bond reserves), total avail = %d, total needed = %d", + sum, len(coins), shieldedAvail, shieldedAvail+sum, shieldedSplitNeeded+shieldedSplitFees) + return + } + + return +} + +func (w *zecWallet) SetBondReserves(reserves uint64) { + w.reserves.Store(reserves) +} + +func (w *zecWallet) AuditContract(coinID, contract, txData dex.Bytes, rebroadcast bool) (*asset.AuditInfo, error) { + txHash, vout, err := decodeCoinID(coinID) + if err != nil { + return nil, err + } + // Get the receiving address. + _, receiver, stamp, secretHash, err := dexbtc.ExtractSwapDetails(contract, false /* segwit */, w.btcParams) + if err != nil { + return nil, fmt.Errorf("error extracting swap addresses: %w", err) + } + + // If no tx data is provided, attempt to get the required data (the txOut) + // from the wallet. If this is a full node wallet, a simple gettxout RPC is + // sufficient with no pkScript or "since" time. If this is an SPV wallet, + // only a confirmed counterparty contract can be located, and only one + // within ContractSearchLimit. As such, this mode of operation is not + // intended for normal server-coordinated operation. + var tx *dexzec.Tx + var txOut *wire.TxOut + if len(txData) == 0 { + // Fall back to gettxout, but we won't have the tx to rebroadcast. + txOut, _, err = getTxOut(w, txHash, vout) + if err != nil || txOut == nil { + return nil, fmt.Errorf("error finding unspent contract: %s:%d : %w", txHash, vout, err) + } + } else { + tx, err = dexzec.DeserializeTx(txData) + if err != nil { + return nil, fmt.Errorf("coin not found, and error encountered decoding tx data: %v", err) + } + if len(tx.TxOut) <= int(vout) { + return nil, fmt.Errorf("specified output %d not found in decoded tx %s", vout, txHash) + } + txOut = tx.TxOut[vout] + } + + // Check for standard P2SH. NOTE: btc.scriptHashScript(contract) should + // equal txOut.PkScript. All we really get from the TxOut is the *value*. + scriptClass, addrs, numReq, err := txscript.ExtractPkScriptAddrs(txOut.PkScript, w.btcParams) + if err != nil { + return nil, fmt.Errorf("error extracting script addresses from '%x': %w", txOut.PkScript, err) + } + if scriptClass != txscript.ScriptHashTy { + return nil, fmt.Errorf("unexpected script class. expected %s, got %s", txscript.ScriptHashTy, scriptClass) + } + // Compare the contract hash to the P2SH address. + contractHash := btcutil.Hash160(contract) + // These last two checks are probably overkill. + if numReq != 1 { + return nil, fmt.Errorf("unexpected number of signatures expected for P2SH script: %d", numReq) + } + if len(addrs) != 1 { + return nil, fmt.Errorf("unexpected number of addresses for P2SH script: %d", len(addrs)) + } + + addr := addrs[0] + if !bytes.Equal(contractHash, addr.ScriptAddress()) { + return nil, fmt.Errorf("contract hash doesn't match script address. %x != %x", + contractHash, addr.ScriptAddress()) + } + + // Broadcast the transaction, but do not block because this is not required + // and does not affect the audit result. + if rebroadcast && tx != nil { + go func() { + if hashSent, err := sendRawTransaction(w, tx); err != nil { + w.log.Debugf("Rebroadcasting counterparty contract %v (THIS MAY BE NORMAL): %v", txHash, err) + } else if !hashSent.IsEqual(txHash) { + w.log.Errorf("Counterparty contract %v was rebroadcast as %v!", txHash, hashSent) + } + }() + } + + addrStr, err := dexzec.EncodeAddress(receiver, w.addrParams) + if err != nil { + w.log.Errorf("Failed to stringify receiver address %v (default): %v", receiver, err) + } + + return &asset.AuditInfo{ + Coin: btc.NewOutput(txHash, vout, uint64(txOut.Value)), + Recipient: addrStr, + Contract: contract, + SecretHash: secretHash, + Expiration: time.Unix(int64(stamp), 0).UTC(), + }, nil +} + +func (w *zecWallet) ConfirmRedemption(coinID dex.Bytes, redemption *asset.Redemption, feeSuggestion uint64) (*asset.ConfirmRedemptionStatus, error) { + txHash, _, err := decodeCoinID(coinID) + if err != nil { + return nil, err + } + + tx, err := getWalletTransaction(w, txHash) + // redemption transaction found, return its confirms. + // + // TODO: Investigate the case where this redeem has been sitting in the + // mempool for a long amount of time, possibly requiring some action by + // us to get it unstuck. + if err == nil { + return &asset.ConfirmRedemptionStatus{ + Confs: tx.Confirmations, + Req: requiredRedeemConfirms, + CoinID: coinID, + }, nil + } + + // Redemption transaction is missing from the point of view of our node! + // Unlikely, but possible it was redeemed by another transaction. Check + // if the contract is still an unspent output. + + swapHash, vout, err := decodeCoinID(redemption.Spends.Coin.ID()) + if err != nil { + return nil, err + } + + utxo, _, err := getTxOut(w, swapHash, vout) + if err != nil { + return nil, newError(errNoTx, "error finding unspent contract %s with swap hash %v vout %d: %w", redemption.Spends.Coin.ID(), swapHash, vout, err) + } + if utxo == nil { + // TODO: Spent, but by who. Find the spending tx. + w.log.Warnf("Contract coin %v with swap hash %v vout %d spent by someone but not sure who.", redemption.Spends.Coin.ID(), swapHash, vout) + // Incorrect, but we will be in a loop of erroring if we don't + // return something. + return &asset.ConfirmRedemptionStatus{ + Confs: requiredRedeemConfirms, + Req: requiredRedeemConfirms, + CoinID: coinID, + }, nil + } + + // The contract has not yet been redeemed, but it seems the redeeming + // tx has disappeared. Assume the fee was too low at the time and it + // was eventually purged from the mempool. Attempt to redeem again with + // a currently reasonable fee. + + form := &asset.RedeemForm{ + Redemptions: []*asset.Redemption{redemption}, + } + _, coin, _, err := w.Redeem(form) + if err != nil { + return nil, fmt.Errorf("unable to re-redeem %s with swap hash %v vout %d: %w", redemption.Spends.Coin.ID(), swapHash, vout, err) + } + return &asset.ConfirmRedemptionStatus{ + Confs: 0, + Req: requiredRedeemConfirms, + CoinID: coin.ID(), + }, nil +} + +func (w *zecWallet) ContractLockTimeExpired(ctx context.Context, contract dex.Bytes) (bool, time.Time, error) { + _, _, locktime, _, err := dexbtc.ExtractSwapDetails(contract, false /* segwit */, w.btcParams) + if err != nil { + return false, time.Time{}, fmt.Errorf("error extracting contract locktime: %w", err) + } + contractExpiry := time.Unix(int64(locktime), 0).UTC() + expired, err := w.LockTimeExpired(ctx, contractExpiry) + if err != nil { + return false, time.Time{}, err + } + return expired, contractExpiry, nil +} + +func (w *zecWallet) DepositAddress() (string, error) { + return transparentAddressString(w) +} + +func (w *zecWallet) NewAddress() (string, error) { + return w.DepositAddress() +} + +// DEPRECATED +func (w *zecWallet) EstimateRegistrationTxFee(feeRate uint64) uint64 { + return math.MaxUint64 +} + +func (w *zecWallet) FindRedemption(ctx context.Context, coinID, contract dex.Bytes) (redemptionCoin, secret dex.Bytes, err error) { + return w.rf.FindRedemption(ctx, coinID) +} + +func (w *zecWallet) FundMultiOrder(mo *asset.MultiOrder, maxLock uint64) (coins []asset.Coins, redeemScripts [][]dex.Bytes, fundingFees uint64, err error) { + w.log.Debugf("Attempting to fund a multi-order for ZEC") + + var totalRequiredForOrders uint64 + var swapInputSize uint64 = dexbtc.RedeemP2PKHInputSize + for _, v := range mo.Values { + if v.Value == 0 { + return nil, nil, 0, newError(errBadInput, "cannot fund value = 0") + } + 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) + totalRequiredForOrders += req + } + + if maxLock < totalRequiredForOrders && maxLock != 0 { + return nil, nil, 0, newError(errMaxLock, "maxLock < totalRequiredForOrders (%d < %d)", maxLock, totalRequiredForOrders) + } + + bal, err := w.Balance() + if err != nil { + return nil, nil, 0, newError(errFunding, "error getting wallet balance: %w", err) + } + if bal.Available < totalRequiredForOrders { + 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) + if err != nil { + return nil, nil, 0, codedError(errFunding, err) + } + if len(coins) == len(values) || !allowSplit { + 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 + defer func() { + if !success { + lockUnspent(w, true, spents) + } + }() + + 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) + if err != nil { + return nil, 0, err + } + baseTx.AddTxOut(wire.NewTxOut(int64(req), script)) + } + + changeAddr, err := transparentAddress(w, w.addrParams, w.btcParams) + if err != nil { + return nil, 0, err + } + tx, err := w.sendWithReturn(baseTx, changeAddr, totalIn, totalRequired) + if err != nil { + return nil, 0, 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) + } + + success = true + return coins, totalIn - totalOut, nil +} + +func (w *zecWallet) fundsRequiredForMultiOrders(orders []*asset.MultiOrderValue, inputCount, inputsSize uint64) ([]uint64, uint64) { + requiredForOrders := make([]uint64, len(orders)) + var totalRequired uint64 + + for i, value := range orders { + req := dexzec.RequiredOrderFunds(value.Value, inputCount, inputsSize, value.MaxSwapCount) + requiredForOrders[i] = req + totalRequired += req + } + + return requiredForOrders, totalRequired +} + +// 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) + if err != nil { + return false, nil, nil + } + + 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 + } + } + + return true, fundSplitCoins, spents +} + +func (w *zecWallet) Info() *asset.WalletInfo { + return WalletInfo +} + +func (w *zecWallet) LockTimeExpired(_ context.Context, lockTime time.Time) (bool, error) { + chainStamper := func(blockHash *chainhash.Hash) (stamp time.Time, prevHash *chainhash.Hash, err error) { + hdr, err := getBlockHeader(w, blockHash) + if err != nil { + return + } + return hdr.Timestamp, &hdr.PrevBlock, nil + } + + w.tipMtx.RLock() + tip := w.currentTip + w.tipMtx.RUnlock() + + medianTime, err := btc.CalcMedianTime(chainStamper, &tip.Hash) // TODO: pass ctx + if err != nil { + return false, fmt.Errorf("error getting median time: %w", err) + } + return medianTime.After(lockTime), nil +} + +// 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 `ini:"multisplit"` +} + +func decodeFundMultiSettings(settings map[string]string) (*fundMultiOptions, error) { + opts := new(fundMultiOptions) + return opts, config.Unmapify(settings, opts) +} + +func (w *zecWallet) MaxFundingFees(numTrades uint32, feeRate uint64, settings map[string]string) uint64 { + customCfg, err := decodeFundMultiSettings(settings) + if err != nil { + w.log.Errorf("Error decoding multi-fund settings: %v", err) + return 0 + } + + // Assume a split from shielded + txOutsSize := uint64(numTrades*dexbtc.P2PKHOutputSize + 1) // 1 for varint + shieldedSplitFees := dexzec.TxFeesZIP317(0, txOutsSize, 0, 0, 0, nActionsOrchardEstimate) + + if !customCfg.Split { + return shieldedSplitFees + } + const numInputs = 12 // plan for lots of inputs to get a safe estimate + + return shieldedSplitFees + dexzec.TxFeesZIP317(1, uint64(numTrades+1)*dexbtc.P2PKHOutputSize, 0, 0, 0, 0) +} + +func (w *zecWallet) OwnsDepositAddress(addrStr string) (bool, error) { + return ownsAddress(w, addrStr) +} + +func (w *zecWallet) RedemptionAddress() (string, error) { + return w.recyclableAddress() +} + +// A recyclable address is a redemption or refund address that may be recycled +// if unused. If already recycled addresses are available, one will be returned. +func (w *zecWallet) recyclableAddress() (string, error) { + var returns []string + defer w.ar.ReturnAddresses(returns) + for { + addr := w.ar.Address() + if addr == "" { + break + } + if owns, err := w.OwnsDepositAddress(addr); owns { + return addr, nil + } else if err != nil { + w.log.Errorf("Error checking ownership of recycled address %q: %v", addr, err) + returns = append(returns, addr) + } + } + return w.DepositAddress() +} + +func (w *zecWallet) Refund(coinID, contract dex.Bytes, feeRate uint64) (dex.Bytes, error) { + txHash, vout, err := decodeCoinID(coinID) + if err != nil { + return nil, err + } + + // TODO: I'd recommend not passing a pkScript without a limited startTime + // to prevent potentially long searches. In this case though, the output + // will be found in the wallet and won't need to be searched for, only + // the spender search will be conducted using the pkScript starting from + // the block containing the original tx. The script can be gotten from + // the wallet tx though and used for the spender search, while not passing + // a script here to ensure no attempt is made to find the output without + // a limited startTime. + utxo, _, err := getTxOut(w, txHash, vout) + if err != nil { + return nil, fmt.Errorf("error finding unspent contract: %w", err) + } + if utxo == nil { + return nil, asset.CoinNotFoundError // spent + } + msgTx, err := w.refundTx(txHash, vout, contract, uint64(utxo.Value), nil, feeRate) + if err != nil { + return nil, fmt.Errorf("error creating refund tx: %w", err) + } + + refundHash, err := w.broadcastTx(dexzec.NewTxFromMsgTx(msgTx, dexzec.MaxExpiryHeight)) + if err != nil { + return nil, fmt.Errorf("broadcastTx: %w", err) + } + return btc.ToCoinID(refundHash, 0), nil +} + +func (w *zecWallet) broadcastTx(tx *dexzec.Tx) (*chainhash.Hash, error) { + rawTx := func() string { + b, err := tx.Bytes() + if err != nil { + return "serialization error: " + err.Error() + } + return dex.Bytes(b).String() + } + txHash, err := sendRawTransaction(w, tx) + if err != nil { + return nil, fmt.Errorf("sendrawtx error: %v: %s", err, rawTx()) + } + checkHash := tx.TxHash() + if *txHash != checkHash { + return nil, fmt.Errorf("transaction sent, but received unexpected transaction ID back from RPC server. "+ + "expected %s, got %s. raw tx: %s", checkHash, txHash, rawTx()) + } + return txHash, nil +} + +func (w *zecWallet) refundTx(txHash *chainhash.Hash, vout uint32, contract dex.Bytes, val uint64, refundAddr btcutil.Address, fees uint64) (*wire.MsgTx, error) { + sender, _, lockTime, _, err := dexbtc.ExtractSwapDetails(contract, false, w.btcParams) + if err != nil { + return nil, fmt.Errorf("error extracting swap addresses: %w", err) + } + + // Create the transaction that spends the contract. + msgTx := wire.NewMsgTx(dexzec.VersionNU5) + msgTx.LockTime = uint32(lockTime) + prevOut := wire.NewOutPoint(txHash, vout) + txIn := wire.NewTxIn(prevOut, []byte{}, nil) + // Enable the OP_CHECKLOCKTIMEVERIFY opcode to be used. + // + // https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki#Spending_wallet_policy + txIn.Sequence = wire.MaxTxInSequenceNum - 1 + msgTx.AddTxIn(txIn) + // Calculate fees and add the change output. + + if refundAddr == nil { + refundAddr, err = transparentAddress(w, w.addrParams, w.btcParams) + if err != nil { + return nil, fmt.Errorf("error getting new address from the wallet: %w", err) + } + } + pkScript, err := txscript.PayToAddrScript(refundAddr) + if err != nil { + return nil, fmt.Errorf("error creating change script: %w", err) + } + txOut := wire.NewTxOut(int64(val-fees), pkScript) + // One last check for dust. + if isDust(uint64(txOut.Value), uint64(txOut.SerializeSize())) { + return nil, fmt.Errorf("refund output is dust. value = %d, size = %d", txOut.Value, txOut.SerializeSize()) + } + msgTx.AddTxOut(txOut) + + prevScript, err := w.scriptHashScript(contract) + if err != nil { + return nil, fmt.Errorf("error constructing p2sh script: %w", err) + } + + refundSig, refundPubKey, err := w.createSig(msgTx, 0, contract, sender, []int64{int64(val)}, [][]byte{prevScript}) + if err != nil { + return nil, fmt.Errorf("createSig: %w", err) + } + txIn.SignatureScript, err = dexbtc.RefundP2SHContract(contract, refundSig, refundPubKey) + if err != nil { + return nil, fmt.Errorf("RefundP2SHContract: %w", err) + } + return msgTx, nil +} + +func (w *zecWallet) createSig(msgTx *wire.MsgTx, idx int, pkScript []byte, addr btcutil.Address, vals []int64, prevScripts [][]byte) (sig, pubkey []byte, err error) { + addrStr, err := dexzec.EncodeAddress(addr, w.addrParams) + if err != nil { + return nil, nil, fmt.Errorf("error encoding address: %w", err) + } + privKey, err := dumpPrivKey(w, addrStr) + if err != nil { + return nil, nil, fmt.Errorf("dumpPrivKey error: %w", err) + } + defer privKey.Zero() + + sig, err = signTx(msgTx, idx, pkScript, txscript.SigHashAll, privKey, vals, prevScripts) + if err != nil { + return nil, nil, err + } + + return sig, privKey.PubKey().SerializeCompressed(), nil +} + +func (w *zecWallet) RegFeeConfirmations(_ context.Context, id dex.Bytes) (confs uint32, err error) { + txHash, _, err := decodeCoinID(id) + if err != nil { + return 0, err + } + tx, err := getWalletTransaction(w, txHash) + if err != nil { + return 0, err + } + return uint32(tx.Confirmations), nil +} + +func sendEnough(val uint64) btc.EnoughFunc { + return func(inputCount, inputsSize, sum uint64) (bool, uint64) { + fees := dexzec.TxFeesZIP317(inputCount*dexbtc.RedeemP2PKHInputSize+1, 2*dexbtc.P2PKHOutputSize+1, 0, 0, 0, 0) + total := fees + val + if sum >= total { + return true, sum - total + } + return false, 0 + } +} + +func (w *zecWallet) EstimateSendTxFee( + addrStr string, val, _ /* feeRate*/ uint64, _ /* subtract */ bool, +) ( + fee uint64, isValidAddress bool, err error, +) { + + isValidAddress = w.ValidateAddress(addrStr) + + const minConfs = 0 + _, _, spents, _, inputsSize, _, err := w.cm.Fund(w.reserves.Load(), minConfs, false, sendEnough(val)) + if err != nil { + return 0, isValidAddress, newError(errFunding, "error funding transaction: %w", err) + } + fee = dexzec.TxFeesZIP317(inputsSize+uint64(wire.VarIntSerializeSize(uint64(len(spents)))), 2*dexbtc.P2PKHOutputSize+1, 0, 0, 0, 0) + return +} + +func (w *zecWallet) Send(address string, value, feeRate uint64) (asset.Coin, error) { + txHash, vout, sent, err := w.send(address, value, false) + if err != nil { + return nil, err + } + return btc.NewOutput(txHash, vout, sent), nil +} + +// send the value to the address, with the given fee rate. If subtract is true, +// the fees will be subtracted from the value. If false, the fees are in +// addition to the value. feeRate is in units of sats/byte. +func (w *zecWallet) send(addrStr string, val uint64, subtract bool) (*chainhash.Hash, uint32, uint64, error) { + addr, err := dexzec.DecodeAddress(addrStr, w.addrParams, w.btcParams) + if err != nil { + return nil, 0, 0, fmt.Errorf("invalid address: %s", addrStr) + } + pay2script, err := txscript.PayToAddrScript(addr) + if err != nil { + return nil, 0, 0, fmt.Errorf("PayToAddrScript error: %w", err) + } + + const minConfs = 0 + _, _, spents, _, inputsSize, _, err := w.cm.Fund(w.reserves.Load(), minConfs, false, sendEnough(val)) + if err != nil { + return nil, 0, 0, newError(errFunding, "error funding transaction: %w", err) + } + + fundedTx, totalIn, _, err := w.fundedTx(spents) + if err != nil { + return nil, 0, 0, fmt.Errorf("error adding inputs to transaction: %w", err) + } + + fees := dexzec.TxFeesZIP317(inputsSize, 2*dexbtc.P2PKHOutputSize+1, 0, 0, 0, 0) + var toSend uint64 + if subtract { + toSend = val - fees + } else { + toSend = val + } + fundedTx.AddTxOut(wire.NewTxOut(int64(toSend), pay2script)) + + changeAddr, err := transparentAddress(w, w.addrParams, w.btcParams) + if err != nil { + return nil, 0, 0, fmt.Errorf("error creating change address: %w", err) + } + + signedTx, _, err := w.signTxAndAddChange(fundedTx, changeAddr, totalIn, val, fees) + if err != nil { + return nil, 0, 0, fmt.Errorf("signTxAndAddChange error: %v", err) + } + + txHash, err := w.broadcastTx(signedTx) + if err != nil { + return nil, 0, 0, err } -) -func init() { - asset.Register(BipID, &Driver{}) + return txHash, 0, toSend, nil } -// Driver implements asset.Driver. -type Driver struct{} +func (w *zecWallet) sendWithReturn(baseTx *dexzec.Tx, addr btcutil.Address, totalIn, totalOut uint64) (*dexzec.Tx, error) { + txInsSize := uint64(len(baseTx.TxIn))*dexbtc.RedeemP2PKHInputSize + 1 + var txOutsSize uint64 = uint64(wire.VarIntSerializeSize(uint64(len(baseTx.TxOut) + 1))) + for _, txOut := range baseTx.TxOut { + txOutsSize += uint64(txOut.SerializeSize()) + } -// Open creates the ZEC exchange wallet. Start the wallet with its Run method. -func (d *Driver) Open(cfg *asset.WalletConfig, logger dex.Logger, network dex.Network) (asset.Wallet, error) { - return NewWallet(cfg, logger, network) -} + fees := dexzec.TxFeesZIP317(txInsSize, txOutsSize, 0, 0, 0, 0) -// DecodeCoinID creates a human-readable representation of a coin ID for -// Zcash. -func (d *Driver) DecodeCoinID(coinID []byte) (string, error) { - // Zcash shielded transactions don't have transparent outputs, so the coinID - // will just be the tx hash. - if len(coinID) == chainhash.HashSize { - var txHash chainhash.Hash - copy(txHash[:], coinID) - return txHash.String(), nil + signedTx, _, err := w.signTxAndAddChange(baseTx, addr, totalIn, totalOut, fees) + if err != nil { + return nil, err } - // For transparent transactions, Zcash and Bitcoin have the same tx hash - // and output format. - return (&btc.Driver{}).DecodeCoinID(coinID) + + _, err = w.broadcastTx(signedTx) + return signedTx, err } -// Info returns basic information about the wallet and asset. -func (d *Driver) Info() *asset.WalletInfo { - return WalletInfo +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) + } + utxo := w.cm.LockedOutput(op.Pt) + + if utxo == nil { + return nil, nil, fmt.Errorf("no utxo found for %s", op) + } + privKey, err := dumpPrivKey(w, utxo.Address) + if err != nil { + return nil, nil, err + } + defer privKey.Zero() + pk := privKey.PubKey() + hash := chainhash.HashB(msg) // legacy servers will not accept this signature! + sig := ecdsa.Sign(privKey, hash) + pubkeys = append(pubkeys, pk.SerializeCompressed()) + sigs = append(sigs, sig.Serialize()) // DER format serialization + return } -// NewWallet is the exported constructor by which the DEX will import the -// exchange wallet. The wallet will shut down when the provided context is -// canceled. The configPath can be an empty string, in which case the standard -// system location of the zcashd config file is assumed. -func NewWallet(cfg *asset.WalletConfig, logger dex.Logger, net dex.Network) (asset.Wallet, error) { - var btcParams *chaincfg.Params - var addrParams *dexzec.AddressParams - switch net { - case dex.Mainnet: - btcParams = dexzec.MainNetParams - addrParams = dexzec.MainNetAddressParams - case dex.Testnet: - btcParams = dexzec.TestNet4Params - addrParams = dexzec.TestNet4AddressParams - case dex.Regtest: - btcParams = dexzec.RegressionNetParams - addrParams = dexzec.RegressionNetAddressParams - default: - return nil, fmt.Errorf("unknown network ID %v", net) +func (w *zecWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin, uint64, error) { + contracts := make([][]byte, 0, len(swaps.Contracts)) + var totalOut uint64 + // Start with an empty MsgTx. + coins := make([]*btc.Output, len(swaps.Inputs)) + for i, coin := range swaps.Inputs { + c, err := btc.ConvertCoin(coin) + if err != nil { + return nil, nil, 0, fmt.Errorf("error converting coin ID: %w", err) + } + coins[i] = c + } + baseTx, totalIn, pts, err := w.fundedTx(coins) + if err != nil { + return nil, nil, 0, err } - // Designate the clone ports. These will be overwritten by any explicit - // settings in the configuration file. - ports := dexbtc.NetPorts{ - Mainnet: "8232", - Testnet: "18232", - Simnet: "18232", + var customCfg swapOptions + err = config.Unmapify(swaps.Options, &customCfg) + if err != nil { + return nil, nil, 0, fmt.Errorf("error parsing swap options: %w", err) } - var w *btc.ExchangeWalletNoAuth - cloneCFG := &btc.BTCCloneCFG{ - WalletCFG: cfg, - MinNetworkVersion: minNetworkVersion, - WalletInfo: WalletInfo, - Symbol: "zec", - Logger: logger, - Network: net, - ChainParams: btcParams, - Ports: ports, - DefaultFallbackFee: defaultFee, - DefaultFeeRateLimit: defaultFeeRateLimit, - LegacyRawFeeLimit: true, - BalanceFunc: func(ctx context.Context, locked uint64) (*asset.Balance, error) { - var bal uint64 - // args: "(dummy)" minconf includeWatchonly - if err := w.CallRPC("getbalance", []interface{}{"", 0, false, true}, &bal); err != nil { - return nil, err - } - return &asset.Balance{ - Available: bal - locked, - Locked: locked, - Other: make(map[asset.BalanceCategory]asset.CustomBalance), - }, nil - }, - Segwit: false, - InitTxSize: dexzec.InitTxSize, - InitTxSizeBase: dexzec.InitTxSizeBase, - OmitAddressType: true, - LegacySignTxRPC: true, - NumericGetRawRPC: true, - LegacyValidateAddressRPC: true, - SingularWallet: true, - UnlockSpends: true, - FeeEstimator: estimateFee, - ConnectFunc: func() error { - return connect(w) - }, - AddrFunc: func() (btcutil.Address, error) { - return transparentAddress(w, addrParams, btcParams) - }, - AddressDecoder: func(addr string, net *chaincfg.Params) (btcutil.Address, error) { - return dexzec.DecodeAddress(addr, addrParams, btcParams) - }, - AddressStringer: func(addr btcutil.Address, btcParams *chaincfg.Params) (string, error) { - return dexzec.EncodeAddress(addr, addrParams) - }, - TxSizeCalculator: dexzec.CalcTxSize, - NonSegwitSigner: signTx, - TxDeserializer: func(b []byte) (*wire.MsgTx, error) { - zecTx, err := dexzec.DeserializeTx(b) - if err != nil { - return nil, err - } - return zecTx.MsgTx, nil - }, - BlockDeserializer: func(b []byte) (*wire.MsgBlock, error) { - zecBlock, err := dexzec.DeserializeBlock(b) - if err != nil { - return nil, err - } - return &zecBlock.MsgBlock, nil - }, - TxSerializer: func(btcTx *wire.MsgTx) ([]byte, error) { - return zecTx(btcTx).Bytes() - }, - TxHasher: func(tx *wire.MsgTx) *chainhash.Hash { - h := zecTx(tx).TxHash() - return &h - }, - TxVersion: func() int32 { - return dexzec.VersionNU5 - }, - // https://github.com/zcash/zcash/pull/6005 - ManualMedianTime: true, - OmitRPCOptionsArg: true, - AssetID: BipID, + refundAddrs := make([]btcutil.Address, 0, len(swaps.Contracts)) + + // Add the contract outputs. + // TODO: Make P2WSH contract and P2WPKH change outputs instead of + // legacy/non-segwit swap contracts pkScripts. + var txOutsSize uint64 + for _, contract := range swaps.Contracts { + totalOut += contract.Value + // revokeAddr is the address belonging to the key that may be used to + // sign and refund a swap past its encoded refund locktime. + revokeAddrStr, err := w.recyclableAddress() + if err != nil { + return nil, nil, 0, fmt.Errorf("error creating revocation address: %w", err) + } + revokeAddr, err := dexzec.DecodeAddress(revokeAddrStr, w.addrParams, w.btcParams) + if err != nil { + return nil, nil, 0, fmt.Errorf("refund address decode error: %v", err) + } + refundAddrs = append(refundAddrs, revokeAddr) + + contractAddr, err := dexzec.DecodeAddress(contract.Address, w.addrParams, w.btcParams) + if err != nil { + return nil, nil, 0, fmt.Errorf("contract address decode error: %v", err) + } + + // Create the contract, a P2SH redeem script. + contractScript, err := dexbtc.MakeContract(contractAddr, revokeAddr, + contract.SecretHash, int64(contract.LockTime), false, w.btcParams) + if err != nil { + return nil, nil, 0, fmt.Errorf("unable to create pubkey script for address %s: %w", contract.Address, err) + } + contracts = append(contracts, contractScript) + + // Make the P2SH address and pubkey script. + scriptAddr, err := w.scriptHashAddress(contractScript) + if err != nil { + return nil, nil, 0, fmt.Errorf("error encoding script address: %w", err) + } + + pkScript, err := txscript.PayToAddrScript(scriptAddr) + if err != nil { + return nil, nil, 0, fmt.Errorf("error creating pubkey script: %w", err) + } + + // Add the transaction output. + txOut := wire.NewTxOut(int64(contract.Value), pkScript) + txOutsSize += uint64(txOut.SerializeSize()) + baseTx.AddTxOut(txOut) + } + if totalIn < totalOut { + return nil, nil, 0, newError(errInsufficientBalance, "unfunded contract. %d < %d", totalIn, totalOut) + } + + // Ensure we have enough outputs before broadcasting. + swapCount := len(swaps.Contracts) + if len(baseTx.TxOut) < swapCount { + return nil, nil, 0, fmt.Errorf("fewer outputs than swaps. %d < %d", len(baseTx.TxOut), swapCount) } - var err error - w, err = btc.BTCCloneWalletNoAuth(cloneCFG) + // Grab a change address. + changeAddr, err := transparentAddress(w, w.addrParams, w.btcParams) if err != nil { - return nil, err + return nil, nil, 0, fmt.Errorf("error creating change address: %w", err) + } + + txInsSize := uint64(len(coins))*dexbtc.RedeemP2PKHInputSize + 1 + fees := dexzec.TxFeesZIP317(txInsSize, txOutsSize, 0, 0, 0, 0) + + // Sign, add change, but don't send the transaction yet until + // the individual swap refund txs are prepared and signed. + msgTx, change, err := w.signTxAndAddChange(baseTx, changeAddr, totalIn, totalOut, fees) + if err != nil { + return nil, nil, 0, err + } + txHash := msgTx.TxHash() + + // Prepare the receipts. + receipts := make([]asset.Receipt, 0, swapCount) + for i, contract := range swaps.Contracts { + output := btc.NewOutput(&txHash, uint32(i), contract.Value) + refundAddr := refundAddrs[i] + signedRefundTx, err := w.refundTx(&output.Pt.TxHash, output.Pt.Vout, contracts[i], contract.Value, refundAddr, fees) + if err != nil { + return nil, nil, 0, fmt.Errorf("error creating refund tx: %w", err) + } + refundBuff := new(bytes.Buffer) + err = signedRefundTx.Serialize(refundBuff) + if err != nil { + return nil, nil, 0, fmt.Errorf("error serializing refund tx: %w", err) + } + receipts = append(receipts, &btc.SwapReceipt{ + Output: output, + SwapContract: contracts[i], + ExpirationTime: time.Unix(int64(contract.LockTime), 0).UTC(), + SignedRefundBytes: refundBuff.Bytes(), + }) + } + + // Refund txs prepared and signed. Can now broadcast the swap(s). + _, err = w.broadcastTx(msgTx) + if err != nil { + return nil, nil, 0, err + } + + // If change is nil, return a nil asset.Coin. + var changeCoin asset.Coin + if change != nil { + changeCoin = change } - return &zecWallet{ExchangeWalletNoAuth: w, log: logger}, nil + + if swaps.LockChange && change != nil { + // Lock the change output + w.log.Debugf("locking change coin %s", change) + err = lockUnspent(w, false, []*btc.Output{change}) + if err != nil { + // The swap transaction is already broadcasted, so don't fail now. + w.log.Errorf("failed to lock change output: %v", err) + } + + addrStr, err := dexzec.EncodeAddress(changeAddr, w.addrParams) + if err != nil { + 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{{ + TxHash: &change.Pt.TxHash, + Vout: change.Pt.Vout, + Address: addrStr, + Amount: change.Val, + }}) + } + + w.cm.UnlockOutPoints(pts) + + return receipts, changeCoin, fees, nil } -type rpcCaller interface { - CallRPC(method string, args []any, thing any) error +func (w *zecWallet) SwapConfirmations(_ context.Context, id dex.Bytes, contract dex.Bytes, startTime time.Time) (uint32, bool, error) { + txHash, vout, err := decodeCoinID(id) + if err != nil { + return 0, false, err + } + // Check for an unspent output. + txOut, err := getTxOutput(w, txHash, vout) + if err == nil && txOut != nil { + return uint32(txOut.Confirmations), false, nil + } + // Check wallet transactions. + tx, err := getWalletTransaction(w, txHash) + if err != nil { + if errors.Is(err, asset.CoinNotFoundError) { + return 0, false, asset.CoinNotFoundError + } + return 0, false, newError(errNoTx, "gettransaction error; %w", err) + } + return uint32(tx.Confirmations), true, nil } -type zecWallet struct { - *btc.ExchangeWalletNoAuth - log dex.Logger - lastAddress atomic.Value // "string" +func (w *zecWallet) SyncStatus() (bool, float32, error) { + ss, err := syncStatus(w) + if err != nil { + return false, 0, err + } + + if ss.Target == 0 { // do not say progress = 1 + return false, 0, nil + } + if ss.Syncing { + ogTip := atomic.LoadInt64(&w.tipAtConnect) + totalToSync := ss.Target - int32(ogTip) + var progress float32 = 1 + if totalToSync > 0 { + progress = 1 - (float32(ss.Target-ss.Height) / float32(totalToSync)) + } + return false, progress, nil + } + + // It looks like we are ready based on syncStatus, but that may just be + // comparing wallet height to known chain height. Now check peers. + numPeers, err := peerCount(w) + if err != nil { + return false, 0, err + } + return numPeers > 0, 1, nil } -var _ asset.FeeRater = (*zecWallet)(nil) +func (w *zecWallet) ValidateAddress(address string) bool { + _, err := dexzec.DecodeAddress(address, w.addrParams, w.btcParams) + return err == nil +} -// FeeRate returns the asset standard fee rate for Zcash. -func (w *zecWallet) FeeRate() uint64 { - return dexzec.LegacyFeeRate +func (w *zecWallet) ValidateSecret(secret, secretHash []byte) bool { + h := sha256.Sum256(secret) + return bytes.Equal(h[:], secretHash) +} + +func (w *zecWallet) useSplitTx() bool { + return w.walletCfg.Load().(*WalletConfig).UseSplitTx } var _ asset.ShieldedWallet = (*zecWallet)(nil) -func transparentAddress(c rpcCaller, addrParams *dexzec.AddressParams, btcParams *chaincfg.Params) (btcutil.Address, error) { - const zerothAccount = 0 +func transparentAddressString(c rpcCaller) (string, error) { // One of the address types MUST be shielded. - addrRes, err := zGetAddressForAccount(c, zerothAccount, []string{transparentAddressType, orchardAddressType}) + addrRes, err := zGetAddressForAccount(c, shieldedAcctNumber, []string{transparentAddressType, orchardAddressType}) if err != nil { - return nil, err + return "", err } receivers, err := zGetUnifiedReceivers(c, addrRes.Address) if err != nil { - return nil, err + return "", err } - return dexzec.DecodeAddress(receivers.Transparent, addrParams, btcParams) + return receivers.Transparent, nil } -// connect is Zcash's BTCCloneCFG.ConnectFunc. Ensures that accounts are set -// up correctly. -func connect(c rpcCaller) error { - // Make sure we have zeroth and first account or are able to create them. - accts, err := zListAccounts(c) +func transparentAddress(c rpcCaller, addrParams *dexzec.AddressParams, btcParams *chaincfg.Params) (btcutil.Address, error) { + addrStr, err := transparentAddressString(c) if err != nil { - return fmt.Errorf("error listing Zcash accounts: %w", err) - } - - createAccount := func(n uint32) error { - for _, acct := range accts { - if acct.Number == n { - return nil - } - } - acctNumber, err := zGetNewAccount(c) - if err != nil { - if strings.Contains(err.Error(), "zcashd-wallet-tool") { - return fmt.Errorf("account %d does not exist and cannot be created because wallet seed backup has not been acknowledged with the zcashd-wallet-tool utility", n) - } - return fmt.Errorf("error creating account %d: %w", n, err) - } - if acctNumber != n { - return fmt.Errorf("no account %d found and newly created account has unexpected account number %d", n, acctNumber) - } - return nil - } - if err := createAccount(0); err != nil { - return err - } - if err := createAccount(1); err != nil { - return err + return nil, err } - return nil + return dexzec.DecodeAddress(addrStr, addrParams, btcParams) } func (w *zecWallet) lastShieldedAddress() (addr string, err error) { @@ -348,7 +2483,7 @@ func (w *zecWallet) lastShieldedAddress() (addr string, err error) { return w.NewShieldedAddress() } -// ShieldedBalance list the last address and the balance in the shielded +// ShieldedStatus list the last address and the balance in the shielded // account. func (w *zecWallet) ShieldedStatus() (status *asset.ShieldedStatus, err error) { // z_listaccounts to get account 1 addresses @@ -362,11 +2497,13 @@ func (w *zecWallet) ShieldedStatus() (status *asset.ShieldedStatus, err error) { return nil, err } - status.Balance, err = zGetBalanceForAccount(w, shieldedAcctNumber) + bals, err := zGetBalanceForAccount(w, shieldedAcctNumber, 0) if err != nil { return nil, err } + status.Balance = bals.Orchard + return status, nil } @@ -385,26 +2522,73 @@ func (w *zecWallet) ShieldedStatus() (status *asset.ShieldedStatus, err error) { // add transparent receivers to addresses generated from the shielded account. // This doesn't preclude a user doing something silly with zcash-cli. func (w *zecWallet) Balance() (*asset.Balance, error) { - bal, err := w.ExchangeWalletNoAuth.Balance() + + bals, err := w.balances() if err != nil { - return nil, err + return nil, codedError(errBalanceRetrieval, err) } - - shielded, err := zGetBalanceForAccount(w, shieldedAcctNumber) + locked, err := w.lockedZats() if err != nil { return nil, err } - if bal.Other == nil { - bal.Other = make(map[asset.BalanceCategory]asset.CustomBalance) + bal := &asset.Balance{ + Available: bals.orchard.avail + bals.transparent, + Immature: bals.orchard.maturing, + Locked: locked, + Other: make(map[asset.BalanceCategory]asset.CustomBalance), } bal.Other[asset.BalanceCategoryShielded] = asset.CustomBalance{ - Amount: shielded, + Amount: bals.orchard.avail, // + bals.orchard.maturing, + } + + reserves := w.reserves.Load() + if reserves > bal.Available { + w.log.Warnf("Available balance is below configured reserves: %s < %s", + btcutil.Amount(bal.Available), btcutil.Amount(reserves)) + bal.ReservesDeficit = reserves - bal.Available + reserves = bal.Available } + + bal.BondReserves = reserves + bal.Available -= reserves + bal.Locked += reserves + return bal, nil } +// lockedSats is the total value of locked outputs, as locked with LockUnspent. +func (w *zecWallet) lockedZats() (uint64, error) { + lockedOutpoints, err := listLockUnspent(w, w.log) + if err != nil { + return 0, err + } + var sum uint64 + for _, rpcOP := range lockedOutpoints { + txHash, err := chainhash.NewHashFromStr(rpcOP.TxID) + if err != nil { + return 0, err + } + pt := btc.NewOutPoint(txHash, rpcOP.Vout) + utxo := w.cm.LockedOutput(pt) + if utxo != nil { + sum += utxo.Amount + continue + } + tx, err := getWalletTransaction(w, txHash) + if err != nil { + return 0, err + } + txOut, err := btc.TxOutFromTxBytes(tx.Bytes, rpcOP.Vout, deserializeTx, hashTx) + if err != nil { + return 0, err + } + sum += uint64(txOut.Value) + } + return sum, nil +} + // NewShieldedAddress creates a new shielded address. A shielded address can be // be reused without sacrifice of privacy on-chain, but that doesn't stop // meat-space coordination to reduce privacy. @@ -438,22 +2622,27 @@ func (w *zecWallet) ShieldFunds(ctx context.Context, transparentVal uint64) ([]b // need to either 1) Send all coinbase outputs to a transparent address, or // 2) use z_shieldcoinbase from their zcash-cli interface. const anyTAddr = "ANY_TADDR" - return w.sendOne(ctx, anyTAddr, oneAddr, transparentVal, AllowRevealedSenders) + txHash, err := w.sendOne(ctx, anyTAddr, oneAddr, transparentVal, AllowRevealedSenders) + if err != nil { + return nil, err + } + return txHash[:], nil } // UnshieldFunds moves funds from the shielded account to the transparent // account. func (w *zecWallet) UnshieldFunds(ctx context.Context, amt uint64) ([]byte, error) { - bal, err := zGetBalanceForAccount(w, shieldedAcctNumber) + bals, err := zGetBalanceForAccount(w, shieldedAcctNumber, minOrchardConfs) if err != nil { return nil, fmt.Errorf("z_getbalance error: %w", err) } + bal := bals.Orchard const fees = 1000 // TODO: Update after v5.5.0 which includes ZIP137 if bal < fees || bal-fees < amt { return nil, asset.ErrInsufficientBalance } - unified, err := zGetAddressForAccount(w, transparentAcctNumber, []string{transparentAddressType, orchardAddressType}) + unified, err := zGetAddressForAccount(w, shieldedAcctNumber, []string{transparentAddressType, orchardAddressType}) if err != nil { return nil, fmt.Errorf("z_getaddressforaccount error: %w", err) } @@ -463,11 +2652,16 @@ func (w *zecWallet) UnshieldFunds(ctx context.Context, amt uint64) ([]byte, erro return nil, fmt.Errorf("z_getunifiedreceivers error: %w", err) } - return w.sendOneShielded(ctx, receivers.Transparent, amt, AllowRevealedRecipients) + txHash, err := w.sendOneShielded(ctx, receivers.Transparent, amt, AllowRevealedRecipients) + if err != nil { + return nil, err + } + + return txHash[:], nil } // sendOne is a helper function for doing a z_sendmany with a single recipient. -func (w *zecWallet) sendOne(ctx context.Context, fromAddr, toAddr string, amt uint64, priv privacyPolicy) ([]byte, error) { +func (w *zecWallet) sendOne(ctx context.Context, fromAddr, toAddr string, amt uint64, priv privacyPolicy) (*chainhash.Hash, error) { recip := singleSendManyRecipient(toAddr, amt) operationID, err := zSendMany(w, fromAddr, recip, priv) @@ -475,12 +2669,7 @@ func (w *zecWallet) sendOne(ctx context.Context, fromAddr, toAddr string, amt ui return nil, fmt.Errorf("z_sendmany error: %w", err) } - txHash, err := w.awaitSendManyOperation(ctx, w, operationID) - if err != nil { - return nil, err - } - - return txHash[:], nil + return w.awaitSendManyOperation(ctx, w, operationID) } // awaitSendManyOperation waits for the asynchronous result from a z_sendmany @@ -497,6 +2686,9 @@ func (w *zecWallet) awaitSendManyOperation(ctx context.Context, c rpcCaller, ope return nil, fmt.Errorf("z_sendmany operation failed: %s", res.Error.Message) case "success": + if res.Result == nil { + return nil, errors.New("async operation result = 'success' but no Result field") + } txHash, err := chainhash.NewHashFromStr(res.Result.TxID) if err != nil { return nil, fmt.Errorf("error decoding txid: %w", err) @@ -515,7 +2707,7 @@ func (w *zecWallet) awaitSendManyOperation(ctx context.Context, c rpcCaller, ope } } -func (w *zecWallet) sendOneShielded(ctx context.Context, toAddr string, amt uint64, priv privacyPolicy) ([]byte, error) { +func (w *zecWallet) sendOneShielded(ctx context.Context, toAddr string, amt uint64, priv privacyPolicy) (*chainhash.Hash, error) { lastAddr, err := w.lastShieldedAddress() if err != nil { return nil, err @@ -526,10 +2718,11 @@ func (w *zecWallet) sendOneShielded(ctx context.Context, toAddr string, amt uint // SendShielded sends funds from the shielded account to the provided shielded // or transparent address. func (w *zecWallet) SendShielded(ctx context.Context, toAddr string, amt uint64) ([]byte, error) { - bal, err := zGetBalanceForAccount(w, shieldedAcctNumber) + bals, err := zGetBalanceForAccount(w, shieldedAcctNumber, minOrchardConfs) if err != nil { return nil, err } + bal := bals.Orchard const fees = 1000 // TODO: Update after v5.5.0 which includes ZIP137 if bal < fees || bal-fees < amt { return nil, asset.ErrInsufficientBalance @@ -573,7 +2766,12 @@ func (w *zecWallet) SendShielded(ctx context.Context, toAddr string, amt uint64) return nil, fmt.Errorf("unknown address type: %q", res.AddressType) } - return w.sendOneShielded(ctx, toAddr, amt, priv) + txHash, err := w.sendOneShielded(ctx, toAddr, amt, priv) + if err != nil { + return nil, err + } + + return txHash[:], nil } func zecTx(tx *wire.MsgTx) *dexzec.Tx { @@ -599,3 +2797,37 @@ func signTx(btcTx *wire.MsgTx, idx int, pkScript []byte, hashType txscript.SigHa return append(ecdsa.Sign(key, sigHash[:]).Serialize(), byte(hashType)), nil } + +// isDust returns true if the output will be rejected as dust. +func isDust(val, outputSize uint64) bool { + // See https://github.com/zcash/zcash/blob/5066efbb98bc2af5eed201212d27c77993950cee/src/primitives/transaction.h#L630 + // https://github.com/zcash/zcash/blob/5066efbb98bc2af5eed201212d27c77993950cee/src/primitives/transaction.cpp#L127 + // Also see informative comments hinting towards future changes at + // https://github.com/zcash/zcash/blob/master/src/policy/policy.h + sz := outputSize + 148 // 148 accounts for an input on spending tx + const oneThirdDustThresholdRate = 100 // zats / kB + nFee := oneThirdDustThresholdRate * sz / 1000 // This is different from BTC + if nFee == 0 { + nFee = oneThirdDustThresholdRate + } + + return val < 3*nFee +} + +// Convert the ZEC value to satoshi. +func toZats(v float64) uint64 { + return uint64(math.Round(v * 1e8)) +} + +func hashTx(tx *wire.MsgTx) *chainhash.Hash { + h := zecTx(tx).TxHash() + return &h +} + +func deserializeTx(b []byte) (*wire.MsgTx, error) { + tx, err := dexzec.DeserializeTx(b) + if err != nil { + return nil, err + } + return tx.MsgTx, nil +} diff --git a/client/asset/zec/zec_test.go b/client/asset/zec/zec_test.go new file mode 100644 index 0000000000..8a5f0aea7f --- /dev/null +++ b/client/asset/zec/zec_test.go @@ -0,0 +1,1567 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package zec + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/rand" + "os" + "sync" + "testing" + "time" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/client/asset/btc" + "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/encode" + dexbtc "decred.org/dcrdex/dex/networks/btc" + dexzec "decred.org/dcrdex/dex/networks/zec" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcjson" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/decred/dcrd/dcrjson/v4" +) + +const ( + maxFutureBlockTime = 2 * time.Hour // see MaxTimeOffsetSeconds in btcd/blockchain/validate.go + tAddr = "tmH2m5fi5yY3Qg2GpGwcCrnnoD4wp944RMJ" + tUnifiedAddr = "uregtest1w2khftfjd8w7vgw32g24nwqdt0f3zfrwe9a4uy6trsq3swlpgsfssf2uqsfluu3490jdgers4vwz8l9yg39c9x70phllu8cy57f8mdc6ym5e7xtra0f99wtxvnm0wg4uz7an0wvl5s7jt2y7fqla2j976ej0e4hsspq73m0zrw5lzly797fhku0q74xeshuwvwmrku5u8f7gyd2r0sx" +) + +var ( + tCtx context.Context + tZEC = &dex.Asset{ + ID: 0, + Symbol: "zec", + Version: version, + SwapConf: 1, + } + tLogger = dex.StdOutLogger("T", dex.LevelError) + tTxID = "308e9a3675fc3ea3862b7863eeead08c621dcc37ff59de597dd3cdab41450ad9" + tTxHash *chainhash.Hash + tP2PKH []byte + tErr = errors.New("test error") + tLotSize uint64 = 1e6 +) + +type msgBlockWithHeight struct { + msgBlock *dexzec.Block + height int64 +} + +type hashEntry struct { + hash chainhash.Hash + lastAccess time.Time +} + +const testBlocksPerBlockTimeOffset = 4 + +func generateTestBlockTime(blockHeight int64) time.Time { + return time.Unix(1e6, 0).Add(time.Duration(blockHeight) * maxFutureBlockTime / testBlocksPerBlockTimeOffset) +} + +func makeRawTx(pkScripts []dex.Bytes, inputs []*wire.TxIn) *dexzec.Tx { + tx := &wire.MsgTx{ + TxIn: inputs, + } + for _, pkScript := range pkScripts { + tx.TxOut = append(tx.TxOut, wire.NewTxOut(1, pkScript)) + } + return dexzec.NewTxFromMsgTx(tx, dexzec.MaxExpiryHeight) +} + +func dummyInput() *wire.TxIn { + return wire.NewTxIn(wire.NewOutPoint(&chainhash.Hash{0x01}, 0), nil, nil) +} + +func dummyTx() *dexzec.Tx { + return makeRawTx([]dex.Bytes{encode.RandomBytes(32)}, []*wire.TxIn{dummyInput()}) +} + +func signRawJSONTx(raw json.RawMessage) (*dexzec.Tx, []byte) { + var txB dex.Bytes + if err := json.Unmarshal(raw, &txB); err != nil { + panic(fmt.Sprintf("error unmarshaling tx: %v", err)) + } + tx, err := dexzec.DeserializeTx(txB) + if err != nil { + panic(fmt.Sprintf("error deserializing tx: %v", err)) + } + for i := range tx.TxIn { + tx.TxIn[i].SignatureScript = encode.RandomBytes(dexbtc.RedeemP2PKHSigScriptSize) + } + txB, err = tx.Bytes() + if err != nil { + panic(fmt.Sprintf("error serializing tx: %v", err)) + } + return tx, txB +} + +func scriptHashAddress(contract []byte, chainParams *chaincfg.Params) (btcutil.Address, error) { + return btcutil.NewAddressScriptHash(contract, chainParams) +} + +func makeSwapContract(lockTimeOffset time.Duration) (secret []byte, secretHash [32]byte, pkScript, contract []byte, addr, contractAddr btcutil.Address, lockTime time.Time) { + secret = encode.RandomBytes(32) + secretHash = sha256.Sum256(secret) + + addr, _ = dexzec.DecodeAddress(tAddr, dexzec.RegressionNetAddressParams, dexzec.RegressionNetParams) + + lockTime = time.Now().Add(lockTimeOffset) + contract, err := dexbtc.MakeContract(addr, addr, secretHash[:], lockTime.Unix(), false, &chaincfg.MainNetParams) + if err != nil { + panic("error making swap contract:" + err.Error()) + } + contractAddr, _ = scriptHashAddress(contract, &chaincfg.RegressionNetParams) + pkScript, _ = txscript.PayToAddrScript(contractAddr) + return +} + +type tRPCClient struct { + tipChanged chan asset.WalletNotification + responses map[string][]any + + // If there is an "any" key in the getTransactionMap, that value will be + // returned for all requests. Otherwise the tx id is looked up. + getTransactionMap map[string]*btc.GetTransactionResult + getTransactionErr error + + blockchainMtx sync.RWMutex + verboseBlocks map[string]*msgBlockWithHeight + dbBlockForTx map[chainhash.Hash]*hashEntry + mainchain map[int64]*chainhash.Hash + getBlockchainInfo *btc.GetBlockchainInfoResult + getBestBlockHashErr error +} + +func newRPCClient() *tRPCClient { + genesisHash := chaincfg.MainNetParams.GenesisHash + return &tRPCClient{ + responses: make(map[string][]any), + tipChanged: make(chan asset.WalletNotification), + verboseBlocks: map[string]*msgBlockWithHeight{ + genesisHash.String(): {msgBlock: &dexzec.Block{}}, + }, + dbBlockForTx: make(map[chainhash.Hash]*hashEntry), + mainchain: map[int64]*chainhash.Hash{ + 0: genesisHash, + }, + } +} + +func (c *tRPCClient) queueResponse(method string, resp any) { + c.responses[method] = append(c.responses[method], resp) +} + +func (c *tRPCClient) checkEmptiness(t *testing.T) { + t.Helper() + var stillQueued = map[string]int{} + for method, resps := range c.responses { + if len(resps) > 0 { + stillQueued[method] = len(resps) + fmt.Printf("Method %s still has %d responses queued \n", method, len(resps)) + } + } + if len(stillQueued) > 0 { + t.Fatalf("response queue not empty: %+v", stillQueued) + } +} + +func (c *tRPCClient) getBlock(blockHash string) *msgBlockWithHeight { + c.blockchainMtx.Lock() + defer c.blockchainMtx.Unlock() + return c.verboseBlocks[blockHash] +} + +func (c *tRPCClient) bestBlock() (*chainhash.Hash, int64) { + c.blockchainMtx.RLock() + defer c.blockchainMtx.RUnlock() + var bestHash *chainhash.Hash + var bestBlkHeight int64 + for height, hash := range c.mainchain { + if height >= bestBlkHeight { + bestBlkHeight = height + bestHash = hash + } + } + return bestHash, bestBlkHeight +} + +func (c *tRPCClient) getBestBlockHeight() int64 { + c.blockchainMtx.RLock() + defer c.blockchainMtx.RUnlock() + var bestBlkHeight int64 + for height := range c.mainchain { + if height >= bestBlkHeight { + bestBlkHeight = height + } + } + return bestBlkHeight +} + +func (c *tRPCClient) addRawTx(blockHeight int64, tx *dexzec.Tx) (*chainhash.Hash, *dexzec.Block) { + c.blockchainMtx.Lock() + defer c.blockchainMtx.Unlock() + blockHash, found := c.mainchain[blockHeight] + if !found { + prevBlock := &chainhash.Hash{} + if blockHeight > 0 { + var exists bool + prevBlock, exists = c.mainchain[blockHeight-1] + if !exists { + prevBlock = &chainhash.Hash{} + } + } + nonce, bits := rand.Uint32(), rand.Uint32() + header := wire.NewBlockHeader(0, prevBlock, &chainhash.Hash{} /* lie, maybe fix this */, bits, nonce) + header.Timestamp = generateTestBlockTime(blockHeight) + blk := &dexzec.Block{MsgBlock: *wire.NewMsgBlock(header)} // only now do we know the block hash + hash := blk.BlockHash() + blockHash = &hash + c.verboseBlocks[blockHash.String()] = &msgBlockWithHeight{ + msgBlock: blk, + height: blockHeight, + } + c.mainchain[blockHeight] = blockHash + } + block := c.verboseBlocks[blockHash.String()] + block.msgBlock.AddTransaction(tx.MsgTx) + return blockHash, block.msgBlock +} + +func (c *tRPCClient) RawRequest(ctx context.Context, method string, args []json.RawMessage) (json.RawMessage, error) { + respond := func(resp any) (json.RawMessage, error) { + b, err := json.Marshal(resp) + if err != nil { + panic(fmt.Sprintf("error marshaling test response: %v", err)) + } + return b, nil + } + + responses := c.responses[method] + if len(responses) > 0 { + resp := c.responses[method][0] + c.responses[method] = c.responses[method][1:] + if err, is := resp.(error); is { + return nil, err + } + if f, is := resp.(func([]json.RawMessage) (json.RawMessage, error)); is { + return f(args) + } + return respond(resp) + } + + // Some methods are handled by the testing infrastructure + switch method { + case "getbestblockhash": + c.blockchainMtx.RLock() + if c.getBestBlockHashErr != nil { + c.blockchainMtx.RUnlock() + return nil, c.getBestBlockHashErr + } + c.blockchainMtx.RUnlock() + bestHash, _ := c.bestBlock() + return respond(bestHash.String()) + case "getblockhash": + var blockHeight int64 + if err := json.Unmarshal(args[0], &blockHeight); err != nil { + panic(fmt.Sprintf("error unmarshaling block height: %v", err)) + } + c.blockchainMtx.RLock() + defer c.blockchainMtx.RUnlock() + for height, blockHash := range c.mainchain { + if height == blockHeight { + return respond(blockHash.String()) + } + } + return nil, fmt.Errorf("block not found") + case "getblock": + c.blockchainMtx.Lock() + defer c.blockchainMtx.Unlock() + var blockHashStr string + if err := json.Unmarshal(args[0], &blockHashStr); err != nil { + panic(fmt.Sprintf("error unmarshaling block hash: %v", err)) + } + blk, found := c.verboseBlocks[blockHashStr] + if !found { + return nil, fmt.Errorf("block not found") + } + var buf bytes.Buffer + err := blk.msgBlock.Serialize(&buf) + if err != nil { + return nil, fmt.Errorf("block serialization error: %v", err) + } + return respond(hex.EncodeToString(buf.Bytes())) + case "getblockheader": + var blkHash string + if err := json.Unmarshal(args[0], &blkHash); err != nil { + panic(fmt.Sprintf("error unmarshaling block hash: %v", err)) + } + block := c.getBlock(blkHash) + if block == nil { + return nil, fmt.Errorf("no block verbose found") + } + // block may get modified concurrently, lock mtx before reading fields. + c.blockchainMtx.RLock() + defer c.blockchainMtx.RUnlock() + return respond(&btc.BlockHeader{ + Hash: block.msgBlock.BlockHash().String(), + Height: block.height, + // Confirmations: block.Confirmations, + // Time: block.Time, + }) + case "gettransaction": + if c.getTransactionErr != nil { + return nil, c.getTransactionErr + } + + c.blockchainMtx.Lock() + defer c.blockchainMtx.Unlock() + var txID string + if err := json.Unmarshal(args[0], &txID); err != nil { + panic(fmt.Sprintf("error unmarshaling block hash: %v", err)) + } + var txData *btc.GetTransactionResult + if c.getTransactionMap != nil { + if txData = c.getTransactionMap["any"]; txData == nil { + txData = c.getTransactionMap[txID] + } + } + if txData == nil { + return nil, btc.WalletTransactionNotFound + } + return respond(txData) + case "signrawtransaction": + _, txB := signRawJSONTx(args[0]) + return respond(&btc.SignTxResult{ + Hex: txB, + Complete: true, + }) + } + return nil, fmt.Errorf("no test response queued for %q", method) +} + +func boolPtr(v bool) *bool { + return &v +} + +func tNewWallet() (*zecWallet, *tRPCClient, func()) { + dataDir, err := os.MkdirTemp("", "") + if err != nil { + panic("couldn't create data dir:" + err.Error()) + } + + cl := newRPCClient() + walletCfg := &asset.WalletConfig{ + Emit: asset.NewWalletEmitter(cl.tipChanged, BipID, tLogger), + PeersChange: func(num uint32, err error) { + fmt.Printf("peer count = %d, err = %v", num, err) + }, + DataDir: dataDir, + Settings: map[string]string{ + "rpcuser": "a", + "rpcpassword": "b", + }, + } + walletCtx, shutdown := context.WithCancel(tCtx) + + wi, err := NewWallet(walletCfg, tLogger, dex.Simnet) + if err != nil { + panic(fmt.Sprintf("NewWallet error: %v", err)) + } + + w := wi.(*zecWallet) + w.node = cl + bestHash, _ := cl.bestBlock() + w.currentTip = &btc.BlockVector{ + Height: cl.getBestBlockHeight(), + Hash: *bestHash, + } + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + w.watchBlocks(walletCtx) + }() + shutdownAndWait := func() { + shutdown() + os.RemoveAll(dataDir) + wg.Wait() + } + return w, cl, shutdownAndWait +} + +func TestMain(m *testing.M) { + tLogger = dex.StdOutLogger("TEST", dex.LevelCritical) + var shutdown func() + tCtx, shutdown = context.WithCancel(context.Background()) + tTxHash, _ = chainhash.NewHashFromStr(tTxID) + tP2PKH, _ = hex.DecodeString("76a9148fc02268f208a61767504fe0b48d228641ba81e388ac") + // tP2SH, _ = hex.DecodeString("76a91412a9abf5c32392f38bd8a1f57d81b1aeecc5699588ac") + doIt := func() int { + // Not counted as coverage, must test Archiver constructor explicitly. + defer shutdown() + return m.Run() + } + os.Exit(doIt()) +} + +func TestFundOrder(t *testing.T) { + w, cl, shutdown := tNewWallet() + defer shutdown() + defer cl.checkEmptiness(t) + + acctBal := &zAccountBalance{} + queueAccountBals := func() { + cl.queueResponse(methodZGetBalanceForAccount, acctBal) // 0-conf + cl.queueResponse(methodZGetBalanceForAccount, acctBal) // minOrchardConfs + cl.queueResponse(methodZGetNotesCount, &zNotesCount{Orchard: 1}) + } + + // With an empty list returned, there should be no error, but the value zero + // should be returned. + unspents := make([]*btc.ListUnspentResult, 0) + queueAccountBals() + cl.queueResponse("listlockunspent", []*btc.RPCOutpoint{}) + + bal, err := w.Balance() + if err != nil { + t.Fatalf("error for zero utxos: %v", err) + } + if bal.Available != 0 { + t.Fatalf("expected available = 0, got %d", bal.Available) + } + if bal.Immature != 0 { + t.Fatalf("expected unconf = 0, got %d", bal.Immature) + } + + cl.queueResponse(methodZGetBalanceForAccount, tErr) + _, err = w.Balance() + if !errorHasCode(err, errBalanceRetrieval) { + t.Fatalf("wrong error for rpc error: %v", err) + } + + var littleLots uint64 = 12 + littleOrder := tLotSize * littleLots + littleFunds := dexzec.RequiredOrderFunds(littleOrder, 1, dexbtc.RedeemP2PKHInputSize, littleLots) + littleUTXO := &btc.ListUnspentResult{ + TxID: tTxID, + Address: "tmH2m5fi5yY3Qg2GpGwcCrnnoD4wp944RMJ", + Amount: float64(littleFunds) / 1e8, + Confirmations: 0, + ScriptPubKey: tP2PKH, + Spendable: true, + Solvable: true, + SafePtr: boolPtr(true), + } + unspents = append(unspents, littleUTXO) + + lockedVal := uint64(1e6) + acctBal = &zAccountBalance{ + Pools: zBalancePools{ + Transparent: valZat{ + ValueZat: littleFunds - lockedVal, + }, + }, + } + + lockedOutpoints := []*btc.RPCOutpoint{ + { + TxID: tTxID, + Vout: 1, + }, + } + + queueAccountBals() + cl.queueResponse("gettxout", &btcjson.GetTxOutResult{}) + cl.queueResponse("listlockunspent", lockedOutpoints) + + tx := makeRawTx([]dex.Bytes{{0x01}, {0x02}}, []*wire.TxIn{dummyInput()}) + tx.TxOut[1].Value = int64(lockedVal) + txB, _ := tx.Bytes() + const blockHeight = 5 + blockHash, _ := cl.addRawTx(blockHeight, tx) + + cl.getTransactionMap = map[string]*btc.GetTransactionResult{ + "any": { + BlockHash: blockHash.String(), + BlockIndex: blockHeight, + Bytes: txB, + }, + } + + bal, err = w.Balance() + if err != nil { + t.Fatalf("error for 1 utxo: %v", err) + } + if bal.Available != littleFunds-lockedVal { + t.Fatalf("expected available = %d for confirmed utxos, got %d", littleOrder-lockedVal, bal.Available) + } + if bal.Immature != 0 { + t.Fatalf("expected immature = 0, got %d", bal.Immature) + } + if bal.Locked != lockedVal { + t.Fatalf("expected locked = %d, got %d", lockedVal, bal.Locked) + } + + var lottaLots uint64 = 100 + lottaOrder := tLotSize * lottaLots + // Add funding for an extra input to accommodate the later combined tests. + lottaFunds := dexzec.RequiredOrderFunds(lottaOrder, 1, dexbtc.RedeemP2PKHInputSize, lottaLots) + lottaUTXO := &btc.ListUnspentResult{ + TxID: tTxID, + Address: "tmH2m5fi5yY3Qg2GpGwcCrnnoD4wp944RMJ", + Amount: float64(lottaFunds) / 1e8, + Confirmations: 0, + Vout: 1, + ScriptPubKey: tP2PKH, + Spendable: true, + Solvable: true, + SafePtr: boolPtr(true), + } + unspents = append(unspents, lottaUTXO) + // littleUTXO.Confirmations = 1 + // node.listUnspent = unspents + // bals.Mine.Trusted += float64(lottaFunds) / 1e8 + // node.getBalances = &bals + acctBal.Pools.Transparent.ValueZat = littleFunds + lottaFunds - lockedVal + queueAccountBals() + cl.queueResponse("gettxout", &btcjson.GetTxOutResult{}) + cl.queueResponse("listlockunspent", lockedOutpoints) + bal, err = w.Balance() + if err != nil { + t.Fatalf("error for 2 utxos: %v", err) + } + if bal.Available != littleFunds+lottaFunds-lockedVal { + t.Fatalf("expected available = %d for 2 outputs, got %d", littleFunds+lottaFunds-lockedVal, bal.Available) + } + if bal.Immature != 0 { + t.Fatalf("expected immature = 0 for 2 outputs, got %d", bal.Immature) + } + + ord := &asset.Order{ + Version: version, + Value: 0, + MaxSwapCount: 1, + Options: make(map[string]string), + } + + setOrderValue := func(v uint64) { + ord.Value = v + ord.MaxSwapCount = v / tLotSize + } + + queueBalances := func() { + cl.queueResponse(methodZGetBalanceForAccount, acctBal) // 0-conf + cl.queueResponse(methodZGetBalanceForAccount, acctBal) // minOrchardConfs + cl.queueResponse(methodZGetNotesCount, &zNotesCount{Orchard: nActionsOrchardEstimate}) + } + + // Zero value + _, _, _, err = w.FundOrder(ord) + if !errorHasCode(err, errNoFundsRequested) { + t.Fatalf("wrong error for zero value: %v", err) + } + + // Nothing to spend + acctBal.Pools.Transparent.ValueZat = 0 + queueBalances() + cl.queueResponse("listunspent", []*btc.ListUnspentResult{}) + setOrderValue(littleOrder) + _, _, _, err = w.FundOrder(ord) + if !errorHasCode(err, errFunding) { + t.Fatalf("wrong error for zero utxos: %v", err) + } + + // RPC error + acctBal.Pools.Transparent.ValueZat = littleFunds + lottaFunds + queueBalances() + cl.queueResponse("listunspent", tErr) + _, _, _, err = w.FundOrder(ord) + if !errorHasCode(err, errFunding) { + t.Fatalf("wrong funding error for rpc error: %v", err) + } + + // Negative response when locking outputs. + queueBalances() + cl.queueResponse("listunspent", unspents) + cl.queueResponse("lockunspent", false) + _, _, _, err = w.FundOrder(ord) + if !errorHasCode(err, errLockUnspent) { + t.Fatalf("wrong error for lockunspent result = false: %v", err) + } + // New coin manager with no locked coins. Could also use ReturnCoins + w.prepareCoinManager() + + queueSuccess := func() { + queueBalances() + cl.queueResponse("listunspent", unspents) + cl.queueResponse("lockunspent", true) + } + + // Fund a little bit, with zero-conf littleUTXO. + littleUTXO.Confirmations = 0 + lottaUTXO.Confirmations = 1 + queueSuccess() + spendables, _, _, err := w.FundOrder(ord) + if err != nil { + t.Fatalf("error funding small amount: %v", err) + } + if len(spendables) != 1 { + t.Fatalf("expected 1 spendable, got %d", len(spendables)) + } + v := spendables[0].Value() + if v != lottaFunds { // has to pick the larger output + t.Fatalf("expected spendable of value %d, got %d", lottaFunds, v) + } + w.prepareCoinManager() + + // Now with confirmed littleUTXO. + littleUTXO.Confirmations = 1 + queueSuccess() + spendables, _, _, err = w.FundOrder(ord) + if err != nil { + t.Fatalf("error funding small amount: %v", err) + } + if len(spendables) != 1 { + t.Fatalf("expected 1 spendable, got %d", len(spendables)) + } + v = spendables[0].Value() + if v != littleFunds { + t.Fatalf("expected spendable of value %d, got %d", littleFunds, v) + } + w.prepareCoinManager() + + // // Adding a fee bump should now require the larger UTXO. + // ord.Options = map[string]string{swapFeeBumpKey: "1.5"} + // spendables, _, _, err = wallet.FundOrder(ord) + // if err != nil { + // t.Fatalf("error funding bumped fees: %v", err) + // } + // if len(spendables) != 1 { + // t.Fatalf("expected 1 spendable, got %d", len(spendables)) + // } + // v = spendables[0].Value() + // if v != lottaFunds { // picks the bigger output because it is confirmed + // t.Fatalf("expected bumped fee utxo of value %d, got %d", littleFunds, v) + // } + // ord.Options = nil + // littleUTXO.Confirmations = 0 + // _ = wallet.ReturnCoins(spendables) + + // Make lottaOrder unconfirmed like littleOrder, favoring little now. + littleUTXO.Confirmations = 0 + lottaUTXO.Confirmations = 0 + queueSuccess() + spendables, _, _, err = w.FundOrder(ord) + if err != nil { + t.Fatalf("error funding small amount: %v", err) + } + if len(spendables) != 1 { + t.Fatalf("expected 1 spendable, got %d", len(spendables)) + } + v = spendables[0].Value() + if v != littleFunds { // now picks the smaller output + t.Fatalf("expected spendable of value %d, got %d", littleFunds, v) + } + w.prepareCoinManager() + + // Fund a lotta bit, covered by just the lottaBit UTXO. + setOrderValue(lottaOrder) + queueSuccess() + spendables, _, fees, err := w.FundOrder(ord) + if err != nil { + t.Fatalf("error funding large amount: %v", err) + } + if len(spendables) != 1 { + t.Fatalf("expected 1 spendable, got %d", len(spendables)) + } + if fees != 0 { + t.Fatalf("expected no fees, got %d", fees) + } + v = spendables[0].Value() + if v != lottaFunds { + t.Fatalf("expected spendable of value %d, got %d", lottaFunds, v) + } + w.prepareCoinManager() + + // require both spendables + extraLottaOrder := littleOrder + lottaOrder + setOrderValue(extraLottaOrder) + queueSuccess() + spendables, _, fees, err = w.FundOrder(ord) + if err != nil { + t.Fatalf("error funding large amount: %v", err) + } + if len(spendables) != 2 { + t.Fatalf("expected 2 spendable, got %d", len(spendables)) + } + if fees != 0 { + t.Fatalf("expected no split tx fees, got %d", fees) + } + v = spendables[0].Value() + if v != lottaFunds { + t.Fatalf("expected spendable of value %d, got %d", lottaFunds, v) + } + w.prepareCoinManager() + + // Not enough to cover transaction fees. + extraLottaLots := littleLots + lottaLots + tweak := float64(littleFunds+lottaFunds-dexzec.RequiredOrderFunds(extraLottaOrder, 2, 2*dexbtc.RedeemP2PKHInputSize, extraLottaLots)+1) / 1e8 + lottaUTXO.Amount -= tweak + // cl.queueResponse("listunspent", unspents) + _, _, _, err = w.FundOrder(ord) + if err == nil { + t.Fatalf("no error when not enough to cover tx fees") + } + lottaUTXO.Amount += tweak + w.prepareCoinManager() + + // Prepare for a split transaction. + w.walletCfg.Load().(*WalletConfig).UseSplitTx = true + queueSuccess() + // No error when no split performed cuz math. + coins, _, fees, err := w.FundOrder(ord) + if err != nil { + t.Fatalf("error for no-split split: %v", err) + } + if fees != 0 { + t.Fatalf("no-split split returned non-zero fees: %d", fees) + } + // Should be both coins. + if len(coins) != 2 { + t.Fatalf("no-split split didn't return both coins") + } + w.prepareCoinManager() + + // No split because not standing order. + ord.Immediate = true + queueSuccess() + coins, _, fees, err = w.FundOrder(ord) + if err != nil { + t.Fatalf("error for no-split split: %v", err) + } + ord.Immediate = false + if len(coins) != 2 { + t.Fatalf("no-split split didn't return both coins") + } + if fees != 0 { + t.Fatalf("no-split split returned non-zero fees: %d", fees) + } + w.prepareCoinManager() + + var rawSent bool + queueSplit := func() { + cl.queueResponse(methodZGetAddressForAccount, &zGetAddressForAccountResult{Address: tAddr}) // split output + cl.queueResponse(methodZListUnifiedReceivers, &unifiedReceivers{Transparent: tAddr}) + cl.queueResponse(methodZGetAddressForAccount, &zGetAddressForAccountResult{Address: tAddr}) // change + cl.queueResponse(methodZListUnifiedReceivers, &unifiedReceivers{Transparent: tAddr}) + cl.queueResponse("sendrawtransaction", func(args []json.RawMessage) (json.RawMessage, error) { + rawSent = true + tx, _ := signRawJSONTx(args[0]) + return json.Marshal(tx.TxHash().String()) + }) + } + + // With a little more available, the split should be performed. + baggageFees := dexzec.TxFeesZIP317(2*dexbtc.RedeemP2PKHInputSize+1, 2*dexbtc.P2PKHOutputSize+1, 0, 0, 0, 0) + lottaUTXO.Amount += (float64(baggageFees) + 1) / 1e8 + queueSuccess() + queueSplit() + coins, _, fees, err = w.FundOrder(ord) + if err != nil { + t.Fatalf("error for split tx: %v", err) + } + // Should be just one coin. + if len(coins) != 1 { + t.Fatalf("split failed - coin count != 1. got %d", len(coins)) + } + if !rawSent { + t.Fatalf("split failed - no tx sent") + } + if fees != baggageFees { + t.Fatalf("split returned unexpected fees. wanted %d, got %d", baggageFees, fees) + } + w.prepareCoinManager() + + // The split should also be added if we set the option at order time. + w.walletCfg.Load().(*WalletConfig).UseSplitTx = true + ord.Options = map[string]string{"swapsplit": "true"} + queueSuccess() + queueSplit() + coins, _, fees, err = w.FundOrder(ord) + if err != nil { + t.Fatalf("error for forced split tx: %v", err) + } + // Should be just one coin still. + if len(coins) != 1 { + t.Fatalf("forced split failed - coin count != 1") + } + if fees != baggageFees { + t.Fatalf("split returned unexpected fees. wanted %d, got %d", baggageFees, fees) + } + w.prepareCoinManager() + + // Go back to just enough, but add reserves and get an error. + lottaUTXO.Amount = float64(lottaFunds) / 1e8 + w.reserves.Store(1) + cl.queueResponse("listunspent", unspents) + queueBalances() + _, _, _, err = w.FundOrder(ord) + if !errors.Is(err, asset.ErrInsufficientBalance) { + t.Fatalf("wrong error for reserves rejection: %v", err) + } + + // Double-check that we're still good + w.reserves.Store(0) + queueSuccess() + _, _, _, err = w.FundOrder(ord) + if err != nil { + t.Fatalf("got out of whack somehow") + } + w.prepareCoinManager() + + // No reserves, no little UTXO, but shielded funds available for + // shielded split + unspents = []*btc.ListUnspentResult{lottaUTXO} + splitOutputAmt := dexzec.RequiredOrderFunds(ord.Value, 1, dexbtc.RedeemP2PKHInputSize, ord.MaxSwapCount) + shieldedSplitFees := dexzec.TxFeesZIP317(dexbtc.RedeemP2PKHInputSize+1, dexbtc.P2PKHOutputSize+1, 0, 0, 0, nActionsOrchardEstimate) + w.lastAddress.Store(tUnifiedAddr) + acctBal.Pools.Orchard.ValueZat = splitOutputAmt + shieldedSplitFees - lottaFunds + queueSuccess() + cl.queueResponse(methodZGetAddressForAccount, &zGetAddressForAccountResult{Address: tAddr}) // split output + cl.queueResponse(methodZListUnifiedReceivers, &unifiedReceivers{Transparent: tAddr}) + cl.queueResponse(methodZSendMany, "opid-123456") + txid := dex.Bytes(encode.RandomBytes(32)).String() + cl.queueResponse(methodZGetOperationResult, []*operationStatus{{ + Status: "success", + Result: &opResult{ + TxID: txid, + }, + }}) + btcAddr, _ := dexzec.DecodeAddress(tAddr, w.addrParams, w.btcParams) + pkScript, _ := txscript.PayToAddrScript(btcAddr) + msgTx := wire.NewMsgTx(dexzec.VersionNU5) + msgTx.TxOut = append(msgTx.TxOut, wire.NewTxOut(int64(splitOutputAmt), pkScript)) + txB, _ = dexzec.NewTxFromMsgTx(msgTx, dexzec.MaxExpiryHeight).Bytes() + cl.getTransactionMap = map[string]*btc.GetTransactionResult{ + txid: { + Bytes: txB, + }, + } + coins, _, fees, err = w.FundOrder(ord) + if err != nil { + t.Fatalf("error for shielded split: %v", err) + } + if len(coins) != 1 { + t.Fatalf("shielded split failed - coin count %d != 1", len(coins)) + } + if fees != shieldedSplitFees { + t.Fatalf("shielded split returned unexpected fees. wanted %d, got %d", shieldedSplitFees, fees) + } +} + +func TestFundingCoins(t *testing.T) { + w, cl, shutdown := tNewWallet() + defer shutdown() + defer cl.checkEmptiness(t) + + const vout0 = 1 + const txBlockHeight = 3 + tx0 := makeRawTx([]dex.Bytes{{0x01}, tP2PKH}, []*wire.TxIn{dummyInput()}) + txHash0 := tx0.TxHash() + _, _ = cl.addRawTx(txBlockHeight, tx0) + coinID0 := btc.ToCoinID(&txHash0, vout0) + // Make spendable (confs > 0) + cl.addRawTx(txBlockHeight+1, dummyTx()) + + p2pkhUnspent0 := &btc.ListUnspentResult{ + TxID: txHash0.String(), + Vout: vout0, + ScriptPubKey: tP2PKH, + Spendable: true, + Solvable: true, + SafePtr: boolPtr(true), + Amount: 1, + } + unspents := []*btc.ListUnspentResult{p2pkhUnspent0} + + // Add a second funding coin to make sure more than one iteration of the + // utxo loops is required. + const vout1 = 0 + tx1 := makeRawTx([]dex.Bytes{tP2PKH, {0x02}}, []*wire.TxIn{dummyInput()}) + txHash1 := tx1.TxHash() + _, _ = cl.addRawTx(txBlockHeight, tx1) + coinID1 := btc.ToCoinID(&txHash1, vout1) + // Make spendable (confs > 0) + cl.addRawTx(txBlockHeight+1, dummyTx()) + + p2pkhUnspent1 := &btc.ListUnspentResult{ + TxID: txHash1.String(), + Vout: vout1, + ScriptPubKey: tP2PKH, + Spendable: true, + Solvable: true, + SafePtr: boolPtr(true), + Amount: 1, + } + unspents = append(unspents, p2pkhUnspent1) + coinIDs := []dex.Bytes{coinID0, coinID1} + locked := []*btc.RPCOutpoint{} + + queueSuccess := func() { + cl.queueResponse("listlockunspent", locked) + cl.queueResponse("listunspent", unspents) + cl.queueResponse("lockunspent", true) + } + + ensureGood := func() { + t.Helper() + coins, err := w.FundingCoins(coinIDs) + if err != nil { + t.Fatalf("FundingCoins error: %v", err) + } + if len(coins) != 2 { + t.Fatalf("expected 2 coins, got %d", len(coins)) + } + } + queueSuccess() + ensureGood() + + ensureErr := func(tag string) { + t.Helper() + // Clear the cache. + w.prepareCoinManager() + _, err := w.FundingCoins(coinIDs) + if err == nil { + t.Fatalf("%s: no error", tag) + } + } + + // No coins + cl.queueResponse("listlockunspent", locked) + cl.queueResponse("listunspent", []*btc.ListUnspentResult{}) + ensureErr("no coins") + + // RPC error + cl.queueResponse("listlockunspent", tErr) + ensureErr("rpc coins") + + // Bad coin ID. + goodCoinIDs := coinIDs + coinIDs = []dex.Bytes{encode.RandomBytes(35)} + ensureErr("bad coin ID") + coinIDs = goodCoinIDs + + queueSuccess() + ensureGood() +} + +func TestSwap(t *testing.T) { + w, cl, shutdown := tNewWallet() + defer shutdown() + defer cl.checkEmptiness(t) + + swapVal := toZats(5) + coins := asset.Coins{ + btc.NewOutput(tTxHash, 0, toZats(3)), + btc.NewOutput(tTxHash, 0, toZats(3)), + } + addrStr := tAddr + + privBytes, _ := hex.DecodeString("b07209eec1a8fb6cfe5cb6ace36567406971a75c330db7101fb21bc679bc5330") + privKey, _ := btcec.PrivKeyFromBytes(privBytes) + wif, _ := btcutil.NewWIF(privKey, &chaincfg.MainNetParams, true) + + secretHash, _ := hex.DecodeString("5124208c80d33507befa517c08ed01aa8d33adbf37ecd70fb5f9352f7a51a88d") + contract := &asset.Contract{ + Address: addrStr, + Value: swapVal, + SecretHash: secretHash, + LockTime: uint64(time.Now().Unix()), + } + + swaps := &asset.Swaps{ + Inputs: coins, + Contracts: []*asset.Contract{contract}, + LockChange: true, + } + + var rawSent bool + queueSuccess := func() { + cl.queueResponse(methodZGetAddressForAccount, &zGetAddressForAccountResult{Address: tAddr}) // revocation output + cl.queueResponse(methodZListUnifiedReceivers, &unifiedReceivers{Transparent: tAddr}) + cl.queueResponse(methodZGetAddressForAccount, &zGetAddressForAccountResult{Address: tAddr}) // change output + cl.queueResponse(methodZListUnifiedReceivers, &unifiedReceivers{Transparent: tAddr}) + cl.queueResponse("dumpprivkey", wif.String()) + cl.queueResponse("sendrawtransaction", func(args []json.RawMessage) (json.RawMessage, error) { + rawSent = true + tx, _ := signRawJSONTx(args[0]) + return json.Marshal(tx.TxHash().String()) + }) + } + + // This time should succeed. + queueSuccess() + _, _, feesPaid, err := w.Swap(swaps) + if err != nil { + t.Fatalf("swap error: %v", err) + } + if !rawSent { + t.Fatalf("tx not sent?") + } + + // Fees should be returned. + btcAddr, _ := dexzec.DecodeAddress(tAddr, w.addrParams, w.btcParams) + contractScript, err := dexbtc.MakeContract(btcAddr, btcAddr, encode.RandomBytes(32), 0, false, w.btcParams) + if err != nil { + t.Fatalf("unable to create pubkey script for address %s: %v", contract.Address, err) + } + // contracts = append(contracts, contractScript) + + // Make the P2SH address and pubkey script. + scriptAddr, err := w.scriptHashAddress(contractScript) + if err != nil { + t.Fatalf("error encoding script address: %v", err) + } + + pkScript, err := txscript.PayToAddrScript(scriptAddr) + if err != nil { + t.Fatalf("error creating pubkey script: %v", err) + } + txOutsSize := uint64(wire.NewTxOut(int64(contract.Value), pkScript).SerializeSize()) + txInsSize := uint64(len(coins))*dexbtc.RedeemP2PKHInputSize + 1 + fees := dexzec.TxFeesZIP317(txInsSize, txOutsSize, 0, 0, 0, 0) + if feesPaid != fees { + t.Fatalf("sent fees, %d, less than required fees, %d", feesPaid, fees) + } + + // Not enough funds + swaps.Inputs = coins[:1] + cl.queueResponse(methodZGetAddressForAccount, &zGetAddressForAccountResult{Address: tAddr}) // change address + cl.queueResponse(methodZListUnifiedReceivers, &unifiedReceivers{Transparent: tAddr}) + _, _, _, err = w.Swap(swaps) + if !errorHasCode(err, errInsufficientBalance) { + t.Fatalf("wrong error for listunspent not enough funds: %v", err) + } + swaps.Inputs = coins + + // Make sure we can succeed again. + queueSuccess() + _, _, _, err = w.Swap(swaps) + if err != nil { + t.Fatalf("re-swap error: %v", err) + } +} + +func TestRedeem(t *testing.T) { + w, cl, shutdown := tNewWallet() + defer shutdown() + defer cl.checkEmptiness(t) + swapVal := toZats(5) + + secret, _, _, contract, _, _, lockTime := makeSwapContract(time.Hour * 12) + + coin := btc.NewOutput(tTxHash, 0, swapVal) + ci := &asset.AuditInfo{ + Coin: coin, + Contract: contract, + Recipient: tAddr, + Expiration: lockTime, + } + + redemption := &asset.Redemption{ + Spends: ci, + Secret: secret, + } + + privBytes, _ := hex.DecodeString("b07209eec1a8fb6cfe5cb6ace36567406971a75c330db7101fb21bc679bc5330") + privKey, _ := btcec.PrivKeyFromBytes(privBytes) + wif, err := btcutil.NewWIF(privKey, &chaincfg.RegressionNetParams, true) + if err != nil { + t.Fatalf("error encoding wif: %v", err) + } + + redemptions := &asset.RedeemForm{ + Redemptions: []*asset.Redemption{redemption}, + } + + cl.queueResponse(methodZGetAddressForAccount, &zGetAddressForAccountResult{Address: tAddr}) // revocation output + cl.queueResponse(methodZListUnifiedReceivers, &unifiedReceivers{Transparent: tAddr}) + cl.queueResponse("dumpprivkey", wif.String()) + var rawSent bool + cl.queueResponse("sendrawtransaction", func(args []json.RawMessage) (json.RawMessage, error) { + rawSent = true + tx, _ := signRawJSONTx(args[0]) + return json.Marshal(tx.TxHash().String()) + }) + + _, _, _, err = w.Redeem(redemptions) + if err != nil { + t.Fatalf("redeem error: %v", err) + } + if !rawSent { + t.Fatalf("split failed - no tx sent") + } +} +func TestSend(t *testing.T) { + w, cl, shutdown := tNewWallet() + defer shutdown() + defer cl.checkEmptiness(t) + + const txBlockHeight = 3 + const vout = 0 + const sendAmt uint64 = 1e8 + btcAddr, _ := dexzec.DecodeAddress(tAddr, w.addrParams, w.btcParams) + pkScript, _ := txscript.PayToAddrScript(btcAddr) + tx := makeRawTx([]dex.Bytes{encode.RandomBytes(5), pkScript}, []*wire.TxIn{dummyInput()}) + txHash := tx.TxHash() + // _, _ = cl.addRawTx(txBlockHeight, tx) + // coinID := btc.ToCoinID(&txHash, vout) + // Make spendable (confs > 0) + // cl.addRawTx(txBlockHeight+1, dummyTx()) + + fees := dexzec.TxFeesZIP317(dexbtc.RedeemP2PKHInputSize+1, 2*dexbtc.P2PKHOutputSize+1, 0, 0, 0, 0) + // Do it with on input: + unspent := &btc.ListUnspentResult{ + TxID: txHash.String(), + Vout: vout, + ScriptPubKey: tP2PKH, + Spendable: true, + Solvable: true, + SafePtr: boolPtr(true), + Amount: float64(sendAmt+fees) / 1e8, + } + + unspents := []*btc.ListUnspentResult{unspent} + // coinIDs := []dex.Bytes{coinID0, coinID1} + // locked := []*btc.RPCOutpoint{} + + queueSuccess := func() { + cl.queueResponse("listunspent", unspents) + cl.queueResponse(methodZGetAddressForAccount, &zGetAddressForAccountResult{Address: tAddr}) // split output + cl.queueResponse(methodZListUnifiedReceivers, &unifiedReceivers{Transparent: tAddr}) + cl.queueResponse("sendrawtransaction", func(args []json.RawMessage) (json.RawMessage, error) { + tx, _ := signRawJSONTx(args[0]) + return json.Marshal(tx.TxHash().String()) + }) + } + + queueSuccess() + if _, err := w.Send(tAddr, sendAmt, 0); err != nil { + t.Fatalf("Send error: %v", err) + } + + unspent.Amount -= 1e-8 + cl.queueResponse("listunspent", unspents) + if _, err := w.Send(tAddr, sendAmt, 0); !errorHasCode(err, errFunding) { + t.Fatalf("Wrong error for insufficient funds: %v", err) + } + unspent.Amount += 1e-8 +} + +func TestSwapConfirmations(t *testing.T) { + w, cl, shutdown := tNewWallet() + defer shutdown() + defer cl.checkEmptiness(t) + + _, _, pkScript, contract, _, _, _ := makeSwapContract(time.Hour * 12) + const tipHeight = 10 + const swapHeight = 2 + const expConfs = tipHeight - swapHeight + 1 + + tx := makeRawTx([]dex.Bytes{pkScript}, []*wire.TxIn{dummyInput()}) + txB, _ := tx.Bytes() + blockHash, swapBlock := cl.addRawTx(swapHeight, tx) + txHash := tx.TxHash() + cl.getTransactionMap = map[string]*btc.GetTransactionResult{ + txHash.String(): { + Bytes: txB, + }, + } + coinID := btc.ToCoinID(&txHash, 0) + // Simulate a spending transaction, and advance the tip so that the swap + // has two confirmations. + spendingTx := dummyTx() + spendingTx.TxIn[0].PreviousOutPoint.Hash = txHash + + // Prime the blockchain + for i := int64(1); i <= tipHeight; i++ { + cl.addRawTx(i, dummyTx()) + } + + matchTime := swapBlock.Header.Timestamp + + // Bad coin id + _, _, err := w.SwapConfirmations(tCtx, encode.RandomBytes(35), contract, matchTime) + if err == nil { + t.Fatalf("no error for bad coin ID") + } + + txOutRes := &btcjson.GetTxOutResult{ + Confirmations: expConfs, + BestBlock: blockHash.String(), + } + + cl.queueResponse("gettxout", txOutRes) + confs, _, err := w.SwapConfirmations(tCtx, coinID, contract, matchTime) + if err != nil { + t.Fatalf("error for gettransaction path: %v", err) + } + if confs != expConfs { + t.Fatalf("confs not retrieved from gettxout path. expected %d, got %d", expConfs, confs) + } + + // no tx output found + cl.queueResponse("gettxout", nil) + cl.queueResponse("gettransaction", tErr) + _, _, err = w.SwapConfirmations(tCtx, coinID, contract, matchTime) + if !errorHasCode(err, errNoTx) { + t.Fatalf("wrong error for gettransaction error: %v", err) + } + + cl.queueResponse("gettxout", nil) + cl.queueResponse("gettransaction", &dcrjson.RPCError{ + Code: dcrjson.RPCErrorCode(btcjson.ErrRPCNoTxInfo), + }) + _, _, err = w.SwapConfirmations(tCtx, coinID, contract, matchTime) + if !errors.Is(err, asset.CoinNotFoundError) { + t.Fatalf("wrong error for CoinNotFoundError: %v", err) + } + + cl.queueResponse("gettxout", nil) + // Will pull transaction from getTransactionMap + _, _, err = w.SwapConfirmations(tCtx, coinID, contract, matchTime) + if err != nil { + t.Fatalf("error for gettransaction path: %v", err) + } +} + +func TestSyncStatus(t *testing.T) { + w, cl, shutdown := tNewWallet() + defer shutdown() + defer cl.checkEmptiness(t) + + bci := &btc.GetBlockchainInfoResult{ + Headers: 100, + Blocks: 99, // full node allowed to be synced when 1 block behind + } + + const numConns = 2 + ni := &networkInfo{ + Connections: numConns, + } + + // ok + cl.queueResponse("getblockchaininfo", bci) + cl.queueResponse("getnetworkinfo", ni) + synced, progress, err := w.SyncStatus() + if err != nil { + t.Fatalf("SyncStatus error (synced expected): %v", err) + } + if !synced { + t.Fatalf("synced = false") + } + if progress < 1 { + t.Fatalf("progress not complete when loading last block") + } + + // getblockchaininfo error + cl.queueResponse("getblockchaininfo", tErr) + _, _, err = w.SyncStatus() + if !errorHasCode(err, errGetChainInfo) { + t.Fatalf("SyncStatus wrong error: %v", err) + } + + // getnetworkinfo error + cl.queueResponse("getblockchaininfo", bci) + cl.queueResponse("getnetworkinfo", tErr) + _, _, err = w.SyncStatus() + if !errorHasCode(err, errGetNetInfo) { + t.Fatalf("SyncStatus wrong error: %v", err) + } + + // no peers is not synced = false + ni.Connections = 0 + cl.queueResponse("getblockchaininfo", bci) + cl.queueResponse("getnetworkinfo", ni) + synced, _, err = w.SyncStatus() + if err != nil { + t.Fatalf("SyncStatus error (!synced expected): %v", err) + } + if synced { + t.Fatalf("synced = true") + } + ni.Connections = 2 + + // No headers is progress = 0 + bci.Headers = 0 + cl.queueResponse("getblockchaininfo", bci) + synced, progress, err = w.SyncStatus() + if err != nil { + t.Fatalf("SyncStatus error (no headers): %v", err) + } + if synced || progress != 0 { + t.Fatal("wrong sync status for no headers", synced, progress) + } + bci.Headers = 100 + + // 50% synced + w.tipAtConnect = 100 + bci.Headers = 200 + bci.Blocks = 150 + cl.queueResponse("getblockchaininfo", bci) + synced, progress, err = w.SyncStatus() + if err != nil { + t.Fatalf("SyncStatus error (half-synced): %v", err) + } + if synced { + t.Fatalf("synced = true for 50 blocks to go") + } + if progress > 0.500001 || progress < 0.4999999 { + t.Fatalf("progress out of range. Expected 0.5, got %.2f", progress) + } +} + +func TestPreSwap(t *testing.T) { + w, cl, shutdown := tNewWallet() + defer shutdown() + defer cl.checkEmptiness(t) + + // See math from TestFundEdges. 10 lots with max fee rate of 34 sats/vbyte. + + const lots = 10 + swapVal := lots * tLotSize + + singleSwapFees := dexzec.TxFeesZIP317(dexbtc.RedeemP2PKHInputSize+1, dexbtc.P2SHOutputSize+1, 0, 0, 0, 0) + bestCaseFees := singleSwapFees + worstCaseFees := lots * bestCaseFees + + minReq := swapVal + worstCaseFees + + unspent := &btc.ListUnspentResult{ + TxID: tTxID, + Address: tAddr, + Confirmations: 5, + ScriptPubKey: tP2PKH, + Spendable: true, + Solvable: true, + } + unspents := []*btc.ListUnspentResult{unspent} + + setFunds := func(v uint64) { + unspent.Amount = float64(v) / 1e8 + } + + form := &asset.PreSwapForm{ + Version: version, + LotSize: tLotSize, + Lots: lots, + Immediate: false, + } + + setFunds(minReq) + + acctBal := &zAccountBalance{} + queueFunding := func() { + cl.queueResponse("listunspent", unspents) // maxOrder + cl.queueResponse(methodZGetBalanceForAccount, acctBal) // 0-conf + cl.queueResponse(methodZGetBalanceForAccount, acctBal) // minOrchardConfs + cl.queueResponse(methodZGetNotesCount, &zNotesCount{Orchard: 1}) + } + + // Initial success. + queueFunding() + preSwap, err := w.PreSwap(form) + if err != nil { + t.Fatalf("PreSwap error: %v", err) + } + + if preSwap.Estimate.Lots != lots { + t.Fatalf("wrong lots. expected %d got %d", lots, preSwap.Estimate.Lots) + } + if preSwap.Estimate.MaxFees != worstCaseFees { + t.Fatalf("wrong worst-case fees. expected %d got %d", worstCaseFees, preSwap.Estimate.MaxFees) + } + if preSwap.Estimate.RealisticBestCase != bestCaseFees { + t.Fatalf("wrong best-case fees. expected %d got %d", bestCaseFees, preSwap.Estimate.RealisticBestCase) + } + w.prepareCoinManager() + + // Too little funding is an error. + setFunds(minReq - 1) + cl.queueResponse("listunspent", unspents) // maxOrder + cl.queueResponse(methodZGetBalanceForAccount, &zAccountBalance{ + Pools: zBalancePools{ + Transparent: valZat{ + ValueZat: toZats(unspent.Amount), + }, + }, + }) + _, err = w.PreSwap(form) + if err == nil { + t.Fatalf("no PreSwap error for not enough funds") + } + setFunds(minReq) + + // Success again. + queueFunding() + _, err = w.PreSwap(form) + if err != nil { + t.Fatalf("final PreSwap error: %v", err) + } +} + +func TestConfirmRedemption(t *testing.T) { + w, cl, shutdown := tNewWallet() + defer shutdown() + defer cl.checkEmptiness(t) + + swapVal := toZats(5) + + secret, _, _, contract, addr, _, lockTime := makeSwapContract(time.Hour * 12) + + coin := btc.NewOutput(tTxHash, 0, swapVal) + coinID := coin.ID() + ci := &asset.AuditInfo{ + Coin: coin, + Contract: contract, + Recipient: addr.String(), + Expiration: lockTime, + } + + redemption := &asset.Redemption{ + Spends: ci, + Secret: secret, + } + + walletTx := &btc.GetTransactionResult{ + Confirmations: 1, + } + + cl.queueResponse("gettransaction", walletTx) + st, err := w.ConfirmRedemption(coinID, redemption, 0) + if err != nil { + t.Fatalf("Initial ConfirmRedemption error: %v", err) + } + if st.Confs != walletTx.Confirmations { + t.Fatalf("wrongs confs, %d != %d", st.Confs, walletTx.Confirmations) + } + + cl.queueResponse("gettransaction", tErr) + cl.queueResponse("gettxout", tErr) + _, err = w.ConfirmRedemption(coinID, redemption, 0) + if !errorHasCode(err, errNoTx) { + t.Fatalf("wrong error for gettxout error: %v", err) + } + + cl.queueResponse("gettransaction", tErr) + cl.queueResponse("gettxout", nil) + st, err = w.ConfirmRedemption(coinID, redemption, 0) + if err != nil { + t.Fatalf("ConfirmRedemption error for spent redemption: %v", err) + } + if st.Confs != requiredRedeemConfirms { + t.Fatalf("wrong confs for spent redemption: %d != %d", st.Confs, requiredRedeemConfirms) + } + + // Re-submission path is tested by TestRedemption +} + +func TestFundMultiOrder(t *testing.T) { + w, cl, shutdown := tNewWallet() + defer shutdown() + defer cl.checkEmptiness(t) + + const maxLock = 1e16 + + // Invalid input + mo := &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{{}}, // 1 zero-value order + } + if _, _, _, err := w.FundMultiOrder(mo, maxLock); !errorHasCode(err, errBadInput) { + t.Fatalf("wrong error for zero value: %v", err) + } + + mo = &asset.MultiOrder{ + Values: []*asset.MultiOrderValue{{ + Value: tLotSize, + MaxSwapCount: 1, + }}, + } + req := tLotSize + dexzec.TxFeesZIP317(dexbtc.RedeemP2PKHInputSize+1, dexbtc.P2SHOutputSize+1, 0, 0, 0, 0) + + // maxLock too low + if _, _, _, err := w.FundMultiOrder(mo, 1); !errorHasCode(err, errMaxLock) { + t.Fatalf("wrong error for exceeding maxLock: %v", err) + } + + // Balance error + cl.queueResponse(methodZGetBalanceForAccount, tErr) + if _, _, _, err := w.FundMultiOrder(mo, maxLock); !errorHasCode(err, errFunding) { + t.Fatalf("wrong error for expected balance error: %v", err) + } + + acctBal := &zAccountBalance{ + Pools: zBalancePools{ + Transparent: valZat{ + ValueZat: req - 1, // too little + }, + }, + } + + queueBalance := func() { + cl.queueResponse(methodZGetBalanceForAccount, acctBal) // 0-conf + cl.queueResponse(methodZGetBalanceForAccount, acctBal) // minOrchardConfs + cl.queueResponse(methodZGetNotesCount, &zNotesCount{Orchard: 1}) + cl.queueResponse("listlockunspent", nil) + } + + // Not enough balance + queueBalance() + if _, _, _, err := w.FundMultiOrder(mo, maxLock); !errorHasCode(err, errInsufficientBalance) { + t.Fatalf("wrong low balance error: %v", err) + } + + // listunspent error + acctBal.Pools.Transparent.ValueZat = req + queueBalance() + cl.queueResponse("listunspent", tErr) + if _, _, _, err := w.FundMultiOrder(mo, maxLock); !errorHasCode(err, errFunding) { + t.Fatalf("wrong error for listunspent error: %v", err) + } + + // got enough + unspent := &btc.ListUnspentResult{ + ScriptPubKey: tP2PKH, + Amount: float64(req) / 1e8, + } + 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/core/core_test.go b/client/core/core_test.go index a030f6531c..61b7b9fdb5 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -111,7 +111,7 @@ var ( tUnparseableHost = string([]byte{0x7f}) tSwapFeesPaid uint64 = 500 tRedemptionFeesPaid uint64 = 350 - tLogger = dex.StdOutLogger("TCORE", dex.LevelDebug) + tLogger = dex.StdOutLogger("TCORE", dex.LevelInfo) tMaxFeeRate uint64 = 10 tWalletInfo = &asset.WalletInfo{ Version: 0, @@ -752,6 +752,7 @@ func newTWallet(assetID uint32) (*xcWallet, *TXCWallet) { version: w.info.Version, supportedVersions: w.info.SupportedVersions, Wallet: w, + Symbol: dex.BipIDSymbol(assetID), connector: dex.NewConnectionMaster(w), AssetID: assetID, hookedUp: true, @@ -9355,7 +9356,7 @@ func TestSuspectTrades(t *testing.T) { setSwaps() tDcrWallet.swapErr = tErr _, err = tCore.tick(tracker) - if err == nil || !strings.Contains(err.Error(), "error sending swap transaction") { + if err == nil || !strings.Contains(err.Error(), "error sending dcr swap transaction") { t.Fatalf("swap error not propagated, err = %v", err) } if tDcrWallet.swapCounter != 1 { diff --git a/client/core/simnet_trade.go b/client/core/simnet_trade.go index 66575ca112..080de02816 100644 --- a/client/core/simnet_trade.go +++ b/client/core/simnet_trade.go @@ -1225,6 +1225,7 @@ func (s *simulationTest) monitorTrackedTrade(client *simulationClient, tracker * var waitedForOtherSideMakerInit, waitedForOtherSideTakerInit bool tryUntil(s.ctx, maxTradeDuration, func() bool { + var completedTrades int mineAssets := make(map[uint32]uint32) var waitForOtherSideMakerInit, waitForOtherSideTakerInit bool @@ -1306,6 +1307,7 @@ func (s *simulationTest) monitorTrackedTrade(client *simulationClient, tracker * logIt("redeem", assetID, nBlocks) } } + finish := completedTrades == len(tracker.matches) // Do not hold the lock while mining as this hinders trades. tracker.mtx.Unlock() @@ -1460,7 +1462,7 @@ func (s *simulationTest) checkAndWaitForRefunds(ctx context.Context, client *sim // allow up to 30 seconds for core to get around to refunding the swaps var notRefundedSwaps int - refundWaitTimeout := 30 * time.Second + refundWaitTimeout := 60 * time.Second refundedSwaps := tryUntil(ctx, refundWaitTimeout, func() bool { tracker.mtx.RLock() defer tracker.mtx.RUnlock() @@ -1799,7 +1801,17 @@ func dgbWallet(node string) (*tWallet, error) { } func zecWallet(node string) (*tWallet, error) { - return btcCloneWallet(zec.BipID, node, WTCoreClone) + if node == "alpha" { + return nil, errors.New("cannot use alpha wallet on Zcash") + } + cfg, err := config.Parse(filepath.Join(dextestDir, "zec", node, node+".conf")) + if err != nil { + return nil, err + } + return &tWallet{ + walletType: "zcashdRPC", + config: cfg, + }, nil } func zclWallet(node string) (*tWallet, error) { @@ -1941,6 +1953,7 @@ func (s *simulationTest) registerDEX(client *simulationClient) error { } dexFee := feeAsset.Amt + // TODO: Use bonds. // connect dex and pay fee regRes, err := client.core.Register(&RegisterForm{ Addr: dexHost, diff --git a/client/core/trade.go b/client/core/trade.go index 7f4e4ba99f..9891b75f00 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -1673,6 +1673,7 @@ func (t *trackedTrade) isRefundable(ctx context.Context, match *matchTracker) bo } return false } + if swapLocktimeExpired { return true } @@ -2390,7 +2391,7 @@ func (c *Core) swapMatchGroup(t *trackedTrade, matches []*matchTracker, errs *er match.swapErr = err } } - errs.add("error sending swap transaction: %v", err) + errs.add("error sending %s swap transaction: %v", fromWallet.Symbol, err) return } diff --git a/client/webserver/site/src/js/markets.ts b/client/webserver/site/src/js/markets.ts index 4149c93d5e..b6a99cd634 100644 --- a/client/webserver/site/src/js/markets.ts +++ b/client/webserver/site/src/js/markets.ts @@ -2520,7 +2520,7 @@ export default class MarketsPage extends BasePage { const tier = strongTier(auth) page.tradingTier.textContent = String(tier) const [usedParcels, parcelLimit] = tradingLimits(host) - page.tradingLimit.textContent = String(parcelLimit * mkt.parcelsize) + page.tradingLimit.textContent = (parcelLimit * mkt.parcelsize).toFixed(2) page.limitUsage.textContent = parcelLimit > 0 ? (usedParcels / parcelLimit * 100).toFixed(1) : '0' page.orderLimitRemain.textContent = ((parcelLimit - usedParcels) * mkt.parcelsize).toFixed(1) @@ -3390,7 +3390,6 @@ class BalanceWidget { side.parentBal = balTmpl.bal } } - addRow(intl.prep(intl.ID_AVAILABLE), bal.available, asset.unitInfo) addRow(intl.prep(intl.ID_LOCKED), bal.locked + bal.contractlocked + bal.bondlocked, asset.unitInfo) addRow(intl.prep(intl.ID_IMMATURE), bal.immature, asset.unitInfo) diff --git a/dex/networks/btc/script.go b/dex/networks/btc/script.go index b8004f7146..f9cb08a6ff 100644 --- a/dex/networks/btc/script.go +++ b/dex/networks/btc/script.go @@ -362,7 +362,7 @@ func MakeContract(rAddr, sAddr btcutil.Address, secretHash []byte, lockTime int6 } else { _, ok := rAddr.(*btcutil.AddressPubKeyHash) if !ok { - return nil, fmt.Errorf("recipient address %s is not a witness-pubkey-hash address", rAddr.String()) + return nil, fmt.Errorf("recipient address %s is not a pubkey-hash address", rAddr.String()) } _, ok = sAddr.(*btcutil.AddressPubKeyHash) if !ok { @@ -413,12 +413,12 @@ func IsDust(txOut *wire.TxOut, minRelayTxFee uint64) bool { } // IsDustVal is like IsDust but only takes the txSize, amount and if segwit. -func IsDustVal(txSize, value, minRelayTxFee uint64, segwit bool) bool { - totalSize := txSize + 41 +func IsDustVal(txOutSize, value, minRelayTxFee uint64, segwit bool) bool { + totalSize := txOutSize + 41 if segwit { // This function is taken from btcd, but noting here that we are not // rounding up and probably should be. - totalSize += (107 / witnessWeight) + totalSize += (107 / witnessWeight) // + 26 } else { totalSize += 107 } diff --git a/dex/networks/dcr/script.go b/dex/networks/dcr/script.go index a83069ad71..27360ad2b6 100644 --- a/dex/networks/dcr/script.go +++ b/dex/networks/dcr/script.go @@ -631,8 +631,8 @@ func IsDust(txOut *wire.TxOut, minRelayTxFee uint64) bool { // IsDustVal is like IsDust but it only needs the size of the serialized output // and its amount. -func IsDustVal(sz, amt, minRelayTxFee uint64) bool { - totalSize := sz + 165 +func IsDustVal(txOutSize, amt, minRelayTxFee uint64) bool { + totalSize := txOutSize + 165 return amt/(3*totalSize) < minRelayTxFee } diff --git a/dex/networks/zec/addr.go b/dex/networks/zec/addr.go index ace8e47c1d..7ec3c290ab 100644 --- a/dex/networks/zec/addr.go +++ b/dex/networks/zec/addr.go @@ -50,7 +50,7 @@ func DecodeAddress(a string, addrParams *AddressParams, btcParams *chaincfg.Para return btcutil.NewAddressScriptHashFromHash(data, btcParams) } - return nil, fmt.Errorf("unknown address type %v", addrID) + return nil, fmt.Errorf("unknown zec address type %v %v %v", addrID, addrParams.PubKeyHashAddrID, addrParams.ScriptHashAddrID) } // RecodeAddress converts an internal btc address to a Zcash address string. diff --git a/dex/networks/zec/block.go b/dex/networks/zec/block.go index bbdea363b0..72d8793be1 100644 --- a/dex/networks/zec/block.go +++ b/dex/networks/zec/block.go @@ -59,6 +59,18 @@ func DeserializeBlock(b []byte) (*Block, error) { return zecBlock, nil } +func DeserializeBlockHeader(b []byte) (*wire.BlockHeader, error) { + zecBlock := &Block{} + + // https://zips.z.cash/protocol/protocol.pdf section 7.6 + r := bytes.NewReader(b) + + if err := zecBlock.decodeBlockHeader(r); err != nil { + return nil, err + } + return &zecBlock.Header, nil +} + // See github.com/zcash/zcash CBlockHeader -> SerializeOp func (z *Block) decodeBlockHeader(r io.Reader) error { hdr := &z.MsgBlock.Header diff --git a/dex/networks/zec/block_test.go b/dex/networks/zec/block_test.go index 070e55e605..717a740a88 100644 --- a/dex/networks/zec/block_test.go +++ b/dex/networks/zec/block_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" ) var ( @@ -15,6 +16,8 @@ var ( simnetBlockHeader []byte //go:embed test-data/block_1624455.dat block1624455 []byte + //go:embed test-data/header_1624455.dat + header1624455 []byte //go:embed test-data/solution_1624455.dat solution1624455 []byte ) @@ -30,37 +33,44 @@ func TestBlock(t *testing.T) { expBits := binary.LittleEndian.Uint32(mustDecodeHex("d0aa011c")) const expTime = 1649294915 - zecBlock, err := DeserializeBlock(block1624455) - if err != nil { - t.Fatalf("decodeBlockHeader error: %v", err) - } + checkHeader := func(hdr *wire.BlockHeader) { + if hdr.Version != expVersion { + t.Fatalf("wrong version. expected %d, got %d", expVersion, hdr.Version) + } - hdr := &zecBlock.MsgBlock.Header + if *expPrevBlock != hdr.PrevBlock { + t.Fatal("wrong previous block", expPrevBlock, hdr.PrevBlock[:]) + } - if hdr.Version != expVersion { - t.Fatalf("wrong version. expected %d, got %d", expVersion, hdr.Version) - } + if *expMerkleRoot != hdr.MerkleRoot { + t.Fatal("wrong merkle root", expMerkleRoot, hdr.MerkleRoot[:]) + } - if *expPrevBlock != hdr.PrevBlock { - t.Fatal("wrong previous block", expPrevBlock, hdr.PrevBlock[:]) - } + // TODO: Find out why this is not right. + // if !bytes.Equal(zecBlock.HashBlockCommitments[:], expHashBlockCommitments) { + // t.Fatal("wrong hashBlockCommitments", zecBlock.HashBlockCommitments[:], expHashBlockCommitments, h) + // } - if *expMerkleRoot != hdr.MerkleRoot { - t.Fatal("wrong merkle root", expMerkleRoot, hdr.MerkleRoot[:]) - } + if hdr.Bits != expBits { + t.Fatalf("wrong bits") + } - // TODO: Find out why this is not right. - // if !bytes.Equal(zecBlock.HashBlockCommitments[:], expHashBlockCommitments) { - // t.Fatal("wrong hashBlockCommitments", zecBlock.HashBlockCommitments[:], expHashBlockCommitments, h) - // } + if hdr.Timestamp.Unix() != expTime { + t.Fatalf("wrong timestamp") + } + } - if hdr.Bits != expBits { - t.Fatalf("wrong bits") + hdr, err := DeserializeBlockHeader(header1624455) + if err != nil { + t.Fatalf("DeserializeBlockHeader error: %v", err) } + checkHeader(hdr) - if hdr.Timestamp.Unix() != expTime { - t.Fatalf("wrong timestamp") + zecBlock, err := DeserializeBlock(block1624455) + if err != nil { + t.Fatalf("decodeBlockHeader error: %v", err) } + checkHeader(&zecBlock.MsgBlock.Header) if !bytes.Equal(zecBlock.Nonce[:], expNonce) { t.Fatal("wrong nonce", zecBlock.Nonce[:], expNonce) diff --git a/dex/networks/zec/script.go b/dex/networks/zec/script.go new file mode 100644 index 0000000000..c1032dae31 --- /dev/null +++ b/dex/networks/zec/script.go @@ -0,0 +1,56 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org + +package zec + +import ( + "math" + + dexbtc "decred.org/dcrdex/dex/networks/btc" + "github.com/btcsuite/btcd/wire" +) + +// https://zips.z.cash/zip-0317 + +// TransparentTxFeesZIP317 calculates the ZIP-0317 fees for a fully transparent +// Zcash transaction, which only depends on the size of the tx_in and tx_out +// fields. +func TransparentTxFeesZIP317(txInSize, txOutSize uint64) uint64 { + return TxFeesZIP317(txInSize, txOutSize, 0, 0, 0, 0) +} + +// TxFeexZIP317 calculates fees for a transaction. The caller must sum up the +// txin and txout, which is the entire serialization size associated with the +// respective field, including the size of the count varint. +func TxFeesZIP317(transparentTxInsSize, transparentTxOutsSize uint64, nSpendsSapling, nOutputsSapling, nJoinSplit, nActionsOrchard uint64) uint64 { + const ( + marginalFee = 5000 + graceActions = 2 + pkhStandardInputSize = 150 + pkhStandardOutputSize = 34 + ) + + nIn := math.Ceil(float64(transparentTxInsSize) / pkhStandardInputSize) + nOut := math.Ceil(float64(transparentTxOutsSize) / pkhStandardOutputSize) + + nSapling := uint64(math.Max(float64(nSpendsSapling), float64(nOutputsSapling))) + logicalActions := uint64(math.Max(nIn, nOut)) + 2*nJoinSplit + nSapling + nActionsOrchard + + return marginalFee * uint64(math.Max(graceActions, float64(logicalActions))) +} + +// RequiredOrderFunds is the ZIP-0317 compliant version of +// calc.RequiredOrderFunds. +func RequiredOrderFunds(swapVal, inputCount, inputsSize, maxSwaps uint64) uint64 { + // One p2sh output for the contract, 1 change output. + const txOutsSize = dexbtc.P2PKHOutputSize + dexbtc.P2SHOutputSize + 1 /* wire.VarIntSerializeSize(2) */ + txInsSize := inputsSize + uint64(wire.VarIntSerializeSize(inputCount)) + firstTxFees := TransparentTxFeesZIP317(txInsSize, txOutsSize) + if maxSwaps == 1 { + return swapVal + firstTxFees + } + + otherTxsFees := TransparentTxFeesZIP317(dexbtc.RedeemP2PKHInputSize+1, txOutsSize) + fees := firstTxFees + (maxSwaps-1)*otherTxsFees + return swapVal + fees +} diff --git a/dex/networks/zec/test-data/header_1624455.dat b/dex/networks/zec/test-data/header_1624455.dat new file mode 100644 index 0000000000000000000000000000000000000000..f12cea91fa2b91a6175fe06da11781d6524abafd GIT binary patch literal 1487 zcmV;=1u*&q0001aqVdx|y-~LNp_7R;W3)?hS7P|#jZHazaW(+}0001gau(G^uk=|t z_tI3=8Ens)9d>_xC$I|Ven`>J!tO&oMN?4YW9RTttMkK3+Y<;h=mQ%vGGv2Va*~%z zQg8c1K2BoLssSA0&;S4c00000000000000000RI3000000000$38eM?Km`C~L#v|| zeLmBKAz3pLR>bNpJB3pjlX|Dla`E@!M>Eq_@*#Vg8y_`30w`V;Acw&CV|$7{z3s;; zl|o-qa3|}oYFbq^Sq?NsS_V|Zs;gO^l#B$RnfO2jt9J@`al7wuMa}iH_tp#)*_o)+ z+S{2#nlfFsE7mlgpeLnbN#Ot*Ggd)EPe#TUT0gFF#dw7UCU<&SWaL{U?5&uIJ1q3&;U>ve zsQ3%0%QIPHd8p@ZQ6|8PIK=@w#mUXlzRRp?kgLL@y1BL3R8vI<^DgowCLJ+4EO7Yr zA)#Pv^4WOE2)m%$^egbl>dM{8DQzRbk72;0Y-2!MPj90<=!V-S^WGqZA8D zT(U}+@Tl5*2zjemjY2KYzEh?^eXhWw+Djir^ks-*M>k!PC=dK-A>ETcG>~>`wm$Yl3DRUalsL*uH+vr zYx*%?sk!@?>ASQWaT$u*6+2>K7YPec@2fa;SG+X-Tb#2+Ism)cv+v2}%dyouzEv0vddXTM z97(~KP&OE}LiKc}C9rYHs#=xbk+L7I1-qt?$+gP4Bvy|$;4$7Tce4sH)|MUS8bMn! z{6yX{-c}-opIRLsCxHocRLuk=!Odg*sW-PiP8Igds40>QO;@I*L2rI^Jy^& z=oEhSo$qq(CjI4mT@&P#=m<-t#30E_pVrXB0wjvoyuY!b`w$>gD+d<3|CsB%v(t zIktx73<}t5s#?~$iZlT}6W{3HiCw<6(j!~+iDd;@88r?IyKmCZO4lojK5;LKOV?3v zWi1OQ0@hNeqU(DN+N3K1$1Oir|t~lX++!* zGvi}~0_$XPE!IYQlCI|sm-#TGP!H3dXYrrze6Gv%1b^CS8A9=LP4kDz#xy3bylmcz zk~duJZCt$!_JcCMMXWui7#_!amWf$6$5#SBa7B8~^M;c!f_fii5z$|@7i%32fu~#p z(*DrA8*x0y@h_FdY+9{p2W|?u7-Tjl0$RS?=ng*RZ$vVzO*l)_3 z8Hp3vatH> "./markets.json" } ], @@ -327,7 +334,7 @@ cat << EOF >> "./markets.json" "regConfs": 2, "regFee": 20000000, "regXPub": "vpub5SLqN2bLY4WeZJ9SmNJHsyzqVKreTXD4ZnPC22MugDNcjhKX5xNX9QiQWcE4SSRzVWyHWUihpKRT7hckDGNzVc69wSX2JPcfGeNiT5c2XZy", - "bondAmt": 10000, + "bondAmt": 100000, "bondConfs": 1, "nodeRelayID": "${BTC_NODERELAY_ID}" EOF @@ -450,9 +457,7 @@ if [ $ZEC_ON -eq 0 ]; then "network": "simnet", "maxFeeRate": 200, "swapConf": 1, - "configPath": "${TEST_ROOT}/zec/alpha/alpha.conf", - "bondAmt": 40000000, - "bondConfs": 1 + "configPath": "${TEST_ROOT}/zec/alpha/alpha.conf" EOF fi diff --git a/dex/testing/zec/harness.sh b/dex/testing/zec/harness.sh index 5f28f3539c..1f5564f7fa 100755 --- a/dex/testing/zec/harness.sh +++ b/dex/testing/zec/harness.sh @@ -142,7 +142,7 @@ echo "Starting simnet alpha node" tmux send-keys -t $SESSION:0 "${DAEMON} -rpcuser=user -rpcpassword=pass \ -rpcport=${ALPHA_RPC_PORT} -datadir=${ALPHA_DIR} -conf=alpha.conf \ -debug=rpc -debug=net -debug=mempool -debug=walletdb -debug=addrman -debug=mempoolrej \ - -whitelist=127.0.0.0/8 -whitelist=::1 \ + -whitelist=127.0.0.0/8 -whitelist=::1 -preferredtxversion=5 \ -txindex=1 -regtest=1 -port=${ALPHA_LISTEN_PORT} -fallbackfee=0.00001 \ -printtoconsole; tmux wait-for -S alpha${SYMBOL}" C-m @@ -158,7 +158,7 @@ echo "Starting simnet beta node" tmux send-keys -t $SESSION:1 "${DAEMON} -rpcuser=user -rpcpassword=pass \ -rpcport=${BETA_RPC_PORT} -datadir=${BETA_DIR} -conf=beta.conf -txindex=1 -regtest=1 \ -debug=rpc -debug=net -debug=mempool -debug=walletdb -debug=addrman -debug=mempoolrej \ - -whitelist=127.0.0.0/8 -whitelist=::1 \ + -whitelist=127.0.0.0/8 -whitelist=::1 -preferredtxversion=5 \ -port=${BETA_LISTEN_PORT} -fallbackfee=0.00001 -printtoconsole; \ tmux wait-for -S beta${SYMBOL}" C-m @@ -174,7 +174,7 @@ echo "Starting simnet delta node" tmux send-keys -t $SESSION:2 "${DAEMON} -rpcuser=user -rpcpassword=pass \ -rpcport=${DELTA_RPC_PORT} -datadir=${DELTA_DIR} -conf=delta.conf -regtest=1 \ -debug=rpc -debug=net -debug=mempool -debug=walletdb -debug=addrman -debug=mempoolrej \ - -whitelist=127.0.0.0/8 -whitelist=::1 \ + -whitelist=127.0.0.0/8 -whitelist=::1 -preferredtxversion=5 \ -port=${DELTA_LISTEN_PORT} -fallbackfee=0.00001 -printtoconsole; \ tmux wait-for -S delta${SYMBOL}" C-m @@ -190,7 +190,7 @@ echo "Starting simnet gamma node" tmux send-keys -t $SESSION:3 "${DAEMON} -rpcuser=user -rpcpassword=pass \ -rpcport=${GAMMA_RPC_PORT} -datadir=${GAMMA_DIR} -conf=gamma.conf -regtest=1 \ -debug=rpc -debug=net -debug=mempool -debug=walletdb -debug=addrman -debug=mempoolrej \ - -whitelist=127.0.0.0/8 -whitelist=::1 \ + -whitelist=127.0.0.0/8 -whitelist=::1 -preferredtxversion=5 \ -port=${GAMMA_LISTEN_PORT} -fallbackfee=0.00001 -printtoconsole; \ tmux wait-for -S gamma${SYMBOL}" C-m sleep 30 @@ -218,7 +218,7 @@ printf "rpcuser=user\nrpcpassword=pass\nregtest=1\nrpcport=\$2\nexportdir=${SOUR ${DAEMON} -rpcuser=user -rpcpassword=pass \ -rpcport=\$2 -datadir=${NODES_ROOT}/\$1 -regtest=1 -conf=\$1.conf \ -debug=rpc -debug=net -debug=mempool -debug=walletdb -debug=addrman -debug=mempoolrej \ --whitelist=127.0.0.0/8 -whitelist=::1 \ +-whitelist=127.0.0.0/8 -whitelist=::1 -preferredtxversion=5 \ -port=\$3 -fallbackfee=0.00001 -printtoconsole EOF chmod +x "./start-wallet" @@ -328,10 +328,25 @@ tmux send-keys -t $SESSION:4 "./alpha generate 400${DONE}" C-m\; ${WAIT} # Send gamma and delta some coin ################################################################################ +getaddr () { + cd ${HARNESS_DIR} + NODE=$1 + ./${NODE} z_getnewaccount > /dev/null + R=$(./${NODE} z_getaddressforaccount 0) + UADDR=$(sed -rn 's/.*"address": "([^"]+)".*/\1/p' <<< "${R}") + R=$(./${NODE} z_listunifiedreceivers ${UADDR}) + ADDR=$(sed -rn 's/.*"p2pkh": "([^"]+)".*/\1/p' <<< "${R}") + echo $ADDR +} + ALPHA_ADDR="tmEgW8c44RQQfft9FHXnqGp8XEcQQSRcUXD" -BETA_ADDR="tmSog4freWuq1aC13yf1996fy4qXPmv3GTB" -DELTA_ADDR="tmYBxRStK3QCFeML4qJmzuvFCR9Kob82bi8" -GAMMA_ADDR="tmEWZVKveNfnrdmkgizFWBtD18bnxT1NYFc" +BETA_ADDR=$(getaddr beta) +DELTA_ADDR=$(getaddr delta) +GAMMA_ADDR=$(getaddr gamma) + +echo "beta address ${BETA_ADDR}" +echo "delta address ${DELTA_ADDR}" +echo "gamma address ${GAMMA_ADDR}" # Send the lazy wallets some dough. echo "Sending 174 ZEC to beta in 8 blocks" diff --git a/server/asset/btc/btc.go b/server/asset/btc/btc.go index 29527925d1..20494cadf4 100644 --- a/server/asset/btc/btc.go +++ b/server/asset/btc/btc.go @@ -17,6 +17,7 @@ import ( "time" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/calc" "decred.org/dcrdex/dex/config" dexbtc "decred.org/dcrdex/dex/networks/btc" "decred.org/dcrdex/server/account" @@ -514,6 +515,11 @@ func (btc *Backend) FundingCoin(_ context.Context, coinID []byte, redeemScript [ return utxo, nil } +func (*Backend) ValidateOrderFunding(swapVal, valSum, _, inputsSize, maxSwaps uint64, nfo *dex.Asset) bool { + reqVal := calc.RequiredOrderFunds(swapVal, inputsSize, maxSwaps, nfo) + return valSum >= reqVal +} + // ValidateCoinID attempts to decode the coinID. func (btc *Backend) ValidateCoinID(coinID []byte) (string, error) { txid, vout, err := decodeCoinID(coinID) @@ -840,8 +846,8 @@ func (*Backend) Info() *asset.BackendInfo { // ValidateFeeRate checks that the transaction fees used to initiate the // contract are sufficient. -func (*Backend) ValidateFeeRate(contract *asset.Contract, reqFeeRate uint64) bool { - return contract.FeeRate() >= reqFeeRate +func (btc *Backend) ValidateFeeRate(c asset.Coin, reqFeeRate uint64) bool { + return c.FeeRate() >= reqFeeRate } // CheckSwapAddress checks that the given address is parseable, and suitable as @@ -1193,13 +1199,28 @@ func (btc *Backend) transaction(txHash *chainhash.Hash, verboseTx *VerboseTxExte if verboseTx.Vsize > 0 { feeRate = (sumIn - sumOut) / uint64(verboseTx.Vsize) } - } else if verboseTx.Size > 0 { + } else if verboseTx.Size > 0 && sumIn > sumOut { // For non-segwit transactions, Size = Vsize anyway, so use Size to // cover assets that won't set Vsize in their RPC response. feeRate = (sumIn - sumOut) / uint64(verboseTx.Size) - } - return newTransaction(btc, txHash, blockHash, lastLookup, blockHeight, isCoinbase, inputs, outputs, feeRate, verboseTx.Raw), nil + hash := blockHash + if hash == nil { + hash = &zeroHash + } + return &Tx{ + btc: btc, + blockHash: *hash, + height: blockHeight, + hash: *txHash, + ins: inputs, + outs: outputs, + isCoinbase: isCoinbase, + lastLookup: lastLookup, + inputSum: sumIn, + feeRate: feeRate, + raw: verboseTx.Raw, + }, nil } // Get information for an unspent transaction output and it's transaction. diff --git a/server/asset/btc/btc_test.go b/server/asset/btc/btc_test.go index 364d8595ab..ea785dc6ad 100644 --- a/server/asset/btc/btc_test.go +++ b/server/asset/btc/btc_test.go @@ -1394,7 +1394,7 @@ func TestAuxiliary(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if utxo.TxID() != txid { + if utxo.Coin().TxID() != txid { t.Fatalf("utxo txid doesn't match") } diff --git a/server/asset/btc/tx.go b/server/asset/btc/tx.go index 489bd6b82a..00fa4b630c 100644 --- a/server/asset/btc/tx.go +++ b/server/asset/btc/tx.go @@ -28,7 +28,8 @@ type Tx struct { // calls to Confirmations. lastLookup *chainhash.Hash // The calculated transaction fee rate, in satoshis/vbyte - feeRate uint64 + feeRate uint64 + inputSum uint64 // raw is the raw tx bytes. raw []byte } @@ -47,28 +48,6 @@ type txOut struct { pkScript []byte } -// A getter for a new Tx. -func newTransaction(btc *Backend, txHash, blockHash, lastLookup *chainhash.Hash, - blockHeight int64, isCoinbase bool, ins []txIn, outs []txOut, feeRate uint64, rawTx []byte) *Tx { - // Set a nil blockHash to the zero hash. - hash := blockHash - if hash == nil { - hash = &zeroHash - } - return &Tx{ - btc: btc, - blockHash: *hash, - height: blockHeight, - hash: *txHash, - ins: ins, - outs: outs, - isCoinbase: isCoinbase, - lastLookup: lastLookup, - feeRate: feeRate, - raw: rawTx, - } -} - // JoinSplit represents a Zcash JoinSplit. // https://zips.z.cash/protocol/canopy.pdf section 4.11 type JoinSplit struct { diff --git a/server/asset/btc/utxo.go b/server/asset/btc/utxo.go index a1deada9fd..830506847f 100644 --- a/server/asset/btc/utxo.go +++ b/server/asset/btc/utxo.go @@ -99,6 +99,14 @@ func (txio *TXIO) FeeRate() uint64 { return txio.tx.feeRate } +func (txio *TXIO) InputsValue() uint64 { + return txio.tx.inputSum +} + +func (txio *TXIO) RawTx() []byte { + return txio.tx.raw +} + // Input is a transaction input. type Input struct { TXIO @@ -277,6 +285,10 @@ type UTXO struct { *Output } +func (utxo *UTXO) Coin() asset.Coin { + return utxo +} + // Confirmations returns the number of confirmations on this output's // transaction. See also (*Output).Confirmations. This function differs from the // Output method in that it is necessary to relocate the utxo after a reorg, it diff --git a/server/asset/common.go b/server/asset/common.go index 182bdedcc0..c86fa9c3c2 100644 --- a/server/asset/common.go +++ b/server/asset/common.go @@ -85,7 +85,7 @@ type Backend interface { Info() *BackendInfo // ValidateFeeRate checks that the transaction fees used to initiate the // contract are sufficient. - ValidateFeeRate(contract *Contract, reqFeeRate uint64) bool + ValidateFeeRate(coin Coin, reqFeeRate uint64) bool } // OutputTracker is implemented by backends for UTXO-based blockchains. @@ -100,6 +100,8 @@ type OutputTracker interface { // with non-standard pkScripts or scripts that require zero signatures to // redeem must return an error. FundingCoin(ctx context.Context, coinID []byte, redeemScript []byte) (FundingCoin, error) + // ValidateOrderFunding validates that the supplied utxos are enough to fund an order. + ValidateOrderFunding(swapVal, valSum, inputCount, inputsSize, maxSwaps uint64, nfo *dex.Asset) bool } // AccountBalancer is implemented by backends for account-based blockchains. @@ -147,7 +149,7 @@ type Coin interface { // FundingCoin is some unspent value on the blockchain. type FundingCoin interface { - Coin + Coin() Coin // Auth checks that the owner of the provided pubkeys can spend the // FundingCoin. The signatures (sigs) generated with the private keys // corresponding to pubkeys must validate against the pubkeys and signing diff --git a/server/asset/dcr/dcr.go b/server/asset/dcr/dcr.go index de1c118ac8..b9dce320cd 100644 --- a/server/asset/dcr/dcr.go +++ b/server/asset/dcr/dcr.go @@ -18,6 +18,7 @@ import ( "time" "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/calc" dexdcr "decred.org/dcrdex/dex/networks/dcr" "decred.org/dcrdex/server/account" "decred.org/dcrdex/server/asset" @@ -445,8 +446,8 @@ func (*Backend) Info() *asset.BackendInfo { // ValidateFeeRate checks that the transaction fees used to initiate the // contract are sufficient. -func (*Backend) ValidateFeeRate(contract *asset.Contract, reqFeeRate uint64) bool { - return contract.FeeRate() >= reqFeeRate +func (dcr *Backend) ValidateFeeRate(c asset.Coin, reqFeeRate uint64) bool { + return c.FeeRate() >= reqFeeRate } // BlockChannel creates and returns a new channel on which to receive block @@ -575,6 +576,11 @@ func ValidateXPub(xpub string) error { return nil } +func (*Backend) ValidateOrderFunding(swapVal, valSum, _, inputsSize, maxSwaps uint64, nfo *dex.Asset) bool { + reqVal := calc.RequiredOrderFunds(swapVal, inputsSize, maxSwaps, nfo) + return valSum >= reqVal +} + // ValidateCoinID attempts to decode the coinID. func (dcr *Backend) ValidateCoinID(coinID []byte) (string, error) { txid, vout, err := decodeCoinID(coinID) diff --git a/server/asset/dcr/dcr_test.go b/server/asset/dcr/dcr_test.go index f6c08735de..0ce382212d 100644 --- a/server/asset/dcr/dcr_test.go +++ b/server/asset/dcr/dcr_test.go @@ -1411,10 +1411,10 @@ func TestAuxiliary(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - if txHash.String() != utxo.TxID() { + if txHash.String() != utxo.Coin().TxID() { t.Fatalf("utxo tx hash doesn't match") } - if utxo.TxID() != txid { + if utxo.Coin().TxID() != txid { t.Fatalf("utxo txid doesn't match") } diff --git a/server/asset/dcr/utxo.go b/server/asset/dcr/utxo.go index c29d9781d1..29a49810b6 100644 --- a/server/asset/dcr/utxo.go +++ b/server/asset/dcr/utxo.go @@ -302,6 +302,10 @@ type UTXO struct { *Output } +func (utxo *UTXO) Coin() asset.Coin { + return utxo +} + // Confirmations returns the number of confirmations on this output's // transaction. See also (*Output).Confirmations. This function differs from the // Output method in that it is necessary to relocate the utxo after a reorg, it diff --git a/server/asset/eth/eth.go b/server/asset/eth/eth.go index 8880eb153b..5ed850fb0c 100644 --- a/server/asset/eth/eth.go +++ b/server/asset/eth/eth.go @@ -516,8 +516,7 @@ func (*baseBackend) Info() *asset.BackendInfo { // ValidateFeeRate checks that the transaction fees used to initiate the // contract are sufficient. For most assets only the contract.FeeRate() cannot // be less than reqFeeRate, but for Eth, the gasTipCap must also be checked. -func (eth *baseBackend) ValidateFeeRate(contract *asset.Contract, reqFeeRate uint64) bool { - coin := contract.Coin +func (eth *baseBackend) ValidateFeeRate(coin asset.Coin, reqFeeRate uint64) bool { sc, ok := coin.(*swapCoin) if !ok { eth.baseLogger.Error("%v contract coin type must be a swapCoin but got %T", eth.baseChainName, sc) @@ -530,7 +529,7 @@ func (eth *baseBackend) ValidateFeeRate(contract *asset.Contract, reqFeeRate uin return false } - return contract.FeeRate() >= reqFeeRate + return sc.FeeRate() >= reqFeeRate } // BlockChannel creates and returns a new channel on which to receive block diff --git a/server/asset/eth/eth_test.go b/server/asset/eth/eth_test.go index 8a2052caf9..f51ff39c2b 100644 --- a/server/asset/eth/eth_test.go +++ b/server/asset/eth/eth_test.go @@ -506,16 +506,16 @@ func TestValidateFeeRate(t *testing.T) { eth, _ := tNewBackend(BipID) - if !eth.ValidateFeeRate(contract, 100) { + if !eth.ValidateFeeRate(contract.Coin, 100) { t.Fatalf("expected valid fee rate, but was not valid") } - if eth.ValidateFeeRate(contract, 101) { + if eth.ValidateFeeRate(contract.Coin, 101) { t.Fatalf("expected invalid fee rate, but was valid") } swapCoin.gasTipCap = dexeth.MinGasTipCap - 1 - if eth.ValidateFeeRate(contract, 100) { + if eth.ValidateFeeRate(contract.Coin, 100) { t.Fatalf("expected invalid fee rate, but was valid") } } diff --git a/server/asset/zec/zec.go b/server/asset/zec/zec.go index b14668a7c8..e8d45431da 100644 --- a/server/asset/zec/zec.go +++ b/server/asset/zec/zec.go @@ -132,6 +132,7 @@ func NewBackend(cfg *asset.BackendConfig) (asset.Backend, error) { return &ZECBackend{ Backend: be, + log: cfg.Logger, addrParams: addrParams, btcParams: btcParams, }, nil @@ -141,6 +142,7 @@ func NewBackend(cfg *asset.BackendConfig) (asset.Backend, error) { // with Zcash address translation. type ZECBackend struct { *btc.Backend + log dex.Logger btcParams *chaincfg.Params addrParams *dexzec.AddressParams } @@ -167,6 +169,35 @@ func (be *ZECBackend) FeeRate(context.Context) (uint64, error) { return dexzec.LegacyFeeRate, nil } +func (*ZECBackend) ValidateOrderFunding(swapVal, valSum, inputCount, inputsSize, maxSwaps uint64, _ *dex.Asset) bool { + reqVal := dexzec.RequiredOrderFunds(swapVal, inputCount, inputsSize, maxSwaps) + return valSum >= reqVal +} + +func (be *ZECBackend) ValidateFeeRate(ci asset.Coin, reqFeeRate uint64) bool { + c, is := ci.(interface { + InputsValue() uint64 + RawTx() []byte + }) + if !is { + be.log.Error("ValidateFeeRate contract does not implement TXIO methods") + return false + } + tx, err := dexzec.DeserializeTx(c.RawTx()) + if err != nil { + be.log.Errorf("error deserializing tx for fee validation: %v", err) + return false + } + + fees, err := newFeeTx(tx).Fees(c.InputsValue()) + if err != nil { + be.log.Errorf("error calculating tx fees: %v", err) + return false + } + + return fees >= tx.RequiredTxFeesZIP317() +} + func blockFeeTransactions(rc *btc.RPCClient, blockHash *chainhash.Hash) (feeTxs []btc.FeeTx, prevBlock chainhash.Hash, err error) { blockB, err := rc.GetRawBlock(blockHash) if err != nil { @@ -253,12 +284,20 @@ func (tx *feeTx) FeeRate(prevOuts map[chainhash.Hash]map[int]int64) (uint64, err } transparentIn += uint64(prevOutValue) } + fees, err := tx.Fees(transparentIn) + if err != nil { + return 0, err + } + return uint64(math.Round(float64(fees) / float64(tx.size))), nil +} + +func (tx *feeTx) Fees(transparentIn uint64) (uint64, error) { in := tx.shieldedIn + transparentIn out := tx.shieldedOut + tx.transparentOut if out > in { return 0, fmt.Errorf("out > in. %d > %d", out, in) } - return uint64(math.Round(float64(in-out) / float64(tx.size))), nil + return in - out, nil } func shieldedIO(tx *btc.VerboseTxExtended) (in, out uint64, err error) { diff --git a/server/market/orderrouter.go b/server/market/orderrouter.go index ac5b5d6410..2882a9f8b7 100644 --- a/server/market/orderrouter.go +++ b/server/market/orderrouter.go @@ -473,6 +473,11 @@ func (r *OrderRouter) processTrade(oRecord *orderRecord, tunnel MarketTunnel, as // Funding coins are from a utxo-based asset. Need to find them. + funder, is := assets.funding.Backend.(asset.OutputTracker) + if !is { + return msgjson.NewError(msgjson.RPCInternal, "internal error") + } + // Validate coin IDs and prepare some strings for debug logging. coinStrs := make([]string, 0, len(coins)) for _, coinID := range trade.Coins { @@ -545,7 +550,7 @@ func (r *OrderRouter) processTrade(oRecord *orderRecord, tunnel MarketTunnel, as } delete(neededCoins, key) // don't check this coin again - valSum += dexCoin.Value() + valSum += dexCoin.Coin().Value() // NOTE: Summing like this is actually not quite sufficient to // estimate the size associated with the input, because if it's a // BTC segwit output, we would also have to account for the marker @@ -563,13 +568,12 @@ func (r *OrderRouter) processTrade(oRecord *orderRecord, tunnel MarketTunnel, as } // Calculate the fees and check that the utxo sum is enough. - var reqVal uint64 + var swapVal uint64 if sell { - reqVal = calc.RequiredOrderFunds(trade.Quantity, uint64(spendSize), lots, &fundingAsset.Asset) + swapVal = trade.Quantity } else { if rate > 0 { // limit buy - quoteQty := calc.BaseToQuote(rate, trade.Quantity) - reqVal = calc.RequiredOrderFunds(quoteQty, uint64(spendSize), lots, &assets.quote.Asset) + swapVal = calc.BaseToQuote(rate, trade.Quantity) } else { // This is a market buy order, so the quantity gets special handling. // 1. The quantity is in units of the quote asset. @@ -580,18 +584,16 @@ func (r *OrderRouter) processTrade(oRecord *orderRecord, tunnel MarketTunnel, as } buyBuffer := tunnel.MarketBuyBuffer() lotWithBuffer := uint64(float64(lotSize) * buyBuffer) - minReq := matcher.BaseToQuote(midGap, lotWithBuffer) - if trade.Quantity < minReq { - errStr := fmt.Sprintf("order quantity does not satisfy market buy buffer. %d < %d. midGap = %d", trade.Quantity, minReq, midGap) + swapVal = matcher.BaseToQuote(midGap, lotWithBuffer) + if trade.Quantity < swapVal { + errStr := fmt.Sprintf("order quantity does not satisfy market buy buffer. %d < %d. midGap = %d", trade.Quantity, swapVal, midGap) return false, msgjson.NewError(msgjson.FundingError, errStr) } - reqVal = calc.RequiredOrderFunds(minReq, uint64(spendSize), 1, &assets.quote.Asset) } - } - if valSum < reqVal { - return false, msgjson.NewError(msgjson.FundingError, - fmt.Sprintf("not enough funds. need at least %d, got %d", reqVal, valSum)) + + if !funder.ValidateOrderFunding(swapVal, valSum, uint64(len(trade.Coins)), uint64(spendSize), lots, &assets.funding.Asset) { + return false, msgjson.NewError(msgjson.FundingError, "failed funding validation") } return false, nil @@ -761,7 +763,7 @@ func (r *OrderRouter) submitOrderToMarket(tunnel MarketTunnel, oRecord *orderRec func (r *OrderRouter) checkZeroConfs(dexCoin asset.FundingCoin, fundingAsset *asset.BackedAsset) *msgjson.Error { // Verify that zero-conf coins are within 10% of the last known fee // rate. - confs, err := coinConfirmations(dexCoin) + confs, err := coinConfirmations(dexCoin.Coin()) if err != nil { log.Debugf("Confirmations error for %s coin %s: %v", fundingAsset.Symbol, dexCoin, err) return msgjson.NewError(msgjson.FundingError, fmt.Sprintf("failed to verify coin %v", dexCoin)) @@ -771,11 +773,11 @@ func (r *OrderRouter) checkZeroConfs(dexCoin asset.FundingCoin, fundingAsset *as } lastKnownFeeRate := r.feeSource.LastRate(fundingAsset.ID) // MaxFeeRate applied inside feeSource feeMinimum := uint64(math.Round(float64(lastKnownFeeRate) * ZeroConfFeeRateThreshold)) - feeRate := dexCoin.FeeRate() - if lastKnownFeeRate > 0 && feeRate < feeMinimum { - log.Debugf("Fee rate too low %s coin %s: %d < %d", fundingAsset.Symbol, dexCoin, feeRate, feeMinimum) + + if !fundingAsset.Backend.ValidateFeeRate(dexCoin.Coin(), feeMinimum) { + log.Debugf("Fees too low %s coin %s: fee mim %d", fundingAsset.Symbol, dexCoin, feeMinimum) return msgjson.NewError(msgjson.FundingError, - fmt.Sprintf("fee rate for %s is too low. %d < %d", dexCoin, feeRate, feeMinimum)) + fmt.Sprintf("fee rate for %s is too low. fee min %d", dexCoin, feeMinimum)) } return nil } diff --git a/server/market/routers_test.go b/server/market/routers_test.go index 87e34404ca..d4b80816d3 100644 --- a/server/market/routers_test.go +++ b/server/market/routers_test.go @@ -394,7 +394,8 @@ type TBackend struct { synced uint32 syncedErr error confsMinus2 int64 - feeRateMinus10 int64 + invalidFeeRate bool + unfunded bool } func tNewUTXOBackend() *tUTXOBackend { @@ -427,7 +428,7 @@ func (b *TBackend) utxo(coinID []byte) (*tUTXO, error) { val: v, decoded: str, confs: b.confsMinus2 + 2, - feeRate: uint64(b.feeRateMinus10 + 10), + feeRate: 20, }, b.utxoErr } func (b *TBackend) Contract(coinID, redeemScript []byte) (*asset.Contract, error) { @@ -471,8 +472,8 @@ func (b *TBackend) TxData([]byte) ([]byte, error) { return nil, nil } func (*TBackend) Info() *asset.BackendInfo { return &asset.BackendInfo{} } -func (*TBackend) ValidateFeeRate(*asset.Contract, uint64) bool { - return true +func (b *TBackend) ValidateFeeRate(asset.Coin, uint64) bool { + return !b.invalidFeeRate } type tUTXOBackend struct { @@ -488,6 +489,10 @@ func (b *tUTXOBackend) VerifyUnspentCoin(_ context.Context, coinID []byte) error return err } +func (b *tUTXOBackend) ValidateOrderFunding(swapVal, valSum, inputCount, inputsSize, maxSwaps uint64, nfo *dex.Asset) bool { + return !b.unfunded +} + type tAccountBackend struct { *TBackend bal uint64 @@ -516,6 +521,7 @@ type tUTXO struct { var utxoAuthErr error +func (u *tUTXO) Coin() asset.Coin { return u } func (u *tUTXO) Confirmations(context.Context) (int64, error) { return u.confs, nil } func (u *tUTXO) Auth(pubkeys, sigs [][]byte, msg []byte) error { return utxoAuthErr @@ -526,7 +532,7 @@ func (u *tUTXO) TxID() string { return "" } func (u *tUTXO) String() string { return u.decoded } func (u *tUTXO) SpendsCoin([]byte) (bool, error) { return true, nil } func (u *tUTXO) Value() uint64 { return u.val } -func (u *tUTXO) FeeRate() uint64 { return u.feeRate } +func (u *tUTXO) FeeRate() uint64 { return 0 } type tUser struct { acct account.AccountID @@ -903,16 +909,13 @@ func TestLimit(t *testing.T) { func(tag string, code int) { t.Helper(); ensureErr(tag, sendLimit(), code) }, ) - // Zero confs is ok, because fees are > 90% of last known fee rate. + // Zero-conf fails fee rate validation. oRig.dcr.confsMinus2 = -2 - oRig.dcr.feeRateMinus10 = -1 // fee rate 9 >= 0.9 * 10. - ensureSuccess("valid zero-conf order") - // But any lower fees on the funding coin, and the order will fail. - oRig.dcr.feeRateMinus10-- + oRig.dcr.invalidFeeRate = true ensureErr("low-fee zero-conf order", sendLimit(), msgjson.FundingError) // reset oRig.dcr.confsMinus2 = 0 - oRig.dcr.feeRateMinus10 = 0 + oRig.dcr.invalidFeeRate = false // Rate = 0 limit.Rate = 0 @@ -1128,17 +1131,12 @@ func TestMarketStartProcessStop(t *testing.T) { func(tag string, code int) { t.Helper(); ensureErr(tag, sendMarket(), code) }, ) - // Zero confs is ok, because fees are > 90% of last known fee rate. + // Zero-conf fails fee rate validation. oRig.dcr.confsMinus2 = -2 - oRig.dcr.feeRateMinus10 = -1 // fee rate 9 >= 0.9 * 10. - ensureSuccess("valid zero-conf order") - - // But any lower fees on the funding coin, and the order will fail. - oRig.dcr.feeRateMinus10-- + oRig.dcr.invalidFeeRate = true ensureErr("low-fee zero-conf order", sendMarket(), msgjson.FundingError) - // reset oRig.dcr.confsMinus2 = 0 - oRig.dcr.feeRateMinus10 = 0 + oRig.dcr.invalidFeeRate = false // Redeem to a quote asset. mkt.Quote = assetETH.ID @@ -1441,7 +1439,9 @@ func testPrefixTrade(prefix *msgjson.Prefix, trade *msgjson.Trade, fundingAsset, // Not enough funding trade.Coins = ogUTXOs[:1] + fundingAsset.unfunded = true checkCode("unfunded", msgjson.FundingError) + fundingAsset.unfunded = false trade.Coins = ogUTXOs // Invalid address diff --git a/server/swap/swap.go b/server/swap/swap.go index c4f816a8ce..c5343e152a 100644 --- a/server/swap/swap.go +++ b/server/swap/swap.go @@ -1557,15 +1557,15 @@ func (s *Swapper) processInit(msg *msgjson.Message, params *msgjson.Init, stepIn reqFeeRate = stepInfo.match.FeeRateBase } - if !chain.ValidateFeeRate(contract, reqFeeRate) { + if !chain.ValidateFeeRate(contract.Coin, reqFeeRate) { confs := swapConfs() if confs < 1 { actor.status.endSwapSearch() // allow client retry even before notifying him s.respondError(msg.ID, actor.user, msgjson.ContractError, "low tx fee") return wait.DontTryAgain } - log.Infof("Swap txn %v (%s) with low fee rate %v (%v required), accepted with %d confirmations.", - contract, stepInfo.asset.Symbol, contract.FeeRate(), reqFeeRate, confs) + log.Infof("Swap txn %v (%s) with low fee rate (%v required), accepted with %d confirmations.", + contract, stepInfo.asset.Symbol, reqFeeRate, confs) } if contract.SwapAddress != counterParty.order.Trade().SwapAddress() { actor.status.endSwapSearch() // allow client retry even before notifying him @@ -1669,8 +1669,8 @@ func (s *Swapper) processInit(msg *msgjson.Message, params *msgjson.Init, stepIn actor.status.endSwapSearch() log.Debugf("processInit: valid contract %v (%s) received at %v from user %v (%s) for match %v, "+ - "fee rate = %d, swapStatus %v => %v", contract, stepInfo.asset.Symbol, swapTime, actor.user, - makerTaker(actor.isMaker), matchID, contract.FeeRate(), stepInfo.step, stepInfo.nextStep) + "swapStatus %v => %v", contract, stepInfo.asset.Symbol, swapTime, actor.user, + makerTaker(actor.isMaker), matchID, stepInfo.step, stepInfo.nextStep) // Issue a positive response to the actor. s.authMgr.Sign(params) diff --git a/server/swap/swap_test.go b/server/swap/swap_test.go index 00cc576ec2..59f510bbca 100644 --- a/server/swap/swap_test.go +++ b/server/swap/swap_test.go @@ -466,7 +466,7 @@ func (a *TBackend) setRedemption(redeem asset.Coin, cpSwap asset.Coin, resetErr func (*TBackend) Info() *asset.BackendInfo { return &asset.BackendInfo{} } -func (a *TBackend) ValidateFeeRate(*asset.Contract, uint64) bool { +func (a *TBackend) ValidateFeeRate(asset.Coin, uint64) bool { return !a.invalidFeeRate }