Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Atomic trade #4414

Closed
wants to merge 12 commits into from
12 changes: 12 additions & 0 deletions core/src/main/java/bisq/core/btc/TxFeeEstimationService.java
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ public class TxFeeEstimationService {
public static int TYPICAL_TX_WITH_1_INPUT_SIZE = 260;
private static int DEPOSIT_TX_SIZE = 320;
private static int PAYOUT_TX_SIZE = 380;
private static int ATOMIC_TX_SIZE = 975;
private static int BSQ_INPUT_INCREASE = 150;
private static int EXTRA_INPUT_INCREASE = 225;
private static int EXTRA_OUTPUT_INCREASE = 70;
private static int MAX_ITERATIONS = 10;

private final FeeService feeService;
Expand Down Expand Up @@ -80,6 +83,15 @@ public Tuple2<Coin, Integer> getEstimatedFeeAndTxSizeForMaker(Coin reservedFunds
preferences);
}

public Tuple2<Coin, Integer> getEstimatedAtomicFeeAndTxSize(int estimatedInputs, int estimatedOutputs) {
var txFeePerByte = feeService.getTxFeePerByte();
var extraInputSize = estimatedInputs > 3 ? (estimatedInputs - 3) * EXTRA_INPUT_INCREASE : 0;
var extraOutputSize = estimatedOutputs > 4 ? (estimatedOutputs - 4) * EXTRA_OUTPUT_INCREASE : 0;
var estimatedSize = ATOMIC_TX_SIZE + extraInputSize + extraOutputSize;
var txFee = txFeePerByte.multiply(estimatedSize);
return new Tuple2<>(txFee, estimatedSize);
}

private Tuple2<Coin, Integer> getEstimatedFeeAndTxSize(boolean isTaker,
Coin amount,
Coin tradeFee,
Expand Down
127 changes: 114 additions & 13 deletions core/src/main/java/bisq/core/btc/wallet/BsqWalletService.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@
import bisq.core.btc.exceptions.TransactionVerificationException;
import bisq.core.btc.exceptions.WalletException;
import bisq.core.btc.listeners.BsqBalanceListener;
import bisq.core.btc.model.RawTransactionInput;
import bisq.core.btc.setup.WalletsSetup;
import bisq.core.dao.DaoFacade;
import bisq.core.dao.DaoKillSwitch;
import bisq.core.dao.state.DaoStateListener;
import bisq.core.dao.state.DaoStateService;
import bisq.core.dao.state.model.blockchain.BaseTxOutput;
import bisq.core.dao.state.model.blockchain.Block;
import bisq.core.dao.state.model.blockchain.Tx;
import bisq.core.dao.state.model.blockchain.TxOutput;
Expand All @@ -34,8 +37,10 @@
import bisq.core.dao.state.unconfirmed.UnconfirmedBsqChangeOutputListService;
import bisq.core.provider.fee.FeeService;
import bisq.core.user.Preferences;
import bisq.core.util.coin.BsqFormatter;

import bisq.common.UserThread;
import bisq.common.util.Tuple2;

import org.bitcoinj.core.Address;
import org.bitcoinj.core.AddressFormatException;
Expand Down Expand Up @@ -97,6 +102,9 @@ public interface WalletTransactionsChangeListener {
private final CopyOnWriteArraySet<BsqBalanceListener> bsqBalanceListeners = new CopyOnWriteArraySet<>();
private final List<WalletTransactionsChangeListener> walletTransactionsChangeListeners = new ArrayList<>();
private boolean updateBsqWalletTransactionsPending;
@Getter
private final BsqFormatter bsqFormatter;


// balance of non BSQ satoshis
@Getter
Expand Down Expand Up @@ -127,7 +135,8 @@ public BsqWalletService(WalletsSetup walletsSetup,
UnconfirmedBsqChangeOutputListService unconfirmedBsqChangeOutputListService,
Preferences preferences,
FeeService feeService,
DaoKillSwitch daoKillSwitch) {
DaoKillSwitch daoKillSwitch,
BsqFormatter bsqFormatter) {
super(walletsSetup,
preferences,
feeService);
Expand All @@ -137,6 +146,7 @@ public BsqWalletService(WalletsSetup walletsSetup,
this.daoStateService = daoStateService;
this.unconfirmedBsqChangeOutputListService = unconfirmedBsqChangeOutputListService;
this.daoKillSwitch = daoKillSwitch;
this.bsqFormatter = bsqFormatter;

walletsSetup.addSetupCompletedHandler(() -> {
wallet = walletsSetup.getBsqWallet();
Expand Down Expand Up @@ -485,21 +495,11 @@ public Optional<Transaction> isWalletTransaction(String txId) {

public Transaction signTx(Transaction tx) throws WalletException, TransactionVerificationException {
for (int i = 0; i < tx.getInputs().size(); i++) {
TransactionInput txIn = tx.getInputs().get(i);
TransactionOutput connectedOutput = txIn.getConnectedOutput();
if (connectedOutput != null && connectedOutput.isMine(wallet)) {
signTransactionInput(wallet, aesKey, tx, txIn, i);
checkScriptSig(tx, txIn, i);
}
signInput(tx, i);
}

for (TransactionOutput txo : tx.getOutputs()) {
Coin value = txo.getValue();
// OpReturn outputs have value 0
if (value.isPositive()) {
checkArgument(Restrictions.isAboveDust(txo.getValue()),
"An output value is below dust limit. Transaction=" + tx);
}
verifyNonDustTxo(tx, txo);
}

checkWalletConsistency(wallet);
Expand All @@ -508,6 +508,41 @@ public Transaction signTx(Transaction tx) throws WalletException, TransactionVer
return tx;
}

public Transaction signInputs(Transaction tx, List<TransactionInput> transactionInputs)
throws WalletException, TransactionVerificationException {
for (int i = 0; i < tx.getInputs().size(); i++) {
if (transactionInputs.contains(tx.getInput(i)))
signInput(tx, i);
}

for (TransactionOutput txo : tx.getOutputs()) {
verifyNonDustTxo(tx, txo);
}

checkWalletConsistency(wallet);
verifyTransaction(tx);
// printTx("BSQ wallet: Signed Tx", tx);
return tx;
}

private void signInput(Transaction tx, int i) throws TransactionVerificationException {
TransactionInput txIn = tx.getInputs().get(i);
TransactionOutput connectedOutput = txIn.getConnectedOutput();
if (connectedOutput != null && connectedOutput.isMine(wallet)) {
signTransactionInput(wallet, aesKey, tx, txIn, i);
checkScriptSig(tx, txIn, i);
}
}

private void verifyNonDustTxo(Transaction tx, TransactionOutput txo) {
Coin value = txo.getValue();
// OpReturn outputs have value 0
if (value.isPositive()) {
checkArgument(Restrictions.isAboveDust(txo.getValue()),
"An output value is below dust limit. Transaction=" + tx);
}
}


///////////////////////////////////////////////////////////////////////////////////////////
// Commit tx
Expand Down Expand Up @@ -710,6 +745,30 @@ private void addInputsAndChangeOutputForTx(Transaction tx,
}


///////////////////////////////////////////////////////////////////////////////////////////
// Atomic trade tx
///////////////////////////////////////////////////////////////////////////////////////////

public Tuple2<Transaction, Coin> prepareAtomicBsqInputs(Coin requiredInput) throws InsufficientBsqException {
daoKillSwitch.assertDaoIsNotDisabled();

var dummyTx = new Transaction(params);
var coinSelection = bsqCoinSelector.select(requiredInput, wallet.calculateAllSpendCandidates());
coinSelection.gathered.forEach(dummyTx::addInput);

var change = Coin.ZERO;
try {
change = bsqCoinSelector.getChange(requiredInput, coinSelection);
} catch (InsufficientMoneyException e) {
log.error("Missing funds in takerPreparesAtomicBsqInputs");
throw new InsufficientBsqException(e.missing);
}
checkArgument(change.isZero() || Restrictions.isAboveDust(change));

return new Tuple2<>(dummyTx, change);
}


///////////////////////////////////////////////////////////////////////////////////////////
// Blind vote tx
///////////////////////////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -811,4 +870,46 @@ public String getUnusedBsqAddressAsString() {
protected boolean isDustAttackUtxo(TransactionOutput output) {
return false;
}

public long getBsqRawInputAmount(List<RawTransactionInput> inputs, DaoFacade daoFacade) {
return inputs.stream()
.map(rawInput -> {
var tx = getTxFromSerializedTx(rawInput.parentTransaction);
return daoFacade.getUnspentTxOutputs().stream()
.filter(output -> output.getTxId().equals(tx.getHashAsString()))
.filter(output -> output.getIndex() == rawInput.index)
.map(BaseTxOutput::getValue)
.findAny()
.orElse(0L);
}).reduce(Long::sum)
.orElse(0L);
}

public long getBsqInputAmount(List<TransactionInput> inputs, DaoFacade daoFacade) {
return inputs.stream()
.map(input -> {
var txId = input.getOutpoint().getHash().toString();
return daoFacade.getUnspentTxOutputs().stream()
.filter(output -> output.getTxId().equals(txId))
.filter(output -> output.getIndex() == input.getOutpoint().getIndex())
.map(BaseTxOutput::getValue)
.findAny()
.orElse(0L);
})
.reduce(Long::sum)
.orElse(0L);
}

public TransactionInput verifyTransactionInput(TransactionInput input, RawTransactionInput rawTransactionInput) {
var connectedOutputTx = getTransaction(input.getOutpoint().getHash());
checkNotNull(connectedOutputTx);
var outPoint1 = input.getOutpoint();
var outPoint2 = new TransactionOutPoint(params,
rawTransactionInput.index, new Transaction(params, rawTransactionInput.parentTransaction));
if (!outPoint1.equals(outPoint2))
return null;
var dummyTx = new Transaction(params);
dummyTx.addInput(connectedOutputTx.getOutput(input.getOutpoint().getIndex()));
return dummyTx.getInput(0);
}
}
64 changes: 56 additions & 8 deletions core/src/main/java/bisq/core/btc/wallet/BtcWalletService.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import bisq.core.btc.exceptions.WalletException;
import bisq.core.btc.model.AddressEntry;
import bisq.core.btc.model.AddressEntryList;
import bisq.core.btc.model.RawTransactionInput;
import bisq.core.btc.setup.WalletsSetup;
import bisq.core.provider.fee.FeeService;
import bisq.core.user.Preferences;
Expand Down Expand Up @@ -55,6 +56,7 @@

import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;
Expand Down Expand Up @@ -144,6 +146,21 @@ String getWalletAsString(boolean includePrivKeys) {
// Public Methods
///////////////////////////////////////////////////////////////////////////////////////////

public Transaction signTx(Transaction tx) throws WalletException, TransactionVerificationException {
for (int i = 0; i < tx.getInputs().size(); i++) {
var input = tx.getInput(i);
if (input.getConnectedOutput() != null && input.getConnectedOutput().isMine(wallet)) {
signTransactionInput(wallet, aesKey, tx, input, i);
checkScriptSig(tx, input, i);
}
}

checkWalletConsistency(wallet);
verifyTransaction(tx);
printTx("BTC wallet: Signed Tx", tx);
return tx;
}

///////////////////////////////////////////////////////////////////////////////////////////
// Burn BSQ txs (some proposal txs, asset listing fee tx, proof of burn tx)
///////////////////////////////////////////////////////////////////////////////////////////
Expand All @@ -157,12 +174,18 @@ public Transaction completePreparedBurnBsqTx(Transaction preparedBurnFeeTx, byte
// Proposal txs
///////////////////////////////////////////////////////////////////////////////////////////

public Transaction completePreparedReimbursementRequestTx(Coin issuanceAmount, Address issuanceAddress, Transaction feeTx, byte[] opReturnData)
public Transaction completePreparedReimbursementRequestTx(Coin issuanceAmount,
Address issuanceAddress,
Transaction feeTx,
byte[] opReturnData)
throws TransactionVerificationException, WalletException, InsufficientMoneyException {
return completePreparedProposalTx(feeTx, opReturnData, issuanceAmount, issuanceAddress);
}

public Transaction completePreparedCompensationRequestTx(Coin issuanceAmount, Address issuanceAddress, Transaction feeTx, byte[] opReturnData)
public Transaction completePreparedCompensationRequestTx(Coin issuanceAmount,
Address issuanceAddress,
Transaction feeTx,
byte[] opReturnData)
throws TransactionVerificationException, WalletException, InsufficientMoneyException {
return completePreparedProposalTx(feeTx, opReturnData, issuanceAmount, issuanceAddress);
}
Expand Down Expand Up @@ -292,7 +315,8 @@ public Transaction completePreparedBlindVoteTx(Transaction preparedTx, byte[] op
return completePreparedBsqTxWithBtcFee(preparedTx, opReturnData);
}

private Transaction completePreparedBsqTxWithBtcFee(Transaction preparedTx, byte[] opReturnData) throws InsufficientMoneyException, TransactionVerificationException, WalletException {
private Transaction completePreparedBsqTxWithBtcFee(Transaction preparedTx, byte[] opReturnData)
throws InsufficientMoneyException, TransactionVerificationException, WalletException {
// Remember index for first BTC input
int indexOfBtcFirstInput = preparedTx.getInputs().size();

Expand All @@ -306,7 +330,8 @@ private Transaction completePreparedBsqTxWithBtcFee(Transaction preparedTx, byte
return tx;
}

private Transaction addInputsForMinerFee(Transaction preparedTx, byte[] opReturnData) throws InsufficientMoneyException {
private Transaction addInputsForMinerFee(Transaction preparedTx, byte[] opReturnData)
throws InsufficientMoneyException {
// safety check counter to avoid endless loops
int counter = 0;
// estimated size of input sig
Expand Down Expand Up @@ -376,7 +401,6 @@ private void signAllBtcInputs(int indexOfBtcFirstInput, Transaction tx) throws T
}
}


///////////////////////////////////////////////////////////////////////////////////////////
// Vote reveal tx
///////////////////////////////////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -421,8 +445,10 @@ public Transaction completePreparedSendBsqTx(Transaction preparedBsqTx, boolean
return completePreparedBsqTx(preparedBsqTx, isSendTx, null);
}

public Transaction completePreparedBsqTx(Transaction preparedBsqTx, boolean useCustomTxFee, @Nullable byte[] opReturnData) throws
TransactionVerificationException, WalletException, InsufficientMoneyException {
public Transaction completePreparedBsqTx(Transaction preparedBsqTx,
boolean useCustomTxFee,
@Nullable byte[] opReturnData)
throws TransactionVerificationException, WalletException, InsufficientMoneyException {

// preparedBsqTx has following structure:
// inputs [1-n] BSQ inputs
Expand Down Expand Up @@ -545,7 +571,8 @@ public void commitTx(Transaction tx) {
// AddressEntry
///////////////////////////////////////////////////////////////////////////////////////////

public Optional<AddressEntry> getAddressEntry(String offerId, @SuppressWarnings("SameParameterValue") AddressEntry.Context context) {
public Optional<AddressEntry> getAddressEntry(String offerId,
@SuppressWarnings("SameParameterValue") AddressEntry.Context context) {
return getAddressEntryListAsImmutableList().stream()
.filter(e -> offerId.equals(e.getOfferId()))
.filter(e -> context == e.getContext())
Expand Down Expand Up @@ -1175,4 +1202,25 @@ public Transaction createRefundPayoutTx(Coin buyerAmount,

return resultTx;
}

// There is currently no way to verify that this tx hasn't been spent already
// If it has, the atomic tx won't confirm, not good but no funds lost
public long getBtcRawInputAmount(List<RawTransactionInput> inputs) {
return inputs.stream()
.map(rawInput -> {
var tx = getTxFromSerializedTx(rawInput.parentTransaction);
return tx.getOutput(rawInput.index).getValue().getValue();
})
.reduce(Long::sum)
.orElse(0L);
}

// There is currently no way to verify that this tx hasn't been spent already
// If it has, the atomic tx won't confirm, not good but no funds lost
public long getBtcInputAmount(List<TransactionInput> inputs) {
return inputs.stream()
.map(input -> Objects.requireNonNull(input.getValue()).getValue())
.reduce(Long::sum)
.orElse(0L);
}
}
Loading