Skip to content

Commit 8b9ff7f

Browse files
committed
Sanity check deposit tx output & receiver total before refunding
Enhance the tx chain validation logic in 'DisputeSummaryWindow' to check that the deposit tx funds no less than the total trade collateral of the contract, and that the BM won't receive less than the proposed refund, due to excessive tx fees. This is to guard against a malicious pair of traders who could attempt to trick the refund agent into paying out more than they deposited, by supplying a mismatched contract, or (less feasibly) colluding with a miner and using an unreasonably high mining fee to steal some of the funds that should go to the BM. Also verify that the DPT output address matches the donation address in the 'Dispute' DTO, for any dispute using the legacy BM, as that option is not forbidden for v4 protocol trades. Finally, validate the txId string passed in the URL to mempool.space from the methods 'MempoolHttpClient::(getTxDetails|requestTxAsHex)', for safety and to avoid GET requests like '/null' or '/null/hex' if the txId is null.
1 parent d97b63e commit 8b9ff7f

File tree

3 files changed

+67
-32
lines changed

3 files changed

+67
-32
lines changed

core/src/main/java/bisq/core/provider/MempoolHttpClient.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222

2323
import bisq.common.app.Version;
2424

25+
import org.bitcoinj.core.Sha256Hash;
26+
2527
import javax.inject.Inject;
2628
import javax.inject.Singleton;
2729

@@ -41,7 +43,7 @@ public MempoolHttpClient(@Nullable Socks5ProxyProvider socks5ProxyProvider) {
4143
// returns JSON of the transaction details
4244
public String getTxDetails(String txId) throws IOException {
4345
super.shutDown(); // close any prior incomplete request
44-
String api = "/" + txId;
46+
String api = "/" + Sha256Hash.wrap(txId);
4547
return get(api, "User-Agent", "bisq/" + Version.VERSION);
4648
}
4749

@@ -50,7 +52,7 @@ public CompletableFuture<String> requestTxAsHex(String txId) {
5052
super.shutDown(); // close any prior incomplete request
5153

5254
return CompletableFuture.supplyAsync(() -> {
53-
String api = "/" + txId + "/hex";
55+
String api = "/" + Sha256Hash.wrap(txId) + "/hex";
5456
try {
5557
return get(api, "User-Agent", "bisq/" + Version.VERSION);
5658
} catch (IOException e) {

core/src/main/java/bisq/core/support/dispute/refund/RefundManager.java

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
import bisq.core.trade.ClosedTradableManager;
4141
import bisq.core.trade.TradeManager;
4242
import bisq.core.trade.bisq_v1.FailedTradesManager;
43+
import bisq.core.trade.model.bisq_v1.Contract;
4344
import bisq.core.trade.model.bisq_v1.Trade;
4445
import bisq.core.trade.protocol.bisq_v5.model.StagedPayoutTxParameters;
4546

@@ -55,6 +56,7 @@
5556
import bisq.common.util.Hex;
5657
import bisq.common.util.Tuple2;
5758

59+
import org.bitcoinj.core.Coin;
5860
import org.bitcoinj.core.NetworkParameters;
5961
import org.bitcoinj.core.Transaction;
6062
import org.bitcoinj.core.TransactionInput;
@@ -351,21 +353,34 @@ private static boolean firstOutputConnectsToFirstInput(Transaction parent, Trans
351353
}
352354

353355
public void verifyDelayedPayoutTxReceivers(Transaction depositTx, Transaction delayedPayoutTx, Dispute dispute) {
354-
long inputAmount = depositTx.getOutput(0).getValue().value;
355-
int selectionHeight = dispute.getBurningManSelectionHeight();
356-
357-
List<Tuple2<Long, String>> receivers = delayedPayoutTxReceiverService.getReceivers(
358-
selectionHeight,
359-
inputAmount,
360-
dispute.getTradeTxFee(),
361-
DelayedPayoutTxReceiverService.ReceiverFlag.flagsActivatedBy(dispute.getTradeDate()));
362-
log.info("Verify delayedPayoutTx using selectionHeight {} and receivers {}", selectionHeight, receivers);
363-
checkArgument(delayedPayoutTx.getOutputs().size() == receivers.size(),
364-
"Number of outputs must equal number of receivers");
365-
checkOutputsPrefixMatchesReceivers(delayedPayoutTx, receivers);
356+
if (dispute.isUsingLegacyBurningMan()) {
357+
checkArgument(delayedPayoutTx.getOutputs().size() == 1,
358+
"delayedPayoutTx must have 1 output when using legacy BM");
359+
NetworkParameters params = btcWalletService.getParams();
360+
TransactionOutput transactionOutput = delayedPayoutTx.getOutput(0);
361+
String outputAddress = transactionOutput.getScriptPubKey().getToAddress(params).toString();
362+
checkArgument(outputAddress.equals(dispute.getDonationAddressOfDelayedPayoutTx()),
363+
"Output address does not match donation address (%s). transactionOutput=%s",
364+
dispute.getDonationAddressOfDelayedPayoutTx(), transactionOutput);
365+
} else {
366+
long inputAmount = depositTx.getOutput(0).getValue().value;
367+
int selectionHeight = dispute.getBurningManSelectionHeight();
368+
369+
List<Tuple2<Long, String>> receivers = delayedPayoutTxReceiverService.getReceivers(
370+
selectionHeight,
371+
inputAmount,
372+
dispute.getTradeTxFee(),
373+
DelayedPayoutTxReceiverService.ReceiverFlag.flagsActivatedBy(dispute.getTradeDate()));
374+
log.info("Verify delayedPayoutTx using selectionHeight {} and receivers {}", selectionHeight, receivers);
375+
checkArgument(delayedPayoutTx.getOutputs().size() == receivers.size(),
376+
"Number of outputs must equal number of receivers");
377+
checkOutputsPrefixMatchesReceivers(delayedPayoutTx, receivers);
378+
}
366379
}
367380

368381
public void verifyRedirectTxReceivers(Transaction warningTx, Transaction redirectTx, Dispute dispute) {
382+
checkArgument(!dispute.isUsingLegacyBurningMan(), "Legacy BM use not permitted with redirectTx");
383+
369384
long inputAmount = warningTx.getOutput(0).getValue().value;
370385
long inputAmountMinusFeeBumpAmount = inputAmount - StagedPayoutTxParameters.REDIRECT_TX_FEE_BUMP_OUTPUT_VALUE;
371386
int selectionHeight = dispute.getBurningManSelectionHeight();
@@ -389,11 +404,33 @@ private void checkOutputsPrefixMatchesReceivers(Transaction delayedPayoutOrRedir
389404
TransactionOutput transactionOutput = delayedPayoutOrRedirectTx.getOutput(i);
390405
Tuple2<Long, String> receiverTuple = receivers.get(i);
391406
checkArgument(transactionOutput.getScriptPubKey().getToAddress(params).toString().equals(receiverTuple.second),
392-
"Output address does not match receiver address (%s). transactionOutput=%s",
393-
receiverTuple.second, transactionOutput);
407+
"Output address does not match receiver address (%s). transactionOutput=%s, index=%s",
408+
receiverTuple.second, transactionOutput, i);
394409
checkArgument(transactionOutput.getValue().value == receiverTuple.first,
395-
"Output value does not match receiver value (%s). transactionOutput=%s",
396-
receiverTuple.first, transactionOutput);
410+
"Output value does not match receiver value (%s). transactionOutput=%s, index=%s",
411+
receiverTuple.first, transactionOutput, i);
397412
}
398413
}
414+
415+
public void validateCollateralAndPayoutTotals(Transaction depositTx,
416+
Transaction delayedPayoutOrRedirectTx,
417+
Dispute dispute,
418+
DisputeResult disputeResult) {
419+
Contract contract = dispute.getContract();
420+
Coin expectedCollateral = contract.getTradeAmount()
421+
.add(Coin.valueOf(contract.getOfferPayload().getBuyerSecurityDeposit()))
422+
.add(Coin.valueOf(contract.getOfferPayload().getSellerSecurityDeposit()));
423+
Coin depositOutputValue = depositTx.getOutput(0).getValue();
424+
425+
checkArgument(!depositOutputValue.isLessThan(expectedCollateral),
426+
"First depositTx output value (%s) is less than expected trade collateral: %s sats",
427+
depositOutputValue, expectedCollateral);
428+
429+
Coin totalPayoutAmount = disputeResult.getBuyerPayoutAmount().add(disputeResult.getSellerPayoutAmount());
430+
Coin totalReceiverPlusPossibleFeeBumpOutputAmount = delayedPayoutOrRedirectTx.getOutputSum();
431+
432+
checkArgument(!totalReceiverPlusPossibleFeeBumpOutputAmount.isLessThan(totalPayoutAmount),
433+
"DPT/redirectTx output sum (%s) is less than proposed refund of %s sats to traders",
434+
totalReceiverPlusPossibleFeeBumpOutputAmount, totalPayoutAmount);
435+
}
399436
}

desktop/src/main/java/bisq/desktop/main/overlays/windows/DisputeSummaryWindow.java

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -842,10 +842,6 @@ public void onFailure(TxBroadcastException exception) {
842842
}
843843
}
844844

845-
// TODO: We should probably check that the tx fees paid by the warning & redirect txs (or DPT for v4) don't reduce
846-
// the amount going to the BM by too much, say by no more than the trade security deposit amount, as the fees could
847-
// spike (or be manipulated) and in general the redirect tx pays out a final sum that will be less than the total
848-
// trade collateral (due to fees).
849845
private CompletableFuture<Boolean> maybeCheckTransactions() {
850846
final CompletableFuture<Boolean> asyncStatus = new CompletableFuture<>();
851847
var disputeManager = getDisputeManager();
@@ -862,16 +858,16 @@ private CompletableFuture<Boolean> maybeCheckTransactions() {
862858
if (throwable == null) {
863859
try {
864860
refundManager.verifyTradeTxChain(txList);
865-
if (!dispute.isUsingLegacyBurningMan()) {
866-
Transaction depositTx = txList.get(2);
867-
if (txList.size() == 4) {
868-
Transaction delayedPayoutTx = txList.get(3);
869-
refundManager.verifyDelayedPayoutTxReceivers(depositTx, delayedPayoutTx, dispute);
870-
} else {
871-
Transaction warningTx = txList.get(3);
872-
Transaction redirectTx = txList.get(4);
873-
refundManager.verifyRedirectTxReceivers(warningTx, redirectTx, dispute);
874-
}
861+
Transaction depositTx = txList.get(2);
862+
if (txList.size() == 4) {
863+
Transaction delayedPayoutTx = txList.get(3);
864+
refundManager.verifyDelayedPayoutTxReceivers(depositTx, delayedPayoutTx, dispute);
865+
refundManager.validateCollateralAndPayoutTotals(depositTx, delayedPayoutTx, dispute, disputeResult);
866+
} else {
867+
Transaction warningTx = txList.get(3);
868+
Transaction redirectTx = txList.get(4);
869+
refundManager.verifyRedirectTxReceivers(warningTx, redirectTx, dispute);
870+
refundManager.validateCollateralAndPayoutTotals(depositTx, redirectTx, dispute, disputeResult);
875871
}
876872
asyncStatus.complete(true);
877873
} catch (Throwable error) {

0 commit comments

Comments
 (0)