From 93e7e874dbcfc65d423090d8a2070020abeb0aa0 Mon Sep 17 00:00:00 2001 From: buck54321 Date: Tue, 21 May 2024 21:40:40 -0500 Subject: [PATCH] eth: refactor tx montoring, handle tx resubmissions better, general refactoring (#2752) * fix fee and tip caps * redo nonces and pending txs * safer fee rates * use NonceAt along with PendingNonceAt to characterize discrepancies * better waitgroup and db connect/run pattern --- client/asset/btc/btc.go | 8 +- client/asset/dcr/dcr.go | 8 +- client/asset/dcr/externaltx.go | 2 +- client/asset/estimation.go | 3 + client/asset/eth/contractor.go | 10 - client/asset/eth/deploy.go | 36 +- client/asset/eth/eth.go | 2502 ++++++++++------- client/asset/eth/eth_test.go | 2030 +++++-------- client/asset/eth/multirpc.go | 229 +- client/asset/eth/multirpc_live_test.go | 4 + client/asset/eth/multirpc_test_util.go | 145 +- client/asset/eth/nodeclient.go | 26 +- client/asset/eth/nodeclient_harness_test.go | 35 +- client/asset/eth/txdb.go | 681 ++--- client/asset/eth/txdb_test.go | 364 +-- client/asset/interface.go | 75 +- client/asset/polygon/multirpc_live_test.go | 12 + client/asset/polygon/polygon.go | 1 + client/asset/zec/zec.go | 7 +- client/cmd/bisonw-desktop/app_darwin.go | 2 +- client/cmd/simnet-trade-tests/run | 4 +- client/core/core.go | 126 +- client/core/core_test.go | 150 +- client/core/notification.go | 91 +- client/core/simnet_trade.go | 12 +- client/core/trade.go | 66 +- client/core/types.go | 15 +- client/webserver/api.go | 17 + client/webserver/live_test.go | 75 +- client/webserver/locales/en-us.go | 2 + client/webserver/site/src/css/bootstrap.scss | 1 + client/webserver/site/src/css/main.scss | 12 + client/webserver/site/src/css/market.scss | 6 +- client/webserver/site/src/css/mixins.scss | 9 + client/webserver/site/src/css/order.scss | 4 - client/webserver/site/src/css/utilities.scss | 19 +- client/webserver/site/src/css/wallets.scss | 4 +- .../webserver/site/src/html/bodybuilder.tmpl | 133 + client/webserver/site/src/html/markets.tmpl | 10 +- client/webserver/site/src/html/order.tmpl | 4 +- client/webserver/site/src/html/wallets.tmpl | 39 +- client/webserver/site/src/js/app.ts | 302 +- client/webserver/site/src/js/coinexplorers.ts | 2 +- client/webserver/site/src/js/doc.ts | 9 + client/webserver/site/src/js/forms.ts | 11 +- client/webserver/site/src/js/init.ts | 5 - client/webserver/site/src/js/markets.ts | 45 +- client/webserver/site/src/js/registry.ts | 31 +- client/webserver/site/src/js/wallets.ts | 18 +- client/webserver/webserver.go | 2 + client/webserver/webserver_test.go | 2 + dex/networks/eth/params.go | 32 +- dex/testing/dcrdex/harness.sh | 4 +- server/asset/eth/coiner.go | 18 +- server/asset/eth/coiner_test.go | 14 +- server/asset/eth/eth.go | 12 +- server/asset/eth/eth_test.go | 11 +- server/dex/dex.go | 4 +- tatanka/tcp/client/client.go | 2 +- 59 files changed, 4007 insertions(+), 3496 deletions(-) diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index e4247257f4..cf7b4da657 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -1944,7 +1944,9 @@ func (btc *baseWallet) maxOrder(lotSize, feeSuggestion, maxFeeRate uint64) (utxo return utxos, est, err } - return utxos, &asset.SwapEstimate{}, nil + return utxos, &asset.SwapEstimate{ + FeeReservesPerLot: basicFee, + }, nil } // sizeUnit returns the short form of the unit used to measure size, either @@ -2170,6 +2172,8 @@ func (btc *baseWallet) estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate uin bumpedNetRate = uint64(math.Ceil(float64(bumpedNetRate) * feeBump)) } + feeReservesPerLot := bumpedMaxRate * btc.initTxSize + val := lots * lotSize // The orderEnough func does not account for a split transaction at the start, // so it is possible that funding for trySplit would actually choose more @@ -2211,6 +2215,7 @@ func (btc *baseWallet) estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate uin MaxFees: maxFees + splitMaxFees, RealisticBestCase: estLowFees + splitFees, RealisticWorstCase: estHighFees + splitFees, + FeeReservesPerLot: feeReservesPerLot, }, true, reqFunds, nil // requires reqTotal, but locks reqFunds in the split output } } @@ -2234,6 +2239,7 @@ func (btc *baseWallet) estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate uin MaxFees: maxFees, RealisticBestCase: estLowFees, RealisticWorstCase: estHighFees, + FeeReservesPerLot: feeReservesPerLot, }, false, sum, nil } diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index a07ab7fee6..4570817e01 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -1454,7 +1454,9 @@ func (dcr *ExchangeWallet) maxOrder(lotSize, feeSuggestion, maxFeeRate uint64) ( return utxos, est, err } - return nil, &asset.SwapEstimate{}, nil + return nil, &asset.SwapEstimate{ + FeeReservesPerLot: basicFee, + }, nil } // estimateSwap prepares an *asset.SwapEstimate. @@ -1469,6 +1471,8 @@ func (dcr *ExchangeWallet) estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate bumpedNetRate = uint64(math.Ceil(float64(bumpedNetRate) * feeBump)) } + feeReservesPerLot := bumpedMaxRate * dexdcr.InitTxSize + val := lots * lotSize // The orderEnough func does not account for a split transaction at the // start, so it is possible that funding for trySplit would actually choose @@ -1515,6 +1519,7 @@ func (dcr *ExchangeWallet) estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate MaxFees: maxFees + splitMaxFees, RealisticBestCase: estLowFees + splitFees, RealisticWorstCase: estHighFees + splitFees, + FeeReservesPerLot: feeReservesPerLot, }, true, reqFunds, nil // requires reqTotal, but locks reqFunds in the split output } } @@ -1541,6 +1546,7 @@ func (dcr *ExchangeWallet) estimateSwap(lots, lotSize, feeSuggestion, maxFeeRate MaxFees: maxFees, RealisticBestCase: estLowFees, RealisticWorstCase: estHighFees, + FeeReservesPerLot: feeReservesPerLot, }, false, sum, nil } diff --git a/client/asset/dcr/externaltx.go b/client/asset/dcr/externaltx.go index 77033d575b..b954c5ae40 100644 --- a/client/asset/dcr/externaltx.go +++ b/client/asset/dcr/externaltx.go @@ -100,7 +100,7 @@ func (dcr *ExchangeWallet) externalTxOutput(ctx context.Context, op outPoint, pk // Scan block filters to find the tx block if it is yet unknown. if txBlock == nil { - dcr.log.Infof("Output %s:%d NOT yet found; now searching with block filters.", op.txHash, op.vout) + dcr.log.Tracef("Output %s:%d NOT yet found; now searching with block filters.", op.txHash, op.vout) txBlock, err = dcr.scanFiltersForTxBlock(ctx, tx, [][]byte{pkScript}, earliestTxTime) if err != nil { return nil, nil, fmt.Errorf("error checking if tx %s is mined: %w", tx.hash, err) diff --git a/client/asset/estimation.go b/client/asset/estimation.go index 9455853e74..ad53c5e305 100644 --- a/client/asset/estimation.go +++ b/client/asset/estimation.go @@ -20,6 +20,9 @@ type SwapEstimate struct { // RealisticBestCase is an estimation of the fees that might be assessed in // a best-case scenario of 1 tx and 1 output for the entire order. RealisticBestCase uint64 `json:"realisticBestCase"` + // FeeReservesPerLot is the amount that must be reserved per lot to cover + // fees for swap transactions. + FeeReservesPerLot uint64 `json:"feeReservesPerLot"` } // RedeemEstimate is an estimate of the range of fees that might realistically diff --git a/client/asset/eth/contractor.go b/client/asset/eth/contractor.go index 5deba2a498..47e6147cb1 100644 --- a/client/asset/eth/contractor.go +++ b/client/asset/eth/contractor.go @@ -41,7 +41,6 @@ type contractor interface { // case will always be zero. value(context.Context, *types.Transaction) (incoming, outgoing uint64, err error) isRefundable(secretHash [32]byte) (bool, error) - voidUnusedNonce() } // tokenContractor interacts with an ERC20 token contract and a token swap @@ -307,15 +306,6 @@ func (c *contractorV0) outgoingValue(tx *types.Transaction) (swapped uint64) { return } -// voidUnusedNonce allows the next nonce received from a provider to be the same -// as a recent nonce. Use when we fetch a nonce but error before or while -// sending a transaction. -func (c *contractorV0) voidUnusedNonce() { - if mRPC, is := c.cb.(*multiRPCClient); is { - mRPC.voidUnusedNonce() - } -} - // tokenContractorV0 is a contractor that implements the tokenContractor // methods, providing access to the methods of the token's ERC20 contract. type tokenContractorV0 struct { diff --git a/client/asset/eth/deploy.go b/client/asset/eth/deploy.go index 14abb89419..975f155ec2 100644 --- a/client/asset/eth/deploy.go +++ b/client/asset/eth/deploy.go @@ -72,7 +72,7 @@ func (contractDeployer) estimateDeployFunding( } defer os.RemoveAll(walletDir) - cl, feeRate, err := ContractDeployer.nodeAndRate(ctx, chain, walletDir, credentialsPath, chainCfg, log, net) + cl, maxFeeRate, _, err := ContractDeployer.nodeAndRate(ctx, chain, walletDir, credentialsPath, chainCfg, log, net) if err != nil { return err } @@ -102,6 +102,7 @@ func (contractDeployer) estimateDeployFunding( } } + feeRate := dexeth.WeiToGweiCeil(maxFeeRate) if gas == 0 { gas = deploymentGas } @@ -237,7 +238,7 @@ func (contractDeployer) deployContract( } defer os.RemoveAll(walletDir) - cl, feeRate, err := ContractDeployer.nodeAndRate(ctx, chain, walletDir, credentialsPath, chainCfg, log, net) + cl, maxFeeRate, tipRate, err := ContractDeployer.nodeAndRate(ctx, chain, walletDir, credentialsPath, chainCfg, log, net) if err != nil { return err } @@ -262,7 +263,8 @@ func (contractDeployer) deployContract( return fmt.Errorf("EstimateGas error: %v", err) } - log.Infof("Estimated fees: %s", ui.ConventionalString(feeRate*gas)) + feeRate := dexeth.WeiToGweiCeil(maxFeeRate) + log.Infof("Estimated fees: %s gwei / gas", ui.ConventionalString(feeRate*gas)) gas *= 5 / 4 // Add 20% buffer feesWithBuffer := feeRate * gas @@ -275,7 +277,7 @@ func (contractDeployer) deployContract( ui.ConventionalString(shortage), cl.address()) } - txOpts, err := cl.txOpts(ctx, 0, gas, dexeth.GweiToWei(feeRate), nil) + txOpts, err := cl.txOpts(ctx, 0, gas, dexeth.GweiToWei(feeRate), tipRate, nil) if err != nil { return err } @@ -309,13 +311,13 @@ func (contractDeployer) ReturnETH( } defer os.RemoveAll(walletDir) - cl, feeRate, err := ContractDeployer.nodeAndRate(ctx, chain, walletDir, credentialsPath, chainCfg, log, net) + cl, maxFeeRate, tipRate, err := ContractDeployer.nodeAndRate(ctx, chain, walletDir, credentialsPath, chainCfg, log, net) if err != nil { return err } defer cl.shutdown() - return GetGas.returnFunds(ctx, cl, dexeth.GweiToWei(feeRate), returnAddr, nil, ui, log, net) + return GetGas.returnFunds(ctx, cl, maxFeeRate, tipRate, returnAddr, nil, ui, log, net) } func (contractDeployer) nodeAndRate( @@ -327,11 +329,11 @@ func (contractDeployer) nodeAndRate( chainCfg *params.ChainConfig, log dex.Logger, net dex.Network, -) (*multiRPCClient, uint64, error) { +) (*multiRPCClient, *big.Int, *big.Int, error) { seed, providers, err := getFileCredentials(chain, credentialsPath, net) if err != nil { - return nil, 0, err + return nil, nil, nil, err } pw := []byte("abc") @@ -346,30 +348,30 @@ func (contractDeployer) nodeAndRate( Net: net, Logger: log, }, nil /* we don't need the full api, skipConnect = true allows for nil compat */, true); err != nil { - return nil, 0, fmt.Errorf("error creating wallet: %w", err) + return nil, nil, nil, fmt.Errorf("error creating wallet: %w", err) } - cl, err := newMultiRPCClient(walletDir, providers, log, chainCfg, net) + cl, err := newMultiRPCClient(walletDir, providers, log, chainCfg, 3, net) if err != nil { - return nil, 0, fmt.Errorf("error creating rpc client: %w", err) + return nil, nil, nil, fmt.Errorf("error creating rpc client: %w", err) } if err := cl.unlock(string(pw)); err != nil { - return nil, 0, fmt.Errorf("error unlocking rpc client: %w", err) + return nil, nil, nil, fmt.Errorf("error unlocking rpc client: %w", err) } if err = cl.connect(ctx); err != nil { - return nil, 0, fmt.Errorf("error connecting: %w", err) + return nil, nil, nil, fmt.Errorf("error connecting: %w", err) } - base, tip, err := cl.currentFees(ctx) + baseRate, tipRate, err := cl.currentFees(ctx) if err != nil { cl.shutdown() - return nil, 0, fmt.Errorf("Error estimating fee rate: %v", err) + return nil, nil, nil, fmt.Errorf("Error estimating fee rate: %v", err) } - feeRate := dexeth.WeiToGwei(new(big.Int).Add(tip, new(big.Int).Mul(base, big.NewInt(2)))) - return cl, feeRate, nil + maxFeeRate := new(big.Int).Add(tipRate, new(big.Int).Mul(baseRate, big.NewInt(2))) + return cl, maxFeeRate, tipRate, nil } // DeployMultiBalance deployes a contract with a function for reading all diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index b0a75989d7..1c5cb9ffc4 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -88,8 +88,7 @@ const ( // confCheckTimeout is the amount of time allowed to check for // confirmations. Testing on testnet has shown spikes up to 2.5 // seconds. This value may need to be adjusted in the future. - confCheckTimeout = 4 * time.Second - dynamicSwapOrRedemptionFeesConfs = 2 + confCheckTimeout = 4 * time.Second // coinIDTakerFoundMakerRedemption is a prefix to identify one of CoinID formats, // see DecodeCoinID func for details. @@ -103,15 +102,31 @@ const ( // TODO: Find a way to ask the host about their config set max fee and // gas values. maxTxFeeGwei = 1_000_000_000 + + LiveEstimateFailedError = dex.ErrorKind("live gas estimate failed") + + // txAgeOut is the amount of time after which we forego any tx + // synchronization efforts for unconfirmed pending txs. + txAgeOut = 2 * time.Hour + // stateUpdateTick is the minimum amount of time between checks for + // new block and updating of pending txs, counter-party redemptions and + // approval txs. + // HTTP RPC clients meter tip header calls to minimum 10 seconds. + // WebSockets will stay up-to-date, so can expect new blocks often. + // A shorter blockTicker would be too much for e.g. Polygon where the block + // time is 2 or 3 seconds. We'd be doing a ton of calls for pending tx + // updates. + stateUpdateTick = time.Second * 5 + // maxUnindexedTxs is the number of pending txs we will allow to be + // unverified on-chain before we halt broadcasting of new txs. + maxUnindexedTxs = 10 + peerCountTicker = 5 * time.Second // no rpc calls here ) var ( usdcTokenID, _ = dex.BipSymbolID("usdc.eth") usdtTokenID, _ = dex.BipSymbolID("usdt.eth") - // blockTicker is the delay between calls to check for new blocks. - blockTicker = time.Second - peerCountTicker = 5 * time.Second - walletOpts = []*asset.ConfigOption{ + walletOpts = []*asset.ConfigOption{ { Key: "gasfeelimit", DisplayName: "Gas Fee Limit", @@ -212,6 +227,28 @@ func perTxGasLimit(gasFeeLimit uint64) uint64 { return blockGasLimit } +// safeConfs returns the confirmations for a given tip and block number, +// returning 0 if the block number is zero or if the tip is lower than the +// block number. +func safeConfs(tip, blockNum uint64) uint64 { + if blockNum == 0 { + return 0 + } + if tip < blockNum { + return 0 + } + return tip - blockNum + 1 +} + +// safeConfsBig is safeConfs but with a *big.Int blockNum. A nil blockNum will +// result in a zero. +func safeConfsBig(tip uint64, blockNum *big.Int) uint64 { + if blockNum == nil { + return 0 + } + return safeConfs(tip, blockNum.Uint64()) +} + // WalletConfig are wallet-level configuration settings. type WalletConfig struct { GasFeeLimit uint64 `ini:"gasfeelimit"` @@ -335,17 +372,19 @@ type ethFetcher interface { lock() error locked() bool shutdown() - sendSignedTransaction(ctx context.Context, tx *types.Transaction) error - sendTransaction(ctx context.Context, txOpts *bind.TransactOpts, to common.Address, data []byte) (*types.Transaction, error) + sendSignedTransaction(ctx context.Context, tx *types.Transaction, filts ...acceptabilityFilter) error + sendTransaction(ctx context.Context, txOpts *bind.TransactOpts, to common.Address, data []byte, filts ...acceptabilityFilter) (*types.Transaction, error) signData(data []byte) (sig, pubKey []byte, err error) syncProgress(context.Context) (progress *ethereum.SyncProgress, tipTime uint64, err error) transactionConfirmations(context.Context, common.Hash) (uint32, error) getTransaction(context.Context, common.Hash) (*types.Transaction, int64, error) - txOpts(ctx context.Context, val, maxGas uint64, maxFeeRate, nonce *big.Int) (*bind.TransactOpts, error) + txOpts(ctx context.Context, val, maxGas uint64, maxFeeRate, tipCap, nonce *big.Int) (*bind.TransactOpts, error) currentFees(ctx context.Context) (baseFees, tipCap *big.Int, err error) unlock(pw string) error getConfirmedNonce(context.Context) (uint64, error) - transactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, *types.Transaction, error) + transactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) + transactionAndReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, *types.Transaction, error) + nonce(ctx context.Context) (confirmed, next *big.Int, err error) } // txPoolFetcher can be implemented by node types that support fetching of @@ -394,6 +433,8 @@ type baseWallet struct { dir string walletType string + finalizeConfs uint64 + multiBalanceAddress common.Address multiBalanceContract *multibal.MultiBalanceV0 @@ -414,21 +455,24 @@ type baseWallet struct { walletsMtx sync.RWMutex wallets map[uint32]*assetWallet - monitoredTxsMtx sync.RWMutex - monitoredTxs map[common.Hash]*monitoredTx - - pendingTxsMtx sync.RWMutex - pendingTxs map[uint64]*extendedWalletTx // nonce -> tx - - // nonceSendMtx should be locked for the node.txOpts -> tx send sequence - // for all txs, to ensure nonce correctness. - nonceSendMtx sync.Mutex + nonceMtx sync.RWMutex + pendingTxs []*extendedWalletTx + confirmedNonceAt *big.Int + pendingNonceAt *big.Int + recoveryRequestSent bool balances struct { sync.Mutex m map[uint32]*cachedBalance } + currentFees struct { + sync.Mutex + blockNum uint64 + baseRate *big.Int + tipRate *big.Int + } + txDB txDB } @@ -472,7 +516,7 @@ type assetWallet struct { evmify func(uint64) *big.Int atomize func(*big.Int) uint64 - // pendingTxCheckBal is protected by the pendingTxsMtx. We use this field + // pendingTxCheckBal is protected by the nonceMtx. We use this field // as a secondary check to see if we need to request confirmations for // pending txs, since tips are cached for up to 10 seconds. We check the // status of pending txs if the tip has changed OR if the balance has @@ -662,6 +706,7 @@ func newWallet(assetCFG *asset.WalletConfig, logger dex.Logger, net dex.Network) CompatData: &comp, VersionedGases: dexeth.VersionedGases, Tokens: dexeth.Tokens, + FinalizeConfs: 3, Logger: logger, BaseChainContracts: contracts, MultiBalAddress: dexeth.MultiBalanceAddresses[net], @@ -679,10 +724,11 @@ type EVMWalletConfig struct { CompatData *CompatibilityData VersionedGases map[uint32]*dexeth.Gases Tokens map[uint32]*dexeth.Token + FinalizeConfs uint64 Logger dex.Logger BaseChainContracts map[uint32]common.Address DefaultProviders []string - MultiBalAddress common.Address + MultiBalAddress common.Address // If empty, separate calls for N tokens + 1 WalletInfo asset.WalletInfo Net dex.Network } @@ -713,21 +759,19 @@ func NewEVMWallet(cfg *EVMWalletConfig) (w *ETHWallet, err error) { gasFeeLimit = defaultGasFeeLimit } eth := &baseWallet{ - net: cfg.Net, - baseChainID: cfg.BaseChainID, - chainCfg: cfg.ChainCfg, - chainID: chainID, - compat: cfg.CompatData, - tokens: cfg.Tokens, - log: cfg.Logger, - dir: cfg.AssetCfg.DataDir, - walletType: cfg.AssetCfg.Type, - settings: cfg.AssetCfg.Settings, - gasFeeLimitV: gasFeeLimit, - wallets: make(map[uint32]*assetWallet), - monitoredTxs: make(map[common.Hash]*monitoredTx), - pendingTxs: make(map[uint64]*extendedWalletTx), - // Can be empty + net: cfg.Net, + baseChainID: cfg.BaseChainID, + chainCfg: cfg.ChainCfg, + chainID: chainID, + compat: cfg.CompatData, + tokens: cfg.Tokens, + log: cfg.Logger, + dir: cfg.AssetCfg.DataDir, + walletType: cfg.AssetCfg.Type, + finalizeConfs: cfg.FinalizeConfs, + settings: cfg.AssetCfg.Settings, + gasFeeLimitV: gasFeeLimit, + wallets: make(map[uint32]*assetWallet), multiBalanceAddress: cfg.MultiBalAddress, } @@ -807,10 +851,12 @@ func (w *ETHWallet) Connect(ctx context.Context) (_ *sync.WaitGroup, err error) if providerDef, found := w.settings[providersKey]; found && len(providerDef) > 0 { endpoints = strings.Split(providerDef, " ") } - cl, err = newMultiRPCClient(w.dir, endpoints, w.log.SubLogger("RPC"), w.chainCfg, w.net) + rpcCl, err := newMultiRPCClient(w.dir, endpoints, w.log.SubLogger("RPC"), w.chainCfg, w.finalizeConfs, w.net) if err != nil { return nil, err } + rpcCl.finalizeConfs = w.finalizeConfs + cl = rpcCl default: return nil, fmt.Errorf("unknown wallet type %q", w.walletType) } @@ -843,37 +889,66 @@ func (w *ETHWallet) Connect(ctx context.Context) (_ *sync.WaitGroup, err error) } } - w.txDB = newBadgerTxDB(filepath.Join(w.dir, "txhistorydb"), w.log.SubLogger("TXDB")) - wg, err := w.txDB.connect(ctx) - if err != nil { - return nil, err - } - - w.monitoredTxs, err = w.txDB.getMonitoredTxs() + w.txDB, err = newBadgerTxDB(filepath.Join(w.dir, "txhistorydb"), w.log.SubLogger("TXDB")) if err != nil { return nil, err } - w.pendingTxs, err = w.txDB.getPendingTxs() + pendingTxs, err := w.txDB.getPendingTxs() if err != nil { return nil, err } + sort.Slice(pendingTxs, func(i, j int) bool { + return pendingTxs[i].Nonce.Cmp(pendingTxs[j].Nonce) < 0 + }) // Initialize the best block. bestHdr, err := w.node.bestHeader(ctx) if err != nil { return nil, fmt.Errorf("error getting best block hash: %w", err) } + confirmedNonce, nextNonce, err := w.node.nonce(ctx) + if err != nil { + return nil, fmt.Errorf("error establishing nonce: %w", err) + } w.tipMtx.Lock() w.currentTip = bestHdr w.tipMtx.Unlock() + + w.nonceMtx.Lock() + w.pendingTxs = pendingTxs + w.confirmedNonceAt = confirmedNonce + w.pendingNonceAt = nextNonce + w.nonceMtx.Unlock() + + if w.log.Level() <= dex.LevelDebug { + var highestPendingNonce, lowestPendingNonce uint64 + for _, pendingTx := range pendingTxs { + n := pendingTx.Nonce.Uint64() + if n > highestPendingNonce { + highestPendingNonce = n + } + if lowestPendingNonce == 0 || n < lowestPendingNonce { + lowestPendingNonce = n + } + } + w.log.Debugf("Synced with header %s and confirmed nonce %s, pending nonce %s, %d pending txs from nonce %d to nonce %d", + bestHdr.Number, confirmedNonce, nextNonce, len(pendingTxs), highestPendingNonce, lowestPendingNonce) + } + height := w.currentTip.Number // NOTE: We should be using the tipAtConnect to set Progress in SyncStatus. atomic.StoreInt64(&w.tipAtConnect, height.Int64()) w.log.Infof("Connected to eth (%s), at height %d", w.walletType, height) - w.connected.Store(true) + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + w.txDB.run(ctx) + }() wg.Add(1) go func() { @@ -888,12 +963,13 @@ func (w *ETHWallet) Connect(ctx context.Context) (_ *sync.WaitGroup, err error) w.monitorPeers(ctx) }() + w.connected.Store(true) go func() { <-ctx.Done() w.connected.Store(false) }() - return wg, nil + return &wg, nil } // Connect waits for context cancellation and closes the WaitGroup. Satisfies @@ -924,6 +1000,13 @@ func (w *TokenWallet) Connect(ctx context.Context) (*sync.WaitGroup, error) { return &wg, nil } +// tipHeight gets the current best header's tip height. +func (w *baseWallet) tipHeight() uint64 { + w.tipMtx.RLock() + defer w.tipMtx.RUnlock() + return w.currentTip.Number.Uint64() +} + // Reconfigure attempts to reconfigure the wallet. func (w *ETHWallet) Reconfigure(ctx context.Context, cfg *asset.WalletConfig, currentAddress string) (restart bool, err error) { walletCfg, err := parseWalletConfig(cfg.Settings) @@ -996,6 +1079,114 @@ func (eth *baseWallet) gasFeeLimit() uint64 { return atomic.LoadUint64(ð.gasFeeLimitV) } +// transactionGenerator is an action that uses a nonce and returns a tx, it's +// type specifier, and its value. +type transactionGenerator func(nonce *big.Int) (*types.Transaction, asset.TransactionType, uint64, error) + +// withNonce is called with a function intended to generate a new transaction +// using the next available nonce. If the function returns a non-nil tx, the +// nonce will be treated as used, and an extendedWalletTransaction will be +// generated, stored, and queued for monitoring. +func (w *assetWallet) withNonce(ctx context.Context, f transactionGenerator) (err error) { + w.nonceMtx.Lock() + defer w.nonceMtx.Unlock() + if err = nonceIsSane(w.pendingTxs, w.pendingNonceAt); err != nil { + return err + } + nonce := func() *big.Int { + n := new(big.Int).Set(w.confirmedNonceAt) + for _, pendingTx := range w.pendingTxs { + if pendingTx.Nonce.Cmp(n) < 0 { + continue + } + if pendingTx.Nonce.Cmp(n) == 0 { + n.Add(n, big.NewInt(1)) + } else { + break + } + } + return n + } + + n := nonce() + w.log.Trace("Nonce chosen for tx generator =", n) + + // Make a first attempt with our best-known nonce. + tx, txType, amt, err := f(n) + if err != nil && strings.Contains(err.Error(), "nonce too low") { + w.log.Warnf("Too-low nonce detected. Attempting recovery") + confirmedNonceAt, pendingNonceAt, err := w.node.nonce(ctx) + if err != nil { + return fmt.Errorf("error during too-low nonce recovery: %v", err) + } + w.confirmedNonceAt = confirmedNonceAt + w.pendingNonceAt = pendingNonceAt + if newNonce := nonce(); newNonce != n { + n = newNonce + // Try again. + tx, txType, amt, err = f(n) + if err != nil { + return err + } + w.log.Info("Nonce recovered and transaction broadcast") + } else { + return fmt.Errorf("best RPC nonce %d not better than our best nonce %d", newNonce, n) + } + } + + if tx != nil { + et := w.extendedTx(tx, txType, amt) + w.pendingTxs = append(w.pendingTxs, et) + if n.Cmp(w.pendingNonceAt) >= 0 { + w.pendingNonceAt.Add(n, big.NewInt(1)) + } + w.emitTransactionNote(et.WalletTransaction, true) + w.log.Tracef("Transaction %s generated for nonce %s", et.ID, n) + } + return err +} + +// nonceIsSane performs sanity checks on pending txs. +func nonceIsSane(pendingTxs []*extendedWalletTx, pendingNonceAt *big.Int) error { + if len(pendingTxs) == 0 && pendingNonceAt == nil { + return errors.New("no pending txs and no best nonce") + } + var lastNonce uint64 + var numNotIndexed, confirmedTip int + for i, pendingTx := range pendingTxs { + if !pendingTx.savedToDB { + return errors.New("tx database problem detected") + } + nonce := pendingTx.Nonce.Uint64() + if nonce < lastNonce { + return fmt.Errorf("pending txs not sorted") + } + if pendingTx.Confirmed || pendingTx.BlockNumber != 0 { + if confirmedTip != i { + return fmt.Errorf("confirmed tx sequence error. pending tx %s is confirmed but older txs were not", pendingTx.ID) + + } + confirmedTip = i + 1 + continue + } + lastNonce = nonce + age := pendingTx.age() + // Only allow a handful of txs that we haven't been seen on-chain yet. + if age > stateUpdateTick*10 { + numNotIndexed++ + } + if age >= txAgeOut { + // If any tx is unindexed and aged out, wait for user to fix it. + return fmt.Errorf("tx %s is aged out. waiting for user to take action", pendingTx.ID) + } + + } + if numNotIndexed >= maxUnindexedTxs { + return fmt.Errorf("%d unindexed txs has reached the limit of %d", numNotIndexed, maxUnindexedTxs) + } + return nil +} + // tokenWalletConfig is the configuration options for token wallets. type tokenWalletConfig struct { // LimitAllowance disabled for now. @@ -1251,27 +1442,30 @@ func (w *assetWallet) maxOrder(lotSize uint64, maxFeeRate uint64, ver uint32, if err != nil { return nil, err } - if balance.Available == 0 { - return &asset.SwapEstimate{}, nil + // Get the refund gas. + if g := w.gases(ver); g == nil { + return nil, fmt.Errorf("no gas table") } g, err := w.initGasEstimate(1, ver, redeemVer, redeemAssetID) - if err != nil { + liveEstimateFailed := errors.Is(err, LiveEstimateFailedError) + if err != nil && !liveEstimateFailed { return nil, fmt.Errorf("gasEstimate error: %w", err) } refundCost := g.Refund * maxFeeRate oneFee := g.oneGas * maxFeeRate + feeReservesPerLot := oneFee + refundCost var lots uint64 if feeWallet == nil { - lots = balance.Available / (lotSize + oneFee + refundCost) + lots = balance.Available / (lotSize + feeReservesPerLot) } else { // token lots = balance.Available / lotSize parentBal, err := feeWallet.Balance() if err != nil { return nil, fmt.Errorf("error getting base chain balance: %w", err) } - feeLots := parentBal.Available / (oneFee + refundCost) + feeLots := parentBal.Available / feeReservesPerLot if feeLots < lots { w.log.Infof("MaxOrder reducing lots because of low fee reserves: %d -> %d", lots, feeLots) lots = feeLots @@ -1279,9 +1473,11 @@ func (w *assetWallet) maxOrder(lotSize uint64, maxFeeRate uint64, ver uint32, } if lots < 1 { - return &asset.SwapEstimate{}, nil + return &asset.SwapEstimate{ + FeeReservesPerLot: feeReservesPerLot, + }, nil } - return w.estimateSwap(lots, lotSize, maxFeeRate, ver, redeemVer, redeemAssetID) + return w.estimateSwap(lots, lotSize, maxFeeRate, ver, feeReservesPerLot) } // PreSwap gets order estimates based on the available funds and the wallet @@ -1308,7 +1504,7 @@ func (w *assetWallet) preSwap(req *asset.PreSwapForm, feeWallet *assetWallet) (* } est, err := w.estimateSwap(req.Lots, req.LotSize, req.MaxFeeRate, - req.Version, req.RedeemVersion, req.RedeemAssetID) + req.Version, maxEst.FeeReservesPerLot) if err != nil { return nil, err } @@ -1334,17 +1530,21 @@ func (w *assetWallet) SingleLotSwapRefundFees(version uint32, feeSuggestion uint // estimateSwap prepares an *asset.SwapEstimate. The estimate does not include // funds that might be locked for refunds. -func (w *assetWallet) estimateSwap(lots, lotSize uint64, maxFeeRate uint64, ver uint32, - redeemVer, redeemAssetID uint32) (*asset.SwapEstimate, error) { +func (w *assetWallet) estimateSwap( + lots, lotSize uint64, maxFeeRate uint64, ver uint32, feeReservesPerLot uint64, +) (*asset.SwapEstimate, error) { + if lots == 0 { - return &asset.SwapEstimate{}, nil + return &asset.SwapEstimate{ + FeeReservesPerLot: feeReservesPerLot, + }, nil } rateNow, err := w.currentFeeRate(w.ctx) if err != nil { return nil, err } - rate, err := dexeth.WeiToGweiUint64(rateNow) + rate, err := dexeth.WeiToGweiSafe(rateNow) if err != nil { return nil, fmt.Errorf("invalid current fee rate: %v", err) } @@ -1368,6 +1568,7 @@ func (w *assetWallet) estimateSwap(lots, lotSize uint64, maxFeeRate uint64, ver MaxFees: maxFees, RealisticWorstCase: oneGasMax * rate, RealisticBestCase: oneSwap * rate, // not even batch, just perfect match + FeeReservesPerLot: feeReservesPerLot, }, nil } @@ -1444,6 +1645,11 @@ func (eth *TokenWallet) createTokenFundingCoin(amount, fees uint64) *tokenFundin // FundOrder locks value for use in an order. func (w *ETHWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes, uint64, error) { + if ord.MaxFeeRate < dexeth.MinGasTipCap { + return nil, nil, 0, fmt.Errorf("%v: server's max fee rate is lower than our min gas tip cap. %d < %d", + dex.BipIDSymbol(w.assetID), ord.MaxFeeRate, dexeth.MinGasTipCap) + } + if w.gasFeeLimit() < ord.MaxFeeRate { return nil, nil, 0, fmt.Errorf( "%v: server's max fee rate %v higher than configured fee rate limit %v", @@ -1477,6 +1683,11 @@ func (w *ETHWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes, uint6 // FundOrder locks value for use in an order. func (w *TokenWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes, uint64, error) { + if ord.MaxFeeRate < dexeth.MinGasTipCap { + return nil, nil, 0, fmt.Errorf("%v: server's max fee rate is lower than our min gas tip cap. %d < %d", + dex.BipIDSymbol(w.assetID), ord.MaxFeeRate, dexeth.MinGasTipCap) + } + if w.gasFeeLimit() < ord.MaxFeeRate { return nil, nil, 0, fmt.Errorf( "%v: server's max fee rate %v higher than configured fee rate limit %v", @@ -1642,17 +1853,19 @@ func (w *assetWallet) initGasEstimate(n int, initVer, redeemVer, redeemAssetID u } est.Swap, est.nSwap, err = w.swapGas(n, initVer) - if err != nil { - return nil, fmt.Errorf("error calculating swap gas: %w", err) + if err != nil && !errors.Is(err, LiveEstimateFailedError) { + return nil, err } - + // Could be LiveEstimateFailedError. Still populate static estimates if we + // couldn't get live. Error is still propagated. est.oneGas = est.Swap est.nGas = est.nSwap if redeemW := w.wallet(redeemAssetID); redeemW != nil { - est.Redeem, est.nRedeem, err = redeemW.redeemGas(n, redeemVer) + var er error + est.Redeem, est.nRedeem, er = redeemW.redeemGas(n, redeemVer) if err != nil { - return nil, fmt.Errorf("error calculating fee-family redeem gas: %w", err) + return nil, fmt.Errorf("error calculating fee-family redeem gas: %w", er) } est.oneGas += est.Redeem est.nGas += est.nRedeem @@ -1699,7 +1912,8 @@ func (w *assetWallet) swapGas(n int, ver uint32) (oneSwap, nSwap uint64, err err // use the live estimate with a warning. gasEst, err := w.estimateInitGas(w.ctx, nMax, ver) if err != nil { - return 0, 0, err + err = errors.Join(err, LiveEstimateFailedError) + return // Or we could go with what we know? But this estimate error could be a // hint that the transaction would fail, and we don't have a way to // recover from that. Play it safe and allow caller to retry assuming @@ -1972,14 +2186,18 @@ func (w *ETHWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin, uint6 } } - tx, err := w.initiate(w.ctx, w.assetID, swaps.Contracts, swaps.FeeRate, gasLimit, swaps.Version) + maxFeeRate := dexeth.GweiToWei(swaps.FeeRate) + _, tipRate, err := w.currentNetworkFees(w.ctx) + if err != nil { + return fail("Swap: failed to get network tip cap: %w", err) + } + + tx, err := w.initiate(w.ctx, w.assetID, swaps.Contracts, gasLimit, maxFeeRate, tipRate, swaps.Version) if err != nil { return fail("Swap: initiate error: %w", err) } txHash := tx.Hash() - w.addToTxHistory(tx.Nonce(), swapVal, swaps.FeeRate*gasLimit, w.assetID, txHash, asset.Swap, nil) - receipts := make([]asset.Receipt, 0, n) for _, swap := range swaps.Contracts { var secretHash [dexeth.SecretHashSize]byte @@ -2057,7 +2275,13 @@ func (w *TokenWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin, uin } // See (*ETHWallet).Swap comments for a third option. } - tx, err := w.initiate(w.ctx, w.assetID, swaps.Contracts, swaps.FeeRate, gasLimit, swaps.Version) + maxFeeRate := dexeth.GweiToWei(swaps.FeeRate) + _, tipRate, err := w.currentNetworkFees(w.ctx) + if err != nil { + return fail("Swap: failed to get network tip cap: %w", err) + } + + tx, err := w.initiate(w.ctx, w.assetID, swaps.Contracts, gasLimit, maxFeeRate, tipRate, swaps.Version) if err != nil { return fail("Swap: initiate error: %w", err) } @@ -2069,8 +2293,6 @@ func (w *TokenWallet) Swap(swaps *asset.Swaps) ([]asset.Receipt, asset.Coin, uin contractAddr := w.netToken.SwapContracts[swaps.Version].Address.String() txHash := tx.Hash() - w.addToTxHistory(tx.Nonce(), swapVal, swaps.FeeRate*gasLimit, w.assetID, txHash, asset.Swap, nil) - receipts := make([]asset.Receipt, 0, n) for _, swap := range swaps.Contracts { var secretHash [dexeth.SecretHashSize]byte @@ -2168,6 +2390,9 @@ func (w *assetWallet) Redeem(form *asset.RedeemForm, feeWallet *assetWallet, non if err != nil { return nil, nil, 0, fmt.Errorf("error finding swap state: %w", err) } + if swapData.State != dexeth.SSInitiated { + return nil, nil, 0, asset.ErrSwapNotInitiated + } redeemedValue += w.atomize(swapData.Value) } @@ -2216,11 +2441,11 @@ func (w *assetWallet) Redeem(form *asset.RedeemForm, feeWallet *assetWallet, non // If the base fee is higher than the FeeSuggestion we attempt to increase // the gasFeeCap to 2*baseFee. If we don't have enough funds, we use the // funds we have available. - baseFee, _, err := w.node.currentFees(w.ctx) + baseFee, tipRate, err := w.currentNetworkFees(w.ctx) if err != nil { return fail(fmt.Errorf("Error getting net fee state: %w", err)) } - baseFeeGwei := dexeth.WeiToGwei(baseFee) + baseFeeGwei := dexeth.WeiToGweiCeil(baseFee) if baseFeeGwei > form.FeeSuggestion { additionalFundsNeeded := (2 * baseFeeGwei * gasLimit) - originalFundsReserved if bal.Available > additionalFundsNeeded { @@ -2231,26 +2456,18 @@ func (w *assetWallet) Redeem(form *asset.RedeemForm, feeWallet *assetWallet, non w.log.Warnf("base fee %d > server max fee rate %d. using %d as gas fee cap for redemption", baseFeeGwei, form.FeeSuggestion, gasFeeCap) } - hdr, err := w.node.bestHeader(w.ctx) - if err != nil { - return fail(fmt.Errorf("error fetching best header: %w", err)) - } - - tx, err := w.redeem(w.ctx, w.assetID, form.Redemptions, gasFeeCap, gasLimit, contractVer, nonceOverride) + tx, err := w.redeem(w.ctx, form.Redemptions, gasFeeCap, tipRate, gasLimit, contractVer) if err != nil { return fail(fmt.Errorf("Redeem: redeem error: %w", err)) } txHash := tx.Hash() - w.addToTxHistory(tx.Nonce(), redeemedValue, gasFeeCap*gasLimit, w.assetID, txHash, asset.Redeem, nil) txs := make([]dex.Bytes, len(form.Redemptions)) for i := range txs { txs[i] = txHash[:] } - w.monitorTx(tx, hdr.Number.Uint64()) - outputCoin := &coin{ id: txHash, value: redeemedValue, @@ -2301,31 +2518,22 @@ func (w *assetWallet) tokenAllowance(version uint32) (allowance *big.Int, err er // approveToken approves the token swap contract to spend tokens on behalf of // account handled by the wallet. -func (w *assetWallet) approveToken(amount *big.Int, maxFeeRate, gasLimit uint64, contractVer uint32) (tx *types.Transaction, err error) { - w.nonceSendMtx.Lock() - defer w.nonceSendMtx.Unlock() - txOpts, err := w.node.txOpts(w.ctx, 0, gasLimit, dexeth.GweiToWei(maxFeeRate), nil) - if err != nil { - return nil, fmt.Errorf("addSignerToOpts error: %w", err) - } - - return tx, w.withTokenContractor(w.assetID, contractVer, func(c tokenContractor) error { - tx, err = c.approve(txOpts, amount) +func (w *assetWallet) approveToken(ctx context.Context, amount *big.Int, gasLimit uint64, maxFeeRate, tipRate *big.Int, contractVer uint32) (tx *types.Transaction, err error) { + return tx, w.withNonce(ctx, func(nonce *big.Int) (*types.Transaction, asset.TransactionType, uint64, error) { + txOpts, err := w.node.txOpts(w.ctx, 0, gasLimit, maxFeeRate, tipRate, nonce) if err != nil { - c.voidUnusedNonce() - return err - } - w.log.Infof("Approval sent for %s at token address %s, nonce = %s, txID = %s", - dex.BipIDSymbol(w.assetID), c.tokenAddress(), txOpts.Nonce, tx.Hash().Hex()) - - txHash := tx.Hash() - txType := asset.ApproveToken - if amount.Cmp(big.NewInt(0)) == 0 { - txType = asset.RevokeTokenApproval + return nil, 0, 0, fmt.Errorf("addSignerToOpts error: %w", err) } - w.addToTxHistory(tx.Nonce(), 0, maxFeeRate*gasLimit, w.assetID, txHash, txType, nil) - return nil + return tx, asset.ApproveToken, w.atomize(amount), w.withTokenContractor(w.assetID, contractVer, func(c tokenContractor) error { + tx, err = c.approve(txOpts, amount) + if err != nil { + return err + } + w.log.Infof("Approval sent for %s at token address %s, nonce = %s, txID = %s", + dex.BipIDSymbol(w.assetID), c.tokenAddress(), txOpts.Nonce, tx.Hash().Hex()) + return nil + }) }) } @@ -2383,11 +2591,11 @@ func (w *TokenWallet) ApproveToken(assetVer uint32, onConfirm func()) (string, e return "", fmt.Errorf("approval is already pending") } - feeRate, err := w.recommendedMaxFeeRate(w.ctx) + maxFeeRate, tipRate, err := w.recommendedMaxFeeRate(w.ctx) if err != nil { return "", fmt.Errorf("error calculating approval fee rate: %w", err) } - feeRateGwei := dexeth.WeiToGwei(feeRate) + feeRateGwei := dexeth.WeiToGweiCeil(maxFeeRate) approvalGas, err := w.approvalGas(unlimitedAllowance, assetVer) if err != nil { return "", fmt.Errorf("error calculating approval gas: %w", err) @@ -2402,7 +2610,7 @@ func (w *TokenWallet) ApproveToken(assetVer uint32, onConfirm func()) (string, e approvalGas*feeRateGwei, ethBal.Available) } - tx, err := w.approveToken(unlimitedAllowance, feeRateGwei, approvalGas, assetVer) + tx, err := w.approveToken(w.ctx, unlimitedAllowance, approvalGas, maxFeeRate, tipRate, assetVer) if err != nil { return "", fmt.Errorf("error approving token: %w", err) } @@ -2433,11 +2641,11 @@ func (w *TokenWallet) UnapproveToken(assetVer uint32, onConfirm func()) (string, return "", fmt.Errorf("approval is pending") } - feeRate, err := w.recommendedMaxFeeRate(w.ctx) + maxFeeRate, tipRate, err := w.recommendedMaxFeeRate(w.ctx) if err != nil { return "", fmt.Errorf("error calculating approval fee rate: %w", err) } - feeRateGwei := dexeth.WeiToGwei(feeRate) + feeRateGwei := dexeth.WeiToGweiCeil(maxFeeRate) approvalGas, err := w.approvalGas(big.NewInt(0), assetVer) if err != nil { return "", fmt.Errorf("error calculating approval gas: %w", err) @@ -2452,7 +2660,7 @@ func (w *TokenWallet) UnapproveToken(assetVer uint32, onConfirm func()) (string, approvalGas*feeRateGwei, ethBal.Available) } - tx, err := w.approveToken(big.NewInt(0), feeRateGwei, approvalGas, assetVer) + tx, err := w.approveToken(w.ctx, big.NewInt(0), approvalGas, maxFeeRate, tipRate, assetVer) if err != nil { return "", fmt.Errorf("error unapproving token: %w", err) } @@ -2482,13 +2690,11 @@ func (w *TokenWallet) ApprovalFee(assetVer uint32, approve bool) (uint64, error) return 0, fmt.Errorf("error calculating approval gas: %w", err) } - feeRate, err := w.recommendedMaxFeeRate(w.ctx) + feeRateGwei, err := w.recommendedMaxFeeRateGwei(w.ctx) if err != nil { return 0, fmt.Errorf("error calculating approval fee rate: %w", err) } - feeRateGwei := dexeth.WeiToGwei(feeRate) - return approvalGas * feeRateGwei, nil } @@ -2683,17 +2889,6 @@ func (w *assetWallet) AuditContract(coinID, contract, serializedTx dex.Bytes, re value: w.atomize(initiation.Value), } - // The counter-party should have broadcasted the contract tx but rebroadcast - // just in case to ensure that the tx is sent to the network. Do not block - // because this is not required and does not affect the audit result. - if rebroadcast { - go func() { - if err := w.node.sendSignedTransaction(w.ctx, tx); err != nil { - w.log.Debugf("Rebroadcasting counterparty contract %v (THIS MAY BE NORMAL): %v", txHash, err) - } - }() - } - return &asset.AuditInfo{ Recipient: initiation.Participant.Hex(), Expiration: initiation.LockTime, @@ -2899,15 +3094,18 @@ func (w *assetWallet) Refund(_, contract dex.Bytes, feeRate uint64) (dex.Bytes, return nil, fmt.Errorf("Refund: swap with secret hash %x is not refundable", secretHash) } - tx, fees, err := w.refund(secretHash, feeRate, version) + maxFeeRate := dexeth.GweiToWei(feeRate) + _, tipRate, err := w.currentNetworkFees(w.ctx) + if err != nil { + return nil, fmt.Errorf("Refund: failed to get network tip cap: %w", err) + } + + tx, err := w.refund(secretHash, w.atomize(swap.Value), maxFeeRate, tipRate, version) if err != nil { return nil, fmt.Errorf("Refund: failed to call refund: %w", err) } txHash := tx.Hash() - refundValue := dexeth.WeiToGwei(swap.Value) - w.addToTxHistory(tx.Nonce(), refundValue, fees, w.assetID, txHash, asset.Refund, nil) - return txHash[:], nil } @@ -2998,13 +3196,14 @@ func isValidSend(addr string, value uint64, subtract bool) error { // canSend ensures that the wallet has enough to cover send value and returns // the fee rate and max fee required for the send tx. If isPreEstimate is false, // wallet balance must be enough to cover total spend. -func (w *ETHWallet) canSend(value uint64, verifyBalance, isPreEstimate bool) (uint64, *big.Int, error) { - maxFeeRate, err := w.recommendedMaxFeeRate(w.ctx) +func (w *ETHWallet) canSend(value uint64, verifyBalance, isPreEstimate bool) (maxFee uint64, maxFeeRate, tipRate *big.Int, err error) { + maxFeeRate, tipRate, err = w.recommendedMaxFeeRate(w.ctx) if err != nil { - return 0, nil, fmt.Errorf("error getting max fee rate: %w", err) + return 0, nil, nil, fmt.Errorf("error getting max fee rate: %w", err) } + maxFeeRateGwei := dexeth.WeiToGwei(maxFeeRate) - maxFee := defaultSendGasLimit * dexeth.WeiToGwei(maxFeeRate) + maxFee = defaultSendGasLimit * maxFeeRateGwei if isPreEstimate { maxFee = maxFee * 12 / 10 // 20% buffer @@ -3013,34 +3212,35 @@ func (w *ETHWallet) canSend(value uint64, verifyBalance, isPreEstimate bool) (ui if verifyBalance { bal, err := w.Balance() if err != nil { - return 0, nil, err + return 0, nil, nil, err } avail := bal.Available if avail < value { - return 0, nil, fmt.Errorf("not enough funds to send: have %d gwei need %d gwei", avail, value) + return 0, nil, nil, fmt.Errorf("not enough funds to send: have %d gwei need %d gwei", avail, value) } if avail < value+maxFee { - return 0, nil, fmt.Errorf("available funds %d gwei cannot cover value being sent: need %d gwei + %d gwei max fee", avail, value, maxFee) + return 0, nil, nil, fmt.Errorf("available funds %d gwei cannot cover value being sent: need %d gwei + %d gwei max fee", avail, value, maxFee) } } - return maxFee, maxFeeRate, nil + return } // canSend ensures that the wallet has enough to cover send value and returns // the fee rate and max fee required for the send tx. -func (w *TokenWallet) canSend(value uint64, verifyBalance, isPreEstimate bool) (uint64, *big.Int, error) { - maxFeeRate, err := w.recommendedMaxFeeRate(w.ctx) +func (w *TokenWallet) canSend(value uint64, verifyBalance, isPreEstimate bool) (maxFee uint64, maxFeeRate, tipRate *big.Int, err error) { + maxFeeRate, tipRate, err = w.recommendedMaxFeeRate(w.ctx) if err != nil { - return 0, nil, fmt.Errorf("error getting max fee rate: %w", err) + return 0, nil, nil, fmt.Errorf("error getting max fee rate: %w", err) } + maxFeeRateGwei := dexeth.WeiToGweiCeil(maxFeeRate) g := w.gases(contractVersionNewest) if g == nil { - return 0, nil, fmt.Errorf("gas table not found") + return 0, nil, nil, fmt.Errorf("gas table not found") } - maxFee := dexeth.WeiToGwei(maxFeeRate) * g.Transfer + maxFee = maxFeeRateGwei * g.Transfer if isPreEstimate { maxFee = maxFee * 12 / 10 // 20% buffer @@ -3049,24 +3249,24 @@ func (w *TokenWallet) canSend(value uint64, verifyBalance, isPreEstimate bool) ( if verifyBalance { bal, err := w.Balance() if err != nil { - return 0, nil, err + return 0, nil, nil, err } avail := bal.Available if avail < value { - return 0, nil, fmt.Errorf("not enough tokens: have %[1]d %[3]s need %[2]d %[3]s", avail, value, w.ui.AtomicUnit) + return 0, nil, nil, fmt.Errorf("not enough tokens: have %[1]d %[3]s need %[2]d %[3]s", avail, value, w.ui.AtomicUnit) } ethBal, err := w.parent.Balance() if err != nil { - return 0, nil, fmt.Errorf("error getting base chain balance: %w", err) + return 0, nil, nil, fmt.Errorf("error getting base chain balance: %w", err) } if ethBal.Available < maxFee { - return 0, nil, fmt.Errorf("insufficient balance to cover token transfer fees. %d < %d", + return 0, nil, nil, fmt.Errorf("insufficient balance to cover token transfer fees. %d < %d", ethBal.Available, maxFee) } } - return maxFee, maxFeeRate, nil + return } // EstimateSendTxFee returns a tx fee estimate for a send tx. The provided fee @@ -3077,7 +3277,7 @@ func (w *ETHWallet) EstimateSendTxFee(addr string, value, _ uint64, subtract boo if err := isValidSend(addr, value, subtract); err != nil && addr != "" { // fee estimate for a send tx. return 0, false, err } - maxFee, _, err := w.canSend(value, addr != "", true) + maxFee, _, _, err := w.canSend(value, addr != "", true) if err != nil { return 0, false, err } @@ -3092,12 +3292,11 @@ func (w *TokenWallet) EstimateSendTxFee(addr string, value, _ uint64, subtract b if err := isValidSend(addr, value, subtract); err != nil && addr != "" { // fee estimate for a send tx. return 0, false, err } - maxFee, _, err := w.canSend(value, addr != "", true) + maxFee, _, _, err := w.canSend(value, addr != "", true) if err != nil { return 0, false, err } return maxFee, w.ValidateAddress(addr), nil - } // RestorationInfo returns information about how to restore the wallet in @@ -3135,28 +3334,21 @@ func (w *assetWallet) SwapConfirmations(ctx context.Context, coinID dex.Bytes, c ctx, cancel := context.WithTimeout(ctx, confCheckTimeout) defer cancel() - hdr, err := w.node.bestHeader(ctx) - if err != nil { - return 0, false, fmt.Errorf("error fetching best header: %w", err) - } + tip := w.tipHeight() - swapData, err := w.swap(w.ctx, secretHash, contractVer) + swapData, err := w.swap(ctx, secretHash, contractVer) if err != nil { return 0, false, fmt.Errorf("error finding swap state: %w", err) } if swapData.State == dexeth.SSNone { + // Check if we know about the tx ourselves. If it's not in pendingTxs + // or the database, assume it's lost. return 0, false, asset.ErrSwapNotInitiated } spent = swapData.State >= dexeth.SSRedeemed - tip := hdr.Number.Uint64() - // TODO: If tip < swapData.BlockHeight (which has been observed), what does - // that mean? Are we using the wrong provider in a multi-provider setup? How - // do we resolve provider relevance? - if tip >= swapData.BlockHeight { - confs = uint32(hdr.Number.Uint64() - swapData.BlockHeight + 1) - } + confs = uint32(safeConfs(tip, swapData.BlockHeight)) return } @@ -3167,7 +3359,7 @@ func (w *ETHWallet) Send(addr string, value, _ uint64) (asset.Coin, error) { return nil, err } - maxFee, maxFeeRate, err := w.canSend(value, true, false) + _ /* maxFee */, maxFeeRate, tipRate, err := w.canSend(value, true, false) if err != nil { return nil, err } @@ -3176,18 +3368,12 @@ func (w *ETHWallet) Send(addr string, value, _ uint64) (asset.Coin, error) { // value -= maxFee // } - tx, err := w.sendToAddr(common.HexToAddress(addr), value, maxFeeRate) + tx, err := w.sendToAddr(common.HexToAddress(addr), value, maxFeeRate, tipRate) if err != nil { return nil, err } txHash := tx.Hash() - txType := asset.Send - if strings.EqualFold(addr, w.addr.String()) { - txType = asset.SelfSend - } - - w.addToTxHistory(tx.Nonce(), value, maxFee, w.assetID, txHash, txType, &addr) return &coin{id: txHash, value: value}, nil } @@ -3200,20 +3386,17 @@ func (w *TokenWallet) Send(addr string, value, _ uint64) (asset.Coin, error) { return nil, err } - maxFee, maxFeeRate, err := w.canSend(value, true, false) + _ /* maxFee */, maxFeeRate, tipRate, err := w.canSend(value, true, false) if err != nil { return nil, err } - tx, err := w.sendToAddr(common.HexToAddress(addr), value, maxFeeRate) + tx, err := w.sendToAddr(common.HexToAddress(addr), value, maxFeeRate, tipRate) if err != nil { return nil, err } - txHash := tx.Hash() - w.addToTxHistory(tx.Nonce(), value, maxFee, w.assetID, txHash, asset.Send, &addr) - - return &coin{id: txHash, value: value}, nil + return &coin{id: tx.Hash(), value: value}, nil } // ValidateSecret checks that the secret satisfies the contract. @@ -3291,6 +3474,37 @@ func (eth *assetWallet) DynamicRedemptionFeesPaid(ctx context.Context, coinID, c return eth.swapOrRedemptionFeesPaid(ctx, coinID, contractData, false) } +// extractSecretHashes extracts the secret hashes from the reedeem or swap tx +// data. The returned hashes are sorted lexicographically. +func extractSecretHashes(isInit bool, txData []byte, contractVer uint32) (secretHashes [][]byte, _ error) { + defer func() { + sort.Slice(secretHashes, func(i, j int) bool { return bytes.Compare(secretHashes[i], secretHashes[j]) < 0 }) + }() + if isInit { + inits, err := dexeth.ParseInitiateData(txData, contractVer) + if err != nil { + return nil, fmt.Errorf("invalid initiate data: %v", err) + } + secretHashes = make([][]byte, 0, len(inits)) + for k := range inits { + copyK := k + secretHashes = append(secretHashes, copyK[:]) + } + return secretHashes, nil + } + // redeem + redeems, err := dexeth.ParseRedeemData(txData, contractVer) + if err != nil { + return nil, fmt.Errorf("invalid redeem data: %v", err) + } + secretHashes = make([][]byte, 0, len(redeems)) + for k := range redeems { + copyK := k + secretHashes = append(secretHashes, copyK[:]) + } + return secretHashes, nil +} + // swapOrRedemptionFeesPaid returns exactly how much gwei was used to send an // initiation or redemption transaction. It also returns the secret hashes // included with this init or redeem. Secret hashes are sorted so returns are @@ -3300,62 +3514,58 @@ func (eth *assetWallet) DynamicRedemptionFeesPaid(ctx context.Context, coinID, c // asset.ErrNotEnoughConfirms for txn with too few confirmations. Will also // error if the secret hash in the contractData is not found in the transaction // secret hashes. -func (eth *baseWallet) swapOrRedemptionFeesPaid(ctx context.Context, coinID, contractData dex.Bytes, - isInit bool) (fee uint64, secretHashes [][]byte, err error) { - contractVer, secretHash, err := dexeth.DecodeContractData(contractData) - if err != nil { - return 0, nil, err - } +func (w *baseWallet) swapOrRedemptionFeesPaid( + ctx context.Context, + coinID dex.Bytes, + contractData dex.Bytes, + isInit bool, +) (fee uint64, secretHashes [][]byte, err error) { var txHash common.Hash copy(txHash[:], coinID) - receipt, tx, err := eth.node.transactionReceipt(ctx, txHash) + + contractVer, secretHash, err := dexeth.DecodeContractData(contractData) if err != nil { return 0, nil, err } - hdr, err := eth.node.headerByHash(ctx, receipt.BlockHash) - if err != nil { - return 0, nil, fmt.Errorf("error getting header %s: %w", receipt.BlockHash, err) - } - if hdr == nil { - return 0, nil, fmt.Errorf("header for hash %v not found", receipt.BlockHash) + tip := w.tipHeight() + + var blockNum uint64 + var tx *types.Transaction + if w.withLocalTxRead(txHash, func(wt *extendedWalletTx) { + blockNum = wt.BlockNumber + fee = wt.Fees + tx, err = wt.tx() + if err != nil { + w.log.Errorf("Error decoding wallet transaction %s: %v", txHash, err) + } + }) && err == nil { + if confs := safeConfs(tip, blockNum); confs < w.finalizeConfs { + return 0, nil, asset.ErrNotEnoughConfirms + } + secretHashes, err = extractSecretHashes(isInit, tx.Data(), contractVer) + return } - bestHdr, err := eth.node.bestHeader(ctx) + // We don't have information locally. This really shouldn't happen anymore, + // but let's look on-chain anyway. + + receipt, tx, err := w.node.transactionAndReceipt(ctx, txHash) if err != nil { return 0, nil, err } - confs := bestHdr.Number.Int64() - hdr.Number.Int64() + 1 - if confs < dynamicSwapOrRedemptionFeesConfs { + if confs := safeConfsBig(tip, receipt.BlockNumber); confs < w.finalizeConfs { return 0, nil, asset.ErrNotEnoughConfirms } - effectiveGasPrice := new(big.Int).Add(hdr.BaseFee, tx.EffectiveGasTipValue(hdr.BaseFee)) - bigFees := new(big.Int).Mul(effectiveGasPrice, big.NewInt(int64(receipt.GasUsed))) - if isInit { - inits, err := dexeth.ParseInitiateData(tx.Data(), contractVer) - if err != nil { - return 0, nil, fmt.Errorf("invalid initiate data: %v", err) - } - secretHashes = make([][]byte, 0, len(inits)) - for k := range inits { - copyK := k - secretHashes = append(secretHashes, copyK[:]) - } - } else { - redeems, err := dexeth.ParseRedeemData(tx.Data(), contractVer) - if err != nil { - return 0, nil, fmt.Errorf("invalid redeem data: %v", err) - } - secretHashes = make([][]byte, 0, len(redeems)) - for k := range redeems { - copyK := k - secretHashes = append(secretHashes, copyK[:]) - } + bigFees := new(big.Int).Mul(receipt.EffectiveGasPrice, big.NewInt(int64(receipt.GasUsed))) + fee = dexeth.WeiToGweiCeil(bigFees) + secretHashes, err = extractSecretHashes(isInit, tx.Data(), contractVer) + if err != nil { + return 0, nil, err } - sort.Slice(secretHashes, func(i, j int) bool { return bytes.Compare(secretHashes[i], secretHashes[j]) < 0 }) var found bool for i := range secretHashes { if bytes.Equal(secretHash[:], secretHashes[i]) { @@ -3366,58 +3576,85 @@ func (eth *baseWallet) swapOrRedemptionFeesPaid(ctx context.Context, coinID, con if !found { return 0, nil, fmt.Errorf("secret hash %x not found in transaction", secretHash) } - return dexeth.WeiToGwei(bigFees), secretHashes, nil + return } // RegFeeConfirmations gets the number of confirmations for the specified // transaction. -func (eth *baseWallet) RegFeeConfirmations(ctx context.Context, coinID dex.Bytes) (confs uint32, err error) { +func (w *baseWallet) RegFeeConfirmations(ctx context.Context, coinID dex.Bytes) (confs uint32, err error) { var txHash common.Hash copy(txHash[:], coinID) - return eth.node.transactionConfirmations(ctx, txHash) + if found, txData := w.localTxStatus(txHash); found { + if tip := w.tipHeight(); txData.blockNum != 0 && txData.blockNum < tip { + return uint32(tip - txData.blockNum + 1), nil + } + return 0, nil + } + + return w.node.transactionConfirmations(ctx, txHash) +} + +// currentNetworkFees give the current base fee rate (from the best header), +// and recommended tip cap. +func (w *baseWallet) currentNetworkFees(ctx context.Context) (baseRate, tipRate *big.Int, err error) { + tip := w.tipHeight() + c := &w.currentFees + c.Lock() + defer c.Unlock() + if tip > 0 && c.blockNum == tip { + return c.baseRate, c.tipRate, nil + } + c.baseRate, c.tipRate, err = w.node.currentFees(ctx) + if err != nil { + return nil, nil, fmt.Errorf("Error getting net fee state: %v", err) + } + c.blockNum = tip + return c.baseRate, c.tipRate, nil } // currentFeeRate gives the current rate of transactions being mined. Only // use this to provide informative realistic estimates of actual fee *use*. For -// transaction planning, use recommendedMaxFeeRate. -func (eth *baseWallet) currentFeeRate(ctx context.Context) (*big.Int, error) { - base, tip, err := eth.node.currentFees(ctx) +// transaction planning, use recommendedMaxFeeRateGwei. +func (w *baseWallet) currentFeeRate(ctx context.Context) (_ *big.Int, err error) { + b, t, err := w.currentNetworkFees(ctx) if err != nil { - return nil, fmt.Errorf("Error getting net fee state: %v", err) + return nil, err } - - return new(big.Int).Add(tip, base), nil + return new(big.Int).Add(b, t), nil } // recommendedMaxFeeRate finds a recommended max fee rate using the somewhat // standard baseRate * 2 + tip formula. -func (eth *baseWallet) recommendedMaxFeeRate(ctx context.Context) (*big.Int, error) { - base, tip, err := eth.node.currentFees(ctx) +func (eth *baseWallet) recommendedMaxFeeRate(ctx context.Context) (maxFeeRate, tipRate *big.Int, err error) { + base, tip, err := eth.currentNetworkFees(ctx) if err != nil { - return nil, fmt.Errorf("Error getting net fee state: %v", err) + return nil, nil, fmt.Errorf("Error getting net fee state: %v", err) } return new(big.Int).Add( tip, new(big.Int).Mul(base, big.NewInt(2)), - ), nil + ), tip, nil } -// FeeRate satisfies asset.FeeRater. -func (eth *baseWallet) FeeRate() uint64 { - feeRate, err := eth.recommendedMaxFeeRate(eth.ctx) +// recommendedMaxFeeRateGwei gets the recommended max fee rate and converts it +// to gwei. +func (w *baseWallet) recommendedMaxFeeRateGwei(ctx context.Context) (uint64, error) { + feeRate, _, err := w.recommendedMaxFeeRate(ctx) if err != nil { - eth.log.Errorf("Error getting net fee state: %v", err) - return 0 + return 0, err } + return dexeth.WeiToGweiSafe(feeRate) +} - feeRateGwei, err := dexeth.WeiToGweiUint64(feeRate) +// FeeRate satisfies asset.FeeRater. +func (eth *baseWallet) FeeRate() uint64 { + r, err := eth.recommendedMaxFeeRateGwei(eth.ctx) if err != nil { - eth.log.Errorf("Failed to convert wei to gwei: %v", err) + eth.log.Errorf("Error getting max fee recommendation: %v", err) return 0 } - - return feeRateGwei + return r } func (eth *ETHWallet) checkPeers() { @@ -3449,7 +3686,7 @@ func (eth *ETHWallet) monitorPeers(ctx context.Context) { // when the block changes. New blocks are also scanned for potential contract // redeems. func (eth *ETHWallet) monitorBlocks(ctx context.Context) { - ticker := time.NewTicker(blockTicker) + ticker := time.NewTicker(stateUpdateTick) defer ticker.Stop() for { select { @@ -3486,540 +3723,193 @@ func (eth *ETHWallet) checkForNewBlocks(ctx context.Context) { } eth.tipMtx.Lock() - defer eth.tipMtx.Unlock() - prevTip := eth.currentTip eth.currentTip = bestHdr + eth.tipMtx.Unlock() eth.log.Debugf("tip change: %s (%s) => %s (%s)", prevTip.Number, currentTipHash, bestHdr.Number, bestHash) - connectedWallets := eth.connectedWallets() - - go func() { - for _, w := range connectedWallets { - w.emit.TipChange(bestHdr.Number.Uint64()) - } - }() - - go func() { - for _, w := range connectedWallets { - w.checkFindRedemptions() - } - }() + eth.checkPendingTxs() + for _, w := range eth.connectedWallets() { + w.checkFindRedemptions() + w.checkPendingApprovals() + w.emit.TipChange(bestHdr.Number.Uint64()) + } +} - go func() { - for _, w := range connectedWallets { - w.checkPendingApprovals() - } - }() +// ConfirmRedemption checks the status of a redemption. If a transaction has +// been fee-replaced, the caller is notified of this by having a different +// coinID in the returned asset.ConfirmRedemptionStatus as was used to call the +// function. Fee argument is ignored since it is calculated from the best +// header. +func (w *ETHWallet) ConfirmRedemption(coinID dex.Bytes, redemption *asset.Redemption, _ uint64) (*asset.ConfirmRedemptionStatus, error) { + return w.confirmRedemption(coinID, redemption) +} - go eth.checkPendingTxs() +// ConfirmRedemption checks the status of a redemption. If a transaction has +// been fee-replaced, the caller is notified of this by having a different +// coinID in the returned asset.ConfirmRedemptionStatus as was used to call the +// function. Fee argument is ignored since it is calculated from the best +// header. +func (w *TokenWallet) ConfirmRedemption(coinID dex.Bytes, redemption *asset.Redemption, _ uint64) (*asset.ConfirmRedemptionStatus, error) { + return w.confirmRedemption(coinID, redemption) } -// getLatestMonitoredTx looks up a txHash in the monitoredTxs map. If the -// transaction has been replaced, the latest in the chain of transactions -// is returned. -// -// !!WARNING!!: The latest transaction is returned with its mutex locked. -// It must be unlocked by the caller. This is done in order to prevent -// another transaction starting the redemption process before -// a potential replacement. -func (w *assetWallet) getLatestMonitoredTx(txHash common.Hash) (*monitoredTx, error) { - maxLoops := 100 // avoid an infinite loop in case of a cycle - for i := 0; i < maxLoops; i++ { - w.monitoredTxsMtx.RLock() - tx, found := w.monitoredTxs[txHash] - w.monitoredTxsMtx.RUnlock() - if !found { - return nil, fmt.Errorf("%s not found among monitored transactions", txHash) - } - tx.mtx.Lock() - if tx.replacementTx == nil { - return tx, nil - } - txHash = *tx.replacementTx - tx.mtx.Unlock() +func confStatus(confs, req uint64, txHash common.Hash) *asset.ConfirmRedemptionStatus { + return &asset.ConfirmRedemptionStatus{ + Confs: confs, + Req: req, + CoinID: txHash[:], } - return nil, fmt.Errorf("there is a cycle in the monitored transactions") } -// recordReplacementTx updates a monitoredTx with a replacement transaction. -// This change is also stored in the db. -// -// originalTx's mtx must be held when calling this function. -func (w *assetWallet) recordReplacementTx(originalTx *monitoredTx, replacementHash common.Hash) error { - originalTx.replacementTx = &replacementHash - originalHash := originalTx.tx.Hash() - if err := w.txDB.storeMonitoredTx(originalHash, originalTx); err != nil { - return fmt.Errorf("error recording replacement tx: %v", err) +// confirmRedemption checks the confirmation status of a redemption transaction. +func (w *assetWallet) confirmRedemption(coinID dex.Bytes, redemption *asset.Redemption) (*asset.ConfirmRedemptionStatus, error) { + if len(coinID) != common.HashLength { + return nil, fmt.Errorf("expected coin ID to be a transaction hash, but it has a length of %d", + len(coinID)) } + var txHash common.Hash + copy(txHash[:], coinID) - w.monitoredTxsMtx.Lock() - defer w.monitoredTxsMtx.Unlock() - - replacementTx, found := w.monitoredTxs[replacementHash] - if !found { - w.log.Errorf("could not find replacement monitored tx %s", replacementHash) - } - replacementTx.mtx.Lock() - defer replacementTx.mtx.Unlock() - replacementTx.replacedTx = &originalHash - if err := w.txDB.storeMonitoredTx(replacementHash, replacementTx); err != nil { - return fmt.Errorf("error recording replaced tx: %v", err) + contractVer, secretHash, err := dexeth.DecodeContractData(redemption.Spends.Contract) + if err != nil { + return nil, fmt.Errorf("failed to decode contract data: %w", err) } - return nil -} + tip := w.tipHeight() -// txsToDelete retraces the doubly linked list to find the all the -// ancestors of a monitoredTx. -func (w *assetWallet) txsToDelete(tx *monitoredTx) []common.Hash { - txsToDelete := []common.Hash{tx.tx.Hash()} + // If we have local information, use that. + if found, s := w.localTxStatus(txHash); found { + if s.assumedLost || len(s.nonceReplacement) > 0 { + if !s.feeReplacement { + // Tell core to update it's coin ID. + txHash = common.HexToHash(s.nonceReplacement) + } else { + return nil, asset.ErrTxLost + } + } - maxLoops := 100 // avoid an infinite loop in case of a cycle - for i := 0; i < maxLoops; i++ { - if tx.replacedTx == nil { - return txsToDelete + var confirmStatus *asset.ConfirmRedemptionStatus + if s.blockNum != 0 && s.blockNum <= tip { + confirmStatus = confStatus(tip-s.blockNum+1, w.finalizeConfs, txHash) + } else { + // Apparently not mined yet. + confirmStatus = confStatus(0, w.finalizeConfs, txHash) } - txsToDelete = append(txsToDelete, *tx.replacedTx) - var found bool - tx, found = w.monitoredTxs[*tx.replacedTx] - if !found { - w.log.Errorf("failed to find replaced tx: %v", *tx.replacedTx) - return txsToDelete + if s.receipt != nil && s.receipt.Status != types.ReceiptStatusSuccessful && confirmStatus.Confs >= w.finalizeConfs { + return nil, asset.ErrTxRejected } + return confirmStatus, nil } - w.log.Errorf("found cycle while clearing monitored txs") - return txsToDelete -} - -// clearMonitoredTx removes a monitored tx and all of its ancestors from the -// monitoredTxs map and the underlying database. -func (w *assetWallet) clearMonitoredTx(tx *monitoredTx) { - if tx == nil { - return + // We know nothing of the tx locally. This shouldn't really happen, but + // we'll look for it on-chain anyway. + r, err := w.node.transactionReceipt(w.ctx, txHash) + if err != nil { + if errors.Is(err, asset.CoinNotFoundError) { + // We don't know it ourselves and we can't see it on-chain. This + // used to be a CoinNotFoundError, but since we have local tx + // storage, we'll assume it's lost to space and time now. + return nil, asset.ErrTxLost + } + return nil, err } - w.monitoredTxsMtx.Lock() - defer w.monitoredTxsMtx.Unlock() + // We could potentially grab the tx, check the from address, and store it + // to our db right here, but I suspect that this case would be exceedingly + // rare anyway. - txsToDelete := w.txsToDelete(tx) - err := w.txDB.removeMonitoredTxs(txsToDelete) - if err != nil { - w.log.Errorf("Error removing monitored txs: %v", err) - // Don't remove these txs from the memory map, so that the removal - // from the db can be attempted again. - return - } - - // Delete from the database immediately, but keep in the memory map a bit - // longer to allow time for other matches that used the same transaction - // to complete. If they are cleared too early there will just be an error - // message stating that the monitored tx is missing, but no other issue. - go func() { - timer := time.NewTimer(3 * time.Minute) - select { - case <-w.ctx.Done(): - return - case <-timer.C: - } - w.monitoredTxsMtx.Lock() - defer w.monitoredTxsMtx.Unlock() - for _, hash := range txsToDelete { - delete(w.monitoredTxs, hash) + confs := safeConfsBig(tip, r.BlockNumber) + if confs >= w.finalizeConfs { + if r.Status == types.ReceiptStatusSuccessful { + return confStatus(w.finalizeConfs, w.finalizeConfs, txHash), nil } - }() -} - -// monitorTx adds a transaction to the map of monitored transactions and also -// stores it in the db. -func (w *assetWallet) monitorTx(tx *types.Transaction, blockSubmitted uint64) { - w.monitoredTxsMtx.Lock() - defer w.monitoredTxsMtx.Unlock() - - monitoredTx := &monitoredTx{ - tx: tx, - blockSubmitted: blockSubmitted, - } - h := tx.Hash() - if err := w.txDB.storeMonitoredTx(h, monitoredTx); err != nil { - w.log.Errorf("error storing monitored tx: %v", err) - } - - w.monitoredTxs[tx.Hash()] = monitoredTx -} - -// resubmitRedemption resubmits a redemption transaction. Only the redemptions -// in the batch that are still redeemable are included in the new transaction. -// nonceOverride is set to a non-nil value when a specific nonce is required -// (when a transaction has not been mined due to a low fee). -func (w *assetWallet) resubmitRedemption(tx *types.Transaction, contractVersion uint32, nonceOverride *uint64, feeWallet *assetWallet, monitoredTx *monitoredTx) (*common.Hash, error) { - parsedRedemptions, err := dexeth.ParseRedeemData(tx.Data(), contractVersion) - if err != nil { - return nil, fmt.Errorf("failed to parse redeem data: %w", err) - } - - redemptions := make([]*asset.Redemption, 0, len(parsedRedemptions)) - - // Whether or not a swap can be redeemed is checked in Redeem, but here - // we filter out unredeemable swaps in case one of the swaps in the tx - // was refunded/redeemed but the others were not. - for _, r := range parsedRedemptions { - redeemable, err := w.isRedeemable(r.SecretHash, r.Secret, contractVersion) + // We weren't able to redeem. Perhaps fees were too low, but we'll + // check the status in the contract for a couple of other conditions. + swap, err := w.swap(w.ctx, secretHash, contractVer) if err != nil { - return nil, err - } else if !redeemable { - w.log.Warnf("swap %x is not redeemable. not resubmitting", r.SecretHash) - continue + return nil, fmt.Errorf("error pulling swap data from contract: %v", err) } - - contractData := dexeth.EncodeContractData(contractVersion, r.SecretHash) - redemptions = append(redemptions, &asset.Redemption{ - Spends: &asset.AuditInfo{ - Contract: contractData, - SecretHash: r.SecretHash[:], - }, - Secret: r.Secret[:], - }) - } - if len(redemptions) == 0 { - return nil, fmt.Errorf("the swaps in tx %s are no longer redeemable. not resubmitting.", tx.Hash()) - } - - txs, _, _, err := w.Redeem(&asset.RedeemForm{ - Redemptions: redemptions, - FeeSuggestion: w.gasFeeLimit(), - }, feeWallet, nonceOverride) - if err != nil { - return nil, fmt.Errorf("failed to resubmit redemption: %v", err) - } - if len(txs) == 0 { - return nil, fmt.Errorf("Redeem did not return a tx id") - } - - var replacementHash common.Hash - copy(replacementHash[:], txs[0]) - - w.log.Infof("Redemption transaction %s was broadcast to replace transaction %s (original tx: %s)", replacementHash, monitoredTx.tx.Hash(), tx.Hash()) - - if monitoredTx != nil { - err = w.recordReplacementTx(monitoredTx, replacementHash) - if err != nil { - w.log.Errorf("failed to record %s as a replacement for %s", replacementHash, tx.Hash()) + switch swap.State { + case dexeth.SSRedeemed: + w.log.Infof("Redemption in tx %s was apparently redeemed by another tx. OK.", txHash) + return confStatus(w.finalizeConfs, w.finalizeConfs, txHash), nil + case dexeth.SSRefunded: + return nil, asset.ErrSwapRefunded } - } - - return &replacementHash, nil -} - -// swapIsRedeemed checks if a swap is in the redeemed state. ErrSwapRefunded -// is returned if the swap has been refunded. -func (w *assetWallet) swapIsRedeemed(secretHash common.Hash, contractVersion uint32) (bool, error) { - swap, err := w.swap(w.ctx, secretHash, contractVersion) - if err != nil { - return false, err - } - - switch swap.State { - case dexeth.SSRedeemed: - return true, nil - case dexeth.SSRefunded: - return false, asset.ErrSwapRefunded - default: - return false, nil - } -} - -// ConfirmRedemption checks the status of a redemption. If it is determined -// that a transaction will not be mined, this function will submit a new -// transaction to replace the old one. The caller is notified of this by having -// a different coinID in the returned asset.ConfirmRedemptionStatus as was used -// to call the function. Fee argument is ignored since it is calculated from -// the best header. -func (w *ETHWallet) ConfirmRedemption(coinID dex.Bytes, redemption *asset.Redemption, _ uint64) (*asset.ConfirmRedemptionStatus, error) { - return w.confirmRedemption(coinID, redemption, nil) -} - -// ConfirmRedemption checks the status of a redemption. If it is determined -// that a transaction will not be mined, this function will submit a new -// transaction to replace the old one. The caller is notified of this by having -// a different coinID in the returned asset.ConfirmRedemptionStatus as was used -// to call the function. Fee argument is ignored since it is calculated from -// the best header. -func (w *TokenWallet) ConfirmRedemption(coinID dex.Bytes, redemption *asset.Redemption, _ uint64) (*asset.ConfirmRedemptionStatus, error) { - return w.confirmRedemption(coinID, redemption, w.parent) -} -const ( - txConfsNeededToConfirm = 3 - blocksToWaitBeforeCoinNotFound = 10 - blocksToWaitBeforeCheckingIfReplaced = 10 -) - -func confStatus(confs uint64, txHash common.Hash) *asset.ConfirmRedemptionStatus { - return &asset.ConfirmRedemptionStatus{ - Confs: confs, - Req: txConfsNeededToConfirm, - CoinID: txHash[:], + err = fmt.Errorf("tx %s failed to redeem %s funds", txHash, dex.BipIDSymbol(w.assetID)) + return nil, errors.Join(err, asset.ErrTxRejected) } + return confStatus(confs, w.finalizeConfs, txHash), nil } -// checkUnconfirmedRedemption is called when a transaction has not yet been -// confirmed. It does the following: -// -- checks if the swap has already been redeemed by another tx -// -- resubmits the tx with a new nonce if it has been nonce replaced -// -- resubmits the tx with the same nonce but higher fee if the fee is too low -// -- otherwise, resubmits the same tx to ensure propagation -func (w *assetWallet) checkUnconfirmedRedemption(secretHash common.Hash, contractVer uint32, txHash common.Hash, tx *types.Transaction, feeWallet *assetWallet, monitoredTx *monitoredTx) (*asset.ConfirmRedemptionStatus, error) { - // Check if the swap has been redeemed by another transaction we are unaware of. - swapIsRedeemed, err := w.swapIsRedeemed(secretHash, contractVer) - if err != nil { - return nil, err - } - if swapIsRedeemed { - w.clearMonitoredTx(monitoredTx) - return confStatus(txConfsNeededToConfirm, txHash), nil - } - - // Resubmit the transaction if another transaction with the same nonce has - // already been confirmed. - confirmedNonce, err := w.node.getConfirmedNonce(w.ctx) - if err != nil { - return nil, err - } - if confirmedNonce > tx.Nonce() { - replacementTxHash, err := w.resubmitRedemption(tx, contractVer, nil, feeWallet, monitoredTx) - if err != nil { - return nil, err +// withLocalTxRead runs a function that reads a pending or DB tx under +// read-lock. Certain DB transactions in undeterminable states will not be +// used. +func (w *baseWallet) withLocalTxRead(txHash common.Hash, f func(*extendedWalletTx)) bool { + withPendingTxRead := func(txHash common.Hash, f func(*extendedWalletTx)) bool { + w.nonceMtx.RLock() + defer w.nonceMtx.RUnlock() + for _, pendingTx := range w.pendingTxs { + if pendingTx.txHash == txHash { + f(pendingTx) + return true + } } - return confStatus(0, *replacementTxHash), nil + return false } - - // Resubmit the transaction if the current base fee is higher than the gas - // fee cap in the transaction. - baseFee, _, err := w.node.currentFees(w.ctx) - if err != nil { - return nil, fmt.Errorf("error getting net fee state: %w", err) + if withPendingTxRead(txHash, f) { + return true } - if baseFee.Cmp(tx.GasFeeCap()) > 0 { - w.log.Errorf("redemption tx %s has a gas fee cap %v lower than the current base fee %v", - txHash, tx.GasFeeCap(), baseFee) - nonce := tx.Nonce() - replacementTxHash, err := w.resubmitRedemption(tx, contractVer, &nonce, feeWallet, monitoredTx) - if err != nil { - return nil, err + // Could be finalized and in the database. + if confirmedTx, err := w.txDB.getTx(txHash); err != nil { + w.log.Errorf("Error getting DB transaction: %v", err) + } else if confirmedTx != nil { + if !confirmedTx.Confirmed && confirmedTx.Receipt == nil && !confirmedTx.AssumedLost && confirmedTx.NonceReplacement == "" { + // If it's in the db but not in pendingTxs, and we know nothing + // about the tx, don't use it. + return false } - return confStatus(0, *replacementTxHash), nil - } - - // Resend the transaction in case it has not been mined because it was not - // successfully propagated. - err = w.node.sendSignedTransaction(w.ctx, tx) - if err != nil { - return nil, fmt.Errorf("failed to resubmit transaction %w", err) + f(confirmedTx) + return true } - - return confStatus(0, txHash), nil + return false } -// confirmRedemptionWithoutMonitoredTx checks the confirmation status of a -// redemption transaction. It is called when a monitored tx cannot be -// found. The main difference between the regular path and this one is that -// when we can also not find the transaction, instead of resubmitting an -// entire redemption batch, a new transaction containing only the swap we are -// searching for will be created. -func (w *assetWallet) confirmRedemptionWithoutMonitoredTx(txHash common.Hash, redemption *asset.Redemption, feeWallet *assetWallet) (*asset.ConfirmRedemptionStatus, error) { - contractVer, secretHash, err := dexeth.DecodeContractData(redemption.Spends.Contract) - if err != nil { - return nil, fmt.Errorf("failed to decode contract data: %w", err) - } - hdr, err := w.node.bestHeader(w.ctx) - if err != nil { - return nil, fmt.Errorf("failed to get best header: %w", err) - } - currentTip := hdr.Number.Uint64() - - tx, txBlock, err := w.node.getTransaction(w.ctx, txHash) - if errors.Is(err, asset.CoinNotFoundError) { - w.log.Errorf("ConfirmRedemption: could not find tx: %s", txHash) - swapIsRedeemed, err := w.swapIsRedeemed(secretHash, contractVer) - if err != nil { - return nil, err - } - if swapIsRedeemed { - return confStatus(txConfsNeededToConfirm, txHash), nil - } - - // If we cannot find the transaction, and it also wasn't among the - // monitored txs, we will resubmit the swap individually. - txs, _, _, err := w.Redeem(&asset.RedeemForm{ - Redemptions: []*asset.Redemption{redemption}, - FeeSuggestion: w.FeeRate(), - }, nil, nil) - if err != nil { - return nil, err - } - if len(txs) == 0 { - return nil, errors.New("no txs returned while resubmitting redemption") - } - var resubmittedTxHash common.Hash - copy(resubmittedTxHash[:], txs[0]) - return confStatus(0, resubmittedTxHash), nil - } - if err != nil { - return nil, err - } - - var confirmations uint64 - if txBlock > 0 { - if currentTip >= uint64(txBlock) { - confirmations = currentTip - uint64(txBlock) + 1 - } - } - if confirmations >= txConfsNeededToConfirm { - receipt, _, err := w.node.transactionReceipt(w.ctx, txHash) - if err != nil { - return nil, err - } - if receipt.Status == types.ReceiptStatusSuccessful { - return confStatus(txConfsNeededToConfirm, txHash), nil - } - replacementTxHash, err := w.resubmitRedemption(tx, contractVer, nil, feeWallet, nil) - if err != nil { - return nil, err - } - return confStatus(0, *replacementTxHash), nil - } - if confirmations > 0 { - return confStatus(confirmations, txHash), nil - } - - return w.checkUnconfirmedRedemption(secretHash, contractVer, txHash, tx, feeWallet, nil) +// walletTxStatus is data copied from an extendedWalletTx. +type walletTxStatus struct { + confirmed bool + blockNum uint64 + nonceReplacement string + feeReplacement bool + receipt *types.Receipt + assumedLost bool } -// confirmRedemption checks the confirmation status of a redemption transaction. -// It will resubmit transactions if it has been determined that the transaction -// cannot be mined. -func (w *assetWallet) confirmRedemption(coinID dex.Bytes, redemption *asset.Redemption, feeWallet *assetWallet) (*asset.ConfirmRedemptionStatus, error) { - if len(coinID) != common.HashLength { - return nil, fmt.Errorf("expected coin ID to be a transaction hash, but it has a length of %d", - len(coinID)) - } - var txHash common.Hash - copy(txHash[:], coinID) - - monitoredTx, err := w.getLatestMonitoredTx(txHash) - if err != nil { - w.log.Errorf("getLatestMonitoredTx error: %v", err) - return w.confirmRedemptionWithoutMonitoredTx(txHash, redemption, feeWallet) - } - // This mutex is locked inside of getLatestMonitoredTx. - defer monitoredTx.mtx.Unlock() - monitoredTxHash := monitoredTx.tx.Hash() - if monitoredTxHash != txHash { - w.log.Debugf("tx %s was replaced by %s since the last attempt to confirm redemption", - txHash, monitoredTxHash) - txHash = monitoredTxHash - } - - contractVer, secretHash, err := dexeth.DecodeContractData(redemption.Spends.Contract) - if err != nil { - return nil, fmt.Errorf("failed to decode contract data: %w", err) - } - hdr, err := w.node.bestHeader(w.ctx) - if err != nil { - return nil, fmt.Errorf("failed to get best header: %w", err) - } - currentTip := hdr.Number.Uint64() - - var blocksSinceSubmission uint64 - if currentTip > monitoredTx.blockSubmitted { - blocksSinceSubmission = currentTip - monitoredTx.blockSubmitted - } - - tx, txBlock, err := w.node.getTransaction(w.ctx, txHash) - if errors.Is(err, asset.CoinNotFoundError) { - if blocksSinceSubmission > 2 { - w.log.Errorf("ConfirmRedemption: could not find tx: %s", txHash) - } - - if blocksSinceSubmission < blocksToWaitBeforeCoinNotFound { - return confStatus(0, txHash), nil - } - - swapIsRedeemed, err := w.swapIsRedeemed(secretHash, contractVer) - if err != nil { - return nil, err - } - if swapIsRedeemed { - return confStatus(txConfsNeededToConfirm, txHash), nil - } - - replacementTxHash, err := w.resubmitRedemption(monitoredTx.tx, contractVer, nil, feeWallet, monitoredTx) - if err != nil { - return nil, err - } - return confStatus(0, *replacementTxHash), nil - } - if err != nil { - return nil, err - } - - var confirmations uint64 - if txBlock > 0 { - if currentTip >= uint64(txBlock) { - confirmations = currentTip - uint64(txBlock) + 1 +// localTxStatus looks for an extendedWalletTx and copies critical values to +// a walletTxStatus for use without mutex protection. +func (w *baseWallet) localTxStatus(txHash common.Hash) (_ bool, s *walletTxStatus) { + return w.withLocalTxRead(txHash, func(wt *extendedWalletTx) { + s = &walletTxStatus{ + confirmed: wt.Confirmed, + blockNum: wt.BlockNumber, + nonceReplacement: wt.NonceReplacement, + feeReplacement: wt.FeeReplacement, + receipt: wt.Receipt, + assumedLost: wt.AssumedLost, } - } - if confirmations >= txConfsNeededToConfirm { - receipt, _, err := w.node.transactionReceipt(w.ctx, txHash) - if err != nil { - return nil, err - } - if receipt.Status == types.ReceiptStatusSuccessful { - w.clearMonitoredTx(monitoredTx) - return confStatus(txConfsNeededToConfirm, txHash), nil - } - - w.log.Errorf("Redemption transaction rejected!!! Tx %s failed to redeem %s funds", tx.Hash(), dex.BipIDSymbol(w.assetID)) - // Only broadcast a single replacement before giving up. - if monitoredTx.replacedTx == nil { - w.log.Infof("Attempting to resubmit a %s redemption with secret hash %s", dex.BipIDSymbol(w.assetID), hex.EncodeToString(secretHash[:])) - replacementTxHash, err := w.resubmitRedemption(tx, contractVer, nil, feeWallet, monitoredTx) - if err != nil { - return nil, err - } - return confStatus(0, *replacementTxHash), nil - } - // We've failed to redeem twice. We can't keep broadcasting txs into the - // void. We have to give up. Print a bunch of errors and then report - // the redemption as confirmed so we'll stop following it. - if monitoredTx.errorsBroadcasted < 100 { - monitoredTx.errorsBroadcasted++ - return nil, fmt.Errorf("failed to redeem %s swap with secret hash %s twice. not trying again", - dex.BipIDSymbol(w.assetID), hex.EncodeToString(secretHash[:])) - } - const aTonOfFakeConfs = 1e3 - return confStatus(aTonOfFakeConfs, txHash), nil - } - if confirmations > 0 { - return confStatus(confirmations, txHash), nil - } - - // If the transaction is unconfirmed, check to see if it should be - // resubmitted once every blocksToWaitBeforeCheckingIfReplaced blocks. - if blocksSinceSubmission%blocksToWaitBeforeCheckingIfReplaced != 0 || blocksSinceSubmission == 0 { - return confStatus(0, txHash), nil - } - - return w.checkUnconfirmedRedemption(secretHash, contractVer, txHash, tx, feeWallet, monitoredTx) + }), s } // checkFindRedemptions checks queued findRedemptionRequests. func (w *assetWallet) checkFindRedemptions() { for secretHash, req := range w.findRedemptionRequests() { + if w.ctx.Err() != nil { + return + } secret, makerAddr, err := w.findSecret(secretHash, req.contractVer) if err != nil { w.sendFindRedemptionResult(req, secretHash, nil, "", err) @@ -4034,14 +3924,21 @@ func (w *assetWallet) checkPendingApprovals() { defer w.approvalsMtx.Unlock() for version, pendingApproval := range w.pendingApprovals { - confs, err := w.node.transactionConfirmations(w.ctx, pendingApproval.txHash) - - if err != nil && !errors.Is(err, asset.CoinNotFoundError) { - w.log.Errorf("error getting confirmations for tx %s: %v", pendingApproval.txHash, err) - continue + if w.ctx.Err() != nil { + return } - - if confs > 0 { + var confirmed bool + if found, txData := w.localTxStatus(pendingApproval.txHash); found { + confirmed = txData.blockNum > 0 && txData.blockNum <= w.tipHeight() + } else { + confs, err := w.node.transactionConfirmations(w.ctx, pendingApproval.txHash) + if err != nil && !errors.Is(err, asset.CoinNotFoundError) { + w.log.Errorf("error getting confirmations for tx %s: %v", pendingApproval.txHash, err) + continue + } + confirmed = confs > 0 + } + if confirmed { go pendingApproval.onConfirm() delete(w.pendingApprovals, version) } @@ -4054,68 +3951,35 @@ func (w *assetWallet) checkPendingApprovals() { func (w *assetWallet) sumPendingTxs(bal *big.Int) (out, in uint64) { isToken := w.assetID != w.baseChainID - pendingTxsCopy := make(map[uint64]*extendedWalletTx, len(w.pendingTxs)) - w.pendingTxsMtx.Lock() - for nonce, tx := range w.pendingTxs { - pendingTxsCopy[nonce] = tx - } - balanceHasChanged := w.pendingTxCheckBal == nil || bal.Cmp(w.pendingTxCheckBal) != 0 - w.pendingTxCheckBal = bal - w.pendingTxsMtx.Unlock() - - w.tipMtx.RLock() - tip := w.currentTip.Number.Uint64() - w.tipMtx.RUnlock() - - addPendingTx := func(txAssetID uint32, pt *extendedWalletTx) { - if txAssetID == w.assetID { - if asset.IncomingTxType(pt.Type) { - in += pt.Amount - } else { - out += pt.Amount - } - } - if !isToken { - out += pt.Fees - } - } - - processPendingTx := func(nonce uint64, wt *extendedWalletTx) { - wt.mtx.Lock() - defer wt.mtx.Unlock() - + sumPendingTx := func(pendingTx *extendedWalletTx) { // Already confirmed, but still in the unconfirmed txs map waiting for // txConfsNeededToConfirm confirmations. - if wt.BlockNumber != 0 { + if pendingTx.BlockNumber != 0 { return } txAssetID := w.baseChainID - if wt.TokenID != nil { - txAssetID = *wt.TokenID - } - if isToken && w.assetID != txAssetID { - return + if pendingTx.TokenID != nil { + txAssetID = *pendingTx.TokenID } - if tip == wt.lastCheck || !balanceHasChanged { - // Expect nothing has changed since our last check. - addPendingTx(txAssetID, wt) - return - } - - givenUp := w.checkPendingTx(nonce, wt) - if givenUp { - return + if txAssetID == w.assetID { + if asset.IncomingTxType(pendingTx.Type) { + in += pendingTx.Amount + } else { + out += pendingTx.Amount + } } - - if wt.BlockNumber == 0 { - addPendingTx(txAssetID, wt) + if !isToken { + out += pendingTx.Fees } } - for nonce, wt := range pendingTxsCopy { - processPendingTx(nonce, wt) + w.nonceMtx.RLock() + defer w.nonceMtx.RUnlock() + + for _, pendingTx := range w.pendingTxs { + sumPendingTx(pendingTx) } return @@ -4123,9 +3987,7 @@ func (w *assetWallet) sumPendingTxs(bal *big.Int) (out, in uint64) { func (w *assetWallet) getConfirmedBalance() (*big.Int, error) { now := time.Now() - w.tipMtx.RLock() - tipHeight := w.currentTip.Number.Uint64() - w.tipMtx.RUnlock() + tip := w.tipHeight() w.balances.Lock() defer w.balances.Unlock() @@ -4135,7 +3997,7 @@ func (w *assetWallet) getConfirmedBalance() (*big.Int, error) { } // Check to see if we already have one up-to-date cached := w.balances.m[w.assetID] - if cached != nil && cached.height == tipHeight && time.Since(cached.stamp) < time.Minute { + if cached != nil && cached.height == tip && time.Since(cached.stamp) < time.Minute { return cached.bal, nil } @@ -4152,7 +4014,7 @@ func (w *assetWallet) getConfirmedBalance() (*big.Int, error) { } w.balances.m[w.assetID] = &cachedBalance{ stamp: now, - height: tipHeight, + height: tip, bal: bal, } return bal, nil @@ -4193,7 +4055,7 @@ func (w *assetWallet) getConfirmedBalance() (*big.Int, error) { } w.balances.m[assetID] = &cachedBalance{ stamp: now, - height: tipHeight, + height: tip, bal: bal, } } @@ -4301,45 +4163,97 @@ func (w *assetWallet) balanceWithTxPool() (*Balance, error) { }, nil } +// Uncomment here and in sendToAddr to test actionTypeLostNonce. +// var nonceBorked atomic.Bool +// func (w *ETHWallet) borkNonce(tx *types.Transaction) { +// fmt.Printf("\n##### losing tx for lost nonce testing\n\n") +// txHash := tx.Hash() +// v := big.NewInt(dexeth.GweiFactor) +// spoofTx := types.NewTransaction(tx.Nonce(), w.addr, v, defaultSendGasLimit, v, nil) +// spoofHash := tx.Hash() +// pendingSpoofTx := w.extendedTx(spoofTx, asset.SelfSend, dexeth.GweiFactor) +// w.nonceMtx.Lock() +// for i, pendingTx := range w.pendingTxs { +// if pendingTx.txHash == txHash { +// w.pendingTxs[i] = pendingSpoofTx +// w.tryStoreDBTx(pendingSpoofTx) +// fmt.Printf("\n##### Replaced pending tx %s with spoof tx %s, nonce %d \n\n", txHash, spoofHash, tx.Nonce()) +// break +// } +// } +// w.nonceMtx.Unlock() +// } + +// Uncomment here and in sendToAddr to test actionTypeMissingNonces. +// var nonceFuturized atomic.Bool + +// Uncomment here and in sendToAddr to test actionTypeTooCheap +// var nonceSkimped atomic.Bool + // sendToAddr sends funds to the address. -func (w *ETHWallet) sendToAddr(addr common.Address, amt uint64, maxFeeRate *big.Int) (tx *types.Transaction, err error) { - w.baseWallet.nonceSendMtx.Lock() - defer w.baseWallet.nonceSendMtx.Unlock() - txOpts, err := w.node.txOpts(w.ctx, amt, defaultSendGasLimit, maxFeeRate, nil) - if err != nil { - return nil, err - } - tx, err = w.node.sendTransaction(w.ctx, txOpts, addr, nil) - if err != nil { - if mRPC, is := w.node.(*multiRPCClient); is { - mRPC.voidUnusedNonce() +func (w *ETHWallet) sendToAddr(addr common.Address, amt uint64, maxFeeRate, tipRate *big.Int) (tx *types.Transaction, err error) { + + // Uncomment here and above to test actionTypeLostNonce. + // Also change txAgeOut to like 1 minute. + // if nonceBorked.CompareAndSwap(false, true) { + // defer w.borkNonce(tx) + // } + + return tx, w.withNonce(w.ctx, func(nonce *big.Int) (*types.Transaction, asset.TransactionType, uint64, error) { + + // Uncomment here and above to test actionTypeMissingNonces. + // if nonceFuturized.CompareAndSwap(false, true) { + // fmt.Printf("\n##### advancing nonce for missing nonce testing\n\n") + // nonce.Add(nonce, big.NewInt(3)) + // } + + // Uncomment here and above to test actionTypeTooCheap. + // if nonceSkimped.CompareAndSwap(false, true) { + // fmt.Printf("\n##### lower max fee rate to test cheap tx handling\n\n") + // maxFeeRate.SetUint64(1) + // tipRate.SetUint64(0) + // } + + txOpts, err := w.node.txOpts(w.ctx, amt, defaultSendGasLimit, maxFeeRate, tipRate, nonce) + if err != nil { + return nil, 0, 0, err } - return nil, err - } - return tx, nil + tx, err = w.node.sendTransaction(w.ctx, txOpts, addr, nil) + if err != nil { + return nil, 0, 0, err + } + txType := asset.Send + if addr == w.addr { + txType = asset.SelfSend + } + return tx, txType, amt, nil + }) } // sendToAddr sends funds to the address. -func (w *TokenWallet) sendToAddr(addr common.Address, amt uint64, maxFeeRate *big.Int) (tx *types.Transaction, err error) { - w.baseWallet.nonceSendMtx.Lock() - defer w.baseWallet.nonceSendMtx.Unlock() +func (w *TokenWallet) sendToAddr(addr common.Address, amt uint64, maxFeeRate, tipRate *big.Int) (tx *types.Transaction, err error) { g := w.gases(contractVersionNewest) if g == nil { return nil, fmt.Errorf("no gas table") } - - txOpts, err := w.node.txOpts(w.ctx, 0, g.Transfer, nil, nil) - if err != nil { - return nil, err - } - return tx, w.withTokenContractor(w.assetID, contractVersionNewest, func(c tokenContractor) error { - tx, err = c.transfer(txOpts, addr, w.evmify(amt)) + return tx, w.withNonce(w.ctx, func(nonce *big.Int) (*types.Transaction, asset.TransactionType, uint64, error) { + txOpts, err := w.node.txOpts(w.ctx, 0, g.Transfer, maxFeeRate, tipRate, nonce) if err != nil { - c.voidUnusedNonce() - return err + return nil, 0, 0, err } - return nil + txType := asset.Send + if addr == w.addr { + txType = asset.SelfSend + } + return tx, txType, amt, w.withTokenContractor(w.assetID, contractVersionNewest, func(c tokenContractor) error { + tx, err = c.transfer(txOpts, addr, w.evmify(amt)) + if err != nil { + return err + } + return nil + }) }) + } // swap gets a swap keyed by secretHash in the contract. @@ -4351,28 +4265,27 @@ func (w *assetWallet) swap(ctx context.Context, secretHash [32]byte, contractVer } // initiate initiates multiple swaps in the same transaction. -func (w *assetWallet) initiate(ctx context.Context, assetID uint32, contracts []*asset.Contract, - maxFeeRate, gasLimit uint64, contractVer uint32) (tx *types.Transaction, err error) { - - var val uint64 - if assetID == w.baseChainID { - for _, c := range contracts { +func (w *assetWallet) initiate( + ctx context.Context, assetID uint32, contracts []*asset.Contract, gasLimit uint64, maxFeeRate, tipRate *big.Int, contractVer uint32, +) (tx *types.Transaction, err error) { + + var val, amt uint64 + for _, c := range contracts { + amt += c.Value + if assetID == w.baseChainID { val += c.Value } } - w.nonceSendMtx.Lock() - defer w.nonceSendMtx.Unlock() - txOpts, err := w.node.txOpts(ctx, val, gasLimit, dexeth.GweiToWei(maxFeeRate), nil) - if err != nil { - return nil, err - } - return tx, w.withContractor(contractVer, func(c contractor) error { - tx, err = c.initiate(txOpts, contracts) + return tx, w.withNonce(ctx, func(nonce *big.Int) (*types.Transaction, asset.TransactionType, uint64, error) { + txOpts, err := w.node.txOpts(ctx, val, gasLimit, maxFeeRate, tipRate, nonce) if err != nil { - c.voidUnusedNonce() - return err + return nil, 0, 0, err } - return nil + + return tx, asset.Swap, amt, w.withContractor(contractVer, func(c contractor) error { + tx, err = c.initiate(txOpts, contracts) + return err + }) }) } @@ -4485,58 +4398,68 @@ func (w *assetWallet) estimateTransferGas(val uint64) (gas uint64, err error) { }) } +// Can uncomment here and in redeem to test rejected redemption reauthorization. +// var firstRedemptionBorked atomic.Bool + +// Uncomment here and below to test core's handling of lost redemption txs. +// var firstRedemptionLost atomic.Bool + // redeem redeems a swap contract. Any on-chain failure, such as this secret not // matching the hash, will not cause this to error. -func (w *assetWallet) redeem(ctx context.Context, assetID uint32 /* ?? */, redemptions []*asset.Redemption, - maxFeeRate, gasLimit uint64, contractVer uint32, nonceOverride *uint64) (tx *types.Transaction, err error) { - w.nonceSendMtx.Lock() - defer w.nonceSendMtx.Unlock() - var nonce *big.Int - if nonceOverride != nil { - nonce = new(big.Int).SetUint64(*nonceOverride) - } - txOpts, err := w.node.txOpts(ctx, 0, gasLimit, dexeth.GweiToWei(maxFeeRate), nonce) - if err != nil { - return nil, err - } +func (w *assetWallet) redeem( + ctx context.Context, + redemptions []*asset.Redemption, + maxFeeRate uint64, + tipRate *big.Int, + gasLimit uint64, + contractVer uint32, +) (tx *types.Transaction, err error) { + + // // Uncomment here and above to test core's handling of ErrTxLost from + // if firstRedemptionLost.CompareAndSwap(false, true) { + // fmt.Printf("\n##### Spoofing tx for lost tx testing\n\n") + // return types.NewTransaction(10, w.addr, big.NewInt(dexeth.GweiFactor), gasLimit, dexeth.GweiToWei(maxFeeRate), nil), nil + // } - return tx, w.withContractor(contractVer, func(c contractor) error { - tx, err = c.redeem(txOpts, redemptions) + return tx, w.withNonce(ctx, func(nonce *big.Int) (*types.Transaction, asset.TransactionType, uint64, error) { + var amt uint64 + for _, r := range redemptions { + amt += r.Spends.Coin.Value() + } + // Uncomment here and above to test rejected redemption handling. + // if firstRedemptionBorked.CompareAndSwap(false, true) { + // fmt.Printf("\n##### Borking gas limit for rejected tx testing\n\n") + // gasLimit /= 4 + // } + + txOpts, err := w.node.txOpts(ctx, 0, gasLimit, dexeth.GweiToWei(maxFeeRate), tipRate, nonce) if err != nil { - // If we did not override the nonce for a replacement - // transaction, make it available for the next transaction - // on error. - if nonceOverride == nil { - c.voidUnusedNonce() - } - return err + return nil, 0, 0, err } - return nil + return tx, asset.Redeem, amt, w.withContractor(contractVer, func(c contractor) error { + tx, err = c.redeem(txOpts, redemptions) + return err + }) }) } // refund refunds a swap contract using the account controlled by the wallet. // Any on-chain failure, such as the locktime not being past, will not cause // this to error. -func (w *assetWallet) refund(secretHash [32]byte, maxFeeRate uint64, contractVer uint32) (tx *types.Transaction, fees uint64, err error) { +func (w *assetWallet) refund(secretHash [32]byte, amt uint64, maxFeeRate, tipRate *big.Int, contractVer uint32) (tx *types.Transaction, err error) { gas := w.gases(contractVer) if gas == nil { - return nil, 0, fmt.Errorf("no gas table for asset %d, version %d", w.assetID, contractVer) - } - w.nonceSendMtx.Lock() - defer w.nonceSendMtx.Unlock() - txOpts, err := w.node.txOpts(w.ctx, 0, gas.Refund, dexeth.GweiToWei(maxFeeRate), nil) - if err != nil { - return nil, 0, err + return nil, fmt.Errorf("no gas table for asset %d, version %d", w.assetID, contractVer) } - - return tx, gas.Refund * maxFeeRate, w.withContractor(contractVer, func(c contractor) error { - tx, err = c.refund(txOpts, secretHash) + return tx, w.withNonce(w.ctx, func(nonce *big.Int) (*types.Transaction, asset.TransactionType, uint64, error) { + txOpts, err := w.node.txOpts(w.ctx, 0, gas.Refund, maxFeeRate, tipRate, nonce) if err != nil { - c.voidUnusedNonce() - return err + return nil, 0, 0, err } - return nil + return tx, asset.Refund, amt, w.withContractor(contractVer, func(c contractor) error { + tx, err = c.refund(txOpts, secretHash) + return err + }) }) } @@ -4596,177 +4519,624 @@ func (w *baseWallet) emitTransactionNote(tx *asset.WalletTransaction, new bool) } } -// checkPendingTx checks the confirmation status of a transaction. The +func findMissingNonces(confirmedAt, pendingAt *big.Int, pendingTxs []*extendedWalletTx) (ns []uint64) { + pendingTxMap := make(map[uint64]struct{}) + // It's not clear whether all providers will update PendingNonceAt if + // there are gaps. geth doesn't do it on simnet, apparently. We'll use + // our own pendingTxs max nonce as a backup. + nonceHigh := big.NewInt(-1) + for _, pendingTx := range pendingTxs { + if pendingTx.indexed && pendingTx.Nonce.Cmp(nonceHigh) > 0 { + nonceHigh.Set(pendingTx.Nonce) + } + pendingTxMap[pendingTx.Nonce.Uint64()] = struct{}{} + } + nonceHigh.Add(nonceHigh, big.NewInt(1)) + if pendingAt.Cmp(nonceHigh) > 0 { + nonceHigh.Set(pendingAt) + } + for n := confirmedAt.Uint64(); n < nonceHigh.Uint64(); n++ { + if _, found := pendingTxMap[n]; !found { + ns = append(ns, n) + } + } + return +} + +func (w *baseWallet) missingNoncesActionID() string { + return fmt.Sprintf("missingNonces_%d", w.baseChainID) +} + +// updatePendingTx checks the confirmation status of a transaction. The // BlockNumber, Fees, and Timestamp fields of the extendedWalletTx are updated // if the transaction is confirmed, and if the transaction has reached the // required number of confirmations, it is removed from w.pendingTxs. -// True is returned from this function if we have given up on the transaction, and it -// should not be considered in the pending tx calculation. // -// extendedWalletTx.mtx MUST be held while calling this function, but the -// w.pendingTxsMtx MUST NOT be held. -func (w *baseWallet) checkPendingTx(nonce uint64, pendingTx *extendedWalletTx) (givenUp bool) { - w.tipMtx.RLock() - tip := w.currentTip.Number.Uint64() - w.tipMtx.RUnlock() +// w.nonceMtx must be held. +func (w *baseWallet) updatePendingTx(tip uint64, pendingTx *extendedWalletTx) { + if pendingTx.Confirmed && pendingTx.savedToDB { + return + } + waitingOnConfs := pendingTx.BlockNumber > 0 && safeConfs(tip, pendingTx.BlockNumber) < w.finalizeConfs + if waitingOnConfs { + // We're just waiting on confs. Don't check again until we expect to be + // finalized. + return + } + // Only check when the tip has changed. + if pendingTx.lastCheck == tip { + return + } + pendingTx.lastCheck = tip var updated bool defer func() { - if givenUp { - err := w.txDB.removeTx(pendingTx.ID) - if err != nil { - w.log.Errorf("failed to remove tx from db: %v", err) - } else { - w.pendingTxsMtx.Lock() - delete(w.pendingTxs, nonce) - w.pendingTxsMtx.Unlock() + if updated || !pendingTx.savedToDB { + w.tryStoreDBTx(pendingTx) + w.emitTransactionNote(pendingTx.WalletTransaction, false) + } + }() + + receipt, tx, err := w.node.transactionAndReceipt(w.ctx, pendingTx.txHash) + if w.log.Level() == dex.LevelTrace { + w.log.Tracef("Attempted to fetch tx and receipt for %s. receipt = %+v, tx = %+v, err = %+v", pendingTx.txHash, receipt, tx, err) + } + if err != nil { + if errors.Is(err, asset.CoinNotFoundError) { + pendingTx.indexed = tx != nil + // transactionAndReceipt will return a CoinNotFound for either no + // reciept or no tx. If they report the tx, we'll consider the tx + // to be "indexed", and won't send lost tx action-required requests. + if pendingTx.BlockNumber > 0 { + w.log.Warnf("Transaction %s was previously mined but now cannot be confirmed. Might be normal.", pendingTx.txHash) + pendingTx.BlockNumber = 0 + pendingTx.Timestamp = 0 + updated = true } + } else { + w.log.Errorf("Error getting confirmations for pending tx %s: %v", pendingTx.txHash, err) + } + return + } + + pendingTx.Receipt = receipt + pendingTx.indexed = true + pendingTx.Rejected = receipt.Status != types.ReceiptStatusSuccessful + updated = true + + if receipt.BlockNumber == nil || receipt.BlockNumber.Cmp(new(big.Int)) == 0 { + if pendingTx.BlockNumber > 0 { + w.log.Warnf("Transaction %s was previously mined but is now unconfirmed", pendingTx.txHash) + pendingTx.Timestamp = 0 + pendingTx.BlockNumber = 0 + } + return + } + effectiveGasPrice := receipt.EffectiveGasPrice + // NOTE: Would be nice if the receipt contained the block time so we could + // set the timestamp without having to fetch the header. We could use + // SubmissionTime, I guess. Less accurate, but probably not by much. + // NOTE: I don't really know why effectiveGasPrice would be nil, but the + // code I'm replacing got it from the header, so I'm gonna add this check + // just in case. + if pendingTx.Timestamp == 0 || effectiveGasPrice == nil { + hdr, err := w.node.headerByHash(w.ctx, receipt.BlockHash) + if err != nil { + w.log.Errorf("Error getting header for hash %v: %v", receipt.BlockHash, err) return } + if hdr == nil { + w.log.Errorf("Header for hash %v is nil", receipt.BlockHash) + return + } + pendingTx.Timestamp = hdr.Time + if effectiveGasPrice == nil { + effectiveGasPrice = new(big.Int).Add(hdr.BaseFee, tx.EffectiveGasTipValue(hdr.BaseFee)) + } + } - if updated || !pendingTx.savedToDB { - err := w.txDB.storeTx(nonce, pendingTx) - if err != nil { - w.log.Errorf("error updating tx in db: %w", err) - pendingTx.savedToDB = false - return - } + bigFees := new(big.Int).Mul(effectiveGasPrice, big.NewInt(int64(receipt.GasUsed))) + pendingTx.Fees = dexeth.WeiToGweiCeil(bigFees) + pendingTx.BlockNumber = receipt.BlockNumber.Uint64() + pendingTx.Confirmed = safeConfs(tip, pendingTx.BlockNumber) >= w.finalizeConfs +} - pendingTx.savedToDB = true - if pendingTx.Confirmed { - w.pendingTxsMtx.Lock() - delete(w.pendingTxs, nonce) - w.pendingTxsMtx.Unlock() - } +// checkPendingTxs checks the confirmation status of all pending transactions. +func (w *baseWallet) checkPendingTxs() { + tip := w.tipHeight() - w.emitTransactionNote(pendingTx.WalletTransaction, false) + w.nonceMtx.Lock() + defer w.nonceMtx.Unlock() + + // If we have pending txs, trace log the before and after. + if w.log.Level() == dex.LevelTrace { + if nPending := len(w.pendingTxs); nPending > 0 { + defer func() { + w.log.Tracef("Checked %d pending txs. Finalized %d", nPending, nPending-len(w.pendingTxs)) + }() } - }() - if pendingTx.lastCheck == tip { - return false } - pendingTx.lastCheck = tip - h, err := common.ParseHexOrString(pendingTx.ID) - if err != nil { - w.log.Errorf("error parsing tx hash %s: %v", pendingTx.ID, err) - return + // keepFromIndex will be the index of the first un-finalized tx. + // lastConfirmed, will be the index of the last confirmed tx. All txs with + // nonces lower that lastConfirmed should also be confirmed, or else + // something isn't right and we may need to request user input. + var keepFromIndex int + var lastConfirmed int = -1 + for i, pendingTx := range w.pendingTxs { + if w.ctx.Err() != nil { + return + } + w.updatePendingTx(tip, pendingTx) + if pendingTx.Confirmed { + lastConfirmed = i + if pendingTx.Nonce.Cmp(w.confirmedNonceAt) == 0 { + w.confirmedNonceAt.Add(w.confirmedNonceAt, big.NewInt(1)) + } + if pendingTx.savedToDB { + if i == keepFromIndex { + // This is what we expect. No tx should be confirmed before a + // tx with a lower nonce. We'll delete at least up to this + // one. + keepFromIndex = i + 1 + } + } + // This transaction is finalized. If we had previously sought action + // on it, cancel that request. + if pendingTx.actionRequested { + pendingTx.actionRequested = false + w.requestAction(asset.ActionResolved, pendingTx.ID, nil, pendingTx.TokenID) + } + } } - txHash := common.BytesToHash(h) - receipt, tx, err := w.node.transactionReceipt(w.ctx, txHash) - if err != nil { - if errors.Is(err, asset.CoinNotFoundError) && pendingTx.BlockNumber > 0 { - w.log.Warnf("TxID %v was previously confirmed but now cannot be found", pendingTx.ID) - pendingTx.BlockNumber = 0 - pendingTx.Timestamp = 0 - updated = true + + // If we have missing nonces, send an alert. + if !w.recoveryRequestSent && len(findMissingNonces(w.confirmedNonceAt, w.pendingNonceAt, w.pendingTxs)) != 0 { + w.recoveryRequestSent = true + w.requestAction(actionTypeMissingNonces, w.missingNoncesActionID(), nil, nil) + } + + // Loop again, classifying problems and sending action requests. + for i, pendingTx := range w.pendingTxs { + if pendingTx.Confirmed || pendingTx.BlockNumber > 0 { + continue + } + if time.Since(pendingTx.actionIgnored) < txAgeOut { + // They asked us to keep waiting. + continue + } + age := pendingTx.age() + // i < lastConfirmed means unconfirmed nonce < a confirmed nonce. + if (i < lastConfirmed) || + w.confirmedNonceAt.Cmp(pendingTx.Nonce) > 0 || + (age >= txAgeOut && pendingTx.Receipt == nil && !pendingTx.indexed) { + + // The tx in our records wasn't accepted. Where's the right one? + req := newLostNonceNote(*pendingTx.WalletTransaction, pendingTx.Nonce.Uint64()) + pendingTx.actionRequested = true + w.requestAction(actionTypeLostNonce, pendingTx.ID, req, pendingTx.TokenID) + continue + } + // Recheck fees periodically. + const feeCheckInterval = time.Minute * 5 + if time.Since(pendingTx.lastFeeCheck) < feeCheckInterval { + continue + } + pendingTx.lastFeeCheck = time.Now() + tx, err := pendingTx.tx() + if err != nil { + w.log.Errorf("Error decoding raw tx %s for fee check: %v", pendingTx.ID, err) + continue } - if !errors.Is(err, asset.CoinNotFoundError) { - w.log.Errorf("Error getting confirmations for pending tx %s: %v", txHash, err) + txCap := tx.GasFeeCap() + baseRate, tipRate, err := w.currentNetworkFees(w.ctx) + if err != nil { + w.log.Errorf("Error getting base fee: %w", err) + continue } - if time.Since(time.Unix(int64(pendingTx.SubmissionTime), 0)) > time.Minute*6 { - givenUp = true + if txCap.Cmp(baseRate) < 0 { + maxFees := new(big.Int).Add(tipRate, new(big.Int).Mul(baseRate, big.NewInt(2))) + maxFees.Mul(maxFees, new(big.Int).SetUint64(tx.Gas())) + req := newLowFeeNote(*pendingTx.WalletTransaction, dexeth.WeiToGweiCeil(maxFees)) + pendingTx.actionRequested = true + w.requestAction(actionTypeTooCheap, pendingTx.ID, req, pendingTx.TokenID) + continue } + // Fees look good and there's no reason to believe this tx will + // not be mined. Do we do anything? + // actionRequired(actionTypeStuckTx, pendingTx) + } - return + // Delete finalized txs from local tracking. + w.pendingTxs = w.pendingTxs[keepFromIndex:] + + // Re-broadcast any txs that are not indexed and haven't been re-broadcast + // in a while, logging any errors as warnings. + const rebroadcastPeriod = time.Minute * 5 + for _, pendingTx := range w.pendingTxs { + if pendingTx.Confirmed || pendingTx.BlockNumber > 0 || + pendingTx.actionRequested || // Waiting on action + pendingTx.indexed || // Provider knows about it + time.Since(pendingTx.lastBroadcast) < rebroadcastPeriod { + + continue + } + pendingTx.lastBroadcast = time.Now() + tx, err := pendingTx.tx() + if err != nil { + w.log.Errorf("Error decoding raw tx %s for rebroadcast: %v", pendingTx.ID, err) + continue + } + if err := w.node.sendSignedTransaction(w.ctx, tx, allowAlreadyKnownFilter); err != nil { + w.log.Warnf("Error rebroadcasting tx %s: %v", pendingTx.ID, err) + } else { + w.log.Infof("Rebroadcasted un-indexed transaction %s", pendingTx.ID) + } } +} - if receipt.BlockNumber == nil || receipt.BlockNumber.Cmp(new(big.Int)) == 0 { - if pendingTx.BlockNumber > 0 { - w.log.Warnf("TxID %v was previously confirmed but now is not confirmed", pendingTx.ID) - pendingTx.BlockNumber = 0 - pendingTx.Timestamp = 0 - updated = true +// Required Actions: Extraordinary conditions that might require user input. + +var _ asset.ActionTaker = (*assetWallet)(nil) + +const ( + actionTypeMissingNonces = "missingNonces" + actionTypeLostNonce = "lostNonce" + actionTypeTooCheap = "tooCheap" +) + +// TransactionActionNote is used to request user action on transactions in +// abnormal states. +type TransactionActionNote struct { + Tx *asset.WalletTransaction `json:"tx"` + Nonce uint64 `json:"nonce,omitempty"` + NewFees uint64 `json:"newFees,omitempty"` +} + +// newLostNonceNote is information regarding a tx that appears to be lost. +func newLostNonceNote(tx asset.WalletTransaction, nonce uint64) *TransactionActionNote { + return &TransactionActionNote{ + Tx: &tx, + Nonce: nonce, + } +} + +// newLowFeeNote is data about a tx that is stuck in mempool with too-low fees. +func newLowFeeNote(tx asset.WalletTransaction, newFees uint64) *TransactionActionNote { + return &TransactionActionNote{ + Tx: &tx, + NewFees: newFees, + } +} + +// parse the pending tx and index from the slice. +func pendingTxWithID(txID string, pendingTxs []*extendedWalletTx) (int, *extendedWalletTx) { + for i, pendingTx := range pendingTxs { + if pendingTx.ID == txID { + return i, pendingTx } - return } - hdr, err := w.node.headerByHash(w.ctx, receipt.BlockHash) + return 0, nil +} + +// amendPendingTx is called with a function that intends to modify a pendingTx +// under mutex lock. +func (w *assetWallet) amendPendingTx(txID string, f func(common.Hash, *types.Transaction, *extendedWalletTx, int) error) error { + txHash := common.HexToHash(txID) + if txHash == (common.Hash{}) { + return fmt.Errorf("invalid tx ID %q", txID) + } + w.nonceMtx.Lock() + defer w.nonceMtx.Unlock() + idx, pendingTx := pendingTxWithID(txID, w.pendingTxs) + if pendingTx == nil { + // Nothing to do anymore. + return nil + } + tx, err := pendingTx.tx() if err != nil { - w.log.Errorf("Error getting header for hash %v: %v", receipt.BlockHash, err) - return + return fmt.Errorf("error decoding transaction: %w", err) } - if hdr == nil { - w.log.Errorf("Header for hash %v is nil", receipt.BlockHash) - return + if err := f(txHash, tx, pendingTx, idx); err != nil { + return err + } + w.emit.ActionResolved(txID) + pendingTx.actionRequested = false + return nil +} + +// userActionBumpFees is a request by a user to resolve a actionTypeTooCheap +// condition. +func (w *assetWallet) userActionBumpFees(actionB []byte) error { + var action struct { + TxID string `json:"txID"` + Bump *bool `json:"bump"` + } + if err := json.Unmarshal(actionB, &action); err != nil { + return fmt.Errorf("error unmarshaling bump action: %v", err) } + if action.Bump == nil { + return errors.New("no bump value specified") + } + return w.amendPendingTx(action.TxID, func(txHash common.Hash, tx *types.Transaction, pendingTx *extendedWalletTx, idx int) error { + if !*action.Bump { + pendingTx.actionIgnored = time.Now() + return nil + } - effectiveGasPrice := new(big.Int).Add(hdr.BaseFee, tx.EffectiveGasTipValue(hdr.BaseFee)) - bigFees := new(big.Int).Mul(effectiveGasPrice, big.NewInt(int64(receipt.GasUsed))) - fees := dexeth.WeiToGwei(bigFees) - blockNumber := receipt.BlockNumber.Uint64() - if pendingTx.BlockNumber != blockNumber || pendingTx.Fees != fees || pendingTx.Timestamp != hdr.Time { - pendingTx.Fees = dexeth.WeiToGwei(bigFees) - pendingTx.BlockNumber = blockNumber - pendingTx.Timestamp = hdr.Time - updated = true + nonce := new(big.Int).SetUint64(tx.Nonce()) + maxFeeRate, tipCap, err := w.recommendedMaxFeeRate(w.ctx) + if err != nil { + return fmt.Errorf("error getting new fee rate: %w", err) + } + txOpts, err := w.node.txOpts(w.ctx, 0 /* set below */, tx.Gas(), maxFeeRate, tipCap, nonce) + if err != nil { + return fmt.Errorf("error preparing tx opts: %w", err) + } + txOpts.Value = tx.Value() + addr := tx.To() + if addr == nil { + return errors.New("pending tx has no recipient?") + } + + newTx, err := w.node.sendTransaction(w.ctx, txOpts, *addr, tx.Data()) + if err != nil { + return fmt.Errorf("error sending bumped-fee transaction: %w", err) + } + + newPendingTx := w.extendedTx(newTx, pendingTx.Type, pendingTx.Amount) + + pendingTx.NonceReplacement = newPendingTx.ID + pendingTx.FeeReplacement = true + + w.tryStoreDBTx(pendingTx) + w.tryStoreDBTx(newPendingTx) + + w.pendingTxs[idx] = newPendingTx + return nil + }) +} + +// tryStoreDBTx attempts to store the DB tx and logs errors internally. This +// method sets the savedToDB flag, so if this tx is in pendingTxs, the nonceMtx +// must be held for reading. +func (w *baseWallet) tryStoreDBTx(wt *extendedWalletTx) { + err := w.txDB.storeTx(wt) + if err != nil { + w.log.Errorf("Error storing tx %s to DB: %v", wt.txHash, err) } + wt.savedToDB = err == nil +} - var confs uint64 - if blockNumber > 0 && tip >= blockNumber { - confs = tip - blockNumber + 1 +// userActionNonceReplacement is a request by a user to resolve a +// actionTypeLostNonce condition. +func (w *assetWallet) userActionNonceReplacement(actionB []byte) error { + var action struct { + TxID string `json:"txID"` + Abandon *bool `json:"abandon"` + ReplacementID string `json:"replacementID"` } - if confs >= txConfsNeededToConfirm { - if !pendingTx.Confirmed { - updated = true - } - pendingTx.Confirmed = true + if err := json.Unmarshal(actionB, &action); err != nil { + return fmt.Errorf("error unmarshaling user action: %v", err) + } + if action.Abandon == nil { + return fmt.Errorf("no abandon value provided for user action for tx %s", action.TxID) + } + abandon := *action.Abandon + if !abandon && action.ReplacementID == "" { // keep waiting + return w.amendPendingTx(action.TxID, func(_ common.Hash, _ *types.Transaction, pendingTx *extendedWalletTx, idx int) error { + pendingTx.actionIgnored = time.Now() + return nil + }) + } + if abandon { // abandon + return w.amendPendingTx(action.TxID, func(txHash common.Hash, _ *types.Transaction, wt *extendedWalletTx, idx int) error { + w.log.Infof("Abandoning transaction %s via user action", txHash) + wt.AssumedLost = true + w.tryStoreDBTx(wt) + copy(w.pendingTxs[idx:], w.pendingTxs[idx+1:]) + w.pendingTxs = w.pendingTxs[:len(w.pendingTxs)-1] + return nil + }) } - return + replacementHash := common.HexToHash(action.ReplacementID) + replacementTx, _, err := w.node.getTransaction(w.ctx, replacementHash) + if err != nil { + return fmt.Errorf("error fetching nonce replacement tx: %v", err) + } + + from, err := types.LatestSigner(w.node.chainConfig()).Sender(replacementTx) + if err != nil { + return fmt.Errorf("error parsing originator address from specified replacement tx %s: %w", from, err) + } + if from != w.addr { + return fmt.Errorf("specified replacement tx originator %s is not you", from) + } + return w.amendPendingTx(action.TxID, func(txHash common.Hash, oldTx *types.Transaction, pendingTx *extendedWalletTx, idx int) error { + if replacementTx.Nonce() != pendingTx.Nonce.Uint64() { + return fmt.Errorf("nonce replacement doesn't have the right nonce. %d != %s", replacementTx.Nonce(), pendingTx.Nonce) + } + newPendingTx := w.extendedTx(replacementTx, asset.Unknown, 0) + pendingTx.NonceReplacement = newPendingTx.ID + var oldTo, newTo common.Address + if oldAddr := oldTx.To(); oldAddr != nil { + oldTo = *oldAddr + } + if newAddr := replacementTx.To(); newAddr != nil { + newTo = *newAddr + } + if bytes.Equal(oldTx.Data(), replacementTx.Data()) && oldTo == newTo { + pendingTx.FeeReplacement = true + } + w.tryStoreDBTx(pendingTx) + w.tryStoreDBTx(newPendingTx) + w.pendingTxs[idx] = newPendingTx + return nil + }) } -// checkPendingTxs checks the confirmation status of all pending transactions. -func (w *baseWallet) checkPendingTxs() { - pendingTxsCopy := make(map[uint64]*extendedWalletTx, len(w.pendingTxs)) - w.pendingTxsMtx.Lock() - for nonce, tx := range w.pendingTxs { - pendingTxsCopy[nonce] = tx +// userActionRecoverNonces, if recover is true, examines our confirmed and +// pending nonces and our pendingTx set and sends zero-value txs to ourselves +// to fill any gaps or replace any rogue transactions. This should never happen +// if we're only running one instance of this wallet. +func (w *assetWallet) userActionRecoverNonces(actionB []byte) error { + var action struct { + Recover *bool `json:"recover"` + } + if err := json.Unmarshal(actionB, &action); err != nil { + return fmt.Errorf("error unmarshaling user action: %v", err) + } + if action.Recover == nil { + return errors.New("no recover value specified") + } + if !*action.Recover { + // Don't reset recoveryRequestSent. They won't see this message again until + // they reboot. + return nil + } + maxFeeRate, tipRate, err := w.recommendedMaxFeeRate(w.ctx) + if err != nil { + return fmt.Errorf("error getting max fee rate for nonce resolution: %v", err) + } + w.nonceMtx.Lock() + defer w.nonceMtx.Unlock() + missingNonces := findMissingNonces(w.confirmedNonceAt, w.pendingNonceAt, w.pendingTxs) + if len(missingNonces) == 0 { + return nil + } + for i, n := range missingNonces { + nonce := new(big.Int).SetUint64(n) + txOpts, err := w.node.txOpts(w.ctx, 0, defaultSendGasLimit, maxFeeRate, tipRate, nonce) + if err != nil { + return fmt.Errorf("error getting tx opts for nonce resolution: %v", err) + } + var skip bool + tx, err := w.node.sendTransaction(w.ctx, txOpts, w.addr, nil, func(err error) (discard, propagate, fail bool) { + if errorFilter(err, "replacement transaction underpriced") { + skip = true + return true, false, false + } + return false, false, true + }) + if err != nil { + return fmt.Errorf("error sending tx %d for nonce resolution: %v", nonce, err) + } + if skip { + w.log.Warnf("skipping storing underpriced replacement tx for nonce %d", nonce) + } else { + pendingTx := w.extendAndStoreTx(tx, asset.SelfSend, 0, nil) + w.emitTransactionNote(pendingTx.WalletTransaction, true) + w.pendingTxs = append(w.pendingTxs, pendingTx) + sort.Slice(w.pendingTxs, func(i, j int) bool { + return w.pendingTxs[i].Nonce.Cmp(w.pendingTxs[j].Nonce) < 0 + }) + } + if i < len(missingNonces)-1 { + select { + case <-time.After(time.Second * 1): + case <-w.ctx.Done(): + return nil + } + } + } + w.emit.ActionResolved(w.missingNoncesActionID()) + return nil +} + +// requestAction sends a ActionRequired or ActionResolved notification up the +// chain of command. nonceMtx must be locked. +func (w *baseWallet) requestAction(actionID, uniqueID string, req *TransactionActionNote, tokenID *uint32) { + assetID := w.baseChainID + if tokenID != nil { + assetID = *tokenID + } + aw := w.wallet(assetID) + if aw == nil { // sanity + return } - w.pendingTxsMtx.Unlock() + aw.emit.ActionRequired(uniqueID, actionID, req) +} - for nonce, pendingTx := range pendingTxsCopy { - pendingTx.mtx.Lock() - w.checkPendingTx(nonce, pendingTx) - pendingTx.mtx.Unlock() +// TakeAction satisfies asset.ActionTaker. This handles responses from the +// user for an ActionRequired request, usually for a stuck tx or otherwise +// abnormal condition. +func (w *assetWallet) TakeAction(actionID string, actionB []byte) error { + switch actionID { + case actionTypeTooCheap: + return w.userActionBumpFees(actionB) + case actionTypeMissingNonces: + return w.userActionRecoverNonces(actionB) + case actionTypeLostNonce: + return w.userActionNonceReplacement(actionB) + default: + return fmt.Errorf("unknown action %q", actionID) } } const txHistoryNonceKey = "Nonce" -func (w *baseWallet) addToTxHistory(nonce, amount, fees uint64, assetID uint32, txHash common.Hash, txType asset.TransactionType, recipient *string) { +// transactionFeeLimit calculates the maximum tx fees that are allowed by a tx. +func transactionFeeLimit(tx *types.Transaction) *big.Int { + fees := new(big.Int) + feeCap, tipCap := tx.GasFeeCap(), tx.GasTipCap() + if feeCap != nil && tipCap != nil { + fees.Add(fees, feeCap) + fees.Add(fees, tipCap) + fees.Mul(fees, new(big.Int).SetUint64(tx.Gas())) + } + return fees +} + +// extendedTx generates an *extendedWalletTx for a newly-broadcast tx and stores +// it in the DB. +func (w *assetWallet) extendedTx(tx *types.Transaction, txType asset.TransactionType, amt uint64) *extendedWalletTx { var tokenAssetID *uint32 - if assetID != w.baseChainID { - tokenAssetID = &assetID + if w.assetID != w.baseChainID { + tokenAssetID = &w.assetID + } + return w.baseWallet.extendAndStoreTx(tx, txType, amt, tokenAssetID) +} + +func (w *baseWallet) extendAndStoreTx(tx *types.Transaction, txType asset.TransactionType, amt uint64, tokenAssetID *uint32) *extendedWalletTx { + nonce := tx.Nonce() + rawTx, err := tx.MarshalBinary() + if err != nil { + w.log.Errorf("Error marshaling tx %q: %v", tx.Hash(), err) } + var recipient *string + if to := tx.To(); to != nil { + s := to.String() + recipient = &s + } + + now := time.Now() wt := &extendedWalletTx{ WalletTransaction: &asset.WalletTransaction{ Type: txType, - ID: txHash.String(), - Amount: amount, - Fees: fees, + ID: tx.Hash().String(), + Amount: amt, + Fees: dexeth.WeiToGweiCeil(transactionFeeLimit(tx)), // updated later TokenID: tokenAssetID, Recipient: recipient, AdditionalData: map[string]string{ txHistoryNonceKey: strconv.FormatUint(nonce, 10), }, }, - SubmissionTime: uint64(time.Now().Unix()), + SubmissionTime: uint64(now.Unix()), + RawTx: rawTx, + Nonce: big.NewInt(int64(nonce)), + txHash: tx.Hash(), savedToDB: true, + lastBroadcast: now, + lastFeeCheck: now, } - err := w.txDB.storeTx(nonce, wt) - if err != nil { - w.log.Errorf("error storing tx in db: %v", err) - wt.savedToDB = false - } - - w.pendingTxsMtx.Lock() - w.pendingTxs[nonce] = wt - w.pendingTxsMtx.Unlock() + w.tryStoreDBTx(wt) - w.emitTransactionNote(wt.WalletTransaction, true) + return wt } // TxHistory returns all the transactions the wallet has made. This @@ -4776,12 +5146,7 @@ func (w *baseWallet) addToTxHistory(nonce, amount, fees uint64, assetID uint32, // the transactions after the refID are returned. n is the number of // transactions to return. If n is <= 0, all the transactions will be returned. func (w *ETHWallet) TxHistory(n int, refID *string, past bool) ([]*asset.WalletTransaction, error) { - baseChainWallet := w.wallet(w.baseChainID) - if baseChainWallet == nil || !baseChainWallet.connected.Load() { - return nil, fmt.Errorf("wallet not connected") - } - - return w.txDB.getTxs(n, refID, past, nil) + return w.txHistory(n, refID, past, nil) } // TxHistory returns all the transactions the token wallet has made. If refID @@ -4791,16 +5156,22 @@ func (w *ETHWallet) TxHistory(n int, refID *string, past bool) ([]*asset.WalletT // number of transactions to return. If n is <= 0, all the transactions will be // returned. func (w *TokenWallet) TxHistory(n int, refID *string, past bool) ([]*asset.WalletTransaction, error) { - baseChainWallet := w.wallet(w.baseChainID) - if baseChainWallet == nil || !baseChainWallet.connected.Load() { - return nil, fmt.Errorf("wallet not connected") - } + return w.txHistory(n, refID, past, &w.assetID) +} - return w.txDB.getTxs(n, refID, past, &w.assetID) +func (w *baseWallet) txHistory(n int, refID *string, past bool, assetID *uint32) ([]*asset.WalletTransaction, error) { + var hashID *common.Hash + if refID != nil { + h := common.HexToHash(*refID) + if h == (common.Hash{}) { + return nil, fmt.Errorf("invalid reference ID %q provided", *refID) + } + hashID = &h + } + return w.txDB.getTxs(n, hashID, past, assetID) } -func (w *ETHWallet) getReceivingTransaction(ctx context.Context, txID string) (*asset.WalletTransaction, error) { - txHash := common.HexToHash(txID) +func (w *ETHWallet) getReceivingTransaction(ctx context.Context, txHash common.Hash) (*asset.WalletTransaction, error) { tx, blockHeight, err := w.node.getTransaction(ctx, txHash) if err != nil { return nil, err @@ -4810,56 +5181,26 @@ func (w *ETHWallet) getReceivingTransaction(ctx context.Context, txID string) (* return nil, asset.CoinNotFoundError } - w.tipMtx.RLock() - tipHeight := w.currentTip.Number.Uint64() - w.tipMtx.RUnlock() - - var confirmed bool - if blockHeight < 0 { - blockHeight = 0 - // TODO: check when this stops being pending - } else if tipHeight-txConfsNeededToConfirm+1 >= uint64(blockHeight) { - confirmed = true - } - - return &asset.WalletTransaction{ - Type: asset.Receive, - ID: txHash.String(), - Amount: dexeth.WeiToGwei(tx.Value()), - BlockNumber: uint64(blockHeight), - Confirmed: confirmed, - AdditionalData: map[string]string{ - txHistoryNonceKey: strconv.FormatUint(tx.Nonce(), 10), - }, - }, nil + wt := w.extendedTx(tx, asset.Receive, dexeth.WeiToGweiCeil(tx.Value())) + tip := int64(w.tipHeight()) + wt.Confirmed = blockHeight > 0 && (tip-blockHeight+1) >= int64(w.finalizeConfs) + return wt.WalletTransaction, nil } // WalletTransaction returns a transaction that either the wallet has made or // one in which the wallet has received funds. func (w *ETHWallet) WalletTransaction(ctx context.Context, txID string) (*asset.WalletTransaction, error) { txHash := common.HexToHash(txID) - txID = txHash.String() - txs, err := w.TxHistory(1, &txID, false) - if errors.Is(err, asset.CoinNotFoundError) { - return w.getReceivingTransaction(ctx, txID) - } - if err != nil { - return nil, err - } - if len(txs) == 0 { - return nil, asset.CoinNotFoundError - } - - tx := txs[0] - if tx.BlockNumber > 0 { - tx.Confirmed = true + var localTx asset.WalletTransaction + if w.withLocalTxRead(txHash, func(wt *extendedWalletTx) { + localTx = *wt.WalletTransaction + }) { + return &localTx, nil } - - return tx, nil + return w.getReceivingTransaction(ctx, txHash) } -func (w *TokenWallet) getReceivingTransaction(ctx context.Context, txID string) (*asset.WalletTransaction, error) { - txHash := common.HexToHash(txID) +func (w *TokenWallet) getReceivingTransaction(ctx context.Context, txHash common.Hash) (*asset.WalletTransaction, error) { tx, blockHeight, err := w.node.getTransaction(ctx, txHash) if err != nil { return nil, err @@ -4877,49 +5218,21 @@ func (w *TokenWallet) getReceivingTransaction(ctx context.Context, txID string) return nil, asset.CoinNotFoundError } - w.tipMtx.RLock() - tipHeight := w.currentTip.Number.Uint64() - w.tipMtx.RUnlock() - - var confirmed bool - if blockHeight < 0 { - blockHeight = 0 - // TODO: check when this stops being pending - } else if tipHeight-txConfsNeededToConfirm+1 >= uint64(blockHeight) { - confirmed = true - } - - return &asset.WalletTransaction{ - Type: asset.Receive, - ID: txID, - Amount: w.atomize(value), - BlockNumber: uint64(blockHeight), - Confirmed: confirmed, - AdditionalData: map[string]string{ - txHistoryNonceKey: strconv.FormatUint(tx.Nonce(), 10), - }, - }, nil + wt := w.extendedTx(tx, asset.Receive, w.atomize(value)) + tip := int64(w.tipHeight()) + wt.Confirmed = blockHeight > 0 && (tip-blockHeight+1) >= int64(w.finalizeConfs) + return wt.WalletTransaction, nil } func (w *TokenWallet) WalletTransaction(ctx context.Context, txID string) (*asset.WalletTransaction, error) { - txID = common.HexToHash(txID).String() - txs, err := w.TxHistory(1, &txID, false) - if errors.Is(err, asset.CoinNotFoundError) { - return w.getReceivingTransaction(ctx, txID) - } - if err != nil { - return nil, err - } - if len(txs) == 0 { - return nil, asset.CoinNotFoundError - } - - tx := txs[0] - if tx.BlockNumber > 0 { - tx.Confirmed = true + txHash := common.HexToHash(txID) + var localTx *extendedWalletTx + if w.withLocalTxRead(txHash, func(wt *extendedWalletTx) { + localTx = wt + }) { + return localTx.WalletTransaction, nil } - - return tx, nil + return w.getReceivingTransaction(ctx, txHash) } // providersFile reads a file located at ~/dextest/credentials.json. @@ -4983,7 +5296,7 @@ func quickNode(ctx context.Context, walletDir string, contractVer uint32, return nil, nil, fmt.Errorf("error creating initiator wallet: %v", err) } - cl, err := newMultiRPCClient(walletDir, providers, log, wParams.ChainCfg, net) + cl, err := newMultiRPCClient(walletDir, providers, log, wParams.ChainCfg, 3, net) if err != nil { return nil, nil, fmt.Errorf("error opening initiator rpc client: %v", err) } @@ -5034,7 +5347,7 @@ func waitForConfirmation(ctx context.Context, cl ethFetcher, txHash common.Hash) if err != nil { return fmt.Errorf("error getting best header: %w", err) } - ticker := time.NewTicker(blockTicker) + ticker := time.NewTicker(stateUpdateTick) defer ticker.Stop() for { select { @@ -5154,7 +5467,7 @@ func getGetGasClientWithEstimatesAndBalances(ctx context.Context, net dex.Networ return nil, nil, 0, 0, 0, nil, nil, fmt.Errorf("error getting eth balance: %v", err) } - feeRate = dexeth.WeiToGwei(new(big.Int).Add(tip, new(big.Int).Mul(base, big.NewInt(2)))) + feeRate = dexeth.WeiToGweiCeil(new(big.Int).Add(tip, new(big.Int).Mul(base, big.NewInt(2)))) // Check that we have a balance for swaps and fees. g := wParams.Gas @@ -5300,20 +5613,20 @@ func (getGas) Return( } defer cl.shutdown() - base, tip, err := cl.currentFees(ctx) + baseRate, tipRate, err := cl.currentFees(ctx) if err != nil { return fmt.Errorf("Error estimating fee rate: %v", err) } + maxFeeRate := new(big.Int).Add(tipRate, new(big.Int).Mul(baseRate, big.NewInt(2))) - recommendedFeeRate := new(big.Int).Add(tip, new(big.Int).Mul(base, big.NewInt(2))) - - return GetGas.returnFunds(ctx, cl, recommendedFeeRate, common.HexToAddress(returnAddr), wParams.Token, wParams.UnitInfo, log, net) + return GetGas.returnFunds(ctx, cl, maxFeeRate, tipRate, common.HexToAddress(returnAddr), wParams.Token, wParams.UnitInfo, log, net) } func (getGas) returnFunds( ctx context.Context, cl *multiRPCClient, - feeRate *big.Int, + maxFeeRate *big.Int, + tipRate *big.Int, returnAddr common.Address, token *dexeth.Token, // nil for base chain ui *dex.UnitInfo, @@ -5337,7 +5650,7 @@ func (getGas) returnFunds( g = sc.Gas break } - fees := g.Transfer * dexeth.WeiToGwei(feeRate) + fees := g.Transfer * dexeth.WeiToGweiCeil(maxFeeRate) if fees > ethBal { return fmt.Errorf("not enough base chain balance (%s) to cover fees (%s)", dexeth.UnitInfo.ConventionalString(ethBal), dexeth.UnitInfo.ConventionalString(fees)) @@ -5358,7 +5671,7 @@ func (getGas) returnFunds( return fmt.Errorf("error getting token balance: %w", err) } - txOpts, err := cl.txOpts(ctx, 0, g.Transfer, feeRate, nil) + txOpts, err := cl.txOpts(ctx, 0, g.Transfer, maxFeeRate, tipRate, nil) if err != nil { return fmt.Errorf("error generating tx opts: %w", err) } @@ -5371,9 +5684,9 @@ func (getGas) returnFunds( return nil } - bigFees := new(big.Int).Mul(new(big.Int).SetUint64(defaultSendGasLimit), feeRate) + bigFees := new(big.Int).Mul(new(big.Int).SetUint64(defaultSendGasLimit), maxFeeRate) - fees := dexeth.WeiToGwei(bigFees) + fees := dexeth.WeiToGweiCeil(bigFees) ethFmt := ui.ConventionalString if fees >= ethBal { @@ -5381,7 +5694,7 @@ func (getGas) returnFunds( } remainder := ethBal - fees - txOpts, err := cl.txOpts(ctx, remainder, defaultSendGasLimit, feeRate, nil) + txOpts, err := cl.txOpts(ctx, remainder, defaultSendGasLimit, maxFeeRate, tipRate, nil) if err != nil { return fmt.Errorf("error generating tx opts: %w", err) } @@ -5435,8 +5748,8 @@ func (getGas) Estimate(ctx context.Context, net dex.Network, assetID, contractVe bUnit := bui.Conventional.Unit ethFmt := bui.ConventionalString - log.Infof("%s balance: %s", bUnit, ethFmt(dexeth.WeiToGwei(ethBal))) atomicBal := dexeth.WeiToGwei(ethBal) + log.Infof("%s balance: %s", bUnit, ethFmt(atomicBal)) if atomicBal < ethReq { return fmt.Errorf("%s balance insufficient to get gas estimates. current: %[2]s, required ~ %[3]s %[1]s. send %[1]s to %[4]s", bUnit, ethFmt(atomicBal), ethFmt(ethReq*5/4), cl.address()) @@ -5475,7 +5788,7 @@ func (getGas) Estimate(ctx context.Context, net dex.Network, assetID, contractVe // we've reserved in our fee checks. Is it worth recovering unused // balance? feePreload := wParams.Gas.Approve * 2 * 6 / 5 * feeRate - txOpts, err := cl.txOpts(ctx, feePreload, defaultSendGasLimit, nil, nil) + txOpts, err := cl.txOpts(ctx, feePreload, defaultSendGasLimit, nil, nil, nil) if err != nil { return fmt.Errorf("error creating tx opts for sending fees for approval client: %v", err) } @@ -5540,6 +5853,11 @@ func getGasEstimates(ctx context.Context, cl, acl ethFetcher, c contractor, ac t return v * 13 / 10 } + baseRate, tipRate, err := cl.currentFees(ctx) + if err != nil { + return fmt.Errorf("error getting network fees: %v", err) + } + defer func() { if len(stats.swaps) == 0 { return @@ -5583,7 +5901,7 @@ func getGasEstimates(ctx context.Context, cl, acl ethFetcher, c contractor, ac t // Estimate approve for tokens. if isToken { sendApprove := func(cl ethFetcher, c tokenContractor) error { - txOpts, err := cl.txOpts(ctx, 0, g.Approve*2, nil, nil) + txOpts, err := cl.txOpts(ctx, 0, g.Approve*2, baseRate, tipRate, nil) if err != nil { return fmt.Errorf("error constructing signed tx opts for approve: %w", err) } @@ -5594,7 +5912,7 @@ func getGasEstimates(ctx context.Context, cl, acl ethFetcher, c contractor, ac t if err = waitForConfirmation(ctx, cl, tx.Hash()); err != nil { return fmt.Errorf("error waiting for approve transaction: %w", err) } - receipt, _, err := cl.transactionReceipt(ctx, tx.Hash()) + receipt, _, err := cl.transactionAndReceipt(ctx, tx.Hash()) if err != nil { return fmt.Errorf("error getting receipt for approve tx: %w", err) } @@ -5617,7 +5935,7 @@ func getGasEstimates(ctx context.Context, cl, acl ethFetcher, c contractor, ac t return fmt.Errorf("error sending approve transaction for the initiator: %w", err) } - txOpts, err := cl.txOpts(ctx, 0, g.Transfer*2, nil, nil) + txOpts, err := cl.txOpts(ctx, 0, g.Transfer*2, baseRate, tipRate, nil) if err != nil { return fmt.Errorf("error constructing signed tx opts for transfer: %w", err) } @@ -5633,7 +5951,7 @@ func getGasEstimates(ctx context.Context, cl, acl ethFetcher, c contractor, ac t if err = waitForConfirmation(ctx, cl, transferTx.Hash()); err != nil { return fmt.Errorf("error waiting for transfer tx: %w", err) } - receipt, _, err := cl.transactionReceipt(ctx, transferTx.Hash()) + receipt, _, err := cl.transactionAndReceipt(ctx, transferTx.Hash()) if err != nil { return fmt.Errorf("error getting tx receipt for transfer tx: %w", err) } @@ -5667,7 +5985,7 @@ func getGasEstimates(ctx context.Context, cl, acl ethFetcher, c contractor, ac t } // Send the inits - txOpts, err := cl.txOpts(ctx, optsVal, g.SwapN(n)*2, nil, nil) + txOpts, err := cl.txOpts(ctx, optsVal, g.SwapN(n)*2, baseRate, tipRate, nil) if err != nil { return fmt.Errorf("error constructing signed tx opts for %d swaps: %v", n, err) } @@ -5679,7 +5997,7 @@ func getGasEstimates(ctx context.Context, cl, acl ethFetcher, c contractor, ac t if err = waitForConfirmation(ctx, cl, tx.Hash()); err != nil { return fmt.Errorf("error waiting for init tx to be mined: %w", err) } - receipt, _, err := cl.transactionReceipt(ctx, tx.Hash()) + receipt, _, err := cl.transactionAndReceipt(ctx, tx.Hash()) if err != nil { return fmt.Errorf("error getting init tx receipt: %w", err) } @@ -5709,7 +6027,7 @@ func getGasEstimates(ctx context.Context, cl, acl ethFetcher, c contractor, ac t }) } - txOpts, err = cl.txOpts(ctx, 0, g.RedeemN(n)*2, nil, nil) + txOpts, err = cl.txOpts(ctx, 0, g.RedeemN(n)*2, baseRate, tipRate, nil) if err != nil { return fmt.Errorf("error constructing signed tx opts for %d redeems: %v", n, err) } @@ -5721,7 +6039,7 @@ func getGasEstimates(ctx context.Context, cl, acl ethFetcher, c contractor, ac t if err = waitForConfirmation(ctx, cl, tx.Hash()); err != nil { return fmt.Errorf("error waiting for redeem tx to be mined: %w", err) } - receipt, _, err = cl.transactionReceipt(ctx, tx.Hash()) + receipt, _, err = cl.transactionAndReceipt(ctx, tx.Hash()) if err != nil { return fmt.Errorf("error getting redeem tx receipt: %w", err) } diff --git a/client/asset/eth/eth_test.go b/client/asset/eth/eth_test.go index fe50dac909..561194a3fc 100644 --- a/client/asset/eth/eth_test.go +++ b/client/asset/eth/eth_test.go @@ -14,7 +14,6 @@ import ( "fmt" "math/big" "math/rand" - "path/filepath" "sort" "strings" "sync" @@ -36,6 +35,10 @@ import ( "github.com/ethereum/go-ethereum/params" ) +const ( + txConfsNeededToConfirm = 3 +) + var ( _ ethFetcher = (*testNode)(nil) tLogger = dex.StdOutLogger("ETHTEST", dex.LevelTrace) @@ -79,6 +82,8 @@ var ( SwapConf: 1, } + signer = types.LatestSigner(params.AllEthashProtocolChanges) + // simBackend = backends.NewSimulatedBackend(core.GenesisAlloc{ // testAddressA: core.GenesisAccount{Balance: dexeth.GweiToWei(5e10)}, // }, 1e9) @@ -122,6 +127,7 @@ type testNode struct { receiptErrs map[common.Hash]error hdrByHash *types.Header lastSignedTx *types.Transaction + sentTxs int sendTxTx *types.Transaction sendTxErr error simBackend bind.ContractBackend @@ -142,6 +148,21 @@ func newBalance(current, in, out uint64) *Balance { } } +func (n *testNode) newTransaction(nonce uint64, value *big.Int) *types.Transaction { + to := common.BytesToAddress(encode.RandomBytes(20)) + tx, err := types.SignTx(types.NewTx(&types.DynamicFeeTx{ + Nonce: nonce, + Value: value, + Gas: 50_000, + To: &to, + ChainID: n.chainConfig().ChainID, + }), signer, n.privKey) + if err != nil { + panic("tx signing error") + } + return tx +} + func (n *testNode) address() common.Address { return n.addr } @@ -178,7 +199,7 @@ func (n *testNode) getTransaction(ctx context.Context, hash common.Hash) (*types return n.getTxRes, n.getTxHeight, n.getTxErr } -func (n *testNode) txOpts(ctx context.Context, val, maxGas uint64, maxFeeRate, nonce *big.Int) (*bind.TransactOpts, error) { +func (n *testNode) txOpts(ctx context.Context, val, maxGas uint64, maxFeeRate, tipRate, nonce *big.Int) (*bind.TransactOpts, error) { if maxFeeRate == nil { maxFeeRate = n.maxFeeRate } @@ -231,10 +252,11 @@ func (n *testNode) signData(data []byte) (sig, pubKey []byte, err error) { return sig, crypto.FromECDSAPub(&n.privKey.PublicKey), nil } -func (n *testNode) sendTransaction(ctx context.Context, txOpts *bind.TransactOpts, to common.Address, data []byte) (*types.Transaction, error) { +func (n *testNode) sendTransaction(ctx context.Context, txOpts *bind.TransactOpts, to common.Address, data []byte, filts ...acceptabilityFilter) (*types.Transaction, error) { + n.sentTxs++ return n.sendTxTx, n.sendTxErr } -func (n *testNode) sendSignedTransaction(ctx context.Context, tx *types.Transaction) error { +func (n *testNode) sendSignedTransaction(ctx context.Context, tx *types.Transaction, filts ...acceptabilityFilter) error { n.lastSignedTx = tx return nil } @@ -261,7 +283,18 @@ func (n *testNode) headerByHash(_ context.Context, txHash common.Hash) (*types.H return n.hdrByHash, nil } -func (n *testNode) transactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, *types.Transaction, error) { +func (n *testNode) transactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, error) { + if n.receiptErr != nil { + return nil, n.receiptErr + } + if n.receipt != nil { + return n.receipt, nil + } + + return n.receipts[txHash], n.receiptErrs[txHash] +} + +func (n *testNode) transactionAndReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, *types.Transaction, error) { if n.receiptErr != nil { return nil, nil, n.receiptErr } @@ -272,6 +305,10 @@ func (n *testNode) transactionReceipt(ctx context.Context, txHash common.Hash) ( return n.receipts[txHash], n.receiptTxs[txHash], n.receiptErrs[txHash] } +func (n *testNode) nonce(ctx context.Context) (*big.Int, *big.Int, error) { + return big.NewInt(0), big.NewInt(1), nil +} + func (n *testNode) setBalanceError(w *assetWallet, err error) { n.balErr = err n.tokenContractor.balErr = err @@ -424,6 +461,8 @@ type tTxDB struct { storeTxErr error removeTxCalled bool removeTxErr error + txToGet *extendedWalletTx + getTxErr error } var _ txDB = (*tTxDB)(nil) @@ -431,8 +470,7 @@ var _ txDB = (*tTxDB)(nil) func (db *tTxDB) connect(ctx context.Context) (*sync.WaitGroup, error) { return &sync.WaitGroup{}, nil } - -func (db *tTxDB) storeTx(nonce uint64, wt *extendedWalletTx) error { +func (db *tTxDB) storeTx(wt *extendedWalletTx) error { db.storeTxCalled = true return db.storeTxErr } @@ -440,235 +478,472 @@ func (db *tTxDB) removeTx(id string) error { db.removeTxCalled = true return db.removeTxErr } -func (db *tTxDB) getTxs(n int, refID *string, past bool, tokenID *uint32) ([]*asset.WalletTransaction, error) { +func (db *tTxDB) getTxs(n int, refID *common.Hash, past bool, tokenID *uint32) ([]*asset.WalletTransaction, error) { return nil, nil } -func (db *tTxDB) getPendingTxs() (map[uint64]*extendedWalletTx, error) { - return nil, nil + +// getTx gets a single transaction. It is not an error if the tx is not known. +// In that case, a nil tx is returned. +func (db *tTxDB) getTx(txHash common.Hash) (tx *extendedWalletTx, _ error) { + return db.txToGet, db.getTxErr } -func (db *tTxDB) getMonitoredTxs() (map[common.Hash]*monitoredTx, error) { +func (db *tTxDB) getPendingTxs() ([]*extendedWalletTx, error) { return nil, nil } -func (db *tTxDB) storeMonitoredTx(txHash common.Hash, monitoredTx *monitoredTx) error { - return nil -} -func (db *tTxDB) removeMonitoredTxs(txHash []common.Hash) error { - return nil -} func (db *tTxDB) close() error { return nil } func (db *tTxDB) run(context.Context) {} -func TestCheckUnconfirmedTxs(t *testing.T) { - const tipHeight = 50 - const baseFeeGwei = 100 - const gasTipCapGwei = 2 +// func TestCheckUnconfirmedTxs(t *testing.T) { +// const tipHeight = 50 +// const baseFeeGwei = 100 +// const gasTipCapGwei = 2 + +// type tExtendedWalletTx struct { +// wt *extendedWalletTx +// confs uint32 +// gasUsed uint64 +// txReceiptErr error +// } + +// newExtendedWalletTx := func(assetID uint32, nonce int64, maxFees uint64, currBlockNumber uint64, txReceiptConfs uint32, +// txReceiptGasUsed uint64, txReceiptErr error, timeStamp int64, savedToDB bool) *tExtendedWalletTx { +// var tokenID *uint32 +// if assetID != BipID { +// tokenID = &assetID +// } + +// return &tExtendedWalletTx{ +// wt: &extendedWalletTx{ +// WalletTransaction: &asset.WalletTransaction{ +// BlockNumber: currBlockNumber, +// TokenID: tokenID, +// Fees: maxFees, +// }, +// SubmissionTime: uint64(timeStamp), +// nonce: big.NewInt(nonce), +// savedToDB: savedToDB, +// }, +// confs: txReceiptConfs, +// gasUsed: txReceiptGasUsed, +// txReceiptErr: txReceiptErr, +// } +// } + +// gasFee := func(gasUsed uint64) uint64 { +// return gasUsed * (baseFeeGwei + gasTipCapGwei) +// } + +// now := time.Now().Unix() + +// tests := []struct { +// name string +// assetID uint32 +// unconfirmedTxs []*tExtendedWalletTx +// confirmedNonce uint64 + +// expTxsAfter []*extendedWalletTx +// expStoreTxCalled bool +// expRemoveTxCalled bool +// storeTxErr error +// removeTxErr error +// }{ +// { +// name: "coin not found", +// assetID: BipID, +// unconfirmedTxs: []*tExtendedWalletTx{ +// newExtendedWalletTx(BipID, 5, 1e7, 0, 0, 0, asset.CoinNotFoundError, now, true), +// }, +// expTxsAfter: []*extendedWalletTx{ +// newExtendedWalletTx(BipID, 5, 1e7, 0, 0, 0, asset.CoinNotFoundError, now, true).wt, +// }, +// }, +// { +// name: "still in mempool", +// assetID: BipID, +// unconfirmedTxs: []*tExtendedWalletTx{ +// newExtendedWalletTx(BipID, 5, 1e7, 0, 0, 0, nil, now, true), +// }, +// expTxsAfter: []*extendedWalletTx{ +// newExtendedWalletTx(BipID, 5, 1e7, 0, 0, 0, nil, now, true).wt, +// }, +// }, +// { +// name: "1 confirmation", +// assetID: BipID, +// unconfirmedTxs: []*tExtendedWalletTx{ +// newExtendedWalletTx(BipID, 5, 1e7, 0, 1, 6e5, nil, now, true), +// }, +// expTxsAfter: []*extendedWalletTx{ +// newExtendedWalletTx(BipID, 5, gasFee(6e5), tipHeight, 0, 0, nil, now, true).wt, +// }, +// expStoreTxCalled: true, +// }, +// { +// name: "3 confirmations", +// assetID: BipID, +// unconfirmedTxs: []*tExtendedWalletTx{ +// newExtendedWalletTx(BipID, 1, gasFee(6e5), tipHeight, 3, 6e5, nil, now, true), +// }, +// expTxsAfter: []*extendedWalletTx{}, +// expStoreTxCalled: true, +// }, +// { +// name: "3 confirmations, leave in unconfirmed txs if txDB.storeTx fails", +// assetID: BipID, +// unconfirmedTxs: []*tExtendedWalletTx{ +// newExtendedWalletTx(BipID, 5, gasFee(6e5), tipHeight-2, 3, 6e5, nil, now, true), +// }, +// expTxsAfter: []*extendedWalletTx{ +// newExtendedWalletTx(BipID, 5, gasFee(6e5), tipHeight-2, 3, 6e5, nil, now, true).wt, +// }, +// expStoreTxCalled: true, +// storeTxErr: errors.New("test error"), +// }, +// { +// name: "was confirmed but now not found", +// assetID: BipID, +// unconfirmedTxs: []*tExtendedWalletTx{ +// newExtendedWalletTx(BipID, 5, 1e7, tipHeight-1, 0, 0, asset.CoinNotFoundError, now, true), +// }, +// expTxsAfter: []*extendedWalletTx{ +// newExtendedWalletTx(BipID, 5, 1e7, 0, 0, 0, asset.CoinNotFoundError, now, true).wt, +// }, +// expStoreTxCalled: true, +// }, +// } + +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// _, eth, node, shutdown := tassetWallet(tt.assetID) +// defer shutdown() + +// node.tokenContractor.bal = unlimitedAllowance +// node.receipts = make(map[common.Hash]*types.Receipt) +// node.receiptTxs = make(map[common.Hash]*types.Transaction) +// node.receiptErrs = make(map[common.Hash]error) +// node.hdrByHash = &types.Header{ +// BaseFee: dexeth.GweiToWei(baseFeeGwei), +// } +// node.confNonce = tt.confirmedNonce +// eth.connected.Store(true) +// eth.tipMtx.Lock() +// eth.currentTip = &types.Header{Number: new(big.Int).SetUint64(tipHeight)} +// eth.tipMtx.Unlock() + +// txDB := &tTxDB{ +// storeTxErr: tt.storeTxErr, +// removeTxErr: tt.removeTxErr, +// } +// eth.txDB = txDB + +// for _, pt := range tt.unconfirmedTxs { +// txHash := common.BytesToHash(encode.RandomBytes(32)) +// pt.wt.ID = txHash.String() +// pt.wt.txHash = txHash +// eth.pendingTxs = append(eth.pendingTxs, pt.wt) +// var blockNumber *big.Int +// if pt.confs > 0 { +// blockNumber = big.NewInt(int64(tipHeight - pt.confs + 1)) +// } +// node.receipts[txHash] = &types.Receipt{BlockNumber: blockNumber, GasUsed: pt.gasUsed} + +// node.receiptTxs[txHash] = types.NewTx(&types.DynamicFeeTx{ +// GasTipCap: dexeth.GweiToWei(gasTipCapGwei), +// GasFeeCap: dexeth.GweiToWei(2 * baseFeeGwei), +// }) +// node.receiptErrs[txHash] = pt.txReceiptErr +// } + +// eth.checkPendingTxs() + +// if len(eth.pendingTxs) != len(tt.expTxsAfter) { +// t.Fatalf("expected %d unconfirmed txs, got %d", len(tt.expTxsAfter), len(eth.pendingTxs)) +// } +// for i, expTx := range tt.expTxsAfter { +// tx := eth.pendingTxs[i] +// if expTx.nonce.Cmp(tx.nonce) != 0 { +// t.Fatalf("expected tx index %d to have nonce %d, not %d", i, expTx.nonce, tx.nonce) +// } +// } + +// if txDB.storeTxCalled != tt.expStoreTxCalled { +// t.Fatalf("expected storeTx called %v, got %v", tt.expStoreTxCalled, txDB.storeTxCalled) +// } +// if txDB.removeTxCalled != tt.expRemoveTxCalled { +// t.Fatalf("expected removeTx called %v, got %v", tt.expRemoveTxCalled, txDB.removeTxCalled) +// } +// }) +// } +// } + +func TestCheckPendingTxs(t *testing.T) { + _, eth, node, shutdown := tassetWallet(BipID) + defer shutdown() - type tExtendedWalletTx struct { - wt *extendedWalletTx - confs uint32 - gasUsed uint64 - txReceiptErr error + const tip = 12552 + const finalized = tip - txConfsNeededToConfirm + 1 + now := uint64(time.Now().Unix()) + finalizedStamp := now - txConfsNeededToConfirm*10 + rebroadcastable := now - 300 + mature := now - 600 // able to send actions + agedOut := now - uint64(txAgeOut.Seconds()) - 1 + + val := dexeth.GweiToWei(1) + extendedTx := func(nonce, blockNum, blockStamp, submissionStamp uint64) *extendedWalletTx { + pendingTx := eth.extendedTx(node.newTransaction(nonce, val), asset.Send, 1) + pendingTx.BlockNumber = blockNum + pendingTx.Confirmed = blockNum > 0 && blockNum <= finalized + pendingTx.Timestamp = blockStamp + pendingTx.SubmissionTime = submissionStamp + pendingTx.lastBroadcast = time.Unix(int64(submissionStamp), 0) + pendingTx.lastFeeCheck = time.Unix(int64(submissionStamp), 0) + return pendingTx } - newExtendedWalletTx := func(assetID uint32, maxFees uint64, currBlockNumber uint64, txReceiptConfs uint32, - txReceiptGasUsed uint64, txReceiptErr error, timeStamp int64, savedToDB bool) *tExtendedWalletTx { - var tokenID *uint32 - if assetID != BipID { - tokenID = &assetID + newReceipt := func(confs uint64) *types.Receipt { + r := &types.Receipt{ + EffectiveGasPrice: big.NewInt(1), } - - return &tExtendedWalletTx{ - wt: &extendedWalletTx{ - WalletTransaction: &asset.WalletTransaction{ - BlockNumber: currBlockNumber, - TokenID: tokenID, - Fees: maxFees, - }, - SubmissionTime: uint64(timeStamp), - savedToDB: savedToDB, - }, - confs: txReceiptConfs, - gasUsed: txReceiptGasUsed, - txReceiptErr: txReceiptErr, + if confs > 0 { + r.BlockNumber = big.NewInt(int64(tip - confs + 1)) } + return r } - gasFee := func(gasUsed uint64) uint64 { - return gasUsed * (baseFeeGwei + gasTipCapGwei) - } + emitChan := make(chan asset.WalletNotification, 128) + eth.emit = asset.NewWalletEmitter(emitChan, BipID, eth.log) - now := time.Now().Unix() + getAction := func(t *testing.T) string { + for { + select { + case ni := <-emitChan: + if n, ok := ni.(*asset.ActionRequiredNote); ok { + return n.ActionID + } + default: + t.Fatalf("no ActionRequiredNote found") + } + } + } - tests := []struct { - name string - assetID uint32 - unconfirmedTxs map[uint64]*tExtendedWalletTx - confirmedNonce uint64 - - expTxsAfter map[uint64]*extendedWalletTx - expStoreTxCalled bool - expRemoveTxCalled bool - storeTxErr error - removeTxErr error + for _, tt := range []*struct { + name string + dbErr error + pendingTxs []*extendedWalletTx + receipts []*types.Receipt + receiptErrs []error + txs []bool + noncesAfter []uint64 + actionID string + recast bool }{ { - name: "coin not found", - assetID: BipID, - unconfirmedTxs: map[uint64]*tExtendedWalletTx{ - 1: newExtendedWalletTx(BipID, 1e7, 0, 0, 0, asset.CoinNotFoundError, now, true), - }, - expTxsAfter: map[uint64]*extendedWalletTx{ - 1: newExtendedWalletTx(BipID, 1e7, 0, 0, 0, asset.CoinNotFoundError, now, true).wt, - }, - }, - { - name: "not nonce replaced, but still cannot find after 7 mins", - assetID: BipID, - unconfirmedTxs: map[uint64]*tExtendedWalletTx{ - 1: newExtendedWalletTx(BipID, 1e7, 0, 0, 0, asset.CoinNotFoundError, now-(7*60+1), true), - }, - confirmedNonce: 0, - expTxsAfter: map[uint64]*extendedWalletTx{}, - expRemoveTxCalled: true, - }, - { - name: "leave in unconfirmed txs if txDB.removeTx fails", - assetID: BipID, - unconfirmedTxs: map[uint64]*tExtendedWalletTx{ - 1: newExtendedWalletTx(BipID, 1e7, 0, 0, 0, asset.CoinNotFoundError, now-(7*60+1), true), - }, - confirmedNonce: 1, - expTxsAfter: map[uint64]*extendedWalletTx{ - 1: newExtendedWalletTx(BipID, 1e7, 0, 0, 0, asset.CoinNotFoundError, now-(7*60+1), true).wt, - }, - removeTxErr: errors.New(""), - expRemoveTxCalled: true, - }, - { - name: "still in mempool", - assetID: BipID, - unconfirmedTxs: map[uint64]*tExtendedWalletTx{ - 1: newExtendedWalletTx(BipID, 1e7, 0, 0, 0, nil, now, true), - }, - expTxsAfter: map[uint64]*extendedWalletTx{ - 1: newExtendedWalletTx(BipID, 1e7, 0, 0, 0, nil, now, true).wt, + name: "first of two is confirmed", + pendingTxs: []*extendedWalletTx{ + extendedTx(0, finalized, finalizedStamp, finalizedStamp), + extendedTx(1, finalized+1, finalizedStamp, finalizedStamp), // won't even be checked. }, + noncesAfter: []uint64{1}, }, { - name: "1 confirmation", - assetID: BipID, - unconfirmedTxs: map[uint64]*tExtendedWalletTx{ - 1: newExtendedWalletTx(BipID, 1e7, 0, 1, 6e5, nil, now, true), + name: "second one is confirmed first", + pendingTxs: []*extendedWalletTx{ + extendedTx(2, 0, 0, rebroadcastable), + extendedTx(3, finalized, finalizedStamp, finalizedStamp), }, - expTxsAfter: map[uint64]*extendedWalletTx{ - 1: newExtendedWalletTx(BipID, gasFee(6e5), tipHeight, 0, 0, nil, now, true).wt, - }, - expStoreTxCalled: true, + noncesAfter: []uint64{2, 3}, + receipts: []*types.Receipt{nil, nil}, + txs: []bool{false, true}, + receiptErrs: []error{asset.CoinNotFoundError, nil}, + actionID: actionTypeLostNonce, }, { - name: "3 confirmations", - assetID: BipID, - unconfirmedTxs: map[uint64]*tExtendedWalletTx{ - 1: newExtendedWalletTx(BipID, gasFee(6e5), tipHeight, 3, 6e5, nil, now, true), + name: "confirm one with receipt", + pendingTxs: []*extendedWalletTx{ + extendedTx(4, 0, 0, finalizedStamp), }, - expTxsAfter: map[uint64]*extendedWalletTx{}, - expStoreTxCalled: true, + receipts: []*types.Receipt{newReceipt(txConfsNeededToConfirm)}, }, { - name: "3 confirmations, leave in unconfirmed txs if txDB.storeTx fails", - assetID: BipID, - unconfirmedTxs: map[uint64]*tExtendedWalletTx{ - 1: newExtendedWalletTx(BipID, gasFee(6e5), tipHeight-2, 3, 6e5, nil, now, true), + name: "old and unindexed", + pendingTxs: []*extendedWalletTx{ + extendedTx(5, 0, 0, agedOut), }, - expTxsAfter: map[uint64]*extendedWalletTx{ - 1: newExtendedWalletTx(BipID, gasFee(6e5), tipHeight-2, 3, 6e5, nil, now, true).wt, - }, - expStoreTxCalled: true, - storeTxErr: errors.New(""), + noncesAfter: []uint64{5}, + receipts: []*types.Receipt{nil}, + receiptErrs: []error{asset.CoinNotFoundError}, + txs: []bool{false}, + actionID: actionTypeLostNonce, }, { - name: "was confirmed but now not found", - assetID: BipID, - unconfirmedTxs: map[uint64]*tExtendedWalletTx{ - 1: newExtendedWalletTx(BipID, 1e7, tipHeight-1, 0, 0, asset.CoinNotFoundError, now, true), + name: "mature and indexed, low fees", + pendingTxs: []*extendedWalletTx{ + extendedTx(6, 0, 0, mature), }, - expTxsAfter: map[uint64]*extendedWalletTx{ - 1: newExtendedWalletTx(BipID, 1e7, 0, 0, 0, asset.CoinNotFoundError, now, true).wt, + noncesAfter: []uint64{6}, + receipts: []*types.Receipt{newReceipt(0)}, + actionID: actionTypeTooCheap, + }, { + name: "missing nonces", + pendingTxs: []*extendedWalletTx{ + extendedTx(8, finalized, finalizedStamp, finalizedStamp), + extendedTx(11, finalized+1, finalizedStamp, finalizedStamp), }, - expStoreTxCalled: true, + noncesAfter: []uint64{11}, + actionID: actionTypeMissingNonces, }, - } - - for _, tt := range tests { + } { t.Run(tt.name, func(t *testing.T) { - _, eth, node, shutdown := tassetWallet(tt.assetID) - defer shutdown() - - node.tokenContractor.bal = unlimitedAllowance - node.receipts = make(map[common.Hash]*types.Receipt) - node.receiptTxs = make(map[common.Hash]*types.Transaction) - node.receiptErrs = make(map[common.Hash]error) - node.hdrByHash = &types.Header{ - BaseFee: dexeth.GweiToWei(baseFeeGwei), - } - node.confNonce = tt.confirmedNonce - eth.connected.Store(true) - eth.tipMtx.Lock() - eth.currentTip = &types.Header{Number: new(big.Int).SetUint64(tipHeight)} - eth.tipMtx.Unlock() - - txDB := &tTxDB{ - storeTxErr: tt.storeTxErr, - removeTxErr: tt.removeTxErr, - } - eth.txDB = txDB - - for nonce, pt := range tt.unconfirmedTxs { - txHash := common.BytesToHash(encode.RandomBytes(32)) - pt.wt.ID = txHash.String() - eth.pendingTxs[nonce] = pt.wt - var blockNumber *big.Int - if pt.confs > 0 { - blockNumber = big.NewInt(int64(tipHeight - pt.confs + 1)) + eth.confirmedNonceAt = tt.pendingTxs[0].Nonce + eth.pendingNonceAt = new(big.Int).Add(tt.pendingTxs[len(tt.pendingTxs)-1].Nonce, big.NewInt(1)) + + node.lastSignedTx = nil + eth.currentTip = &types.Header{Number: new(big.Int).SetUint64(tip)} + eth.pendingTxs = tt.pendingTxs + for i, r := range tt.receipts { + pendingTx := tt.pendingTxs[i] + if tt.receiptErrs != nil { + node.receiptErrs[pendingTx.txHash] = tt.receiptErrs[i] + } + if r == nil { + continue + } + node.receipts[pendingTx.txHash] = r + if len(tt.txs) < i+1 || tt.txs[i] { + node.receiptTxs[pendingTx.txHash], _ = pendingTx.tx() + } + if pendingTx.Timestamp == 0 && r.BlockNumber != nil && r.BlockNumber.Uint64() != 0 { + node.hdrByHash = &types.Header{ + Number: r.BlockNumber, + Time: now, + } } - node.receipts[txHash] = &types.Receipt{BlockNumber: blockNumber, GasUsed: pt.gasUsed} - node.receiptTxs[txHash] = types.NewTx(&types.DynamicFeeTx{ - GasTipCap: dexeth.GweiToWei(gasTipCapGwei), - GasFeeCap: dexeth.GweiToWei(2 * baseFeeGwei), - }) - node.receiptErrs[txHash] = pt.txReceiptErr } - eth.checkPendingTxs() - - if len(eth.pendingTxs) != len(tt.expTxsAfter) { - t.Fatalf("expected %d unconfirmed txs, got %d", len(tt.expTxsAfter), len(eth.pendingTxs)) + if len(eth.pendingTxs) != len(tt.noncesAfter) { + t.Fatalf("wrong number of pending txs. expected %d got %d", len(tt.noncesAfter), len(eth.pendingTxs)) } - for nonce, expTx := range tt.expTxsAfter { - if tx, ok := eth.pendingTxs[nonce]; !ok { - t.Fatalf("expected unconfirmed tx with nonce %d", nonce) - } else { - if tx.Fees != expTx.Fees { - t.Fatalf("expected fees %d, got %d", expTx.Fees, tx.Fees) - } - if tx.BlockNumber != expTx.BlockNumber { - t.Fatalf("expected block number %d, got %d", expTx.BlockNumber, tx.BlockNumber) - } + for i, pendingTx := range eth.pendingTxs { + if pendingTx.Nonce.Uint64() != tt.noncesAfter[i] { + t.Fatalf("Expected nonce %d at index %d, but got nonce %s", tt.noncesAfter[i], i, pendingTx.Nonce) } } - - if txDB.storeTxCalled != tt.expStoreTxCalled { - t.Fatalf("expected storeTx called %v, got %v", tt.expStoreTxCalled, txDB.storeTxCalled) + if tt.actionID != "" { + if actionID := getAction(t); actionID != tt.actionID { + t.Fatalf("expected action %s, got %s", tt.actionID, actionID) + } } - if txDB.removeTxCalled != tt.expRemoveTxCalled { - t.Fatalf("expected removeTx called %v, got %v", tt.expRemoveTxCalled, txDB.removeTxCalled) + if tt.recast != (node.lastSignedTx != nil) { + t.Fatalf("wrong recast result recast = %t, lastSignedTx = %t", tt.recast, node.lastSignedTx != nil) } }) } } +func TestTakeAction(t *testing.T) { + _, eth, node, shutdown := tassetWallet(BipID) + defer shutdown() + + aGwei := dexeth.GweiToWei(1) + + pendingTx := eth.extendedTx(node.newTransaction(0, aGwei), asset.Send, 1) + eth.pendingTxs = []*extendedWalletTx{pendingTx} + + feeCap := new(big.Int).Mul(aGwei, big.NewInt(5)) + tipCap := new(big.Int).Mul(aGwei, big.NewInt(2)) + replacementTx, _ := types.SignTx(types.NewTx(&types.DynamicFeeTx{ + Nonce: 1, + GasTipCap: tipCap, + GasFeeCap: feeCap, + Gas: 50_000, + ChainID: node.chainConfig().ChainID, + }), signer, node.privKey) + node.sendTxTx = replacementTx + + tooCheapAction := []byte(fmt.Sprintf(`{"txID":"%s","bump":true}`, pendingTx.ID)) + if err := eth.TakeAction(actionTypeTooCheap, tooCheapAction); err != nil { + t.Fatalf("TakeAction error: %v", err) + } + + newPendingTx := eth.pendingTxs[0] + if pendingTx.txHash == newPendingTx.txHash { + t.Fatal("tx wasn't replaced") + } + tx, _ := newPendingTx.tx() + if tx.GasFeeCap().Cmp(feeCap) != 0 { + t.Fatalf("wrong fee cap. wanted %s, got %s", feeCap, tx.GasFeeCap()) + } + if tx.GasTipCap().Cmp(tipCap) != 0 { + t.Fatalf("wrong tip cap. wanted %s, got %s", tipCap, tx.GasTipCap()) + } + if !newPendingTx.savedToDB { + t.Fatal("didn't save to DB") + } + + pendingTx = eth.extendedTx(node.newTransaction(1, aGwei), asset.Send, 1) + eth.pendingTxs = []*extendedWalletTx{pendingTx} + pendingTx.SubmissionTime = 0 + // Neglecting to bump should reset submission time. + tooCheapAction = []byte(fmt.Sprintf(`{"txID":"%s","bump":false}`, pendingTx.ID)) + if err := eth.TakeAction(actionTypeTooCheap, tooCheapAction); err != nil { + t.Fatalf("TakeAction bump=false error: %v", err) + } + tx, _ = pendingTx.tx() + if tx.GasTipCap().Uint64() != 0 { + t.Fatal("The fee was bumped. The fee shouldn't have been bumped.") + } + if pendingTx.actionIgnored.IsZero() { + t.Fatalf("The ignore time wasn't reset") + } + if len(eth.pendingTxs) != 1 { + t.Fatalf("Tx was removed") + } + + // Nonce-replaced tx + eth.pendingTxs = []*extendedWalletTx{pendingTx} + lostNonceAction := []byte(fmt.Sprintf(`{"txID":"%s","abandon":true}`, pendingTx.ID)) + if err := eth.TakeAction(actionTypeLostNonce, lostNonceAction); err != nil { + t.Fatalf("TakeAction replacment=false, abandon=true error: %v", err) + } + if len(eth.pendingTxs) != 0 { + t.Fatalf("Tx wasn't abandoned") + } + eth.pendingTxs = []*extendedWalletTx{pendingTx} + node.getTxRes = replacementTx + lostNonceAction = []byte(fmt.Sprintf(`{"txID":"%s","abandon":false,"replacementID":"%s"}`, pendingTx.ID, replacementTx.Hash())) + if err := eth.TakeAction(actionTypeLostNonce, lostNonceAction); err != nil { + t.Fatalf("TakeAction replacment=true, error: %v", err) + } + newPendingTx = eth.pendingTxs[0] + if newPendingTx.txHash != replacementTx.Hash() { + t.Fatalf("replacement tx wasn't accepted") + } + // wrong nonce is an error though + pendingTx = eth.extendedTx(node.newTransaction(5050, aGwei), asset.Send, 1) + eth.pendingTxs = []*extendedWalletTx{pendingTx} + lostNonceAction = []byte(fmt.Sprintf(`{"txID":"%s","abandon":false,"replacementID":"%s"}`, pendingTx.ID, replacementTx.Hash())) + if err := eth.TakeAction(actionTypeLostNonce, lostNonceAction); err == nil { + t.Fatalf("no error for wrong nonce") + } + + // Missing nonces + tx5 := eth.extendedTx(node.newTransaction(5, aGwei), asset.Send, 1) + eth.pendingTxs = []*extendedWalletTx{tx5} + eth.confirmedNonceAt = big.NewInt(2) + eth.pendingNonceAt = big.NewInt(6) + nonceRecoveryAction := []byte(`{"recover":true}`) + node.sentTxs = 0 + if err := eth.TakeAction(actionTypeMissingNonces, nonceRecoveryAction); err != nil { + t.Fatalf("error for nonce recover: %v", err) + } + if node.sentTxs != 3 { + t.Fatalf("expected 2 new txs. saw %d", node.sentTxs) + } + +} + func TestCheckForNewBlocks(t *testing.T) { header0 := &types.Header{Number: new(big.Int)} header1 := &types.Header{Number: big.NewInt(1)} @@ -697,12 +972,15 @@ func TestCheckForNewBlocks(t *testing.T) { w := ÐWallet{ assetWallet: &assetWallet{ baseWallet: &baseWallet{ - node: node, - addr: node.address(), - ctx: ctx, - log: tLogger, - currentTip: header0, - txDB: &tTxDB{}, + node: node, + addr: node.address(), + ctx: ctx, + log: tLogger, + currentTip: header0, + confirmedNonceAt: new(big.Int), + pendingNonceAt: new(big.Int), + txDB: &tTxDB{}, + finalizeConfs: txConfsNeededToConfirm, }, log: tLogger.SubLogger("ETH"), emit: emit, @@ -775,7 +1053,7 @@ func TestSyncStatus(t *testing.T) { CurrentBlock: 25, HighestBlock: 0, }, - syncProgErr: errors.New(""), + syncProgErr: errors.New("test error"), wantErr: true, }} @@ -788,10 +1066,11 @@ func TestSyncStatus(t *testing.T) { syncProgErr: test.syncProgErr, } eth := &baseWallet{ - node: node, - addr: node.address(), - ctx: ctx, - log: tLogger, + node: node, + addr: node.address(), + ctx: ctx, + log: tLogger, + finalizeConfs: txConfsNeededToConfirm, } synced, ratio, err := eth.SyncStatus() cancel() @@ -849,6 +1128,11 @@ func newTestNode(assetID uint32) *tMempoolNode { contractor: c, tContractor: tc, tokenContractor: ttc, + txConfirmations: make(map[common.Hash]uint32), + txConfsErr: make(map[common.Hash]error), + receipts: make(map[common.Hash]*types.Receipt), + receiptErrs: make(map[common.Hash]error), + receiptTxs: make(map[common.Hash]*types.Transaction), }, } } @@ -873,7 +1157,7 @@ func tassetWallet(assetID uint32) (asset.Wallet, *assetWallet, *tMempoolNode, co } } - emitChan := make(chan asset.WalletNotification, 8) + emitChan := make(chan asset.WalletNotification, 128) go func() { for { select { @@ -886,19 +1170,21 @@ func tassetWallet(assetID uint32) (asset.Wallet, *assetWallet, *tMempoolNode, co aw := &assetWallet{ baseWallet: &baseWallet{ - baseChainID: BipID, - chainID: dexeth.ChainIDs[dex.Simnet], - tokens: dexeth.Tokens, - addr: node.addr, - net: dex.Simnet, - node: node, - ctx: ctx, - log: tLogger, - gasFeeLimitV: defaultGasFeeLimit, - monitoredTxs: make(map[common.Hash]*monitoredTx), - pendingTxs: make(map[uint64]*extendedWalletTx), - txDB: &tTxDB{}, - currentTip: &types.Header{Number: new(big.Int)}, + baseChainID: BipID, + chainID: dexeth.ChainIDs[dex.Simnet], + tokens: dexeth.Tokens, + addr: node.addr, + net: dex.Simnet, + node: node, + ctx: ctx, + log: tLogger, + gasFeeLimitV: defaultGasFeeLimit, + pendingNonceAt: new(big.Int), + confirmedNonceAt: new(big.Int), + pendingTxs: make([]*extendedWalletTx, 0), + txDB: &tTxDB{}, + currentTip: &types.Header{Number: new(big.Int)}, + finalizeConfs: txConfsNeededToConfirm, }, versionedGases: versionedGases, maxSwapGas: versionedGases[0].Swap, @@ -1033,7 +1319,7 @@ func TestBalanceWithMempool(t *testing.T) { // }, { name: "node balance error", bal: newBalance(0, 0, 0), - balErr: errors.New(""), + balErr: errors.New("test error"), wantErr: true, }} @@ -1057,15 +1343,7 @@ func TestBalanceWithMempool(t *testing.T) { var nonce uint64 newTx := func(value *big.Int) *types.Transaction { nonce++ - signer := types.LatestSignerForChainID(node.chainConfig().ChainID) - tx, err := types.SignTx(types.NewTx(&types.DynamicFeeTx{ - Nonce: nonce, - Value: value, - }), signer, node.privKey) - if err != nil { - t.Fatalf("SignTx error: %v", err) - } - return tx + return node.newTransaction(nonce, value) } if test.bal.PendingIn.Cmp(new(big.Int)) > 0 { @@ -1106,148 +1384,103 @@ func TestBalanceWithMempool(t *testing.T) { } func TestBalanceNoMempool(t *testing.T) { - const tipHeight = 50 - const lastCheck = tipHeight - 1 - - type tExtendedWalletTx struct { - wt *extendedWalletTx - confs uint32 - } + var nonceCounter int64 - newExtendedWalletTx := func(assetID uint32, amt, maxFees uint64, currBlockNumber uint64, txReceiptConfs uint32, txType asset.TransactionType) *tExtendedWalletTx { + newExtendedWalletTx := func(assetID uint32, amt, maxFees, blockNum uint64, txType asset.TransactionType) *extendedWalletTx { var tokenID *uint32 if assetID != BipID { tokenID = &assetID } + txHash := common.BytesToHash(encode.RandomBytes(32)) - return &tExtendedWalletTx{ - wt: &extendedWalletTx{ - WalletTransaction: &asset.WalletTransaction{ - Type: txType, - Amount: amt, - BlockNumber: 0, - TokenID: tokenID, - Fees: maxFees, - }, - lastCheck: lastCheck, + et := &extendedWalletTx{ + WalletTransaction: &asset.WalletTransaction{ + ID: txHash.String(), + Type: txType, + Amount: amt, + BlockNumber: blockNum, + TokenID: tokenID, + Fees: maxFees, }, - confs: txReceiptConfs, + Nonce: big.NewInt(nonceCounter), + txHash: txHash, } + nonceCounter++ + + return et } tests := []struct { name string assetID uint32 - unconfirmedTxs map[uint64]*tExtendedWalletTx + unconfirmedTxs []*extendedWalletTx expPendingIn uint64 expPendingOut uint64 - expCountAfter int }{ { name: "single eth tx", assetID: BipID, - unconfirmedTxs: map[uint64]*tExtendedWalletTx{ - 0: newExtendedWalletTx(BipID, 1, 2, 0, 0, asset.Send), + unconfirmedTxs: []*extendedWalletTx{ + newExtendedWalletTx(BipID, 1, 2, 0, asset.Send), }, expPendingOut: 3, - expCountAfter: 1, - }, - { - name: "single tx expired", - assetID: BipID, - unconfirmedTxs: map[uint64]*tExtendedWalletTx{ - 0: newExtendedWalletTx(BipID, 1, 1, 0, 1, asset.Send), - }, - expCountAfter: 1, - }, - { - name: "single tx expired, txConfsNeededToConfirm confs", - assetID: BipID, - unconfirmedTxs: map[uint64]*tExtendedWalletTx{ - 0: newExtendedWalletTx(BipID, 1, 1, 0, txConfsNeededToConfirm, asset.Send), - }, }, { name: "eth with token fees", assetID: BipID, - unconfirmedTxs: map[uint64]*tExtendedWalletTx{ - 0: newExtendedWalletTx(usdcTokenID, 4, 5, 0, 0, asset.Send), + unconfirmedTxs: []*extendedWalletTx{ + newExtendedWalletTx(usdcTokenID, 4, 5, 0, asset.Send), }, expPendingOut: 5, - expCountAfter: 1, }, { name: "token with 1 tx and other ignored assets", assetID: usdcTokenID, - unconfirmedTxs: map[uint64]*tExtendedWalletTx{ - 0: newExtendedWalletTx(usdcTokenID, 4, 5, 0, 0, asset.Send), - 1: newExtendedWalletTx(usdcTokenID+1, 8, 9, 0, 0, asset.Send), + unconfirmedTxs: []*extendedWalletTx{ + newExtendedWalletTx(usdcTokenID, 4, 5, 0, asset.Send), + newExtendedWalletTx(usdcTokenID+1, 8, 9, 0, asset.Send), }, expPendingOut: 4, - expCountAfter: 2, }, { name: "token with 1 tx incoming", assetID: usdcTokenID, - unconfirmedTxs: map[uint64]*tExtendedWalletTx{ - 0: newExtendedWalletTx(usdcTokenID, 15, 5, 0, 0, asset.Redeem), + unconfirmedTxs: []*extendedWalletTx{ + newExtendedWalletTx(usdcTokenID, 15, 5, 0, asset.Redeem), }, - expPendingIn: 15, - expCountAfter: 1, + expPendingIn: 15, }, { name: "eth mixed txs", assetID: BipID, - unconfirmedTxs: map[uint64]*tExtendedWalletTx{ - 0: newExtendedWalletTx(BipID, 1, 2, 0, 0, asset.Swap), // 3 eth out - 1: newExtendedWalletTx(usdcTokenID, 3, 4, 0, txConfsNeededToConfirm, asset.Send), // confirmed - 2: newExtendedWalletTx(usdcTokenID, 5, 6, 0, 0, asset.Swap), // 6 eth out - 3: newExtendedWalletTx(BipID, 7, 1, 0, 0, asset.Refund), // 1 eth out, 7 eth in + unconfirmedTxs: []*extendedWalletTx{ + newExtendedWalletTx(BipID, 1, 2, 0, asset.Swap), // 3 eth out + newExtendedWalletTx(usdcTokenID, 3, 4, 1, asset.Send), // confirmed + newExtendedWalletTx(usdcTokenID, 5, 6, 0, asset.Swap), // 6 eth out + newExtendedWalletTx(BipID, 7, 1, 0, asset.Refund), // 1 eth out, 7 eth in }, expPendingOut: 10, expPendingIn: 7, - expCountAfter: 3, }, { name: "already confirmed, but still waiting for txConfsNeededToConfirm", assetID: usdcTokenID, - unconfirmedTxs: map[uint64]*tExtendedWalletTx{ - 0: newExtendedWalletTx(usdcTokenID, 15, 5, tipHeight, 1, asset.Redeem), + unconfirmedTxs: []*extendedWalletTx{ + newExtendedWalletTx(usdcTokenID, 15, 5, 1, asset.Redeem), }, - expCountAfter: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, eth, tNode, shutdown := tassetWallet(tt.assetID) + _, eth, node, shutdown := tassetWallet(tt.assetID) defer shutdown() - eth.node = tNode.testNode // no mempool - tNode.bal = unlimitedAllowance - tNode.tokenContractor.bal = unlimitedAllowance - tNode.receipts = make(map[common.Hash]*types.Receipt) - tNode.receiptTxs = make(map[common.Hash]*types.Transaction) - tNode.hdrByHash = &types.Header{ - BaseFee: big.NewInt(100e9), - } + eth.node = node.testNode // no mempool + node.bal = unlimitedAllowance + node.tokenContractor.bal = unlimitedAllowance eth.connected.Store(true) - eth.tipMtx.Lock() - eth.currentTip = &types.Header{Number: new(big.Int).SetUint64(tipHeight)} - eth.tipMtx.Unlock() - - for nonce, pt := range tt.unconfirmedTxs { - txHash := common.BytesToHash(encode.RandomBytes(32)) - pt.wt.ID = txHash.String() - eth.pendingTxs[nonce] = pt.wt - var blockNumber *big.Int - if pt.confs > 0 { - blockNumber = big.NewInt(int64(tipHeight - pt.confs + 1)) - } - tNode.receipts[txHash] = &types.Receipt{BlockNumber: blockNumber} - tNode.receiptTxs[txHash] = types.NewTx(&types.DynamicFeeTx{ - GasTipCap: big.NewInt(2e9), - }) - } + + eth.pendingTxs = tt.unconfirmedTxs bal, err := eth.balanceWithTxPool() if err != nil { @@ -1261,10 +1494,6 @@ func TestBalanceNoMempool(t *testing.T) { if out := dexeth.WeiToGwei(bal.PendingOut); out != tt.expPendingOut { t.Fatalf("wrong PendingOut. wanted %d, got %d", tt.expPendingOut, out) } - - if len(eth.pendingTxs) != tt.expCountAfter { - t.Fatalf("wrong pending tx count after balance check. expected %d, got %d", tt.expCountAfter, len(eth.pendingTxs)) - } }) } } @@ -1274,9 +1503,11 @@ func TestFeeRate(t *testing.T) { defer cancel() node := &testNode{} eth := &baseWallet{ - node: node, - ctx: ctx, - log: tLogger, + node: node, + ctx: ctx, + log: tLogger, + finalizeConfs: txConfsNeededToConfirm, + currentTip: &types.Header{Number: big.NewInt(100)}, } maxInt := ^int64(0) @@ -1295,7 +1526,7 @@ func TestFeeRate(t *testing.T) { }, { name: "net fee state error", - netFeeStateErr: errors.New(""), + netFeeStateErr: errors.New("test error"), }, { name: "overflow error", @@ -1305,6 +1536,7 @@ func TestFeeRate(t *testing.T) { } for _, test := range tests { + eth.currentFees.blockNum = 0 node.baseFee = test.baseFee node.tip = test.tip node.netFeeStateErr = test.netFeeStateErr @@ -1390,13 +1622,13 @@ func testRefund(t *testing.T, assetID uint32) { { name: "swap error", swapStep: dexeth.SSInitiated, - swapErr: errors.New(""), + swapErr: errors.New("test error"), wantErr: true, }, { name: "is refundable error", isRefundable: true, - isRefundableErr: errors.New(""), + isRefundableErr: errors.New("test error"), wantErr: true, }, { @@ -1407,7 +1639,7 @@ func testRefund(t *testing.T, assetID uint32) { { name: "refund error", isRefundable: true, - refundErr: errors.New(""), + refundErr: errors.New("test error"), wantErr: true, }, { @@ -1654,7 +1886,7 @@ func testFundOrderReturnCoinsFundingCoins(t *testing.T, assetID uint32) { } checkBalance(eth, walletBalanceGwei, 0, "returned correct amount") - node.setBalanceError(eth, errors.New("")) + node.setBalanceError(eth, errors.New("test error")) _, _, _, err = w.FundOrder(&order) if err == nil { t.Fatalf("balance error should cause error but did not") @@ -1743,7 +1975,7 @@ func testFundOrderReturnCoinsFundingCoins(t *testing.T, assetID uint32) { checkBalance(eth2, walletBalanceGwei-coinVal, coinVal, "after funding error 4") // Test funding coins with balance error - node.balErr = errors.New("") + node.balErr = errors.New("test error") _, err = w2.FundingCoins([]dex.Bytes{badCoin.ID()}) if err == nil { t.Fatalf("expected error but did not get") @@ -2162,7 +2394,7 @@ func TestPreSwap(t *testing.T) { { name: "balanceError", bal: 5 * lotSize, - balErr: errors.New(""), + balErr: errors.New("test error"), lots: 1, wantErr: true, @@ -2170,7 +2402,7 @@ func TestPreSwap(t *testing.T) { { name: "balanceError - token", bal: 5 * lotSize, - balErr: errors.New(""), + balErr: errors.New("test error"), lots: 1, token: true, @@ -2299,7 +2531,6 @@ func testSwap(t *testing.T, assetID uint32) { } testSwap := func(testName string, swaps asset.Swaps, expectError bool) { - t.Helper() originalBalance, err := eth.Balance() if err != nil { t.Fatalf("%v: error getting balance: %v", testName, err) @@ -2403,7 +2634,7 @@ func testSwap(t *testing.T, assetID uint32) { expiration := uint64(time.Now().Add(time.Hour * 8).Unix()) // Ensure error when initializing swap errors - node.tContractor.initErr = errors.New("") + node.tContractor.initErr = errors.New("test error") contracts := []*asset.Contract{ { Address: receivingAddress, @@ -2966,7 +3197,7 @@ func testRedeem(t *testing.T, assetID uint32) { }, { name: "redeem error", - redeemErr: errors.New(""), + redeemErr: errors.New("test error"), swapMap: swappableSwapMap, expectError: true, ethBal: dexeth.GweiToWei(10e9), @@ -3029,11 +3260,6 @@ func testRedeem(t *testing.T, assetID uint32) { for secretHash, step := range test.swapMap { contractorV1.swapMap[secretHash].State = step } - - eth.monitoredTxsMtx.Lock() - eth.monitoredTxs = make(map[common.Hash]*monitoredTx) - eth.monitoredTxsMtx.Unlock() - node.bal = test.ethBal node.baseFee = test.baseFee @@ -3099,18 +3325,6 @@ func testRedeem(t *testing.T, assetID uint32) { if contractorV1.lastRedeemOpts.GasFeeCap.Cmp(test.expectedGasFeeCap) != 0 { t.Fatalf("%s: expected gas fee cap %v, but got %v", test.name, test.expectedGasFeeCap, contractorV1.lastRedeemOpts.GasFeeCap) } - - // Check that tx was stored in the monitored transactions - txHash := contractorV1.redeemTx.Hash() - eth.monitoredTxsMtx.RLock() - monitoredTx, stored := eth.monitoredTxs[txHash] - if !stored { - t.Fatalf("%s: tx was not stored in monitored transactions", test.name) - } - if monitoredTx.blockSubmitted != uint64(bestBlock) { - t.Fatalf("%s: expected block submitted to be %d, but got %d", test.name, bestBlock, monitoredTx.blockSubmitted) - } - eth.monitoredTxsMtx.RUnlock() } } @@ -3229,7 +3443,7 @@ func TestMaxOrder(t *testing.T) { lotSize: 10, feeSuggestion: 90, maxFeeRate: 100, - balErr: errors.New(""), + balErr: errors.New("test error"), wantErr: true, }, } @@ -3516,7 +3730,8 @@ func TestOwnsAddress(t *testing.T) { rand.Read(otherAddress[:]) eth := &baseWallet{ - addr: common.HexToAddress(address), + addr: common.HexToAddress(address), + finalizeConfs: txConfsNeededToConfirm, } tests := []struct { @@ -3598,10 +3813,11 @@ func TestSignMessage(t *testing.T) { node := newTestNode(BipID) eth := &assetWallet{ baseWallet: &baseWallet{ - node: node, - addr: node.address(), - ctx: ctx, - log: tLogger, + node: node, + addr: node.address(), + ctx: ctx, + log: tLogger, + finalizeConfs: txConfsNeededToConfirm, }, assetID: BipID, } @@ -3609,7 +3825,7 @@ func TestSignMessage(t *testing.T) { msg := []byte("msg") // SignData error - node.signDataErr = errors.New("") + node.signDataErr = errors.New("test error") _, _, err := eth.SignMessage(nil, msg) if err == nil { t.Fatalf("expected error due to error in rpcclient signData") @@ -3646,7 +3862,7 @@ func TestSwapConfirmation(t *testing.T) { state.BlockHeight = 5 state.State = dexeth.SSInitiated hdr.Number = big.NewInt(6) - node.bestHdr = hdr + eth.currentTip = hdr ver := uint32(0) @@ -3654,6 +3870,7 @@ func TestSwapConfirmation(t *testing.T) { defer cancel() checkResult := func(expErr bool, expConfs uint32, expSpent bool) { + t.Helper() confs, spent, err := eth.SwapConfirmations(ctx, nil, dexeth.EncodeContractData(ver, secretHash), time.Time{}) if err != nil { if expErr { @@ -3681,11 +3898,6 @@ func TestSwapConfirmation(t *testing.T) { checkResult(true, 0, false) node.tContractor.swapErr = nil - // header error - node.bestHdrErr = fmt.Errorf("test error") - checkResult(true, 0, false) - node.bestHdrErr = nil - // ErrSwapNotInitiated state.State = dexeth.SSNone _, _, err := eth.SwapConfirmations(ctx, nil, dexeth.EncodeContractData(0, secretHash), time.Time{}) @@ -4297,7 +4509,7 @@ func testSend(t *testing.T, assetID uint32) { node.sendTxTx = tx node.tokenContractor.transferTx = tx - maxFeeRate, _ := eth.recommendedMaxFeeRate(eth.ctx) + maxFeeRate, _, _ := eth.recommendedMaxFeeRate(eth.ctx) ethFees := dexeth.WeiToGwei(maxFeeRate) * defaultSendGasLimit tokenFees := dexeth.WeiToGwei(maxFeeRate) * tokenGases.Transfer @@ -4314,7 +4526,7 @@ func testSend(t *testing.T, assetID uint32) { addr: testAddr, }, { name: "balance error", - balErr: errors.New(""), + balErr: errors.New("test error"), wantErr: true, addr: testAddr, }, { @@ -4329,7 +4541,7 @@ func testSend(t *testing.T, assetID uint32) { addr: testAddr, }, { name: "sendToAddr error", - sendTxErr: errors.New(""), + sendTxErr: errors.New("test error"), wantErr: true, addr: testAddr, }, { @@ -4374,716 +4586,147 @@ func testConfirmRedemption(t *testing.T, assetID uint32) { wi, eth, node, shutdown := tassetWallet(assetID) defer shutdown() - txHashes := make([]common.Hash, 5) - secrets := make([]common.Hash, 5) - secretHashes := make([][32]byte, 5) - for i := 0; i < 5; i++ { - copy(txHashes[i][:], encode.RandomBytes(32)) - copy(secrets[i][:], encode.RandomBytes(32)) - secretHashes[i] = sha256.Sum256(secrets[i][:]) - } + db := eth.txDB.(*tTxDB) - type txData struct { - nonce uint64 - gasFeeCapGwei uint64 - height int64 - data dex.Bytes - } - - toEthTx := func(nonce, gasFeeCapGwei uint64, data dex.Bytes) *types.Transaction { - return types.NewTx(&types.DynamicFeeTx{ - Nonce: nonce, - GasFeeCap: dexeth.GweiToWei(gasFeeCapGwei), - Data: data, - }) - } + const tip = 12 + const confBlock = tip - txConfsNeededToConfirm + 1 - toEthTxHash := func(nonce, gasFeeCapGwei uint64, data dex.Bytes) *common.Hash { - txHash := toEthTx(nonce, gasFeeCapGwei, data).Hash() - return &txHash - } - - toEthTxCoinID := func(nonce, gasFeeCapGwei uint64, data dex.Bytes) dex.Bytes { - txHash := toEthTx(nonce, gasFeeCapGwei, data).Hash() - return txHash[:] - } + var secret, secretHash [32]byte + copy(secret[:], encode.RandomBytes(32)) + copy(secretHash[:], encode.RandomBytes(32)) + var txHash common.Hash + copy(txHash[:], encode.RandomBytes(32)) - redeem0 := []*dexeth.Redemption{ - { - Secret: secrets[0], - SecretHash: secretHashes[0], + redemption := &asset.Redemption{ + Spends: &asset.AuditInfo{ + Contract: dexeth.EncodeContractData(0, secretHash), }, - } - redeem0Data, err := packRedeemDataV0(redeem0) - if err != nil { - panic("failed to pack redeem data") + Secret: secret[:], } - redeem0and1 := []*dexeth.Redemption{ - { - Secret: secrets[0], - SecretHash: secretHashes[0], - }, - { - Secret: secrets[1], - SecretHash: secretHashes[1], + pendingTx := &extendedWalletTx{ + WalletTransaction: &asset.WalletTransaction{ + ID: txHash.String(), + BlockNumber: confBlock + 1, }, + txHash: txHash, } - redeem0and1Data, err := packRedeemDataV0(redeem0and1) - if err != nil { - panic("failed to pack redeem data") - } - - assetRedemption := func(secretHash, secret common.Hash) *asset.Redemption { - return &asset.Redemption{ - Spends: &asset.AuditInfo{ - Contract: dexeth.EncodeContractData(0, secretHash), - }, - Secret: secret[:], - } - } - - tempDir := t.TempDir() - - txDB := newBadgerTxDB(filepath.Join(tempDir, "tx.db"), tLogger) - if err != nil { - t.Fatalf("error creating tx db: %v", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - wg, err := txDB.connect(ctx) - if err != nil { - t.Fatalf("error connecting to tx db: %v", err) + dbTx := &extendedWalletTx{ + WalletTransaction: &asset.WalletTransaction{ + ID: txHash.String(), + BlockNumber: confBlock, + }, } - defer func() { - cancel() - wg.Wait() - }() type test struct { - name string - - redemption *asset.Redemption - coinID dex.Bytes - - expectedResult *asset.ConfirmRedemptionStatus - expectErr bool - expectSwapRefundedErr bool - expectedResubmittedRedemptions []*asset.Redemption - expectSentSignedTransaction *types.Transaction - expectedMonitoredTxs map[common.Hash]*monitoredTx - - getTxResMap map[common.Hash]*txData - swapMap map[[32]byte]*dexeth.SwapState - monitoredTxs map[common.Hash]*monitoredTx - - redeemTx *types.Transaction - redeemErr error - - confNonce uint64 - confNonceErr error - - baseFee *big.Int - getFeeErr error - - bestBlock int64 - bestHdrErr error - - receipt *types.Receipt - receiptErr error + name string + expectedConfs uint64 + expectErr bool + expectSwapRefundedErr bool + expectRedemptionFailedErr bool + pendingTx *extendedWalletTx + dbTx *extendedWalletTx + dbErr error + step dexeth.SwapStep + receipt *types.Receipt + receiptErr error } tests := []*test{ { - name: "in monitored txs, found by geth, not yet confirmed", - coinID: toEthTxCoinID(3, 200, redeem0Data), - redemption: assetRedemption(secretHashes[0], secrets[0]), - getTxResMap: map[common.Hash]*txData{ - (*toEthTxHash(3, 200, redeem0Data)): { - nonce: 3, - gasFeeCapGwei: 200, - height: 10, - data: redeem0Data, - }, - }, - swapMap: map[[32]byte]*dexeth.SwapState{ - secretHashes[0]: { - State: dexeth.SSInitiated, - }, - }, - monitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0Data)): { - tx: toEthTx(3, 200, redeem0Data), - blockSubmitted: 9, - }, - }, - expectedMonitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0Data)): { - tx: toEthTx(3, 200, redeem0Data), - blockSubmitted: 9, - }, - }, - bestBlock: 11, // tx.height + txConfsNeededToConfirm - 2 - expectedResult: &asset.ConfirmRedemptionStatus{ - Confs: txConfsNeededToConfirm - 1, - Req: txConfsNeededToConfirm, - CoinID: toEthTxCoinID(3, 200, redeem0Data), - }, - baseFee: dexeth.GweiToWei(100), - }, - { - name: "in monitored txs, found by geth, confirmed", - coinID: toEthTxCoinID(3, 200, redeem0Data), - redemption: assetRedemption(secretHashes[0], secrets[0]), - getTxResMap: map[common.Hash]*txData{ - (*toEthTxHash(3, 200, redeem0Data)): { - nonce: 3, - gasFeeCapGwei: 200, - height: 10, - data: redeem0Data, - }, - }, - swapMap: map[[32]byte]*dexeth.SwapState{ - secretHashes[0]: { - State: dexeth.SSRedeemed, - }, - }, - monitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0Data)): { - tx: toEthTx(3, 200, redeem0Data), - blockSubmitted: 9, - }, - }, - expectedMonitoredTxs: map[common.Hash]*monitoredTx{}, - bestBlock: 12, // tx.height + txConfsNeededToConfirm - expectedResult: &asset.ConfirmRedemptionStatus{ - Confs: txConfsNeededToConfirm, - Req: txConfsNeededToConfirm, - CoinID: toEthTxCoinID(3, 200, redeem0Data), - }, + name: "found on-chain. not yet confirmed", receipt: &types.Receipt{ - Status: types.ReceiptStatusSuccessful, + Status: types.ReceiptStatusSuccessful, + BlockNumber: big.NewInt(confBlock + 1), }, - baseFee: dexeth.GweiToWei(100), + expectedConfs: txConfsNeededToConfirm - 1, }, { - name: "in monitored txs, found by geth, receipt failed", - coinID: toEthTxCoinID(3, 200, redeem0Data), - redemption: assetRedemption(secretHashes[0], secrets[0]), - getTxResMap: map[common.Hash]*txData{ - (*toEthTxHash(3, 200, redeem0Data)): { - nonce: 3, - gasFeeCapGwei: 200, - height: 10, - data: redeem0Data, - }, - }, - swapMap: map[[32]byte]*dexeth.SwapState{ - secretHashes[0]: { - State: dexeth.SSInitiated, - }, - }, - monitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0Data)): { - tx: toEthTx(3, 200, redeem0Data), - blockSubmitted: 9, - }, - }, - expectedMonitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0Data)): { - tx: toEthTx(3, 200, redeem0Data), - blockSubmitted: 9, - replacementTx: toEthTxHash(4, 123, redeem0Data), - }, - (*toEthTxHash(4, 123, redeem0Data)): { - tx: toEthTx(4, 123, redeem0Data), - blockSubmitted: 19, - }, - }, - // redeemableMap: map[common.Hash]bool{ - // secretHashes[0]: true, - // }, - bestBlock: 19, - expectedResult: &asset.ConfirmRedemptionStatus{ - Confs: 0, - Req: txConfsNeededToConfirm, - CoinID: toEthTxCoinID(4, 123, redeem0Data), - }, - redeemTx: toEthTx(4, 123, redeem0Data), + name: "found on-chain. confirmed", + step: dexeth.SSRedeemed, + expectedConfs: txConfsNeededToConfirm, receipt: &types.Receipt{ - Status: types.ReceiptStatusFailed, + Status: types.ReceiptStatusSuccessful, + BlockNumber: big.NewInt(confBlock), }, - baseFee: dexeth.GweiToWei(100), - }, - { - name: "in monitored txs, found by geth, refunded", - coinID: toEthTxCoinID(3, 200, redeem0Data), - redemption: assetRedemption(secretHashes[0], secrets[0]), - getTxResMap: map[common.Hash]*txData{ - (*toEthTxHash(3, 200, redeem0Data)): { - nonce: 3, - gasFeeCapGwei: 200, - height: -1, - data: redeem0Data, - }, - }, - swapMap: map[[32]byte]*dexeth.SwapState{ - secretHashes[0]: { - State: dexeth.SSRefunded, - }, - }, - monitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0Data)): { - tx: toEthTx(3, 200, redeem0Data), - blockSubmitted: 9, - }, - }, - expectedMonitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0Data)): { - tx: toEthTx(3, 200, redeem0Data), - blockSubmitted: 9, - }, - }, - bestBlock: 19, - expectErr: true, - expectSwapRefundedErr: true, - receipt: &types.Receipt{ - Status: types.ReceiptStatusSuccessful, - }, - baseFee: dexeth.GweiToWei(100), - }, - { - name: "not in monitored txs, not found by geth", - coinID: toEthTxCoinID(3, 200, redeem0Data), - redemption: assetRedemption(secretHashes[0], secrets[0]), - getTxResMap: map[common.Hash]*txData{}, - swapMap: map[[32]byte]*dexeth.SwapState{ - secretHashes[0]: { - State: dexeth.SSInitiated, - }, - }, - monitoredTxs: map[common.Hash]*monitoredTx{}, - expectedMonitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(4, 123, redeem0Data)): { - tx: toEthTx(4, 123, redeem0Data), - blockSubmitted: 13, - }, - }, - bestBlock: 13, - expectedResult: &asset.ConfirmRedemptionStatus{ - Confs: 0, - Req: txConfsNeededToConfirm, - CoinID: toEthTxCoinID(4, 123, redeem0Data), - }, - redeemTx: toEthTx(4, 123, redeem0Data), - baseFee: dexeth.GweiToWei(100), - }, - { - name: "not in monitored txs, found by geth, < 3 confirmations", - coinID: toEthTxCoinID(3, 200, redeem0Data), - redemption: assetRedemption(secretHashes[0], secrets[0]), - getTxResMap: map[common.Hash]*txData{ - (*toEthTxHash(3, 200, redeem0Data)): { - nonce: 3, - gasFeeCapGwei: 200, - height: 10, - data: redeem0Data, - }, - }, - swapMap: map[[32]byte]*dexeth.SwapState{ - secretHashes[0]: { - State: dexeth.SSInitiated, - }, - }, - monitoredTxs: map[common.Hash]*monitoredTx{}, - bestBlock: 11, - - expectedResult: &asset.ConfirmRedemptionStatus{ - Confs: 2, - Req: txConfsNeededToConfirm, - CoinID: toEthTxCoinID(3, 200, redeem0Data), - }, - baseFee: dexeth.GweiToWei(100), - }, - { - name: "in monitored txs, not found by geth, 10 blocks since submitted", - coinID: toEthTxCoinID(3, 200, redeem0and1Data), - redemption: assetRedemption(secretHashes[0], secrets[0]), - getTxResMap: map[common.Hash]*txData{}, - swapMap: map[[32]byte]*dexeth.SwapState{ - secretHashes[0]: { - State: dexeth.SSInitiated, - }, - secretHashes[1]: { - State: dexeth.SSInitiated, - }, - }, - monitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0and1Data)): { - tx: toEthTx(3, 200, redeem0and1Data), - blockSubmitted: 3, - }, - }, - expectedMonitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0and1Data)): { - tx: toEthTx(3, 200, redeem0and1Data), - blockSubmitted: 3, - replacementTx: toEthTxHash(4, 123, redeem0and1Data), - }, - (*toEthTxHash(4, 123, redeem0and1Data)): { - tx: toEthTx(4, 123, redeem0and1Data), - blockSubmitted: 13, - }, - }, - bestBlock: 13, - expectedResult: &asset.ConfirmRedemptionStatus{ - Confs: 0, - Req: txConfsNeededToConfirm, - CoinID: toEthTxCoinID(4, 123, redeem0and1Data), - }, - expectedResubmittedRedemptions: []*asset.Redemption{ - assetRedemption(secretHashes[0], secrets[0]), - assetRedemption(secretHashes[1], secrets[1]), - }, - redeemTx: toEthTx(4, 123, redeem0and1Data), - baseFee: big.NewInt(100), - }, - { - name: "in monitored txs, not found by geth, other swap in tx already complete", - coinID: toEthTxCoinID(3, 200, redeem0and1Data), - redemption: assetRedemption(secretHashes[0], secrets[0]), - getTxResMap: map[common.Hash]*txData{}, - swapMap: map[[32]byte]*dexeth.SwapState{ - secretHashes[0]: { - State: dexeth.SSInitiated, - }, - secretHashes[1]: { - State: dexeth.SSRedeemed, - }, - }, - monitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0and1Data)): { - tx: toEthTx(3, 200, redeem0and1Data), - blockSubmitted: 3, - }, - }, - expectedMonitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0and1Data)): { - tx: toEthTx(3, 200, redeem0and1Data), - blockSubmitted: 3, - replacementTx: toEthTxHash(4, 123, redeem0Data), - }, - (*toEthTxHash(4, 123, redeem0Data)): { - tx: toEthTx(4, 123, redeem0Data), - blockSubmitted: 13, - }, - }, - bestBlock: 13, - expectedResult: &asset.ConfirmRedemptionStatus{ - Confs: 0, - Req: txConfsNeededToConfirm, - CoinID: toEthTxCoinID(4, 123, redeem0Data), - }, - expectedResubmittedRedemptions: []*asset.Redemption{ - assetRedemption(secretHashes[0], secrets[0]), - }, - redeemTx: toEthTx(4, 123, redeem0Data), - baseFee: big.NewInt(100), - }, - { - name: "replaced, but call with old coin ID", - coinID: toEthTxCoinID(3, 200, redeem0Data), - redemption: assetRedemption(secretHashes[0], secrets[0]), - getTxResMap: map[common.Hash]*txData{ - (*toEthTxHash(5, 200, redeem0Data)): { - nonce: 5, - gasFeeCapGwei: 200, - height: 21, - data: redeem0Data, - }, - }, - swapMap: map[[32]byte]*dexeth.SwapState{ - secretHashes[0]: { - State: dexeth.SSInitiated, - }, - }, - monitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0Data)): { - tx: toEthTx(3, 200, redeem0Data), - blockSubmitted: 9, - replacementTx: toEthTxHash(5, 200, redeem0Data), - }, - (*toEthTxHash(5, 200, redeem0Data)): { - tx: toEthTx(5, 200, redeem0Data), - blockSubmitted: 19, - }, - }, - expectedMonitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0Data)): { - tx: toEthTx(3, 200, redeem0Data), - blockSubmitted: 9, - replacementTx: toEthTxHash(5, 200, redeem0Data), - }, - (*toEthTxHash(5, 200, redeem0Data)): { - tx: toEthTx(5, 200, redeem0Data), - blockSubmitted: 19, - }, - }, - bestBlock: 22, - expectedResult: &asset.ConfirmRedemptionStatus{ - Confs: 2, - Req: txConfsNeededToConfirm, - CoinID: toEthTxCoinID(5, 200, redeem0Data), - }, - baseFee: dexeth.GweiToWei(100), }, { - name: "found by geth, redeemed by another unknown transaction", - coinID: toEthTxCoinID(3, 200, redeem0Data), - redemption: assetRedemption(secretHashes[0], secrets[0]), - getTxResMap: map[common.Hash]*txData{ - (*toEthTxHash(3, 200, redeem0Data)): { - nonce: 3, - gasFeeCapGwei: 200, - height: -1, - data: redeem0Data, - }, - }, - swapMap: map[[32]byte]*dexeth.SwapState{ - secretHashes[0]: { - State: dexeth.SSRedeemed, - }, - }, - bestBlock: 3, // txConfsNeededToConfirm + tx.height + 1 - expectedResult: &asset.ConfirmRedemptionStatus{ - Confs: txConfsNeededToConfirm, - Req: txConfsNeededToConfirm, - CoinID: toEthTxCoinID(3, 200, redeem0Data), - }, - baseFee: dexeth.GweiToWei(100), + name: "found in pending txs", + step: dexeth.SSRedeemed, + pendingTx: pendingTx, + expectedConfs: txConfsNeededToConfirm - 1, }, { - name: "found by geth, nonce replaced", - coinID: toEthTxCoinID(3, 200, redeem0Data), - redemption: assetRedemption(secretHashes[0], secrets[0]), - getTxResMap: map[common.Hash]*txData{ - (*toEthTxHash(3, 200, redeem0Data)): { - nonce: 3, - gasFeeCapGwei: 200, - height: -1, - data: redeem0Data, - }, - }, - swapMap: map[[32]byte]*dexeth.SwapState{ - secretHashes[0]: { - State: dexeth.SSInitiated, - }, - }, - monitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0Data)): { - tx: toEthTx(3, 200, redeem0Data), - blockSubmitted: 3, - }, - }, - expectedMonitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0Data)): { - tx: toEthTx(3, 200, redeem0Data), - blockSubmitted: 3, - replacementTx: toEthTxHash(4, 123, redeem0Data), - }, - (*toEthTxHash(4, 123, redeem0Data)): { - tx: toEthTx(4, 123, redeem0Data), - blockSubmitted: 13, - }, - }, - bestBlock: 13, - expectedResult: &asset.ConfirmRedemptionStatus{ - Confs: 0, - Req: txConfsNeededToConfirm, - CoinID: toEthTxCoinID(4, 123, redeem0Data), - }, - expectedResubmittedRedemptions: []*asset.Redemption{ - assetRedemption(secretHashes[0], secrets[0]), + name: "found in db", + step: dexeth.SSRedeemed, + dbTx: dbTx, + expectedConfs: txConfsNeededToConfirm, + receipt: &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + BlockNumber: big.NewInt(confBlock), }, - confNonce: 4, - redeemTx: toEthTx(4, 123, redeem0Data), - baseFee: dexeth.GweiToWei(100), }, { - name: "found by geth, fee too low", - coinID: toEthTxCoinID(3, 200, redeem0Data), - redemption: assetRedemption(secretHashes[0], secrets[0]), - getTxResMap: map[common.Hash]*txData{ - (*toEthTxHash(3, 200, redeem0Data)): { - nonce: 3, - gasFeeCapGwei: 200, - height: -1, - data: redeem0Data, - }, - }, - swapMap: map[[32]byte]*dexeth.SwapState{ - secretHashes[0]: { - State: dexeth.SSInitiated, - }, - }, - monitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0Data)): { - tx: toEthTx(3, 200, redeem0Data), - blockSubmitted: 3, - }, - }, - expectedMonitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0Data)): { - tx: toEthTx(3, 200, redeem0Data), - blockSubmitted: 3, - replacementTx: toEthTxHash(4, 123, redeem0Data), - }, - (*toEthTxHash(4, 123, redeem0Data)): { - tx: toEthTx(4, 123, redeem0Data), - blockSubmitted: 13, - }, - }, - bestBlock: 13, - expectedResult: &asset.ConfirmRedemptionStatus{ - Confs: 0, - Req: txConfsNeededToConfirm, - CoinID: toEthTxCoinID(4, 123, redeem0Data), - }, - expectedResubmittedRedemptions: []*asset.Redemption{ - assetRedemption(secretHashes[0], secrets[0]), + name: "db error not propagated. unconfirmed", + step: dexeth.SSRedeemed, + dbErr: errors.New("test error"), + expectedConfs: txConfsNeededToConfirm - 1, + receipt: &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + BlockNumber: big.NewInt(confBlock + 1), }, - confNonce: 3, - redeemTx: toEthTx(4, 123, redeem0Data), - baseFee: dexeth.GweiToWei(300), }, { - name: "found by geth, expect resubmission", - coinID: toEthTxCoinID(3, 200, redeem0Data), - redemption: assetRedemption(secretHashes[0], secrets[0]), - getTxResMap: map[common.Hash]*txData{ - (*toEthTxHash(3, 200, redeem0Data)): { - nonce: 3, - gasFeeCapGwei: 200, - height: -1, - data: redeem0Data, - }, - }, - swapMap: map[[32]byte]*dexeth.SwapState{ - secretHashes[0]: { - State: dexeth.SSInitiated, - }, - }, - monitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0Data)): { - tx: toEthTx(3, 200, redeem0Data), - blockSubmitted: 3, - }, - }, - expectedMonitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0Data)): { - tx: toEthTx(3, 200, redeem0Data), - blockSubmitted: 3, - }, - }, - bestBlock: 13, - expectedResult: &asset.ConfirmRedemptionStatus{ - Confs: 0, - Req: txConfsNeededToConfirm, - CoinID: toEthTxCoinID(3, 200, redeem0Data), + name: "found on-chain. tx failed", + step: dexeth.SSInitiated, + expectErr: true, + expectRedemptionFailedErr: true, + receipt: &types.Receipt{ + Status: types.ReceiptStatusFailed, + BlockNumber: big.NewInt(confBlock), }, - expectSentSignedTransaction: toEthTx(3, 200, redeem0Data), - confNonce: 3, - baseFee: dexeth.GweiToWei(100), }, { - name: "best hdr error", - coinID: toEthTxCoinID(3, 200, redeem0Data), - redemption: assetRedemption(secretHashes[0], secrets[0]), - getTxResMap: map[common.Hash]*txData{ - (*toEthTxHash(3, 200, redeem0Data)): { - nonce: 3, - gasFeeCapGwei: 200, - height: 10, - data: redeem0Data, - }, - }, - swapMap: map[[32]byte]*dexeth.SwapState{ - secretHashes[0]: { - State: dexeth.SSInitiated, - }, - }, - monitoredTxs: map[common.Hash]*monitoredTx{ - (*toEthTxHash(3, 200, redeem0Data)): { - tx: toEthTx(3, 200, redeem0Data), - blockSubmitted: 9, - }, + name: "found on-chain. redeemed by another unknown transaction", + step: dexeth.SSRedeemed, + receipt: &types.Receipt{ + Status: types.ReceiptStatusFailed, + BlockNumber: big.NewInt(confBlock), }, - bestBlock: 13, - bestHdrErr: errors.New(""), - expectErr: true, - baseFee: dexeth.GweiToWei(100), + expectedConfs: txConfsNeededToConfirm, }, } runTest := func(test *test) { fmt.Printf("###### %s ###### \n", test.name) - node.getTxResMap = make(map[common.Hash]*tGetTxRes) - for hash, txData := range test.getTxResMap { - node.getTxResMap[hash] = &tGetTxRes{ - tx: toEthTx(txData.nonce, txData.gasFeeCapGwei, txData.data), - height: txData.height, - } - } - for _, s := range test.swapMap { - s.Value = big.NewInt(1) - } - node.tContractor.swapMap = test.swapMap - node.tContractor.redeemTx = test.redeemTx + node.tContractor.swapMap = map[[32]byte]*dexeth.SwapState{ + secretHash: {State: test.step}, + } node.tContractor.lastRedeems = nil node.tokenContractor.bal = big.NewInt(1e9) node.bal = big.NewInt(1e9) - node.lastSignedTx = nil + eth.pendingTxs = []*extendedWalletTx{} + if test.pendingTx != nil { + eth.pendingTxs = append(eth.pendingTxs, test.pendingTx) + } - node.baseFee = test.baseFee - node.netFeeStateErr = test.getFeeErr - node.confNonce = test.confNonce - node.confNonceErr = test.confNonceErr - node.bestHdr = &types.Header{Number: big.NewInt(test.bestBlock)} - node.bestHdrErr = test.bestHdrErr + db.txToGet = test.dbTx + db.getTxErr = test.dbErr + + node.lastSignedTx = nil + eth.currentTip = &types.Header{Number: big.NewInt(tip)} node.receipt = test.receipt node.receiptErr = test.receiptErr - eth.txDB = txDB - eth.monitoredTxs = test.monitoredTxs - - for h, tx := range test.monitoredTxs { - if err := eth.txDB.storeMonitoredTx(h, tx); err != nil { - t.Fatalf("%s: error storing monitored tx: %v", test.name, err) - } - } - - // clear the monitored txs after each test - defer func() { - storedTxs, err := eth.txDB.getMonitoredTxs() - if err != nil { - t.Fatalf("%s: failed to load stored txs", test.name) - } - storedTxHashes := make([]common.Hash, 0, len(storedTxs)) - for h := range storedTxs { - storedTxHashes = append(storedTxHashes, h) - } - err = eth.txDB.removeMonitoredTxs(storedTxHashes) - if err != nil { - t.Fatalf("%s: failed to remove stored txs", test.name) - } - }() - - result, err := wi.ConfirmRedemption(test.coinID, test.redemption, 0) + result, err := wi.ConfirmRedemption(txHash[:], redemption, 0) if test.expectErr { if err == nil { - t.Fatalf("%s: expected but did not get", test.name) + t.Fatalf("%s: expected error but did not get", test.name) + } + if test.expectRedemptionFailedErr && !errors.Is(err, asset.ErrTxRejected) { + t.Fatalf("%s: expected rejected tx error. got %v", test.name, err) } if test.expectSwapRefundedErr && !errors.Is(asset.ErrSwapRefunded, err) { t.Fatalf("%s: expected swap refunded error but got %v", test.name, err) @@ -5091,95 +4734,13 @@ func testConfirmRedemption(t *testing.T, assetID uint32) { return } if err != nil { - t.Fatalf("%s: unexpected error %v", test.name, err) - } - - // Check that the correct swaps were resubmitted - if test.expectedResubmittedRedemptions != nil { - if len(test.expectedResubmittedRedemptions) != len(node.tContractor.lastRedeems) { - t.Fatalf("%s expected %d redeems but got %d", - test.name, - len(test.expectedResubmittedRedemptions), - len(node.tContractor.lastRedeems)) - } - - // The redemptions might be out of order, so we create this map. - lastRedeems := make(map[string]*asset.Redemption) - for _, redeem := range node.tContractor.lastRedeems { - lastRedeems[fmt.Sprintf("%x", redeem.Spends.Contract)] = redeem - } - for i := range test.expectedResubmittedRedemptions { - expected := test.expectedResubmittedRedemptions[i] - actual, found := lastRedeems[fmt.Sprintf("%x", expected.Spends.Contract)] - if !found { - t.Fatalf("%s: expected contract not found among redemptions", test.name) - } - if !bytes.Equal(expected.Spends.Contract, actual.Spends.Contract) { - t.Fatalf("%s: redeemed contract not as expected", test.name) - } - if !bytes.Equal(expected.Secret, actual.Secret) { - t.Fatalf("%s: redeemed secret not as expected", test.name) - } - } - } - - // If the transaction should be resubmitted unmodified, check that this - // happened properly - if test.expectSentSignedTransaction != nil { - if test.expectSentSignedTransaction.Hash() != node.lastSignedTx.Hash() { - t.Fatalf("%s expected sent signed tx %s != actual %s", - test.name, test.expectSentSignedTransaction.Hash(), node.lastSignedTx.Hash()) - } - } - - // Check that the monitoredTxs were updated properly - monitoredTxsMatch := func(a, b *monitoredTx) bool { - if a.blockSubmitted != b.blockSubmitted { - return false - } else if a.tx.Hash() != b.tx.Hash() { - return false - } else if a.tx.Hash() != b.tx.Hash() { - return false - } else if (a.replacementTx == nil) != (b.replacementTx == nil) { - return false - } else if a.replacementTx != nil && *a.replacementTx != *b.replacementTx { - return false - } - return true - } - storedTxs, err := eth.txDB.getMonitoredTxs() - if err != nil { - t.Fatalf("%s: failed to load stored txs", test.name) - } - - // We do not check the length of the in memory map because that will be cleared later - if len(storedTxs) != len(test.expectedMonitoredTxs) { - t.Fatalf("expected %d monitored txs to be stored but got %d", len(test.expectedMonitoredTxs), len(storedTxs)) - } - - for hash, expected := range test.expectedMonitoredTxs { - actual, found := eth.monitoredTxs[hash] - if !found { - t.Fatalf("%s: expected monitored tx not found among monitored txs", test.name) - } - if !monitoredTxsMatch(expected, actual) { - t.Fatalf("%s: expected monitored tx %+v != actual %+v", test.name, expected, actual) - } - - stored, found := storedTxs[hash] - if !found { - t.Fatalf("%s: expected monitored tx not found among stored txs", test.name) - } - if !monitoredTxsMatch(expected, stored) { - t.Fatalf("%s: expected monitored tx %+v != stored %+v", test.name, expected, stored) - } + t.Fatalf("%s: unexpected error: %v", test.name, err) } // Check that the resulting status is as expected - if !bytes.Equal(test.expectedResult.CoinID, result.CoinID) || - test.expectedResult.Confs != result.Confs || - test.expectedResult.Req != result.Req { - t.Fatalf("%s: expected result %+v != result %+v", test.name, test.expectedResult, result) + if test.expectedConfs != result.Confs || + txConfsNeededToConfirm != result.Req { + t.Fatalf("%s: expected confs %d != result %d", test.name, test.expectedConfs, result.Confs) } } @@ -5188,56 +4749,6 @@ func testConfirmRedemption(t *testing.T, assetID uint32) { } } -func TestMarshalMonitoredTx(t *testing.T) { - var replacementTxHash common.Hash - copy(replacementTxHash[:], encode.RandomBytes(32)) - - original := &monitoredTx{ - tx: tTx(100, 200, 300, &testAddressA, []byte{}, 21000), - blockSubmitted: 123, - replacementTx: &replacementTxHash, - } - - originalB, err := original.MarshalBinary() - if err != nil { - t.Fatalf("error marshaling monitored tx: %v", err) - } - - var unmarshaledMonitoredTx monitoredTx - err = unmarshaledMonitoredTx.UnmarshalBinary(originalB) - if err != nil { - t.Fatalf("error unmarshalling monitored tx: %v", err) - } - - if original.tx.Hash() != unmarshaledMonitoredTx.tx.Hash() || - original.blockSubmitted != unmarshaledMonitoredTx.blockSubmitted || - *original.replacementTx != *unmarshaledMonitoredTx.replacementTx { - t.Fatalf("incorrectly unmarshalled") - } - - originalNoReplacement := &monitoredTx{ - tx: tTx(100, 200, 300, &testAddressA, []byte{}, 21000), - blockSubmitted: 123, - } - - noReplacementB, err := originalNoReplacement.MarshalBinary() - if err != nil { - t.Fatalf("error marshaling monitored tx: %v", err) - } - - var unmarshalledNoReplacement monitoredTx - err = unmarshalledNoReplacement.UnmarshalBinary(noReplacementB) - if err != nil { - t.Fatalf("error unmarshalling monitored tx: %v", err) - } - - if originalNoReplacement.tx.Hash() != unmarshalledNoReplacement.tx.Hash() || - originalNoReplacement.blockSubmitted != unmarshalledNoReplacement.blockSubmitted || - originalNoReplacement.replacementTx != unmarshalledNoReplacement.replacementTx { - t.Fatalf("incorrectly unmarshalled") - } -} - // Ensures that a small rise in the base fee between estimation // and sending will not cause a failure. func TestEstimateVsActualSendFees(t *testing.T) { @@ -5268,7 +4779,7 @@ func testEstimateVsActualSendFees(t *testing.T, assetID uint32) { if assetID == BipID { node.bal = dexeth.GweiToWei(11e9) canSend := new(big.Int).Sub(node.bal, dexeth.GweiToWei(fee)) - canSendGwei, err := dexeth.WeiToGweiUint64(canSend) + canSendGwei, err := dexeth.WeiToGweiSafe(canSend) if err != nil { t.Fatalf("error converting canSend to gwei: %v", err) } @@ -5296,7 +4807,7 @@ func testEstimateSendTxFee(t *testing.T, assetID uint32) { w, eth, node, shutdown := tassetWallet(assetID) defer shutdown() - maxFeeRate, _ := eth.recommendedMaxFeeRate(eth.ctx) + maxFeeRate, _, _ := eth.recommendedMaxFeeRate(eth.ctx) ethFees := dexeth.WeiToGwei(maxFeeRate) * defaultSendGasLimit tokenFees := dexeth.WeiToGwei(maxFeeRate) * tokenGases.Transfer @@ -5336,7 +4847,7 @@ func testEstimateSendTxFee(t *testing.T, assetID uint32) { addr: testAddr, }, { name: "balance error", - balErr: errors.New(""), + balErr: errors.New("test error"), wantErr: true, addr: testAddr, }} @@ -5439,22 +4950,35 @@ func testMaxSwapRedeemLots(t *testing.T, assetID uint32) { func TestSwapOrRedemptionFeesPaid(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - node := &testNode{} - bw := &baseWallet{node: node} - coinID, secretHA, secretHB := encode.RandomBytes(32), encode.RandomBytes(32), encode.RandomBytes(32) + _, bw, node, shutdown := tassetWallet(BipID) + defer shutdown() + + secretHA, secretHB := encode.RandomBytes(32), encode.RandomBytes(32) contractDataFn := func(ver uint32, secretH []byte) []byte { s := [32]byte{} copy(s[:], secretH) return dexeth.EncodeContractData(ver, s) } - rcpt := &types.Receipt{ - GasUsed: 100, - } - hdr := &types.Header{ + const tip = 100 + const feeRate = 2 // gwei / gas + const gasUsed = 100 + const fees = feeRate * gasUsed + + bw.currentTip = &types.Header{ BaseFee: dexeth.GweiToWei(2), - Number: big.NewInt(1), + Number: big.NewInt(tip), } - hdrConfirms := &types.Header{Number: big.NewInt(11)} + + confirmedReceipt := &types.Receipt{ + GasUsed: gasUsed, + EffectiveGasPrice: dexeth.GweiToWei(feeRate), + BlockNumber: big.NewInt(tip - txConfsNeededToConfirm + 1), + } + + unconfirmedReceipt := &types.Receipt{ + BlockNumber: big.NewInt(tip - txConfsNeededToConfirm + 2), + } + initFn := func(secretHs [][]byte) []byte { inits := make([]*dexeth.Initiation, 0, len(secretHs)) for i := range secretHs { @@ -5496,115 +5020,100 @@ func TestSwapOrRedemptionFeesPaid(t *testing.T) { sort.Slice(ab, func(i, j int) bool { return bytes.Compare(ab[i], ab[j]) < 0 }) return ab } + initTx := tTx(200, 2, 0, nil, initFn(abFn()), 200) + redeemTx := tTx(200, 3, 0, nil, redeemFn(abFn()), 200) tests := []struct { - name string - coinID, contractData []byte - isInit, wantErr bool - receipt *types.Receipt - receiptTx *types.Transaction - receiptErr, bestHdrErr error - hdrByHash, bestHdr *types.Header - wantSecrets [][]byte - wantEstimatedFee uint64 - wantFee uint64 + name string + contractData []byte + isInit, wantErr bool + receipt *types.Receipt + receiptTx *types.Transaction + receiptErr error + wantSecrets [][]byte + pendingTx *types.Transaction + pendingTxBlock uint64 }{{ - name: "ok init", - coinID: coinID, - contractData: contractDataFn(0, secretHA), - isInit: true, - receipt: rcpt, - receiptTx: tTx(200, 2, 0, nil, initFn(abFn()), 200), - hdrByHash: hdr, - bestHdr: hdrConfirms, - wantSecrets: sortedFn(), - wantEstimatedFee: 200 * 200, - wantFee: 400, + name: "ok init", + contractData: contractDataFn(0, secretHA), + isInit: true, + receipt: confirmedReceipt, + receiptTx: initTx, + wantSecrets: sortedFn(), + }, { + name: "ok redeem", + contractData: contractDataFn(0, secretHB), + receipt: confirmedReceipt, + receiptTx: redeemTx, + wantSecrets: sortedFn(), }, { - name: "ok redeem", - coinID: coinID, - contractData: contractDataFn(0, secretHB), - receipt: rcpt, - receiptTx: tTx(200, 3, 0, nil, redeemFn(abFn()), 200), - hdrByHash: hdr, - bestHdr: hdrConfirms, - wantSecrets: sortedFn(), - wantEstimatedFee: 200 * 200, - wantFee: 500, + name: "ok init from pending txs", + contractData: contractDataFn(0, secretHA), + isInit: true, + pendingTx: initTx, + pendingTxBlock: confirmedReceipt.BlockNumber.Uint64(), + wantSecrets: sortedFn(), + }, { + name: "ok redeem from pending txs", + contractData: contractDataFn(0, secretHB), + pendingTx: redeemTx, + pendingTxBlock: confirmedReceipt.BlockNumber.Uint64(), + wantSecrets: sortedFn(), }, { name: "bad contract data", - coinID: coinID, contractData: nil, wantErr: true, }, { name: "receipt error", - coinID: coinID, - contractData: contractDataFn(0, secretHA), - receiptErr: errors.New(""), - wantErr: true, - }, { - name: "nil header", - coinID: coinID, - contractData: contractDataFn(0, secretHA), - receipt: rcpt, - receiptTx: tTx(200, 2, 0, nil, initFn(abFn()), 200), - bestHdr: hdrConfirms, - wantErr: true, - }, { - name: "best header error", - coinID: coinID, contractData: contractDataFn(0, secretHA), - receipt: rcpt, - hdrByHash: hdr, - bestHdrErr: errors.New(""), + receiptErr: errors.New("test error"), wantErr: true, }, { name: "not enough confirms", - coinID: coinID, contractData: contractDataFn(0, secretHA), - receipt: rcpt, - hdrByHash: hdr, - bestHdr: hdr, + receipt: unconfirmedReceipt, wantErr: true, + }, { + name: "not enough confs, pending tx", + contractData: contractDataFn(0, secretHA), + isInit: true, + pendingTx: initTx, + pendingTxBlock: confirmedReceipt.BlockNumber.Uint64() + 1, + wantSecrets: sortedFn(), + wantErr: true, }, { name: "bad init data", - coinID: coinID, contractData: contractDataFn(0, secretHA), isInit: true, - receipt: rcpt, + receipt: confirmedReceipt, receiptTx: tTx(200, 2, 0, nil, nil, 200), - hdrByHash: hdr, - bestHdr: hdrConfirms, wantErr: true, }, { name: "bad redeem data", - coinID: coinID, contractData: contractDataFn(0, secretHA), - receipt: rcpt, + receipt: confirmedReceipt, receiptTx: tTx(200, 2, 0, nil, nil, 200), - hdrByHash: hdr, - bestHdr: hdrConfirms, wantErr: true, }, { name: "secret hash not found", - coinID: coinID, contractData: contractDataFn(0, secretHB), isInit: true, - receipt: rcpt, + receipt: confirmedReceipt, receiptTx: tTx(200, 2, 0, nil, initFn([][]byte{secretHA}), 200), - hdrByHash: hdr, - bestHdr: hdrConfirms, wantErr: true, }} for _, test := range tests { var txHash common.Hash - copy(txHash[:], test.coinID) + if test.pendingTx != nil { + wt := bw.extendedTx(test.pendingTx, asset.Unknown, 1) + wt.BlockNumber = test.pendingTxBlock + wt.Fees = fees + bw.pendingTxs = []*extendedWalletTx{wt} + txHash = test.pendingTx.Hash() + } node.receiptTx = test.receiptTx node.receipt = test.receipt node.receiptErr = test.receiptErr - node.hdrByHash = test.hdrByHash - node.bestHdr = test.bestHdr - node.bestHdrErr = test.bestHdrErr - fee, secretHs, err := bw.swapOrRedemptionFeesPaid(ctx, test.coinID, test.contractData, test.isInit) + feesPaid, secretHs, err := bw.swapOrRedemptionFeesPaid(ctx, txHash[:], test.contractData, test.isInit) if test.wantErr { if err == nil { t.Fatalf("%q: expected error", test.name) @@ -5614,8 +5123,8 @@ func TestSwapOrRedemptionFeesPaid(t *testing.T) { if err != nil { t.Fatalf("%q: unexpected error: %v", test.name, err) } - if test.wantFee != fee { - t.Fatalf("%q: wanted fee %d but got %d", test.name, test.wantFee, fee) + if feesPaid != fees { + t.Fatalf("%q: wanted fee %d but got %d", test.name, fees, feesPaid) } if len(test.wantSecrets) != len(secretHs) { t.Fatalf("%q: wanted %d secrets but got %d", test.name, len(test.wantSecrets), len(secretHs)) @@ -5631,7 +5140,9 @@ func TestSwapOrRedemptionFeesPaid(t *testing.T) { } func TestReceiptCache(t *testing.T) { - m := &multiRPCClient{} + m := &multiRPCClient{ + finalizeConfs: 3, + } c := make(map[common.Hash]*receiptRecord) m.receipts.cache = c @@ -5676,42 +5187,6 @@ func TestReceiptCache(t *testing.T) { } -func TestUnusedNonce(t *testing.T) { - mRPC := new(multiRPCClient) - tests := []struct { - name string - nonce uint64 - want bool - wait bool - }{{ - name: "ok initiation", - nonce: 0, - want: true, - }, { - name: "ok larger", - nonce: 1, - want: true, - }, { - name: "same nonce", - nonce: 1, - // Uncomment for full tests. - // }, { - // name: "ok after expiration", - // nonce: 1, - // wait: true, - // want: true, - }} - for _, test := range tests { - if test.wait { - time.Sleep(time.Minute + time.Second) - } - got := mRPC.registerNonce(test.nonce) - if test.want != got { - t.Fatalf("%q: wanted %v got %v", test.name, test.want, got) - } - } -} - func TestFreshProviderList(t *testing.T) { tests := []struct { @@ -5734,7 +5209,10 @@ func TestFreshProviderList(t *testing.T) { for i, tt := range tests { t.Run(fmt.Sprintf("test#%d", i), func(t *testing.T) { - node := &multiRPCClient{providers: make([]*provider, len(tt.times))} + node := &multiRPCClient{ + finalizeConfs: 3, + providers: make([]*provider, len(tt.times)), + } for i, stamp := range tt.times { p := &provider{} p.tip.headerStamp = time.Unix(stamp, 0) diff --git a/client/asset/eth/multirpc.go b/client/asset/eth/multirpc.go index 30f707c685..d4bb29d96f 100644 --- a/client/asset/eth/multirpc.go +++ b/client/asset/eth/multirpc.go @@ -107,10 +107,11 @@ type provider struct { // tip tracks the best known header as well as any error encountered tip struct { sync.RWMutex - header *types.Header - headerStamp time.Time - failStamp time.Time - failCount int + header *types.Header + headerStamp time.Time + failStamp time.Time + failCount int + wsHeaderSeen atomic.Bool } } @@ -140,7 +141,7 @@ func (p *provider) setTip(header *types.Header, log dex.Logger) { // cachedTip retrieves the last known best header. func (p *provider) cachedTip() *types.Header { stale := time.Second * 10 - if p.ws { + if p.tip.wsHeaderSeen.Load() { // We want to avoid requests, and we expect that our notification feed // is working. Setting this too low would result in unnecessary requests // when notifications are working right. Setting this too high will @@ -318,6 +319,7 @@ func (p *provider) subscribeHeaders(ctx context.Context, sub ethereum.Subscripti case hdr := <-h: log.Tracef("%q reported new tip at height %s (%s)", p.host, hdr.Number, hdr.Hash()) p.setTip(hdr, log) + p.tip.wsHeaderSeen.Store(true) case err, ok := <-sub.Err(): if !ok { // Subscription cancelled @@ -363,16 +365,12 @@ type multiRPCClient struct { chainID *big.Int net dex.Network + finalizeConfs uint64 + providerMtx sync.RWMutex endpoints []string providers []*provider - lastNonce struct { - sync.Mutex - nonce uint64 - stamp time.Time - } - // When we send transactions close together, we'll want to use the same // provider. lastProvider struct { @@ -390,7 +388,15 @@ type multiRPCClient struct { var _ ethFetcher = (*multiRPCClient)(nil) -func newMultiRPCClient(dir string, endpoints []string, log dex.Logger, cfg *params.ChainConfig, net dex.Network) (*multiRPCClient, error) { +func newMultiRPCClient( + dir string, + endpoints []string, + log dex.Logger, + cfg *params.ChainConfig, + finalizeConfs uint64, + net dex.Network, +) (*multiRPCClient, error) { + walletDir := getWalletDir(dir, net) creds, err := pathCredentials(filepath.Join(walletDir, "keystore")) if err != nil { @@ -398,12 +404,13 @@ func newMultiRPCClient(dir string, endpoints []string, log dex.Logger, cfg *para } m := &multiRPCClient{ - net: net, - cfg: cfg, - log: log, - creds: creds, - chainID: cfg.ChainID, - endpoints: endpoints, + net: net, + cfg: cfg, + log: log, + creds: creds, + chainID: cfg.ChainID, + endpoints: endpoints, + finalizeConfs: finalizeConfs, } m.receipts.cache = make(map[common.Hash]*receiptRecord) m.receipts.lastClean = time.Now() @@ -669,44 +676,6 @@ func (m *multiRPCClient) connect(ctx context.Context) (err error) { return nil } -// registerNonce returns true and saves the nonce for the next call when a nonce -// has not been received recently. -func (m *multiRPCClient) registerNonce(nonce uint64) bool { - const expiration = time.Minute - ln := &m.lastNonce - set := func() bool { - ln.nonce = nonce - ln.stamp = time.Now() - return true - } - ln.Lock() - defer ln.Unlock() - // Ok if the nonce is larger than previous. - if ln.nonce < nonce { - return set() - } - // Ok if initiation. - if ln.stamp.IsZero() { - return set() - } - // Ok if expiration has passed. - if time.Now().After(ln.stamp.Add(expiration)) { - return set() - } - // Nonce is the same or less than previous and expiration has not - // passed. - return false -} - -// voidUnusedNonce sets time to zero time so that the next call to registerNonce -// will return true. This is needed when we know that a tx has failed at the -// time of sending so that the same nonce can be used again. -func (m *multiRPCClient) voidUnusedNonce() { - m.lastNonce.Lock() - defer m.lastNonce.Unlock() - m.lastNonce.stamp = time.Time{} -} - // createAndCheckProviders creates and connects to providers. It checks that // unknown providers have a sufficient api to trade and saves good providers to // file. One bad provider or connect problem will cause this to error. @@ -854,47 +823,65 @@ func (m *multiRPCClient) cachedReceipt(txHash common.Hash) *types.Receipt { return nil } -func (m *multiRPCClient) transactionReceipt(ctx context.Context, txHash common.Hash) (r *types.Receipt, tx *types.Transaction, err error) { - // TODO - // TODO: Plug in to the monitoredTx system from #1638. - // TODO - if tx, _, err = m.getTransaction(ctx, txHash); err != nil { - return nil, nil, err - } - +func (m *multiRPCClient) transactionReceipt(ctx context.Context, txHash common.Hash) (r *types.Receipt, err error) { if r = m.cachedReceipt(txHash); r != nil { - return r, tx, nil + return r, nil } - - // Fetch a fresh one. - if err = m.withPreferred(ctx, func(ctx context.Context, p *provider) error { + if err := m.withPreferred(ctx, func(ctx context.Context, p *provider) error { r, err = p.ec.TransactionReceipt(ctx, txHash) return err }); err != nil { if isNotFoundError(err) { - return nil, nil, asset.CoinNotFoundError + return nil, asset.CoinNotFoundError } - return nil, nil, err + return nil, err } - - var confs int64 + var confs uint64 if r.BlockNumber != nil { - tip, err := m.bestHeader(ctx) + hdr, err := m.bestHeader(ctx) if err != nil { - return nil, nil, fmt.Errorf("bestHeader error: %v", err) + return nil, fmt.Errorf("error getting best header: %v", err) + } + if tip := hdr.Number.Uint64(); tip >= r.BlockNumber.Uint64() { + confs = tip - r.BlockNumber.Uint64() + 1 } - confs = new(big.Int).Sub(tip.Number, r.BlockNumber).Int64() + 1 } m.receipts.Lock() m.receipts.cache[txHash] = &receiptRecord{ r: r, lastAccess: time.Now(), - confirmed: confs > txConfsNeededToConfirm, + confirmed: confs > m.finalizeConfs, } m.receipts.Unlock() - return r, tx, nil + return r, nil + +} + +func (m *multiRPCClient) transactionAndReceipt(ctx context.Context, txHash common.Hash) (r *types.Receipt, tx *types.Transaction, err error) { + if tx, _, err = m.getTransaction(ctx, txHash); err != nil { + return nil, nil, err + } + + r, err = m.transactionReceipt(ctx, txHash) + return r, tx, err +} + +// nonce gets the best next nonce for the account. +func (m *multiRPCClient) nonce(ctx context.Context) (confirmed, pending *big.Int, _ error) { + confirmed, pending = new(big.Int), new(big.Int) + return confirmed, pending, m.withAll(ctx, func(ctx context.Context, p *provider) error { + confirmedAt, err := p.ec.NonceAt(ctx, m.creds.addr, nil) + if err == nil && confirmed.Uint64() < confirmedAt { + confirmed.SetUint64(confirmedAt) + } + pendingAt, err := p.ec.PendingNonceAt(ctx, m.creds.addr) + if err == nil && pending.Uint64() < pendingAt { + pending.SetUint64(pendingAt) + } + return err + }) } type rpcTransaction struct { @@ -1065,8 +1052,12 @@ func (m *multiRPCClient) withOne(ctx context.Context, providers []*provider, f f // will not try all providers. withAll should only be used for actions that are // safe to repeat, such as broadcasting a transaction or getting results for a // read-only operation. -func (m *multiRPCClient) withAll(ctx context.Context, f func(context.Context, *provider) error, - acceptabilityFilters ...acceptabilityFilter) error { +func (m *multiRPCClient) withAll( + ctx context.Context, + f func(context.Context, *provider) error, + acceptabilityFilters ...acceptabilityFilter, +) error { + var atLeastOne bool var errs []error for _, p := range m.nonceProviderList() { @@ -1098,7 +1089,6 @@ func (m *multiRPCClient) withAll(ctx context.Context, f func(context.Context, *p if discarded { atLeastOne = true } else { - errs = append(errs, err) m.log.Warnf("Failed request from %q: %v", p, err) } } @@ -1194,37 +1184,6 @@ func (m *multiRPCClient) nonceProviderList() []*provider { return providers } -// nextNonce returns the next nonce number for the account. -func (m *multiRPCClient) nextNonce(ctx context.Context) (nonce uint64, err error) { - checks := 5 - checkDelay := time.Second * 5 - for i := 0; i < checks; i++ { - var host string - err = m.withPreferred(ctx, func(ctx context.Context, p *provider) error { - host = p.host - nonce, err = p.ec.PendingNonceAt(ctx, m.creds.addr) - return err - }) - if err != nil { - return 0, err - } - if m.registerNonce(nonce) { - return nonce, nil - } - m.log.Warnf("host %s returned recently used account nonce number %d. try %d of %d.", - host, nonce, i+1, checks) - // Delay all but the last check. - if i+1 < checks { - select { - case <-time.After(checkDelay): - case <-ctx.Done(): - return 0, ctx.Err() - } - } - } - return 0, errors.New("preferred provider returned a recently used account nonce") -} - func (m *multiRPCClient) address() common.Address { return m.creds.addr } @@ -1305,20 +1264,21 @@ func (m *multiRPCClient) shutdown() { for _, p := range m.providerList() { p.shutdown() } +} +func allowAlreadyKnownFilter(err error) (discard, propagate, fail bool) { + // NOTE: err never hits errors.Is(err, txpool.ErrAlreadyKnown) because + // err is a *rpc.jsonError, but it does have a Message that matches. + return errorFilter(err, txpool.ErrAlreadyKnown, "known transaction"), false, false } -func (m *multiRPCClient) sendSignedTransaction(ctx context.Context, tx *types.Transaction) error { +func (m *multiRPCClient) sendSignedTransaction(ctx context.Context, tx *types.Transaction, filts ...acceptabilityFilter) error { var lastProvider *provider if err := m.withAll(ctx, func(ctx context.Context, p *provider) error { lastProvider = p m.log.Tracef("Sending signed tx via %q", p.host) return p.ec.SendTransaction(ctx, tx) - }, func(err error) (discard, propagate, fail bool) { - // NOTE: err never hits errors.Is(err, txpool.ErrAlreadyKnown) because - // err is a *rpc.jsonError, but it does have a Message that matches. - return errorFilter(err, txpool.ErrAlreadyKnown, "known transaction"), false, false - }); err != nil { + }, filts...); err != nil { return err } m.lastProvider.Lock() @@ -1328,7 +1288,7 @@ func (m *multiRPCClient) sendSignedTransaction(ctx context.Context, tx *types.Tr return nil } -func (m *multiRPCClient) sendTransaction(ctx context.Context, txOpts *bind.TransactOpts, to common.Address, data []byte) (*types.Transaction, error) { +func (m *multiRPCClient) sendTransaction(ctx context.Context, txOpts *bind.TransactOpts, to common.Address, data []byte, filts ...acceptabilityFilter) (*types.Transaction, error) { tx, err := m.creds.ks.SignTx(*m.creds.acct, types.NewTx(&types.DynamicFeeTx{ To: &to, ChainID: m.chainID, @@ -1344,7 +1304,7 @@ func (m *multiRPCClient) sendTransaction(ctx context.Context, txOpts *bind.Trans return nil, fmt.Errorf("signing error: %v", err) } - return tx, m.sendSignedTransaction(ctx, tx) + return tx, m.sendSignedTransaction(ctx, tx, filts...) } func (m *multiRPCClient) signData(data []byte) (sig, pubKey []byte, err error) { @@ -1401,26 +1361,31 @@ func (m *multiRPCClient) transactionConfirmations(ctx context.Context, txHash co // txOpts creates transaction options and sets the passed nonce if supplied. If // nonce is nil the next nonce will be fetched and the passed argument altered. -func (m *multiRPCClient) txOpts(ctx context.Context, val, maxGas uint64, maxFeeRate, nonce *big.Int) (*bind.TransactOpts, error) { - baseFees, gasTipCap, err := m.currentFees(ctx) - if err != nil { - return nil, err - } - - if maxFeeRate == nil { - maxFeeRate = new(big.Int).Mul(baseFees, big.NewInt(2)) +// txOpts can be called with either one or both of maxFeeRate or tipRate, but +// if either is nil, as many as two RPC calls may be made to establish the +// missing values. If the maxFeeRate is not specified, the standard 2*base+tip +// formula is used. +func (m *multiRPCClient) txOpts(ctx context.Context, val, maxGas uint64, maxFeeRate, tipRate, nonce *big.Int) (_ *bind.TransactOpts, err error) { + if maxFeeRate == nil || tipRate == nil { + baseRate, newTipRate, err := m.currentFees(ctx) + if err != nil { + return nil, err + } + if tipRate == nil { + tipRate = newTipRate + } + maxFeeRate = new(big.Int).Add(tipRate, new(big.Int).Mul(baseRate, big.NewInt(2))) } - txOpts := newTxOpts(ctx, m.creds.addr, val, maxGas, maxFeeRate, gasTipCap) + txOpts := newTxOpts(ctx, m.creds.addr, val, maxGas, maxFeeRate, tipRate) // If nonce is not nil, this indicates that we are trying to re-send an // old transaction with higher fee in order to ensure it is mined. if nonce == nil { - n, err := m.nextNonce(ctx) + _, nonce, err = m.nonce(ctx) if err != nil { return nil, fmt.Errorf("error getting nonce: %v", err) } - nonce = new(big.Int).SetUint64(n) } txOpts.Nonce = nonce @@ -1613,7 +1578,7 @@ func newCompatibilityTests(cb bind.ContractBackend, compat *CompatibilityData, l if err != nil { return err } - log.Debugf("#### Retrieved tip cap: %d gwei", dexeth.WeiToGwei(tipCap)) + log.Debugf("#### Retrieved tip cap: %d gwei", dexeth.WeiToGweiCeil(tipCap)) return nil }, }, @@ -1624,7 +1589,7 @@ func newCompatibilityTests(cb bind.ContractBackend, compat *CompatibilityData, l if err != nil { return err } - log.Debugf("#### Balance retrieved: %.9f", float64(dexeth.WeiToGwei(bal))/1e9) + log.Debugf("#### Balance retrieved: %.9f", float64(dexeth.WeiToGweiCeil(bal))/1e9) return nil }, }, diff --git a/client/asset/eth/multirpc_live_test.go b/client/asset/eth/multirpc_live_test.go index 9b2b64c749..28cff731e9 100644 --- a/client/asset/eth/multirpc_live_test.go +++ b/client/asset/eth/multirpc_live_test.go @@ -115,3 +115,7 @@ func TestFreeTestnetServers(t *testing.T) { func TestMainnetCompliance(t *testing.T) { mt.TestMainnetCompliance(t) } + +func TestReceiptsHaveEffectiveGasPrice(t *testing.T) { + mt.TestReceiptsHaveEffectiveGasPrice(t) +} diff --git a/client/asset/eth/multirpc_test_util.go b/client/asset/eth/multirpc_test_util.go index b9e2a67d8c..857dc7882c 100644 --- a/client/asset/eth/multirpc_test_util.go +++ b/client/asset/eth/multirpc_test_util.go @@ -6,6 +6,7 @@ import ( "context" "flag" "fmt" + "math/big" "math/rand" "os" "os/exec" @@ -17,7 +18,9 @@ import ( "decred.org/dcrdex/client/asset" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/encode" + dexeth "decred.org/dcrdex/dex/networks/eth" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus/misc" "github.com/ethereum/go-ethereum/params" ) @@ -92,7 +95,7 @@ func (m *MRPCTest) rpcClient(dir string, seed []byte, endpoints []string, net de return nil, fmt.Errorf("error creating wallet: %v", err) } - return newMultiRPCClient(dir, endpoints, log, cfg, net) + return newMultiRPCClient(dir, endpoints, log, cfg, 3, net) } func (m *MRPCTest) TestHTTP(t *testing.T, port string) { @@ -150,7 +153,7 @@ func (m *MRPCTest) TestSimnetMultiRPCClient(t *testing.T, wsPort, httpPort strin for i := 0; i < 10; i++ { // Send two in a row. They should use each provider, preferred first. for j := 0; j < 2; j++ { - txOpts, err := cl.txOpts(ctx, amt, defaultSendGasLimit, nil, nil) + txOpts, err := cl.txOpts(ctx, amt, defaultSendGasLimit, nil, nil, nil) if err != nil { t.Fatal(err) } @@ -176,6 +179,7 @@ func (m *MRPCTest) TestSimnetMultiRPCClient(t *testing.T, wsPort, httpPort strin func (m *MRPCTest) TestMonitorNet(t *testing.T, net dex.Network) { seed, providers := m.readProviderFile(t, net) dir, _ := os.MkdirTemp("", "") + defer os.RemoveAll(dir) cl, err := m.rpcClient(dir, seed, providers, net, true) if err != nil { @@ -289,6 +293,136 @@ func (m *MRPCTest) TestMainnetCompliance(t *testing.T) { } } +func (m *MRPCTest) TestReceiptsHaveEffectiveGasPrice(t *testing.T) { + m.withClient(t, dex.Mainnet, func(ctx context.Context, cl *multiRPCClient) { + if err := cl.withAny(ctx, func(ctx context.Context, p *provider) error { + blk, err := p.ec.BlockByNumber(ctx, nil) + if err != nil { + return fmt.Errorf("BlockByNumber error: %v", err) + } + h := blk.Number() + const m = 20 // how many txs + var n int + for n < m { + txs := blk.Transactions() + fmt.Printf("##### Block %d has %d transactions", h, len(txs)) + for _, tx := range txs { + n++ + r, err := cl.transactionReceipt(ctx, tx.Hash()) + if err != nil { + return fmt.Errorf("transactionReceipt error: %v", err) + } + if r.EffectiveGasPrice != nil { + fmt.Printf("##### Effective gas price: %s \n", r.EffectiveGasPrice) + } else { + fmt.Printf("##### No effective gas price for tx %s \n", tx.Hash()) + } + } + h.Add(h, big.NewInt(-1)) + blk, err = p.ec.BlockByNumber(ctx, h) + if err != nil { + return fmt.Errorf("error getting block %d: %w", h, err) + } + } + return nil + }); err != nil { + t.Fatal(err) + } + }) +} + +func (m *MRPCTest) withClient(t *testing.T, net dex.Network, f func(context.Context, *multiRPCClient)) { + seed, providers := m.readProviderFile(t, net) + dir, _ := os.MkdirTemp("", "") + defer os.RemoveAll(dir) + + cl, err := m.rpcClient(dir, seed, providers, net, false) + if err != nil { + t.Fatalf("Error creating rpc client: %v", err) + } + + ctx, cancel := context.WithTimeout(m.ctx, time.Hour) + defer cancel() + + if err := cl.connect(ctx); err != nil { + t.Fatalf("Connection error: %v", err) + } + + f(ctx, cl) +} + +// FeeHistory prints the base fees sampled once per week going back the +// specified number of days. +func (m *MRPCTest) FeeHistory(t *testing.T, net dex.Network, blockTimeSecs, days uint64) { + m.withClient(t, net, func(ctx context.Context, cl *multiRPCClient) { + tip, err := cl.bestHeader(ctx) + if err != nil { + t.Fatalf("bestHeader error: %v", err) + } + + tipHeight := tip.Number.Uint64() + + baseFees := misc.CalcBaseFee(cl.cfg, tip) + + fmt.Printf("##### Tip = %d \n", tipHeight) + fmt.Printf("##### Current base fees: %s \n", fmtFee(baseFees)) + + const secondsPerDay = 86_400 + var samplingDuration uint64 = 7 * secondsPerDay // Check every 7 days + totalDuration := secondsPerDay * days + n := totalDuration / samplingDuration + samplingDistance := samplingDuration / blockTimeSecs + fees := make([]uint64, n) + for i := range fees { + height := tipHeight - (uint64(i+1) * samplingDistance) + hdr, err := cl.HeaderByNumber(ctx, big.NewInt(int64(height))) + if err != nil { + t.Fatalf("HeaderByNumber(%d) error: %v", height, err) + } + if hdr.BaseFee == nil { + fmt.Println("nil base fees for height", height) + continue + } + baseFees = misc.CalcBaseFee(cl.cfg, hdr) + fmt.Printf("##### Base fees height %d @ %s: %s \n", height, time.Unix(int64(hdr.Time), 0), fmtFee(baseFees)) + } + }) +} + +func (m *MRPCTest) TipCaps(t *testing.T, net dex.Network) { + m.withClient(t, net, func(ctx context.Context, cl *multiRPCClient) { + if err := cl.withAny(ctx, func(ctx context.Context, p *provider) error { + blk, err := p.ec.BlockByNumber(ctx, nil) + if err != nil { + return err + } + h := blk.Number() + const m = 20 // how many txs + var n int + for { + txs := blk.Transactions() + fmt.Printf("##### Block %d has %d transactions \n", h, len(txs)) + for _, tx := range txs { + n++ + fmt.Println("##### Tx tip cap =", fmtFee(tx.GasTipCap())) + } + if n >= m { + break + } + h.Add(h, big.NewInt(-1)) + blk, err = p.ec.BlockByNumber(ctx, h) + if err != nil { + return fmt.Errorf("error getting block %d: %w", h, err) + } + } + + return nil + }); err != nil { + t.Fatalf("Error getting block: %v", err) + } + }) +} + func (m *MRPCTest) testSimnetEndpoint(endpoints []string, syncBlocks uint64, tFunc func(context.Context, *multiRPCClient)) error { dir, _ := os.MkdirTemp("", "") defer os.RemoveAll(dir) @@ -361,3 +495,10 @@ func (m *MRPCTest) readProviderFile(t *testing.T, net dex.Network) (seed []byte, } return } + +func fmtFee(v *big.Int) string { + if v.Cmp(dexeth.GweiToWei(1)) < 0 { + return fmt.Sprintf("%s wei / gas", v) + } + return fmt.Sprintf("%d gwei / gas", dexeth.WeiToGwei(v)) +} diff --git a/client/asset/eth/nodeclient.go b/client/asset/eth/nodeclient.go index 13e2ec46ed..1331debdbd 100644 --- a/client/asset/eth/nodeclient.go +++ b/client/asset/eth/nodeclient.go @@ -171,8 +171,12 @@ func (n *nodeClient) locked() bool { return status != "Unlocked" } +func (n *nodeClient) transactionReceipt(ctx context.Context, txHash common.Hash) (r *types.Receipt, err error) { + return nil, fmt.Errorf("unimplemented") +} + // transactionReceipt retrieves the transaction's receipt. -func (n *nodeClient) transactionReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, *types.Transaction, error) { +func (n *nodeClient) transactionAndReceipt(ctx context.Context, txHash common.Hash) (*types.Receipt, *types.Transaction, error) { tx, blockHash, _, index, err := n.leth.ApiBackend.GetTransaction(ctx, txHash) if err != nil { if errors.Is(err, ethereum.NotFound) { @@ -197,6 +201,10 @@ func (n *nodeClient) transactionReceipt(ctx context.Context, txHash common.Hash) return receipt, tx, nil } +func (n *nodeClient) nonce(ctx context.Context) (*big.Int, *big.Int, error) { + return nil, nil, errors.New("unimplemented") +} + // pendingTransactions returns pending transactions. func (n *nodeClient) pendingTransactions() ([]*types.Transaction, error) { return n.leth.ApiBackend.GetPoolTransactions() @@ -224,7 +232,7 @@ func (n *nodeClient) getConfirmedNonce(ctx context.Context) (uint64, error) { // sendTransaction sends a tx. The nonce should be set in txOpts. func (n *nodeClient) sendTransaction(ctx context.Context, txOpts *bind.TransactOpts, - to common.Address, data []byte) (*types.Transaction, error) { + to common.Address, data []byte, filts ...acceptabilityFilter) (*types.Transaction, error) { tx, err := n.creds.ks.SignTx(*n.creds.acct, types.NewTx(&types.DynamicFeeTx{ To: &to, @@ -309,7 +317,7 @@ func (n *nodeClient) getCodeAt(ctx context.Context, contractAddr common.Address) // // NOTE: The nonce included in the txOpts must be sent before txOpts is used // again. The caller should ensure that txOpts -> send sequence is synchronized. -func (n *nodeClient) txOpts(ctx context.Context, val, maxGas uint64, maxFeeRate, nonce *big.Int) (*bind.TransactOpts, error) { +func (n *nodeClient) txOpts(ctx context.Context, val, maxGas uint64, maxFeeRate, tipRate, nonce *big.Int) (*bind.TransactOpts, error) { baseFee, gasTipCap, err := n.currentFees(ctx) if err != nil { return nil, err @@ -387,14 +395,22 @@ func (n *nodeClient) transactionConfirmations(ctx context.Context, txHash common } // sendSignedTransaction injects a signed transaction into the pending pool for execution. -func (n *nodeClient) sendSignedTransaction(ctx context.Context, tx *types.Transaction) error { +func (n *nodeClient) sendSignedTransaction(ctx context.Context, tx *types.Transaction, filts ...acceptabilityFilter) error { return n.leth.ApiBackend.SendTx(ctx, tx) } // newTxOpts is a constructor for a TransactOpts. func newTxOpts(ctx context.Context, from common.Address, val, maxGas uint64, maxFeeRate, gasTipCap *big.Int) *bind.TransactOpts { + // We'll enforce dexeth.MinGasTipCap since the server does, but this isn't + // necessarily a constant for all networks or under all conditions. + minGasWei := dexeth.GweiToWei(dexeth.MinGasTipCap) + if gasTipCap.Cmp(minGasWei) < 0 { + gasTipCap.Set(minGasWei) + } + // This is enforced by concensus. We shouldn't be able to get here with a + // swap tx. if gasTipCap.Cmp(maxFeeRate) > 0 { - gasTipCap = maxFeeRate + gasTipCap.Set(maxFeeRate) } return &bind.TransactOpts{ Context: ctx, diff --git a/client/asset/eth/nodeclient_harness_test.go b/client/asset/eth/nodeclient_harness_test.go index 3af1253e3c..2c25d0b0fa 100644 --- a/client/asset/eth/nodeclient_harness_test.go +++ b/client/asset/eth/nodeclient_harness_test.go @@ -153,7 +153,7 @@ func waitForReceipt(nc ethFetcher, tx *types.Transaction) (*types.Receipt, error case <-ctx.Done(): return nil, ctx.Err() case <-time.After(time.Second): - receipt, _, err := nc.transactionReceipt(ctx, hash) + receipt, err := nc.transactionReceipt(ctx, hash) if err != nil { if errors.Is(err, asset.CoinNotFoundError) { continue @@ -255,7 +255,7 @@ func prepareRPCClient(name, dataDir string, providers []string, net dex.Network) return nil, nil, err } - c, err := newMultiRPCClient(dataDir, providers, tLogger.SubLogger(name), cfg, net) + c, err := newMultiRPCClient(dataDir, providers, tLogger.SubLogger(name), cfg, 3, net) if err != nil { return nil, nil, fmt.Errorf("(%s) newNodeClient error: %v", name, err) } @@ -695,7 +695,7 @@ func prepareTokenClients(t *testing.T) { if err != nil { t.Fatalf("initiator unlock error; %v", err) } - txOpts, err := ethClient.txOpts(ctx, 0, tokenGases.Approve, nil, nil) + txOpts, err := ethClient.txOpts(ctx, 0, tokenGases.Approve, nil, nil, nil) if err != nil { t.Fatalf("txOpts error: %v", err) } @@ -708,7 +708,7 @@ func prepareTokenClients(t *testing.T) { t.Fatalf("participant unlock error; %v", err) } - txOpts, err = participantEthClient.txOpts(ctx, 0, tokenGases.Approve, nil, nil) + txOpts, err = participantEthClient.txOpts(ctx, 0, tokenGases.Approve, nil, nil, nil) if err != nil { t.Fatalf("txOpts error: %v", err) } @@ -932,7 +932,7 @@ func testSendTransaction(t *testing.T) { t.Fatalf("no CoinNotFoundError") } - txOpts, err := ethClient.txOpts(ctx, 1, defaultSendGasLimit, nil, nil) + txOpts, err := ethClient.txOpts(ctx, 1, defaultSendGasLimit, nil, nil, nil) if err != nil { t.Fatalf("txOpts error: %v", err) } @@ -1014,10 +1014,11 @@ func testSendSignedTransaction(t *testing.T) { ks = c.creds.ks chainID = c.chainID case *multiRPCClient: - nonce, err = c.nextNonce(ctx) + n, _, err := c.nonce(ctx) if err != nil { t.Fatalf("error getting nonce: %v", err) } + nonce = n.Uint64() ks = c.creds.ks chainID = c.chainID } @@ -1068,7 +1069,7 @@ func testSendSignedTransaction(t *testing.T) { } func testTransactionReceipt(t *testing.T) { - txOpts, err := ethClient.txOpts(ctx, 1, defaultSendGasLimit, nil, nil) + txOpts, err := ethClient.txOpts(ctx, 1, defaultSendGasLimit, nil, nil, nil) if err != nil { t.Fatalf("txOpts error: %v", err) } @@ -1365,7 +1366,7 @@ func testInitiate(t *testing.T, assetID uint32) { } expGas := gases.SwapN(len(test.swaps)) - txOpts, err := ethClient.txOpts(ctx, optsVal, expGas, dexeth.GweiToWei(maxFeeRate), nil) + txOpts, err := ethClient.txOpts(ctx, optsVal, expGas, dexeth.GweiToWei(maxFeeRate), nil, nil) if err != nil { t.Fatalf("%s: txOpts error: %v", test.name, err) } @@ -1382,7 +1383,6 @@ func testInitiate(t *testing.T, assetID uint32) { } if err != nil { if test.swapErr { - sc.voidUnusedNonce() continue } t.Fatalf("%s: initiate error: %v", test.name, err) @@ -1498,7 +1498,7 @@ func testRedeemGas(t *testing.T, assetID uint32) { pc = participantTokenContractor } - txOpts, err := ethClient.txOpts(ctx, optsVal, gases.SwapN(len(swaps)), dexeth.GweiToWei(maxFeeRate), nil) + txOpts, err := ethClient.txOpts(ctx, optsVal, gases.SwapN(len(swaps)), dexeth.GweiToWei(maxFeeRate), nil, nil) if err != nil { t.Fatalf("txOpts error: %v", err) } @@ -1708,7 +1708,7 @@ func testRedeem(t *testing.T, assetID uint32) { } } - txOpts, err := test.redeemerClient.txOpts(ctx, optsVal, gases.SwapN(len(test.swaps)), dexeth.GweiToWei(maxFeeRate), nil) + txOpts, err := test.redeemerClient.txOpts(ctx, optsVal, gases.SwapN(len(test.swaps)), dexeth.GweiToWei(maxFeeRate), nil, nil) if err != nil { t.Fatalf("%s: txOpts error: %v", test.name, err) } @@ -1758,13 +1758,12 @@ func testRedeem(t *testing.T, assetID uint32) { } expGas := gases.RedeemN(len(test.redemptions)) - txOpts, err = test.redeemerClient.txOpts(ctx, 0, expGas, dexeth.GweiToWei(maxFeeRate), nil) + txOpts, err = test.redeemerClient.txOpts(ctx, 0, expGas, dexeth.GweiToWei(maxFeeRate), nil, nil) if err != nil { t.Fatalf("%s: txOpts error: %v", test.name, err) } tx, err = test.redeemerContractor.redeem(txOpts, test.redemptions) if test.expectRedeemErr { - test.redeemerContractor.voidUnusedNonce() if err == nil { t.Fatalf("%s: expected error but did not get", test.name) } @@ -1877,7 +1876,7 @@ func testRefundGas(t *testing.T, assetID uint32) { lockTime := uint64(time.Now().Unix()) - txOpts, err := ethClient.txOpts(ctx, optsVal, gases.SwapN(1), nil, nil) + txOpts, err := ethClient.txOpts(ctx, optsVal, gases.SwapN(1), nil, nil, nil) if err != nil { t.Fatalf("txOpts error: %v", err) } @@ -2006,7 +2005,7 @@ func testRefund(t *testing.T, assetID uint32) { inLocktime := uint64(time.Now().Add(test.addTime).Unix()) - txOpts, err := ethClient.txOpts(ctx, optsVal, gases.SwapN(1), nil, nil) + txOpts, err := ethClient.txOpts(ctx, optsVal, gases.SwapN(1), nil, nil, nil) if err != nil { t.Fatalf("%s: txOpts error: %v", test.name, err) } @@ -2020,7 +2019,7 @@ func testRefund(t *testing.T, assetID uint32) { t.Fatalf("%s: pre-redeem mining error: %v", test.name, err) } - txOpts, err = participantEthClient.txOpts(ctx, 0, gases.RedeemN(1), nil, nil) + txOpts, err = participantEthClient.txOpts(ctx, 0, gases.RedeemN(1), nil, nil, nil) if err != nil { t.Fatalf("%s: txOpts error: %v", test.name, err) } @@ -2057,7 +2056,7 @@ func testRefund(t *testing.T, assetID uint32) { test.name, test.isRefundable, isRefundable) } - txOpts, err = test.refunderClient.txOpts(ctx, 0, gases.Refund, dexeth.GweiToWei(maxFeeRate), nil) + txOpts, err = test.refunderClient.txOpts(ctx, 0, gases.Refund, dexeth.GweiToWei(maxFeeRate), nil, nil) if err != nil { t.Fatalf("%s: txOpts error: %v", test.name, err) } @@ -2152,7 +2151,7 @@ func testApproveAllowance(t *testing.T) { t.Fatal(err) } - txOpts, err := ethClient.txOpts(ctx, 0, tokenGases.Approve, nil, nil) + txOpts, err := ethClient.txOpts(ctx, 0, tokenGases.Approve, nil, nil, nil) if err != nil { t.Fatalf("txOpts error: %v", err) } diff --git a/client/asset/eth/txdb.go b/client/asset/eth/txdb.go index c6b64137eb..c2542bd365 100644 --- a/client/asset/eth/txdb.go +++ b/client/asset/eth/txdb.go @@ -4,20 +4,19 @@ package eth import ( - "bytes" "context" "encoding/binary" "encoding/json" "errors" "fmt" "math" + "math/big" "sync" - "sync/atomic" "time" "decred.org/dcrdex/client/asset" "decred.org/dcrdex/dex" - "decred.org/dcrdex/dex/encode" + "decred.org/dcrdex/dex/utils" "github.com/dgraph-io/badger" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" @@ -26,87 +25,40 @@ import ( // extendedWalletTx is an asset.WalletTransaction extended with additional // fields used for tracking transactions. type extendedWalletTx struct { - mtx sync.RWMutex *asset.WalletTransaction - BlockSubmitted uint64 `json:"blockSubmitted"` - SubmissionTime uint64 `json:"timeStamp"` - - lastCheck uint64 - savedToDB bool + BlockSubmitted uint64 `json:"blockSubmitted"` + SubmissionTime uint64 `json:"timeStamp"` // seconds + Nonce *big.Int `json:"nonce"` + Receipt *types.Receipt `json:"receipt,omitempty"` + RawTx dex.Bytes `json:"rawTx"` + // NonceReplacement is a transaction with the same nonce that was accepted + // by the network, meaning this tx was not applied. + NonceReplacement string `json:"nonceReplacement,omitempty"` + // FeeReplacement is true if the NonceReplacement is the same tx as this + // one, just with higher fees. + FeeReplacement bool `json:"feeReplacement,omitempty"` + // AssumedLost will be set to true if a transaction is assumed to be lost. + // This typically requires feedback from the user in response to an + // ActionRequiredNote. + AssumedLost bool `json:"assumedLost,omitempty"` + + txHash common.Hash + lastCheck uint64 + savedToDB bool + lastBroadcast time.Time + lastFeeCheck time.Time + actionRequested bool + actionIgnored time.Time + indexed bool } -// monitoredTx is used to keep track of redemption transactions that have not -// yet been confirmed. If a transaction has to be replaced due to the fee -// being too low or another transaction being mined with the same nonce, -// the replacement transaction's ID is recorded in the replacementTx field. -// replacedTx is used to maintain a doubly linked list, which allows deletion -// of transactions that were replaced after a transaction is confirmed. -type monitoredTx struct { - tx *types.Transaction - blockSubmitted uint64 - - // This mutex must be held during the entire process of confirming - // a transaction. This is to avoid confirmations of the same - // transactions happening concurrently resulting in more than one - // replacement for the same transaction. - mtx sync.Mutex - replacementTx *common.Hash - // replacedTx could be set when the tx is created, be immutable, and not - // need the mutex, but since Redeem doesn't know if the transaction is a - // replacement or a new one, this variable is set in recordReplacementTx. - replacedTx *common.Hash - errorsBroadcasted uint16 +func (t *extendedWalletTx) age() time.Duration { + return time.Since(time.Unix(int64(t.SubmissionTime), 0)) } -// MarshalBinary marshals a monitoredTx into a byte array. -// It satisfies the encoding.BinaryMarshaler interface for monitoredTx. -func (m *monitoredTx) MarshalBinary() (data []byte, err error) { - b := encode.BuildyBytes{0} - txB, err := m.tx.MarshalBinary() - if err != nil { - return nil, fmt.Errorf("error marshaling tx: %v", err) - } - b = b.AddData(txB) - - blockB := make([]byte, 8) - binary.BigEndian.PutUint64(blockB, m.blockSubmitted) - b = b.AddData(blockB) - - if m.replacementTx != nil { - replacementTxHash := m.replacementTx[:] - b = b.AddData(replacementTxHash) - } - - return b, nil -} - -// UnmarshalBinary loads a data from a marshalled byte array into a -// monitoredTx. -func (m *monitoredTx) UnmarshalBinary(data []byte) error { - ver, pushes, err := encode.DecodeBlob(data) - if err != nil { - return err - } - if ver != 0 { - return fmt.Errorf("unknown version %d", ver) - } - if len(pushes) != 2 && len(pushes) != 3 { - return fmt.Errorf("wrong number of pushes %d", len(pushes)) - } - m.tx = &types.Transaction{} - if err := m.tx.UnmarshalBinary(pushes[0]); err != nil { - return fmt.Errorf("error reading tx: %w", err) - } - - m.blockSubmitted = binary.BigEndian.Uint64(pushes[1]) - - if len(pushes) == 3 { - var replacementTxHash common.Hash - copy(replacementTxHash[:], pushes[2]) - m.replacementTx = &replacementTxHash - } - - return nil +func (t *extendedWalletTx) tx() (*types.Transaction, error) { + tx := new(types.Transaction) + return tx, tx.UnmarshalBinary(t.RawTx) } var ( @@ -116,9 +68,6 @@ var ( // txHashPrefix is the prefix for the key used to map a transaction hash // to a nonce key. txHashPrefix = []byte("txHash-") - // monitoredTxPrefix is the prefix for the key used to map a transaction - // hash to a monitoredTx. - monitoredTxPrefix = []byte("monitoredTx-") // dbVersionKey is the key used to store the database version. dbVersionKey = []byte("dbVersion") ) @@ -130,50 +79,28 @@ func nonceKey(nonce uint64) []byte { return key } -func nonceFromKey(nk []byte) (uint64, error) { - if !bytes.HasPrefix(nk, noncePrefix) { - return 0, fmt.Errorf("nonce key %x does not have nonce prefix %x", nk, noncePrefix) - } - return binary.BigEndian.Uint64(nk[len(noncePrefix):]), nil -} - -func txIDKey(txID string) []byte { - key := make([]byte, len(txHashPrefix)+len([]byte(txID))) +func txKey(txHash common.Hash) []byte { + key := make([]byte, len(txHashPrefix)+20) copy(key, txHashPrefix) - copy(key[len(txHashPrefix):], []byte(txID)) - return key -} - -func monitoredTxKey(txHash dex.Bytes) []byte { - key := make([]byte, len(monitoredTxPrefix)+len(txHash)) - copy(key, monitoredTxPrefix) - copy(key[len(monitoredTxPrefix):], txHash) + copy(key[len(txHashPrefix):], txHash[:]) return key } -func monitoredTxHashFromKey(mtk []byte) (common.Hash, error) { - if !bytes.HasPrefix(mtk, monitoredTxPrefix) { - return common.Hash{}, fmt.Errorf("monitored tx key %x does not have monitored tx prefix %x", mtk, monitoredTxPrefix) - } - var txHash common.Hash - copy(txHash[:], mtk[len(monitoredTxPrefix):]) - return txHash, nil -} - // badgerDB returns ErrConflict when a read happening in a update (read/write) // transaction is stale. This function retries updates multiple times in // case of conflicts. -func (db *badgerTxDB) handleConflictWithBackoff(update func() error) error { - maxRetries := 10 +func (db *badgerTxDB) Update(f func(txn *badger.Txn) error) (err error) { + db.updateWG.Add(1) + defer db.updateWG.Done() + + const maxRetries = 10 sleepTime := 5 * time.Millisecond - var err error for i := 0; i < maxRetries; i++ { - sleepTime *= 2 - err = update() - if err != badger.ErrConflict { + if err = db.DB.Update(f); err == nil || !errors.Is(err, badger.ErrConflict) { return err } + sleepTime *= 2 time.Sleep(sleepTime) } @@ -183,124 +110,140 @@ func (db *badgerTxDB) handleConflictWithBackoff(update func() error) error { var maxNonceKey = nonceKey(math.MaxUint64) // initialDBVersion only contained mappings from txHash -> monitoredTx. -const initialDBVersion = 0 +// const initialDBVersion = 0 -// prefixDBVersion contains three mappings each marked with a prefix: +// prefixDBVersion contains two mappings each marked with a prefix: // // nonceKey -> extendedWalletTx (noncePrefix) // txHash -> nonceKey (txHashPrefix) -// txHash -> monitoredTx (monitoredTxPrefix) -const prefixDBVersion = 1 -const txDBVersion = prefixDBVersion +// const prefixDBVersion = 1 + +// txMappingVersion reverses the semantics so that all txs are accessible +// by txHash. +// +// nonceKey -> best-known txHash +// txHash -> extendedWalletTx, which contains a nonce +const txMappingVersion = 2 + +const txDBVersion = txMappingVersion type txDB interface { - connect(ctx context.Context) (*sync.WaitGroup, error) - storeTx(nonce uint64, wt *extendedWalletTx) error - removeTx(id string) error - getTxs(n int, refID *string, past bool, tokenID *uint32) ([]*asset.WalletTransaction, error) - getPendingTxs() (map[uint64]*extendedWalletTx, error) - storeMonitoredTx(txHash common.Hash, tx *monitoredTx) error - getMonitoredTxs() (map[common.Hash]*monitoredTx, error) - removeMonitoredTxs([]common.Hash) error + run(ctx context.Context) + storeTx(wt *extendedWalletTx) error + getTxs(n int, refID *common.Hash, past bool, tokenID *uint32) ([]*asset.WalletTransaction, error) + // getTx gets a single transaction. It is not an error if the tx is not known. + // In that case, a nil tx is returned. + getTx(txHash common.Hash) (*extendedWalletTx, error) + // getPendingTxs returns any recent txs that are not confirmed, ordered + // by nonce lowest-first. + getPendingTxs() ([]*extendedWalletTx, error) } type badgerTxDB struct { *badger.DB filePath string log dex.Logger - running atomic.Bool - wg sync.WaitGroup - ctx context.Context + updateWG sync.WaitGroup } var _ txDB = (*badgerTxDB)(nil) -// badgerLoggerWrapper wraps dex.Logger and translates Warnf to Warningf to -// satisfy badger.Logger. It also lowers the log level of Infof to Debugf -// and Debugf to Tracef. -type badgerLoggerWrapper struct { - dex.Logger -} - -var _ badger.Logger = (*badgerLoggerWrapper)(nil) - -// Debugf -> dex.Logger.Tracef -func (log *badgerLoggerWrapper) Debugf(s string, a ...interface{}) { - log.Tracef(s, a...) -} - -// Infof -> dex.Logger.Debugf -func (log *badgerLoggerWrapper) Infof(s string, a ...interface{}) { - log.Debugf(s, a...) -} - -// Warningf -> dex.Logger.Warnf -func (log *badgerLoggerWrapper) Warningf(s string, a ...interface{}) { - log.Warnf(s, a...) -} - -func newBadgerTxDB(filePath string, log dex.Logger) *badgerTxDB { - return &badgerTxDB{ - filePath: filePath, - log: log, - } -} -func (db *badgerTxDB) connect(ctx context.Context) (*sync.WaitGroup, error) { +func newBadgerTxDB(filePath string, log dex.Logger) (*badgerTxDB, error) { // If memory use is a concern, could try // .WithValueLogLoadingMode(options.FileIO) // default options.MemoryMap // .WithMaxTableSize(sz int64); // bytes, default 6MB // .WithValueLogFileSize(sz int64), bytes, default 1 GB, must be 1MB <= sz <= 1GB - opts := badger.DefaultOptions(db.filePath).WithLogger(&badgerLoggerWrapper{db.log}) + opts := badger.DefaultOptions(filePath).WithLogger(&badgerLoggerWrapper{log}) var err error - db.DB, err = badger.Open(opts) + bdb, err := badger.Open(opts) if err == badger.ErrTruncateNeeded { // Probably a Windows thing. // https://github.com/dgraph-io/badger/issues/744 - db.log.Warnf("error opening badger db: %v", err) + log.Warnf("error opening badger db: %v", err) // Try again with value log truncation enabled. opts.Truncate = true - db.log.Warnf("Attempting to reopen badger DB with the Truncate option set...") - db.DB, err = badger.Open(opts) + log.Warnf("Attempting to reopen badger DB with the Truncate option set...") + bdb, err = badger.Open(opts) } if err != nil { return nil, err } - db.ctx = ctx + + db := &badgerTxDB{ + DB: bdb, + filePath: filePath, + log: log, + } err = db.updateVersion() if err != nil { return nil, fmt.Errorf("failed to update db: %w", err) } + return db, nil +} - db.running.Store(true) - - var wg sync.WaitGroup - wg.Add(1) - go func() { - defer wg.Done() - - ticker := time.NewTicker(5 * time.Minute) - defer ticker.Stop() - for { - select { - case <-ticker.C: - err := db.RunValueLogGC(0.5) - if err != nil && !errors.Is(err, badger.ErrNoRewrite) { - db.log.Errorf("garbage collection error: %v", err) - } - case <-ctx.Done(): - db.running.Store(false) - db.wg.Wait() - err = db.Close() - if err != nil { - db.log.Errorf("error closing db: %v", err) - } - return +func (db *badgerTxDB) run(ctx context.Context) { + defer func() { + db.updateWG.Wait() + db.Close() + }() + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + err := db.RunValueLogGC(0.5) + if err != nil && !errors.Is(err, badger.ErrNoRewrite) { + db.log.Errorf("garbage collection error: %v", err) } + case <-ctx.Done(): + return } - }() + } +} + +// txForNonce gets the registered for the given nonce. +func txForNonce(txn *badger.Txn, nonce uint64) (tx *extendedWalletTx, err error) { + nk := nonceKey(nonce) + txHashi, err := txn.Get(nk) + if err != nil { + return nil, err + } + return tx, txHashi.Value(func(txHashB []byte) error { + var txHash common.Hash + copy(txHash[:], txHashB) + txi, err := txn.Get(txKey(txHash)) + if err != nil { + return err + } + return txi.Value(func(wtB []byte) error { + tx, err = unmarshalTx(wtB) + return err + }) + }) +} - return &wg, nil +// txForHash get the extendedWalletTx at the given tx hash and checks for any +// unsaved nonce replacement. +func txForHash(txn *badger.Txn, txHash common.Hash) (wt *extendedWalletTx, err error) { + txi, err := txn.Get(txKey(txHash)) + if err != nil { + return nil, err + } + return wt, txi.Value(func(wtB []byte) error { + wt, err = unmarshalTx(wtB) + if err != nil || wt.Confirmed || wt.NonceReplacement != "" { + return err + } + nonceTx, err := txForNonce(txn, wt.Nonce.Uint64()) + if err != nil { + return err + } + if nonceTx.txHash != wt.txHash && nonceTx.Confirmed { + wt.NonceReplacement = wt.txHash.String() + } + return nil + }) } // updateVersion updates the DB to the latest version. In version 0, @@ -327,39 +270,18 @@ func (db *badgerTxDB) updateVersion() error { db.log.Errorf("error retrieving database version: %v", err) } - if version == initialDBVersion { - err = db.Update(func(txn *badger.Txn) error { - opts := badger.DefaultIteratorOptions - it := txn.NewIterator(opts) - defer it.Close() - - for it.Rewind(); it.Valid(); it.Next() { - item := it.Item() - key := item.Key() - newKey := monitoredTxKey(key) - monitoredTxB, err := item.ValueCopy(nil) - if err != nil { - return err - } - - err = txn.Set(newKey, monitoredTxB) - if err != nil { - return err - } - err = txn.Delete(key) - if err != nil { - return err - } - } - - versionB := make([]byte, 8) - binary.BigEndian.PutUint64(versionB, 1) + if version < txMappingVersion { + if err := db.DB.DropAll(); err != nil { + return fmt.Errorf("error deleting DB entries for version upgrade: %w", err) + } + versionB := make([]byte, 8) + binary.BigEndian.PutUint64(versionB, txMappingVersion) + if err = db.Update(func(txn *badger.Txn) error { return txn.Set(dbVersionKey, versionB) - }) - if err != nil { + }); err != nil { return err } - db.log.Infof("Updated database to version %d", prefixDBVersion) + db.log.Infof("Upgraded DB to version %d by deleting everything and starting from scratch.", txMappingVersion) } else if version > txDBVersion { return fmt.Errorf("database version %d is not supported", version) } @@ -367,177 +289,123 @@ func (db *badgerTxDB) updateVersion() error { return nil } -func (db *badgerTxDB) storeTxImpl(nonce uint64, wt *extendedWalletTx) error { +// storeTx stores a mapping from nonce to extendedWalletTx and a mapping from +// transaction hash to nonce so transactions can be looked up by hash. If a +// nonce already exists, the extendedWalletTx is overwritten. +func (db *badgerTxDB) storeTx(wt *extendedWalletTx) error { wtB, err := json.Marshal(wt) if err != nil { return err } - nk := nonceKey(nonce) - tk := txIDKey(wt.ID) + nonce := wt.Nonce.Uint64() return db.Update(func(txn *badger.Txn) error { - oldWtItem, err := txn.Get(nk) + // If there is not a confirmed tx at this tx's nonce, map the nonce + // to this tx. + nonceTx, err := txForNonce(txn, nonce) if err != nil && !errors.Is(err, badger.ErrKeyNotFound) { - return err + return fmt.Errorf("error reading nonce tx: %w", err) } - - // If there is an existing transaction with this nonce, delete the - // mapping from tx hash to nonce. - if err == nil { - oldWt := new(extendedWalletTx) - err = oldWtItem.Value(func(oldWtB []byte) error { - err := json.Unmarshal(oldWtB, oldWt) - if err != nil { - db.log.Errorf("unable to unmarhsal wallet transaction: %s: %v", string(oldWtB), err) - } - return err - }) - if err == nil && oldWt.ID != wt.ID { - err = txn.Delete(txIDKey(oldWt.ID)) - if err != nil { - db.log.Errorf("failed to delete old tx id: %s: %v", oldWt.ID, err) - } + // If we don't have a tx stored at the nonce or the tx stored at the + // nonce is not confirmed, put this one there instead, unless this one + // has been marked as nonce-replaced. + if (nonceTx == nil || !nonceTx.Confirmed) && wt.NonceReplacement == "" { + if err := txn.Set(nonceKey(nonce), wt.txHash[:]); err != nil { + return fmt.Errorf("error mapping nonce to tx hash: %w", err) } } - - // Store nonce key -> wallet transaction - if err := txn.Set(nk, wtB); err != nil { - return err - } - - // Store tx hash -> nonce key - return txn.Set(tk, nk) + // Store the tx at its hash. + return txn.Set(txKey(wt.txHash), wtB) }) } -// storeTx stores a mapping from nonce to extendedWalletTx and a mapping from -// transaction hash to nonce so transactions can be looked up by hash. If a -// nonce already exists, the extendedWalletTx is overwritten. -func (db *badgerTxDB) storeTx(nonce uint64, wt *extendedWalletTx) error { - db.wg.Add(1) - defer db.wg.Done() - if !db.running.Load() { - return fmt.Errorf("database is not running") - } - - return db.handleConflictWithBackoff(func() error { return db.storeTxImpl(nonce, wt) }) -} - -func (db *badgerTxDB) removeTxImpl(id string) error { - tk := txIDKey(id) - - return db.Update(func(txn *badger.Txn) error { - txIDEntry, err := txn.Get(tk) - if err != nil { - return err - } - err = txn.Delete(tk) - if err != nil { - return err - } - - nk, err := txIDEntry.ValueCopy(nil) - if err != nil { - return err +// getTx gets a single transaction. It is not an error if the tx is not known. +// In that case, a nil tx is returned. +func (db *badgerTxDB) getTx(txHash common.Hash) (tx *extendedWalletTx, err error) { + return tx, db.View(func(txn *badger.Txn) error { + tx, err = txForHash(txn, txHash) + if errors.Is(err, badger.ErrKeyNotFound) { + return nil } - - return txn.Delete(nk) + return err }) } -// removeTx removes a tx from the db. -func (db *badgerTxDB) removeTx(id string) error { - db.wg.Add(1) - defer db.wg.Done() - if !db.running.Load() { - return fmt.Errorf("database is not running") +// unmarshalTx attempts to decode the binary tx and sets some unexported fields. +func unmarshalTx(wtB []byte) (wt *extendedWalletTx, err error) { + if err = json.Unmarshal(wtB, &wt); err != nil { + return nil, err } - - return db.handleConflictWithBackoff(func() error { return db.removeTxImpl(id) }) + wt.txHash = common.HexToHash(wt.ID) + wt.lastBroadcast = time.Unix(int64(wt.SubmissionTime), 0) + wt.savedToDB = true + return } -// getTxs returns the n more recent transaction if refID is nil, or the -// n transactions before/after refID depending on the value of past. The -// transactions are returned in reverse chronological order. +// getTxs fetches n transactions. If no refID is provided, getTxs returns the +// n most recent txs in reverse-nonce order. If no refID is provided, the past +// argument is ignored. If a refID is provided, getTxs will return n txs +// starting with the nonce of the tx referenced. When refID is provided, and +// past is false, the results will be in increasing order starting at and +// including the nonce of the referenced tx. If refID is provided and past +// is true, the results will be in decreasing nonce order starting at and +// including the referenced tx. No orphans will be included in the results. // If a non-nil refID is not found, asset.CoinNotFoundError is returned. -func (db *badgerTxDB) getTxs(n int, refID *string, past bool, tokenID *uint32) ([]*asset.WalletTransaction, error) { - db.wg.Add(1) - defer db.wg.Done() - if !db.running.Load() { - return nil, fmt.Errorf("database is not running") - } - - var txs []*asset.WalletTransaction +func (db *badgerTxDB) getTxs(n int, refID *common.Hash, past bool, tokenID *uint32) ([]*asset.WalletTransaction, error) { + txs := make([]*asset.WalletTransaction, 0, n) - err := db.View(func(txn *badger.Txn) error { - var startNonceKey []byte + return txs, db.View(func(txn *badger.Txn) error { + opts := badger.DefaultIteratorOptions + opts.Reverse = true // If non refID, it's always reverse + opts.Prefix = noncePrefix + startNonceKey := maxNonceKey if refID != nil { + opts.Reverse = past // Get the nonce for the provided tx hash. - tk := txIDKey(*refID) - item, err := txn.Get(tk) + wt, err := txForHash(txn, *refID) if err != nil { - return asset.CoinNotFoundError - } - if startNonceKey, err = item.ValueCopy(nil); err != nil { + if errors.Is(err, badger.ErrKeyNotFound) { + return asset.CoinNotFoundError + } return err } - } else { - past = true - } - if startNonceKey == nil { - startNonceKey = maxNonceKey + startNonceKey = nonceKey(wt.Nonce.Uint64()) } - opts := badger.DefaultIteratorOptions - opts.Reverse = past - opts.Prefix = noncePrefix it := txn.NewIterator(opts) defer it.Close() for it.Seek(startNonceKey); it.Valid() && (n <= 0 || len(txs) < n); it.Next() { - item := it.Item() - err := item.Value(func(wtB []byte) error { - wt := new(asset.WalletTransaction) - err := json.Unmarshal(wtB, wt) + txHashi := it.Item() + if err := txHashi.Value(func(txHashB []byte) error { + var txHash common.Hash + copy(txHash[:], txHashB) + wt, err := txForHash(txn, txHash) if err != nil { - db.log.Errorf("unable to unmarhsal wallet transaction: %s: %v", string(wtB), err) return err } if tokenID != nil && (wt.TokenID == nil || *tokenID != *wt.TokenID) { return nil } - if past { - txs = append(txs, wt) - } else { - txs = append([]*asset.WalletTransaction{wt}, txs...) - } + txs = append(txs, wt.WalletTransaction) return nil - }) - if err != nil { + }); err != nil { return err } } return nil }) - - return txs, err } // getPendingTxs returns a map of nonce to extendedWalletTx for all // pending transactions. -func (db *badgerTxDB) getPendingTxs() (map[uint64]*extendedWalletTx, error) { - db.wg.Add(1) - defer db.wg.Done() - if !db.running.Load() { - return nil, fmt.Errorf("database is not running") - } - +func (db *badgerTxDB) getPendingTxs() ([]*extendedWalletTx, error) { // We will be iterating backwards from the most recent nonce. // If we find numConfirmedTxsToCheck consecutive confirmed transactions, // we can stop iterating. const numConfirmedTxsToCheck = 20 - txs := make(map[uint64]*extendedWalletTx, 4) + txs := make([]*extendedWalletTx, 0, 4) err := db.View(func(txn *badger.Txn) error { opts := badger.DefaultIteratorOptions @@ -548,28 +416,35 @@ func (db *badgerTxDB) getPendingTxs() (map[uint64]*extendedWalletTx, error) { var numConfirmedTxs int for it.Seek(maxNonceKey); it.Valid(); it.Next() { - item := it.Item() - err := item.Value(func(wtB []byte) error { - wt := new(extendedWalletTx) - err := json.Unmarshal(wtB, wt) + txHashi := it.Item() + err := txHashi.Value(func(txHashB []byte) error { + var txHash common.Hash + copy(txHash[:], txHashB) + txi, err := txn.Get(txKey(txHash)) if err != nil { - db.log.Errorf("unable to unmarhsal wallet transaction: %s: %v", string(wtB), err) return err } - if !wt.Confirmed { - numConfirmedTxs = 0 - nonce, err := nonceFromKey(item.Key()) + return txi.Value(func(wtB []byte) error { + wt, err := unmarshalTx(wtB) if err != nil { + db.log.Errorf("unable to unmarhsal wallet transaction: %s: %v", string(wtB), err) return err } - txs[nonce] = wt - } else { - numConfirmedTxs++ - if numConfirmedTxs >= numConfirmedTxsToCheck { + if wt.AssumedLost { return nil } - } - return nil + if !wt.Confirmed { + numConfirmedTxs = 0 + txs = append(txs, wt) + } else { + numConfirmedTxs++ + if numConfirmedTxs >= numConfirmedTxsToCheck { + return nil + } + } + return nil + }) + }) if err != nil { return err @@ -578,95 +453,31 @@ func (db *badgerTxDB) getPendingTxs() (map[uint64]*extendedWalletTx, error) { return nil }) - return txs, err -} - -func (db *badgerTxDB) storeMonitoredTxImpl(txHash common.Hash, tx *monitoredTx) error { - txKey := monitoredTxKey(txHash.Bytes()) - txBytes, err := tx.MarshalBinary() - if err != nil { - return err - } + utils.ReverseSlice(txs) - return db.Update(func(txn *badger.Txn) error { - return txn.Set(txKey, txBytes) - }) + return txs, err } -// storeMonitoredTx stores a monitoredTx in the database. -func (db *badgerTxDB) storeMonitoredTx(txHash common.Hash, tx *monitoredTx) error { - db.wg.Add(1) - defer db.wg.Done() - if !db.running.Load() { - return fmt.Errorf("database is not running") - } - - return db.handleConflictWithBackoff(func() error { return db.storeMonitoredTxImpl(txHash, tx) }) +// badgerLoggerWrapper wraps dex.Logger and translates Warnf to Warningf to +// satisfy badger.Logger. It also lowers the log level of Infof to Debugf +// and Debugf to Tracef. +type badgerLoggerWrapper struct { + dex.Logger } -// getMonitoredTxs returns a map of transaction hash to monitoredTx for all -// monitored transactions. -func (db *badgerTxDB) getMonitoredTxs() (map[common.Hash]*monitoredTx, error) { - db.wg.Add(1) - defer db.wg.Done() - if !db.running.Load() { - return nil, fmt.Errorf("database is not running") - } - - monitoredTxs := make(map[common.Hash]*monitoredTx) - - err := db.View(func(txn *badger.Txn) error { - opts := badger.DefaultIteratorOptions - opts.Prefix = monitoredTxPrefix - it := txn.NewIterator(opts) - defer it.Close() - - for it.Seek(monitoredTxPrefix); it.Valid(); it.Next() { - item := it.Item() - err := item.Value(func(txBytes []byte) error { - tx := new(monitoredTx) - err := tx.UnmarshalBinary(txBytes) - if err != nil { - return err - } - txHash, err := monitoredTxHashFromKey(item.Key()) - if err != nil { - return err - } - monitoredTxs[txHash] = tx - return nil - }) - if err != nil { - return err - } - } - return nil - }) +var _ badger.Logger = (*badgerLoggerWrapper)(nil) - return monitoredTxs, err +// Debugf -> dex.Logger.Tracef +func (log *badgerLoggerWrapper) Debugf(s string, a ...interface{}) { + log.Tracef(s, a...) } -func (db *badgerTxDB) removeMonitoredTxsImpl(txHashes []common.Hash) error { - return db.Update(func(txn *badger.Txn) error { - for _, txHash := range txHashes { - txKey := monitoredTxKey(txHash.Bytes()) - err := txn.Delete(txKey) - if err != nil { - return err - } - } - return nil - }) +// Infof -> dex.Logger.Debugf +func (log *badgerLoggerWrapper) Infof(s string, a ...interface{}) { + log.Debugf(s, a...) } -// removeMonitoredTxs removes the monitored transactions with the provided -// hashes from the database. -func (db *badgerTxDB) removeMonitoredTxs(txHashes []common.Hash) error { - db.wg.Add(1) - defer db.wg.Done() - if !db.running.Load() { - return fmt.Errorf("database is not running") - } - - return db.handleConflictWithBackoff(func() error { return db.removeMonitoredTxsImpl(txHashes) }) +// Warningf -> dex.Logger.Warnf +func (log *badgerLoggerWrapper) Warningf(s string, a ...interface{}) { + log.Warnf(s, a...) } diff --git a/client/asset/eth/txdb_test.go b/client/asset/eth/txdb_test.go index 8888ceb805..e006e060f1 100644 --- a/client/asset/eth/txdb_test.go +++ b/client/asset/eth/txdb_test.go @@ -1,27 +1,25 @@ +//go:build !harness && !rpclive + package eth import ( - "context" - "encoding/hex" + "math/big" "reflect" "testing" "decred.org/dcrdex/client/asset" "decred.org/dcrdex/dex" - "decred.org/dcrdex/dex/encode" - "github.com/dgraph-io/badger" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" ) func TestTxDB(t *testing.T) { tempDir := t.TempDir() tLogger := dex.StdOutLogger("TXDB", dex.LevelTrace) - txHistoryStore := newBadgerTxDB(tempDir, tLogger) + // Grab these for the tx generation utilities + _, eth, node, shutdown := tassetWallet(BipID) + shutdown() - ctx, cancel := context.WithCancel(context.Background()) - wg, err := txHistoryStore.connect(ctx) + txHistoryStore, err := newBadgerTxDB(tempDir, tLogger) if err != nil { t.Fatalf("error connecting to tx history store: %v", err) } @@ -34,64 +32,22 @@ func TestTxDB(t *testing.T) { t.Fatalf("expected 0 txs but got %d", len(txs)) } - wt1 := &extendedWalletTx{ - WalletTransaction: &asset.WalletTransaction{ - Type: asset.Send, - ID: hex.EncodeToString(encode.RandomBytes(32)), - Amount: 100, - Fees: 300, - BlockNumber: 123, - AdditionalData: map[string]string{ - "Nonce": "1", - }, - TokenID: &usdcTokenID, - Confirmed: true, - }, - } - - wt2 := &extendedWalletTx{ - WalletTransaction: &asset.WalletTransaction{ - Type: asset.Swap, - ID: hex.EncodeToString(encode.RandomBytes(32)), - Amount: 200, - Fees: 100, - BlockNumber: 124, - AdditionalData: map[string]string{ - "Nonce": "2", - }, - }, - } - - wt3 := &extendedWalletTx{ - WalletTransaction: &asset.WalletTransaction{ - Type: asset.Redeem, - ID: hex.EncodeToString(encode.RandomBytes(32)), - Amount: 200, - Fees: 200, - BlockNumber: 125, - AdditionalData: map[string]string{ - "Nonce": "3", - }, - }, + newTx := func(nonce uint64) *extendedWalletTx { + return eth.extendedTx(node.newTransaction(nonce, big.NewInt(1)), asset.Send, 1) } - wt4 := &extendedWalletTx{ - WalletTransaction: &asset.WalletTransaction{ - Type: asset.Redeem, - ID: hex.EncodeToString(encode.RandomBytes(32)), - Amount: 200, - Fees: 300, - BlockNumber: 125, - AdditionalData: map[string]string{ - "Nonce": "3", - }, - }, - } + wt1 := newTx(1) + wt1.Confirmed = true + wt1.TokenID = &usdcTokenID + wt2 := newTx(2) + wt3 := newTx(3) + wt4 := newTx(4) - err = txHistoryStore.storeTx(1, wt1) + err = txHistoryStore.storeTx(wt1) if err != nil { t.Fatalf("error storing tx: %v", err) } + txs, err = txHistoryStore.getTxs(0, nil, true, nil) if err != nil { t.Fatalf("error retrieving txs: %v", err) @@ -101,7 +57,7 @@ func TestTxDB(t *testing.T) { t.Fatalf("expected txs %+v but got %+v", expectedTxs, txs) } - err = txHistoryStore.storeTx(2, wt2) + err = txHistoryStore.storeTx(wt2) if err != nil { t.Fatalf("error storing tx: %v", err) } @@ -114,7 +70,7 @@ func TestTxDB(t *testing.T) { t.Fatalf("expected txs %+v but got %+v", expectedTxs, txs) } - err = txHistoryStore.storeTx(3, wt3) + err = txHistoryStore.storeTx(wt3) if err != nil { t.Fatalf("error storing tx: %v", err) } @@ -127,7 +83,7 @@ func TestTxDB(t *testing.T) { t.Fatalf("expected txs %+v but got %+v", expectedTxs, txs) } - txs, err = txHistoryStore.getTxs(0, &wt2.ID, true, nil) + txs, err = txHistoryStore.getTxs(0, &wt2.txHash, true, nil) if err != nil { t.Fatalf("error retrieving txs: %v", err) } @@ -136,35 +92,20 @@ func TestTxDB(t *testing.T) { t.Fatalf("expected txs %+v but got %+v", expectedTxs, txs) } - txs, err = txHistoryStore.getTxs(0, &wt2.ID, false, nil) + txs, err = txHistoryStore.getTxs(0, &wt2.txHash, false, nil) if err != nil { t.Fatalf("error retrieving txs: %v", err) } - expectedTxs = []*asset.WalletTransaction{wt3.WalletTransaction, wt2.WalletTransaction} + expectedTxs = []*asset.WalletTransaction{wt2.WalletTransaction, wt3.WalletTransaction} if !reflect.DeepEqual(expectedTxs, txs) { t.Fatalf("expected txs %+v but got %+v", expectedTxs, txs) } - // Update nonce with different tx - err = txHistoryStore.storeTx(3, wt4) - if err != nil { - t.Fatalf("error storing tx: %v", err) - } - txs, err = txHistoryStore.getTxs(0, nil, false, nil) - if err != nil { - t.Fatalf("error retrieving txs: %v", err) - } - if len(txs) != 3 { - t.Fatalf("expected 3 txs but got %d", len(txs)) - } - expectedTxs = []*asset.WalletTransaction{wt4.WalletTransaction, wt2.WalletTransaction, wt1.WalletTransaction} - if !reflect.DeepEqual(expectedTxs, txs) { - t.Fatalf("expected txs %+v but got %+v", expectedTxs, txs) - } + allTxs := []*asset.WalletTransaction{wt4.WalletTransaction, wt3.WalletTransaction, wt2.WalletTransaction, wt1.WalletTransaction} // Update same tx with new fee wt4.Fees = 300 - err = txHistoryStore.storeTx(3, wt4) + err = txHistoryStore.storeTx(wt4) if err != nil { t.Fatalf("error storing tx: %v", err) } @@ -172,27 +113,22 @@ func TestTxDB(t *testing.T) { if err != nil { t.Fatalf("error retrieving txs: %v", err) } - expectedTxs = []*asset.WalletTransaction{wt4.WalletTransaction, wt2.WalletTransaction, wt1.WalletTransaction} - if !reflect.DeepEqual(expectedTxs, txs) { + if !reflect.DeepEqual(allTxs, txs) { t.Fatalf("expected txs %+v but got %+v", expectedTxs, txs) } + txHistoryStore.Close() - cancel() - wg.Wait() - - ctx, cancel = context.WithCancel(context.Background()) - txHistoryStore = newBadgerTxDB(tempDir, dex.StdOutLogger("TXDB", dex.LevelTrace)) - wg, err = txHistoryStore.connect(ctx) + txHistoryStore, err = newBadgerTxDB(tempDir, dex.StdOutLogger("TXDB", dex.LevelTrace)) if err != nil { t.Fatalf("error connecting to tx history store: %v", err) } + defer txHistoryStore.Close() txs, err = txHistoryStore.getTxs(0, nil, false, nil) if err != nil { t.Fatalf("error retrieving txs: %v", err) } - expectedTxs = []*asset.WalletTransaction{wt4.WalletTransaction, wt2.WalletTransaction, wt1.WalletTransaction} - if !reflect.DeepEqual(expectedTxs, txs) { + if !reflect.DeepEqual(allTxs, txs) { t.Fatalf("expected txs %+v but got %+v", expectedTxs, txs) } @@ -200,25 +136,32 @@ func TestTxDB(t *testing.T) { if err != nil { t.Fatalf("error retrieving txs: %v", err) } - expectedUnconfirmedTxs := map[uint64]*extendedWalletTx{ - 3: wt4, - 2: wt2, + expectedUnconfirmedTxs := []*extendedWalletTx{wt2, wt3, wt4} + compareTxs := func(txs0, txs1 []*extendedWalletTx) bool { + if len(txs0) != len(txs1) { + return false + } + for i, tx0 := range txs0 { + tx1 := txs1[i] + n0, n1 := tx0.Nonce, tx1.Nonce + tx0.Nonce, tx1.Nonce = nil, nil + eq := reflect.DeepEqual(tx0.WalletTransaction, tx1.WalletTransaction) + tx0.Nonce, tx1.Nonce = n0, n1 + if !eq { + return false + } + } + return true } - if !reflect.DeepEqual(expectedUnconfirmedTxs, unconfirmedTxs) { + if !compareTxs(expectedUnconfirmedTxs, unconfirmedTxs) { t.Fatalf("expected txs %+v but got %+v", expectedUnconfirmedTxs, unconfirmedTxs) } - err = txHistoryStore.removeTx(wt2.ID) - if err != nil { - t.Fatalf("error removing tx: %v", err) - } - txs, err = txHistoryStore.getTxs(0, nil, false, nil) if err != nil { t.Fatalf("error retrieving txs: %v", err) } - expectedTxs = []*asset.WalletTransaction{wt4.WalletTransaction, wt1.WalletTransaction} - if !reflect.DeepEqual(expectedTxs, txs) { + if !reflect.DeepEqual(allTxs, txs) { t.Fatalf("expected txs %+v but got %+v", expectedTxs, txs) } @@ -230,217 +173,4 @@ func TestTxDB(t *testing.T) { if !reflect.DeepEqual(expectedTxs, txs) { t.Fatalf("expected txs %+v but got %+v", expectedTxs, txs) } - - txHashes := make([]common.Hash, 3) - for i := range txHashes { - txHashes[i] = common.BytesToHash(encode.RandomBytes(32)) - } - monitoredTx1 := &monitoredTx{ - tx: types.NewTx(&types.LegacyTx{Data: []byte{1}}), - replacementTx: &txHashes[1], - blockSubmitted: 1, - } - monitoredTx2 := &monitoredTx{ - tx: types.NewTx(&types.LegacyTx{Data: []byte{2}}), - replacementTx: &txHashes[2], - replacedTx: &txHashes[0], - blockSubmitted: 2, - } - monitoredTx3 := &monitoredTx{ - tx: types.NewTx(&types.LegacyTx{Data: []byte{3}}), - replacedTx: &txHashes[1], - blockSubmitted: 3, - } - - txHistoryStore.storeMonitoredTx(txHashes[0], monitoredTx1) - txHistoryStore.storeMonitoredTx(txHashes[1], monitoredTx2) - txHistoryStore.storeMonitoredTx(txHashes[2], monitoredTx3) - monitoredTxs, err := txHistoryStore.getMonitoredTxs() - if err != nil { - t.Fatalf("error retrieving monitored txs: %v", err) - } - - expectedMonitoredTxs := map[common.Hash]*monitoredTx{ - txHashes[0]: monitoredTx1, - txHashes[1]: monitoredTx2, - txHashes[2]: monitoredTx3, - } - - if len(monitoredTxs) != len(expectedMonitoredTxs) { - t.Fatalf("expected %d monitored txs but got %d", len(expectedMonitoredTxs), len(monitoredTxs)) - } - - monitoredTxsEqual := func(a, b *monitoredTx) bool { - if a.tx.Hash() != b.tx.Hash() { - return false - } - if a.replacementTx != nil && b.replacementTx != nil && *a.replacementTx != *b.replacementTx { - return false - } - if a.replacedTx != nil && b.replacedTx != nil && *a.replacedTx != *b.replacedTx { - return false - } - if a.blockSubmitted != b.blockSubmitted { - return false - } - return true - } - - for txHash, monitoredTx := range monitoredTxs { - expectedMonitoredTx := expectedMonitoredTxs[txHash] - if !monitoredTxsEqual(monitoredTx, expectedMonitoredTxs[txHash]) { - t.Fatalf("expected monitored tx %+v but got %+v", expectedMonitoredTx, monitoredTx) - } - } - - err = txHistoryStore.removeMonitoredTxs([]common.Hash{txHashes[0]}) - if err != nil { - t.Fatalf("error removing monitored tx: %v", err) - } - - monitoredTxs, err = txHistoryStore.getMonitoredTxs() - if err != nil { - t.Fatalf("error retrieving monitored txs: %v", err) - } - - expectedMonitoredTxs = map[common.Hash]*monitoredTx{ - txHashes[1]: monitoredTx2, - txHashes[2]: monitoredTx3, - } - - if len(monitoredTxs) != len(expectedMonitoredTxs) { - t.Fatalf("expected %d monitored txs but got %d", len(expectedMonitoredTxs), len(monitoredTxs)) - } - - for txHash, monitoredTx := range monitoredTxs { - expectedMonitoredTx := expectedMonitoredTxs[txHash] - if !monitoredTxsEqual(monitoredTx, expectedMonitoredTxs[txHash]) { - t.Fatalf("expected monitored tx %+v but got %+v", expectedMonitoredTx, monitoredTx) - } - } - - err = txHistoryStore.removeMonitoredTxs([]common.Hash{txHashes[1], txHashes[2]}) - if err != nil { - t.Fatalf("error removing monitored tx: %v", err) - } - - monitoredTxs, err = txHistoryStore.getMonitoredTxs() - if err != nil { - t.Fatalf("error retrieving monitored txs: %v", err) - } - - if len(monitoredTxs) != 0 { - t.Fatalf("expected 0 monitored txs but got %d", len(monitoredTxs)) - } - - cancel() - wg.Wait() -} - -func TestTxDBUpgrade(t *testing.T) { - dir := t.TempDir() - tLogger := dex.StdOutLogger("TXDB", dex.LevelTrace) - - opts := badger.DefaultOptions(dir).WithLogger(&badgerLoggerWrapper{tLogger}) - db, err := badger.Open(opts) - if err == badger.ErrTruncateNeeded { - // Probably a Windows thing. - // https://github.com/dgraph-io/badger/issues/744 - tLogger.Warnf("newTxHistoryStore badger db: %v", err) - // Try again with value log truncation enabled. - opts.Truncate = true - tLogger.Warnf("Attempting to reopen badger DB with the Truncate option set...") - db, err = badger.Open(opts) - } - if err != nil { - t.Fatalf("error opening badger db: %v", err) - } - - txHashes := make([]common.Hash, 3) - for i := range txHashes { - txHashes[i] = common.BytesToHash(encode.RandomBytes(32)) - } - - monitoredTxs := map[common.Hash]*monitoredTx{ - txHashes[0]: { - tx: types.NewTx(&types.LegacyTx{Data: []byte{1}}), - replacementTx: &txHashes[1], - blockSubmitted: 1, - }, - txHashes[1]: { - tx: types.NewTx(&types.LegacyTx{Data: []byte{2}}), - replacementTx: &txHashes[2], - replacedTx: &txHashes[0], - blockSubmitted: 2, - }, - txHashes[2]: { - tx: types.NewTx(&types.LegacyTx{Data: []byte{3}}), - replacedTx: &txHashes[1], - blockSubmitted: 3, - }, - } - - err = db.Update(func(txn *badger.Txn) error { - for txHash, monitoredTx := range monitoredTxs { - monitoredTxB, err := monitoredTx.MarshalBinary() - if err != nil { - return err - } - - th := txHash - err = txn.Set(th[:], monitoredTxB) - if err != nil { - return err - } - } - return nil - }) - if err != nil { - t.Fatalf("error storing monitored txs: %v", err) - } - - db.Close() - - ctx, cancel := context.WithCancel(context.Background()) - txHistoryStore := newBadgerTxDB(dir, tLogger) - wg, err := txHistoryStore.connect(ctx) - if err != nil { - t.Fatalf("error connecting to tx history store: %v", err) - } - defer func() { - cancel() - wg.Wait() - }() - - retrievedMonitoredTxs, err := txHistoryStore.getMonitoredTxs() - if err != nil { - t.Fatalf("error retrieving monitored txs: %v", err) - } - - if len(retrievedMonitoredTxs) != len(monitoredTxs) { - t.Fatalf("expected %d monitored txs but got %d", len(monitoredTxs), len(retrievedMonitoredTxs)) - } - - monitoredTxsEqual := func(a, b *monitoredTx) bool { - if a.tx.Hash() != b.tx.Hash() { - return false - } - if a.replacementTx != nil && b.replacementTx != nil && *a.replacementTx != *b.replacementTx { - return false - } - if a.replacedTx != nil && b.replacedTx != nil && *a.replacedTx != *b.replacedTx { - return false - } - if a.blockSubmitted != b.blockSubmitted { - return false - } - return true - } - - for txHash, monitoredTx := range retrievedMonitoredTxs { - expectedMonitoredTx := monitoredTxs[txHash] - if !monitoredTxsEqual(monitoredTx, expectedMonitoredTx) { - t.Fatalf("expected monitored tx %+v but got %+v", expectedMonitoredTx, monitoredTx) - } - } } diff --git a/client/asset/interface.go b/client/asset/interface.go index 8c150fd6e5..c0aac7e04e 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -226,11 +226,23 @@ const ( // not yet been mined. There is no guarantee that the swap will be mined // in the future. ErrSwapNotInitiated = dex.ErrorKind("swap not yet initiated") - CoinNotFoundError = dex.ErrorKind("coin not found") - ErrRequestTimeout = dex.ErrorKind("request timeout") - ErrConnectionDown = dex.ErrorKind("wallet not connected") - ErrNotImplemented = dex.ErrorKind("not implemented") - ErrUnsupported = dex.ErrorKind("unsupported") + + CoinNotFoundError = dex.ErrorKind("coin not found") + // ErrTxRejected is returned when a transaction was rejected. This + // generally would indicate either an internal wallet error, or potentially + // a user using multiple instances of the wallet simultaneously. As such + // it may or may not be advisable to try the tx again without seeking + // further investigation. + ErrTxRejected = dex.ErrorKind("transaction was rejected") + // ErrTxLost is returned when the tx is irreparably lost, as would be the + // case if it's inputs were spent by another tx first or if it is not the + // accepted tx for a given nonce. These txs have incurred no fee losses, so + // the caller should feel free to re-issue the tx. + ErrTxLost = dex.ErrorKind("tx lost") + ErrRequestTimeout = dex.ErrorKind("request timeout") + ErrConnectionDown = dex.ErrorKind("wallet not connected") + ErrNotImplemented = dex.ErrorKind("not implemented") + ErrUnsupported = dex.ErrorKind("unsupported") // ErrSwapRefunded is returned from ConfirmRedemption when the swap has // been refunded before the user could redeem. ErrSwapRefunded = dex.ErrorKind("swap refunded") @@ -1147,7 +1159,13 @@ type WalletTransaction struct { // AdditionalData contains asset specific information, i.e. nonce // for ETH. AdditionalData map[string]string `json:"additionalData"` - Confirmed bool `json:"confirmed"` + // Confirmed is true when the transaction is considered finalized. + // Confirmed transactions are no longer updated and will be considered + // finalized forever. + Confirmed bool `json:"confirmed"` + // Rejected will be true the transaction was rejected and did not have any + // effect, though fees were incurred. + Rejected bool `json:"rejected,omitempty"` } // WalletHistorian is a wallet that is able to retrieve the history of all @@ -1513,6 +1531,11 @@ type CustomWalletNote struct { Payload any `json:"payload"` } +type ActionTaker interface { + // TakeAction processes a response to an ActionRequired wallet notification. + TakeAction(actionID string, payload []byte) error +} + // WalletEmitter handles a channel for wallet notifications and provides methods // that generates notifications. type WalletEmitter struct { @@ -1521,6 +1544,22 @@ type WalletEmitter struct { log dex.Logger } +type ActionRequiredNote struct { + baseWalletNotification + Payload any `json:"payload"` + UniqueID string `json:"uniqueID"` + ActionID string `json:"actionID"` +} + +// ActionResolved is sent by wallets when action is no longer required for a +// unique ID. +const ActionResolved = "actionResolved" + +type ActionResolvedNote struct { + baseWalletNotification + UniqueID string `json:"uniqueID"` +} + // NewWalletEmitter constructs a WalletEmitter for an asset. func NewWalletEmitter(c chan<- WalletNotification, assetID uint32, log dex.Logger) *WalletEmitter { return &WalletEmitter{ @@ -1593,3 +1632,27 @@ func (e *WalletEmitter) TransactionHistorySyncedNote() { Route: "transactionHistorySynced", }) } + +// ActionRequired is a route that will end up as a special dialogue seeking +// user input via (ActionTaker).TakeAction. +func (e *WalletEmitter) ActionRequired(uniqueID, actionID string, payload any) { + e.emit(&ActionRequiredNote{ + baseWalletNotification: baseWalletNotification{ + AssetID: e.assetID, + Route: "actionRequired", + }, + UniqueID: uniqueID, + ActionID: actionID, + Payload: payload, + }) +} + +func (e *WalletEmitter) ActionResolved(uniqueID string) { + e.emit(&ActionResolvedNote{ + baseWalletNotification: baseWalletNotification{ + AssetID: e.assetID, + Route: "actionResolved", + }, + UniqueID: uniqueID, + }) +} diff --git a/client/asset/polygon/multirpc_live_test.go b/client/asset/polygon/multirpc_live_test.go index 5e8194d7ef..61f356d424 100644 --- a/client/asset/polygon/multirpc_live_test.go +++ b/client/asset/polygon/multirpc_live_test.go @@ -106,3 +106,15 @@ func TestFreeTestnetServers(t *testing.T) { func TestMainnetCompliance(t *testing.T) { mt.TestMainnetCompliance(t) } + +func TestTestnetFees(t *testing.T) { + mt.FeeHistory(t, dex.Testnet, 3, 90) +} + +func TestTestnetTipCaps(t *testing.T) { + mt.TipCaps(t, dex.Testnet) +} + +func TestReceiptsHaveEffectiveGasPrice(t *testing.T) { + mt.TestReceiptsHaveEffectiveGasPrice(t) +} diff --git a/client/asset/polygon/polygon.go b/client/asset/polygon/polygon.go index 13ad26ef9b..fd8faeaf3a 100644 --- a/client/asset/polygon/polygon.go +++ b/client/asset/polygon/polygon.go @@ -147,6 +147,7 @@ func (d *Driver) Open(cfg *asset.WalletConfig, logger dex.Logger, net dex.Networ CompatData: &compat, VersionedGases: dexpolygon.VersionedGases, Tokens: dexpolygon.Tokens, + FinalizeConfs: 64, Logger: logger, BaseChainContracts: contracts, MultiBalAddress: dexpolygon.MultiBalanceAddresses[net], diff --git a/client/asset/zec/zec.go b/client/asset/zec/zec.go index d14035de30..fbf86198d0 100644 --- a/client/asset/zec/zec.go +++ b/client/asset/zec/zec.go @@ -130,6 +130,8 @@ var ( NoAuth: true, }}, } + + feeReservesPerLot = dexzec.TxFeesZIP317(dexbtc.RedeemP2PKHInputSize+1, 2*dexbtc.P2PKHOutputSize+1, 0, 0, 0, 0) ) func init() { @@ -970,7 +972,7 @@ func (w *zecWallet) maxOrder(lotSize, feeSuggestion, maxFeeRate uint64) (utxos [ return utxos, bals, est, err } - return utxos, bals, &asset.SwapEstimate{}, nil + return utxos, bals, &asset.SwapEstimate{FeeReservesPerLot: feeReservesPerLot}, nil } // estimateSwap prepares an *asset.SwapEstimate. @@ -1000,6 +1002,7 @@ func (w *zecWallet) estimateSwap( MaxFees: shieldedSplitFees, RealisticBestCase: shieldedSplitFees, RealisticWorstCase: shieldedSplitFees, + FeeReservesPerLot: feeReservesPerLot, }, true, splitLocked, nil } @@ -1037,6 +1040,7 @@ func (w *zecWallet) estimateSwap( MaxFees: maxFees, RealisticBestCase: estLowFees, RealisticWorstCase: estHighFees, + FeeReservesPerLot: feeReservesPerLot, }, true, reqFunds, nil } } @@ -1048,6 +1052,7 @@ func (w *zecWallet) estimateSwap( MaxFees: maxFees, RealisticBestCase: estLowFees, RealisticWorstCase: estHighFees, + FeeReservesPerLot: feeReservesPerLot, }, false, sum, nil } diff --git a/client/cmd/bisonw-desktop/app_darwin.go b/client/cmd/bisonw-desktop/app_darwin.go index fc4e34aebc..add5b8a9b7 100644 --- a/client/cmd/bisonw-desktop/app_darwin.go +++ b/client/cmd/bisonw-desktop/app_darwin.go @@ -152,7 +152,7 @@ func mainCore() error { // Return early if unsupported flags are provided. if cfg.Webview != "" { - return errors.New("--webview flag is not supported. Other OS use it for a specific reason (to support multiple windows)") + return errors.New("the --webview flag is not supported. Other OS use it for a specific reason (to support multiple windows)") } // The --kill flag is a backup measure to end a background process (that diff --git a/client/cmd/simnet-trade-tests/run b/client/cmd/simnet-trade-tests/run index b1b7733a3b..4303d20354 100755 --- a/client/cmd/simnet-trade-tests/run +++ b/client/cmd/simnet-trade-tests/run @@ -1,7 +1,9 @@ #!/bin/bash set -e -go build -tags harness +go build -tags harness -ldflags \ + "-X 'decred.org/dcrdex/dex.testLockTimeTaker=1m' \ + -X 'decred.org/dcrdex/dex.testLockTimeMaker=2m'" case $1 in diff --git a/client/core/core.go b/client/core/core.go index 2464652db0..d1c8609905 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -1553,6 +1553,9 @@ type Core struct { notes chan asset.WalletNotification pokesCache *pokesCache + + requestedActionMtx sync.RWMutex + requestedActions map[string]*asset.ActionRequiredNote } // New is the constructor for a new Core. @@ -1689,7 +1692,8 @@ func New(cfg *Config) (*Core, error) { reFiat: make(chan struct{}, 1), pendingWallets: make(map[uint32]bool), - notes: make(chan asset.WalletNotification, 128), + notes: make(chan asset.WalletNotification, 128), + requestedActions: make(map[string]*asset.ActionRequiredNote), } c.intl.Store(&locale{ @@ -2550,7 +2554,18 @@ func (c *Core) User() *User { FiatRates: c.fiatConversions(), Net: c.net, ExtensionConfig: c.extensionModeConfig, + Actions: c.requestedActionsList(), + } +} + +func (c *Core) requestedActionsList() []*asset.ActionRequiredNote { + c.requestedActionMtx.RLock() + defer c.requestedActionMtx.RUnlock() + actions := make([]*asset.ActionRequiredNote, 0, len(c.requestedActions)) + for _, a := range c.requestedActions { + actions = append(actions, a) } + return actions } // CreateWallet creates a new exchange wallet. @@ -10100,6 +10115,12 @@ func (c *Core) handleWalletNotification(ni asset.WalletNotification) { c.log.Errorf("Error storing and sending emitted balance: %v", err) } return // Notification sent already. + case *asset.ActionRequiredNote: + c.requestedActionMtx.Lock() + c.requestedActions[n.UniqueID] = n + c.requestedActionMtx.Unlock() + case *asset.ActionResolvedNote: + c.deleteRequestedAction(n.UniqueID) } c.notify(newWalletNote(ni)) } @@ -11429,3 +11450,106 @@ func (c *Core) DisableFundsMixer(assetID uint32) error { func (c *Core) NetworkFeeRate(assetID uint32) uint64 { return c.feeSuggestionAny(assetID) } + +func (c *Core) deleteRequestedAction(uniqueID string) { + c.requestedActionMtx.Lock() + delete(c.requestedActions, uniqueID) + c.requestedActionMtx.Unlock() +} + +// handleRetryRedemptionAction handles a response to a user response to an +// ActionRequiredNote for a rejected redemption transaction. +func (c *Core) handleRetryRedemptionAction(actionB []byte) error { + var req struct { + OrderID dex.Bytes `json:"orderID"` + CoinID dex.Bytes `json:"coinID"` + Retry bool `json:"retry"` + } + if err := json.Unmarshal(actionB, &req); err != nil { + return fmt.Errorf("error decoding request: %w", err) + } + c.deleteRequestedAction(req.CoinID.String()) + + if !req.Retry { + // Do nothing + return nil + } + var oid order.OrderID + copy(oid[:], req.OrderID) + var tracker *trackedTrade + for _, dc := range c.dexConnections() { + tracker, _, _ = dc.findOrder(oid) + if tracker != nil { + break + } + } + if tracker == nil { + return fmt.Errorf("order %s not known", oid) + } + tracker.mtx.Lock() + defer tracker.mtx.Unlock() + + for _, match := range tracker.matches { + coinID := match.MetaData.Proof.TakerRedeem + if match.Side == order.Maker { + coinID = match.MetaData.Proof.MakerRedeem + } + if bytes.Equal(coinID, req.CoinID) { + if match.Side == order.Taker && match.Status == order.MatchComplete { + // Try to redeem again. + match.redemptionRejected = false + match.MetaData.Proof.TakerRedeem = nil + match.Status = order.MakerRedeemed + if err := c.db.UpdateMatch(&match.MetaMatch); err != nil { + c.log.Errorf("Failed to update match in DB: %v", err) + } + } else if match.Side == order.Maker && match.Status == order.MakerRedeemed { + match.redemptionRejected = false + match.MetaData.Proof.MakerRedeem = nil + match.Status = order.TakerSwapCast + if err := c.db.UpdateMatch(&match.MetaMatch); err != nil { + c.log.Errorf("Failed to update match in DB: %v", err) + } + } else { + c.log.Errorf("Redemption retry attempted for order side %s status %s", match.Side, match.Status) + } + } + } + return nil +} + +// handleCoreAction checks if the actionID is a known core action, and if so +// attempts to take the action requested. +func (c *Core) handleCoreAction(actionID string, actionB json.RawMessage) ( /* handled */ bool, error) { + switch actionID { + case ActionIDRedeemRejected: + return true, c.handleRetryRedemptionAction(actionB) + } + return false, nil +} + +// TakeAction is called in response to a ActionRequiredNote. The note may have +// come from core or from a wallet. +func (c *Core) TakeAction(assetID uint32, actionID string, actionB json.RawMessage) (err error) { + defer func() { + if err != nil { + c.log.Errorf("Error while attempting user action %q with parameters %q, asset ID %d: %v", + actionID, string(actionB), assetID, err) + } else { + c.log.Infof("User completed action %q with parameters %q, asset ID %d", + actionID, string(actionB), assetID) + } + }() + if handled, err := c.handleCoreAction(actionID, actionB); handled { + return err + } + w, err := c.connectedWallet(assetID) + if err != nil { + return err + } + goGetter, is := w.Wallet.(asset.ActionTaker) + if !is { + return fmt.Errorf("wallet for %s cannot handle user actions", w.Symbol) + } + return goGetter.TakeAction(actionID, actionB) +} diff --git a/client/core/core_test.go b/client/core/core_test.go index 186c29adc3..6058bcd7ab 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -1363,9 +1363,10 @@ func newTestRig() *testRig { reCrypter: func([]byte, []byte) (encrypt.Crypter, error) { return crypter, crypter.recryptErr }, noteChans: make(map[uint64]chan Notification), - fiatRateSources: make(map[string]*commonRateSource), - notes: make(chan asset.WalletNotification, 128), - pokesCache: newPokesCache(pokesCapacity), + fiatRateSources: make(map[string]*commonRateSource), + notes: make(chan asset.WalletNotification, 128), + pokesCache: newPokesCache(pokesCapacity), + requestedActions: make(map[string]*asset.ActionRequiredNote), }, db: tdb, queue: queue, @@ -9082,6 +9083,36 @@ func TestConfirmRedemption(t *testing.T) { }, expectConfirmRedemptionCalled: true, }, + { + name: "taker, takerRedeemed, redemption tx rejected error", + matchStatus: order.MatchComplete, + matchSide: order.Taker, + confirmRedemptionErr: asset.ErrTxRejected, + expectedStatus: order.MatchComplete, + expectedNotifications: []*note{ + { + severity: db.Data, + topic: TopicRedeemRejected, + }, + }, + expectConfirmRedemptionCalled: true, + }, + { + name: "maker, makerRedeemed, redemption tx lost", + matchStatus: order.MakerRedeemed, + matchSide: order.Maker, + confirmRedemptionErr: asset.ErrTxLost, + expectedStatus: order.TakerSwapCast, + expectConfirmRedemptionCalled: true, + }, + { + name: "taker, takerRedeemed, redemption tx lost", + matchStatus: order.MatchComplete, + matchSide: order.Taker, + confirmRedemptionErr: asset.ErrTxLost, + expectedStatus: order.MakerRedeemed, + expectConfirmRedemptionCalled: true, + }, { name: "maker, matchConfirmed", matchStatus: order.MatchConfirmed, @@ -9099,7 +9130,7 @@ func TestConfirmRedemption(t *testing.T) { expectConfirmRedemptionCalled: false, }, { - name: "taler, TakerSwapCast", + name: "taker, TakerSwapCast", matchStatus: order.TakerSwapCast, matchSide: order.Taker, expectedStatus: order.TakerSwapCast, @@ -9127,31 +9158,22 @@ func TestConfirmRedemption(t *testing.T) { } for _, expectedNotification := range test.expectedNotifications { - var note *Notification + var n Notification + out: for { select { - case n := <-notificationFeed.C: - if n.Topic() == TopicRedemptionConfirmed || - n.Topic() == TopicRedemptionResubmitted || - n.Topic() == TopicSwapRefunded || - n.Topic() == TopicConfirms { - note = &n + case n = <-notificationFeed.C: + if n.Topic() == expectedNotification.topic { + break out } case <-time.After(60 * time.Second): t.Fatalf("%s: did not receive expected notification", test.name) } - if note != nil { - break - } } - if (*note).Severity() != expectedNotification.severity { + if n.Severity() != expectedNotification.severity { t.Fatalf("%s: expected severity %v, got %v", - test.name, expectedNotification.severity, (*note).Severity()) - } - if (*note).Topic() != expectedNotification.topic { - t.Fatalf("%s:, expected topic %v, got %v", - test.name, expectedNotification.topic, (*note).Topic()) + test.name, expectedNotification.severity, n.Severity()) } } @@ -11122,3 +11144,91 @@ func TestPokesCachePokes(t *testing.T) { } } } + +func TestTakeAction(t *testing.T) { + rig := newTestRig() + defer rig.shutdown() + + coinID := encode.RandomBytes(32) + uniqueID := dex.Bytes(coinID).String() + + newMatch := func() *matchTracker { + var matchID order.MatchID + copy(matchID[:], encode.RandomBytes(32)) + return &matchTracker{ + MetaMatch: db.MetaMatch{ + UserMatch: &order.UserMatch{ + Status: order.MatchComplete, + MatchID: matchID, + Side: order.Taker, + }, + MetaData: &db.MatchMetaData{}, + }, + } + } + rightMatch := newMatch() + rightMatch.MetaData.Proof.TakerRedeem = coinID + rightMatch.redemptionRejected = true + + wrongMatch := newMatch() + wrongMatch.MetaData.Proof.TakerRedeem = encode.RandomBytes(31) + + makerMatch := newMatch() + makerMatch.Status = order.MakerRedeemed + makerMatch.MetaData.Proof.MakerRedeem = coinID + makerMatch.Side = order.Maker + + tracker := &trackedTrade{ + matches: map[order.MatchID]*matchTracker{ + rightMatch.MatchID: rightMatch, + wrongMatch.MatchID: wrongMatch, + makerMatch.MatchID: makerMatch, + }, + } + + var oid order.OrderID + copy(oid[:], encode.RandomBytes(32)) + + rig.dc.trades[oid] = tracker + + requestData := []byte(fmt.Sprintf(`{"orderID":"abcd","coinID":"%s","retry":true}`, dex.Bytes(coinID))) + + err := rig.core.TakeAction(0, ActionIDRedeemRejected, requestData) + if err == nil { + t.Fatalf("expected error for wrong order ID but got nothing") + } + + rig.core.requestedActions[uniqueID] = nil + requestData = []byte(fmt.Sprintf(`{"orderID":"%s","coinID":"%s","retry":false}`, oid, dex.Bytes(coinID))) + + err = rig.core.TakeAction(0, ActionIDRedeemRejected, requestData) + if err != nil { + t.Fatalf("error for retry=false: %v", err) + } + if len(rig.core.requestedActions) != 0 { + t.Fatal("requested action not removed") + } + + requestData = []byte(fmt.Sprintf(`{"orderID":"%s","coinID":"%s","retry":true}`, oid, dex.Bytes(coinID))) + err = rig.core.TakeAction(0, ActionIDRedeemRejected, requestData) + if err != nil { + t.Fatalf("error for taker retry=true: %v", err) + } + + if len(rightMatch.MetaData.Proof.TakerRedeem) != 0 { + t.Fatalf("taker redemption not cleared") + } + if len(wrongMatch.MetaData.Proof.TakerRedeem) == 0 { + t.Fatalf("wrong taker redemption cleared") + } + + makerMatch.redemptionRejected = true + err = rig.core.TakeAction(0, ActionIDRedeemRejected, requestData) + if err != nil { + t.Fatalf("error for maker retry=true: %v", err) + } + if len(makerMatch.MetaData.Proof.MakerRedeem) != 0 { + t.Fatalf("maker redemption not cleared") + } + +} diff --git a/client/core/notification.go b/client/core/notification.go index 6d46292491..64e72939bc 100644 --- a/client/core/notification.go +++ b/client/core/notification.go @@ -12,34 +12,36 @@ import ( "decred.org/dcrdex/client/db" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/msgjson" + "decred.org/dcrdex/dex/order" "decred.org/dcrdex/server/account" ) // Notifications should use the following note type strings. const ( - NoteTypeFeePayment = "feepayment" - NoteTypeBondPost = "bondpost" - NoteTypeBondRefund = "bondrefund" - NoteTypeUnknownBond = "unknownbond" - NoteTypeSend = "send" - NoteTypeOrder = "order" - NoteTypeMatch = "match" - NoteTypeEpoch = "epoch" - NoteTypeConnEvent = "conn" - NoteTypeBalance = "balance" - NoteTypeSpots = "spots" - NoteTypeWalletConfig = "walletconfig" - NoteTypeWalletState = "walletstate" - NoteTypeServerNotify = "notify" - NoteTypeSecurity = "security" - NoteTypeUpgrade = "upgrade" - NoteTypeBot = "bot" - NoteTypeDEXAuth = "dex_auth" - NoteTypeFiatRates = "fiatrateupdate" - NoteTypeCreateWallet = "createwallet" - NoteTypeLogin = "login" - NoteTypeWalletNote = "walletnote" - NoteTypeReputation = "reputation" + NoteTypeFeePayment = "feepayment" + NoteTypeBondPost = "bondpost" + NoteTypeBondRefund = "bondrefund" + NoteTypeUnknownBond = "unknownbond" + NoteTypeSend = "send" + NoteTypeOrder = "order" + NoteTypeMatch = "match" + NoteTypeEpoch = "epoch" + NoteTypeConnEvent = "conn" + NoteTypeBalance = "balance" + NoteTypeSpots = "spots" + NoteTypeWalletConfig = "walletconfig" + NoteTypeWalletState = "walletstate" + NoteTypeServerNotify = "notify" + NoteTypeSecurity = "security" + NoteTypeUpgrade = "upgrade" + NoteTypeBot = "bot" + NoteTypeDEXAuth = "dex_auth" + NoteTypeFiatRates = "fiatrateupdate" + NoteTypeCreateWallet = "createwallet" + NoteTypeLogin = "login" + NoteTypeWalletNote = "walletnote" + NoteTypeReputation = "reputation" + NoteTypeActionRequired = "actionrequired" ) var noteChanCounter uint64 @@ -736,3 +738,46 @@ func newUnknownBondTierZeroNote(subject, details string) *db.Notification { note := db.NewNotification(NoteTypeUnknownBond, TopicUnknownBondTierZero, subject, details, db.WarningLevel) return ¬e } + +const ( + ActionIDRedeemRejected = "redeemRejected" + TopicRedeemRejected = "RedeemRejected" +) + +func newActionRequiredNote(actionID, uniqueID string, payload any) *asset.ActionRequiredNote { + n := &asset.ActionRequiredNote{ + UniqueID: uniqueID, + ActionID: actionID, + Payload: payload, + } + const routeNotNeededCuzCoreHasNoteType = "" + n.Route = routeNotNeededCuzCoreHasNoteType + return n +} + +type RejectedRedemptionData struct { + OrderID dex.Bytes `json:"orderID"` + CoinID dex.Bytes `json:"coinID"` + AssetID uint32 `json:"assetID"` + CoinFmt string `json:"coinFmt"` +} + +// ActionRequiredNote is structured like a WalletNote. The payload will be +// an *asset.ActionRequiredNote. This is done for compatibility reasons. +type ActionRequiredNote WalletNote + +func newRejectedRedemptionNote(assetID uint32, oid order.OrderID, coinID []byte) (*asset.ActionRequiredNote, *ActionRequiredNote) { + data := &RejectedRedemptionData{ + AssetID: assetID, + OrderID: oid[:], + CoinID: coinID, + CoinFmt: coinIDString(assetID, coinID), + } + uniqueID := dex.Bytes(coinID).String() + actionNote := newActionRequiredNote(ActionIDRedeemRejected, uniqueID, data) + coreNote := &ActionRequiredNote{ + Notification: db.NewNotification(NoteTypeActionRequired, TopicRedeemRejected, "", "", db.Data), + Payload: actionNote, + } + return actionNote, coreNote +} diff --git a/client/core/simnet_trade.go b/client/core/simnet_trade.go index 3d77ae3c00..e57e97fea6 100644 --- a/client/core/simnet_trade.go +++ b/client/core/simnet_trade.go @@ -4,8 +4,8 @@ package core // The asset and dcrdex harnesses should be running before executing this test. // -// The dcrdex harness rebuilds the dcrdex binary with dex.testLockTimeTaker=30s -// and dex.testLockTimeMaker=1m before running the binary, making it possible +// The ./run script rebuilds the dcrdex binary with dex.testLockTimeTaker=1m +// and dex.testLockTimeMaker=2m before running the binary, making it possible // for this test to wait for swap locktimes to expire and ensure that refundable // swaps are actually refunded when the swap locktimes expire. // @@ -70,8 +70,6 @@ var ( ethAlphaIPCFile = filepath.Join(dextestDir, "eth", "alpha", "node", "geth.ipc") polygonAlphaIPCFile = filepath.Join(dextestDir, "polygon", "alpha", "bor", "bor.ipc") - tLockTimeTaker = 30 * time.Second - tLockTimeMaker = 1 * time.Minute ethUsdcID, _ = dex.BipSymbolID("usdc.eth") polygonUsdcID, _ = dex.BipSymbolID("usdc.polygon") ) @@ -516,14 +514,14 @@ func testNoMakerRedeem(s *simulationTest) error { preFilter1 := func(route string) error { if route == msgjson.InitRoute && atomic.CompareAndSwapUint32(&killed, 0, 1) { s.client1.disableWallets() - time.AfterFunc(tLockTimeTaker, func() { enable(s.client1) }) + time.AfterFunc(s.client1.core.lockTimeTaker, func() { enable(s.client1) }) } return nil } preFilter2 := func(route string) error { if route == msgjson.InitRoute && atomic.CompareAndSwapUint32(&killed, 0, 1) { s.client2.disableWallets() - time.AfterFunc(tLockTimeTaker, func() { enable(s.client2) }) + time.AfterFunc(s.client1.core.lockTimeTaker, func() { enable(s.client2) }) } return nil } @@ -1899,8 +1897,6 @@ func (client *simulationClient) init(ctx context.Context) error { return err } - client.core.lockTimeTaker = tLockTimeTaker - client.core.lockTimeMaker = tLockTimeMaker client.notes = client.startNotificationReader(ctx) return nil } diff --git a/client/core/trade.go b/client/core/trade.go index b98383d84c..619ed890a4 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -97,6 +97,15 @@ type matchTracker struct { // match reaches MatchConfirmed status. redemptionConfs uint64 redemptionConfsReq uint64 + // redemptionRejected will be true if a redemption tx was rejected. A + // a rejected tx may indicate a serious internal issue, so we will seek + // user approval before replacing the tx. + redemptionRejected bool + // matchCompleteSent precludes sending another redeem to the server if we + // we are retrying after rejection and they already accepted our first + // request. Additional requests will just error and they don't really care + // if we redeem as taker anyway. + matchCompleteSent bool // The fields below need to be modified without the parent trackedTrade's // mutex being write locked, so they have dedicated mutexes. @@ -1439,7 +1448,7 @@ func (t *trackedTrade) checkSwapFeeConfirms(match *matchTracker) bool { // This method accesses match fields and MUST be called with the trackedTrade // mutex lock held for reads. func (t *trackedTrade) checkRedemptionFeeConfirms(match *matchTracker) bool { - if match.MetaData.Proof.RedemptionFeeConfirmed { + if match.MetaData.Proof.RedemptionFeeConfirmed || match.redemptionRejected { return false } _, dynamic := t.wallets.toWallet.Wallet.(asset.DynamicSwapper) @@ -1798,6 +1807,10 @@ func shouldConfirmRedemption(match *matchTracker) bool { return false } + if match.redemptionRejected { + return false + } + proof := &match.MetaData.Proof if match.Side == order.Maker { return len(proof.MakerRedeem) > 0 @@ -2099,7 +2112,7 @@ func (c *Core) tick(t *trackedTrade) (assetMap, error) { } if len(redemptionConfirms) > 0 { - t.confirmRedemptions(redemptionConfirms) + c.confirmRedemptions(t, redemptionConfirms) } for _, match := range dynamicSwapFeeConfirms { @@ -2780,7 +2793,9 @@ func (c *Core) redeemMatchGroup(t *trackedTrade, matches []*matchTracker, errs * errs.add("error storing swap details in database for match %s, coin %s: %v", match, coinIDString(t.wallets.fromWallet.AssetID, coinID), err) } - c.sendRedeemAsync(t, match, coinIDs[i], proof.Secret) + if !match.matchCompleteSent { + c.sendRedeemAsync(t, match, coinIDs[i], proof.Secret) + } } if refundNum != 0 { t.unlockRefundFraction(refundNum, denom) @@ -2868,6 +2883,8 @@ func (c *Core) sendRedeemAsync(t *trackedTrade, match *matchTracker, coinID, sec } else { match.Status = order.MatchComplete } + } else if match.Side == order.Taker { + match.matchCompleteSent = true } err = t.db.UpdateMatch(&match.MetaMatch) if err != nil { @@ -2901,10 +2918,10 @@ func (t *trackedTrade) redeemFee() uint64 { // confirmRedemption attempts to confirm the redemptions for each match, and // then return any refund addresses that we won't be using. -func (t *trackedTrade) confirmRedemptions(matches []*matchTracker) { +func (c *Core) confirmRedemptions(t *trackedTrade, matches []*matchTracker) { var refundContracts [][]byte for _, m := range matches { - if confirmed, err := t.confirmRedemption(m); err != nil { + if confirmed, err := c.confirmRedemption(t, m); err != nil { t.dc.log.Errorf("Unable to confirm redemption: %v", err) } else if confirmed { refundContracts = append(refundContracts, m.MetaData.Proof.ContractData) @@ -2923,7 +2940,7 @@ func (t *trackedTrade) confirmRedemptions(matches []*matchTracker) { // // This method accesses match fields and MUST be called with the trackedTrade // mutex lock held for writes. -func (t *trackedTrade) confirmRedemption(match *matchTracker) (bool, error) { +func (c *Core) confirmRedemption(t *trackedTrade, match *matchTracker) (bool, error) { if confs := match.redemptionConfs; confs > 0 && confs >= match.redemptionConfsReq { // already there, stop checking if len(match.MetaData.Proof.Auth.RedeemSig) == 0 && (!t.isSelfGoverned() && !match.MetaData.Proof.IsRevoked()) { return false, nil // waiting on redeem request to succeed @@ -2972,7 +2989,9 @@ func (t *trackedTrade) confirmRedemption(match *matchTracker) (bool, error) { Spends: match.counterSwap, Secret: proof.Secret, }, t.redeemFee()) - if errors.Is(asset.ErrSwapRefunded, err) { + switch { + case err == nil: + case errors.Is(err, asset.ErrSwapRefunded): subject, details := t.formatDetails(TopicSwapRefunded, match.token(), makeOrderToken(t.token())) note := newMatchNote(TopicSwapRefunded, subject, details, db.ErrorLevel, t, match) t.notify(note) @@ -2982,7 +3001,38 @@ func (t *trackedTrade) confirmRedemption(match *matchTracker) (bool, error) { t.dc.log.Errorf("Failed to update match in db %v", err) } return false, errors.New("swap was already refunded by the counterparty") - } else if err != nil { + + case errors.Is(err, asset.ErrTxRejected): + match.redemptionRejected = true + // We need to seek user approval before trying again, since new fees + // could be incurred. + actionRequest, note := newRejectedRedemptionNote(toWallet.AssetID, t.ID(), redeemCoinID) + t.notify(note) + c.requestedActionMtx.Lock() + c.requestedActions[dex.Bytes(redeemCoinID).String()] = actionRequest + c.requestedActionMtx.Unlock() + return false, fmt.Errorf("%s transaction %s was rejected. Seeking user approval before trying again", + unbip(toWallet.AssetID), coinIDString(toWallet.AssetID, redeemCoinID)) + case errors.Is(err, asset.ErrTxLost): + // The transaction was nonce-replaced or otherwise lost without + // rejection or with user acknowlegement. Try again. + var coinID order.CoinID + if match.Side == order.Taker { + coinID = match.MetaData.Proof.TakerRedeem + match.MetaData.Proof.TakerRedeem = nil + match.Status = order.MakerRedeemed + } else { + coinID = match.MetaData.Proof.MakerRedeem + match.MetaData.Proof.MakerRedeem = nil + match.Status = order.TakerSwapCast + } + c.log.Infof("Redemption %s (%s) has been noted as lost.", coinID, unbip(toWallet.AssetID)) + + if err := t.db.UpdateMatch(&match.MetaMatch); err != nil { + t.dc.log.Errorf("failed to update match after lost tx reported: %v", err) + } + return false, nil + default: match.delayTicks(time.Minute * 15) return false, fmt.Errorf("error confirming redemption for coin %v. already tried %d times, will retry later: %v", redeemCoinID, match.confirmRedemptionNumTries, err) diff --git a/client/core/types.go b/client/core/types.go index d9bbfe6a9e..a5273941c9 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -164,13 +164,14 @@ type ExtensionModeConfig struct { // User is information about the user's wallets and DEX accounts. type User struct { - Exchanges map[string]*Exchange `json:"exchanges"` - Initialized bool `json:"inited"` - SeedGenerationTime uint64 `json:"seedgentime"` - Assets map[uint32]*SupportedAsset `json:"assets"` - FiatRates map[uint32]float64 `json:"fiatRates"` - Net dex.Network `json:"net"` - ExtensionConfig *ExtensionModeConfig `json:"extensionModeConfig,omitempty"` + Exchanges map[string]*Exchange `json:"exchanges"` + Initialized bool `json:"inited"` + SeedGenerationTime uint64 `json:"seedgentime"` + Assets map[uint32]*SupportedAsset `json:"assets"` + FiatRates map[uint32]float64 `json:"fiatRates"` + Net dex.Network `json:"net"` + ExtensionConfig *ExtensionModeConfig `json:"extensionModeConfig,omitempty"` + Actions []*asset.ActionRequiredNote `json:"actions,omitempty"` } // SupportedAsset is data about an asset and possibly the wallet associated diff --git a/client/webserver/api.go b/client/webserver/api.go index 2fa1894921..eed070fcb3 100644 --- a/client/webserver/api.go +++ b/client/webserver/api.go @@ -5,6 +5,7 @@ package webserver import ( "encoding/hex" + "encoding/json" "errors" "fmt" "net/http" @@ -2268,6 +2269,22 @@ func (s *WebServer) apiTxHistory(w http.ResponseWriter, r *http.Request) { }, s.indent) } +func (s *WebServer) apiTakeAction(w http.ResponseWriter, r *http.Request) { + var req struct { + AssetID uint32 `json:"assetID"` + ActionID string `json:"actionID"` + Action json.RawMessage `json:"action"` + } + if !readPost(w, r, &req) { + return + } + if err := s.core.TakeAction(req.AssetID, req.ActionID, req.Action); err != nil { + s.writeAPIError(w, fmt.Errorf("error taking action: %w", err)) + return + } + writeJSON(w, simpleAck(), s.indent) +} + // writeAPIError logs the formatted error and sends a standardResponse with the // error message. func (s *WebServer) writeAPIError(w http.ResponseWriter, err error) { diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index 0a94be7b77..ea8d47eadb 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -10,6 +10,7 @@ import ( "context" "encoding/binary" "encoding/hex" + "encoding/json" "fmt" "math" mrand "math/rand" @@ -70,6 +71,8 @@ var ( randomizeOrdersCount = false initErrors = false mmConnectErrors = false + enableActions = true + actions []*asset.ActionRequiredNote rand = mrand.New(mrand.NewSource(time.Now().UnixNano())) titler = cases.Title(language.AmericanEnglish) @@ -1775,8 +1778,8 @@ func (c *TCore) User() *core.User { 145: 114.68, // bch 60: 1_209.51, // eth 60001: 0.999, // usdc.eth - }, + Actions: actions, } return user } @@ -1928,6 +1931,14 @@ out: } c.epochOrders = nil c.orderMtx.Unlock() + + // Small chance of randomly generating a required action + if enableActions && rand.Float32() < 0.05 { + c.noteFeed <- &core.WalletNote{ + Notification: db.NewNotification(core.NoteTypeWalletNote, core.TopicWalletNotification, "", "", db.Data), + Payload: makeRequiredAction(baseID, "missingNonces"), + } + } case <-tCtx.Done(): break out } @@ -2105,6 +2116,24 @@ func (c *TCore) Language() string { return c.lang } +func (c *TCore) TakeAction(assetID uint32, actionID string, actionB json.RawMessage) error { + if rand.Float32() < 0.25 { + return fmt.Errorf("it didn't work") + } + for i, req := range actions { + if req.ActionID == actionID && req.AssetID == assetID { + copy(actions[i:], actions[i+1:]) + actions = actions[:len(actions)-1] + c.noteFeed <- &core.WalletNote{ + Notification: db.NewNotification(core.NoteTypeWalletNote, core.TopicWalletNotification, "", "", db.Data), + Payload: makeActionResolved(assetID, req.UniqueID), + } + break + } + } + return nil +} + var binanceMarkets = []*libxc.Market{ {BaseID: 42, QuoteID: 0}, {BaseID: 145, QuoteID: 42}, @@ -2588,6 +2617,41 @@ func (m *TMarketMaker) CEXBook(host string, baseID, quoteID uint32) (buys, sells return book.Buys, book.Sells, nil } +func makeRequiredAction(assetID uint32, actionID string) *asset.ActionRequiredNote { + txID := dex.Bytes(encode.RandomBytes(32)).String() + var payload any + if actionID == core.ActionIDRedeemRejected { + payload = core.RejectedRedemptionData{ + AssetID: assetID, + CoinID: encode.RandomBytes(32), + CoinFmt: "0x8909ec4aa707df569e62e2f8e2040094e2c88fe192b3b3e2dadfa383a41aa645", + } + } else { + payload = ð.TransactionActionNote{ + Tx: randomWalletTransaction(asset.TransactionType(1+rand.Intn(15)), randomBalance()/10), // 1 to 15 + Nonce: uint64(rand.Float64() * 500), + NewFees: uint64(rand.Float64() * math.Pow10(rand.Intn(8))), + } + } + n := &asset.ActionRequiredNote{ + ActionID: actionID, + UniqueID: txID, + Payload: payload, + } + n.AssetID = assetID + n.Route = "actionRequired" + return n +} + +func makeActionResolved(assetID uint32, uniqueID string) *asset.ActionResolvedNote { + n := &asset.ActionResolvedNote{ + UniqueID: uniqueID, + } + n.AssetID = assetID + n.Route = "actionResolved" + return n +} + func TestServer(t *testing.T) { // Register dummy drivers for unimplemented assets. asset.Register(22, &TDriver{}) // mona @@ -2613,6 +2677,15 @@ func TestServer(t *testing.T) { doubleCreateAsyncErr = false randomizeOrdersCount = true + if enableActions { + actions = []*asset.ActionRequiredNote{ + makeRequiredAction(0, "missingNonces"), + makeRequiredAction(42, "lostNonce"), + makeRequiredAction(60, "tooCheap"), + makeRequiredAction(60, "redeemRejected"), + } + } + var shutdown context.CancelFunc tCtx, shutdown = context.WithCancel(context.Background()) time.AfterFunc(time.Minute*59, func() { shutdown() }) diff --git a/client/webserver/locales/en-us.go b/client/webserver/locales/en-us.go index 4176a85ae6..19992f5461 100644 --- a/client/webserver/locales/en-us.go +++ b/client/webserver/locales/en-us.go @@ -631,4 +631,6 @@ var EnUS = map[string]*intl.Translation{ "Round_trip fees": {T: "Round-trip fees"}, "feegap_tooltip": {T: "The rate adjustment necessary to account for on-chain tx fees"}, "remotegap_tooltip": {T: "The buy-sell spread on the linked cex market"}, + "max_zero_no_fees": {T: ` balance < min fees ~`}, + "max_zero_no_bal": {T: `low balance`}, } diff --git a/client/webserver/site/src/css/bootstrap.scss b/client/webserver/site/src/css/bootstrap.scss index f37c600136..edf72c545f 100644 --- a/client/webserver/site/src/css/bootstrap.scss +++ b/client/webserver/site/src/css/bootstrap.scss @@ -216,6 +216,7 @@ $utilities: ( start: left, end: right, center: center, + justify: justify, ) ), ); diff --git a/client/webserver/site/src/css/main.scss b/client/webserver/site/src/css/main.scss index 91645b01d3..571239f663 100644 --- a/client/webserver/site/src/css/main.scss +++ b/client/webserver/site/src/css/main.scss @@ -208,6 +208,18 @@ z-index: 1000; background-color: var(--loader-bg); } +#requiredActions { + position: absolute; + bottom: 0; + left: 0; + z-index: 98; + + & > div { + background-color: var(--body-bg); + border: 3px solid var(--border-color); + } +} + @include media-breakpoint-up(sm) { section { margin: 0.5rem; diff --git a/client/webserver/site/src/css/market.scss b/client/webserver/site/src/css/market.scss index 793d286d51..39ebfdebf9 100644 --- a/client/webserver/site/src/css/market.scss +++ b/client/webserver/site/src/css/market.scss @@ -420,10 +420,6 @@ div[data-handler=markets] { margin: 0 5px; } - .red { - color: $danger; - } - .disclaimer { text-align: justify; } @@ -438,7 +434,7 @@ div[data-handler=markets] { } } - button.selected { + button { &.buygreen-bg { background-color: var(--market-buygreen-bg); } diff --git a/client/webserver/site/src/css/mixins.scss b/client/webserver/site/src/css/mixins.scss index 7fbbb392a1..c5bba682a9 100644 --- a/client/webserver/site/src/css/mixins.scss +++ b/client/webserver/site/src/css/mixins.scss @@ -21,6 +21,15 @@ } } +@mixin hidden-overflow { + overflow: auto; + scrollbar-width: none; /* Firefox */ + + &::-webkit-scrollbar { + display: none; /* Safari and Chrome */ + } +} + @mixin fill-abs { position: absolute; top: 0; diff --git a/client/webserver/site/src/css/order.scss b/client/webserver/site/src/css/order.scss index de4ddcf509..278a458eff 100644 --- a/client/webserver/site/src/css/order.scss +++ b/client/webserver/site/src/css/order.scss @@ -21,10 +21,6 @@ div.match-card { flex-direction: column; align-items: stretch; font-size: 14px; - - .red { - color: $danger; - } } .match-data-label { diff --git a/client/webserver/site/src/css/utilities.scss b/client/webserver/site/src/css/utilities.scss index 944ddfd39d..7a615ede66 100644 --- a/client/webserver/site/src/css/utilities.scss +++ b/client/webserver/site/src/css/utilities.scss @@ -38,6 +38,10 @@ background-color: var(--tertiary-bg); } +.invisible { + visibility: hidden; +} + .stylish-overflow { @include stylish-overflow; @@ -59,6 +63,10 @@ } } +.hidden-overflow { + @include hidden-overflow; +} + @keyframes spin { 0% { transform: rotate(0deg); @@ -73,6 +81,10 @@ transition: color 1s ease; } +.mw-375 { + max-width: 375px; +} + .mw-425 { max-width: 425px; } @@ -161,10 +173,9 @@ sup.token-parent { } -// .hashwrap { -// word-break: break-all; -// user-select: all; -// } +.break-all { + word-break: break-all; +} .lh1 { line-height: 1; diff --git a/client/webserver/site/src/css/wallets.scss b/client/webserver/site/src/css/wallets.scss index 77cb062a26..344d4299e6 100644 --- a/client/webserver/site/src/css/wallets.scss +++ b/client/webserver/site/src/css/wallets.scss @@ -53,7 +53,7 @@ &.selected, &:hover { background-color: var(--body-bg); - border: 1px solid var(--border-color); + // border: 1px solid var(--border-color); opacity: 1; img[data-tmpl=parentImg] { @@ -196,7 +196,7 @@ } .column { - @include stylish-overflow; + @include hidden-overflow; @include fill-abs; } } diff --git a/client/webserver/site/src/html/bodybuilder.tmpl b/client/webserver/site/src/html/bodybuilder.tmpl index 2e34a5d795..1071ec85f3 100644 --- a/client/webserver/site/src/html/bodybuilder.tmpl +++ b/client/webserver/site/src/html/bodybuilder.tmpl @@ -118,6 +118,139 @@ {{end}} {{define "bottom"}} +
+
+ + + 4 + +
+
+
+
+ + Action Required +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
[[[Amount]]] + + +
[[[Fees]]] + + +
New Fees + + +
Tx Type
+ +
+
+ nonces are missing. Would you like + to attempt recovery? +
+
+ + +
+
+
+ +
+
+ A + transaction is stuck and has low fees. Should we replace it with a new + transaction? +
+
+
+ + +
+
+
+ +
+
+ A + transaction might be lost. A different transaction with the same + nonce was confirmed first. You can abandon the transaction +
+
+
+ + +
+
+
+ or you can tell us which transaction has nonce +
+
+ + +
+
+
+ +
+
+ A trade redemption was rejected + by the network. Network transaction fees were incurred. You + can try to redeem again, but it will likely incur more fees and + it may be rejected again. +
+ +
+ + +
+ +
+
+ +
+
+ + + / + + +
+
+
+ diff --git a/client/webserver/site/src/html/markets.tmpl b/client/webserver/site/src/html/markets.tmpl index e5e576b968..6a0b0ca759 100644 --- a/client/webserver/site/src/html/markets.tmpl +++ b/client/webserver/site/src/html/markets.tmpl @@ -463,6 +463,12 @@ --> + + , [[[max_zero_no_fees]]] + + + , [[[max_zero_no_bal]]] + @@ -626,7 +632,7 @@
[[[Your Orders]]]
-
[[[unready_wallets_msg]]]
+
[[[unready_wallets_msg]]]
no active orders
@@ -893,7 +899,7 @@
[[[estimate_unavailable]]] - +

diff --git a/client/webserver/site/src/html/order.tmpl b/client/webserver/site/src/html/order.tmpl index 5458669e9e..b393ca12de 100644 --- a/client/webserver/site/src/html/order.tmpl +++ b/client/webserver/site/src/html/order.tmpl @@ -117,7 +117,7 @@
-
[[[Cancellation]]]
+
[[[Cancellation]]]
()
@@ -189,7 +189,7 @@
- [[[Refund]]] (, [[[you]]])
+ [[[Refund]]] (, [[[you]]])
diff --git a/client/webserver/site/src/html/wallets.tmpl b/client/webserver/site/src/html/wallets.tmpl index 46d706c0fb..088cdd37a7 100644 --- a/client/webserver/site/src/html/wallets.tmpl +++ b/client/webserver/site/src/html/wallets.tmpl @@ -7,17 +7,25 @@
{{- /* ASSET SELECT */ -}} -
diff --git a/client/webserver/site/src/js/app.ts b/client/webserver/site/src/js/app.ts index 5095a60678..b4e8980e6b 100644 --- a/client/webserver/site/src/js/app.ts +++ b/client/webserver/site/src/js/app.ts @@ -2,7 +2,7 @@ import Doc from './doc' import State from './state' import RegistrationPage from './register' import LoginPage from './login' -import WalletsPage from './wallets' +import WalletsPage, { txTypeString } from './wallets' import SettingsPage from './settings' import MarketsPage from './markets' import OrdersPage from './orders' @@ -48,8 +48,14 @@ import { TxHistoryResult, WalletNote, TransactionNote, - PageElement + PageElement, + ActionRequiredNote, + ActionResolvedNote, + TransactionActionNote, + CoreActionRequiredNote, + RejectedRedemptionData } from './registry' +import { setCoinHref } from './coinexplorers' const idel = Doc.idel // = element by id const bind = Doc.bind @@ -127,6 +133,14 @@ const languageData: Record = { } } +interface requiredAction { + div: PageElement + stamp: number + uniqueID: string + actionID: string + selected: boolean +} + // Application is the main javascript web application for Bison Wallet. export default class Application { notes: CoreNotePlus[] @@ -156,6 +170,7 @@ export default class Application { popupTmpl: HTMLElement noteReceivers: Record void>[] txHistoryMap: Record + requiredActions: Record constructor () { this.notes = [] @@ -166,6 +181,7 @@ export default class Application { this.fiatRatesMap = {} this.showPopups = State.fetchLocal(State.popupsLK) === '1' this.txHistoryMap = {} + this.requiredActions = {} console.log('Bison Wallet, Build', this.commitHash.substring(0, 7)) @@ -249,6 +265,7 @@ export default class Application { } // Attach stuff. this.attachHeader() + this.attachActions() this.attachCommon(this.header) this.attach({}) // If we are authed, populate notes, otherwise get we'll them from the login @@ -463,6 +480,255 @@ export default class Application { } } + attachActions () { + const { page } = this + Object.assign(page, Doc.idDescendants(Doc.idel(document.body, 'requiredActions'))) + Doc.cleanTemplates(page.missingNoncesTmpl, page.actionTxTableTmpl, page.tooCheapTmpl, page.lostNonceTmpl) + Doc.bind(page.actionsCollapse, 'click', () => { + Doc.hide(page.actionDialog) + Doc.show(page.actionDialogCollapsed) + }) + Doc.bind(page.actionDialogCollapsed, 'click', () => { + Doc.hide(page.actionDialogCollapsed) + Doc.show(page.actionDialog) + if (page.actionDialogContent.children.length === 0) this.showOldestAction() + }) + const showAdjacentAction = (dir: number) => { + const selected = Object.values(this.requiredActions).filter((r: requiredAction) => r.selected)[0] + const actions = this.sortedActions() + const idx = actions.indexOf(selected) + this.showRequestedAction(actions[idx + dir].uniqueID) + } + Doc.bind(page.prevAction, 'click', () => showAdjacentAction(-1)) + Doc.bind(page.nextAction, 'click', () => showAdjacentAction(1)) + } + + setRequiredActions () { + const { user: { actions }, requiredActions } = this + if (!actions) return + for (const a of actions) this.addAction(a) + if (Object.keys(requiredActions).length) { + this.showOldestAction() + this.blinkAction() + } + } + + sortedActions () { + const actions = Object.values(this.requiredActions) + actions.sort((a: requiredAction, b: requiredAction) => a.stamp - b.stamp) + return actions + } + + showOldestAction () { + this.showRequestedAction(this.sortedActions()[0].uniqueID) + } + + addAction (req: ActionRequiredNote) { + const { page, requiredActions } = this + const existingAction = requiredActions[req.uniqueID] + if (existingAction && existingAction.actionID === req.actionID) return + const div = this.actionForm(req) + if (existingAction) { + if (existingAction.selected) existingAction.div.replaceWith(div) + existingAction.div = div + } else { + requiredActions[req.uniqueID] = { + div, + stamp: (new Date()).getTime(), + uniqueID: req.uniqueID, + actionID: req.actionID, + selected: false + } + const n = Object.keys(requiredActions).length + page.actionDialogCount.textContent = String(n) + page.actionCount.textContent = String(n) + if (Doc.isHidden(page.actionDialog)) { + this.showRequestedAction(req.uniqueID) + } + } + } + + blinkAction () { + Doc.blink(this.page.actionDialog) + Doc.blink(this.page.actionDialogCollapsed) + } + + resolveAction (req: ActionResolvedNote) { + this.resolveActionWithID(req.uniqueID) + } + + resolveActionWithID (uniqueID: string) { + const { page, requiredActions } = this + const existingAction = requiredActions[uniqueID] + if (!existingAction) return + delete requiredActions[uniqueID] + const rem = Object.keys(requiredActions).length + existingAction.div.remove() + if (rem === 0) { + Doc.hide(page.actionDialog, page.actionDialogCollapsed) + return + } + page.actionDialogCount.textContent = String(rem) + page.actionCount.textContent = String(rem) + if (existingAction.selected) this.showOldestAction() + } + + actionForm (req: ActionRequiredNote) { + switch (req.actionID) { + case 'tooCheap': + return this.tooCheapAction(req) + case 'missingNonces': + return this.missingNoncesAction(req) + case 'lostNonce': + return this.lostNonceAction(req) + case 'redeemRejected': + return this.redeemRejectedAction(req) + } + throw Error('unknown required action ID ' + req.actionID) + } + + actionTxTable (req: ActionRequiredNote) { + const { assetID, payload } = req + const n = payload as TransactionActionNote + const { unitInfo: ui, token } = this.assets[assetID] + const table = this.page.actionTxTableTmpl.cloneNode(true) as PageElement + const tmpl = Doc.parseTemplate(table) + tmpl.lostTxID.textContent = n.tx.id + tmpl.lostTxID.dataset.explorerCoin = n.tx.id + setCoinHref(token ? token.parentID : assetID, tmpl.lostTxID) + tmpl.txAmt.textContent = Doc.formatCoinValue(n.tx.amount, ui) + tmpl.amtUnit.textContent = ui.conventional.unit + const parentUI = token ? this.unitInfo(token.parentID) : ui + tmpl.type.textContent = txTypeString(n.tx.type) + tmpl.feeAmount.textContent = Doc.formatCoinValue(n.tx.fees, parentUI) + tmpl.feeUnit.textContent = parentUI.conventional.unit + switch (req.actionID) { + case 'tooCheap': { + Doc.show(tmpl.newFeesRow) + tmpl.newFees.textContent = Doc.formatCoinValue(n.tx.fees, parentUI) + tmpl.newFeesUnit.textContent = parentUI.conventional.unit + break + } + } + return table + } + + async submitAction (req: ActionRequiredNote, action: any, errMsg: PageElement) { + Doc.hide(errMsg) + const loading = this.loading(this.page.actionDialog) + const res = await postJSON('/api/takeaction', { + assetID: req.assetID, + actionID: req.actionID, + action + }) + loading() + if (!this.checkResponse(res)) { + errMsg.textContent = res.msg + Doc.show(errMsg) + return + } + this.resolveActionWithID(req.uniqueID) + } + + missingNoncesAction (req: ActionRequiredNote) { + const { assetID } = req + const div = this.page.missingNoncesTmpl.cloneNode(true) as PageElement + const tmpl = Doc.parseTemplate(div) + const { name } = this.assets[assetID] + tmpl.assetName.textContent = name + Doc.bind(tmpl.doNothingBttn, 'click', () => { + this.submitAction(req, { recover: false }, tmpl.errMsg) + }) + Doc.bind(tmpl.recoverBttn, 'click', () => { + this.submitAction(req, { recover: true }, tmpl.errMsg) + }) + return div + } + + tooCheapAction (req: ActionRequiredNote) { + const { assetID, payload } = req + const n = payload as TransactionActionNote + const div = this.page.tooCheapTmpl.cloneNode(true) as PageElement + const tmpl = Doc.parseTemplate(div) + const { name } = this.assets[assetID] + tmpl.assetName.textContent = name + tmpl.txTable.appendChild(this.actionTxTable(req)) + const act = (bump: boolean) => { + this.submitAction(req, { + txID: n.tx.id, + bump + }, tmpl.errMsg) + } + Doc.bind(tmpl.keepWaitingBttn, 'click', () => act(false)) + Doc.bind(tmpl.addFeesBttn, 'click', () => act(true)) + return div + } + + lostNonceAction (req: ActionRequiredNote) { + const { assetID, payload } = req + const n = payload as TransactionActionNote + const div = this.page.lostNonceTmpl.cloneNode(true) as PageElement + const tmpl = Doc.parseTemplate(div) + const { name } = this.assets[assetID] + tmpl.assetName.textContent = name + tmpl.nonce.textContent = String(n.nonce) + tmpl.txTable.appendChild(this.actionTxTable(req)) + Doc.bind(tmpl.abandonBttn, 'click', () => { + this.submitAction(req, { txID: n.tx.id, abandon: true }, tmpl.errMsg) + }) + Doc.bind(tmpl.keepWaitingBttn, 'click', () => { + this.submitAction(req, { txID: n.tx.id, abandon: false }, tmpl.errMsg) + }) + Doc.bind(tmpl.replaceBttn, 'click', () => { + const replacementID = tmpl.idInput.value + if (!replacementID) { + tmpl.idInput.focus() + Doc.blink(tmpl.idInput) + return + } + this.submitAction(req, { txID: n.tx.id, abandon: false, replacementID }, tmpl.errMsg) + }) + return div + } + + redeemRejectedAction (req: ActionRequiredNote) { + const { orderID, coinID, coinFmt, assetID } = req.payload as RejectedRedemptionData + const div = this.page.rejectedRedemptionTmpl.cloneNode(true) as PageElement + const tmpl = Doc.parseTemplate(div) + const { name, token } = this.assets[assetID] + tmpl.assetName.textContent = name + tmpl.txid.textContent = coinFmt + tmpl.txid.dataset.explorerCoin = coinID + setCoinHref(token ? token.parentID : assetID, tmpl.txid) + Doc.bind(tmpl.doNothingBttn, 'click', () => { + this.submitAction(req, { orderID, coinID, retry: false }, tmpl.errMsg) + }) + Doc.bind(tmpl.tryAgainBttn, 'click', () => { + this.submitAction(req, { orderID, coinID, retry: true }, tmpl.errMsg) + }) + return div + } + + showRequestedAction (uniqueID: string) { + const { page, requiredActions } = this + Doc.hide(page.actionDialogCollapsed) + for (const r of Object.values(requiredActions)) r.selected = r.uniqueID === uniqueID + Doc.empty(page.actionDialogContent) + const action = requiredActions[uniqueID] + page.actionDialogContent.appendChild(action.div) + Doc.show(page.actionDialog) + const actions = this.sortedActions() + if (actions.length === 1) { + Doc.hide(page.actionsNavigator) + return + } + Doc.show(page.actionsNavigator) + const idx = actions.indexOf(action) + page.currentAction.textContent = String(idx + 1) + page.prevAction.classList.toggle('invisible', idx === 0) + page.nextAction.classList.toggle('invisible', idx === actions.length - 1) + } + async setLanguage (lang: string) { await postJSON('/api/setlocale', lang) window.location.reload() @@ -563,6 +829,7 @@ export default class Application { res.notes.reverse() this.setNotes(res.notes) this.setPokes(res.pokes) + this.setRequiredActions() } /* attachCommon scans the provided node and handles some common bindings. */ @@ -629,6 +896,12 @@ export default class Application { delete this.txHistoryMap[assetID] } + loggedIn (notes: CoreNote[], pokes: CoreNote[]) { + this.setNotes(notes) + this.setPokes(pokes) + this.setRequiredActions() + } + /* * setNotes sets the current notification cache and populates the notification * display. @@ -759,11 +1032,28 @@ export default class Application { this.fiatRatesMap = (note as RateNote).fiatRates break } + case 'actionrequired': { + const n = note as CoreActionRequiredNote + this.addAction(n.payload) + break + } case 'walletnote': { const n = note as WalletNote - if (n.payload.route === 'transaction') { - const txNote = n.payload as TransactionNote - this.handleTransactionNote(n.payload.assetID, txNote) + switch (n.payload.route) { + case 'transaction': { + const txNote = n.payload as TransactionNote + this.handleTransactionNote(n.payload.assetID, txNote) + break + } + case 'actionRequired': { + const req = n.payload as ActionRequiredNote + this.addAction(req) + this.blinkAction() + break + } + case 'actionResolved': { + this.resolveAction(n.payload as ActionResolvedNote) + } } if (n.payload.route === 'transactionHistorySynced') { this.handleTxHistorySyncedNote(n.payload.assetID) @@ -1188,7 +1478,7 @@ export default class Application { if (!w) return false const traitAccountLocker = 1 << 14 if ((w.traits & traitAccountLocker) === 0) return false - const res = await postJSON('/api/walletsettings', { baseChainID }) + const res = await postJSON('/api/walletsettings', { assetID: baseChainID }) if (!this.checkResponse(res)) { console.error(res.msg) return false diff --git a/client/webserver/site/src/js/coinexplorers.ts b/client/webserver/site/src/js/coinexplorers.ts index ee5d9be7f8..cddef099b2 100644 --- a/client/webserver/site/src/js/coinexplorers.ts +++ b/client/webserver/site/src/js/coinexplorers.ts @@ -104,7 +104,7 @@ export const CoinExplorers: Record strin }, [Testnet]: (cid: string) => { const [arg, isAddr] = ethBasedExplorerArg(cid) - return isAddr ? `https://mumbai.polygonscan.com/address/${arg}` : `https://mumbai.polygonscan.com/tx/${arg}` + return isAddr ? `https://amoy.polygonscan.com/address/${arg}` : `https://amoy.polygonscan.com/tx/${arg}` }, [Simnet]: (cid: string) => { const [arg, isAddr] = ethBasedExplorerArg(cid) diff --git a/client/webserver/site/src/js/doc.ts b/client/webserver/site/src/js/doc.ts index 8e0512603d..1343e3d867 100644 --- a/client/webserver/site/src/js/doc.ts +++ b/client/webserver/site/src/js/doc.ts @@ -5,6 +5,7 @@ import { WalletState, PageElement } from './registry' +import State from './state' import { RateEncodingFactor } from './orderutil' // Symbolizer is satisfied by both dex.Asset and core.SupportedAsset. Used by @@ -242,6 +243,14 @@ export default class Doc { await new Animation(duration, f, easingAlgo).wait() } + static async blink (el: PageElement) { + const [r, g, b] = State.isDark() ? [255, 255, 255] : [0, 0, 0] + const cycles = 2 + Doc.animate(1000, (p: number) => { + el.style.outline = `2px solid rgba(${r}, ${g}, ${b}, ${(cycles - p * cycles) % 1})` + }) + } + static applySelector (ancestor: HTMLElement, k: string): PageElement[] { return Array.from(ancestor.querySelectorAll(k)) as PageElement[] } diff --git a/client/webserver/site/src/js/forms.ts b/client/webserver/site/src/js/forms.ts index 608b0fbc6c..4ebedb2c99 100644 --- a/client/webserver/site/src/js/forms.ts +++ b/client/webserver/site/src/js/forms.ts @@ -1986,13 +1986,10 @@ export class LoginForm { return } await app().fetchUser() - if (res.notes) { - res.notes.reverse() - app().setNotes(res.notes) - } - if (res.pokes) { - app().setPokes(res.pokes) - } + res.notes = res.notes || [] + res.notes.reverse() + res.pokes = res.pokes || [] + app().loggedIn(res.notes, res.pokes) if (this.pwCache) this.pwCache.pw = pw this.success() } diff --git a/client/webserver/site/src/js/init.ts b/client/webserver/site/src/js/init.ts index a5baa93fa6..3bb7d35e1e 100644 --- a/client/webserver/site/src/js/init.ts +++ b/client/webserver/site/src/js/init.ts @@ -121,11 +121,6 @@ class AppInitForm { return } - // Clear the notification cache. Useful for development purposes, since - // the Application will only clear them on login, which would leave old - // browser-cached notifications in place after registering even if the - // client db is wiped. - app().setNotes([]) page.appPW.value = '' page.appPWAgain.value = '' const loaded = app().loading(this.form) diff --git a/client/webserver/site/src/js/markets.ts b/client/webserver/site/src/js/markets.ts index 8dc7254c28..3082b047ba 100644 --- a/client/webserver/site/src/js/markets.ts +++ b/client/webserver/site/src/js/markets.ts @@ -1386,7 +1386,7 @@ export default class MarketsPage extends BasePage { if (this.maxEstimateTimer) window.clearTimeout(this.maxEstimateTimer) Doc.show(page.maxOrd, page.maxLotBox) - Doc.hide(page.maxAboveZero) + Doc.hide(page.maxAboveZero, page.maxZeroNoFees, page.maxZeroNoBal) page.maxFromLots.textContent = intl.prep(intl.ID_CALCULATING) page.maxFromLotsLbl.textContent = '' this.maxOrderUpdateCounter++ @@ -1421,7 +1421,7 @@ export default class MarketsPage extends BasePage { this.maxLoaded() this.maxLoaded = null } - Doc.show(page.maxOrd, page.maxLotBox, page.maxAboveZero) + Doc.show(page.maxOrd, page.maxLotBox) const sell = this.isSell() let lots = 0 @@ -1430,16 +1430,43 @@ export default class MarketsPage extends BasePage { page.maxFromLots.textContent = lots.toString() // XXX add plural into format details, so we don't need this page.maxFromLotsLbl.textContent = intl.prep(lots === 1 ? intl.ID_LOT : intl.ID_LOTS) - if (!maxOrder) { - Doc.hide(page.maxAboveZero) + if (!maxOrder) return + + const fromAsset = sell ? this.market.base : this.market.quote + + if (lots === 0) { + // If we have a maxOrder, see if we can guess why we have no lots. + let lotSize = this.market.cfg.lotsize + if (!sell) { + const conversionRate = this.anyRate()[1] + if (conversionRate === 0) return + lotSize = lotSize * conversionRate + } + const haveQty = fromAsset.wallet.balance.available / lotSize > 0 + if (haveQty) { + if (fromAsset.token) { + const { wallet: { balance: { available: feeAvail } }, unitInfo } = app().assets[fromAsset.token.parentID] + if (feeAvail < maxOrder.feeReservesPerLot) { + Doc.show(page.maxZeroNoFees) + page.maxZeroNoFeesTicker.textContent = unitInfo.conventional.unit + page.maxZeroMinFees.textContent = Doc.formatCoinValue(maxOrder.feeReservesPerLot, unitInfo) + } + // It looks like we should be able to afford it, but maybe some fees we're not seeing. + // Show nothing. + return + } + // Not a token. Maybe we have enough for the swap but not for fees. + const fundedLots = fromAsset.wallet.balance.available / (lotSize + maxOrder.feeReservesPerLot) + if (fundedLots > 0) return // Not sure why. Could be split txs or utxos. Just show nothing. + } + Doc.show(page.maxZeroNoBal) + page.maxZeroNoBalTicker.textContent = fromAsset.unitInfo.conventional.unit return } - // Could add the estimatedFees here, but that might also be - // confusing. - const fromAsset = sell ? this.market.base : this.market.quote - // DRAFT NOTE: Maybe just use base qty from lots. + Doc.show(page.maxAboveZero) + page.maxFromAmt.textContent = Doc.formatCoinValue(maxOrder.value || 0, fromAsset.unitInfo) - page.maxFromTicker.textContent = fromAsset.symbol.toUpperCase() + page.maxFromTicker.textContent = fromAsset.unitInfo.conventional.unit // Could subtract the maxOrder.redemptionFees here. // The qty conversion doesn't fit well with the new design. // TODO: Make this work somehow? diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index 695e8bfcff..efd037b832 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -343,6 +343,7 @@ export interface User { bots: BotReport[] net: number extensionModeConfig: ExtensionModeConfig + actions: ActionRequiredNote[] } export interface CoreNote { @@ -408,10 +409,37 @@ export interface TransactionNote extends BaseWalletNote { new: boolean } +export interface ActionRequiredNote extends BaseWalletNote { + uniqueID: string + actionID: string + payload: any +} + +export interface ActionResolvedNote extends BaseWalletNote { + uniqueID: string +} + +export interface TransactionActionNote { + tx: WalletTransaction + nonce: number + newFees: number +} + export interface WalletNote extends CoreNote { payload: BaseWalletNote } +export interface CoreActionRequiredNote extends CoreNote { + payload: ActionRequiredNote +} + +export interface RejectedRedemptionData { + assetID: number + orderID: string + coinID: string + coinFmt: string +} + export interface SpotPriceNote extends CoreNote { host: string spots: Record @@ -585,6 +613,7 @@ export interface SwapEstimate { maxFees: number realisticWorstCase: number realisticBestCase: number + feeReservesPerLot: number } export interface RedeemEstimate { @@ -1034,7 +1063,7 @@ export interface Application { attachCommon (node: HTMLElement): void updateBondConfs (dexAddr: string, coinID: string, confs: number, assetID: number): void handleBondNote (note: BondNote): void - setNotes (notes: CoreNote[]): void + loggedIn (notes: CoreNote[], pokes: CoreNote[]): void setPokes(pokes: CoreNote[]): void notify (note: CoreNote): void log (loggerID: string, ...msg: any): void diff --git a/client/webserver/site/src/js/wallets.ts b/client/webserver/site/src/js/wallets.ts index a48b4697f5..26b36820d9 100644 --- a/client/webserver/site/src/js/wallets.ts +++ b/client/webserver/site/src/js/wallets.ts @@ -935,6 +935,7 @@ export default class WalletsPage extends BasePage { const bttn = page.iconSelectTmpl.cloneNode(true) as HTMLElement page.assetSelect.appendChild(bttn) const tmpl = Doc.parseTemplate(bttn) + tmpl.unit.textContent = a.unitInfo.conventional.unit this.assetButtons[a.id] = { tmpl, bttn } this.updateAssetButton(a.id) Doc.bind(bttn, 'click', () => { @@ -949,7 +950,7 @@ export default class WalletsPage extends BasePage { updateAssetButton (assetID: number) { const a = app().assets[assetID] const { bttn, tmpl } = this.assetButtons[assetID] - Doc.hide(tmpl.fiat, tmpl.noWallet) + Doc.hide(tmpl.fiatBox, tmpl.noWallet) bttn.classList.add('nowallet') tmpl.img.src ||= Doc.logoPath(a.symbol) // don't initiate GET if already set (e.g. update on some notification) const symbolParts = a.symbol.split('.') @@ -965,9 +966,10 @@ export default class WalletsPage extends BasePage { const { wallet: { balance: b }, unitInfo: ui } = a const totalBalance = b.available + b.locked + b.immature tmpl.balance.textContent = Doc.formatCoinValue(totalBalance, ui) + Doc.show(tmpl.balanceBox) const rate = app().fiatRatesMap[a.id] if (rate) { - Doc.show(tmpl.fiat) + Doc.show(tmpl.fiatBox) tmpl.fiat.textContent = Doc.formatFiatConversion(totalBalance, rate, ui) } } else Doc.show(tmpl.noWallet) @@ -1005,7 +1007,7 @@ export default class WalletsPage extends BasePage { page.sendReceive, page.connectBttnBox, page.statusLocked, page.statusReady, page.statusOff, page.unlockBttnBox, page.lockBttnBox, page.connectBttnBox, page.peerCountBox, page.syncProgressBox, page.statusDisabled, page.tokenInfoBox, - page.needsProviderBttn + page.needsProviderBox ) this.checkNeedsProvider(assetID) if (token) { @@ -1040,14 +1042,10 @@ export default class WalletsPage extends BasePage { async checkNeedsProvider (assetID: number) { const needs = await app().needsCustomProvider(assetID) - const bttn = this.page.needsProviderBttn - Doc.setVis(needs, bttn) + const { page: { needsProviderBox: box, needsProviderBttn: bttn } } = this + Doc.setVis(needs, box) if (!needs) return - const [r, g, b] = State.isDark() ? [255, 255, 255] : [0, 0, 0] - const cycles = 2 - Doc.animate(1000, (p: number) => { - bttn.style.outline = `2px solid rgba(${r}, ${g}, ${b}, ${(cycles - p * cycles) % 1})` - }) + Doc.blink(bttn) } async updateTicketBuyer (assetID: number) { diff --git a/client/webserver/webserver.go b/client/webserver/webserver.go index 9483bc5e8b..c4f5e0a0b6 100644 --- a/client/webserver/webserver.go +++ b/client/webserver/webserver.go @@ -178,6 +178,7 @@ type clientCore interface { DisableFundsMixer(assetID uint32) error SetLanguage(string) error Language() string + TakeAction(assetID uint32, actionID string, actionB json.RawMessage) error } type mmCore interface { @@ -565,6 +566,7 @@ func New(cfg *Config) (*WebServer, error) { apiAuth.Post("/unapprovetoken", s.apiUnapproveToken) apiAuth.Post("/approvetokenfee", s.apiApproveTokenFee) apiAuth.Post("/txhistory", s.apiTxHistory) + apiAuth.Post("/takeaction", s.apiTakeAction) apiAuth.Post("/shieldedstatus", s.apiShieldedStatus) apiAuth.Post("/newshieldedaddress", s.apiNewShieldedAddress) diff --git a/client/webserver/webserver_test.go b/client/webserver/webserver_test.go index 37d1c8953f..b50de5fe43 100644 --- a/client/webserver/webserver_test.go +++ b/client/webserver/webserver_test.go @@ -371,6 +371,8 @@ func (c *TCore) DisableFundsMixer(assetID uint32) error { func (*TCore) SetLanguage(string) error { return nil } func (*TCore) Language() string { return "en-US" } +func (*TCore) TakeAction(assetID uint32, actionID string, actionB json.RawMessage) error { return nil } + type TWriter struct { b []byte } diff --git a/dex/networks/eth/params.go b/dex/networks/eth/params.go index 6312b3e87e..0591424347 100644 --- a/dex/networks/eth/params.go +++ b/dex/networks/eth/params.go @@ -147,13 +147,16 @@ func RefundGas(contractVer uint32) uint64 { return g.Refund } +var gweiFactorBig = big.NewInt(GweiFactor) + // GweiToWei converts uint64 Gwei to *big.Int Wei. func GweiToWei(v uint64) *big.Int { - return new(big.Int).Mul(big.NewInt(int64(v)), big.NewInt(GweiFactor)) + return new(big.Int).Mul(big.NewInt(int64(v)), gweiFactorBig) } -// WeiToGwei converts *big.Int Wei to uint64 Gwei. If v is determined to be -// unsuitable for a uint64, zero is returned. +// WeiToGweiFloor converts *big.Int Wei to uint64 Gwei. If v is determined to be +// unsuitable for a uint64, zero is returned. For values that are not even +// multiples of 1 gwei, this function returns the floor. func WeiToGwei(v *big.Int) uint64 { vGwei := new(big.Int).Div(v, big.NewInt(GweiFactor)) if vGwei.IsUint64() { @@ -162,14 +165,29 @@ func WeiToGwei(v *big.Int) uint64 { return 0 } -// WeiToGweiUint64 converts a *big.Int in wei (1e18 unit) to gwei (1e9 unit) as +// add before diving by gweiFactorBig to take the ceiling. +var gweiCeilAddend = big.NewInt(GweiFactor - 1) + +// WeiToGweiCeil converts *big.Int Wei to uint64 Gwei. If v is determined to be +// unsuitable for a uint64, zero is returned. For values that are not even +// multiples of 1 gwei, this function returns the ceiling. +func WeiToGweiCeil(v *big.Int) uint64 { + vGwei := new(big.Int).Div(new(big.Int).Add(v, gweiCeilAddend), big.NewInt(GweiFactor)) + if vGwei.IsUint64() { + return vGwei.Uint64() + } + return 0 +} + +// WeiToGweiSafe converts a *big.Int in wei (1e18 unit) to gwei (1e9 unit) as // a uint64. Errors if the amount of gwei is too big to fit fully into a uint64. -func WeiToGweiUint64(wei *big.Int) (uint64, error) { +// For values that are not even multiples of 1 gwei, this function returns the +// ceiling. +func WeiToGweiSafe(wei *big.Int) (uint64, error) { if wei.Cmp(new(big.Int)) == -1 { return 0, fmt.Errorf("wei must be non-negative") } - gweiFactorBig := big.NewInt(GweiFactor) - gwei := new(big.Int).Div(wei, gweiFactorBig) + gwei := new(big.Int).Div(new(big.Int).Add(wei, gweiCeilAddend), gweiFactorBig) if !gwei.IsUint64() { return 0, fmt.Errorf("suggest gas price %v gwei is too big for a uint64", wei) } diff --git a/dex/testing/dcrdex/harness.sh b/dex/testing/dcrdex/harness.sh index 8d70311114..0811b52b6b 100755 --- a/dex/testing/dcrdex/harness.sh +++ b/dex/testing/dcrdex/harness.sh @@ -20,8 +20,8 @@ cat > "${DCRDEX_DATA_DIR}/build" <= reqFeeRate + if sc.gasFeeCap.Cmp(dexeth.GweiToWei(reqFeeRate)) < 0 { + sc.backend.log.Errorf("Transaction %s gas fee cap too low. %s wei / gas < %s gwei / gas", sc.gasFeeCap, reqFeeRate) + return false + } + + return true } // 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 a606ce4ef3..572aee8eb0 100644 --- a/server/asset/eth/eth_test.go +++ b/server/asset/eth/eth_test.go @@ -285,10 +285,10 @@ func TestFeeRate(t *testing.T) { suggGasTipCap: new(big.Int), wantFee: 0, }, { - name: "ok rounded down", + name: "ok rounded up", hdrBaseFee: big.NewInt(dexeth.GweiFactor - 1), suggGasTipCap: new(big.Int), - wantFee: 1, + wantFee: 2, }, { name: "ok 100, 2", hdrBaseFee: big.NewInt(dexeth.GweiFactor * 100), @@ -495,8 +495,9 @@ func TestContract(t *testing.T) { func TestValidateFeeRate(t *testing.T) { swapCoin := swapCoin{ baseCoin: &baseCoin{ - gasFeeCap: 100, - gasTipCap: 2, + backend: &AssetBackend{log: tLogger}, + gasFeeCap: dexeth.GweiToWei(100), + gasTipCap: dexeth.GweiToWei(2), }, } @@ -514,7 +515,7 @@ func TestValidateFeeRate(t *testing.T) { t.Fatalf("expected invalid fee rate, but was valid") } - swapCoin.gasTipCap = dexeth.MinGasTipCap - 1 + swapCoin.gasTipCap = dexeth.GweiToWei(dexeth.MinGasTipCap - 1) if eth.ValidateFeeRate(contract.Coin, 100) { t.Fatalf("expected invalid fee rate, but was valid") } diff --git a/server/dex/dex.go b/server/dex/dex.go index ddc2721983..cb2ba9f5bb 100644 --- a/server/dex/dex.go +++ b/server/dex/dex.go @@ -108,7 +108,7 @@ func loadMarketConf(net dex.Network, src io.Reader) ([]*dex.MarketInfo, []*Asset return nil, nil, err } - log.Debug("-------------------- BEGIN parsed markets.json --------------------") + log.Debug("|-------------------- BEGIN parsed markets.json --------------------") log.Debug("MARKETS") log.Debug(" Base Quote LotSize EpochDur") for i, mktConf := range conf.Markets { @@ -138,7 +138,7 @@ func loadMarketConf(net dex.Network, src io.Reader) ([]*dex.MarketInfo, []*Asset } log.Debugf("%-12s % 10d % 9d % 9s", asset, assetConf.MaxFeeRate, assetConf.SwapConf, assetConf.Network) } - log.Debug("--------------------- END parsed markets.json ---------------------") + log.Debug("|--------------------- END parsed markets.json ---------------------|") // Normalize the asset names to lower case. var assets []*Asset diff --git a/tatanka/tcp/client/client.go b/tatanka/tcp/client/client.go index a970f40098..be59aef2b3 100644 --- a/tatanka/tcp/client/client.go +++ b/tatanka/tcp/client/client.go @@ -59,7 +59,7 @@ func (c *Client) Connect(ctx context.Context) (_ *sync.WaitGroup, err error) { PingWait: 20 * time.Second, Cert: c.cert, ReconnectSync: func() { - fmt.Println("--RECONNECTED RECONNECTED RECONNECTED RECONNECTED ") + fmt.Println("## RECONNECTED RECONNECTED RECONNECTED RECONNECTED ") }, ConnectEventFunc: func(status comms.ConnectionStatus) { if status == comms.Disconnected {