Skip to content

Commit

Permalink
Add front end for redirect tx recovery & broadcast
Browse files Browse the repository at this point in the history
Provide an overlay for calling 'RedirectionTransactionRecoveryService',
activated by the keyboard shortcut ALT/CMD/CTRL-K. The user must supply
the deposit tx ID and the peer's warning tx hex. The latter can be found
with aid of a suitable block explorer, but cannot generally be retrieved
by bitcoinj, since a wallet restored from the user's seed phrase won't
see txs spending the deposit tx output, if there are no incoming UTXOs.

Also improve the error handling of the backend slightly.
  • Loading branch information
stejbac committed Jan 19, 2025
1 parent 790f05c commit 2055bde
Show file tree
Hide file tree
Showing 9 changed files with 401 additions and 25 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/

package bisq.core.btc.exceptions;

public class RedirectTxRecoveryException extends RuntimeException {
public RedirectTxRecoveryException(String message) {
super(message);
}

public RedirectTxRecoveryException(String message, Throwable cause) {
super(message, cause);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,14 @@

package bisq.core.btc.wallet;

import bisq.core.btc.exceptions.RedirectTxRecoveryException;
import bisq.core.btc.exceptions.TransactionVerificationException;
import bisq.core.crypto.LowRSigningKey;
import bisq.core.dao.burningman.DelayedPayoutTxReceiverService;
import bisq.core.dao.burningman.DelayedPayoutTxReceiverService.ReceiverFlag;
import bisq.core.trade.protocol.bisq_v5.model.StagedPayoutTxParameters;

import bisq.common.app.Version;
import bisq.common.util.Tuple2;

import org.bitcoinj.core.Address;
Expand All @@ -43,20 +46,22 @@
import org.bitcoinj.script.Script;
import org.bitcoinj.script.ScriptBuilder;
import org.bitcoinj.script.ScriptChunk;
import org.bitcoinj.script.ScriptException;

import javax.inject.Inject;
import javax.inject.Singleton;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.BoundType;
import com.google.common.collect.ImmutableSetMultimap;
import com.google.common.collect.Range;
import com.google.common.collect.SetMultimap;

import org.bouncycastle.crypto.params.KeyParameter;
import org.bouncycastle.math.ec.ECPoint;

import java.math.BigInteger;

import java.util.Date;
import java.util.List;
import java.util.Map;
import java.util.Objects;
Expand Down Expand Up @@ -95,15 +100,14 @@ public RedirectionTransactionRecoveryService(Supplier<NetworkParameters> params,
public Transaction recoverRedirectTx(Sha256Hash depositTxId,
Transaction peersWarningTx,
@Nullable KeyParameter aesKey)
throws SignatureDecodeException, TransactionVerificationException {
throws RedirectTxRecoveryException {

// TODO: Throw something more useful & specific here...
Transaction depositTx = Optional.ofNullable(btcWalletService.getTransaction(depositTxId))
.orElseThrow();
.orElseThrow(() -> new RedirectTxRecoveryException("Could not find depositTx in our wallet"));
Tuple2<Integer, DeterministicKey> myDepositInputIndexAndKey = findMyDepositInputIndexAndKey(depositTx)
.orElseThrow();
.orElseThrow(() -> new RedirectTxRecoveryException("Invalid depositTx: missing input from our wallet"));
DeterministicKey myMultiSigKeyPair = findMyMultiSigKeyPair(peersWarningTx.getInput(0))
.orElseThrow();
.orElseThrow(() -> new RedirectTxRecoveryException("Invalid peersWarningTx: missing multisig key from our wallet"));

int myDepositInputIndex = myDepositInputIndexAndKey.first;
boolean amBuyer = myDepositInputIndex == 0;
Expand All @@ -122,15 +126,19 @@ public Transaction recoverRedirectTx(Sha256Hash depositTxId,
.flatMap(tx -> matcher.getMatchingSignatures(redirectTxSigHash(peersWarningTx, tx, redeemScript))
.map(signature -> new Tuple2<>(tx, signature)))
.findFirst()
.orElseThrow();
.orElseThrow(() -> new RedirectTxRecoveryException("Unable to find redirectTx matching any peer signature candidate"));

Transaction redirectTx = redirectTxAndPeerSignature.first;
Sha256Hash sigHash = redirectTxSigHash(peersWarningTx, redirectTx, redeemScript);
ECKey.ECDSASignature mySignature = LowRSigningKey.from(myMultiSigKeyPair).sign(sigHash, aesKey);
ECKey.ECDSASignature buyerSignature = amBuyer ? mySignature : redirectTxAndPeerSignature.second;
ECKey.ECDSASignature sellerSignature = amBuyer ? redirectTxAndPeerSignature.second : mySignature;
return redirectionTransactionFactory.finalizeRedirectionTransaction(peersWarningTx.getOutput(0), redirectTx,
redeemScript, buyerSignature, sellerSignature);
try {
return redirectionTransactionFactory.finalizeRedirectionTransaction(peersWarningTx.getOutput(0), redirectTx,
redeemScript, buyerSignature, sellerSignature);
} catch (TransactionVerificationException | ScriptException | IllegalArgumentException e) {
throw new RedirectTxRecoveryException("Recovered redirectTx failed to verify", e);
}
}

private Optional<Tuple2<Integer, DeterministicKey>> findMyDepositInputIndexAndKey(Transaction depositTx) {
Expand Down Expand Up @@ -194,8 +202,13 @@ private Stream<Transaction> unsignedRedirectTxCandidates(TransactionOutput depos

private Stream<Transaction> unsignedRedirectTxTemplates(TransactionOutput depositTxOutput,
Transaction peersWarningTx) {
return lockTimeDelaysPlusErrorsToTry()
.mapToObj(delay -> unsignedRedirectTxTemplate(depositTxOutput, peersWarningTx, delay));
return receiverFlagSetsToTry()
.flatMap(flags -> lockTimeDelaysPlusErrorsToTry()
.mapToObj(delay -> unsignedRedirectTxTemplate(depositTxOutput, peersWarningTx, flags, delay)));
}

private Stream<Set<ReceiverFlag>> receiverFlagSetsToTry() {
return ReceiverFlag.flagsActivatedBy(Range.downTo(Version.PROTOCOL_5_ACTIVATION_DATE, BoundType.OPEN)).stream();
}

private IntStream lockTimeDelaysPlusErrorsToTry() {
Expand All @@ -206,10 +219,9 @@ private IntStream lockTimeDelaysPlusErrorsToTry() {

private Transaction unsignedRedirectTxTemplate(TransactionOutput depositTxOutput,
Transaction peersWarningTx,
Set<ReceiverFlag> receiverFlags,
int lockTimeDelayPlusError) {
long warningTxFee = depositTxOutput.getValue().value -
peersWarningTx.getOutput(0).getValue().value -
peersWarningTx.getOutput(1).getValue().value;
long warningTxFee = depositTxOutput.getValue().value - peersWarningTx.getOutputSum().value;
long depositTxFee = StagedPayoutTxParameters.recoverDepositTxFeeRate(warningTxFee) * 278;
long inputAmount = peersWarningTx.getOutput(0).getValue().value;
long inputAmountMinusFeeBumpAmount = inputAmount - StagedPayoutTxParameters.REDIRECT_TX_FEE_BUMP_OUTPUT_VALUE;
Expand All @@ -221,7 +233,7 @@ private Transaction unsignedRedirectTxTemplate(TransactionOutput depositTxOutput
inputAmountMinusFeeBumpAmount,
depositTxFee,
StagedPayoutTxParameters.REDIRECT_TX_MIN_WEIGHT,
DelayedPayoutTxReceiverService.ReceiverFlag.flagsActivatedBy(new Date())); // FIXME: This will break if we add any more flags.
receiverFlags);

String feeBumpAddress = SegwitAddress.fromHash(params, new byte[20]).toString();
var feeBumpOutputAmountAndAddress = new Tuple2<>(StagedPayoutTxParameters.REDIRECT_TX_FEE_BUMP_OUTPUT_VALUE, feeBumpAddress);
Expand Down Expand Up @@ -260,24 +272,34 @@ static Stream<ECKey.ECDSASignature> recoveredSignatureCandidates(Transaction dep
DeterministicKey myDepositInputKeyPair,
DeterministicKey myMultiSigKeyPair,
@Nullable KeyParameter aesKey)
throws SignatureDecodeException {
throws RedirectTxRecoveryException {

// TODO: We can do a bit more validation of the witness stacks against the supplied args here...
Script scriptCode = ScriptBuilder.createP2PKHOutputScript(myDepositInputKeyPair);
Sha256Hash depositSigHash = depositTx.hashForWitnessSignature(myDepositInputIndex, scriptCode, myDepositInputValue,
Transaction.SigHash.ALL, false);
TransactionSignature depositSignature = TransactionSignature.decodeFromBitcoin(
depositTx.getInput(myDepositInputIndex).getWitness().getPush(0), true, true);
checkArgument(myDepositInputKeyPair.verify(depositSigHash, depositSignature));
TransactionSignature depositSignature;
try {
depositSignature = TransactionSignature.decodeFromBitcoin(
depositTx.getInput(myDepositInputIndex).getWitness().getPush(0), true, true);
checkArgument(myDepositInputKeyPair.verify(depositSigHash, depositSignature));
} catch (SignatureDecodeException | IllegalArgumentException e) {
throw new RedirectTxRecoveryException("Invalid depositTx: could not extract signature for our input", e);
}

boolean amBuyer = myDepositInputIndex == 0;
Script redeemScript = new Script(peersWarningTx.getInput(0).getWitness().getPush(3));
Coin warningTxInputValue = depositTx.getOutput(0).getValue();
Sha256Hash warningSigHash = peersWarningTx.hashForWitnessSignature(0, redeemScript, warningTxInputValue,
Transaction.SigHash.ALL, false);
TransactionSignature myWarningSignature = TransactionSignature.decodeFromBitcoin(
peersWarningTx.getInput(0).getWitness().getPush(amBuyer ? 2 : 1), true, true);
checkArgument(myMultiSigKeyPair.verify(warningSigHash, myWarningSignature));
TransactionSignature myWarningSignature;
try {
myWarningSignature = TransactionSignature.decodeFromBitcoin(
peersWarningTx.getInput(0).getWitness().getPush(amBuyer ? 2 : 1), true, true);
checkArgument(myMultiSigKeyPair.verify(warningSigHash, myWarningSignature));
} catch (SignatureDecodeException | IllegalArgumentException e) {
throw new RedirectTxRecoveryException("Invalid peersWarningTx: could not extract our signature", e);
}

return recoveredSignatureCandidates(
LowRSigningKey.from(myMultiSigKeyPair),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,16 @@
import javax.inject.Singleton;

import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.BoundType;
import com.google.common.collect.Range;

import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.Date;
import java.util.EnumSet;
import java.util.GregorianCalendar;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -141,6 +145,27 @@ public static Set<ReceiverFlag> flagsActivatedBy(Date date) {
flags.removeIf(flag -> !date.after(flag.activationDate));
return flags;
}

public static Set<Set<ReceiverFlag>> flagsActivatedBy(Range<Date> dateRange) {
Set<Set<ReceiverFlag>> flagsSet = new LinkedHashSet<>();
Date[] activationDates = Arrays.stream(values())
.map(f -> f.activationDate)
.toArray(Date[]::new);
Arrays.sort(activationDates, Comparator.naturalOrder());
Date lastDate = null;
for (Date date : activationDates) {
Range<Date> segment = lastDate != null ? Range.openClosed(lastDate, date) : Range.upTo(date, BoundType.CLOSED);
if (dateRange.isConnected(segment) && !dateRange.intersection(segment).isEmpty()) {
flagsSet.add(flagsActivatedBy(date));
}
lastDate = date;
}
Range<Date> lastSegment = lastDate != null ? Range.downTo(lastDate, BoundType.OPEN) : dateRange;
if (dateRange.isConnected(lastSegment) && !dateRange.intersection(lastSegment).isEmpty()) {
flagsSet.add(EnumSet.allOf(ReceiverFlag.class));
}
return flagsSet;
}
}

// We use a snapshot blockHeight to avoid failed trades in case maker and taker have different block heights.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ public ValidationResult validate(String input) {
if (!validationResult.isValid)
return validationResult;

if (minLength == maxLength && input.length() != minLength)
return new ValidationResult(false, Res.get("validation.fixedLength", this.minLength));
if (input.length() > maxLength || input.length() < minLength)
new ValidationResult(false, Res.get("validation.length", minLength, maxLength));
return new ValidationResult(false, Res.get("validation.length", minLength, maxLength));

try {
Utilities.decodeFromHex(input);
Expand Down
2 changes: 2 additions & 0 deletions core/src/main/resources/i18n/displayStrings.properties
Original file line number Diff line number Diff line change
Expand Up @@ -1682,6 +1682,8 @@ setting.about.shortcuts.showTorLogs=Toggle log level for Tor messages between DE

setting.about.shortcuts.manualPayoutTxWindow=Open window for manual payout from 2of2 Multisig deposit tx

setting.about.shortcuts.redirectTxRecoveryWindow=Open window for recovery of the redirection tx for a missing trade

setting.about.shortcuts.reRepublishAllGovernanceData=Republish DAO governance data (proposals, votes)

setting.about.shortcuts.removeStuckTrade=Open popup to move failed trade to open trades tab again
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ public void testUnsignedRedirectTxCandidates_amSeller() {
}

@Test
public void testRecoverRedirectTx_amBuyer() throws Exception {
public void testRecoverRedirectTx_amBuyer() {
setUpWalletServiceStubs(BUYERS_DEPOSIT_INPUT_KEY_PAIR, BUYERS_MULTISIG_KEY_PAIR);

var redirectTx = redirectionTransactionRecoveryService.recoverRedirectTx(DEPOSIT_TX.getTxId(),
Expand All @@ -245,7 +245,7 @@ public void testRecoverRedirectTx_amBuyer() throws Exception {
}

@Test
public void testRecoverRedirectTx_amSeller() throws Exception {
public void testRecoverRedirectTx_amSeller() {
setUpWalletServiceStubs(SELLERS_DEPOSIT_INPUT_KEY_PAIR, SELLERS_MULTISIG_KEY_PAIR);

var redirectTx = redirectionTransactionRecoveryService.recoverRedirectTx(DEPOSIT_TX.getTxId(),
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/main/java/bisq/desktop/app/BisqApp.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import bisq.desktop.main.overlays.windows.BsqEmptyWalletWindow;
import bisq.desktop.main.overlays.windows.BtcEmptyWalletWindow;
import bisq.desktop.main.overlays.windows.FilterWindow;
import bisq.desktop.main.overlays.windows.RedirectTxRecoveryWindow;
import bisq.desktop.main.overlays.windows.supporttool.SupportToolWindow;
import bisq.desktop.main.overlays.windows.SendAlertMessageWindow;
import bisq.desktop.main.overlays.windows.ShowWalletDataWindow;
Expand Down Expand Up @@ -359,6 +360,11 @@ private void addSceneKeyEventHandler(Scene scene, Injector injector) {
injector.getInstance(SupportToolWindow.class).show();
else
new Popup().warning(Res.get("popup.warning.walletNotInitialized")).show();
} else if (Utilities.isAltOrCtrlPressed(KeyCode.K, keyEvent)) {
if (injector.getInstance(BtcWalletService.class).isWalletReady())
injector.getInstance(RedirectTxRecoveryWindow.class).show();
else
new Popup().warning(Res.get("popup.warning.walletNotInitialized")).show();
} else if (DevEnv.isDevMode()) {
if (Utilities.isAltOrCtrlPressed(KeyCode.Z, keyEvent))
showDebugWindow(scene, injector);
Expand Down
Loading

0 comments on commit 2055bde

Please sign in to comment.