diff --git a/client/asset/btc/btc.go b/client/asset/btc/btc.go index 3a6b48ac2e..3b7f44ee97 100644 --- a/client/asset/btc/btc.go +++ b/client/asset/btc/btc.go @@ -82,10 +82,10 @@ const ( multiSplitBufferKey = "multisplitbuffer" redeemFeeBumpFee = "redeemfeebump" - // requiredRedeemConfirms is the amount of confirms a redeem transaction - // needs before the trade is considered confirmed. The redeem is + // requiredConfTxConfirms is the amount of confirms a redeem or refund + // transaction needs before the trade is considered confirmed. The tx is // monitored until this number of confirms is reached. - requiredRedeemConfirms = 1 + requiredConfTxConfirms = 1 ) const ( @@ -5995,7 +5995,7 @@ func (btc *intermediaryWallet) syncTxHistory(tip uint64) { if tx.BlockNumber > 0 && tip >= tx.BlockNumber { confs = tip - tx.BlockNumber + 1 } - if confs >= requiredRedeemConfirms { + if confs >= requiredConfTxConfirms { tx.Confirmed = true updated = true } @@ -6325,82 +6325,97 @@ func msgTxFromBytes(txB []byte) (*wire.MsgTx, error) { return deserializeMsgTx(bytes.NewReader(txB)) } -// ConfirmRedemption returns how many confirmations a redemption has. Normally -// this is very straightforward. However, with fluxuating fees, there's the +// ConfirmTransaction returns how many confirmations a redemption or refund has. +// Normally this is very straightforward. However, with fluctuating fees, there's the // possibility that the tx is never mined and eventually purged from the // mempool. In that case we use the provided fee suggestion to create and send // a new redeem transaction, returning the new transactions hash. -func (btc *baseWallet) ConfirmRedemption(coinID dex.Bytes, redemption *asset.Redemption, feeSuggestion uint64) (*asset.ConfirmRedemptionStatus, error) { +func (btc *baseWallet) ConfirmTransaction(coinID dex.Bytes, confirmTx *asset.ConfirmTx, feeSuggestion uint64) (*asset.ConfirmTxStatus, error) { txHash, _, err := decodeCoinID(coinID) if err != nil { return nil, err } _, confs, err := btc.rawWalletTx(txHash) - // redemption transaction found, return its confirms. + // Transaction found, return its confirms. // - // TODO: Investigate the case where this redeem has been sitting in the + // TODO: Investigate the case where this tx has been sitting in the // mempool for a long amount of time, possibly requiring some action by // us to get it unstuck. if err == nil { - return &asset.ConfirmRedemptionStatus{ + return &asset.ConfirmTxStatus{ Confs: uint64(confs), - Req: requiredRedeemConfirms, + Req: requiredConfTxConfirms, CoinID: coinID, }, nil } if !errors.Is(err, WalletTransactionNotFound) { - return nil, fmt.Errorf("problem searching for redemption transaction %s: %w", txHash, err) + return nil, fmt.Errorf("problem searching for %v transaction %s: %w", confirmTx.TxType(), txHash, err) } - // Redemption transaction is missing from the point of view of our node! - // Unlikely, but possible it was redeemed by another transaction. Check - // if the contract is still an unspent output. + // Redemption or refund transaction is missing from the point of view of + // our node! Unlikely, but possible it was spent by another transaction. + // Check if the contract is still an unspent output. - pkScript, err := btc.scriptHashScript(redemption.Spends.Contract) + pkScript, err := btc.scriptHashScript(confirmTx.Contract()) if err != nil { return nil, fmt.Errorf("error creating contract script: %w", err) } - swapHash, vout, err := decodeCoinID(redemption.Spends.Coin.ID()) + swapHash, vout, err := decodeCoinID(confirmTx.SpendsCoinID()) if err != nil { return nil, err } utxo, _, err := btc.node.getTxOut(swapHash, vout, pkScript, time.Now().Add(-ContractSearchLimit)) if err != nil { - return nil, fmt.Errorf("error finding unspent contract %s with swap hash %v vout %d: %w", redemption.Spends.Coin.ID(), swapHash, vout, err) + return nil, fmt.Errorf("error finding unspent contract %s with swap hash %v vout %d: %w", confirmTx.SpendsCoinID(), swapHash, vout, err) } if utxo == nil { // TODO: Spent, but by who. Find the spending tx. - btc.log.Warnf("Contract coin %v with swap hash %v vout %d spent by someone but not sure who.", redemption.Spends.Coin.ID(), swapHash, vout) + btc.log.Warnf("Contract coin %v with swap hash %v vout %d spent by someone but not sure who.", confirmTx.SpendsCoinID(), swapHash, vout) // Incorrect, but we will be in a loop of erroring if we don't // return something. - return &asset.ConfirmRedemptionStatus{ - Confs: requiredRedeemConfirms, - Req: requiredRedeemConfirms, + return &asset.ConfirmTxStatus{ + Confs: requiredConfTxConfirms, + Req: requiredConfTxConfirms, CoinID: coinID, }, nil } - // The contract has not yet been redeemed, but it seems the redeeming + // The contract has not yet been spent, but it seems the spending // tx has disappeared. Assume the fee was too low at the time and it - // was eventually purged from the mempool. Attempt to redeem again with + // was eventually purged from the mempool. Attempt to spend again with // a currently reasonable fee. + var newCoinID dex.Bytes + if confirmTx.IsRedeem() { + form := &asset.RedeemForm{ + Redemptions: []*asset.Redemption{ + { + Spends: confirmTx.Spends(), + Secret: confirmTx.Secret(), + }, + }, + FeeSuggestion: feeSuggestion, + } + _, coin, _, err := btc.Redeem(form) + if err != nil { + return nil, fmt.Errorf("unable to re-redeem %s with swap hash %v vout %d: %w", confirmTx.SpendsCoinID(), swapHash, vout, err) + } + newCoinID = coin.ID() + } else { + spendsCoinID := confirmTx.SpendsCoinID() + newCoinID, err = btc.Refund(spendsCoinID, confirmTx.Contract(), feeSuggestion) + if err != nil { + return nil, fmt.Errorf("unable to re-refund %s: %w", spendsCoinID, err) + } - form := &asset.RedeemForm{ - Redemptions: []*asset.Redemption{redemption}, - FeeSuggestion: feeSuggestion, - } - _, coin, _, err := btc.Redeem(form) - if err != nil { - return nil, fmt.Errorf("unable to re-redeem %s with swap hash %v vout %d: %w", redemption.Spends.Coin.ID(), swapHash, vout, err) } - return &asset.ConfirmRedemptionStatus{ + return &asset.ConfirmTxStatus{ Confs: 0, - Req: requiredRedeemConfirms, - CoinID: coin.ID(), + Req: requiredConfTxConfirms, + CoinID: newCoinID, }, nil } diff --git a/client/asset/btc/btc_test.go b/client/asset/btc/btc_test.go index b7d27b3404..94166f884b 100644 --- a/client/asset/btc/btc_test.go +++ b/client/asset/btc/btc_test.go @@ -5805,7 +5805,7 @@ func TestReconfigure(t *testing.T) { } } -func TestConfirmRedemption(t *testing.T) { +func TestConfirmTransaction(t *testing.T) { segwit := true wallet, node, shutdown := tNewWallet(segwit, walletTypeRPC) defer shutdown() @@ -5822,10 +5822,7 @@ func TestConfirmRedemption(t *testing.T) { Expiration: lockTime, } - redemption := &asset.Redemption{ - Spends: ci, - Secret: secret, - } + confirmTx := asset.NewRedeemConfTx(ci, secret) coinID := coin.ID() @@ -5841,7 +5838,7 @@ func TestConfirmRedemption(t *testing.T) { tests := []struct { name string - redemption *asset.Redemption + confirmTx *asset.ConfirmTx coinID []byte wantErr bool wantConfs uint64 @@ -5852,38 +5849,43 @@ func TestConfirmRedemption(t *testing.T) { }{{ name: "ok and found", coinID: coinID, - redemption: redemption, + confirmTx: confirmTx, getTransactionResult: new(GetTransactionResult), }, { - name: "ok spent by someone but not sure who", - coinID: coinID, - redemption: redemption, - wantConfs: requiredRedeemConfirms, + name: "ok spent by someone but not sure who", + coinID: coinID, + confirmTx: confirmTx, + wantConfs: requiredConfTxConfirms, }, { - name: "ok but sending new tx", - coinID: coinID, - redemption: redemption, - txOutRes: new(btcjson.GetTxOutResult), + name: "ok but sending new tx", + coinID: coinID, + confirmTx: confirmTx, + txOutRes: new(btcjson.GetTxOutResult), }, { - name: "decode coin error", - redemption: redemption, - wantErr: true, + name: "ok but sending new refund tx", + coinID: coinID, + confirmTx: asset.NewRefundConfTx(coin.ID(), contract, secret), + txOutRes: newTxOutResult(nil, 1e8, 2), }, { - name: "error finding contract output", - coinID: coinID, - redemption: redemption, - txOutErr: errors.New(""), - wantErr: true, + name: "decode coin error", + confirmTx: confirmTx, + wantErr: true, + }, { + name: "error finding contract output", + coinID: coinID, + confirmTx: confirmTx, + txOutErr: errors.New(""), + wantErr: true, }, { name: "error finding redeem tx", coinID: coinID, - redemption: redemption, + confirmTx: confirmTx, getTransactionErr: errors.New(""), wantErr: true, }, { - name: "redemption error", + name: "redeem error", coinID: coinID, - redemption: func() *asset.Redemption { + confirmTx: func() *asset.ConfirmTx { ci := &asset.AuditInfo{ Coin: coin, // Contract: contract, @@ -5891,13 +5893,16 @@ func TestConfirmRedemption(t *testing.T) { Expiration: lockTime, } - return &asset.Redemption{ - Spends: ci, - Secret: secret, - } + return asset.NewRedeemConfTx(ci, secret) }(), txOutRes: new(btcjson.GetTxOutResult), wantErr: true, + }, { + name: "refund error", + coinID: coinID, + confirmTx: asset.NewRefundConfTx(coin.ID(), contract, secret), + txOutRes: new(btcjson.GetTxOutResult), // fee too low + wantErr: true, }} for _, test := range tests { node.txOutRes = test.txOutRes @@ -5905,7 +5910,7 @@ func TestConfirmRedemption(t *testing.T) { node.getTransactionErr = test.getTransactionErr node.getTransactionMap[tTxID] = test.getTransactionResult - status, err := wallet.ConfirmRedemption(test.coinID, test.redemption, 0) + status, err := wallet.ConfirmTransaction(test.coinID, test.confirmTx, 0) if test.wantErr { if err == nil { t.Fatalf("%q: expected error", test.name) diff --git a/client/asset/btc/electrum.go b/client/asset/btc/electrum.go index feb11f239d..f5072e71e9 100644 --- a/client/asset/btc/electrum.go +++ b/client/asset/btc/electrum.go @@ -502,7 +502,7 @@ func (btc *ExchangeWalletElectrum) syncTxHistory(tip uint64) { if tx.BlockNumber > 0 && tip >= tx.BlockNumber { confs = tip - tx.BlockNumber + 1 } - if confs >= requiredRedeemConfirms { + if confs >= requiredConfTxConfirms { tx.Confirmed = true updated = true } diff --git a/client/asset/dcr/dcr.go b/client/asset/dcr/dcr.go index c0805a2944..ab46ca849e 100644 --- a/client/asset/dcr/dcr.go +++ b/client/asset/dcr/dcr.go @@ -92,11 +92,11 @@ const ( // past which fetchFeeFromOracle should be used to refresh the rate. freshFeeAge = time.Minute - // requiredRedeemConfirms is the amount of confirms a redeem transaction - // needs before the trade is considered confirmed. The redeem is + // requiredConfTxConfirms is the amount of confirms a redeem or refund + // transaction needs before the trade is considered confirmed. The tx is // monitored until this number of confirms is reached. Two to make sure - // the block containing the redeem is stakeholder-approved - requiredRedeemConfirms = 2 + // the block containing the tx is stakeholder-approved + requiredConfTxConfirms = 2 vspFileName = "vsp.json" @@ -119,12 +119,12 @@ var ( conventionalConversionFactor = float64(dexdcr.UnitInfo.Conventional.ConversionFactor) walletBlockAllowance = time.Second * 10 - // maxRedeemMempoolAge is the max amount of time the wallet will let a - // redeem transaction sit in mempool from the time it is first seen + // maxMempoolAge is the max amount of time the wallet will let a + // redeem or refund transaction sit in mempool from the time it is first seen // until it attempts to abandon it and try to send a new transaction. // This is necessary because transactions with already spent inputs may // be tried over and over with wallet in SPV mode. - maxRedeemMempoolAge = time.Hour * 2 + maxMempoolAge = time.Hour * 2 walletOpts = []*asset.ConfigOption{ { @@ -600,9 +600,11 @@ type exchangeWalletConfig struct { apiFeeFallback bool } -type mempoolRedeem struct { +// mempoolTx holds a refund or redeem. +type mempoolTx struct { txHash chainhash.Hash firstSeen time.Time + txType asset.ConfirmTxType } // vsp holds info needed for purchasing tickets from a vsp. PubKey is from the @@ -656,9 +658,9 @@ type ExchangeWallet struct { externalTxMtx sync.RWMutex externalTxCache map[chainhash.Hash]*externalTx - // TODO: Consider persisting mempool redeems on file. - mempoolRedeemsMtx sync.RWMutex - mempoolRedeems map[[32]byte]*mempoolRedeem // keyed by secret hash + // TODO: Consider persisting mempool txs on file. + mempoolTxsMtx sync.RWMutex + mempoolTxs map[[32]byte]*mempoolTx // keyed by secret hash vspV atomic.Value // *vsp @@ -844,7 +846,7 @@ func unconnectedWallet(cfg *asset.WalletConfig, dcrCfg *walletConfig, chainParam findRedemptionQueue: make(map[outPoint]*findRedemptionReq), externalTxCache: make(map[chainhash.Hash]*externalTx), oracleFees: make(map[uint64]feeStamped), - mempoolRedeems: make(map[[32]byte]*mempoolRedeem), + mempoolTxs: make(map[[32]byte]*mempoolTx), vspFilepath: vspFilepath, walletType: cfg.Type, subsidyCache: blockchain.NewSubsidyCache(chainParams), @@ -3324,14 +3326,14 @@ func (dcr *ExchangeWallet) Redeem(form *asset.RedeemForm) ([]dex.Bytes, asset.Co }, txHash, true) coinIDs := make([]dex.Bytes, 0, len(form.Redemptions)) - dcr.mempoolRedeemsMtx.Lock() + dcr.mempoolTxsMtx.Lock() for i := range form.Redemptions { coinIDs = append(coinIDs, toCoinID(txHash, uint32(i))) var secretHash [32]byte copy(secretHash[:], form.Redemptions[i].Spends.SecretHash) - dcr.mempoolRedeems[secretHash] = &mempoolRedeem{txHash: *txHash, firstSeen: time.Now()} + dcr.mempoolTxs[secretHash] = &mempoolTx{txHash: *txHash, firstSeen: time.Now(), txType: asset.CTRedeem} } - dcr.mempoolRedeemsMtx.Unlock() + dcr.mempoolTxsMtx.Unlock() return coinIDs, newOutput(txHash, 0, uint64(txOut.Value), wire.TxTreeRegular), fee, nil } @@ -7031,9 +7033,9 @@ func (dcr *ExchangeWallet) TxHistory(n int, refID *string, past bool) ([]*asset. return txHistoryDB.GetTxs(n, refID, past) } -// ConfirmRedemption returns how many confirmations a redemption has. Normally -// this is very straightforward. However there are two situations that have come -// up that this also handles. One is when the wallet can not find the redemption +// ConfirmTransaction returns how many confirmations a redemption or refund has. +// Normally this is very straightforward. However there are two situations that +// have come up that this also handles. One is when the wallet can not find the // transaction. This is most likely because the fee was set too low and the tx // was removed from the mempool. In the case where it is not found, this will // send a new tx using the provided fee suggestion. The second situation @@ -7042,74 +7044,72 @@ func (dcr *ExchangeWallet) TxHistory(n int, refID *string, past bool) ([]*asset. // mode and the transaction inputs having been spent by another transaction. The // wallet will not pick up on this so we could tell it to abandon the original // transaction and, again, send a new one using the provided feeSuggestion, but -// only warning for now. This method should not be run for the same redemption -// concurrently as it need to watch a new redeem transaction before finishing. -func (dcr *ExchangeWallet) ConfirmRedemption(coinID dex.Bytes, redemption *asset.Redemption, feeSuggestion uint64) (*asset.ConfirmRedemptionStatus, error) { +// only warning for now. This method should not be run for the same tx concurrently. +func (dcr *ExchangeWallet) ConfirmTransaction(coinID dex.Bytes, confirmTx *asset.ConfirmTx, feeSuggestion uint64) (*asset.ConfirmTxStatus, error) { txHash, _, err := decodeCoinID(coinID) if err != nil { return nil, err } var secretHash [32]byte - copy(secretHash[:], redemption.Spends.SecretHash) - dcr.mempoolRedeemsMtx.RLock() - mRedeem, have := dcr.mempoolRedeems[secretHash] - dcr.mempoolRedeemsMtx.RUnlock() + copy(secretHash[:], confirmTx.SecretHash()) + dcr.mempoolTxsMtx.RLock() + mTx, have := dcr.mempoolTxs[secretHash] + dcr.mempoolTxsMtx.RUnlock() - var deleteMempoolRedeem bool + var deleteMempoolTx bool defer func() { - if deleteMempoolRedeem { - dcr.mempoolRedeemsMtx.Lock() - delete(dcr.mempoolRedeems, secretHash) - dcr.mempoolRedeemsMtx.Unlock() + if deleteMempoolTx { + dcr.mempoolTxsMtx.Lock() + delete(dcr.mempoolTxs, secretHash) + dcr.mempoolTxsMtx.Unlock() } }() tx, err := dcr.wallet.GetTransaction(dcr.ctx, txHash) if err != nil && !errors.Is(err, asset.CoinNotFoundError) { - return nil, fmt.Errorf("problem searching for redemption transaction %s: %w", txHash, err) + return nil, fmt.Errorf("problem searching for %s transaction %s: %w", confirmTx.TxType(), txHash, err) } if err == nil { - if have && mRedeem.txHash == *txHash { - if tx.Confirmations == 0 && time.Now().After(mRedeem.firstSeen.Add(maxRedeemMempoolAge)) { + if have && mTx.txHash == *txHash { + if tx.Confirmations == 0 && time.Now().After(mTx.firstSeen.Add(maxMempoolAge)) { // Transaction has been sitting in the mempool // for a long time now. // // TODO: Consider abandoning. - redeemAge := time.Since(mRedeem.firstSeen) - dcr.log.Warnf("Redemption transaction %v has been in the mempool for %v which is too long.", txHash, redeemAge) + txAge := time.Since(mTx.firstSeen) + dcr.log.Warnf("%s transaction %v has been in the mempool for %v which is too long.", confirmTx.TxType(), txHash, txAge) } } else { if have { // This should not happen. Core has told us to - // watch a new redeem with a different transaction - // hash for a trade we were already watching. - return nil, fmt.Errorf("tx were were watching %s for redeem with secret hash %x being "+ - "replaced by tx %s. core should not be replacing the transaction. maybe ConfirmRedemption "+ - "is being run concurrently for the same redeem", mRedeem.txHash, secretHash, *txHash) + // watch a new redeem or refund with a different + // transaction hash for a trade we were already watching. + return nil, fmt.Errorf("tx were were watching %s for %s with secret hash %x being "+ + "replaced by tx %s. core should not be replacing the transaction. maybe ConfirmTransaction "+ + "is being run concurrently for the same tx", mTx.txHash, confirmTx.TxType(), secretHash, *txHash) } // Will hit this if bisonw was restarted with an actively - // redeeming swap. - dcr.mempoolRedeemsMtx.Lock() - dcr.mempoolRedeems[secretHash] = &mempoolRedeem{txHash: *txHash, firstSeen: time.Now()} - dcr.mempoolRedeemsMtx.Unlock() + // redeeming or maybe refunding swap. + dcr.mempoolTxsMtx.Lock() + dcr.mempoolTxs[secretHash] = &mempoolTx{txHash: *txHash, firstSeen: time.Now(), txType: confirmTx.TxType()} + dcr.mempoolTxsMtx.Unlock() } - if tx.Confirmations >= requiredRedeemConfirms { - deleteMempoolRedeem = true + if tx.Confirmations >= requiredConfTxConfirms { + deleteMempoolTx = true } - return &asset.ConfirmRedemptionStatus{ + return &asset.ConfirmTxStatus{ Confs: uint64(tx.Confirmations), - Req: requiredRedeemConfirms, + Req: requiredConfTxConfirms, CoinID: coinID, }, nil } - // Redemption transaction is missing from the point of view of our wallet! - // Unlikely, but possible it was redeemed by another transaction. We - // assume a contract past its locktime cannot make it here, so it must - // not be refunded. Check if the contract is still an unspent output. + // Transaction is missing from the point of view of our wallet! + // Unlikely, but possible it was spent by another transaction.Check if + // the contract is still an unspent output. - swapHash, vout, err := decodeCoinID(redemption.Spends.Coin.ID()) + swapHash, vout, err := decodeCoinID(confirmTx.SpendsCoinID()) if err != nil { return nil, err } @@ -7122,7 +7122,7 @@ func (dcr *ExchangeWallet) ConfirmRedemption(coinID dex.Bytes, redemption *asset switch spentStatus { case -1, 1: // First find the block containing the output itself. - scriptAddr, err := stdaddr.NewAddressScriptHashV0(redemption.Spends.Contract, dcr.chainParams) + scriptAddr, err := stdaddr.NewAddressScriptHashV0(confirmTx.Contract(), dcr.chainParams) if err != nil { return nil, fmt.Errorf("error encoding contract address: %w", err) } @@ -7171,59 +7171,73 @@ func (dcr *ExchangeWallet) ConfirmRedemption(coinID dex.Bytes, redemption *asset } confs := uint64(height - block.height) hash := spendTx.TxHash() - if confs < requiredRedeemConfirms { - dcr.mempoolRedeemsMtx.Lock() - dcr.mempoolRedeems[secretHash] = &mempoolRedeem{txHash: hash, firstSeen: time.Now()} - dcr.mempoolRedeemsMtx.Unlock() + if confs < requiredConfTxConfirms { + dcr.mempoolTxsMtx.Lock() + dcr.mempoolTxs[secretHash] = &mempoolTx{txHash: hash, firstSeen: time.Now(), txType: confirmTx.TxType()} + dcr.mempoolTxsMtx.Unlock() } - return &asset.ConfirmRedemptionStatus{ + return &asset.ConfirmTxStatus{ Confs: confs, - Req: requiredRedeemConfirms, + Req: requiredConfTxConfirms, CoinID: toCoinID(&hash, uint32(vin)), }, nil } - dcr.log.Warnf("Contract coin %v spent by someone but not sure who.", redemption.Spends.Coin.ID()) + dcr.log.Warnf("Contract coin %v spent by someone but not sure who.", confirmTx.SpendsCoinID()) // Incorrect, but we will be in a loop of erroring if we don't // return something. We were unable to find the spender for some // reason. // May be still in the map if abandonTx failed. - deleteMempoolRedeem = true + deleteMempoolTx = true - return &asset.ConfirmRedemptionStatus{ - Confs: requiredRedeemConfirms, - Req: requiredRedeemConfirms, + return &asset.ConfirmTxStatus{ + Confs: requiredConfTxConfirms, + Req: requiredConfTxConfirms, CoinID: coinID, }, nil } - // The contract has not yet been redeemed, but it seems the redeeming - // tx has disappeared. Assume the fee was too low at the time and it - // was eventually purged from the mempool. Attempt to redeem again with - // a currently reasonable fee. + // The contract has not yet been redeemed or refunded, but it seems the + // spending tx has disappeared. Assume the fee was too low at the time + // and it was eventually purged from the mempool. Attempt to spend again + // with a currently reasonable fee. - form := &asset.RedeemForm{ - Redemptions: []*asset.Redemption{redemption}, - FeeSuggestion: feeSuggestion, - } - _, coin, _, err := dcr.Redeem(form) - if err != nil { - return nil, fmt.Errorf("unable to re-redeem %s: %w", redemption.Spends.Coin.ID(), err) + var newCoinID dex.Bytes + if confirmTx.IsRedeem() { + form := &asset.RedeemForm{ + Redemptions: []*asset.Redemption{ + { + Spends: confirmTx.Spends(), + Secret: confirmTx.Secret(), + }, + }, + FeeSuggestion: feeSuggestion, + } + _, coin, _, err := dcr.Redeem(form) + if err != nil { + return nil, fmt.Errorf("unable to re-redeem %s: %w", confirmTx.SpendsCoinID(), err) + } + newCoinID = coin.ID() + } else { + spendsCoinID := confirmTx.SpendsCoinID() + newCoinID, err = dcr.Refund(spendsCoinID, confirmTx.Contract(), feeSuggestion) + if err != nil { + return nil, fmt.Errorf("unable to re-refund %s: %w", spendsCoinID, err) + } } - coinID = coin.ID() - newRedeemHash, _, err := decodeCoinID(coinID) + newTxHash, _, err := decodeCoinID(newCoinID) if err != nil { return nil, err } - dcr.mempoolRedeemsMtx.Lock() - dcr.mempoolRedeems[secretHash] = &mempoolRedeem{txHash: *newRedeemHash, firstSeen: time.Now()} - dcr.mempoolRedeemsMtx.Unlock() + dcr.mempoolTxsMtx.Lock() + dcr.mempoolTxs[secretHash] = &mempoolTx{txHash: *newTxHash, firstSeen: time.Now(), txType: confirmTx.TxType()} + dcr.mempoolTxsMtx.Unlock() - return &asset.ConfirmRedemptionStatus{ + return &asset.ConfirmTxStatus{ Confs: 0, - Req: requiredRedeemConfirms, + Req: requiredConfTxConfirms, CoinID: coinID, }, nil } diff --git a/client/asset/dcr/dcr_test.go b/client/asset/dcr/dcr_test.go index 7caf8e9dd9..b4f08007d4 100644 --- a/client/asset/dcr/dcr_test.go +++ b/client/asset/dcr/dcr_test.go @@ -4197,7 +4197,7 @@ func TestEstimateSendTxFee(t *testing.T) { } } -func TestConfirmRedemption(t *testing.T) { +func TestConfirmTransaction(t *testing.T) { wallet, node, shutdown := tNewWallet() defer shutdown() @@ -4207,6 +4207,7 @@ func TestConfirmRedemption(t *testing.T) { lockTime := time.Now().Add(time.Hour * 12) addr := tPKHAddr.String() + node.newAddr = tPKHAddr contract, err := dexdcr.MakeContract(addr, addr, secretHash[:], lockTime.Unix(), tChainParams) if err != nil { t.Fatalf("error making swap contract: %v", err) @@ -4272,10 +4273,7 @@ func TestConfirmRedemption(t *testing.T) { SecretHash: secretHash[:], } - redemption := &asset.Redemption{ - Spends: ci, - Secret: secret, - } + confirmTx := asset.NewRedeemConfTx(ci, secret) coinID := coin.ID() // Inverting the first byte. @@ -4283,56 +4281,62 @@ func TestConfirmRedemption(t *testing.T) { tests := []struct { name string - redemption *asset.Redemption + confirmTx *asset.ConfirmTx coinID []byte wantErr bool bestBlockErr error txRes func() (*walletjson.GetTransactionResult, error) wantConfs uint64 - mempoolRedeems map[[32]byte]*mempoolRedeem + mempoolTxs map[[32]byte]*mempoolTx txOutRes map[outPoint]*chainjson.GetTxOutResult unspentOutputErr error }{{ - name: "ok tx never seen before now", + name: "ok tx never seen before now", + coinID: coinID, + confirmTx: confirmTx, + txRes: txFn([]bool{false}), + }, { + name: "ok tx in map", coinID: coinID, - redemption: redemption, + confirmTx: confirmTx, + txRes: txFn([]bool{false}), + mempoolTxs: map[[32]byte]*mempoolTx{secretHash: {txHash: txHash, firstSeen: time.Now(), txType: asset.CTRedeem}}, + }, { + name: "tx in map has different hash than coin id", + coinID: badCoinID, + confirmTx: confirmTx, txRes: txFn([]bool{false}), + mempoolTxs: map[[32]byte]*mempoolTx{secretHash: {txHash: txHash, firstSeen: time.Now(), txType: asset.CTRedeem}}, + wantErr: true, }, { - name: "ok tx in map", - coinID: coinID, - redemption: redemption, - txRes: txFn([]bool{false}), - mempoolRedeems: map[[32]byte]*mempoolRedeem{secretHash: {txHash: txHash, firstSeen: time.Now()}}, + name: "ok tx not found new tx", + coinID: coinID, + confirmTx: confirmTx, + txRes: txFn([]bool{true, false}), + txOutRes: map[outPoint]*chainjson.GetTxOutResult{newOutPoint(&txHash, 0): makeGetTxOutRes(0, 5, nil)}, }, { - name: "tx in map has different hash than coin id", - coinID: badCoinID, - redemption: redemption, - txRes: txFn([]bool{false}), - mempoolRedeems: map[[32]byte]*mempoolRedeem{secretHash: {txHash: txHash, firstSeen: time.Now()}}, - wantErr: true, + name: "ok refund tx not found new tx", + coinID: coinID, + confirmTx: asset.NewRefundConfTx(coin.ID(), contract, secret), + txRes: txFn([]bool{true, false}), + txOutRes: map[outPoint]*chainjson.GetTxOutResult{newOutPoint(&txHash, 0): makeGetTxOutRes(0, 5, nil)}, }, { - name: "ok tx not found spent new tx", + name: "ok old tx should maybe be abandoned", coinID: coinID, - redemption: redemption, + confirmTx: confirmTx, txRes: txFn([]bool{false}), - txOutRes: map[outPoint]*chainjson.GetTxOutResult{newOutPoint(&txHash, 0): makeGetTxOutRes(0, 5, nil)}, + mempoolTxs: map[[32]byte]*mempoolTx{secretHash: {txHash: txHash, firstSeen: time.Now().Add(-maxMempoolAge - time.Second), txType: asset.CTRedeem}}, }, { - name: "ok old tx should maybe be abandoned", - coinID: coinID, - redemption: redemption, - txRes: txFn([]bool{false}), - mempoolRedeems: map[[32]byte]*mempoolRedeem{secretHash: {txHash: txHash, firstSeen: time.Now().Add(-maxRedeemMempoolAge - time.Second)}}, - }, { - name: "ok and spent", - coinID: coinID, - txRes: txFn([]bool{true, false}), - redemption: redemption, - wantConfs: 1, // one confirm because this tx is in the best block + name: "ok and spent", + coinID: coinID, + txRes: txFn([]bool{true, false}), + confirmTx: confirmTx, + wantConfs: 1, // one confirm because this tx is in the best block }, { name: "ok and spent but we dont know who spent it", coinID: coinID, txRes: txFn([]bool{true, false}), - redemption: func() *asset.Redemption { + confirmTx: func() *asset.ConfirmTx { ci := &asset.AuditInfo{ Coin: coin, Contract: contract, @@ -4340,30 +4344,27 @@ func TestConfirmRedemption(t *testing.T) { Expiration: lockTime, SecretHash: make([]byte, 32), // fake secret hash } - return &asset.Redemption{ - Spends: ci, - Secret: secret, - } + return asset.NewRedeemConfTx(ci, secret) }(), - wantConfs: requiredRedeemConfirms, + wantConfs: requiredConfTxConfirms, }, { - name: "get transaction error", - coinID: coinID, - redemption: redemption, - txRes: txFn([]bool{true, true}), - wantErr: true, + name: "get transaction error", + coinID: coinID, + confirmTx: confirmTx, + txRes: txFn([]bool{true, true}), + wantErr: true, }, { - name: "decode coin error", - coinID: nil, - redemption: redemption, - txRes: txFn([]bool{true, false}), - wantErr: true, + name: "decode coin error", + coinID: nil, + confirmTx: confirmTx, + txRes: txFn([]bool{true, false}), + wantErr: true, }, { name: "redeem error", coinID: coinID, txOutRes: map[outPoint]*chainjson.GetTxOutResult{newOutPoint(&txHash, 0): makeGetTxOutRes(0, 5, nil)}, txRes: txFn([]bool{true, false}), - redemption: func() *asset.Redemption { + confirmTx: func() *asset.ConfirmTx { ci := &asset.AuditInfo{ Coin: coin, // Contract: contract, @@ -4371,25 +4372,29 @@ func TestConfirmRedemption(t *testing.T) { Expiration: lockTime, SecretHash: secretHash[:], } - return &asset.Redemption{ - Spends: ci, - Secret: secret, - } + return asset.NewRedeemConfTx(ci, secret) }(), wantErr: true, + }, { + name: "refund error", + coinID: coinID, + txOutRes: map[outPoint]*chainjson.GetTxOutResult{newOutPoint(&txHash, 0): makeGetTxOutRes(0, 5, nil)}, + txRes: txFn([]bool{true, false}), + confirmTx: asset.NewRefundConfTx(coin.ID(), nil, secret), + wantErr: true, }} for _, test := range tests { node.walletTxFn = test.txRes node.bestBlockErr = test.bestBlockErr - wallet.mempoolRedeems = test.mempoolRedeems - if wallet.mempoolRedeems == nil { - wallet.mempoolRedeems = make(map[[32]byte]*mempoolRedeem) + wallet.mempoolTxs = test.mempoolTxs + if wallet.mempoolTxs == nil { + wallet.mempoolTxs = make(map[[32]byte]*mempoolTx) } node.txOutRes = test.txOutRes if node.txOutRes == nil { node.txOutRes = make(map[outPoint]*chainjson.GetTxOutResult) } - status, err := wallet.ConfirmRedemption(test.coinID, test.redemption, 0) + status, err := wallet.ConfirmTransaction(test.coinID, test.confirmTx, 0) if test.wantErr { if err == nil { t.Fatalf("%q: expected error", test.name) diff --git a/client/asset/eth/eth.go b/client/asset/eth/eth.go index 8eef685728..a1b609997e 100644 --- a/client/asset/eth/eth.go +++ b/client/asset/eth/eth.go @@ -1118,8 +1118,8 @@ func (w *assetWallet) withNonce(ctx context.Context, f transactionGenerator) (er w.log.Trace("Nonce chosen for tx generator =", n) // Make a first attempt with our best-known nonce. - tx, txType, amt, recipient, err := f(n) - if err != nil && strings.Contains(err.Error(), "nonce too low") { + tx, txType, amt, recipient, submitErr := f(n) + if submitErr != nil && strings.Contains(submitErr.Error(), "nonce too low") { w.log.Warnf("Too-low nonce detected. Attempting recovery") confirmedNonceAt, pendingNonceAt, err := w.node.nonce(ctx) if err != nil { @@ -1127,17 +1127,19 @@ func (w *assetWallet) withNonce(ctx context.Context, f transactionGenerator) (er } w.confirmedNonceAt = confirmedNonceAt w.pendingNonceAt = pendingNonceAt - if newNonce := nonce(); newNonce != n { - n = newNonce - // Try again. - tx, txType, amt, recipient, err = f(n) - if err != nil { - return err - } - w.log.Info("Nonce recovered and transaction broadcast") - } else { + newNonce := nonce() + if newNonce == n { return fmt.Errorf("best RPC nonce %d not better than our best nonce %d", newNonce, n) } + n = newNonce + // Try again. + tx, txType, amt, recipient, submitErr = f(n) + if submitErr == nil { + w.log.Info("Nonce recovered and transaction broadcast") + } + } + if submitErr != nil { + return submitErr } if tx != nil { @@ -1149,7 +1151,8 @@ func (w *assetWallet) withNonce(ctx context.Context, f transactionGenerator) (er w.emitTransactionNote(et.WalletTransaction, true) w.log.Tracef("Transaction %s generated for nonce %s", et.ID, n) } - return err + + return nil } // nonceIsSane performs sanity checks on pending txs. @@ -3733,34 +3736,34 @@ func (eth *ETHWallet) checkForNewBlocks(ctx context.Context) { } } -// 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 +// ConfirmTransaction checks the status of a redemption or refund. If a tx +// has been fee-replaced, the caller is notified of this by having a different +// coinID in the returned asset.ConfirmTxStatus 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) +func (w *ETHWallet) ConfirmTransaction(coinID dex.Bytes, confirmTx *asset.ConfirmTx, _ uint64) (*asset.ConfirmTxStatus, error) { + return w.confirmTransaction(coinID, confirmTx) } -// 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 +// ConfirmTransaction checks the status of a redemption or refund. If a tx +// has been fee-replaced, the caller is notified of this by having a different +// coinID in the returned asset.ConfirmTxStatus 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) +func (w *TokenWallet) ConfirmTransaction(coinID dex.Bytes, confirmTx *asset.ConfirmTx, _ uint64) (*asset.ConfirmTxStatus, error) { + return w.confirmTransaction(coinID, confirmTx) } -func confStatus(confs, req uint64, txHash common.Hash) *asset.ConfirmRedemptionStatus { - return &asset.ConfirmRedemptionStatus{ +func confStatus(confs, req uint64, txHash common.Hash) *asset.ConfirmTxStatus { + return &asset.ConfirmTxStatus{ Confs: confs, Req: req, CoinID: txHash[:], } } -// confirmRedemption checks the confirmation status of a redemption transaction. -func (w *assetWallet) confirmRedemption(coinID dex.Bytes, redemption *asset.Redemption) (*asset.ConfirmRedemptionStatus, error) { +// confirmTransaction checks the confirmation status of a transaction. +func (w *assetWallet) confirmTransaction(coinID dex.Bytes, confirmTx *asset.ConfirmTx) (*asset.ConfirmTxStatus, 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)) @@ -3768,7 +3771,15 @@ func (w *assetWallet) confirmRedemption(coinID dex.Bytes, redemption *asset.Rede var txHash common.Hash copy(txHash[:], coinID) - contractVer, secretHash, err := dexeth.DecodeContractData(redemption.Spends.Contract) + // If the status of the swap was refunded when we tried to refund, the + // zero hash is saved. We don't know the tx that altered the swap or + // how many confs it has. Assume confirmed. + zeroHash := common.Hash{} + if txHash == zeroHash { + return confStatus(w.finalizeConfs, w.finalizeConfs, txHash), nil + } + + contractVer, secretHash, err := dexeth.DecodeContractData(confirmTx.Contract()) if err != nil { return nil, fmt.Errorf("failed to decode contract data: %w", err) } @@ -3786,7 +3797,7 @@ func (w *assetWallet) confirmRedemption(coinID dex.Bytes, redemption *asset.Rede } } - var confirmStatus *asset.ConfirmRedemptionStatus + var confirmStatus *asset.ConfirmTxStatus if s.blockNum != 0 && s.blockNum <= tip { confirmStatus = confStatus(tip-s.blockNum+1, w.finalizeConfs, txHash) } else { @@ -3827,15 +3838,25 @@ func (w *assetWallet) confirmRedemption(coinID dex.Bytes, redemption *asset.Rede if err != nil { return nil, fmt.Errorf("error pulling swap data from contract: %v", err) } - 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 + if confirmTx.IsRedeem() { + 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 + } + } else { + switch swap.State { + case dexeth.SSRedeemed: + return nil, asset.ErrSwapRedeemed + case dexeth.SSRefunded: + w.log.Infof("Refund in tx %s was apparently refunded by another tx. OK.", txHash) + return confStatus(w.finalizeConfs, w.finalizeConfs, txHash), nil + } } - err = fmt.Errorf("tx %s failed to redeem %s funds", txHash, dex.BipIDSymbol(w.assetID)) + err = fmt.Errorf("tx %s failed to %s %s funds", txHash, confirmTx.TxType(), dex.BipIDSymbol(w.assetID)) return nil, errors.Join(err, asset.ErrTxRejected) } return confStatus(confs, w.finalizeConfs, txHash), nil diff --git a/client/asset/eth/eth_test.go b/client/asset/eth/eth_test.go index 36e7752fe6..847b1886d6 100644 --- a/client/asset/eth/eth_test.go +++ b/client/asset/eth/eth_test.go @@ -4566,12 +4566,12 @@ func testSend(t *testing.T, assetID uint32) { } } -func TestConfirmRedemption(t *testing.T) { - t.Run("eth", func(t *testing.T) { testConfirmRedemption(t, BipID) }) - t.Run("token", func(t *testing.T) { testConfirmRedemption(t, usdcTokenID) }) +func TestConfirmTransaction(t *testing.T) { + t.Run("eth", func(t *testing.T) { testConfirmTransaction(t, BipID) }) + t.Run("token", func(t *testing.T) { testConfirmTransaction(t, usdcTokenID) }) } -func testConfirmRedemption(t *testing.T, assetID uint32) { +func testConfirmTransaction(t *testing.T, assetID uint32) { wi, eth, node, shutdown := tassetWallet(assetID) defer shutdown() @@ -4586,12 +4586,9 @@ func testConfirmRedemption(t *testing.T, assetID uint32) { var txHash common.Hash copy(txHash[:], encode.RandomBytes(32)) - redemption := &asset.Redemption{ - Spends: &asset.AuditInfo{ - Contract: dexeth.EncodeContractData(0, secretHash), - }, - Secret: secret[:], - } + confirmTx := asset.NewRedeemConfTx(&asset.AuditInfo{ + Contract: dexeth.EncodeContractData(0, secretHash), + }, secret[:]) pendingTx := &extendedWalletTx{ WalletTransaction: &asset.WalletTransaction{ @@ -4619,6 +4616,7 @@ func testConfirmRedemption(t *testing.T, assetID uint32) { step dexeth.SwapStep receipt *types.Receipt receiptErr error + confirmTx *asset.ConfirmTx } tests := []*test{ @@ -4628,6 +4626,7 @@ func testConfirmRedemption(t *testing.T, assetID uint32) { Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(confBlock + 1), }, + confirmTx: confirmTx, expectedConfs: txConfsNeededToConfirm - 1, }, { @@ -4638,11 +4637,13 @@ func testConfirmRedemption(t *testing.T, assetID uint32) { Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(confBlock), }, + confirmTx: confirmTx, }, { name: "found in pending txs", step: dexeth.SSRedeemed, pendingTx: pendingTx, + confirmTx: confirmTx, expectedConfs: txConfsNeededToConfirm - 1, }, { @@ -4650,6 +4651,7 @@ func testConfirmRedemption(t *testing.T, assetID uint32) { step: dexeth.SSRedeemed, dbTx: dbTx, expectedConfs: txConfsNeededToConfirm, + confirmTx: confirmTx, receipt: &types.Receipt{ Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(confBlock), @@ -4660,6 +4662,7 @@ func testConfirmRedemption(t *testing.T, assetID uint32) { step: dexeth.SSRedeemed, dbErr: errors.New("test error"), expectedConfs: txConfsNeededToConfirm - 1, + confirmTx: confirmTx, receipt: &types.Receipt{ Status: types.ReceiptStatusSuccessful, BlockNumber: big.NewInt(confBlock + 1), @@ -4670,6 +4673,7 @@ func testConfirmRedemption(t *testing.T, assetID uint32) { step: dexeth.SSInitiated, expectErr: true, expectRedemptionFailedErr: true, + confirmTx: confirmTx, receipt: &types.Receipt{ Status: types.ReceiptStatusFailed, BlockNumber: big.NewInt(confBlock), @@ -4682,8 +4686,39 @@ func testConfirmRedemption(t *testing.T, assetID uint32) { Status: types.ReceiptStatusFailed, BlockNumber: big.NewInt(confBlock), }, + confirmTx: confirmTx, + expectedConfs: txConfsNeededToConfirm, + }, + { + name: "refund found on-chain. refunded by another unknown transaction", + step: dexeth.SSRefunded, + receipt: &types.Receipt{ + Status: types.ReceiptStatusFailed, + BlockNumber: big.NewInt(confBlock), + }, + confirmTx: asset.NewRefundConfTx(txHash[:], dexeth.EncodeContractData(0, secretHash), secret[:]), expectedConfs: txConfsNeededToConfirm, }, + { + name: "redeem refunded by another unknown transaction", + step: dexeth.SSRefunded, + receipt: &types.Receipt{ + Status: types.ReceiptStatusFailed, + BlockNumber: big.NewInt(confBlock), + }, + confirmTx: confirmTx, + expectErr: true, + }, + { + name: "refund redeemed by another unknown transaction", + step: dexeth.SSRedeemed, + receipt: &types.Receipt{ + Status: types.ReceiptStatusFailed, + BlockNumber: big.NewInt(confBlock), + }, + confirmTx: asset.NewRefundConfTx(txHash[:], dexeth.EncodeContractData(0, secretHash), secret[:]), + expectErr: true, + }, } runTest := func(test *test) { @@ -4709,7 +4744,7 @@ func testConfirmRedemption(t *testing.T, assetID uint32) { node.receipt = test.receipt node.receiptErr = test.receiptErr - result, err := wi.ConfirmRedemption(txHash[:], redemption, 0) + result, err := wi.ConfirmTransaction(txHash[:], test.confirmTx, 0) if test.expectErr { if err == nil { t.Fatalf("%s: expected error but did not get", test.name) diff --git a/client/asset/interface.go b/client/asset/interface.go index 68af08d7f4..bb248153a6 100644 --- a/client/asset/interface.go +++ b/client/asset/interface.go @@ -235,9 +235,12 @@ const ( ErrConnectionDown = dex.ErrorKind("wallet not connected") ErrNotImplemented = dex.ErrorKind("not implemented") ErrUnsupported = dex.ErrorKind("unsupported") - // ErrSwapRefunded is returned from ConfirmRedemption when the swap has + // ErrSwapRefunded is returned from ConfirmTransaction when the swap has // been refunded before the user could redeem. ErrSwapRefunded = dex.ErrorKind("swap refunded") + // ErrSwapRedeemed is returned from ConfirmTransaction when the swap has + // been redeemed before the user could refund. + ErrSwapRedeemed = dex.ErrorKind("swap redeemed") // ErrNotEnoughConfirms is returned when a transaction is confirmed, // but does not have enough confirmations to be trusted. ErrNotEnoughConfirms = dex.ErrorKind("transaction does not have enough confirmations") @@ -400,10 +403,10 @@ type WalletConfig struct { DataDir string } -// ConfirmRedemptionStatus contains the coinID which redeemed a swap, the +// ConfirmTxStatus contains the coinID which redeemed or refunded a swap, the // number of confirmations the transaction has, and the number of confirmations // required for it to be considered confirmed. -type ConfirmRedemptionStatus struct { +type ConfirmTxStatus struct { Confs uint64 Req uint64 CoinID dex.Bytes @@ -547,15 +550,15 @@ type Wallet interface { Send(address string, value, feeRate uint64) (Coin, error) // ValidateAddress checks that the provided address is valid. ValidateAddress(address string) bool - // ConfirmRedemption checks the status of a redemption. It returns the - // number of confirmations the redemption has, the number of confirmations + // ConfirmTransaction checks the status of a redemption or refund. It + // returns the number of confirmations the tx has, the number of confirmations // that are required for it to be considered fully confirmed, and the - // CoinID used to do the 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. - ConfirmRedemption(coinID dex.Bytes, redemption *Redemption, feeSuggestion uint64) (*ConfirmRedemptionStatus, error) + // CoinID it is currently watching for confirms. 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.ConfirmTxStatus as was used to + // call the function. + ConfirmTransaction(coinID dex.Bytes, confirmTx *ConfirmTx, feeSuggestion uint64) (*ConfirmTxStatus, error) // SingleLotSwapRefundFees returns the fees for a swap and refund transaction for a single lot. SingleLotSwapRefundFees(version uint32, feeRate uint64, useSafeTxSize bool) (uint64, uint64, error) // SingleLotRedeemFees returns the fees for a redeem transaction for a single lot. @@ -1367,6 +1370,107 @@ type Contract struct { LockTime uint64 } +// ConfirmTxType is the confirm tx type. +type ConfirmTxType int + +const ( + // CTRedeem is a redeem tx. + CTRedeem = iota + // CTRefund is a refund tx. + CTRefund +) + +// String satisfies Stringer. +func (ct ConfirmTxType) String() string { + switch ct { + case CTRedeem: + return "redeem" + case CTRefund: + return "refund" + } + return "unknown" +} + +// ConfirmTx is a redemption transaction that spends a counter-party's swap +// contract or a refund that spends our own swap. +type ConfirmTx struct { + // spends is the AuditInfo for the swap output being spent. Only needed for redeems. + spends *AuditInfo + // secret is the secret key needed to satisfy the swap contract. Only needed for redeems. + secret dex.Bytes + // spendsCoinID is the tx to refund. Only needed for refunds. + spendsCoinID dex.Bytes + // contract is the contract to refund. Only needed for refunds. + contract dex.Bytes + // secretHash is the secret hash of the swap to refund. Only needed for refunds. + secretHash dex.Bytes + // confirmTxType is redeem or refund. + txType ConfirmTxType +} + +// NewRedeemConfTx creates a new reddem conf tx. +func NewRedeemConfTx(spends *AuditInfo, secret dex.Bytes) *ConfirmTx { + return &ConfirmTx{ + spends: spends, + secret: secret, + txType: CTRedeem, + } +} + +// NewRefundConfTx creates a new refund conf tx. +func NewRefundConfTx(spendsCoinID, contract, secretHash dex.Bytes) *ConfirmTx { + return &ConfirmTx{ + spendsCoinID: spendsCoinID, + contract: contract, + secretHash: secretHash, + txType: CTRefund, + } +} + +// Spends returns the conf tx spends. Only use for redeems. +func (ct *ConfirmTx) Spends() *AuditInfo { + return ct.spends +} + +// Secret returns the conf tx secret. Only use for redeems. +func (ct *ConfirmTx) Secret() dex.Bytes { + return ct.secret +} + +// SpendsCoinID returns the coin id the confirm tx spends. +func (ct *ConfirmTx) SpendsCoinID() dex.Bytes { + if ct.txType == CTRedeem { + return ct.spends.Coin.ID() + } + return ct.spendsCoinID +} + +// SecretHash returns the swap's secret hash. +func (ct *ConfirmTx) SecretHash() dex.Bytes { + if ct.txType == CTRedeem { + return ct.spends.SecretHash + } + return ct.secretHash +} + +// Contract returns the swap's contract. +func (ct *ConfirmTx) Contract() dex.Bytes { + if ct.txType == CTRedeem { + return ct.spends.Contract + } + return ct.contract +} + +// TxType returns the conf tx type. +func (ct *ConfirmTx) TxType() ConfirmTxType { + return ct.txType +} + +// IsRedeem return true if it is a redeem. +func (ct *ConfirmTx) IsRedeem() bool { + return ct.txType == CTRedeem +} + // Redemption is a redemption transaction that spends a counter-party's swap // contract. type Redemption struct { diff --git a/client/asset/zec/zec.go b/client/asset/zec/zec.go index bd68bb57d1..38fa46f867 100644 --- a/client/asset/zec/zec.go +++ b/client/asset/zec/zec.go @@ -70,10 +70,10 @@ const ( blockTicker = time.Second peerCountTicker = 5 * time.Second - // requiredRedeemConfirms is the amount of confirms a redeem transaction - // needs before the trade is considered confirmed. The redeem is - // monitored until this number of confirms is reached. - requiredRedeemConfirms = 1 + // requiredConfTxConfirms is the amount of confirms a redeem or refund + // transaction needs before the trade is considered confirmed. The + // redeem is monitored until this number of confirms is reached. + requiredConfTxConfirms = 1 depositAddrPrefix = "unified:" ) @@ -1567,70 +1567,86 @@ func (w *zecWallet) AuditContract(coinID, contract, txData dex.Bytes, rebroadcas }, nil } -func (w *zecWallet) ConfirmRedemption(coinID dex.Bytes, redemption *asset.Redemption, feeSuggestion uint64) (*asset.ConfirmRedemptionStatus, error) { +func (w *zecWallet) ConfirmTransaction(coinID dex.Bytes, confirmTx *asset.ConfirmTx, feeSuggestion uint64) (*asset.ConfirmTxStatus, error) { txHash, _, err := decodeCoinID(coinID) if err != nil { return nil, err } tx, err := getWalletTransaction(w, txHash) - // redemption transaction found, return its confirms. + // transaction found, return its confirms. // - // TODO: Investigate the case where this redeem has been sitting in the + // TODO: Investigate the case where this tx has been sitting in the // mempool for a long amount of time, possibly requiring some action by // us to get it unstuck. if err == nil { if tx.Confirmations < 0 { tx.Confirmations = 0 } - return &asset.ConfirmRedemptionStatus{ + return &asset.ConfirmTxStatus{ Confs: uint64(tx.Confirmations), - Req: requiredRedeemConfirms, + Req: requiredConfTxConfirms, CoinID: coinID, }, nil } - // Redemption transaction is missing from the point of view of our node! + // Transaction is missing from the point of view of our node! // Unlikely, but possible it was redeemed by another transaction. Check // if the contract is still an unspent output. - swapHash, vout, err := decodeCoinID(redemption.Spends.Coin.ID()) + swapHash, vout, err := decodeCoinID(confirmTx.SpendsCoinID()) if err != nil { return nil, err } utxo, _, err := getTxOut(w, swapHash, vout) if err != nil { - return nil, newError(errNoTx, "error finding unspent contract %s with swap hash %v vout %d: %w", redemption.Spends.Coin.ID(), swapHash, vout, err) + return nil, newError(errNoTx, "error finding unspent contract %s with swap hash %v vout %d: %w", confirmTx.SpendsCoinID(), swapHash, vout, err) } if utxo == nil { // TODO: Spent, but by who. Find the spending tx. - w.log.Warnf("Contract coin %v with swap hash %v vout %d spent by someone but not sure who.", redemption.Spends.Coin.ID(), swapHash, vout) + w.log.Warnf("Contract coin %v with swap hash %v vout %d spent by someone but not sure who.", confirmTx.SpendsCoinID(), swapHash, vout) // Incorrect, but we will be in a loop of erroring if we don't // return something. - return &asset.ConfirmRedemptionStatus{ - Confs: requiredRedeemConfirms, - Req: requiredRedeemConfirms, + return &asset.ConfirmTxStatus{ + Confs: requiredConfTxConfirms, + Req: requiredConfTxConfirms, CoinID: coinID, }, nil } - // The contract has not yet been redeemed, but it seems the redeeming - // tx has disappeared. Assume the fee was too low at the time and it - // was eventually purged from the mempool. Attempt to redeem again with - // a currently reasonable fee. - - form := &asset.RedeemForm{ - Redemptions: []*asset.Redemption{redemption}, - } - _, coin, _, err := w.Redeem(form) - if err != nil { - return nil, fmt.Errorf("unable to re-redeem %s with swap hash %v vout %d: %w", redemption.Spends.Coin.ID(), swapHash, vout, err) + // The contract has not yet been redeemed or refunded, but it seems the + // spending tx has disappeared. Assume the fee was too low at the time + // and it was eventually purged from the mempool. Attempt to spend again + // with a currently reasonable fee. + var newCoinID dex.Bytes + if confirmTx.IsRedeem() { + form := &asset.RedeemForm{ + Redemptions: []*asset.Redemption{ + { + Spends: confirmTx.Spends(), + Secret: confirmTx.Secret(), + }, + }, + FeeSuggestion: feeSuggestion, + } + _, coin, _, err := w.Redeem(form) + if err != nil { + return nil, fmt.Errorf("unable to re-redeem %s with swap hash %v vout %d: %w", confirmTx.SpendsCoinID(), swapHash, vout, err) + } + newCoinID = coin.ID() + } else { + spendsCoinID := confirmTx.SpendsCoinID() + newCoinID, err = w.Refund(spendsCoinID, confirmTx.Contract(), feeSuggestion) + if err != nil { + return nil, fmt.Errorf("unable to re-refund %s: %w", spendsCoinID, err) + } } - return &asset.ConfirmRedemptionStatus{ + + return &asset.ConfirmTxStatus{ Confs: 0, - Req: requiredRedeemConfirms, - CoinID: coin.ID(), + Req: requiredConfTxConfirms, + CoinID: newCoinID, }, nil } diff --git a/client/asset/zec/zec_test.go b/client/asset/zec/zec_test.go index 7795d32073..2ddf91b892 100644 --- a/client/asset/zec/zec_test.go +++ b/client/asset/zec/zec_test.go @@ -1411,19 +1411,16 @@ func TestConfirmRedemption(t *testing.T) { Expiration: lockTime, } - redemption := &asset.Redemption{ - Spends: ci, - Secret: secret, - } + confirmTx := asset.NewRedeemConfTx(ci, secret) walletTx := &btc.GetTransactionResult{ Confirmations: 1, } cl.queueResponse("gettransaction", walletTx) - st, err := w.ConfirmRedemption(coinID, redemption, 0) + st, err := w.ConfirmTransaction(coinID, confirmTx, 0) if err != nil { - t.Fatalf("Initial ConfirmRedemption error: %v", err) + t.Fatalf("Initial ConfirmTransaction error: %v", err) } if st.Confs != walletTx.Confirmations { t.Fatalf("wrongs confs, %d != %d", st.Confs, walletTx.Confirmations) @@ -1431,19 +1428,19 @@ func TestConfirmRedemption(t *testing.T) { cl.queueResponse("gettransaction", tErr) cl.queueResponse("gettxout", tErr) - _, err = w.ConfirmRedemption(coinID, redemption, 0) + _, err = w.ConfirmTransaction(coinID, confirmTx, 0) if !errorHasCode(err, errNoTx) { t.Fatalf("wrong error for gettxout error: %v", err) } cl.queueResponse("gettransaction", tErr) cl.queueResponse("gettxout", nil) - st, err = w.ConfirmRedemption(coinID, redemption, 0) + st, err = w.ConfirmTransaction(coinID, confirmTx, 0) if err != nil { - t.Fatalf("ConfirmRedemption error for spent redemption: %v", err) + t.Fatalf("ConfirmTransaction error for spent redemption: %v", err) } - if st.Confs != requiredRedeemConfirms { - t.Fatalf("wrong confs for spent redemption: %d != %d", st.Confs, requiredRedeemConfirms) + if st.Confs != requiredConfTxConfirms { + t.Fatalf("wrong confs for spent redemption: %d != %d", st.Confs, requiredConfTxConfirms) } // Re-submission path is tested by TestRedemption diff --git a/client/core/core.go b/client/core/core.go index f3572b99d2..ffdd9429c1 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -10674,9 +10674,9 @@ func (c *Core) deleteRequestedAction(uniqueID string) { 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 { +// handleRetryTxAction handles a response to a user response to an +// ActionRequiredNote for a rejected redemption or refund transaction. +func (c *Core) handleRetryTxAction(actionB []byte, isRedeem bool) error { var req struct { OrderID dex.Bytes `json:"orderID"` CoinID dex.Bytes `json:"coinID"` @@ -10707,28 +10707,45 @@ func (c *Core) handleRetryRedemptionAction(actionB []byte) error { defer tracker.mtx.Unlock() for _, match := range tracker.matches { - coinID := match.MetaData.Proof.TakerRedeem - if match.Side == order.Maker { - coinID = match.MetaData.Proof.MakerRedeem + var coinID order.CoinID + if isRedeem { + coinID = match.MetaData.Proof.TakerRedeem + if match.Side == order.Maker { + coinID = match.MetaData.Proof.MakerRedeem + } + } else { + coinID = match.MetaData.Proof.RefundCoin } 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) + // Make sure this is not called after confirmed. + if match.Status == order.MatchConfirmed { + return nil + } + if isRedeem { + 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) } - } else if match.Side == order.Maker && match.Status == order.MakerRedeemed { - match.redemptionRejected = false - match.MetaData.Proof.MakerRedeem = nil - match.Status = order.TakerSwapCast + } else { + match.MetaData.Proof.RefundCoin = nil + match.refundRejected = false 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) } } } @@ -10740,7 +10757,9 @@ func (c *Core) handleRetryRedemptionAction(actionB []byte) error { func (c *Core) handleCoreAction(actionID string, actionB json.RawMessage) ( /* handled */ bool, error) { switch actionID { case ActionIDRedeemRejected: - return true, c.handleRetryRedemptionAction(actionB) + return true, c.handleRetryTxAction(actionB, true) + case ActionIDRefundRejected: + return true, c.handleRetryTxAction(actionB, false) } return false, nil } diff --git a/client/core/core_test.go b/client/core/core_test.go index d470d38b54..dde74c1713 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -716,9 +716,9 @@ type TXCWallet struct { findBond *asset.BondDetails findBondErr error - confirmRedemptionResult *asset.ConfirmRedemptionStatus - confirmRedemptionErr error - confirmRedemptionCalled bool + confirmTxResult *asset.ConfirmTxStatus + confirmTxErr error + confirmTxCalled bool estFee uint64 estFeeErr error @@ -795,9 +795,9 @@ func (w *TXCWallet) Balance() (*asset.Balance, error) { return w.bal, nil } -func (w *TXCWallet) ConfirmRedemption(coinID dex.Bytes, redemption *asset.Redemption, feeSuggestion uint64) (*asset.ConfirmRedemptionStatus, error) { - w.confirmRedemptionCalled = true - return w.confirmRedemptionResult, w.confirmRedemptionErr +func (w *TXCWallet) ConfirmTransaction(coinID dex.Bytes, confirmTx *asset.ConfirmTx, feeSuggestion uint64) (*asset.ConfirmTxStatus, error) { + w.confirmTxCalled = true + return w.confirmTxResult, w.confirmTxErr } func (w *TXCWallet) FundOrder(ord *asset.Order) (asset.Coins, []dex.Bytes, uint64, error) { @@ -4537,8 +4537,8 @@ func TestTradeTracking(t *testing.T) { btcWallet.address = "12DXGkvxFjuq5btXYkwWfBZaz1rVwFgini" btcWallet.Unlock(rig.crypter) - tBtcWallet.confirmRedemptionErr = errors.New("") - tDcrWallet.confirmRedemptionErr = errors.New("") + tBtcWallet.confirmTxErr = errors.New("") + tDcrWallet.confirmTxErr = errors.New("") matchSize := 4 * dcrBtcLotSize cancelledQty := dcrBtcLotSize @@ -5348,6 +5348,7 @@ func TestRefunds(t *testing.T) { tCore.wallets[tACCTAsset.ID] = ethWallet ethWallet.address = "18d65fb8d60c1199bb1ad381be47aa692b482605" ethWallet.Unlock(rig.crypter) + tEthWallet.confirmTxResult = new(asset.ConfirmTxStatus) checkStatus := func(tag string, match *matchTracker, wantStatus order.MatchStatus) { t.Helper() @@ -8740,7 +8741,7 @@ func TestMatchStatusResolution(t *testing.T) { } } -func TestConfirmRedemption(t *testing.T) { +func TestConfirmTx(t *testing.T) { rig := newTestRig() defer rig.shutdown() dc := rig.dc @@ -8769,7 +8770,7 @@ func TestConfirmRedemption(t *testing.T) { tBtcWallet.redeemCoins = []dex.Bytes{tUpdatedCoinID} ourContract := encode.RandomBytes(90) - setupMatch := func(status order.MatchStatus, side order.MatchSide) { + setupMatch := func(status order.MatchStatus, side order.MatchSide, isRefunded bool) { matchID := ordertest.RandomMatchID() _, auditInfo := tMsgAudit(oid, matchID, addr, 0, secretHash[:]) matchTime := time.Now() @@ -8827,9 +8828,17 @@ func TestConfirmRedemption(t *testing.T) { proof.Secret = secret } } + if status == order.MatchComplete { + proof.TakerRedeem = tCoinID + } if status >= order.MatchComplete { proof.TakerRedeem = tCoinID } + if isRefunded { + proof.RefundCoin = tCoinID + } else { + proof.RefundCoin = nil + } } type note struct { @@ -8838,19 +8847,21 @@ func TestConfirmRedemption(t *testing.T) { } tests := []struct { - name string - matchStatus order.MatchStatus - matchSide order.MatchSide - expectedNotifications []*note - confirmRedemptionResult *asset.ConfirmRedemptionStatus - confirmRedemptionErr error - - expectConfirmRedemptionCalled bool - expectedStatus order.MatchStatus - expectTicksDelayed bool + name string + matchStatus order.MatchStatus + matchSide order.MatchSide + expectedNotifications []*note + confirmTxResult, refundConfirmTxResult *asset.ConfirmTxStatus + confirmTxErr, refundConfirmTxErr error + + expectConfirmTxCalled, expectRefundConfirmTxCalled bool + expectedStatus order.MatchStatus + expectTicksDelayed bool + + isRefund bool }{ { - name: "maker, makerRedeemed, confirmedRedemption", + name: "maker, makerRedeemed, confirmedTx", matchStatus: order.MakerRedeemed, matchSide: order.Maker, expectedNotifications: []*note{ @@ -8859,13 +8870,13 @@ func TestConfirmRedemption(t *testing.T) { topic: TopicRedemptionConfirmed, }, }, - confirmRedemptionResult: &asset.ConfirmRedemptionStatus{ + confirmTxResult: &asset.ConfirmTxStatus{ Confs: 10, Req: 10, CoinID: tCoinID, }, - expectConfirmRedemptionCalled: true, - expectedStatus: order.MatchConfirmed, + expectConfirmTxCalled: true, + expectedStatus: order.MatchConfirmed, }, { name: "maker, makerRedeemed, confirmedRedemption, more confs than required", @@ -8877,13 +8888,13 @@ func TestConfirmRedemption(t *testing.T) { topic: TopicRedemptionConfirmed, }, }, - confirmRedemptionResult: &asset.ConfirmRedemptionStatus{ + confirmTxResult: &asset.ConfirmTxStatus{ Confs: 15, Req: 10, CoinID: tCoinID, }, - expectConfirmRedemptionCalled: true, - expectedStatus: order.MatchConfirmed, + expectConfirmTxCalled: true, + expectedStatus: order.MatchConfirmed, }, { name: "taker, matchComplete, confirmedRedemption", @@ -8895,13 +8906,13 @@ func TestConfirmRedemption(t *testing.T) { topic: TopicRedemptionConfirmed, }, }, - confirmRedemptionResult: &asset.ConfirmRedemptionStatus{ + confirmTxResult: &asset.ConfirmTxStatus{ Confs: 10, Req: 10, CoinID: tCoinID, }, - expectConfirmRedemptionCalled: true, - expectedStatus: order.MatchConfirmed, + expectConfirmTxCalled: true, + expectedStatus: order.MatchConfirmed, }, { name: "maker, makerRedeemed, incomplete", @@ -8913,13 +8924,13 @@ func TestConfirmRedemption(t *testing.T) { topic: TopicConfirms, }, }, - confirmRedemptionResult: &asset.ConfirmRedemptionStatus{ + confirmTxResult: &asset.ConfirmTxStatus{ Confs: 5, Req: 10, CoinID: tCoinID, }, - expectConfirmRedemptionCalled: true, - expectedStatus: order.MakerRedeemed, + expectConfirmTxCalled: true, + expectedStatus: order.MakerRedeemed, }, { name: "maker, makerRedeemed, replacedTx", @@ -8935,13 +8946,13 @@ func TestConfirmRedemption(t *testing.T) { topic: TopicConfirms, }, }, - confirmRedemptionResult: &asset.ConfirmRedemptionStatus{ + confirmTxResult: &asset.ConfirmTxStatus{ Confs: 0, Req: 10, CoinID: tUpdatedCoinID, }, - expectConfirmRedemptionCalled: true, - expectedStatus: order.MakerRedeemed, + expectConfirmTxCalled: true, + expectedStatus: order.MakerRedeemed, }, { name: "taker, matchComplete, replacedTx", @@ -8957,13 +8968,13 @@ func TestConfirmRedemption(t *testing.T) { topic: TopicConfirms, }, }, - confirmRedemptionResult: &asset.ConfirmRedemptionStatus{ + confirmTxResult: &asset.ConfirmTxStatus{ Confs: 0, Req: 10, CoinID: tUpdatedCoinID, }, - expectConfirmRedemptionCalled: true, - expectedStatus: order.MatchComplete, + expectConfirmTxCalled: true, + expectedStatus: order.MatchComplete, }, { // This case could happen if the dex was shut down right after @@ -8981,90 +8992,260 @@ func TestConfirmRedemption(t *testing.T) { topic: TopicRedemptionConfirmed, }, }, - confirmRedemptionResult: &asset.ConfirmRedemptionStatus{ + confirmTxResult: &asset.ConfirmTxStatus{ Confs: 10, Req: 10, CoinID: tUpdatedCoinID, }, - expectConfirmRedemptionCalled: true, - expectedStatus: order.MatchConfirmed, + expectConfirmTxCalled: true, + expectedStatus: order.MatchConfirmed, }, { - name: "maker, makerRedeemed, error", - matchStatus: order.MakerRedeemed, - matchSide: order.Maker, - confirmRedemptionErr: errors.New("err"), - expectedStatus: order.MakerRedeemed, - expectTicksDelayed: true, - expectConfirmRedemptionCalled: true, + name: "maker, makerRedeemed, error", + matchStatus: order.MakerRedeemed, + matchSide: order.Maker, + confirmTxErr: errors.New("err"), + expectedStatus: order.MakerRedeemed, + expectTicksDelayed: true, + expectConfirmTxCalled: true, }, { - name: "maker, makerRedeemed, swap refunded error", - matchStatus: order.MakerRedeemed, - matchSide: order.Maker, - confirmRedemptionErr: asset.ErrSwapRefunded, - expectedStatus: order.MatchConfirmed, + name: "maker, makerRedeemed, swap refunded error", + matchStatus: order.MakerRedeemed, + matchSide: order.Maker, + confirmTxErr: asset.ErrSwapRefunded, + expectedStatus: order.MatchConfirmed, expectedNotifications: []*note{ { severity: db.ErrorLevel, topic: TopicSwapRefunded, }, }, - expectConfirmRedemptionCalled: true, + expectConfirmTxCalled: true, }, { - name: "taker, takerRedeemed, redemption tx rejected error", - matchStatus: order.MatchComplete, - matchSide: order.Taker, - confirmRedemptionErr: asset.ErrTxRejected, - expectedStatus: order.MatchComplete, + name: "taker, takerRedeemed, redemption tx rejected error", + matchStatus: order.MatchComplete, + matchSide: order.Taker, + confirmTxErr: asset.ErrTxRejected, + expectedStatus: order.MatchComplete, expectedNotifications: []*note{ { severity: db.Data, topic: TopicRedeemRejected, }, }, - expectConfirmRedemptionCalled: true, + expectConfirmTxCalled: true, + }, + { + name: "maker, makerRedeemed, redemption tx lost", + matchStatus: order.MakerRedeemed, + matchSide: order.Maker, + confirmTxErr: asset.ErrTxLost, + expectedStatus: order.TakerSwapCast, + expectConfirmTxCalled: true, + }, + { + name: "taker, takerRedeemed, redemption tx lost", + matchStatus: order.MatchComplete, + matchSide: order.Taker, + confirmTxErr: asset.ErrTxLost, + expectedStatus: order.MakerRedeemed, + expectConfirmTxCalled: true, + }, + { + name: "maker, matchConfirmed", + matchStatus: order.MatchConfirmed, + matchSide: order.Maker, + expectedStatus: order.MatchConfirmed, + expectedNotifications: []*note{}, + expectConfirmTxCalled: false, + }, + { + name: "maker, TakerSwapCast", + matchStatus: order.TakerSwapCast, + matchSide: order.Maker, + expectedStatus: order.TakerSwapCast, + expectedNotifications: []*note{}, + expectConfirmTxCalled: false, + }, + { + name: "taker, TakerSwapCast", + matchStatus: order.TakerSwapCast, + matchSide: order.Taker, + expectedStatus: order.TakerSwapCast, + expectedNotifications: []*note{}, + expectConfirmTxCalled: false, + }, + { + name: "maker, taker swap cast, confirmedTx, refund", + matchStatus: order.MakerSwapCast, + matchSide: order.Maker, + expectedNotifications: []*note{ + { + severity: db.Success, + topic: TopicRefundConfirmed, + }, + }, + refundConfirmTxResult: &asset.ConfirmTxStatus{ + Confs: 10, + Req: 10, + CoinID: tCoinID, + }, + expectRefundConfirmTxCalled: true, + expectedStatus: order.MatchConfirmed, + isRefund: true, + }, + { + name: "taker, takerSwapCast, confirmedTx, refund", + matchStatus: order.TakerSwapCast, + matchSide: order.Taker, + expectedNotifications: []*note{ + { + severity: db.Success, + topic: TopicRefundConfirmed, + }, + }, + refundConfirmTxResult: &asset.ConfirmTxStatus{ + Confs: 10, + Req: 10, + CoinID: tCoinID, + }, + expectRefundConfirmTxCalled: true, + expectedStatus: order.MatchConfirmed, + isRefund: true, + }, + { + name: "maker, makerSwapCast, incomplete, refund", + matchStatus: order.MakerSwapCast, + matchSide: order.Maker, + expectedNotifications: []*note{ + { + severity: db.Data, + topic: TopicConfirms, + }, + }, + refundConfirmTxResult: &asset.ConfirmTxStatus{ + Confs: 5, + Req: 10, + CoinID: tCoinID, + }, + expectRefundConfirmTxCalled: true, + expectedStatus: order.MakerSwapCast, + isRefund: true, + }, + { + name: "taker, takerSwapCast, replacedTx, refund", + matchStatus: order.TakerSwapCast, + matchSide: order.Taker, + expectedNotifications: []*note{ + { + severity: db.WarningLevel, + topic: TopicRefundResubmitted, + }, + { + severity: db.Data, + topic: TopicConfirms, + }, + }, + refundConfirmTxResult: &asset.ConfirmTxStatus{ + Confs: 0, + Req: 10, + CoinID: tUpdatedCoinID, + }, + expectRefundConfirmTxCalled: true, + expectedStatus: order.TakerSwapCast, + isRefund: true, + }, + { + name: "maker, makerSwapCast, replacedTx confirmed, refund", + matchStatus: order.MakerSwapCast, + matchSide: order.Maker, + expectedNotifications: []*note{ + { + severity: db.WarningLevel, + topic: TopicRefundResubmitted, + }, + { + severity: db.Success, + topic: TopicRefundConfirmed, + }, + }, + refundConfirmTxResult: &asset.ConfirmTxStatus{ + Confs: 15, + Req: 10, + CoinID: tUpdatedCoinID, + }, + expectRefundConfirmTxCalled: true, + expectedStatus: order.MatchConfirmed, + isRefund: true, }, { - name: "maker, makerRedeemed, redemption tx lost", - matchStatus: order.MakerRedeemed, - matchSide: order.Maker, - confirmRedemptionErr: asset.ErrTxLost, - expectedStatus: order.TakerSwapCast, - expectConfirmRedemptionCalled: true, + name: "maker, makerSwapCast, error", + matchStatus: order.MakerSwapCast, + matchSide: order.Maker, + refundConfirmTxErr: errors.New("err"), + expectedStatus: order.MakerSwapCast, + expectTicksDelayed: true, + expectRefundConfirmTxCalled: true, + isRefund: true, }, { - name: "taker, takerRedeemed, redemption tx lost", - matchStatus: order.MatchComplete, - matchSide: order.Taker, - confirmRedemptionErr: asset.ErrTxLost, - expectedStatus: order.MakerRedeemed, - expectConfirmRedemptionCalled: true, + name: "maker, makerSwapCast, swap redeemed error", + matchStatus: order.MakerSwapCast, + matchSide: order.Maker, + refundConfirmTxErr: asset.ErrSwapRedeemed, + expectedStatus: order.MatchConfirmed, + expectedNotifications: []*note{ + { + severity: db.ErrorLevel, + topic: TopicSwapRedeemed, + }, + }, + expectRefundConfirmTxCalled: true, + isRefund: true, }, { - name: "maker, matchConfirmed", - matchStatus: order.MatchConfirmed, - matchSide: order.Maker, - expectedStatus: order.MatchConfirmed, - expectedNotifications: []*note{}, - expectConfirmRedemptionCalled: false, + name: "taker, takerSwapCast, refund tx rejected error", + matchStatus: order.TakerSwapCast, + matchSide: order.Taker, + refundConfirmTxErr: asset.ErrTxRejected, + expectedStatus: order.TakerSwapCast, + expectedNotifications: []*note{ + { + severity: db.Data, + topic: TopicRefundRejected, + }, + }, + expectRefundConfirmTxCalled: true, + isRefund: true, + }, + { + name: "maker, makerSwapCast, refund tx lost", + matchStatus: order.MakerSwapCast, + matchSide: order.Maker, + refundConfirmTxErr: asset.ErrTxLost, + expectedStatus: order.MakerSwapCast, + expectRefundConfirmTxCalled: true, + isRefund: true, }, { - name: "maker, TakerSwapCast", - matchStatus: order.TakerSwapCast, - matchSide: order.Maker, - expectedStatus: order.TakerSwapCast, - expectedNotifications: []*note{}, - expectConfirmRedemptionCalled: false, + name: "taker, takerSwapCast, refund tx lost", + matchStatus: order.TakerSwapCast, + matchSide: order.Taker, + refundConfirmTxErr: asset.ErrTxLost, + expectedStatus: order.TakerSwapCast, + expectRefundConfirmTxCalled: true, + isRefund: true, }, { - name: "taker, TakerSwapCast", - matchStatus: order.TakerSwapCast, - matchSide: order.Taker, - expectedStatus: order.TakerSwapCast, - expectedNotifications: []*note{}, - expectConfirmRedemptionCalled: false, + name: "maker, matchConfirmed, refund", + matchStatus: order.MatchConfirmed, + matchSide: order.Maker, + expectedStatus: order.MatchConfirmed, + expectedNotifications: []*note{}, + expectConfirmTxCalled: false, + isRefund: true, }, } @@ -9072,18 +9253,27 @@ func TestConfirmRedemption(t *testing.T) { for _, test := range tests { tracker.mtx.Lock() - setupMatch(test.matchStatus, test.matchSide) + setupMatch(test.matchStatus, test.matchSide, test.isRefund) tracker.mtx.Unlock() - tBtcWallet.confirmRedemptionResult = test.confirmRedemptionResult - tBtcWallet.confirmRedemptionErr = test.confirmRedemptionErr - tBtcWallet.confirmRedemptionCalled = false + tBtcWallet.confirmTxResult = test.confirmTxResult + tBtcWallet.confirmTxErr = test.confirmTxErr + tBtcWallet.confirmTxCalled = false + + tDcrWallet.confirmTxResult = test.refundConfirmTxResult + tDcrWallet.confirmTxErr = test.refundConfirmTxErr + tDcrWallet.confirmTxCalled = false tCore.tickAsset(dc, tUTXOAssetB.ID) - if tBtcWallet.confirmRedemptionCalled != test.expectConfirmRedemptionCalled { - t.Fatalf("%s: expected confirm redemption to be called=%v but got=%v", - test.name, test.expectConfirmRedemptionCalled, tBtcWallet.confirmRedemptionCalled) + if tBtcWallet.confirmTxCalled != test.expectConfirmTxCalled { + t.Fatalf("%s: expected confirm tx for redemption to be called=%v but got=%v", + test.name, test.expectConfirmTxCalled, tBtcWallet.confirmTxCalled) + } + + if tDcrWallet.confirmTxCalled != test.expectRefundConfirmTxCalled { + t.Fatalf("%s: expected confirm tx for refund to be called=%v but got=%v", + test.name, test.expectRefundConfirmTxCalled, tDcrWallet.confirmTxCalled) } for _, expectedNotification := range test.expectedNotifications { @@ -9107,23 +9297,30 @@ func TestConfirmRedemption(t *testing.T) { } tracker.mtx.RLock() - if test.confirmRedemptionResult != nil { + if test.confirmTxResult != nil { var redeemCoin order.CoinID if test.matchSide == order.Maker { redeemCoin = match.MetaData.Proof.MakerRedeem } else { redeemCoin = match.MetaData.Proof.TakerRedeem } - if !bytes.Equal(redeemCoin, test.confirmRedemptionResult.CoinID) { - t.Fatalf("%s: expected coin %v != actual %v", test.name, test.confirmRedemptionResult.CoinID, redeemCoin) + if !bytes.Equal(redeemCoin, test.confirmTxResult.CoinID) { + t.Fatalf("%s: expected coin %v != actual %v", test.name, test.confirmTxResult.CoinID, redeemCoin) } - if test.confirmRedemptionResult.Confs >= test.confirmRedemptionResult.Req { + if test.confirmTxResult.Confs >= test.confirmTxResult.Req { if len(tDcrWallet.returnedContracts) != 1 || !bytes.Equal(ourContract, tDcrWallet.returnedContracts[0]) { t.Fatalf("%s: refund address not returned", test.name) } } } + if test.refundConfirmTxResult != nil { + refundCoinID := match.MetaData.Proof.RefundCoin + if !bytes.Equal(refundCoinID, test.refundConfirmTxResult.CoinID) { + t.Fatalf("%s: expected coin %v != actual %v", test.name, test.refundConfirmTxResult.CoinID, refundCoinID) + } + } + ticksDelayed := match.tickGovernor != nil if ticksDelayed != test.expectTicksDelayed { t.Fatalf("%s: expected ticks delayed %v but got %v", test.name, test.expectTicksDelayed, ticksDelayed) diff --git a/client/core/locale_ntfn.go b/client/core/locale_ntfn.go index df6e4d695d..7b21eda44a 100644 --- a/client/core/locale_ntfn.go +++ b/client/core/locale_ntfn.go @@ -298,6 +298,10 @@ var originLocale = map[Topic]*translation{ subject: intl.Translation{T: "Redemption Resubmitted"}, template: intl.Translation{T: "Your redemption for match %s in order %s was resubmitted."}, }, + TopicRefundResubmitted: { + subject: intl.Translation{T: "Refund Resubmitted"}, + template: intl.Translation{T: "Your refund for match %s in order %s was resubmitted."}, + }, TopicSwapRefunded: { subject: intl.Translation{T: "Swap Refunded"}, template: intl.Translation{T: "Match %s in order %s was refunded by the counterparty."}, @@ -306,6 +310,10 @@ var originLocale = map[Topic]*translation{ subject: intl.Translation{T: "Redemption Confirmed"}, template: intl.Translation{T: "Your redemption for match %s in order %s was confirmed"}, }, + TopicRefundConfirmed: { + subject: intl.Translation{T: "Refund Confirmed"}, + template: intl.Translation{T: "Your refund for match %s in order %s was confirmed"}, + }, TopicWalletTypeDeprecated: { subject: intl.Translation{T: "Wallet Disabled"}, template: intl.Translation{T: "Your %s wallet type is no longer supported. Create a new wallet."}, diff --git a/client/core/notification.go b/client/core/notification.go index f4d0f844fe..d9c6285010 100644 --- a/client/core/notification.go +++ b/client/core/notification.go @@ -435,8 +435,11 @@ const ( TopicCounterConfirms Topic = "CounterConfirms" TopicConfirms Topic = "Confirms" TopicRedemptionResubmitted Topic = "RedemptionResubmitted" + TopicRefundResubmitted Topic = "RefundResubmitted" TopicSwapRefunded Topic = "SwapRefunded" + TopicSwapRedeemed Topic = "SwapRedeemed" TopicRedemptionConfirmed Topic = "RedemptionConfirmed" + TopicRefundConfirmed Topic = "RefundConfirmed" ) func newMatchNote(topic Topic, subject, details string, severity db.Severity, t *trackedTrade, match *matchTracker) *MatchNote { @@ -451,7 +454,8 @@ func newMatchNote(topic Topic, subject, details string, severity db.Severity, t OrderID: t.ID().Bytes(), Match: matchFromMetaMatchWithConfs(t.Order, &match.MetaMatch, swapConfs, int64(t.metaData.FromSwapConf), counterConfs, int64(t.metaData.ToSwapConf), - int64(match.redemptionConfs), int64(match.redemptionConfsReq)), + int64(match.redemptionConfs), int64(match.redemptionConfsReq), + int64(match.refundConfs), int64(match.refundConfsReq)), Host: t.dc.acct.host, MarketID: marketName(t.Base(), t.Quote()), } @@ -761,6 +765,8 @@ func newUnknownBondTierZeroNote(subject, details string) *db.Notification { const ( ActionIDRedeemRejected = "redeemRejected" TopicRedeemRejected = "RedeemRejected" + ActionIDRefundRejected = "refundRejected" + TopicRefundRejected = "RefundRejected" ) func newActionRequiredNote(actionID, uniqueID string, payload any) *asset.ActionRequiredNote { @@ -774,28 +780,36 @@ func newActionRequiredNote(actionID, uniqueID string, payload any) *asset.Action return n } -type RejectedRedemptionData struct { +type RejectedTxData struct { OrderID dex.Bytes `json:"orderID"` CoinID dex.Bytes `json:"coinID"` AssetID uint32 `json:"assetID"` CoinFmt string `json:"coinFmt"` + TxType string `json:"txType"` } // 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{ +func newRejectedTxNote(assetID uint32, oid order.OrderID, coinID []byte, txType asset.ConfirmTxType) (*asset.ActionRequiredNote, *ActionRequiredNote) { + data := &RejectedTxData{ AssetID: assetID, OrderID: oid[:], CoinID: coinID, CoinFmt: coinIDString(assetID, coinID), + TxType: txType.String(), } uniqueID := dex.Bytes(coinID).String() - actionNote := newActionRequiredNote(ActionIDRedeemRejected, uniqueID, data) + actionID := ActionIDRedeemRejected + topic := db.Topic(TopicRedeemRejected) + if txType == asset.CTRefund { + actionID = ActionIDRefundRejected + topic = TopicRefundRejected + } + actionNote := newActionRequiredNote(actionID, uniqueID, data) coreNote := &ActionRequiredNote{ - Notification: db.NewNotification(NoteTypeActionRequired, TopicRedeemRejected, "", "", db.Data), + Notification: db.NewNotification(NoteTypeActionRequired, topic, "", "", db.Data), Payload: actionNote, } return actionNote, coreNote diff --git a/client/core/trade.go b/client/core/trade.go index e69489b8d0..dbe04eaa47 100644 --- a/client/core/trade.go +++ b/client/core/trade.go @@ -107,6 +107,18 @@ type matchTracker struct { // if we redeem as taker anyway. matchCompleteSent bool + // confirmRefundNumTries is just used for logging. + confirmRefundNumTries int + // refundConfs and refundConfsReq are updated while the refund + // confirmation process is running. Their values are not updated after the + // match reaches MatchConfirmed status. + refundConfs uint64 + refundConfsReq uint64 + // refundRejected will be true if a refund tx was rejected. A + // a rejected tx may indicate a serious internal issue, so we will seek + // user approval before replacing the tx. + refundRejected bool + // The fields below need to be modified without the parent trackedTrade's // mutex being write locked, so they have dedicated mutexes. @@ -256,8 +268,9 @@ type trackedTrade struct { // request a fee suggestion at redeem time because it would require making // the full redemption routine async (TODO?). This fee suggestion is // intentionally not stored as part of the db.OrderMetaData, and should be - // repopulated if the client is restarted. - redeemFeeSuggestion feeStamped + // repopulated if the client is restarted. refundFeeSuggestion is the + // same but for Refunds. + redeemFeeSuggestion, refundFeeSuggestion feeStamped selfGoverned uint32 // (atomic) server either lacks this market or is down @@ -360,59 +373,67 @@ func (t *trackedTrade) status() order.OrderStatus { return t.metaData.Status } -// cacheRedemptionFeeSuggestion sets the redeemFeeSuggestion for the -// trackedTrade. If a request to the server for the fee suggestion must be made, -// the request will be run in a goroutine, i.e. the field is not necessarily set -// when this method returns. If there is a synced book, the estimate will always +// cacheRedemptionFeeSuggestion sets the redeemFeeSuggestion and refundFeeSuggestion +// for the trackedTrade. If a request to the server for the fee suggestion must +// be made, the request will be run in a goroutine, i.e. the field is not necessarily +// set when this method returns. If there is a synced book, the estimate will always // be updated. If there is no synced book, but a non-zero fee suggestion is // already cached, no new requests will be made. // // The trackedTrade mutex should be held for reads for safe access to the // walletSet and the readyToTick flag. -func (t *trackedTrade) cacheRedemptionFeeSuggestion() { +func (t *trackedTrade) cacheFeeSuggestions() { now := time.Now() - t.redeemFeeSuggestion.Lock() - defer t.redeemFeeSuggestion.Unlock() + cache := func(fs *feeStamped, w *xcWallet) { + fs.Lock() + defer fs.Unlock() - if now.Sub(t.redeemFeeSuggestion.stamp) < freshRedeemFeeAge { - return - } - - set := func(rate uint64) { - t.redeemFeeSuggestion.rate = rate - t.redeemFeeSuggestion.stamp = now - } - - // Use the wallet's rate first. Note that this could make a costly request - // to an external fee oracle if an internal estimate is not available and - // the wallet settings permit external API requests. - toWallet := t.wallets.toWallet - if t.readyToTick && toWallet.connected() { - if feeRate := toWallet.feeRate(); feeRate != 0 { - set(feeRate) + if now.Sub(fs.stamp) < freshRedeemFeeAge { return } - } - // Check any book that might have the fee recorded from an epoch_report note - // (requires a book subscription). - redeemAsset := toWallet.AssetID - feeSuggestion := t.dc.bestBookFeeSuggestion(redeemAsset) - if feeSuggestion > 0 { - set(feeSuggestion) - return - } + set := func(rate uint64) { + fs.rate = rate + fs.stamp = now + } - // Fetch it from the server. Last resort! - go func() { - feeSuggestion = t.dc.fetchFeeRate(redeemAsset) + // Use the wallet's rate first. Note that this could make a costly request + // to an external fee oracle if an internal estimate is not available and + // the wallet settings permit external API requests. + toWallet := t.wallets.toWallet + if t.readyToTick && toWallet.connected() { + if feeRate := toWallet.feeRate(); feeRate != 0 { + set(feeRate) + return + } + } + + // Check any book that might have the fee recorded from an epoch_report note + // (requires a book subscription). + asset := w.AssetID + feeSuggestion := t.dc.bestBookFeeSuggestion(asset) if feeSuggestion > 0 { - t.redeemFeeSuggestion.Lock() set(feeSuggestion) - t.redeemFeeSuggestion.Unlock() + return } - }() + + // Fetch it from the server. Last resort! + go func() { + feeSuggestion = t.dc.fetchFeeRate(asset) + if feeSuggestion > 0 { + fs.Lock() + set(feeSuggestion) + fs.Unlock() + } + }() + } + + // Cache redeem fee. + cache(&t.redeemFeeSuggestion, t.wallets.toWallet) + + // Cache refund fee. + cache(&t.refundFeeSuggestion, t.wallets.fromWallet) } // accountRedeemer is equivalent to calling @@ -673,7 +694,8 @@ func (t *trackedTrade) coreOrderInternal() *Order { corder.Matches = append(corder.Matches, matchFromMetaMatchWithConfs(t, &mt.MetaMatch, swapConfs, int64(t.metaData.FromSwapConf), counterConfs, int64(t.metaData.ToSwapConf), - int64(mt.redemptionConfs), int64(mt.redemptionConfsReq))) + int64(mt.redemptionConfs), int64(mt.redemptionConfsReq), + int64(mt.refundConfs), int64(mt.refundConfsReq))) } corder.AllFeesConfirmed = allFeesConfirmed @@ -1843,6 +1865,23 @@ func shouldConfirmRedemption(match *matchTracker) bool { return len(proof.TakerRedeem) > 0 } +// shouldConfirmRefund will return true if a refund transaction has been +// broadcast, but it has not yet been confirmed. +// +// This method accesses match fields and MUST be called with the trackedTrade +// mutex lock held for reads. +func shouldConfirmRefund(match *matchTracker) bool { + if match.Status == order.MatchConfirmed { + return false + } + + if match.refundRejected { + return false + } + + return len(match.MetaData.Proof.RefundCoin) > 0 +} + // tick will check for and perform any match actions necessary. func (c *Core) tick(t *trackedTrade) (assetMap, error) { assets := make(assetMap) // callers expect non-nil map even on error :( @@ -1866,7 +1905,7 @@ func (c *Core) tick(t *trackedTrade) (assetMap, error) { tLock = time.Since(tStart) var swaps, redeems, refunds, revokes, searches, redemptionConfirms, - dynamicSwapFeeConfirms, dynamicRedemptionFeeConfirms []*matchTracker + refundConfirms, dynamicSwapFeeConfirms, dynamicRedemptionFeeConfirms []*matchTracker var sent, quoteSent, received, quoteReceived uint64 checkMatch := func(match *matchTracker) error { // only errors on context.DeadlineExceeded or context.Canceled @@ -1955,6 +1994,11 @@ func (c *Core) tick(t *trackedTrade) (assetMap, error) { return nil } + if shouldConfirmRefund(match) { + refundConfirms = append(refundConfirms, match) + return nil + } + // For certain "self-governed" trades where the market or server has // vanished, we should revoke the match to allow it to retire without // having sent any pending redeem requests. Note that self-governed is @@ -1977,8 +2021,8 @@ func (c *Core) tick(t *trackedTrade) (assetMap, error) { // Begin checks under read-only lock. t.mtx.RLock() - // Make sure we have a redemption fee suggestion cached. - t.cacheRedemptionFeeSuggestion() + // Make sure we have a redemption and refund fee suggestion cached. + t.cacheFeeSuggestions() if !t.readyToTick { t.mtx.RUnlock() @@ -2028,7 +2072,8 @@ func (c *Core) tick(t *trackedTrade) (assetMap, error) { if !rmCancel && len(swaps) == 0 && len(refunds) == 0 && len(redeems) == 0 && len(revokes) == 0 && len(searches) == 0 && len(redemptionConfirms) == 0 && - len(dynamicSwapFeeConfirms) == 0 && len(dynamicRedemptionFeeConfirms) == 0 { + len(refundConfirms) == 0 && len(dynamicSwapFeeConfirms) == 0 && + len(dynamicRedemptionFeeConfirms) == 0 { return assets, nil // nothing to do, don't acquire the write-lock } @@ -2140,6 +2185,14 @@ func (c *Core) tick(t *trackedTrade) (assetMap, error) { c.confirmRedemptions(t, redemptionConfirms) } + if len(refundConfirms) > 0 { + for _, match := range refundConfirms { + if _, err := c.confirmRefund(t, match); err != nil { + t.dc.log.Errorf("Unable to confirm refund: %v", err) + } + } + } + for _, match := range dynamicSwapFeeConfirms { t.updateDynamicSwapOrRedemptionFeesPaid(c.ctx, match, true) } @@ -2974,6 +3027,23 @@ func (t *trackedTrade) redeemFee() uint64 { return feeSuggestion } +func (t *trackedTrade) refundFee() uint64 { + // Try not to use (*Core).feeSuggestion here, since it can incur an RPC + // request to the server. t.refundFeeSuggestion is updated every tick and + // uses a rate directly from our wallet, if available. Only go looking for + // one if we don't have one cached. + var feeSuggestion uint64 + if _, is := t.accountRefunder(); is { + feeSuggestion = t.metaData.MaxFeeRate + } else { + feeSuggestion = t.refundFeeSuggestion.get() + } + if feeSuggestion == 0 { + feeSuggestion = t.dc.bestBookFeeSuggestion(t.wallets.fromWallet.AssetID) + } + return feeSuggestion +} + // confirmRedemption attempts to confirm the redemptions for each match, and // then return any refund addresses that we won't be using. func (c *Core) confirmRedemptions(t *trackedTrade, matches []*matchTracker) { @@ -2999,92 +3069,169 @@ func (c *Core) confirmRedemptions(t *trackedTrade, matches []*matchTracker) { // This method accesses match fields and MUST be called with the trackedTrade // mutex lock held for writes. 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 c.confirmTx(t, match, &txInfo{ + confs: match.redemptionConfs, + confsReq: match.redemptionConfsReq, + numTries: &match.confirmRedemptionNumTries, + wallet: t.wallets.toWallet, + coinID: match.MetaData.Proof.MakerRedeem, + txType: asset.CTRedeem, + fee: t.redeemFee, + isRejected: &match.redemptionRejected, + needsRedeemSig: true, + confTx: func() (*asset.ConfirmTxStatus, error) { + proof := &match.MetaData.Proof + var redeemCoinID order.CoinID + if match.Side == order.Maker { + redeemCoinID = proof.MakerRedeem + } else { + redeemCoinID = proof.TakerRedeem + } + return t.wallets.toWallet.Wallet.ConfirmTransaction(dex.Bytes(redeemCoinID), + asset.NewRedeemConfTx(match.counterSwap, proof.Secret), t.redeemFee()) + }, + handleResubmit: func(newCoinID []byte) { + proof := &match.MetaData.Proof + if match.Side == order.Maker { + proof.MakerRedeem = order.CoinID(newCoinID) + } else { + proof.TakerRedeem = order.CoinID(newCoinID) + } + }, + handleLostTx: func() { + if match.Side == order.Taker { + match.MetaData.Proof.TakerRedeem = nil + match.Status = order.MakerRedeemed + } else { + match.MetaData.Proof.MakerRedeem = nil + match.Status = order.TakerSwapCast + } + }, + resubmitTopic: TopicRedemptionResubmitted, + confirmedTopic: TopicRedemptionConfirmed, + counterTxSuccess: TopicSwapRefunded, + counterTxError: "swap was already refunded by the counterparty", + }) +} + +// confirmRefund checks if the user's refund has been confirmed, +// and if so, updates the match's status to MatchConfirmed. +// +// This method accesses match fields and MUST be called with the trackedTrade +// mutex lock held for writes. +func (c *Core) confirmRefund(t *trackedTrade, match *matchTracker) (bool, error) { + return c.confirmTx(t, match, &txInfo{ + confs: match.refundConfs, + confsReq: match.refundConfsReq, + numTries: &match.confirmRefundNumTries, + wallet: t.wallets.fromWallet, + coinID: match.MetaData.Proof.RefundCoin, + txType: asset.CTRefund, + fee: t.refundFee, + isRejected: &match.refundRejected, + needsRedeemSig: false, + confTx: func() (*asset.ConfirmTxStatus, error) { + proof := &match.MetaData.Proof + var swapCoinID dex.Bytes + if match.Side == order.Maker { + swapCoinID = dex.Bytes(match.MetaData.Proof.MakerSwap) + } else { + swapCoinID = dex.Bytes(match.MetaData.Proof.TakerSwap) + } + return t.wallets.fromWallet.Wallet.ConfirmTransaction(dex.Bytes(proof.RefundCoin), + asset.NewRefundConfTx(swapCoinID, proof.ContractData, proof.SecretHash), t.refundFee()) + }, + handleResubmit: func(newCoinID []byte) { + match.MetaData.Proof.RefundCoin = order.CoinID(newCoinID) + }, + handleLostTx: func() { + match.MetaData.Proof.RefundCoin = nil + }, + resubmitTopic: TopicRefundResubmitted, + confirmedTopic: TopicRefundConfirmed, + counterTxSuccess: TopicSwapRedeemed, + counterTxError: "swap was already redeemed by the counterparty", + }) +} + +type txInfo struct { + confs uint64 + confsReq uint64 + numTries *int + wallet *xcWallet + coinID order.CoinID + txType asset.ConfirmTxType + fee func() uint64 + isRejected *bool + needsRedeemSig bool + confTx func() (*asset.ConfirmTxStatus, error) + handleResubmit func([]byte) + handleLostTx func() + resubmitTopic Topic + confirmedTopic Topic + counterTxSuccess Topic + counterTxError string +} + +func (c *Core) confirmTx(t *trackedTrade, match *matchTracker, info *txInfo) (bool, error) { + if info.confs > 0 && info.confs >= info.confsReq { // already there, stop checking + if info.needsRedeemSig && len(match.MetaData.Proof.Auth.RedeemSig) == 0 && (!t.isSelfGoverned() && !match.MetaData.Proof.IsRevoked()) { return false, nil // waiting on redeem request to succeed } - // Redeem request just succeeded or we gave up on the server. if match.Status == order.MatchConfirmed { - return true, nil // raced with concurrent sendRedeemAsync + return true, nil } match.Status = order.MatchConfirmed err := t.db.UpdateMatch(&match.MetaMatch) if err != nil { t.dc.log.Errorf("failed to update match in db: %v", err) } - subject, details := t.formatDetails(TopicRedemptionConfirmed, match.token(), makeOrderToken(t.token())) - note := newMatchNote(TopicRedemptionConfirmed, subject, details, db.Success, t, match) + subject, details := t.formatDetails(info.confirmedTopic, match.token(), makeOrderToken(t.token())) + note := newMatchNote(info.confirmedTopic, subject, details, db.Success, t, match) t.notify(note) return true, nil } - // In some cases the wallet will need to send a new redeem transaction. - toWallet := t.wallets.toWallet - - if err := toWallet.checkPeersAndSyncStatus(); err != nil { + if err := info.wallet.checkPeersAndSyncStatus(); err != nil { return false, err } - didUnlock, err := toWallet.refreshUnlock() + didUnlock, err := info.wallet.refreshUnlock() if err != nil { // Just log it and try anyway. - t.dc.log.Errorf("refreshUnlock error checking redeem %s: %v", toWallet.Symbol, err) + t.dc.log.Errorf("refreshUnlock error checking %s: %v", info.wallet.Symbol, err) } if didUnlock { - t.dc.log.Warnf("Unexpected unlock needed for the %s wallet to check a redemption", toWallet.Symbol) + t.dc.log.Warnf("Unexpected unlock needed for the %s wallet to check a transaction", info.wallet.Symbol) } - proof := &match.MetaData.Proof - var redeemCoinID order.CoinID - if match.Side == order.Maker { - redeemCoinID = proof.MakerRedeem - } else { - redeemCoinID = proof.TakerRedeem - } - - match.confirmRedemptionNumTries++ + *info.numTries++ - redemptionStatus, err := toWallet.Wallet.ConfirmRedemption(dex.Bytes(redeemCoinID), &asset.Redemption{ - Spends: match.counterSwap, - Secret: proof.Secret, - }, t.redeemFee()) + status, err := info.confTx() 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) + case errors.Is(err, asset.ErrSwapRefunded), errors.Is(err, asset.ErrSwapRedeemed): + subject, details := t.formatDetails(info.counterTxSuccess, match.token(), makeOrderToken(t.token())) + note := newMatchNote(info.counterTxSuccess, subject, details, db.ErrorLevel, t, match) t.notify(note) match.Status = order.MatchConfirmed err := t.db.UpdateMatch(&match.MetaMatch) if err != nil { t.dc.log.Errorf("Failed to update match in db %v", err) } - return false, errors.New("swap was already refunded by the counterparty") + return false, errors.New(info.counterTxError) 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) + *info.isRejected = true + actionRequest, note := newRejectedTxNote(info.wallet.AssetID, t.ID(), info.coinID, info.txType) t.notify(note) c.requestedActionMtx.Lock() - c.requestedActions[dex.Bytes(redeemCoinID).String()] = actionRequest + c.requestedActions[dex.Bytes(info.coinID).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)) + unbip(info.wallet.AssetID), coinIDString(info.wallet.AssetID, info.coinID)) 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)) + info.handleLostTx() + c.log.Infof("Transaction %s (%s) has been noted as lost.", info.coinID, unbip(info.wallet.AssetID)) if err := t.db.UpdateMatch(&match.MetaMatch); err != nil { t.dc.log.Errorf("failed to update match after lost tx reported: %v", err) @@ -3092,50 +3239,52 @@ func (c *Core) confirmRedemption(t *trackedTrade, match *matchTracker) (bool, er 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) + return false, fmt.Errorf("error confirming transaction for coin %v. already tried %d times, will retry later: %v", + info.coinID, *info.numTries, err) } - var redemptionResubmitted, redemptionConfirmed bool - if !bytes.Equal(redeemCoinID, redemptionStatus.CoinID) { - redemptionResubmitted = true - if match.Side == order.Maker { - proof.MakerRedeem = order.CoinID(redemptionStatus.CoinID) - } else { - proof.TakerRedeem = order.CoinID(redemptionStatus.CoinID) - } + var resubmitted, confirmed bool + if !bytes.Equal(info.coinID, status.CoinID) { + resubmitted = true + info.handleResubmit(status.CoinID) } - match.redemptionConfs, match.redemptionConfsReq = redemptionStatus.Confs, redemptionStatus.Req - - if redemptionStatus.Confs >= redemptionStatus.Req && - (len(match.MetaData.Proof.Auth.RedeemSig) > 0 || t.isSelfGoverned()) { - redemptionConfirmed = true - match.Status = order.MatchConfirmed + if info.txType == asset.CTRedeem { + match.redemptionConfs, match.redemptionConfsReq = status.Confs, status.Req + if status.Confs >= status.Req && (len(match.MetaData.Proof.Auth.RedeemSig) > 0 || t.isSelfGoverned()) { + confirmed = true + match.Status = order.MatchConfirmed + } + } else { + match.refundConfs, match.refundConfsReq = status.Confs, status.Req + if status.Confs >= status.Req { + confirmed = true + match.Status = order.MatchConfirmed + } } - if redemptionResubmitted || redemptionConfirmed { + if resubmitted || confirmed { err := t.db.UpdateMatch(&match.MetaMatch) if err != nil { t.dc.log.Errorf("failed to update match in db: %v", err) } } - if redemptionResubmitted { - subject, details := t.formatDetails(TopicRedemptionResubmitted, match.token(), makeOrderToken(t.token())) - note := newMatchNote(TopicRedemptionResubmitted, subject, details, db.WarningLevel, t, match) + if resubmitted { + subject, details := t.formatDetails(info.resubmitTopic, match.token(), makeOrderToken(t.token())) + note := newMatchNote(info.resubmitTopic, subject, details, db.WarningLevel, t, match) t.notify(note) } - if redemptionConfirmed { - subject, details := t.formatDetails(TopicRedemptionConfirmed, match.token(), makeOrderToken(t.token())) - note := newMatchNote(TopicRedemptionConfirmed, subject, details, db.Success, t, match) + if confirmed { + subject, details := t.formatDetails(info.confirmedTopic, match.token(), makeOrderToken(t.token())) + note := newMatchNote(info.confirmedTopic, subject, details, db.Success, t, match) t.notify(note) } else { note := newMatchNote(TopicConfirms, "", "", db.Data, t, match) t.notify(note) } - return redemptionConfirmed, nil + return confirmed, nil } // findMakersRedemption starts a goroutine to search for the redemption of diff --git a/client/core/types.go b/client/core/types.go index f05a9f806e..3f9c7fdd8d 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -302,12 +302,12 @@ func (c *Coin) SetConfirmations(confs, confReq int64) { // This function is intended for use with inactive matches. For active matches, // use matchFromMetaMatchWithConfs. func matchFromMetaMatch(ord order.Order, metaMatch *db.MetaMatch) *Match { - return matchFromMetaMatchWithConfs(ord, metaMatch, 0, 0, 0, 0, 0, 0) + return matchFromMetaMatchWithConfs(ord, metaMatch, 0, 0, 0, 0, 0, 0, 0, 0) } // matchFromMetaMatchWithConfs constructs a *Match from a *MetaMatch, // and sets the confirmations for swaps-in-waiting. -func matchFromMetaMatchWithConfs(ord order.Order, metaMatch *db.MetaMatch, swapConfs, swapReq, counterSwapConfs, counterReq, redeemConfs, redeemReq int64) *Match { +func matchFromMetaMatchWithConfs(ord order.Order, metaMatch *db.MetaMatch, swapConfs, swapReq, counterSwapConfs, counterReq, redeemConfs, redeemReq, refundConfs, refundReq int64) *Match { if _, isCancel := ord.(*order.CancelOrder); isCancel { fmt.Println("matchFromMetaMatchWithConfs got a cancel order for match", metaMatch) return &Match{} @@ -333,6 +333,7 @@ func matchFromMetaMatchWithConfs(ord order.Order, metaMatch *db.MetaMatch, swapC var refund, redeem, counterRedeem, swap, counterSwap *Coin if len(proof.RefundCoin) > 0 { refund = NewCoin(fromID, proof.RefundCoin) + refund.SetConfirmations(refundConfs, refundReq) } if len(swapCoin) > 0 { swap = NewCoin(fromID, swapCoin) diff --git a/client/db/bolt/upgrades.go b/client/db/bolt/upgrades.go index 06cae630c1..c4cd5424d1 100644 --- a/client/db/bolt/upgrades.go +++ b/client/db/bolt/upgrades.go @@ -42,6 +42,9 @@ var upgrades = [...]upgradefunc{ v5Upgrade, // v5 => v6 splits matches into separate active and archived buckets. v6Upgrade, + // v6 => v7 sets the status of all refunded matches to order.MatchConfirmed + // status. + v7Upgrade, } // DBVersion is the latest version of the database that is understood. Databases @@ -366,6 +369,68 @@ func v6Upgrade(dbtx *bbolt.Tx) error { }) } +// v7Upgrade sets the status to all archived refund matches to order.MatchConfirmed. +// From now on all refunds must also have confirmations for the trade to be +// considered inactive. +func v7Upgrade(dbtx *bbolt.Tx) error { + const oldVersion = 6 + + if err := ensureVersion(dbtx, oldVersion); err != nil { + return err + } + + amb := []byte("matches") // archived matches + mKey := []byte("match") // matchKey + pKey := []byte("proof") // proofKey + + var nRefunded int + + defer func() { + upgradeLog.Infof("%d inactive refunded matches set to confirmed status", nRefunded) + }() + + archivedMatchesBkt := dbtx.Bucket(amb) + + return archivedMatchesBkt.ForEach(func(k, _ []byte) error { + archivedMBkt := archivedMatchesBkt.Bucket(k) + if archivedMBkt == nil { + return fmt.Errorf("match %x bucket is not a bucket", k) + } + proofB := archivedMBkt.Get(pKey) + if len(proofB) == 0 { + return fmt.Errorf("empty proof") + } + proof, _, err := dexdb.DecodeMatchProof(proofB) + if err != nil { + return fmt.Errorf("error decoding proof: %w", err) + } + + // Check if refund. + if len(proof.RefundCoin) == 0 { + return nil + } + nRefunded++ + + matchB := archivedMBkt.Get(mKey) + if matchB == nil { + return fmt.Errorf("nil match bytes for %x", k) + } + match, _, err := order.DecodeMatch(matchB) + if err != nil { + return fmt.Errorf("error decoding match %x: %w", k, err) + } + + match.Status = order.MatchConfirmed + + updatedMatchB := order.EncodeMatch(match) + if err != nil { + return fmt.Errorf("error encoding match %x: %w", k, err) + } + + return archivedMBkt.Put(matchKey, updatedMatchB) + }) +} + func ensureVersion(tx *bbolt.Tx, ver uint32) error { dbVersion, err := getVersionTx(tx) if err != nil { diff --git a/client/db/types.go b/client/db/types.go index d20bd4fae5..8495a03e0c 100644 --- a/client/db/types.go +++ b/client/db/types.go @@ -513,11 +513,6 @@ func MatchIsActive(match *order.UserMatch, proof *MatchProof) bool { return false } - // Refunded matches are inactive regardless of status. - if len(proof.RefundCoin) > 0 { - return false - } - // Revoked matches may need to be refunded or auto-redeemed first. if proof.IsRevoked() { // - NewlyMatched requires no further action from either side diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index cae2faf0c1..64dee6956b 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -2607,10 +2607,11 @@ func makeRequiredAction(assetID uint32, actionID string) *asset.ActionRequiredNo txID := dex.Bytes(encode.RandomBytes(32)).String() var payload any if actionID == core.ActionIDRedeemRejected { - payload = core.RejectedRedemptionData{ + payload = core.RejectedTxData{ AssetID: assetID, CoinID: encode.RandomBytes(32), CoinFmt: "0x8909ec4aa707df569e62e2f8e2040094e2c88fe192b3b3e2dadfa383a41aa645", + TxType: "redeem", } } else { payload = ð.TransactionActionNote{ diff --git a/client/webserver/site/src/html/bodybuilder.tmpl b/client/webserver/site/src/html/bodybuilder.tmpl index fb0fca5e3d..1c4eb12853 100644 --- a/client/webserver/site/src/html/bodybuilder.tmpl +++ b/client/webserver/site/src/html/bodybuilder.tmpl @@ -226,12 +226,12 @@
-
+
- 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. + A trade + was rejected by the network. Network transaction fees were incurred. + You can try to again, but it will + likely incur more fees and it may be rejected again.
diff --git a/client/webserver/site/src/html/order.tmpl b/client/webserver/site/src/html/order.tmpl index b393ca12de..ae6ae5c9b8 100644 --- a/client/webserver/site/src/html/order.tmpl +++ b/client/webserver/site/src/html/order.tmpl @@ -188,10 +188,15 @@
-
- [[[Refund]]] (, [[[you]]])
- - +
+
+
+ [[[Refund]]] (, [[[you]]]) + +
+ + +
diff --git a/client/webserver/site/src/js/app.ts b/client/webserver/site/src/js/app.ts index aa035b90f0..ef8b7c4059 100644 --- a/client/webserver/site/src/js/app.ts +++ b/client/webserver/site/src/js/app.ts @@ -55,7 +55,7 @@ import { ActionResolvedNote, TransactionActionNote, CoreActionRequiredNote, - RejectedRedemptionData, + RejectedTxData, MarketMakingStatus, RunStatsNote, MMBotStatus, @@ -645,7 +645,8 @@ export default class Application { case 'lostNonce': return this.lostNonceAction(req) case 'redeemRejected': - return this.redeemRejectedAction(req) + case 'refundRejected': + return this.txRejectedAction(req) } throw Error('unknown required action ID ' + req.actionID) } @@ -754,14 +755,15 @@ export default class Application { return div } - redeemRejectedAction (req: ActionRequiredNote) { - const { orderID, coinID, coinFmt, assetID } = req.payload as RejectedRedemptionData - const div = this.page.rejectedRedemptionTmpl.cloneNode(true) as PageElement + txRejectedAction (req: ActionRequiredNote) { + const { orderID, coinID, coinFmt, assetID, txType } = req.payload as RejectedTxData + const div = this.page.rejectedTxTmpl.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 + tmpl.txType.textContent = txType setCoinHref(token ? token.parentID : assetID, tmpl.txid) Doc.bind(tmpl.doNothingBttn, 'click', () => { this.submitAction(req, { orderID, coinID, retry: false }, tmpl.errMsg) diff --git a/client/webserver/site/src/js/order.ts b/client/webserver/site/src/js/order.ts index 47a56b71e0..c6948cb0e4 100644 --- a/client/webserver/site/src/js/order.ts +++ b/client/webserver/site/src/js/order.ts @@ -307,23 +307,27 @@ export default class OrderPage extends BasePage { if (m.status === OrderUtil.MakerSwapCast && !m.revoked && !m.refund) { const c = makerSwapCoin(m) tmpl.makerSwapMsg.textContent = confirmationString(c) - Doc.hide(tmpl.takerSwapMsg, tmpl.makerRedeemMsg, tmpl.takerRedeemMsg) + Doc.hide(tmpl.takerSwapMsg, tmpl.makerRedeemMsg, tmpl.takerRedeemMsg, tmpl.refundMsg) Doc.show(tmpl.makerSwapMsg) } else if (m.status === OrderUtil.TakerSwapCast && !m.revoked && !m.refund) { const c = takerSwapCoin(m) tmpl.takerSwapMsg.textContent = confirmationString(c) - Doc.hide(tmpl.makerSwapMsg, tmpl.makerRedeemMsg, tmpl.takerRedeemMsg) + Doc.hide(tmpl.makerSwapMsg, tmpl.makerRedeemMsg, tmpl.takerRedeemMsg, tmpl.refundMsg) Doc.show(tmpl.takerSwapMsg) } else if (inConfirmingMakerRedeem(m) && !m.revoked && !m.refund) { tmpl.makerRedeemMsg.textContent = confirmationString(m.redeem) - Doc.hide(tmpl.makerSwapMsg, tmpl.takerSwapMsg, tmpl.takerRedeemMsg) + Doc.hide(tmpl.makerSwapMsg, tmpl.takerSwapMsg, tmpl.takerRedeemMsg, tmpl.refundMsg) Doc.show(tmpl.makerRedeemMsg) } else if (inConfirmingTakerRedeem(m) && !m.revoked && !m.refund) { tmpl.takerRedeemMsg.textContent = confirmationString(m.redeem) - Doc.hide(tmpl.makerSwapMsg, tmpl.takerSwapMsg, tmpl.makerRedeemMsg) + Doc.hide(tmpl.makerSwapMsg, tmpl.takerSwapMsg, tmpl.makerRedeemMsg, tmpl.refundMsg) Doc.show(tmpl.takerRedeemMsg) - } else { + } else if (inConfirmingRefund(m)) { + tmpl.refundMsg.textContent = confirmationString(m.refund) Doc.hide(tmpl.makerSwapMsg, tmpl.takerSwapMsg, tmpl.makerRedeemMsg, tmpl.takerRedeemMsg) + Doc.show(tmpl.refundMsg) + } else { + Doc.hide(tmpl.makerSwapMsg, tmpl.takerSwapMsg, tmpl.makerRedeemMsg, tmpl.takerRedeemMsg, tmpl.refundMsg) } if (!m.revoked) { @@ -558,3 +562,10 @@ function inConfirmingMakerRedeem (m: Match) { function inConfirmingTakerRedeem (m: Match) { return m.status < OrderUtil.MatchConfirmed && m.side === OrderUtil.Taker && m.status >= OrderUtil.MatchComplete } + +/* +* inConfirmingRefund will be true if we are waiting on confirmations for our refund. +*/ +function inConfirmingRefund (m: Match) { + return m.status < OrderUtil.MatchConfirmed && m.refund && m.refund.confs.count <= m.refund.confs.required +} diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index d4832abee4..440d308ae4 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -462,11 +462,12 @@ export interface CoreActionRequiredNote extends CoreNote { payload: ActionRequiredNote } -export interface RejectedRedemptionData { +export interface RejectedTxData { assetID: number orderID: string coinID: string coinFmt: string + txType: string } export interface SpotPriceNote extends CoreNote { diff --git a/dex/order/match.go b/dex/order/match.go index ebae50e0b3..1d144b9b9c 100644 --- a/dex/order/match.go +++ b/dex/order/match.go @@ -93,7 +93,7 @@ const ( // sent the details to the maker. MatchComplete // 4 // MatchConfirmed is a status used only by the client that represents - // that the user's redemption transaction has been confirmed. + // that the user's redemption or refund transaction has been confirmed. MatchConfirmed // 5 )