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