diff --git a/common/src/main/java/bisq/common/app/Version.java b/common/src/main/java/bisq/common/app/Version.java index c9808d858eb..eabfa39930d 100644 --- a/common/src/main/java/bisq/common/app/Version.java +++ b/common/src/main/java/bisq/common/app/Version.java @@ -17,9 +17,13 @@ package bisq.common.app; +import bisq.common.util.Utilities; + import java.net.URL; import java.util.Arrays; +import java.util.Date; +import java.util.GregorianCalendar; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -98,16 +102,21 @@ private static int getSubVersion(String version, int index) { // VERSION = 0.5.0 -> LOCAL_DB_VERSION = 1 public static final int LOCAL_DB_VERSION = 1; - // The version no. of the current protocol. The offer holds that version. - // A taker will check the version of the offers to see if his version is compatible. - // For the switch to version 2, offers created with the old version will become invalid and have to be canceled. - // For the switch to version 3, offers created with the old version can be migrated to version 3 just by opening - // the Bisq app. // VERSION = 0.5.0 -> TRADE_PROTOCOL_VERSION = 1 // Version 1.2.2 -> TRADE_PROTOCOL_VERSION = 2 // Version 1.5.0 -> TRADE_PROTOCOL_VERSION = 3 // Version 1.7.0 -> TRADE_PROTOCOL_VERSION = 4 - public static final int TRADE_PROTOCOL_VERSION = 4; + // Version 1.9.13 and after activation date -> TRADE_PROTOCOL_VERSION = 5 + public static final Date PROTOCOL_5_ACTIVATION_DATE = Utilities.getUTCDate(2023, GregorianCalendar.AUGUST, 1); + + public static boolean isTradeProtocolVersion5Activated() { + return new Date().after(PROTOCOL_5_ACTIVATION_DATE); + } + + public static int getTradeProtocolVersion() { + return isTradeProtocolVersion5Activated() ? 5 : 4; + } + private static int p2pMessageVersion; public static final String BSQ_TX_VERSION = "1"; @@ -136,7 +145,7 @@ public static void printVersion() { "VERSION=" + VERSION + ", P2P_NETWORK_VERSION=" + P2P_NETWORK_VERSION + ", LOCAL_DB_VERSION=" + LOCAL_DB_VERSION + - ", TRADE_PROTOCOL_VERSION=" + TRADE_PROTOCOL_VERSION + + ", TRADE_PROTOCOL_VERSION=" + getTradeProtocolVersion() + ", BASE_CURRENCY_NETWORK=" + BASE_CURRENCY_NETWORK + ", getP2PNetworkId()=" + getP2PMessageVersion() + '}'); diff --git a/core/src/main/java/bisq/core/api/CoreTradesService.java b/core/src/main/java/bisq/core/api/CoreTradesService.java index ccf01c61858..b888f3e29fc 100644 --- a/core/src/main/java/bisq/core/api/CoreTradesService.java +++ b/core/src/main/java/bisq/core/api/CoreTradesService.java @@ -38,8 +38,8 @@ import bisq.core.trade.model.TradeModel; import bisq.core.trade.model.bisq_v1.Trade; import bisq.core.trade.model.bsq_swap.BsqSwapTrade; -import bisq.core.trade.protocol.bisq_v1.BuyerProtocol; -import bisq.core.trade.protocol.bisq_v1.SellerProtocol; +import bisq.core.trade.protocol.BuyerProtocol; +import bisq.core.trade.protocol.SellerProtocol; import bisq.core.user.User; import bisq.core.util.validation.BtcAddressValidator; diff --git a/core/src/main/java/bisq/core/btc/listeners/OutputSpendConfidenceListener.java b/core/src/main/java/bisq/core/btc/listeners/OutputSpendConfidenceListener.java new file mode 100644 index 00000000000..08e73787358 --- /dev/null +++ b/core/src/main/java/bisq/core/btc/listeners/OutputSpendConfidenceListener.java @@ -0,0 +1,34 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.btc.listeners; + +import org.bitcoinj.core.TransactionConfidence; +import org.bitcoinj.core.TransactionOutput; + +import lombok.Getter; + +public abstract class OutputSpendConfidenceListener { + @Getter + private final TransactionOutput output; + + public OutputSpendConfidenceListener(TransactionOutput output) { + this.output = output; + } + + public abstract void onOutputSpendConfidenceChanged(TransactionConfidence confidence); +} diff --git a/core/src/main/java/bisq/core/btc/model/AddressEntry.java b/core/src/main/java/bisq/core/btc/model/AddressEntry.java index 18bb07fcc28..7d5bef9da5a 100644 --- a/core/src/main/java/bisq/core/btc/model/AddressEntry.java +++ b/core/src/main/java/bisq/core/btc/model/AddressEntry.java @@ -55,7 +55,9 @@ public enum Context { OFFER_FUNDING, RESERVED_FOR_TRADE, MULTI_SIG, - TRADE_PAYOUT + TRADE_PAYOUT, + WARNING_TX_FEE_BUMP, + REDIRECT_TX_FEE_BUMP } // keyPair can be null in case the object is created from deserialization as it is transient. diff --git a/core/src/main/java/bisq/core/btc/setup/WalletConfig.java b/core/src/main/java/bisq/core/btc/setup/WalletConfig.java index c08597ee616..7a04c7785c5 100644 --- a/core/src/main/java/bisq/core/btc/setup/WalletConfig.java +++ b/core/src/main/java/bisq/core/btc/setup/WalletConfig.java @@ -549,7 +549,7 @@ public File directory() { return directory; } - public void maybeAddSegwitKeychain(Wallet wallet, KeyParameter aesKey, boolean isBsqWallet) { + public void maybeAddSegwitKeychain(Wallet wallet, @Nullable KeyParameter aesKey, boolean isBsqWallet) { var nonSegwitAccountPath = isBsqWallet ? BisqKeyChainGroupStructure.BIP44_BSQ_NON_SEGWIT_ACCOUNT_PATH : BisqKeyChainGroupStructure.BIP44_BTC_NON_SEGWIT_ACCOUNT_PATH; diff --git a/core/src/main/java/bisq/core/btc/wallet/BisqRiskAnalysis.java b/core/src/main/java/bisq/core/btc/wallet/BisqRiskAnalysis.java index 8b665c67c9e..b5d23fa29d0 100644 --- a/core/src/main/java/bisq/core/btc/wallet/BisqRiskAnalysis.java +++ b/core/src/main/java/bisq/core/btc/wallet/BisqRiskAnalysis.java @@ -60,12 +60,14 @@ // Copied from DefaultRiskAnalysis as DefaultRiskAnalysis has mostly private methods and constructor so we cannot // override it. -// The changes to DefaultRiskAnalysis are: removal of the RBF check and accept as standard an OP_RETURN outputs -// with 0 value. +// The changes to DefaultRiskAnalysis are: removal of the RBF check and removal of the relative lock-time check. // For Bisq's use cases RBF is not considered risky. Requiring a confirmation for RBF payments from a user's // external wallet to Bisq would hurt usability. The trade transaction requires anyway a confirmation and we don't see // a use case where a Bisq user accepts unconfirmed payment from untrusted peers and would not wait anyway for at least // one confirmation. +// Relative lock-times are used by claim txs for the v5 trade protocol. It's doubtful that they would realistically +// show up in any other context (maybe forced lightning channel closures spending straight to Bisq) or would ever be +// replaced once broadcast, so we deem them non-risky. /** *

The default risk analysis. Currently, it only is concerned with whether a tx/dependency is non-final or not, and @@ -122,12 +124,13 @@ private Result analyzeIsFinal() { // return Result.NON_FINAL; // } - // Relative time-locked transactions are risky too. We can't check the locks because usually we don't know the - // spent outputs (to know when they were created). - if (tx.hasRelativeLockTime()) { - nonFinal = tx; - return Result.NON_FINAL; - } + // Commented out to accept claim txs for v5 trade protocol. + // // Relative time-locked transactions are risky too. We can't check the locks because usually we don't know the + // // spent outputs (to know when they were created). + // if (tx.hasRelativeLockTime()) { + // nonFinal = tx; + // return Result.NON_FINAL; + // } if (wallet == null) return null; diff --git a/core/src/main/java/bisq/core/btc/wallet/ClaimTransactionFactory.java b/core/src/main/java/bisq/core/btc/wallet/ClaimTransactionFactory.java new file mode 100644 index 00000000000..b2f9c9513c3 --- /dev/null +++ b/core/src/main/java/bisq/core/btc/wallet/ClaimTransactionFactory.java @@ -0,0 +1,145 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.btc.wallet; + +import bisq.core.btc.exceptions.TransactionVerificationException; +import bisq.core.crypto.LowRSigningKey; + +import org.bitcoinj.core.Address; +import org.bitcoinj.core.AddressFormatException; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.core.Sha256Hash; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionInput; +import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.core.TransactionWitness; +import org.bitcoinj.crypto.DeterministicKey; +import org.bitcoinj.crypto.TransactionSignature; +import org.bitcoinj.script.Script; +import org.bitcoinj.script.ScriptBuilder; + +import org.bouncycastle.crypto.params.KeyParameter; + +import javax.annotation.Nullable; + +import static com.google.common.base.Preconditions.checkArgument; + +public class ClaimTransactionFactory { + private final NetworkParameters params; + + public ClaimTransactionFactory(NetworkParameters params) { + this.params = params; + } + + public Transaction createSignedClaimTransaction(TransactionOutput warningTxOutput, + boolean isBuyer, + long claimDelay, + Address payoutAddress, + long miningFee, + byte[] peersMultiSigPubKey, + DeterministicKey myMultiSigKeyPair, + @Nullable KeyParameter aesKey) + throws AddressFormatException, TransactionVerificationException { + + Transaction claimTx = createUnsignedClaimTransaction(warningTxOutput, claimDelay, payoutAddress, miningFee); + byte[] buyerPubKey = isBuyer ? myMultiSigKeyPair.getPubKey() : peersMultiSigPubKey; + byte[] sellerPubKey = isBuyer ? peersMultiSigPubKey : myMultiSigKeyPair.getPubKey(); + ECKey.ECDSASignature mySignature = signClaimTransaction(claimTx, warningTxOutput, isBuyer, claimDelay, + buyerPubKey, sellerPubKey, myMultiSigKeyPair, aesKey); + return finalizeClaimTransaction(claimTx, warningTxOutput, isBuyer, claimDelay, buyerPubKey, sellerPubKey, mySignature); + } + + private Transaction createUnsignedClaimTransaction(TransactionOutput warningTxOutput, + long claimDelay, + Address payoutAddress, + long miningFee) + throws AddressFormatException, TransactionVerificationException { + + Transaction claimTx = new Transaction(params); + claimTx.setVersion(2); // needed to enable relative lock time + + claimTx.addInput(warningTxOutput); + claimTx.getInput(0).setSequenceNumber(claimDelay); + + Coin amountWithoutMiningFee = warningTxOutput.getValue().subtract(Coin.valueOf(miningFee)); + claimTx.addOutput(amountWithoutMiningFee, payoutAddress); + + WalletService.printTx("Unsigned claimTx", claimTx); + WalletService.verifyTransaction(claimTx); + return claimTx; + } + + private ECKey.ECDSASignature signClaimTransaction(Transaction claimTx, + TransactionOutput warningTxOutput, + boolean isBuyer, + long claimDelay, + byte[] buyerPubKey, + byte[] sellerPubKey, + DeterministicKey myMultiSigKeyPair, + @Nullable KeyParameter aesKey) + throws TransactionVerificationException { + + Script redeemScript = WarningTransactionFactory.createRedeemScript(isBuyer, buyerPubKey, sellerPubKey, claimDelay); + checkArgument(ScriptBuilder.createP2WSHOutputScript(redeemScript).equals(warningTxOutput.getScriptPubKey()), + "Redeem script does not hash to expected ScriptPubKey"); + + Coin claimTxInputValue = warningTxOutput.getValue(); + Sha256Hash sigHash = claimTx.hashForWitnessSignature(0, redeemScript, claimTxInputValue, + Transaction.SigHash.ALL, false); + + ECKey.ECDSASignature mySignature = LowRSigningKey.from(myMultiSigKeyPair).sign(sigHash, aesKey); + WalletService.printTx("claimTx for sig creation", claimTx); + WalletService.verifyTransaction(claimTx); + return mySignature; + } + + private Transaction finalizeClaimTransaction(Transaction claimTx, + TransactionOutput warningTxOutput, + boolean isBuyer, + long claimDelay, + byte[] buyerPubKey, + byte[] sellerPubKey, + ECKey.ECDSASignature mySignature) + throws TransactionVerificationException { + + Script redeemScript = WarningTransactionFactory.createRedeemScript(isBuyer, buyerPubKey, sellerPubKey, claimDelay); + TransactionSignature myTxSig = new TransactionSignature(mySignature, Transaction.SigHash.ALL, false); + + TransactionInput input = claimTx.getInput(0); + TransactionWitness witness = redeemP2WSH(redeemScript, myTxSig); + input.setWitness(witness); + + WalletService.printTx("finalizeClaimTransaction", claimTx); + WalletService.verifyTransaction(claimTx); + + Coin inputValue = warningTxOutput.getValue(); + Script scriptPubKey = warningTxOutput.getScriptPubKey(); + input.getScriptSig().correctlySpends(claimTx, 0, witness, inputValue, scriptPubKey, Script.ALL_VERIFY_FLAGS); + return claimTx; + } + + private static TransactionWitness redeemP2WSH(Script witnessScript, TransactionSignature mySignature) { + var witness = new TransactionWitness(3); + witness.setPush(0, mySignature.encodeToBitcoin()); + witness.setPush(1, new byte[]{}); + witness.setPush(2, witnessScript.getProgram()); + return witness; + } +} diff --git a/core/src/main/java/bisq/core/btc/wallet/RedirectionTransactionFactory.java b/core/src/main/java/bisq/core/btc/wallet/RedirectionTransactionFactory.java new file mode 100644 index 00000000000..0fb3489ec4f --- /dev/null +++ b/core/src/main/java/bisq/core/btc/wallet/RedirectionTransactionFactory.java @@ -0,0 +1,149 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.btc.wallet; + +import bisq.core.btc.exceptions.TransactionVerificationException; +import bisq.core.crypto.LowRSigningKey; + +import bisq.common.util.Tuple2; + +import org.bitcoinj.core.Address; +import org.bitcoinj.core.AddressFormatException; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.core.Sha256Hash; +import org.bitcoinj.core.SignatureDecodeException; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionInput; +import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.core.TransactionWitness; +import org.bitcoinj.crypto.DeterministicKey; +import org.bitcoinj.crypto.TransactionSignature; +import org.bitcoinj.script.Script; +import org.bitcoinj.script.ScriptBuilder; + +import org.bouncycastle.crypto.params.KeyParameter; + +import java.util.List; + +import javax.annotation.Nullable; + +import static com.google.common.base.Preconditions.checkArgument; + +public class RedirectionTransactionFactory { + private final NetworkParameters params; + + public RedirectionTransactionFactory(NetworkParameters params) { + this.params = params; + } + + public Transaction createUnsignedRedirectionTransaction(TransactionOutput warningTxOutput, + List> receivers, + Tuple2 feeBumpOutputAmountAndAddress) + throws AddressFormatException, TransactionVerificationException { + + Transaction redirectionTx = new Transaction(params); + redirectionTx.addInput(warningTxOutput); + + checkArgument(!receivers.isEmpty(), "receivers must not be empty"); + receivers.forEach(receiver -> redirectionTx.addOutput(Coin.valueOf(receiver.first), Address.fromString(params, receiver.second))); + + Address feeBumpAddress = Address.fromString(params, feeBumpOutputAmountAndAddress.second); + checkArgument(feeBumpAddress.getOutputScriptType() == Script.ScriptType.P2WPKH, "fee bump address must be P2WPKH"); + + redirectionTx.addOutput( + Coin.valueOf(feeBumpOutputAmountAndAddress.first), + feeBumpAddress + ); + + WalletService.printTx("Unsigned redirectionTx", redirectionTx); + WalletService.verifyTransaction(redirectionTx); + + return redirectionTx; + } + + public byte[] signRedirectionTransaction(TransactionOutput warningTxOutput, + Transaction redirectionTx, + boolean isBuyer, + long claimDelay, + byte[] buyerPubKey, + byte[] sellerPubKey, + DeterministicKey myMultiSigKeyPair, + @Nullable KeyParameter aesKey) + throws TransactionVerificationException { + + Script redeemScript = WarningTransactionFactory.createRedeemScript(!isBuyer, buyerPubKey, sellerPubKey, claimDelay); + checkArgument(ScriptBuilder.createP2WSHOutputScript(redeemScript).equals(warningTxOutput.getScriptPubKey()), + "Redeem script does not hash to expected ScriptPubKey"); + + Coin redirectionTxInputValue = warningTxOutput.getValue(); + Sha256Hash sigHash = redirectionTx.hashForWitnessSignature(0, redeemScript, + redirectionTxInputValue, Transaction.SigHash.ALL, false); + + ECKey.ECDSASignature mySignature = LowRSigningKey.from(myMultiSigKeyPair).sign(sigHash, aesKey); + WalletService.printTx("redirectionTx for sig creation", redirectionTx); + WalletService.verifyTransaction(redirectionTx); + return mySignature.encodeToDER(); + } + + public Transaction finalizeRedirectionTransaction(TransactionOutput warningTxOutput, + Transaction redirectionTx, + boolean isBuyer, + long claimDelay, + byte[] buyerPubKey, + byte[] sellerPubKey, + byte[] buyerSignature, + byte[] sellerSignature) + throws TransactionVerificationException, SignatureDecodeException { + + Script redeemScript = WarningTransactionFactory.createRedeemScript(!isBuyer, buyerPubKey, sellerPubKey, claimDelay); + ECKey.ECDSASignature buyerECDSASignature = ECKey.ECDSASignature.decodeFromDER(buyerSignature); + ECKey.ECDSASignature sellerECDSASignature = ECKey.ECDSASignature.decodeFromDER(sellerSignature); + + checkArgument(!buyerECDSASignature.r.testBit(255), "buyer signature should be low-R"); + checkArgument(!sellerECDSASignature.r.testBit(255), "seller signature should be low-R"); + + TransactionSignature buyerTxSig = new TransactionSignature(buyerECDSASignature, Transaction.SigHash.ALL, false); + TransactionSignature sellerTxSig = new TransactionSignature(sellerECDSASignature, Transaction.SigHash.ALL, false); + + TransactionInput input = redirectionTx.getInput(0); + TransactionWitness witness = redeemP2WSH(redeemScript, buyerTxSig, sellerTxSig); + input.setWitness(witness); + + WalletService.printTx("finalizeRedirectionTransaction", redirectionTx); + WalletService.verifyTransaction(redirectionTx); + + Coin inputValue = warningTxOutput.getValue(); + Script scriptPubKey = warningTxOutput.getScriptPubKey(); + input.getScriptSig().correctlySpends(redirectionTx, 0, witness, inputValue, scriptPubKey, Script.ALL_VERIFY_FLAGS); + return redirectionTx; + } + + private static TransactionWitness redeemP2WSH(Script witnessScript, + TransactionSignature buyerSignature, + TransactionSignature sellerSignature) { + var witness = new TransactionWitness(5); + witness.setPush(0, new byte[]{}); + witness.setPush(1, sellerSignature.encodeToBitcoin()); + witness.setPush(2, buyerSignature.encodeToBitcoin()); + witness.setPush(3, new byte[]{1}); + witness.setPush(4, witnessScript.getProgram()); + return witness; + } +} diff --git a/core/src/main/java/bisq/core/btc/wallet/TradeWalletService.java b/core/src/main/java/bisq/core/btc/wallet/TradeWalletService.java index 24dac384a69..b6d96aac7d9 100644 --- a/core/src/main/java/bisq/core/btc/wallet/TradeWalletService.java +++ b/core/src/main/java/bisq/core/btc/wallet/TradeWalletService.java @@ -83,6 +83,9 @@ public class TradeWalletService { private final Preferences preferences; private final NetworkParameters params; + private final WarningTransactionFactory warningTransactionFactory; + private final RedirectionTransactionFactory redirectionTransactionFactory; + @Nullable private Wallet wallet; @Nullable @@ -100,6 +103,8 @@ public TradeWalletService(WalletsSetup walletsSetup, Preferences preferences) { this.walletsSetup = walletsSetup; this.preferences = preferences; this.params = Config.baseCurrencyNetworkParameters(); + this.warningTransactionFactory = new WarningTransactionFactory(params); + this.redirectionTransactionFactory = new RedirectionTransactionFactory(params); walletsSetup.addSetupCompletedHandler(() -> { walletConfig = walletsSetup.getWalletConfig(); wallet = walletsSetup.getBtcWallet(); @@ -795,6 +800,143 @@ public Transaction finalizeDelayedPayoutTx(Transaction delayedPayoutTx, return delayedPayoutTx; } + /////////////////////////////////////////////////////////////////////////////////////////// + // Warning tx + /////////////////////////////////////////////////////////////////////////////////////////// + + public Transaction createUnsignedWarningTx(boolean isBuyer, + TransactionOutput depositTxOutput, + long lockTime, + byte[] buyerPubKey, + byte[] sellerPubKey, + long claimDelay, + long miningFee, + Tuple2 feeBumpOutputAmountAndAddress) + throws AddressFormatException, TransactionVerificationException { + return warningTransactionFactory.createUnsignedWarningTransaction( + isBuyer, + depositTxOutput, + lockTime, + buyerPubKey, + sellerPubKey, + claimDelay, + miningFee, + feeBumpOutputAmountAndAddress + ); + } + + public byte[] signWarningTx(Transaction warningTx, + TransactionOutput depositTxOutput, + DeterministicKey myMultiSigKeyPair, + byte[] buyerPubKey, + byte[] sellerPubKey) + throws TransactionVerificationException { + return warningTransactionFactory.signWarningTransaction( + warningTx, + depositTxOutput, + myMultiSigKeyPair, + buyerPubKey, + sellerPubKey, + aesKey + ); + } + + public Transaction finalizeWarningTx(Transaction warningTx, + byte[] buyerPubKey, + byte[] sellerPubKey, + byte[] buyerSignature, + byte[] sellerSignature, + Coin inputValue) + throws TransactionVerificationException, SignatureDecodeException { + return warningTransactionFactory.finalizeWarningTransaction( + warningTx, + buyerPubKey, + sellerPubKey, + buyerSignature, + sellerSignature, + inputValue + ); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // Redirection tx + /////////////////////////////////////////////////////////////////////////////////////////// + + public Transaction createUnsignedRedirectionTx(TransactionOutput warningTxOutput, + List> receivers, + Tuple2 feeBumpOutputAmountAndAddress) + throws AddressFormatException, TransactionVerificationException { + return redirectionTransactionFactory.createUnsignedRedirectionTransaction( + warningTxOutput, + receivers, + feeBumpOutputAmountAndAddress + ); + } + + public byte[] signRedirectionTx(TransactionOutput warningTxOutput, + Transaction redirectionTx, + boolean isBuyer, + long claimDelay, + byte[] buyerPubKey, + byte[] sellerPubKey, + DeterministicKey myMultiSigKeyPair) + throws TransactionVerificationException { + return redirectionTransactionFactory.signRedirectionTransaction( + warningTxOutput, + redirectionTx, + isBuyer, + claimDelay, + buyerPubKey, + sellerPubKey, + myMultiSigKeyPair, + aesKey + ); + } + + public Transaction finalizeRedirectionTx(TransactionOutput warningTxOutput, + Transaction redirectionTx, + boolean isBuyer, + long claimDelay, + byte[] buyerPubKey, + byte[] sellerPubKey, + byte[] buyerSignature, + byte[] sellerSignature) + throws TransactionVerificationException, SignatureDecodeException { + return redirectionTransactionFactory.finalizeRedirectionTransaction( + warningTxOutput, + redirectionTx, + isBuyer, + claimDelay, + buyerPubKey, + sellerPubKey, + buyerSignature, + sellerSignature + ); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // Claim tx + /////////////////////////////////////////////////////////////////////////////////////////// + + public Transaction createSignedClaimTx(TransactionOutput warningTxOutput, + boolean isBuyer, + long claimDelay, + Address payoutAddress, + long miningFee, + byte[] peersMultiSigPubKey, + DeterministicKey myMultiSigKeyPair) + throws AddressFormatException, TransactionVerificationException { + return new ClaimTransactionFactory(params).createSignedClaimTransaction( + warningTxOutput, + isBuyer, + claimDelay, + payoutAddress, + miningFee, + peersMultiSigPubKey, + myMultiSigKeyPair, + aesKey + ); + } /////////////////////////////////////////////////////////////////////////////////////////// // Standard payout tx @@ -1372,7 +1514,7 @@ private Script get2of3MultiSigRedeemScript(byte[] buyerPubKey, byte[] sellerPubK return ScriptBuilder.createMultiSigOutputScript(2, keys); } - private Script get2of2MultiSigRedeemScript(byte[] buyerPubKey, byte[] sellerPubKey) { + static Script get2of2MultiSigRedeemScript(byte[] buyerPubKey, byte[] sellerPubKey) { ECKey buyerKey = ECKey.fromPublicOnly(buyerPubKey); ECKey sellerKey = ECKey.fromPublicOnly(sellerPubKey); // Take care of sorting! Need to reverse to the order we use normally (buyer, seller) @@ -1380,7 +1522,7 @@ private Script get2of2MultiSigRedeemScript(byte[] buyerPubKey, byte[] sellerPubK return ScriptBuilder.createMultiSigOutputScript(2, keys); } - private Script get2of2MultiSigOutputScript(byte[] buyerPubKey, byte[] sellerPubKey, boolean legacy) { + static Script get2of2MultiSigOutputScript(byte[] buyerPubKey, byte[] sellerPubKey, boolean legacy) { Script redeemScript = get2of2MultiSigRedeemScript(buyerPubKey, sellerPubKey); if (legacy) { return ScriptBuilder.createP2SHOutputScript(redeemScript); @@ -1470,7 +1612,7 @@ private void addAvailableInputsAndChangeOutputs(Transaction transaction, } } - private void applyLockTime(long lockTime, Transaction tx) { + static void applyLockTime(long lockTime, Transaction tx) { checkArgument(!tx.getInputs().isEmpty(), "The tx must have inputs. tx={}", tx); tx.getInputs().forEach(input -> input.setSequenceNumber(TransactionInput.NO_SEQUENCE - 1)); tx.setLockTime(lockTime); diff --git a/core/src/main/java/bisq/core/btc/wallet/WalletService.java b/core/src/main/java/bisq/core/btc/wallet/WalletService.java index 72a670df88b..8b5ee65db3b 100644 --- a/core/src/main/java/bisq/core/btc/wallet/WalletService.java +++ b/core/src/main/java/bisq/core/btc/wallet/WalletService.java @@ -21,6 +21,7 @@ import bisq.core.btc.exceptions.WalletException; import bisq.core.btc.listeners.AddressConfidenceListener; import bisq.core.btc.listeners.BalanceListener; +import bisq.core.btc.listeners.OutputSpendConfidenceListener; import bisq.core.btc.listeners.TxConfidenceListener; import bisq.core.btc.setup.WalletsSetup; import bisq.core.btc.wallet.http.MemPoolSpaceTxBroadcaster; @@ -44,6 +45,7 @@ import org.bitcoinj.core.Transaction; import org.bitcoinj.core.TransactionConfidence; import org.bitcoinj.core.TransactionInput; +import org.bitcoinj.core.TransactionOutPoint; import org.bitcoinj.core.TransactionOutput; import org.bitcoinj.core.TransactionWitness; import org.bitcoinj.core.VerificationException; @@ -66,6 +68,7 @@ import org.bitcoinj.wallet.RedeemData; import org.bitcoinj.wallet.SendRequest; import org.bitcoinj.wallet.Wallet; +import org.bitcoinj.wallet.WalletTransaction; import org.bitcoinj.wallet.listeners.WalletChangeEventListener; import org.bitcoinj.wallet.listeners.WalletCoinsReceivedEventListener; import org.bitcoinj.wallet.listeners.WalletCoinsSentEventListener; @@ -89,6 +92,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.concurrent.CopyOnWriteArraySet; import java.util.concurrent.atomic.AtomicReference; @@ -122,6 +126,7 @@ public abstract class WalletService { private final BisqWalletListener walletEventListener = new BisqWalletListener(); private final CopyOnWriteArraySet addressConfidenceListeners = new CopyOnWriteArraySet<>(); private final CopyOnWriteArraySet txConfidenceListeners = new CopyOnWriteArraySet<>(); + private final CopyOnWriteArraySet spendConfidenceListeners = new CopyOnWriteArraySet<>(); private final CopyOnWriteArraySet balanceListeners = new CopyOnWriteArraySet<>(); private final WalletChangeEventListener cacheInvalidationListener; private final AtomicReference> txOutputAddressCache = new AtomicReference<>(); @@ -225,6 +230,14 @@ public void removeTxConfidenceListener(TxConfidenceListener listener) { txConfidenceListeners.remove(listener); } + public void addSpendConfidenceListener(OutputSpendConfidenceListener listener) { + spendConfidenceListeners.add(listener); + } + + public void removeSpendConfidenceListener(OutputSpendConfidenceListener listener) { + spendConfidenceListeners.remove(listener); + } + public void addBalanceListener(BalanceListener listener) { balanceListeners.add(listener); } @@ -284,7 +297,7 @@ public static void checkScriptSig(Transaction transaction, /////////////////////////////////////////////////////////////////////////////////////////// public static void signTx(Wallet wallet, - KeyParameter aesKey, + @Nullable KeyParameter aesKey, Transaction tx) throws WalletException, TransactionVerificationException { for (int i = 0; i < tx.getInputs().size(); i++) { @@ -310,7 +323,7 @@ public static void signTx(Wallet wallet, } public static void signTransactionInput(Wallet wallet, - KeyParameter aesKey, + @Nullable KeyParameter aesKey, Transaction tx, TransactionInput txIn, int index) { @@ -443,29 +456,28 @@ public void broadcastTx(Transaction tx, TxBroadcaster.Callback callback, int tim @Nullable public TransactionConfidence getConfidenceForAddress(Address address) { - List transactionConfidenceList = new ArrayList<>(); if (wallet != null) { Set transactions = getAddressToMatchingTxSetMultimap().get(address); - transactionConfidenceList.addAll(transactions.stream().map(tx -> - getTransactionConfidence(tx, address)).collect(Collectors.toList())); + return getMostRecentConfidence(transactions.stream() + .map(tx -> getTransactionConfidence(tx, address)) + .collect(Collectors.toList())); } - return getMostRecentConfidence(transactionConfidenceList); + return null; } @Nullable public TransactionConfidence getConfidenceForAddressFromBlockHeight(Address address, long targetHeight) { - List transactionConfidenceList = new ArrayList<>(); if (wallet != null) { Set transactions = getAddressToMatchingTxSetMultimap().get(address); // "acceptable confidence" is either a new (pending) Tx, or a Tx confirmed after target block height - transactionConfidenceList.addAll(transactions.stream() + return getMostRecentConfidence(transactions.stream() .map(tx -> getTransactionConfidence(tx, address)) .filter(Objects::nonNull) .filter(con -> con.getConfidenceType() == PENDING || (con.getConfidenceType() == BUILDING && con.getAppearedAtChainHeight() > targetHeight)) .collect(Collectors.toList())); } - return getMostRecentConfidence(transactionConfidenceList); + return null; } private SetMultimap getAddressToMatchingTxSetMultimap() { @@ -497,7 +509,7 @@ public TransactionConfidence getConfidenceForTxId(@Nullable String txId) { } @Nullable - private TransactionConfidence getTransactionConfidence(Transaction tx, Address address) { + private static TransactionConfidence getTransactionConfidence(Transaction tx, Address address) { List transactionConfidenceList = getOutputsWithConnectedOutputs(tx).stream() .filter(output -> address != null && address.equals(getAddressFromOutput(output))) .flatMap(o -> Stream.ofNullable(o.getParentTransaction())) @@ -507,7 +519,7 @@ private TransactionConfidence getTransactionConfidence(Transaction tx, Address a } - private List getOutputsWithConnectedOutputs(Transaction tx) { + private static List getOutputsWithConnectedOutputs(Transaction tx) { List transactionOutputs = tx.getOutputs(); List connectedOutputs = new ArrayList<>(); @@ -527,7 +539,7 @@ private List getOutputsWithConnectedOutputs(Transaction tx) { } @Nullable - private TransactionConfidence getMostRecentConfidence(List transactionConfidenceList) { + private static TransactionConfidence getMostRecentConfidence(List transactionConfidenceList) { TransactionConfidence transactionConfidence = null; for (TransactionConfidence confidence : transactionConfidenceList) { if (confidence != null) { @@ -635,7 +647,7 @@ public Coin getDust(Transaction proposedTransaction) { /////////////////////////////////////////////////////////////////////////////////////////// public void emptyBtcWallet(String toAddress, - KeyParameter aesKey, + @Nullable KeyParameter aesKey, ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) throws InsufficientMoneyException, AddressFormatException { @@ -818,11 +830,33 @@ public boolean isTransactionOutputMine(TransactionOutput transactionOutput) { }*/ public Coin getValueSentFromMeForTransaction(Transaction transaction) throws ScriptException { - return transaction.getValueSentFromMe(wallet); + // Does the same thing as transaction.getValueSentFromMe(wallet), except that watched connected + // outputs don't count towards the total, only outputs with pubKeys belonging to the wallet. + long satoshis = transaction.getInputs().stream() + .flatMap(input -> getConnectedOutput(input, WalletTransaction.Pool.UNSPENT) + .or(() -> getConnectedOutput(input, WalletTransaction.Pool.SPENT)) + .or(() -> getConnectedOutput(input, WalletTransaction.Pool.PENDING)) + .filter(o -> o.isMine(wallet)) + .stream()) + .mapToLong(o -> o.getValue().value) + .sum(); + return Coin.valueOf(satoshis); + } + + private Optional getConnectedOutput(TransactionInput input, WalletTransaction.Pool pool) { + TransactionOutPoint outpoint = input.getOutpoint(); + return Optional.ofNullable(wallet.getTransactionPool(pool).get(outpoint.getHash())) + .map(tx -> tx.getOutput(outpoint.getIndex())); } public Coin getValueSentToMeForTransaction(Transaction transaction) throws ScriptException { - return transaction.getValueSentToMe(wallet); + // Does the same thing as transaction.getValueSentToMe(wallet), except that watched outputs + // don't count towards the total, only outputs with pubKeys belonging to the wallet. + long satoshis = transaction.getOutputs().stream() + .filter(o -> o.isMine(wallet)) + .mapToLong(o -> o.getValue().value) + .sum(); + return Coin.valueOf(satoshis); } @@ -907,7 +941,7 @@ public void onCoinsSent(Wallet wallet, Transaction tx, Coin prevBalance, Coin ne @Override public void onReorganize(Wallet wallet) { - log.warn("onReorganize "); + log.warn("onReorganize"); } @Override @@ -916,13 +950,20 @@ public void onTransactionConfidenceChanged(Wallet wallet, Transaction tx) { TransactionConfidence confidence = getTransactionConfidence(tx, addressConfidenceListener.getAddress()); addressConfidenceListener.onTransactionConfidenceChanged(confidence); } - txConfidenceListeners.stream() - .filter(txConfidenceListener -> tx != null && - tx.getTxId().toString() != null && - txConfidenceListener != null && - tx.getTxId().toString().equals(txConfidenceListener.getTxId())) - .forEach(txConfidenceListener -> - txConfidenceListener.onTransactionConfidenceChanged(tx.getConfidence())); + for (OutputSpendConfidenceListener listener : spendConfidenceListeners) { + TransactionInput spentBy = listener.getOutput().getSpentBy(); + if (spentBy != null && tx.equals(spentBy.getParentTransaction())) { + listener.onOutputSpendConfidenceChanged(tx.getConfidence()); + } + } + if (!txConfidenceListeners.isEmpty()) { + String txId = tx.getTxId().toString(); + for (TxConfidenceListener listener : txConfidenceListeners) { + if (txId.equals(listener.getTxId())) { + listener.onTransactionConfidenceChanged(tx.getConfidence()); + } + } + } } void notifyBalanceListeners(Transaction tx) { diff --git a/core/src/main/java/bisq/core/btc/wallet/WarningTransactionFactory.java b/core/src/main/java/bisq/core/btc/wallet/WarningTransactionFactory.java new file mode 100644 index 00000000000..406d8bb0726 --- /dev/null +++ b/core/src/main/java/bisq/core/btc/wallet/WarningTransactionFactory.java @@ -0,0 +1,170 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.btc.wallet; + +import bisq.core.btc.exceptions.TransactionVerificationException; +import bisq.core.crypto.LowRSigningKey; + +import bisq.common.util.Tuple2; + +import org.bitcoinj.core.Address; +import org.bitcoinj.core.AddressFormatException; +import org.bitcoinj.core.Coin; +import org.bitcoinj.core.ECKey; +import org.bitcoinj.core.NetworkParameters; +import org.bitcoinj.core.Sha256Hash; +import org.bitcoinj.core.SignatureDecodeException; +import org.bitcoinj.core.Transaction; +import org.bitcoinj.core.TransactionInput; +import org.bitcoinj.core.TransactionOutput; +import org.bitcoinj.core.TransactionWitness; +import org.bitcoinj.crypto.DeterministicKey; +import org.bitcoinj.crypto.TransactionSignature; +import org.bitcoinj.script.Script; +import org.bitcoinj.script.ScriptBuilder; + +import org.bouncycastle.crypto.params.KeyParameter; + +import javax.annotation.Nullable; + +import static com.google.common.base.Preconditions.checkArgument; +import static org.bitcoinj.script.ScriptOpCodes.*; + +public class WarningTransactionFactory { + private final NetworkParameters params; + + public WarningTransactionFactory(NetworkParameters params) { + this.params = params; + } + + public Transaction createUnsignedWarningTransaction(boolean isBuyer, + TransactionOutput depositTxOutput, + long lockTime, + byte[] buyerPubKey, + byte[] sellerPubKey, + long claimDelay, + long miningFee, + Tuple2 feeBumpOutputAmountAndAddress) + throws AddressFormatException, TransactionVerificationException { + Transaction warningTx = new Transaction(params); + + warningTx.addInput(depositTxOutput); + + Coin warningTxOutputCoin = depositTxOutput.getValue() + .subtract(Coin.valueOf(miningFee)) + .subtract(Coin.valueOf(feeBumpOutputAmountAndAddress.first)); + Script redeemScript = createRedeemScript(isBuyer, buyerPubKey, sellerPubKey, claimDelay); + Script outputScript = ScriptBuilder.createP2WSHOutputScript(redeemScript); + warningTx.addOutput(warningTxOutputCoin, outputScript); + + Address feeBumpAddress = Address.fromString(params, feeBumpOutputAmountAndAddress.second); + checkArgument(feeBumpAddress.getOutputScriptType() == Script.ScriptType.P2WPKH, "fee bump address must be P2WPKH"); + + warningTx.addOutput( + Coin.valueOf(feeBumpOutputAmountAndAddress.first), + feeBumpAddress + ); + + TradeWalletService.applyLockTime(lockTime, warningTx); + + WalletService.printTx("Unsigned warningTx", warningTx); + WalletService.verifyTransaction(warningTx); + return warningTx; + } + + public byte[] signWarningTransaction(Transaction warningTx, + TransactionOutput depositTxOutput, + DeterministicKey myMultiSigKeyPair, + byte[] buyerPubKey, + byte[] sellerPubKey, + @Nullable KeyParameter aesKey) + throws TransactionVerificationException { + + Script redeemScript = TradeWalletService.get2of2MultiSigRedeemScript(buyerPubKey, sellerPubKey); + Coin warningTxInputValue = depositTxOutput.getValue(); + + Sha256Hash sigHash = warningTx.hashForWitnessSignature(0, redeemScript, + warningTxInputValue, Transaction.SigHash.ALL, false); + + ECKey.ECDSASignature mySignature = LowRSigningKey.from(myMultiSigKeyPair).sign(sigHash, aesKey); + WalletService.printTx("warningTx for sig creation", warningTx); + WalletService.verifyTransaction(warningTx); + return mySignature.encodeToDER(); + } + + public Transaction finalizeWarningTransaction(Transaction warningTx, + byte[] buyerPubKey, + byte[] sellerPubKey, + byte[] buyerSignature, + byte[] sellerSignature, + Coin inputValue) + throws TransactionVerificationException, SignatureDecodeException { + + Script redeemScript = TradeWalletService.get2of2MultiSigRedeemScript(buyerPubKey, sellerPubKey); + ECKey.ECDSASignature buyerECDSASignature = ECKey.ECDSASignature.decodeFromDER(buyerSignature); + ECKey.ECDSASignature sellerECDSASignature = ECKey.ECDSASignature.decodeFromDER(sellerSignature); + + checkArgument(!buyerECDSASignature.r.testBit(255), "buyer signature should be low-R"); + checkArgument(!sellerECDSASignature.r.testBit(255), "seller signature should be low-R"); + + TransactionSignature buyerTxSig = new TransactionSignature(buyerECDSASignature, Transaction.SigHash.ALL, false); + TransactionSignature sellerTxSig = new TransactionSignature(sellerECDSASignature, Transaction.SigHash.ALL, false); + + TransactionInput input = warningTx.getInput(0); + TransactionWitness witness = TransactionWitness.redeemP2WSH(redeemScript, sellerTxSig, buyerTxSig); + input.setWitness(witness); + + WalletService.printTx("finalizeWarningTransaction", warningTx); + WalletService.verifyTransaction(warningTx); + + Script scriptPubKey = TradeWalletService.get2of2MultiSigOutputScript(buyerPubKey, sellerPubKey, false); + input.getScriptSig().correctlySpends(warningTx, 0, witness, inputValue, scriptPubKey, Script.ALL_VERIFY_FLAGS); + return warningTx; + } + + static Script createRedeemScript(boolean isBuyer, byte[] buyerPubKey, byte[] sellerPubKey, long claimDelay) { + var scriptBuilder = new ScriptBuilder() + .data(isBuyer ? buyerPubKey : sellerPubKey) + .op(OP_SWAP); + + if (isBuyer) { + scriptBuilder.op(OP_IF) + .number(2) + .data(sellerPubKey) + .op(OP_ROT) + .number(2) + .op(OP_CHECKMULTISIG); + } else { + scriptBuilder.op(OP_IF) + .number(2) + .op(OP_SWAP) + .data(buyerPubKey) + .number(2) + .op(OP_CHECKMULTISIG); + } + + scriptBuilder.op(OP_ELSE) + .number(claimDelay) + .op(OP_CHECKSEQUENCEVERIFY) + .op(OP_DROP) + .op(OP_CHECKSIG); + + return scriptBuilder.op(OP_ENDIF) + .build(); + } +} diff --git a/core/src/main/java/bisq/core/dao/burningman/DelayedPayoutTxReceiverService.java b/core/src/main/java/bisq/core/dao/burningman/DelayedPayoutTxReceiverService.java index c30f0c8a3fb..3bed9cf1b71 100644 --- a/core/src/main/java/bisq/core/dao/burningman/DelayedPayoutTxReceiverService.java +++ b/core/src/main/java/bisq/core/dao/burningman/DelayedPayoutTxReceiverService.java @@ -23,10 +23,15 @@ import bisq.core.dao.state.DaoStateService; import bisq.core.dao.state.model.blockchain.Block; +import bisq.common.app.Version; import bisq.common.config.Config; import bisq.common.util.Tuple2; import bisq.common.util.Utilities; +import org.bitcoinj.core.Address; +import org.bitcoinj.core.LegacyAddress; +import org.bitcoinj.core.SegwitAddress; + import javax.inject.Inject; import javax.inject.Singleton; @@ -35,12 +40,17 @@ import java.util.Collection; import java.util.Comparator; import java.util.Date; +import java.util.EnumSet; import java.util.GregorianCalendar; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; +import static bisq.core.dao.burningman.DelayedPayoutTxReceiverService.ReceiverFlag.BUGFIX_6699; +import static bisq.core.dao.burningman.DelayedPayoutTxReceiverService.ReceiverFlag.PRECISE_FEES; +import static bisq.core.dao.burningman.DelayedPayoutTxReceiverService.ReceiverFlag.PROPOSAL_412; import static com.google.common.base.Preconditions.checkArgument; /** @@ -60,7 +70,7 @@ public class DelayedPayoutTxReceiverService implements DaoStateListener { public static final Date PROPOSAL_412_ACTIVATION_DATE = Utilities.getUTCDate(2024, GregorianCalendar.MAY, 1); // We don't allow to get further back than 767950 (the block height from Dec. 18th 2022). - static final int MIN_SNAPSHOT_HEIGHT = Config.baseCurrencyNetwork().isRegtest() ? 0 : 767950; + private static final int MIN_SNAPSHOT_HEIGHT = Config.baseCurrencyNetwork().isRegtest() ? 0 : 767950; // One part of the limit for the min. amount to be included in the DPT outputs. // The miner fee rate multiplied by 2 times the output size is the other factor. @@ -79,6 +89,10 @@ public class DelayedPayoutTxReceiverService implements DaoStateListener { // spike when opening arbitration. private static final long DPT_MIN_TX_FEE_RATE = 10; + // The DPT weight (= 4 * size) without any outputs. + private static final long DPT_MIN_WEIGHT = 425; // (would be 1 less if we could guarantee peer signature is low-R) + private static final long UNSIGNED_DPT_MIN_WEIGHT = 204; + private final DaoStateService daoStateService; private final BurningManService burningManService; private int currentChainHeight; @@ -111,28 +125,60 @@ private void applyBlock(Block block) { // API /////////////////////////////////////////////////////////////////////////////////////////// + public enum ReceiverFlag { + BUGFIX_6699(BUGFIX_6699_ACTIVATION_DATE), + PROPOSAL_412(PROPOSAL_412_ACTIVATION_DATE), + PRECISE_FEES(Version.PROTOCOL_5_ACTIVATION_DATE); + + private final Date activationDate; + + ReceiverFlag(Date activationDate) { + this.activationDate = activationDate; + } + + public static Set flagsActivatedBy(Date date) { + Set flags = EnumSet.allOf(ReceiverFlag.class); + flags.removeIf(flag -> !date.after(flag.activationDate)); + return flags; + } + } + // We use a snapshot blockHeight to avoid failed trades in case maker and taker have different block heights. // The selection is deterministic based on DAO data. // The block height is the last mod(10) height from the range of the last 10-20 blocks (139 -> 120; 140 -> 130, 141 -> 130). // We do not have the latest dao state by that but can ensure maker and taker have the same block. public int getBurningManSelectionHeight() { - return getSnapshotHeight(daoStateService.getGenesisBlockHeight(), currentChainHeight, 10); + return getSnapshotHeight(daoStateService.getGenesisBlockHeight(), currentChainHeight); } public List> getReceivers(int burningManSelectionHeight, long inputAmount, long tradeTxFee) { - return getReceivers(burningManSelectionHeight, inputAmount, tradeTxFee, true, true); + return getReceivers(burningManSelectionHeight, inputAmount, tradeTxFee, ReceiverFlag.flagsActivatedBy(new Date())); } public List> getReceivers(int burningManSelectionHeight, long inputAmount, long tradeTxFee, - boolean isBugfix6699Activated, - boolean isProposal412Activated) { + Set receiverFlags) { + return getReceivers(burningManSelectionHeight, inputAmount, tradeTxFee, receiverFlags.contains(PRECISE_FEES) ? + DPT_MIN_WEIGHT : UNSIGNED_DPT_MIN_WEIGHT, receiverFlags); + } + + public List> getReceivers(int burningManSelectionHeight, + long inputAmount, + long tradeTxFee, + long minTxWeight, + Set receiverFlags) { checkArgument(burningManSelectionHeight >= MIN_SNAPSHOT_HEIGHT, "Selection height must be >= " + MIN_SNAPSHOT_HEIGHT); + + boolean isBugfix6699Activated = receiverFlags.contains(BUGFIX_6699); + boolean isProposal412Activated = receiverFlags.contains(PROPOSAL_412); + boolean usePreciseFees = receiverFlags.contains(PRECISE_FEES); + Collection burningManCandidates = burningManService.getActiveBurningManCandidates(burningManSelectionHeight, !isProposal412Activated); + String lbmAddress = burningManService.getLegacyBurningManAddress(burningManSelectionHeight); // We need to use the same txFeePerVbyte value for both traders. // We use the tradeTxFee value which is calculated from the average of taker fee tx size and deposit tx size. @@ -150,36 +196,46 @@ public List> getReceivers(int burningManSelectionHeight, if (burningManCandidates.isEmpty()) { // If there are no compensation requests (e.g. at dev testing) we fall back to the legacy BM - long spendableAmount = getSpendableAmount(1, inputAmount, txFeePerVbyte); - return List.of(new Tuple2<>(spendableAmount, burningManService.getLegacyBurningManAddress(burningManSelectionHeight))); + long spendableAmount = getSpendableAmount(usePreciseFees ? outputSize(lbmAddress) : 32, inputAmount, txFeePerVbyte, minTxWeight); + return spendableAmount > DPT_MIN_REMAINDER_TO_LEGACY_BM + ? List.of(new Tuple2<>(spendableAmount, burningManService.getLegacyBurningManAddress(burningManSelectionHeight))) + : List.of(); } - long spendableAmount = getSpendableAmount(burningManCandidates.size(), inputAmount, txFeePerVbyte); - // We only use outputs >= 1000 sat or at least 2 times the cost for the output (32 bytes). + int totalOutputSize = usePreciseFees ? burningManCandidates.stream() + .mapToInt(candidate -> outputSize(candidate.getReceiverAddress(isBugfix6699Activated).orElseThrow())) + .sum() : burningManCandidates.size() * 32; + long spendableAmount = getSpendableAmount(totalOutputSize, inputAmount, txFeePerVbyte, minTxWeight); + // We only use outputs >= 1000 sat or at least 2 times the cost for the output (32 bytes, assuming P2SH). // If we remove outputs it will be distributed to the remaining receivers. long minOutputAmount = Math.max(DPT_MIN_OUTPUT_AMOUNT, txFeePerVbyte * 32 * 2); // Sanity check that max share of a non-legacy BM is 20% over MAX_BURN_SHARE (taking into account potential increase due adjustment) long maxOutputAmount = Math.round(spendableAmount * (BurningManService.MAX_BURN_SHARE * 1.2)); // We accumulate small amounts which gets filtered out and subtract it from 1 to get an adjustment factor // used later to be applied to the remaining burningmen share. - double adjustment = 1 - burningManCandidates.stream() - .filter(candidate -> candidate.getReceiverAddress(isBugfix6699Activated).isPresent()) + // TODO: This still isn't completely precise. As every small output is filtered, the burn share threshold for + // small outputs decreases slightly. We should really use a priority queue and filter out candidates from + // smallest to biggest share until all the small outputs are removed. (Also, rounding errors can lead to the + // final fee being out by a few sats, if the burn shares add up to 100% with no balance going to the LBM.) + long[] adjustedSpendableAmount = {spendableAmount}; + double shareAdjustment = 1 - burningManCandidates.stream() .mapToDouble(candidate -> { double cappedBurnAmountShare = candidate.getCappedBurnAmountShare(); long amount = Math.round(cappedBurnAmountShare * spendableAmount); + if (usePreciseFees && amount < minOutputAmount) { + String address = candidate.getReceiverAddress(isBugfix6699Activated).orElseThrow(); + adjustedSpendableAmount[0] += outputSize(address) * txFeePerVbyte; + } return amount < minOutputAmount ? cappedBurnAmountShare : 0d; }) .sum(); - // FIXME: The small outputs should be filtered out before adjustment, not afterwards. Otherwise, outputs of - // amount just under 1000 sats or 64 * fee-rate could get erroneously included and lead to significant - // underpaying of the DPT (by perhaps around 5-10% per erroneously included output). List> receivers = burningManCandidates.stream() - .filter(candidate -> candidate.getReceiverAddress(isBugfix6699Activated).isPresent()) + .filter(candidate -> !usePreciseFees || Math.round(candidate.getCappedBurnAmountShare() * spendableAmount) >= minOutputAmount) .map(candidate -> { - double cappedBurnAmountShare = candidate.getCappedBurnAmountShare() / adjustment; - return new Tuple2<>(Math.round(cappedBurnAmountShare * spendableAmount), - candidate.getReceiverAddress(isBugfix6699Activated).get()); + double cappedBurnAmountShare = candidate.getCappedBurnAmountShare() / shareAdjustment; + return new Tuple2<>(Math.round(cappedBurnAmountShare * adjustedSpendableAmount[0]), + candidate.getReceiverAddress(isBugfix6699Activated).orElseThrow()); }) .filter(tuple -> tuple.first >= minOutputAmount) .filter(tuple -> tuple.first <= maxOutputAmount) @@ -187,29 +243,50 @@ public List> getReceivers(int burningManSelectionHeight, .thenComparing(tuple -> tuple.second)) .collect(Collectors.toList()); long totalOutputValue = receivers.stream().mapToLong(e -> e.first).sum(); - if (totalOutputValue < spendableAmount) { - long available = spendableAmount - totalOutputValue; + if (usePreciseFees) { + adjustedSpendableAmount[0] -= outputSize(lbmAddress) * txFeePerVbyte; + } + if (totalOutputValue < adjustedSpendableAmount[0]) { + long available = adjustedSpendableAmount[0] - totalOutputValue; // If the available is larger than DPT_MIN_REMAINDER_TO_LEGACY_BM we send it to legacy BM // Otherwise we use it as miner fee if (available > DPT_MIN_REMAINDER_TO_LEGACY_BM) { - receivers.add(new Tuple2<>(available, burningManService.getLegacyBurningManAddress(burningManSelectionHeight))); + receivers.add(new Tuple2<>(available, lbmAddress)); } } return receivers; } - private static long getSpendableAmount(int numOutputs, long inputAmount, long txFeePerVbyte) { - // Output size: 32 bytes - // Tx size without outputs: 51 bytes - int txSize = 51 + numOutputs * 32; // Min value: txSize=83 - long minerFee = txFeePerVbyte * txSize; // Min value: minerFee=830 + private static long getSpendableAmount(int totalOutputSize, + long inputAmount, + long txFeePerVbyte, + long minTxWeight) { + // P2SH output size: 32 bytes + // Unsigned tx size without outputs: 51 bytes + long txWeight = minTxWeight + totalOutputSize * 4L; // Min value: txWeight=332 (for unsigned DPT with 1 P2SH output) + long minerFee = (txFeePerVbyte * txWeight + 3) / 4; // Min value: minerFee=830 // We need to make sure we have at least 1000 sat as defined in TradeWalletService minerFee = Math.max(TradeWalletService.MIN_DELAYED_PAYOUT_TX_FEE.value, minerFee); - return inputAmount - minerFee; + return Math.max(inputAmount - minerFee, 0); + } + + private static int outputSize(String addressString) { + Address address = Address.fromString(Config.baseCurrencyNetworkParameters(), addressString); + if (address instanceof LegacyAddress) { + switch (address.getOutputScriptType()) { + case P2PKH: + return 34; + case P2SH: + return 32; + } + } else if (address instanceof SegwitAddress) { + return ((SegwitAddress) address).getWitnessProgram().length + 11; + } + throw new IllegalArgumentException("Unknown output size: " + address); } - private static int getSnapshotHeight(int genesisHeight, int height, int grid) { - return getSnapshotHeight(genesisHeight, height, grid, MIN_SNAPSHOT_HEIGHT); + private static int getSnapshotHeight(int genesisHeight, int height) { + return getSnapshotHeight(genesisHeight, height, 10, MIN_SNAPSHOT_HEIGHT); } // Borrowed from DaoStateSnapshotService. We prefer to not reuse to avoid dependency to an unrelated domain. diff --git a/core/src/main/java/bisq/core/offer/OfferFilterService.java b/core/src/main/java/bisq/core/offer/OfferFilterService.java index 11a8877fe62..7f7cfb8017a 100644 --- a/core/src/main/java/bisq/core/offer/OfferFilterService.java +++ b/core/src/main/java/bisq/core/offer/OfferFilterService.java @@ -138,7 +138,7 @@ public boolean isAnyPaymentAccountValidForOffer(Offer offer) { } public boolean hasSameProtocolVersion(Offer offer) { - return offer.getProtocolVersion() == Version.TRADE_PROTOCOL_VERSION; + return offer.getProtocolVersion() == Version.getTradeProtocolVersion(); } public boolean isIgnored(Offer offer) { diff --git a/core/src/main/java/bisq/core/offer/OpenOfferManager.java b/core/src/main/java/bisq/core/offer/OpenOfferManager.java index 3e972df040f..176ac22a0d5 100644 --- a/core/src/main/java/bisq/core/offer/OpenOfferManager.java +++ b/core/src/main/java/bisq/core/offer/OpenOfferManager.java @@ -967,7 +967,8 @@ private void maybeUpdatePersistedOffers() { // Capability.REFUND_AGENT in v1.2.0 and want to rewrite a // persisted offer after the user has updated to 1.2.0 so their offer will be accepted by the network. - if (original.getProtocolVersion() < Version.TRADE_PROTOCOL_VERSION || + int tradeProtocolVersion = Version.getTradeProtocolVersion(); + if (original.getProtocolVersion() < tradeProtocolVersion || !OfferRestrictions.hasOfferMandatoryCapability(originalOffer, Capability.MEDIATION) || !OfferRestrictions.hasOfferMandatoryCapability(originalOffer, Capability.REFUND_AGENT) || !original.getOwnerNodeAddress().equals(p2PService.getAddress())) { @@ -993,9 +994,9 @@ private void maybeUpdatePersistedOffers() { // - Protocol version changed? int protocolVersion = original.getProtocolVersion(); - if (protocolVersion < Version.TRADE_PROTOCOL_VERSION) { + if (protocolVersion < tradeProtocolVersion) { // We update the trade protocol version - protocolVersion = Version.TRADE_PROTOCOL_VERSION; + protocolVersion = tradeProtocolVersion; log.info("Updated the protocol version of offer id={}", originalOffer.getId()); } diff --git a/core/src/main/java/bisq/core/offer/bisq_v1/CreateOfferService.java b/core/src/main/java/bisq/core/offer/bisq_v1/CreateOfferService.java index 8288ef92678..c4be42190bf 100644 --- a/core/src/main/java/bisq/core/offer/bisq_v1/CreateOfferService.java +++ b/core/src/main/java/bisq/core/offer/bisq_v1/CreateOfferService.java @@ -217,7 +217,7 @@ public Offer createAndGetOffer(String offerId, isPrivateOffer, hashOfChallenge, extraDataMap, - Version.TRADE_PROTOCOL_VERSION); + Version.getTradeProtocolVersion()); Offer offer = new Offer(offerPayload); offer.setPriceFeedService(priceFeedService); return offer; diff --git a/core/src/main/java/bisq/core/offer/bsq_swap/OpenBsqSwapOfferService.java b/core/src/main/java/bisq/core/offer/bsq_swap/OpenBsqSwapOfferService.java index d2710f8fe63..092cc01f490 100644 --- a/core/src/main/java/bisq/core/offer/bsq_swap/OpenBsqSwapOfferService.java +++ b/core/src/main/java/bisq/core/offer/bsq_swap/OpenBsqSwapOfferService.java @@ -220,7 +220,7 @@ public void requestNewOffer(String offerId, proofOfWork, null, Version.VERSION, - Version.TRADE_PROTOCOL_VERSION); + Version.getTradeProtocolVersion()); resultHandler.accept(new Offer(bsqSwapOfferPayload)); }); }); diff --git a/core/src/main/java/bisq/core/proto/network/CoreNetworkProtoResolver.java b/core/src/main/java/bisq/core/proto/network/CoreNetworkProtoResolver.java index 01695030946..5550e5f238b 100644 --- a/core/src/main/java/bisq/core/proto/network/CoreNetworkProtoResolver.java +++ b/core/src/main/java/bisq/core/proto/network/CoreNetworkProtoResolver.java @@ -66,6 +66,10 @@ import bisq.core.trade.protocol.bisq_v1.messages.RefreshTradeStateRequest; import bisq.core.trade.protocol.bisq_v1.messages.ShareBuyerPaymentAccountMessage; import bisq.core.trade.protocol.bisq_v1.messages.TraderSignedWitnessMessage; +import bisq.core.trade.protocol.bisq_v5.messages.DepositTxAndSellerPaymentAccountMessage; +import bisq.core.trade.protocol.bisq_v5.messages.InputsForDepositTxResponse_v5; +import bisq.core.trade.protocol.bisq_v5.messages.PreparedTxBuyerSignaturesMessage; +import bisq.core.trade.protocol.bisq_v5.messages.PreparedTxBuyerSignaturesRequest; import bisq.core.trade.protocol.bsq_swap.messages.BsqSwapFinalizeTxRequest; import bisq.core.trade.protocol.bsq_swap.messages.BsqSwapFinalizedTxMessage; import bisq.core.trade.protocol.bsq_swap.messages.BsqSwapTxInputsMessage; @@ -264,9 +268,19 @@ public NetworkEnvelope fromProto(protobuf.NetworkEnvelope proto) throws Protobuf case NEW_ACCOUNTING_BLOCK_BROADCAST_MESSAGE: return NewAccountingBlockBroadcastMessage.fromProto(proto.getNewAccountingBlockBroadcastMessage(), messageVersion); + // Trade protocol v5 messages + case INPUTS_FOR_DEPOSIT_TX_RESPONSE_V_5: + return InputsForDepositTxResponse_v5.fromProto(proto.getInputsForDepositTxResponseV5(), messageVersion); + case PREPARED_TX_BUYER_SIGNATURES_REQUEST: + return PreparedTxBuyerSignaturesRequest.fromProto(proto.getPreparedTxBuyerSignaturesRequest(), messageVersion); + case PREPARED_TX_BUYER_SIGNATURES_MESSAGE: + return PreparedTxBuyerSignaturesMessage.fromProto(proto.getPreparedTxBuyerSignaturesMessage(), messageVersion); + case DEPOSIT_TX_AND_SELLER_PAYMENT_ACCOUNT_MESSAGE: + return DepositTxAndSellerPaymentAccountMessage.fromProto(proto.getDepositTxAndSellerPaymentAccountMessage(), this, messageVersion); + default: throw new ProtobufferException("Unknown proto message case (PB.NetworkEnvelope). messageCase=" + - proto.getMessageCase() + "; proto raw data=" + proto.toString()); + proto.getMessageCase() + "; proto raw data=" + proto); } } else { log.error("PersistableEnvelope.fromProto: PB.NetworkEnvelope is null"); @@ -283,7 +297,7 @@ public NetworkPayload fromProto(protobuf.StorageEntryWrapper proto) { return ProtectedStorageEntry.fromProto(proto.getProtectedStorageEntry(), this); default: throw new ProtobufferRuntimeException("Unknown proto message case(PB.StorageEntryWrapper). " + - "messageCase=" + proto.getMessageCase() + "; proto raw data=" + proto.toString()); + "messageCase=" + proto.getMessageCase() + "; proto raw data=" + proto); } } else { log.error("PersistableEnvelope.fromProto: PB.StorageEntryWrapper is null"); @@ -314,7 +328,7 @@ public NetworkPayload fromProto(protobuf.StoragePayload proto) { return TempProposalPayload.fromProto(proto.getTempProposalPayload()); default: throw new ProtobufferRuntimeException("Unknown proto message case (PB.StoragePayload). messageCase=" - + proto.getMessageCase() + "; proto raw data=" + proto.toString()); + + proto.getMessageCase() + "; proto raw data=" + proto); } } else { log.error("PersistableEnvelope.fromProto: PB.StoragePayload is null"); diff --git a/core/src/main/java/bisq/core/provider/MempoolHttpClient.java b/core/src/main/java/bisq/core/provider/MempoolHttpClient.java index 5a36f023da4..61a605f8cd3 100644 --- a/core/src/main/java/bisq/core/provider/MempoolHttpClient.java +++ b/core/src/main/java/bisq/core/provider/MempoolHttpClient.java @@ -22,6 +22,8 @@ import bisq.common.app.Version; +import org.bitcoinj.core.Sha256Hash; + import javax.inject.Inject; import javax.inject.Singleton; @@ -41,7 +43,7 @@ public MempoolHttpClient(@Nullable Socks5ProxyProvider socks5ProxyProvider) { // returns JSON of the transaction details public String getTxDetails(String txId) throws IOException { super.shutDown(); // close any prior incomplete request - String api = "/" + txId; + String api = "/" + Sha256Hash.wrap(txId); return get(api, "User-Agent", "bisq/" + Version.VERSION); } @@ -50,7 +52,7 @@ public CompletableFuture requestTxAsHex(String txId) { super.shutDown(); // close any prior incomplete request return CompletableFuture.supplyAsync(() -> { - String api = "/" + txId + "/hex"; + String api = "/" + Sha256Hash.wrap(txId) + "/hex"; try { return get(api, "User-Agent", "bisq/" + Version.VERSION); } catch (IOException e) { diff --git a/core/src/main/java/bisq/core/support/dispute/Dispute.java b/core/src/main/java/bisq/core/support/dispute/Dispute.java index ec8a3e74564..dacc0491ab8 100644 --- a/core/src/main/java/bisq/core/support/dispute/Dispute.java +++ b/core/src/main/java/bisq/core/support/dispute/Dispute.java @@ -151,6 +151,14 @@ public static protobuf.Dispute.State toProtoMessage(Dispute.State state) { @Setter private long tradeTxFee; + // Added for v5 protocol + @Setter + @Nullable + private String warningTxId; + @Setter + @Nullable + private String redirectTxId; + // Should be only used in emergency case if we need to add data but do not want to break backward compatibility // at the P2P network storage checks. The hash of the object will be used to verify if the data is valid. Any new @@ -282,12 +290,14 @@ public protobuf.Dispute toProtoMessage() { Optional.ofNullable(disputePayoutTxId).ifPresent(builder::setDisputePayoutTxId); Optional.ofNullable(makerContractSignature).ifPresent(builder::setMakerContractSignature); Optional.ofNullable(takerContractSignature).ifPresent(builder::setTakerContractSignature); - Optional.ofNullable(disputeResultProperty.get()).ifPresent(result -> builder.setDisputeResult(disputeResultProperty.get().toProtoMessage())); - Optional.ofNullable(supportType).ifPresent(result -> builder.setSupportType(SupportType.toProtoMessage(supportType))); - Optional.ofNullable(mediatorsDisputeResult).ifPresent(result -> builder.setMediatorsDisputeResult(mediatorsDisputeResult)); - Optional.ofNullable(delayedPayoutTxId).ifPresent(result -> builder.setDelayedPayoutTxId(delayedPayoutTxId)); - Optional.ofNullable(donationAddressOfDelayedPayoutTx).ifPresent(result -> builder.setDonationAddressOfDelayedPayoutTx(donationAddressOfDelayedPayoutTx)); + Optional.ofNullable(disputeResultProperty.get()).ifPresent(e -> builder.setDisputeResult(e.toProtoMessage())); + Optional.ofNullable(supportType).ifPresent(e -> builder.setSupportType(SupportType.toProtoMessage(e))); + Optional.ofNullable(mediatorsDisputeResult).ifPresent(builder::setMediatorsDisputeResult); + Optional.ofNullable(delayedPayoutTxId).ifPresent(builder::setDelayedPayoutTxId); + Optional.ofNullable(donationAddressOfDelayedPayoutTx).ifPresent(builder::setDonationAddressOfDelayedPayoutTx); Optional.ofNullable(getExtraDataMap()).ifPresent(builder::putAllExtraData); + Optional.ofNullable(warningTxId).ifPresent(builder::setWarningTxId); + Optional.ofNullable(redirectTxId).ifPresent(builder::setRedirectTxId); return builder.build(); } @@ -320,28 +330,21 @@ public static Dispute fromProto(protobuf.Dispute proto, CoreProtoResolver corePr .map(ChatMessage::fromPayloadProto) .collect(Collectors.toList())); - if (proto.hasDisputeResult()) + if (proto.hasDisputeResult()) { dispute.disputeResultProperty.set(DisputeResult.fromProto(proto.getDisputeResult())); - dispute.disputePayoutTxId = ProtoUtil.stringOrNullFromProto(proto.getDisputePayoutTxId()); - - String mediatorsDisputeResult = proto.getMediatorsDisputeResult(); - if (!mediatorsDisputeResult.isEmpty()) { - dispute.setMediatorsDisputeResult(mediatorsDisputeResult); - } - - String delayedPayoutTxId = proto.getDelayedPayoutTxId(); - if (!delayedPayoutTxId.isEmpty()) { - dispute.setDelayedPayoutTxId(delayedPayoutTxId); } + dispute.disputePayoutTxId = ProtoUtil.stringOrNullFromProto(proto.getDisputePayoutTxId()); - String donationAddressOfDelayedPayoutTx = proto.getDonationAddressOfDelayedPayoutTx(); - if (!donationAddressOfDelayedPayoutTx.isEmpty()) { - dispute.setDonationAddressOfDelayedPayoutTx(donationAddressOfDelayedPayoutTx); - } + dispute.setMediatorsDisputeResult(ProtoUtil.stringOrNullFromProto(proto.getMediatorsDisputeResult())); + dispute.setDelayedPayoutTxId(ProtoUtil.stringOrNullFromProto(proto.getDelayedPayoutTxId())); + dispute.setDonationAddressOfDelayedPayoutTx(ProtoUtil.stringOrNullFromProto(proto.getDonationAddressOfDelayedPayoutTx())); dispute.setBurningManSelectionHeight(proto.getBurningManSelectionHeight()); dispute.setTradeTxFee(proto.getTradeTxFee()); + dispute.setWarningTxId(ProtoUtil.stringOrNullFromProto(proto.getWarningTxId())); + dispute.setRedirectTxId(ProtoUtil.stringOrNullFromProto(proto.getRedirectTxId())); + if (Dispute.State.fromProto(proto.getState()) == State.NEEDS_UPGRADE) { // old disputes did not have a state field, so choose an appropriate state: dispute.setState(proto.getIsClosed() ? State.CLOSED : State.OPEN); @@ -387,7 +390,7 @@ public void maybeClearSensitiveData() { if (contract.maybeClearSensitiveData()) { change += "contract;"; } - String edited = contract.sanitizeContractAsJson(contractAsJson); + String edited = Contract.sanitizeContractAsJson(contractAsJson); if (!edited.equals(contractAsJson)) { contractAsJson = edited; change += "contractAsJson;"; @@ -571,6 +574,8 @@ public String toString() { ",\n cachedDepositTx='" + cachedDepositTx + '\'' + ",\n burningManSelectionHeight='" + burningManSelectionHeight + '\'' + ",\n tradeTxFee='" + tradeTxFee + '\'' + + ",\n warningTxId='" + warningTxId + '\'' + + ",\n redirectTxId='" + redirectTxId + '\'' + "\n}"; } } diff --git a/core/src/main/java/bisq/core/support/dispute/DisputeManager.java b/core/src/main/java/bisq/core/support/dispute/DisputeManager.java index 8e1e43be872..882d2560b30 100644 --- a/core/src/main/java/bisq/core/support/dispute/DisputeManager.java +++ b/core/src/main/java/bisq/core/support/dispute/DisputeManager.java @@ -324,9 +324,9 @@ protected void checkForMediatedTradePayout(Trade trade, Dispute dispute) { disputedTradeUpdate(newValue.toString(), dispute, false); } }); - // user rejected mediation after lockup period: opening arbitration + // user rejected mediation after lockup period: opening arbitration after peer redirects trade.disputeStateProperty().addListener((observable, oldValue, newValue) -> { - if (newValue.isArbitrated()) { + if (newValue.isEscalated()) { disputedTradeUpdate(newValue.toString(), dispute, true); } }); @@ -357,7 +357,7 @@ public void maybeClearSensitiveData() { log.info("{} checking closed disputes eligibility for having sensitive data cleared", super.getClass().getSimpleName()); Instant safeDate = closedTradableManager.getSafeDateForSensitiveDataClearing(); getDisputeList().getList().stream() - .filter(e -> e.isClosed()) + .filter(Dispute::isClosed) .filter(e -> e.getOpeningDate().toInstant().isBefore(safeDate)) .forEach(Dispute::maybeClearSensitiveData); requestPersistence(); @@ -453,28 +453,34 @@ protected void onOpenNewDisputeMessage(OpenNewDisputeMessage openNewDisputeMessa protected void onPeerOpenedDisputeMessage(PeerOpenedDisputeMessage peerOpenedDisputeMessage) { Dispute dispute = peerOpenedDisputeMessage.getDispute(); tradeManager.getTradeById(dispute.getTradeId()).ifPresentOrElse( - trade -> peerOpenedDisputeForTrade(peerOpenedDisputeMessage, dispute, trade), - () -> closedTradableManager.getTradableById(dispute.getTradeId()).ifPresentOrElse( - closedTradable -> newDisputeRevertsClosedTrade(peerOpenedDisputeMessage, dispute, (Trade)closedTradable), - () -> failedTradesManager.getTradeById(dispute.getTradeId()).ifPresent( - trade -> newDisputeRevertsFailedTrade(peerOpenedDisputeMessage, dispute, trade)))); + trade -> peerOpenedDisputeForTrade(peerOpenedDisputeMessage, dispute, trade), + () -> closedTradableManager.getTradableById(dispute.getTradeId()).ifPresentOrElse( + closedTradable -> newDisputeRevertsClosedTrade(peerOpenedDisputeMessage, dispute, (Trade) closedTradable), + () -> failedTradesManager.getTradeById(dispute.getTradeId()).ifPresent( + trade -> newDisputeRevertsFailedTrade(peerOpenedDisputeMessage, dispute, trade)))); } - private void newDisputeRevertsFailedTrade(PeerOpenedDisputeMessage peerOpenedDisputeMessage, Dispute dispute, Trade trade) { + private void newDisputeRevertsFailedTrade(PeerOpenedDisputeMessage peerOpenedDisputeMessage, + Dispute dispute, + Trade trade) { log.info("Peer dispute ticket received, reverting failed trade {} to pending", trade.getShortId()); failedTradesManager.removeTrade(trade); tradeManager.addTradeToPendingTrades(trade); peerOpenedDisputeForTrade(peerOpenedDisputeMessage, dispute, trade); } - private void newDisputeRevertsClosedTrade(PeerOpenedDisputeMessage peerOpenedDisputeMessage, Dispute dispute, Trade trade) { + private void newDisputeRevertsClosedTrade(PeerOpenedDisputeMessage peerOpenedDisputeMessage, + Dispute dispute, + Trade trade) { log.info("Peer dispute ticket received, reverting closed trade {} to pending", trade.getShortId()); closedTradableManager.remove(trade); tradeManager.addTradeToPendingTrades(trade); peerOpenedDisputeForTrade(peerOpenedDisputeMessage, dispute, trade); } - private void peerOpenedDisputeForTrade(PeerOpenedDisputeMessage peerOpenedDisputeMessage, Dispute dispute, Trade trade) { + private void peerOpenedDisputeForTrade(PeerOpenedDisputeMessage peerOpenedDisputeMessage, + Dispute dispute, + Trade trade) { String errorMessage = null; T disputeList = getDisputeList(); if (disputeList == null) { @@ -485,7 +491,7 @@ private void peerOpenedDisputeForTrade(PeerOpenedDisputeMessage peerOpenedDisput try { DisputeValidation.validateDisputeData(dispute, btcWalletService); DisputeValidation.validateNodeAddresses(dispute, config); - DisputeValidation.validateTradeAndDispute(dispute, trade); + DisputeValidation.validateTradeAndDispute(dispute, trade, btcWalletService); TradeDataValidation.validateDelayedPayoutTx(trade, trade.getDelayedPayoutTx(), btcWalletService); @@ -707,6 +713,8 @@ private void doSendPeerOpenedDisputeMessage(Dispute disputeFromOpener, dispute.setDonationAddressOfDelayedPayoutTx(disputeFromOpener.getDonationAddressOfDelayedPayoutTx()); dispute.setBurningManSelectionHeight(disputeFromOpener.getBurningManSelectionHeight()); dispute.setTradeTxFee(disputeFromOpener.getTradeTxFee()); + dispute.setWarningTxId(disputeFromOpener.getWarningTxId()); + dispute.setRedirectTxId(disputeFromOpener.getRedirectTxId()); Optional storedDisputeOptional = findDispute(dispute); diff --git a/core/src/main/java/bisq/core/support/dispute/DisputeValidation.java b/core/src/main/java/bisq/core/support/dispute/DisputeValidation.java index bbb4ac0cb3f..581495d6c27 100644 --- a/core/src/main/java/bisq/core/support/dispute/DisputeValidation.java +++ b/core/src/main/java/bisq/core/support/dispute/DisputeValidation.java @@ -31,26 +31,31 @@ import bisq.common.crypto.CryptoException; import bisq.common.crypto.Hash; import bisq.common.crypto.Sig; -import bisq.common.util.Tuple3; import org.bitcoinj.core.Address; import org.bitcoinj.core.NetworkParameters; import org.bitcoinj.core.Transaction; import org.bitcoinj.core.TransactionOutput; +import com.google.common.base.CaseFormat; +import com.google.common.collect.HashMultimap; +import com.google.common.collect.SetMultimap; + import java.util.Arrays; -import java.util.HashMap; -import java.util.HashSet; +import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Consumer; +import java.util.function.Function; import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import javax.annotation.Nullable; + import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; @@ -93,20 +98,42 @@ public static void validateDisputeData(Dispute dispute, } } - public static void validateTradeAndDispute(Dispute dispute, Trade trade) + public static void validateTradeAndDispute(Dispute dispute, Trade trade, BtcWalletService btcWalletService) throws ValidationException { try { checkArgument(dispute.getContract().equals(trade.getContract()), "contract must match contract from trade"); - checkNotNull(trade.getDelayedPayoutTx(), "trade.getDelayedPayoutTx() must not be null"); - checkNotNull(dispute.getDelayedPayoutTxId(), "delayedPayoutTxId must not be null"); - checkArgument(dispute.getDelayedPayoutTxId().equals(trade.getDelayedPayoutTx().getTxId().toString()), - "delayedPayoutTxId must match delayedPayoutTxId from trade"); + if (!trade.hasV5Protocol()) { + checkNotNull(trade.getDelayedPayoutTx(), "trade.getDelayedPayoutTx() must not be null"); + checkNotNull(dispute.getDelayedPayoutTxId(), "delayedPayoutTxId must not be null"); + checkArgument(dispute.getDelayedPayoutTxId().equals(trade.getDelayedPayoutTx().getTxId().toString()), + "delayedPayoutTxId must match delayedPayoutTxId from trade"); + } else if (dispute.getSupportType() == SupportType.REFUND) { + String buyersWarningTxId = toTxId(trade.getBuyersWarningTx(btcWalletService)); + String sellersWarningTxId = toTxId(trade.getSellersWarningTx(btcWalletService)); + String buyersRedirectTxId = toTxId(trade.getBuyersRedirectTx(btcWalletService)); + String sellersRedirectTxId = toTxId(trade.getSellersRedirectTx(btcWalletService)); + checkNotNull(dispute.getWarningTxId(), "warningTxId must not be null"); + checkArgument(Arrays.asList(buyersWarningTxId, sellersWarningTxId).contains(dispute.getWarningTxId()), + "warningTxId must match either buyer's or seller's warningTxId from trade"); + checkNotNull(dispute.getRedirectTxId(), "redirectTxId must not be null"); + checkArgument(Arrays.asList(buyersRedirectTxId, sellersRedirectTxId).contains(dispute.getRedirectTxId()), + "redirectTxId must match either buyer's or seller's redirectTxId from trade"); + boolean isBuyerWarning = dispute.getWarningTxId().equals(buyersWarningTxId); + boolean isBuyerRedirect = dispute.getRedirectTxId().equals(buyersRedirectTxId); + if (isBuyerWarning) { + checkArgument(!isBuyerRedirect, "buyer's redirectTx must be used with seller's warningTx"); + } else { + checkArgument(isBuyerRedirect, "seller's redirectTx must be used with buyer's warningTx"); + } + } else { + checkArgument(dispute.getDelayedPayoutTxId() == null, "delayedPayoutTxId should be null"); + } + checkTxIdFieldCombination(dispute); checkNotNull(trade.getDepositTx(), "trade.getDepositTx() must not be null"); - checkNotNull(dispute.getDepositTxId(), "depositTxId must not be null"); - checkArgument(dispute.getDepositTxId().equals(trade.getDepositTx().getTxId().toString()), + checkArgument(trade.getDepositTx().getTxId().toString().equals(dispute.getDepositTxId()), "depositTx must match depositTx from trade"); checkNotNull(dispute.getDepositTxSerialized(), "depositTxSerialized must not be null"); @@ -115,6 +142,11 @@ public static void validateTradeAndDispute(Dispute dispute, Trade trade) } } + @Nullable + private static String toTxId(@Nullable Transaction tx) { + return tx != null ? tx.getTxId().toString() : null; + } + public static void validateSenderNodeAddress(Dispute dispute, NodeAddress senderNodeAddress) throws NodeAddressException { @@ -178,18 +210,10 @@ public static void validateDonationAddress(Dispute dispute, public static void testIfAnyDisputeTriedReplay(List disputeList, Consumer exceptionHandler) { - var tuple = getTestReplayHashMaps(disputeList); - Map> disputesPerTradeId = tuple.first; - Map> disputesPerDelayedPayoutTxId = tuple.second; - Map> disputesPerDepositTxId = tuple.third; - + var map = getTestReplayMultimaps(disputeList); disputeList.forEach(disputeToTest -> { try { - testIfDisputeTriesReplay(disputeToTest, - disputesPerTradeId, - disputesPerDelayedPayoutTxId, - disputesPerDepositTxId); - + testIfDisputeTriesReplay(disputeToTest, map); } catch (DisputeReplayException e) { exceptionHandler.accept(e); } @@ -198,96 +222,93 @@ public static void testIfAnyDisputeTriedReplay(List disputeList, public static void testIfDisputeTriesReplay(Dispute dispute, List disputeList) throws DisputeReplayException { - var tuple = getTestReplayHashMaps(disputeList); - Map> disputesPerTradeId = tuple.first; - Map> disputesPerDelayedPayoutTxId = tuple.second; - Map> disputesPerDepositTxId = tuple.third; - - testIfDisputeTriesReplay(dispute, - disputesPerTradeId, - disputesPerDelayedPayoutTxId, - disputesPerDepositTxId); + testIfDisputeTriesReplay(dispute, getTestReplayMultimaps(disputeList)); } - private static Tuple3>, Map>, Map>> getTestReplayHashMaps( - List disputeList) { - Map> disputesPerTradeId = new HashMap<>(); - Map> disputesPerDelayedPayoutTxId = new HashMap<>(); - Map> disputesPerDepositTxId = new HashMap<>(); + private static Map> getTestReplayMultimaps(List disputeList) { + Map> disputesPerIdMap = new EnumMap<>(DisputeIdField.class); disputeList.forEach(dispute -> { - String uid = dispute.getUid(); - - String tradeId = dispute.getTradeId(); - disputesPerTradeId.putIfAbsent(tradeId, new HashSet<>()); - Set set = disputesPerTradeId.get(tradeId); - set.add(uid); - - String delayedPayoutTxId = dispute.getDelayedPayoutTxId(); - if (delayedPayoutTxId != null) { - disputesPerDelayedPayoutTxId.putIfAbsent(delayedPayoutTxId, new HashSet<>()); - set = disputesPerDelayedPayoutTxId.get(delayedPayoutTxId); - set.add(uid); - } + String disputeUid = dispute.getUid(); - String depositTxId = dispute.getDepositTxId(); - if (depositTxId != null) { - disputesPerDepositTxId.putIfAbsent(depositTxId, new HashSet<>()); - set = disputesPerDepositTxId.get(depositTxId); - set.add(uid); + for (var field : DisputeIdField.values()) { + String id = field.apply(dispute); + if (id != null) { + disputesPerIdMap.computeIfAbsent(field, k -> HashMultimap.create()).put(id, disputeUid); + } } }); - return new Tuple3<>(disputesPerTradeId, disputesPerDelayedPayoutTxId, disputesPerDepositTxId); + return disputesPerIdMap; } private static void testIfDisputeTriesReplay(Dispute disputeToTest, - Map> disputesPerTradeId, - Map> disputesPerDelayedPayoutTxId, - Map> disputesPerDepositTxId) + Map> disputesPerIdMap) throws DisputeReplayException { try { String disputeToTestTradeId = disputeToTest.getTradeId(); - String disputeToTestDelayedPayoutTxId = disputeToTest.getDelayedPayoutTxId(); - String disputeToTestDepositTxId = disputeToTest.getDepositTxId(); - String disputeToTestUid = disputeToTest.getUid(); - - // For pre v1.4.0 we do not get the delayed payout tx sent in mediation cases but in refund agent case we do. - // So until all users have updated to 1.4.0 we only check in refund agent case. With 1.4.0 we send the - // delayed payout tx also in mediation cases and that if check can be removed. - if (disputeToTest.getSupportType() == SupportType.REFUND) { - checkNotNull(disputeToTestDelayedPayoutTxId, - "Delayed payout transaction ID is null. " + - "Trade ID: " + disputeToTestTradeId); - } - checkNotNull(disputeToTestDepositTxId, - "depositTxId must not be null. Trade ID: " + disputeToTestTradeId); - checkNotNull(disputeToTestUid, - "agentsUid must not be null. Trade ID: " + disputeToTestTradeId); - - Set disputesPerTradeIdItems = disputesPerTradeId.get(disputeToTestTradeId); - checkArgument(disputesPerTradeIdItems != null && disputesPerTradeIdItems.size() <= 2, - "We found more then 2 disputes with the same trade ID. " + - "Trade ID: " + disputeToTestTradeId); - if (!disputesPerDelayedPayoutTxId.isEmpty()) { - Set disputesPerDelayedPayoutTxIdItems = disputesPerDelayedPayoutTxId.get(disputeToTestDelayedPayoutTxId); - checkArgument(disputesPerDelayedPayoutTxIdItems != null && disputesPerDelayedPayoutTxIdItems.size() <= 2, - "We found more then 2 disputes with the same delayedPayoutTxId. " + - "Trade ID: " + disputeToTestTradeId); - } - if (!disputesPerDepositTxId.isEmpty()) { - Set disputesPerDepositTxIdItems = disputesPerDepositTxId.get(disputeToTestDepositTxId); - checkArgument(disputesPerDepositTxIdItems != null && disputesPerDepositTxIdItems.size() <= 2, - "We found more then 2 disputes with the same depositTxId. " + - "Trade ID: " + disputeToTestTradeId); + + checkTxIdFieldCombination(disputeToTest); + checkNotNull(disputeToTest.getUid(), + "agentsUid must not be null. Trade ID: %s", disputeToTestTradeId); + + for (DisputeIdField field : disputesPerIdMap.keySet()) { + String id = field.apply(disputeToTest); + int numDisputesPerId = disputesPerIdMap.get(field).keys().count(id); + checkArgument(numDisputesPerId <= 2, + "We found more than 2 disputes with the same %s. " + + "Trade ID: %s", field, disputeToTestTradeId); } } catch (IllegalArgumentException e) { throw new DisputeReplayException(disputeToTest, e.getMessage()); } catch (NullPointerException e) { log.error("NullPointerException at testIfDisputeTriesReplay: " + - "disputeToTest={}, disputesPerTradeId={}, disputesPerDelayedPayoutTxId={}, " + - "disputesPerDepositTxId={}", - disputeToTest, disputesPerTradeId, disputesPerDelayedPayoutTxId, disputesPerDepositTxId); - throw new DisputeReplayException(disputeToTest, e.toString() + " at dispute " + disputeToTest.toString()); + "disputeToTest={}, disputesPerIdMap={}", disputeToTest, disputesPerIdMap); + throw new DisputeReplayException(disputeToTest, e + " at dispute " + disputeToTest); + } + } + + private static void checkTxIdFieldCombination(Dispute dispute) { + // For pre v1.4.0 we do not get the delayed payout tx sent in mediation cases but in refund agent case we do. + // With 1.4.0 we send the delayed payout tx also in mediation cases. For v5 protocol trades, there is no DPT + // and it is unknown which staged txs will be published, if any, so they are only sent in refund agent cases. + String tradeId = dispute.getTradeId(); + if (dispute.getWarningTxId() != null || dispute.getRedirectTxId() != null) { + checkNotNull(dispute.getWarningTxId(), "warningTxId must not be null. Trade ID: %s", tradeId); + checkNotNull(dispute.getRedirectTxId(), "redirectTxId must not be null. Trade ID: %s", tradeId); + checkArgument(dispute.getDelayedPayoutTxId() == null, + "delayedPayoutTxId should be null. Trade ID: %s", tradeId); + checkArgument(!dispute.isUsingLegacyBurningMan(), + "Legacy BM use not permitted. Trade ID: %s", tradeId); + checkArgument(dispute.getSupportType() == SupportType.REFUND, + "Mediation not permitted after staged txs published. Trade ID: %s", tradeId); + } else if (dispute.getSupportType() == SupportType.REFUND) { + checkNotNull(dispute.getDelayedPayoutTxId(), + "delayedPayoutTxId must not be null. Trade ID: %s", tradeId); + } + checkNotNull(dispute.getDepositTxId(), "depositTxId must not be null. Trade ID: %s", tradeId); + } + + private enum DisputeIdField implements Function { + TRADE_ID(Dispute::getTradeId), + DELAYED_PAYOUT_TX_ID(Dispute::getDelayedPayoutTxId), + WARNING_TX_ID(Dispute::getWarningTxId), + REDIRECT_TX_ID(Dispute::getRedirectTxId), + DEPOSIT_TX_ID(Dispute::getDepositTxId); + + private final Function getter; + + DisputeIdField(Function getter) { + this.getter = getter; + } + + @Override + public String apply(Dispute dispute) { + return getter.apply(dispute); + } + + @Override + public String toString() { + return CaseFormat.UPPER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL, name()); } } diff --git a/core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java b/core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java index 800deb88f31..62302127143 100644 --- a/core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java +++ b/core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java @@ -40,7 +40,9 @@ import bisq.core.trade.ClosedTradableManager; import bisq.core.trade.TradeManager; import bisq.core.trade.bisq_v1.FailedTradesManager; +import bisq.core.trade.model.bisq_v1.Contract; import bisq.core.trade.model.bisq_v1.Trade; +import bisq.core.trade.protocol.bisq_v5.model.StagedPayoutTxParameters; import bisq.network.p2p.AckMessageSourceType; import bisq.network.p2p.NodeAddress; @@ -54,6 +56,7 @@ import bisq.common.util.Hex; import bisq.common.util.Tuple2; +import org.bitcoinj.core.Coin; import org.bitcoinj.core.NetworkParameters; import org.bitcoinj.core.Transaction; import org.bitcoinj.core.TransactionInput; @@ -64,6 +67,7 @@ import com.google.inject.Singleton; import java.util.ArrayList; +import java.util.Iterator; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -262,10 +266,7 @@ public NodeAddress getAgentNodeAddress(Dispute dispute) { return dispute.getContract().getRefundAgentNodeAddress(); } - public CompletableFuture> requestBlockchainTransactions(String makerFeeTxId, - String takerFeeTxId, - String depositTxId, - String delayedPayoutTxId) { + public CompletableFuture> requestBlockchainTransactions(List txIds) { // in regtest mode, simulate a delay & failure obtaining the blockchain transactions // since we cannot request them in regtest anyway. this is useful for checking failure scenarios if (!Config.baseCurrencyNetwork().isMainnet()) { @@ -276,28 +277,28 @@ public CompletableFuture> requestBlockchainTransactions(String NetworkParameters params = btcWalletService.getParams(); List txs = new ArrayList<>(); - return mempoolService.requestTxAsHex(makerFeeTxId) - .thenCompose(txAsHex -> { - txs.add(new Transaction(params, Hex.decode(txAsHex))); - return mempoolService.requestTxAsHex(takerFeeTxId); - }).thenCompose(txAsHex -> { - txs.add(new Transaction(params, Hex.decode(txAsHex))); - return mempoolService.requestTxAsHex(depositTxId); - }).thenCompose(txAsHex -> { - txs.add(new Transaction(params, Hex.decode(txAsHex))); - return mempoolService.requestTxAsHex(delayedPayoutTxId); - }) - .thenApply(txAsHex -> { - txs.add(new Transaction(params, Hex.decode(txAsHex))); - return txs; - }); + Iterator txIdIterator = txIds.iterator(); + if (!txIdIterator.hasNext()) { + return CompletableFuture.completedFuture(txs); + } + CompletableFuture future = mempoolService.requestTxAsHex(txIdIterator.next()); + while (txIdIterator.hasNext()) { + String txId = txIdIterator.next(); + future = future.thenCompose(txAsHex -> { + txs.add(new Transaction(params, Hex.decode(txAsHex))); + return mempoolService.requestTxAsHex(txId); + }); + } + return future.thenApply(txAsHex -> { + txs.add(new Transaction(params, Hex.decode(txAsHex))); + return txs; + }); } public void verifyTradeTxChain(List txs) { Transaction makerFeeTx = txs.get(0); Transaction takerFeeTx = txs.get(1); Transaction depositTx = txs.get(2); - Transaction delayedPayoutTx = txs.get(3); // The order and number of buyer and seller inputs are not part of the trade protocol consensus. // In the current implementation buyer inputs come before seller inputs at depositTx and there is @@ -316,41 +317,120 @@ public void verifyTradeTxChain(List txs) { } checkArgument(makerFeeTxFoundAtInputs, "makerFeeTx not found at depositTx inputs"); checkArgument(takerFeeTxFoundAtInputs, "takerFeeTx not found at depositTx inputs"); - checkArgument(depositTx.getInputs().size() >= 2, - "DepositTx must have at least 2 inputs"); - checkArgument(delayedPayoutTx.getInputs().size() == 1, - "DelayedPayoutTx must have 1 input"); - TransactionOutPoint delayedPayoutTxInputOutpoint = delayedPayoutTx.getInputs().get(0).getOutpoint(); - String fundingTxId = delayedPayoutTxInputOutpoint.getHash().toString(); - checkArgument(fundingTxId.equals(depositTx.getTxId().toString()), - "First input at delayedPayoutTx does not connect to depositTx"); + checkArgument(depositTx.getInputs().size() >= 2, "depositTx must have at least 2 inputs"); + if (txs.size() == 4) { + Transaction delayedPayoutTx = txs.get(3); + checkArgument(delayedPayoutTx.getInputs().size() == 1, "delayedPayoutTx must have 1 input"); + checkArgument(firstOutputConnectsToFirstInput(depositTx, delayedPayoutTx), + "First input at delayedPayoutTx does not connect to depositTx"); + } else { + Transaction warningTx = txs.get(3); + Transaction redirectTx = txs.get(4); + + checkArgument(warningTx.getInputs().size() == 1, "warningTx must have 1 input"); + checkArgument(warningTx.getOutputs().size() == 2, "warningTx must have 2 outputs"); + checkArgument(warningTx.getOutput(1).getValue().value == + StagedPayoutTxParameters.WARNING_TX_FEE_BUMP_OUTPUT_VALUE, + "Second warningTx output is wrong amount for a fee bump output"); + + checkArgument(redirectTx.getInputs().size() == 1, "redirectTx must have 1 input"); + int numReceivers = redirectTx.getOutputs().size() - 1; + checkArgument(redirectTx.getOutput(numReceivers).getValue().value == + StagedPayoutTxParameters.REDIRECT_TX_FEE_BUMP_OUTPUT_VALUE, + "Last redirectTx output is wrong amount for a fee bump output"); + + checkArgument(firstOutputConnectsToFirstInput(depositTx, warningTx), + "First input at warningTx does not connect to depositTx"); + checkArgument(firstOutputConnectsToFirstInput(warningTx, redirectTx), + "First input at redirectTx does not connect to warningTx"); + } + } + + private static boolean firstOutputConnectsToFirstInput(Transaction parent, Transaction child) { + TransactionOutPoint childTxInputOutpoint = child.getInput(0).getOutpoint(); + String fundingTxId = childTxInputOutpoint.getHash().toString(); + return fundingTxId.equals(parent.getTxId().toString()); + } + + public void verifyDelayedPayoutTxReceivers(Transaction depositTx, Transaction delayedPayoutTx, Dispute dispute) { + if (dispute.isUsingLegacyBurningMan()) { + checkArgument(delayedPayoutTx.getOutputs().size() == 1, + "delayedPayoutTx must have 1 output when using legacy BM"); + NetworkParameters params = btcWalletService.getParams(); + TransactionOutput transactionOutput = delayedPayoutTx.getOutput(0); + String outputAddress = transactionOutput.getScriptPubKey().getToAddress(params).toString(); + checkArgument(outputAddress.equals(dispute.getDonationAddressOfDelayedPayoutTx()), + "Output address does not match donation address (%s). transactionOutput=%s", + dispute.getDonationAddressOfDelayedPayoutTx(), transactionOutput); + } else { + long inputAmount = depositTx.getOutput(0).getValue().value; + int selectionHeight = dispute.getBurningManSelectionHeight(); + + List> receivers = delayedPayoutTxReceiverService.getReceivers( + selectionHeight, + inputAmount, + dispute.getTradeTxFee(), + DelayedPayoutTxReceiverService.ReceiverFlag.flagsActivatedBy(dispute.getTradeDate())); + log.info("Verify delayedPayoutTx using selectionHeight {} and receivers {}", selectionHeight, receivers); + checkArgument(delayedPayoutTx.getOutputs().size() == receivers.size(), + "Number of outputs must equal number of receivers"); + checkOutputsPrefixMatchesReceivers(delayedPayoutTx, receivers); + } } - public void verifyDelayedPayoutTxReceivers(Transaction delayedPayoutTx, Dispute dispute) { - Transaction depositTx = dispute.findDepositTx(btcWalletService).orElseThrow(); - long inputAmount = depositTx.getOutput(0).getValue().value; + public void verifyRedirectTxReceivers(Transaction warningTx, Transaction redirectTx, Dispute dispute) { + checkArgument(!dispute.isUsingLegacyBurningMan(), "Legacy BM use not permitted with redirectTx"); + + long inputAmount = warningTx.getOutput(0).getValue().value; + long inputAmountMinusFeeBumpAmount = inputAmount - StagedPayoutTxParameters.REDIRECT_TX_FEE_BUMP_OUTPUT_VALUE; int selectionHeight = dispute.getBurningManSelectionHeight(); - boolean wasBugfix6699ActivatedAtTradeDate = dispute.getTradeDate().after(DelayedPayoutTxReceiverService.BUGFIX_6699_ACTIVATION_DATE); - boolean wasProposal412ActivatedAtTradeDate = dispute.getTradeDate().after(DelayedPayoutTxReceiverService.PROPOSAL_412_ACTIVATION_DATE); - List> delayedPayoutTxReceivers = delayedPayoutTxReceiverService.getReceivers( + List> receivers = delayedPayoutTxReceiverService.getReceivers( selectionHeight, - inputAmount, + inputAmountMinusFeeBumpAmount, dispute.getTradeTxFee(), - wasBugfix6699ActivatedAtTradeDate, - wasProposal412ActivatedAtTradeDate); - log.info("Verify delayedPayoutTx using selectionHeight {} and receivers {}", selectionHeight, delayedPayoutTxReceivers); - checkArgument(delayedPayoutTx.getOutputs().size() == delayedPayoutTxReceivers.size(), - "Size of outputs and delayedPayoutTxReceivers must be the same"); + StagedPayoutTxParameters.REDIRECT_TX_MIN_WEIGHT, + DelayedPayoutTxReceiverService.ReceiverFlag.flagsActivatedBy(dispute.getTradeDate())); + log.info("Verify redirectTx using selectionHeight {} and receivers {}", selectionHeight, receivers); + checkArgument(redirectTx.getOutputs().size() == receivers.size() + 1, + "Number of outputs must equal number of receivers plus 1"); + checkOutputsPrefixMatchesReceivers(redirectTx, receivers); + } + private void checkOutputsPrefixMatchesReceivers(Transaction delayedPayoutOrRedirectTx, + List> receivers) { NetworkParameters params = btcWalletService.getParams(); - for (int i = 0; i < delayedPayoutTx.getOutputs().size(); i++) { - TransactionOutput transactionOutput = delayedPayoutTx.getOutputs().get(i); - Tuple2 receiverTuple = delayedPayoutTxReceivers.get(0); + for (int i = 0; i < receivers.size(); i++) { + TransactionOutput transactionOutput = delayedPayoutOrRedirectTx.getOutput(i); + Tuple2 receiverTuple = receivers.get(i); checkArgument(transactionOutput.getScriptPubKey().getToAddress(params).toString().equals(receiverTuple.second), - "output address does not match delayedPayoutTxReceivers address. transactionOutput=" + transactionOutput); + "Output address does not match receiver address (%s). transactionOutput=%s, index=%s", + receiverTuple.second, transactionOutput, i); checkArgument(transactionOutput.getValue().value == receiverTuple.first, - "output value does not match delayedPayoutTxReceivers value. transactionOutput=" + transactionOutput); + "Output value does not match receiver value (%s). transactionOutput=%s, index=%s", + receiverTuple.first, transactionOutput, i); } } + + public void validateCollateralAndPayoutTotals(Transaction depositTx, + Transaction delayedPayoutOrRedirectTx, + Dispute dispute, + DisputeResult disputeResult) { + Contract contract = dispute.getContract(); + Coin expectedCollateral = contract.getTradeAmount() + .add(Coin.valueOf(contract.getOfferPayload().getBuyerSecurityDeposit())) + .add(Coin.valueOf(contract.getOfferPayload().getSellerSecurityDeposit())); + Coin depositOutputValue = depositTx.getOutput(0).getValue(); + + checkArgument(!depositOutputValue.isLessThan(expectedCollateral), + "First depositTx output value (%s) is less than expected trade collateral: %s sats", + depositOutputValue, expectedCollateral); + + Coin totalPayoutAmount = disputeResult.getBuyerPayoutAmount().add(disputeResult.getSellerPayoutAmount()); + Coin totalReceiverPlusPossibleFeeBumpOutputAmount = delayedPayoutOrRedirectTx.getOutputSum(); + + checkArgument(!totalReceiverPlusPossibleFeeBumpOutputAmount.isLessThan(totalPayoutAmount), + "DPT/redirectTx output sum (%s) is less than proposed refund of %s sats to traders", + totalReceiverPlusPossibleFeeBumpOutputAmount, totalPayoutAmount); + } } diff --git a/core/src/main/java/bisq/core/trade/TradeManager.java b/core/src/main/java/bisq/core/trade/TradeManager.java index a9224e4943f..f7c6acb0ef8 100644 --- a/core/src/main/java/bisq/core/trade/TradeManager.java +++ b/core/src/main/java/bisq/core/trade/TradeManager.java @@ -52,11 +52,11 @@ import bisq.core.trade.model.bsq_swap.BsqSwapSellerAsMakerTrade; import bisq.core.trade.model.bsq_swap.BsqSwapSellerAsTakerTrade; import bisq.core.trade.model.bsq_swap.BsqSwapTrade; +import bisq.core.trade.protocol.MakerProtocol; import bisq.core.trade.protocol.Provider; +import bisq.core.trade.protocol.TakerProtocol; import bisq.core.trade.protocol.TradeProtocol; import bisq.core.trade.protocol.TradeProtocolFactory; -import bisq.core.trade.protocol.bisq_v1.MakerProtocol; -import bisq.core.trade.protocol.bisq_v1.TakerProtocol; import bisq.core.trade.protocol.bisq_v1.messages.InputsForDepositTxRequest; import bisq.core.trade.protocol.bisq_v1.model.ProcessModel; import bisq.core.trade.protocol.bsq_swap.BsqSwapMakerProtocol; @@ -92,6 +92,7 @@ import org.bitcoinj.core.InsufficientMoneyException; import org.bitcoinj.core.Transaction; import org.bitcoinj.core.TransactionConfidence; +import org.bitcoinj.script.Script; import javax.inject.Inject; import javax.inject.Named; @@ -679,7 +680,7 @@ private OfferAvailabilityModel getOfferAvailabilityModel(Offer offer, boolean is public void onWithdrawRequest(String toAddress, Coin amount, Coin fee, - KeyParameter aesKey, + @Nullable KeyParameter aesKey, Trade trade, @Nullable String memo, ResultHandler resultHandler, @@ -688,7 +689,7 @@ public void onWithdrawRequest(String toAddress, AddressEntry.Context.TRADE_PAYOUT).getAddressString(); FutureCallback callback = new FutureCallback<>() { @Override - public void onSuccess(@javax.annotation.Nullable Transaction transaction) { + public void onSuccess(@Nullable Transaction transaction) { if (transaction != null) { log.debug("onWithdraw onSuccess tx ID:" + transaction.getTxId().toString()); onTradeCompleted(trade); @@ -723,6 +724,12 @@ public void onTradeCompleted(Trade trade) { // TODO The address entry should have been removed already. Check and if its the case remove that. btcWalletService.resetAddressEntriesForPendingTrade(trade.getId()); + // FIXME: If the trade fails, any watched scripts will remain in the wallet permanently, which is not ideal. + // If any staged tx was broadcast, we also keep watched scripts so that it won't disappear after an SPV resync. + List