Skip to content

Commit 360b8a4

Browse files
committed
Adapt refund agent tx chain validation to v5 protocol
Make sure the logic to fetch the chain of trade txs from mempool.space, and validate the receiver list, works in the case of the v5 protocol. In that case, the published warning & redirect txs replace the DPT, giving five txs in the chain instead of four. Ensure that in both cases the number of confirmations of the last tx (DPT for v4, redirect tx for v5) shows up in Dispute Summary window (outside of regtest). Also fix a bug validating the outputs of the last tx, where it failed to iterate through the receiver tuples correctly, which would have prevented it from ever working in production (as there's more than one receiver). Finally, make the dispute validation more strict about which txId fields it allows to be null vs non-null, so that the DPT ID is always null when the redirect or warning txId fields are set (signifying a v5 protocol trade for refund disputes). Disallow use of the legacy BM in that case as well, for safety, as it should never be used for new trades.
1 parent da05ebf commit 360b8a4

File tree

4 files changed

+192
-119
lines changed

4 files changed

+192
-119
lines changed

core/src/main/java/bisq/core/support/dispute/DisputeValidation.java

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,12 @@ public static void validateTradeAndDispute(Dispute dispute, Trade trade, BtcWall
104104
checkArgument(dispute.getContract().equals(trade.getContract()),
105105
"contract must match contract from trade");
106106

107-
if (trade.hasV5Protocol()) {
107+
if (!trade.hasV5Protocol()) {
108+
checkNotNull(trade.getDelayedPayoutTx(), "trade.getDelayedPayoutTx() must not be null");
109+
checkNotNull(dispute.getDelayedPayoutTxId(), "delayedPayoutTxId must not be null");
110+
checkArgument(dispute.getDelayedPayoutTxId().equals(trade.getDelayedPayoutTx().getTxId().toString()),
111+
"delayedPayoutTxId must match delayedPayoutTxId from trade");
112+
} else if (dispute.getSupportType() == SupportType.REFUND) {
108113
String buyersWarningTxId = toTxId(trade.getBuyersWarningTx(btcWalletService));
109114
String sellersWarningTxId = toTxId(trade.getSellersWarningTx(btcWalletService));
110115
String buyersRedirectTxId = toTxId(trade.getBuyersRedirectTx(btcWalletService));
@@ -123,15 +128,12 @@ public static void validateTradeAndDispute(Dispute dispute, Trade trade, BtcWall
123128
checkArgument(isBuyerRedirect, "seller's redirectTx must be used with buyer's warningTx");
124129
}
125130
} else {
126-
checkNotNull(trade.getDelayedPayoutTx(), "trade.getDelayedPayoutTx() must not be null");
127-
checkNotNull(dispute.getDelayedPayoutTxId(), "delayedPayoutTxId must not be null");
128-
checkArgument(dispute.getDelayedPayoutTxId().equals(trade.getDelayedPayoutTx().getTxId().toString()),
129-
"delayedPayoutTxId must match delayedPayoutTxId from trade");
131+
checkArgument(dispute.getDelayedPayoutTxId() == null, "delayedPayoutTxId should be null");
130132
}
131133

134+
checkTxIdFieldCombination(dispute);
132135
checkNotNull(trade.getDepositTx(), "trade.getDepositTx() must not be null");
133-
checkNotNull(dispute.getDepositTxId(), "depositTxId must not be null");
134-
checkArgument(dispute.getDepositTxId().equals(trade.getDepositTx().getTxId().toString()),
136+
checkArgument(trade.getDepositTx().getTxId().toString().equals(dispute.getDepositTxId()),
135137
"depositTx must match depositTx from trade");
136138

137139
checkNotNull(dispute.getDepositTxSerialized(), "depositTxSerialized must not be null");
@@ -245,22 +247,7 @@ private static void testIfDisputeTriesReplay(Dispute disputeToTest,
245247
try {
246248
String disputeToTestTradeId = disputeToTest.getTradeId();
247249

248-
// For pre v1.4.0 we do not get the delayed payout tx sent in mediation cases but in refund agent case we do.
249-
// With 1.4.0 we send the delayed payout tx also in mediation cases. For v5 protocol trades, there is no DPT
250-
// and it is unknown which staged txs will be published, if any, so they are only sent in refund agent cases.
251-
if (disputeToTest.getSupportType() == SupportType.REFUND) {
252-
if (disputeToTest.getWarningTxId() == null) {
253-
checkNotNull(disputeToTest.getDelayedPayoutTxId(),
254-
"Delayed payout transaction ID is null. " +
255-
"Trade ID: %s", disputeToTestTradeId);
256-
} else {
257-
checkNotNull(disputeToTest.getRedirectTxId(),
258-
"Redirect transaction ID is null. " +
259-
"Trade ID: %s", disputeToTestTradeId);
260-
}
261-
}
262-
checkNotNull(disputeToTest.getDepositTxId(),
263-
"depositTxId must not be null. Trade ID: %s", disputeToTestTradeId);
250+
checkTxIdFieldCombination(disputeToTest);
264251
checkNotNull(disputeToTest.getUid(),
265252
"agentsUid must not be null. Trade ID: %s", disputeToTestTradeId);
266253

@@ -280,6 +267,27 @@ private static void testIfDisputeTriesReplay(Dispute disputeToTest,
280267
}
281268
}
282269

270+
private static void checkTxIdFieldCombination(Dispute dispute) {
271+
// For pre v1.4.0 we do not get the delayed payout tx sent in mediation cases but in refund agent case we do.
272+
// With 1.4.0 we send the delayed payout tx also in mediation cases. For v5 protocol trades, there is no DPT
273+
// and it is unknown which staged txs will be published, if any, so they are only sent in refund agent cases.
274+
String tradeId = dispute.getTradeId();
275+
if (dispute.getWarningTxId() != null || dispute.getRedirectTxId() != null) {
276+
checkNotNull(dispute.getWarningTxId(), "warningTxId must not be null. Trade ID: %s", tradeId);
277+
checkNotNull(dispute.getRedirectTxId(), "redirectTxId must not be null. Trade ID: %s", tradeId);
278+
checkArgument(dispute.getDelayedPayoutTxId() == null,
279+
"delayedPayoutTxId should be null. Trade ID: %s", tradeId);
280+
checkArgument(!dispute.isUsingLegacyBurningMan(),
281+
"Legacy BM use not permitted. Trade ID: %s", tradeId);
282+
checkArgument(dispute.getSupportType() == SupportType.REFUND,
283+
"Mediation not permitted after staged txs published. Trade ID: %s", tradeId);
284+
} else if (dispute.getSupportType() == SupportType.REFUND) {
285+
checkNotNull(dispute.getDelayedPayoutTxId(),
286+
"delayedPayoutTxId must not be null. Trade ID: %s", tradeId);
287+
}
288+
checkNotNull(dispute.getDepositTxId(), "depositTxId must not be null. Trade ID: %s", tradeId);
289+
}
290+
283291
private enum DisputeIdField implements Function<Dispute, String> {
284292
TRADE_ID(Dispute::getTradeId),
285293
DELAYED_PAYOUT_TX_ID(Dispute::getDelayedPayoutTxId),

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

Lines changed: 85 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
import bisq.core.trade.TradeManager;
4242
import bisq.core.trade.bisq_v1.FailedTradesManager;
4343
import bisq.core.trade.model.bisq_v1.Trade;
44+
import bisq.core.trade.protocol.bisq_v5.model.StagedPayoutTxParameters;
4445

4546
import bisq.network.p2p.AckMessageSourceType;
4647
import bisq.network.p2p.NodeAddress;
@@ -64,6 +65,7 @@
6465
import com.google.inject.Singleton;
6566

6667
import java.util.ArrayList;
68+
import java.util.Iterator;
6769
import java.util.List;
6870
import java.util.Optional;
6971
import java.util.concurrent.CompletableFuture;
@@ -262,10 +264,7 @@ public NodeAddress getAgentNodeAddress(Dispute dispute) {
262264
return dispute.getContract().getRefundAgentNodeAddress();
263265
}
264266

265-
public CompletableFuture<List<Transaction>> requestBlockchainTransactions(String makerFeeTxId,
266-
String takerFeeTxId,
267-
String depositTxId,
268-
String delayedPayoutTxId) {
267+
public CompletableFuture<List<Transaction>> requestBlockchainTransactions(List<String> txIds) {
269268
// in regtest mode, simulate a delay & failure obtaining the blockchain transactions
270269
// since we cannot request them in regtest anyway. this is useful for checking failure scenarios
271270
if (!Config.baseCurrencyNetwork().isMainnet()) {
@@ -276,28 +275,28 @@ public CompletableFuture<List<Transaction>> requestBlockchainTransactions(String
276275

277276
NetworkParameters params = btcWalletService.getParams();
278277
List<Transaction> txs = new ArrayList<>();
279-
return mempoolService.requestTxAsHex(makerFeeTxId)
280-
.thenCompose(txAsHex -> {
281-
txs.add(new Transaction(params, Hex.decode(txAsHex)));
282-
return mempoolService.requestTxAsHex(takerFeeTxId);
283-
}).thenCompose(txAsHex -> {
284-
txs.add(new Transaction(params, Hex.decode(txAsHex)));
285-
return mempoolService.requestTxAsHex(depositTxId);
286-
}).thenCompose(txAsHex -> {
287-
txs.add(new Transaction(params, Hex.decode(txAsHex)));
288-
return mempoolService.requestTxAsHex(delayedPayoutTxId);
289-
})
290-
.thenApply(txAsHex -> {
291-
txs.add(new Transaction(params, Hex.decode(txAsHex)));
292-
return txs;
293-
});
278+
Iterator<String> txIdIterator = txIds.iterator();
279+
if (!txIdIterator.hasNext()) {
280+
return CompletableFuture.completedFuture(txs);
281+
}
282+
CompletableFuture<String> future = mempoolService.requestTxAsHex(txIdIterator.next());
283+
while (txIdIterator.hasNext()) {
284+
String txId = txIdIterator.next();
285+
future = future.thenCompose(txAsHex -> {
286+
txs.add(new Transaction(params, Hex.decode(txAsHex)));
287+
return mempoolService.requestTxAsHex(txId);
288+
});
289+
}
290+
return future.thenApply(txAsHex -> {
291+
txs.add(new Transaction(params, Hex.decode(txAsHex)));
292+
return txs;
293+
});
294294
}
295295

296296
public void verifyTradeTxChain(List<Transaction> txs) {
297297
Transaction makerFeeTx = txs.get(0);
298298
Transaction takerFeeTx = txs.get(1);
299299
Transaction depositTx = txs.get(2);
300-
Transaction delayedPayoutTx = txs.get(3);
301300

302301
// The order and number of buyer and seller inputs are not part of the trade protocol consensus.
303302
// In the current implementation buyer inputs come before seller inputs at depositTx and there is
@@ -316,38 +315,85 @@ public void verifyTradeTxChain(List<Transaction> txs) {
316315
}
317316
checkArgument(makerFeeTxFoundAtInputs, "makerFeeTx not found at depositTx inputs");
318317
checkArgument(takerFeeTxFoundAtInputs, "takerFeeTx not found at depositTx inputs");
319-
checkArgument(depositTx.getInputs().size() >= 2,
320-
"DepositTx must have at least 2 inputs");
321-
checkArgument(delayedPayoutTx.getInputs().size() == 1,
322-
"DelayedPayoutTx must have 1 input");
323-
TransactionOutPoint delayedPayoutTxInputOutpoint = delayedPayoutTx.getInputs().get(0).getOutpoint();
324-
String fundingTxId = delayedPayoutTxInputOutpoint.getHash().toString();
325-
checkArgument(fundingTxId.equals(depositTx.getTxId().toString()),
326-
"First input at delayedPayoutTx does not connect to depositTx");
318+
checkArgument(depositTx.getInputs().size() >= 2, "depositTx must have at least 2 inputs");
319+
if (txs.size() == 4) {
320+
Transaction delayedPayoutTx = txs.get(3);
321+
checkArgument(delayedPayoutTx.getInputs().size() == 1, "delayedPayoutTx must have 1 input");
322+
checkArgument(firstOutputConnectsToFirstInput(depositTx, delayedPayoutTx),
323+
"First input at delayedPayoutTx does not connect to depositTx");
324+
} else {
325+
Transaction warningTx = txs.get(3);
326+
Transaction redirectTx = txs.get(4);
327+
328+
checkArgument(warningTx.getInputs().size() == 1, "warningTx must have 1 input");
329+
checkArgument(warningTx.getOutputs().size() == 2, "warningTx must have 2 outputs");
330+
checkArgument(warningTx.getOutput(1).getValue().value ==
331+
StagedPayoutTxParameters.WARNING_TX_FEE_BUMP_OUTPUT_VALUE,
332+
"Second warningTx output is wrong amount for a fee bump output");
333+
334+
checkArgument(redirectTx.getInputs().size() == 1, "redirectTx must have 1 input");
335+
int numReceivers = redirectTx.getOutputs().size() - 1;
336+
checkArgument(redirectTx.getOutput(numReceivers).getValue().value ==
337+
StagedPayoutTxParameters.REDIRECT_TX_FEE_BUMP_OUTPUT_VALUE,
338+
"Last redirectTx output is wrong amount for a fee bump output");
339+
340+
checkArgument(firstOutputConnectsToFirstInput(depositTx, warningTx),
341+
"First input at warningTx does not connect to depositTx");
342+
checkArgument(firstOutputConnectsToFirstInput(warningTx, redirectTx),
343+
"First input at redirectTx does not connect to warningTx");
344+
}
327345
}
328346

329-
public void verifyDelayedPayoutTxReceivers(Transaction delayedPayoutTx, Dispute dispute) {
330-
Transaction depositTx = dispute.findDepositTx(btcWalletService).orElseThrow();
347+
private static boolean firstOutputConnectsToFirstInput(Transaction parent, Transaction child) {
348+
TransactionOutPoint childTxInputOutpoint = child.getInput(0).getOutpoint();
349+
String fundingTxId = childTxInputOutpoint.getHash().toString();
350+
return fundingTxId.equals(parent.getTxId().toString());
351+
}
352+
353+
public void verifyDelayedPayoutTxReceivers(Transaction depositTx, Transaction delayedPayoutTx, Dispute dispute) {
331354
long inputAmount = depositTx.getOutput(0).getValue().value;
332355
int selectionHeight = dispute.getBurningManSelectionHeight();
333356

334-
List<Tuple2<Long, String>> delayedPayoutTxReceivers = delayedPayoutTxReceiverService.getReceivers(
357+
List<Tuple2<Long, String>> receivers = delayedPayoutTxReceiverService.getReceivers(
335358
selectionHeight,
336359
inputAmount,
337360
dispute.getTradeTxFee(),
338361
DelayedPayoutTxReceiverService.ReceiverFlag.flagsActivatedBy(dispute.getTradeDate()));
339-
log.info("Verify delayedPayoutTx using selectionHeight {} and receivers {}", selectionHeight, delayedPayoutTxReceivers);
340-
checkArgument(delayedPayoutTx.getOutputs().size() == delayedPayoutTxReceivers.size(),
341-
"Size of outputs and delayedPayoutTxReceivers must be the same");
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);
366+
}
367+
368+
public void verifyRedirectTxReceivers(Transaction warningTx, Transaction redirectTx, Dispute dispute) {
369+
long inputAmount = warningTx.getOutput(0).getValue().value;
370+
long inputAmountMinusFeeBumpAmount = inputAmount - StagedPayoutTxParameters.REDIRECT_TX_FEE_BUMP_OUTPUT_VALUE;
371+
int selectionHeight = dispute.getBurningManSelectionHeight();
372+
373+
List<Tuple2<Long, String>> receivers = delayedPayoutTxReceiverService.getReceivers(
374+
selectionHeight,
375+
inputAmountMinusFeeBumpAmount,
376+
dispute.getTradeTxFee(),
377+
StagedPayoutTxParameters.REDIRECT_TX_MIN_WEIGHT,
378+
DelayedPayoutTxReceiverService.ReceiverFlag.flagsActivatedBy(dispute.getTradeDate()));
379+
log.info("Verify redirectTx using selectionHeight {} and receivers {}", selectionHeight, receivers);
380+
checkArgument(redirectTx.getOutputs().size() == receivers.size() + 1,
381+
"Number of outputs must equal number of receivers plus 1");
382+
checkOutputsPrefixMatchesReceivers(redirectTx, receivers);
383+
}
342384

385+
private void checkOutputsPrefixMatchesReceivers(Transaction delayedPayoutOrRedirectTx,
386+
List<Tuple2<Long, String>> receivers) {
343387
NetworkParameters params = btcWalletService.getParams();
344-
for (int i = 0; i < delayedPayoutTx.getOutputs().size(); i++) {
345-
TransactionOutput transactionOutput = delayedPayoutTx.getOutputs().get(i);
346-
Tuple2<Long, String> receiverTuple = delayedPayoutTxReceivers.get(0);
388+
for (int i = 0; i < receivers.size(); i++) {
389+
TransactionOutput transactionOutput = delayedPayoutOrRedirectTx.getOutput(i);
390+
Tuple2<Long, String> receiverTuple = receivers.get(i);
347391
checkArgument(transactionOutput.getScriptPubKey().getToAddress(params).toString().equals(receiverTuple.second),
348-
"output address does not match delayedPayoutTxReceivers address. transactionOutput=" + transactionOutput);
392+
"Output address does not match receiver address (%s). transactionOutput=%s",
393+
receiverTuple.second, transactionOutput);
349394
checkArgument(transactionOutput.getValue().value == receiverTuple.first,
350-
"output value does not match delayedPayoutTxReceivers value. transactionOutput=" + transactionOutput);
395+
"Output value does not match receiver value (%s). transactionOutput=%s",
396+
receiverTuple.first, transactionOutput);
351397
}
352398
}
353399
}

core/src/main/resources/i18n/displayStrings.properties

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2997,9 +2997,9 @@ disputeSummaryWindow.tradePeriodEnd=Trade period end
29972997
disputeSummaryWindow.extraInfo=Extra information
29982998
disputeSummaryWindow.delayedPayoutStatus=Delayed Payout Status
29992999
disputeSummaryWindow.requestingTxs=Requesting blockchain transactions from block explorer...
3000-
disputeSummaryWindow.requestTransactionsError=Requesting the 4 trade transactions failed. Error message: {0}.\n\n\
3000+
disputeSummaryWindow.requestTransactionsError=Requesting the {0} trade transactions failed. Error message: {1}.\n\n\
30013001
Please verify the transactions manually before closing the dispute.
3002-
disputeSummaryWindow.delayedPayoutTxVerificationFailed=Verification of the delayed payout transaction failed. Error message: {0}.\n\n\
3002+
disputeSummaryWindow.delayedPayoutTxVerificationFailed=Verification of the delayed payout / redirection transaction failed. Error message: {0}.\n\n\
30033003
Please do not make the payout but get in touch with developers to clarify the case.
30043004

30053005
# dynamic values are not recognized by IntelliJ

0 commit comments

Comments
 (0)