From 722b02f4c989eae15d39bcf5d4b77da5041b6a97 Mon Sep 17 00:00:00 2001 From: woodser Date: Sun, 11 Jun 2023 15:28:10 -0400 Subject: [PATCH] support reserving exact offer amount by splitting output --- .../main/java/haveno/core/api/CoreApi.java | 2 + .../haveno/core/api/CoreOffersService.java | 4 + .../main/java/haveno/core/offer/Offer.java | 1 + .../java/haveno/core/offer/OpenOffer.java | 22 +- .../haveno/core/offer/OpenOfferManager.java | 278 +++++++++++++----- .../offer/placeoffer/PlaceOfferModel.java | 8 +- .../offer/placeoffer/PlaceOfferProtocol.java | 10 +- .../placeoffer/tasks/AddToOfferBook.java | 6 +- .../placeoffer/tasks/CreateMakerFeeTx.java | 2 +- .../tasks/MakerProcessSignOfferResponse.java | 4 +- .../tasks/MakerReserveOfferFunds.java | 11 +- .../tasks/MakerSendSignOfferRequest.java | 12 +- .../offer/placeoffer/tasks/ValidateOffer.java | 2 +- .../java/haveno/core/trade/TradeManager.java | 3 +- .../tasks/MakerSendInitTradeRequest.java | 2 +- .../tasks/MaybeSendSignContractRequest.java | 16 +- .../tasks/TakerReserveTradeFunds.java | 2 +- .../java/haveno/core/user/Preferences.java | 11 + .../haveno/core/user/PreferencesPayload.java | 3 + .../core/xmr/model/XmrAddressEntry.java | 10 +- .../core/xmr/wallet/XmrWalletService.java | 114 ++++--- .../resources/i18n/displayStrings.properties | 3 +- .../haveno/daemon/grpc/GrpcOffersService.java | 1 + .../main/funds/deposit/DepositListItem.java | 4 +- .../main/funds/deposit/DepositView.java | 4 +- .../main/offer/MutableOfferDataModel.java | 14 + .../desktop/main/offer/MutableOfferView.java | 19 ++ .../main/offer/MutableOfferViewModel.java | 5 + .../editoffer/EditOfferViewModel.java | 1 + proto/src/main/proto/grpc.proto | 3 +- proto/src/main/proto/pb.proto | 20 +- 31 files changed, 424 insertions(+), 173 deletions(-) diff --git a/core/src/main/java/haveno/core/api/CoreApi.java b/core/src/main/java/haveno/core/api/CoreApi.java index 1662e3fca4..99515019ca 100644 --- a/core/src/main/java/haveno/core/api/CoreApi.java +++ b/core/src/main/java/haveno/core/api/CoreApi.java @@ -405,6 +405,7 @@ public class CoreApi { long minAmountAsLong, double buyerSecurityDeposit, String triggerPriceAsString, + boolean splitOutput, String paymentAccountId, Consumer resultHandler, ErrorMessageHandler errorMessageHandler) { @@ -417,6 +418,7 @@ public class CoreApi { minAmountAsLong, buyerSecurityDeposit, triggerPriceAsString, + splitOutput, paymentAccountId, resultHandler, errorMessageHandler); diff --git a/core/src/main/java/haveno/core/api/CoreOffersService.java b/core/src/main/java/haveno/core/api/CoreOffersService.java index 4fcf3ea5c8..bfc087f870 100644 --- a/core/src/main/java/haveno/core/api/CoreOffersService.java +++ b/core/src/main/java/haveno/core/api/CoreOffersService.java @@ -165,6 +165,7 @@ public class CoreOffersService { long minAmountAsLong, double buyerSecurityDeposit, String triggerPriceAsString, + boolean splitOutput, String paymentAccountId, Consumer resultHandler, ErrorMessageHandler errorMessageHandler) { @@ -200,6 +201,7 @@ public class CoreOffersService { placeOffer(offer, triggerPriceAsString, useSavingsWallet, + splitOutput, transaction -> resultHandler.accept(offer), errorMessageHandler); } @@ -269,12 +271,14 @@ public class CoreOffersService { private void placeOffer(Offer offer, String triggerPriceAsString, boolean useSavingsWallet, + boolean splitOutput, Consumer resultHandler, ErrorMessageHandler errorMessageHandler) { long triggerPriceAsLong = PriceUtil.getMarketPriceAsLong(triggerPriceAsString, offer.getCurrencyCode()); openOfferManager.placeOffer(offer, useSavingsWallet, triggerPriceAsLong, + splitOutput, resultHandler::accept, errorMessageHandler); } diff --git a/core/src/main/java/haveno/core/offer/Offer.java b/core/src/main/java/haveno/core/offer/Offer.java index bfcc06a61b..1f3b55fd5d 100644 --- a/core/src/main/java/haveno/core/offer/Offer.java +++ b/core/src/main/java/haveno/core/offer/Offer.java @@ -285,6 +285,7 @@ public class Offer implements NetworkPayload, PersistablePayload { public BigInteger getReserveAmount() { BigInteger reserveAmount = getDirection() == OfferDirection.BUY ? getBuyerSecurityDeposit() : getSellerSecurityDeposit(); if (getDirection() == OfferDirection.SELL) reserveAmount = reserveAmount.add(getAmount()); + reserveAmount = reserveAmount.add(getMakerFee()); return reserveAmount; } diff --git a/core/src/main/java/haveno/core/offer/OpenOffer.java b/core/src/main/java/haveno/core/offer/OpenOffer.java index 9945cfdd5a..3574cb5991 100644 --- a/core/src/main/java/haveno/core/offer/OpenOffer.java +++ b/core/src/main/java/haveno/core/offer/OpenOffer.java @@ -53,7 +53,7 @@ public final class OpenOffer implements Tradable { private State state; @Setter @Getter - private boolean autoSplit; + private boolean splitOutput; @Setter @Getter @Nullable @@ -62,6 +62,10 @@ public final class OpenOffer implements Tradable { @Getter @Nullable private List scheduledTxHashes; + @Setter + @Getter + @Nullable + String splitOutputTxHash; @Nullable @Setter @Getter @@ -92,10 +96,10 @@ public final class OpenOffer implements Tradable { this(offer, triggerPrice, false); } - public OpenOffer(Offer offer, long triggerPrice, boolean autoSplit) { + public OpenOffer(Offer offer, long triggerPrice, boolean splitOutput) { this.offer = offer; this.triggerPrice = triggerPrice; - this.autoSplit = autoSplit; + this.splitOutput = splitOutput; state = State.SCHEDULED; } @@ -106,17 +110,19 @@ public final class OpenOffer implements Tradable { private OpenOffer(Offer offer, State state, long triggerPrice, - boolean autoSplit, + boolean splitOutput, @Nullable String scheduledAmount, @Nullable List scheduledTxHashes, + String splitOutputTxHash, @Nullable String reserveTxHash, @Nullable String reserveTxHex, @Nullable String reserveTxKey) { this.offer = offer; this.state = state; this.triggerPrice = triggerPrice; - this.autoSplit = autoSplit; + this.splitOutput = splitOutput; this.scheduledTxHashes = scheduledTxHashes; + this.splitOutputTxHash = splitOutputTxHash; this.reserveTxHash = reserveTxHash; this.reserveTxHex = reserveTxHex; this.reserveTxKey = reserveTxKey; @@ -131,10 +137,11 @@ public final class OpenOffer implements Tradable { .setOffer(offer.toProtoMessage()) .setTriggerPrice(triggerPrice) .setState(protobuf.OpenOffer.State.valueOf(state.name())) - .setAutoSplit(autoSplit); + .setSplitOutput(splitOutput); Optional.ofNullable(scheduledAmount).ifPresent(e -> builder.setScheduledAmount(scheduledAmount)); Optional.ofNullable(scheduledTxHashes).ifPresent(e -> builder.addAllScheduledTxHashes(scheduledTxHashes)); + Optional.ofNullable(splitOutputTxHash).ifPresent(e -> builder.setSplitOutputTxHash(splitOutputTxHash)); Optional.ofNullable(reserveTxHash).ifPresent(e -> builder.setReserveTxHash(reserveTxHash)); Optional.ofNullable(reserveTxHex).ifPresent(e -> builder.setReserveTxHex(reserveTxHex)); Optional.ofNullable(reserveTxKey).ifPresent(e -> builder.setReserveTxKey(reserveTxKey)); @@ -146,9 +153,10 @@ public final class OpenOffer implements Tradable { OpenOffer openOffer = new OpenOffer(Offer.fromProto(proto.getOffer()), ProtoUtil.enumFromProto(OpenOffer.State.class, proto.getState().name()), proto.getTriggerPrice(), - proto.getAutoSplit(), + proto.getSplitOutput(), proto.getScheduledAmount(), proto.getScheduledTxHashesList(), + ProtoUtil.stringOrNullFromProto(proto.getSplitOutputTxHash()), proto.getReserveTxHash(), proto.getReserveTxHex(), proto.getReserveTxKey()); diff --git a/core/src/main/java/haveno/core/offer/OpenOfferManager.java b/core/src/main/java/haveno/core/offer/OpenOfferManager.java index 4a61993289..a3fc1d83e7 100644 --- a/core/src/main/java/haveno/core/offer/OpenOfferManager.java +++ b/core/src/main/java/haveno/core/offer/OpenOfferManager.java @@ -56,6 +56,7 @@ import haveno.core.user.Preferences; import haveno.core.user.User; import haveno.core.util.JsonUtil; import haveno.core.util.Validator; +import haveno.core.xmr.model.XmrAddressEntry; import haveno.core.xmr.wallet.BtcWalletService; import haveno.core.xmr.wallet.MoneroKeyImageListener; import haveno.core.xmr.wallet.MoneroKeyImagePoller; @@ -79,6 +80,9 @@ import monero.common.MoneroRpcConnection; import monero.daemon.model.MoneroKeyImageSpentStatus; import monero.daemon.model.MoneroTx; import monero.wallet.model.MoneroIncomingTransfer; +import monero.wallet.model.MoneroOutputQuery; +import monero.wallet.model.MoneroTransferQuery; +import monero.wallet.model.MoneroTxConfig; import monero.wallet.model.MoneroTxQuery; import monero.wallet.model.MoneroTxWallet; import monero.wallet.model.MoneroWalletListener; @@ -90,6 +94,7 @@ import javax.annotation.Nullable; import javax.inject.Inject; import java.math.BigInteger; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -290,7 +295,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe // process unposted offers processUnpostedOffers((transaction) -> {}, (errorMessage) -> { - log.warn("Error processing unposted offers on new unlocked balance: " + errorMessage); + log.warn("Error processing unposted offers: " + errorMessage); }); // register to process unposted offers when unlocked balance increases @@ -300,7 +305,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe public void onBalancesChanged(BigInteger newBalance, BigInteger newUnlockedBalance) { if (lastUnlockedBalance == null || lastUnlockedBalance.compareTo(newUnlockedBalance) < 0) { processUnpostedOffers((transaction) -> {}, (errorMessage) -> { - log.warn("Error processing unposted offers on new unlocked balance: " + errorMessage); + log.warn("Error processing unposted offers on new unlocked balance: " + errorMessage); // TODO: popup to notify user that offer did not post }); } lastUnlockedBalance = newUnlockedBalance; @@ -485,16 +490,13 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe public void placeOffer(Offer offer, boolean useSavingsWallet, long triggerPrice, + boolean splitOutput, TransactionResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { checkNotNull(offer.getMakerFee(), "makerFee must not be null"); - boolean autoSplit = false; // TODO: support in api - - // TODO (woodser): validate offer - // create open offer - OpenOffer openOffer = new OpenOffer(offer, triggerPrice, autoSplit); + OpenOffer openOffer = new OpenOffer(offer, triggerPrice, splitOutput); // process open offer to schedule or post processUnpostedOffer(getOpenOffers(), openOffer, (transaction) -> { @@ -786,74 +788,201 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe private void processUnpostedOffer(List openOffers, OpenOffer openOffer, TransactionResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { new Thread(() -> { - try { + synchronized (xmrWalletService) { + try { - // done processing if wallet not initialized - if (xmrWalletService.getWallet() == null) { - resultHandler.handleResult(null); - return; - } - - // get offer reserve amount - BigInteger offerReserveAmount = openOffer.getOffer().getReserveAmount(); - - // handle sufficient available balance - if (xmrWalletService.getWallet().getUnlockedBalance(0).compareTo(offerReserveAmount) >= 0) { - - // split outputs if applicable - boolean splitOutput = openOffer.isAutoSplit(); // TODO: determine if output needs split - if (splitOutput) { - throw new Error("Post offer with split output option not yet supported"); // TODO: support scheduling offer with split outputs + // done processing if wallet not initialized + if (xmrWalletService.getWallet() == null) { + resultHandler.handleResult(null); + return; } - - // otherwise sign and post offer - else { - signAndPostOffer(openOffer, offerReserveAmount, true, resultHandler, errorMessageHandler); - } - return; - } - - // handle unscheduled offer - if (openOffer.getScheduledTxHashes() == null) { - log.info("Scheduling offer " + openOffer.getId()); - - // check for sufficient balance - scheduled offers amount - if (xmrWalletService.getWallet().getBalance(0).subtract(getScheduledAmount(openOffers)).compareTo(offerReserveAmount) < 0) { - throw new RuntimeException("Not enough money in Haveno wallet"); - } - - // get locked txs - List lockedTxs = xmrWalletService.getWallet().getTxs(new MoneroTxQuery().setIsLocked(true)); - - // get earliest unscheduled txs with sufficient incoming amount - List scheduledTxHashes = new ArrayList(); - BigInteger scheduledAmount = BigInteger.valueOf(0); - for (MoneroTxWallet lockedTx : lockedTxs) { - if (isTxScheduled(openOffers, lockedTx.getHash())) continue; - if (lockedTx.getIncomingTransfers() == null || lockedTx.getIncomingTransfers().isEmpty()) continue; - scheduledTxHashes.add(lockedTx.getHash()); - for (MoneroIncomingTransfer transfer : lockedTx.getIncomingTransfers()) { - if (transfer.getAccountIndex() == 0) scheduledAmount = scheduledAmount.add(transfer.getAmount()); + + // get offer reserve amount + BigInteger offerReserveAmount = openOffer.getOffer().getReserveAmount(); + // handle split output offer + if (openOffer.isSplitOutput()) { + + // get tx to fund split output + MoneroTxWallet splitOutputTx = findSplitOutputFundingTx(openOffers, openOffer); + if (openOffer.getScheduledTxHashes() == null && splitOutputTx != null) { + openOffer.setScheduledTxHashes(Arrays.asList(splitOutputTx.getHash())); + openOffer.setSplitOutputTxHash(splitOutputTx.getHash()); + openOffer.setScheduledAmount(offerReserveAmount.toString()); + openOffer.setState(OpenOffer.State.SCHEDULED); + } + + // handle split output available + if (splitOutputTx != null && !splitOutputTx.isLocked()) { + signAndPostOffer(openOffer, true, resultHandler, errorMessageHandler); + return; + } else if (splitOutputTx == null) { + + // handle sufficient available balance to split output + boolean sufficientAvailableBalance = xmrWalletService.getWallet().getUnlockedBalance(0).compareTo(offerReserveAmount) >= 0; + if (sufficientAvailableBalance) { + + // create and relay tx to split output + splitOutputTx = createAndRelaySplitOutputTx(openOffer); // TODO: confirm with user? + + // schedule txs + openOffer.setScheduledTxHashes(Arrays.asList(splitOutputTx.getHash())); + openOffer.setSplitOutputTxHash(splitOutputTx.getHash()); + openOffer.setScheduledAmount(offerReserveAmount.toString()); + openOffer.setState(OpenOffer.State.SCHEDULED); + } else if (openOffer.getScheduledTxHashes() == null) { + scheduleOfferWithEarliestTxs(openOffers, openOffer); + } + } + } else { + + // handle sufficient balance + boolean hasSufficientBalance = xmrWalletService.getWallet().getUnlockedBalance(0).compareTo(offerReserveAmount) >= 0; + if (hasSufficientBalance) { + signAndPostOffer(openOffer, true, resultHandler, errorMessageHandler); + return; + } else if (openOffer.getScheduledTxHashes() == null) { + scheduleOfferWithEarliestTxs(openOffers, openOffer); } - if (scheduledAmount.compareTo(offerReserveAmount) >= 0) break; } - if (scheduledAmount.compareTo(offerReserveAmount) < 0) throw new RuntimeException("Not enough funds to schedule offer"); - - // schedule txs - openOffer.setScheduledTxHashes(scheduledTxHashes); - openOffer.setScheduledAmount(scheduledAmount.toString()); - openOffer.setState(OpenOffer.State.SCHEDULED); + + // handle result + resultHandler.handleResult(null); + } catch (Exception e) { + e.printStackTrace(); + errorMessageHandler.handleErrorMessage(e.getMessage()); } - - // handle result - resultHandler.handleResult(null); - } catch (Exception e) { - e.printStackTrace(); - errorMessageHandler.handleErrorMessage(e.getMessage()); } }).start(); } + public boolean hasAvailableOutput(BigInteger amount) { + return findSplitOutputFundingTx(getOpenOffers(), amount, null) != null; + } + + private MoneroTxWallet findSplitOutputFundingTx(List openOffers, OpenOffer openOffer) { + + // return split output tx if already assigned + if (openOffer.getSplitOutputTxHash() != null) { + return xmrWalletService.getWallet().getTx(openOffer.getSplitOutputTxHash()); + } + + // find tx with exact output + XmrAddressEntry addressEntry = xmrWalletService.getOrCreateAddressEntry(openOffer.getId(), XmrAddressEntry.Context.OFFER_FUNDING); + return findSplitOutputFundingTx(openOffers, openOffer.getOffer().getReserveAmount(), addressEntry.getSubaddressIndex()); + } + + private MoneroTxWallet findSplitOutputFundingTx(List openOffers, BigInteger reserveAmount, Integer subaddressIndex) { + List fundingTxs = new ArrayList<>(); + MoneroTxWallet earliestUnscheduledTx = null; + if (subaddressIndex != null) { + + // return earliest tx with exact confirmed output to fund offer's subaddress if available + fundingTxs = xmrWalletService.getWallet().getTxs(new MoneroTxQuery() + .setIsConfirmed(true) + .setOutputQuery(new MoneroOutputQuery() + .setAccountIndex(0) + .setSubaddressIndex(subaddressIndex) + .setAmount(reserveAmount) + .setIsSpent(false) + .setIsFrozen(false))); + earliestUnscheduledTx = getEarliestUnscheduledTx(openOffers, fundingTxs); + if (earliestUnscheduledTx != null) return earliestUnscheduledTx; + } + + // cache all transactions including from pool + List allTxs = xmrWalletService.getWallet().getTxs(new MoneroTxQuery().setIncludeOutputs(true)); + + if (subaddressIndex != null) { + + // return earliest tx with exact incoming transfer to fund offer's subaddress if available (since outputs are not available until confirmed) + fundingTxs.clear(); + for (MoneroTxWallet tx : allTxs) { + boolean hasExactTransfer = tx.getTransfers(new MoneroTransferQuery() + .setIsIncoming(true) + .setAccountIndex(0) + .setSubaddressIndex(subaddressIndex) + .setAmount(reserveAmount)).size() > 0; + if (hasExactTransfer) fundingTxs.add(tx); + } + earliestUnscheduledTx = getEarliestUnscheduledTx(openOffers, fundingTxs); + if (earliestUnscheduledTx != null) return earliestUnscheduledTx; + } + + // return earliest tx with exact confirmed output to any subaddress if available + fundingTxs.clear(); + for (MoneroTxWallet tx : allTxs) { + boolean hasExactOutput = tx.getOutputsWallet(new MoneroOutputQuery() + .setAccountIndex(0) + .setAmount(reserveAmount) + .setIsSpent(false) + .setIsFrozen(false)).size() > 0; + if (hasExactOutput) fundingTxs.add(tx); + } + earliestUnscheduledTx = getEarliestUnscheduledTx(openOffers, fundingTxs); + if (earliestUnscheduledTx != null) return earliestUnscheduledTx; + + // return earliest tx with exact incoming transfer to any subaddress if available (since outputs are not available until confirmed) + fundingTxs.clear(); + for (MoneroTxWallet tx : allTxs) { + boolean hasExactTransfer = tx.getTransfers(new MoneroTransferQuery() + .setIsIncoming(true) + .setAccountIndex(0) + .setAmount(reserveAmount)).size() > 0; + if (hasExactTransfer) fundingTxs.add(tx); + } + return getEarliestUnscheduledTx(openOffers, fundingTxs); + } + + private MoneroTxWallet getEarliestUnscheduledTx(List openOffers, List txs) { + MoneroTxWallet earliestUnscheduledTx = null; + for (MoneroTxWallet tx : txs) { + if (isTxScheduled(openOffers, tx.getHash())) continue; + if (earliestUnscheduledTx == null || (earliestUnscheduledTx.getNumConfirmations() < tx.getNumConfirmations())) earliestUnscheduledTx = tx; + } + return earliestUnscheduledTx; + } + + private void scheduleOfferWithEarliestTxs(List openOffers, OpenOffer openOffer) { + + // check for sufficient balance - scheduled offers amount + BigInteger offerReserveAmount = openOffer.getOffer().getReserveAmount(); + if (xmrWalletService.getWallet().getBalance(0).subtract(getScheduledAmount(openOffers)).compareTo(offerReserveAmount) < 0) { + throw new RuntimeException("Not enough money in Haveno wallet"); + } + + // get locked txs + List lockedTxs = xmrWalletService.getWallet().getTxs(new MoneroTxQuery().setIsLocked(true)); + + // get earliest unscheduled txs with sufficient incoming amount + List scheduledTxHashes = new ArrayList(); + BigInteger scheduledAmount = BigInteger.valueOf(0); + for (MoneroTxWallet lockedTx : lockedTxs) { + if (isTxScheduled(openOffers, lockedTx.getHash())) continue; + if (lockedTx.getIncomingTransfers() == null || lockedTx.getIncomingTransfers().isEmpty()) continue; + scheduledTxHashes.add(lockedTx.getHash()); + for (MoneroIncomingTransfer transfer : lockedTx.getIncomingTransfers()) { + if (transfer.getAccountIndex() == 0) scheduledAmount = scheduledAmount.add(transfer.getAmount()); + } + if (scheduledAmount.compareTo(offerReserveAmount) >= 0) break; + } + if (scheduledAmount.compareTo(offerReserveAmount) < 0) throw new RuntimeException("Not enough funds to schedule offer"); + + // schedule txs + openOffer.setScheduledTxHashes(scheduledTxHashes); + openOffer.setScheduledAmount(scheduledAmount.toString()); + openOffer.setState(OpenOffer.State.SCHEDULED); + } + + private MoneroTxWallet createAndRelaySplitOutputTx(OpenOffer openOffer) { + BigInteger reserveAmount = openOffer.getOffer().getReserveAmount(); + String fundingSubaddress = xmrWalletService.getAddressEntry(openOffer.getId(), XmrAddressEntry.Context.OFFER_FUNDING).get().getAddressString(); + return xmrWalletService.getWallet().createTx(new MoneroTxConfig() + .setAccountIndex(0) + .setAddress(fundingSubaddress) + .setAmount(reserveAmount) + .setRelay(true)); + } + private BigInteger getScheduledAmount(List openOffers) { BigInteger scheduledAmount = BigInteger.valueOf(0); for (OpenOffer openOffer : openOffers) { @@ -861,8 +990,10 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe if (openOffer.getScheduledTxHashes() == null) continue; List fundingTxs = xmrWalletService.getWallet().getTxs(openOffer.getScheduledTxHashes()); for (MoneroTxWallet fundingTx : fundingTxs) { - for (MoneroIncomingTransfer transfer : fundingTx.getIncomingTransfers()) { - if (transfer.getAccountIndex() == 0) scheduledAmount = scheduledAmount.add(transfer.getAmount()); + if (fundingTx.getIncomingTransfers() != null) { + for (MoneroIncomingTransfer transfer : fundingTx.getIncomingTransfers()) { + if (transfer.getAccountIndex() == 0) scheduledAmount = scheduledAmount.add(transfer.getAmount()); + } } } } @@ -881,14 +1012,13 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe } private void signAndPostOffer(OpenOffer openOffer, - BigInteger offerReserveAmount, - boolean useSavingsWallet, // TODO: remove this + boolean useSavingsWallet, // TODO: remove this? TransactionResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { log.info("Signing and posting offer " + openOffer.getId()); // create model - PlaceOfferModel model = new PlaceOfferModel(openOffer.getOffer(), - offerReserveAmount, + PlaceOfferModel model = new PlaceOfferModel(openOffer, + openOffer.getOffer().getReserveAmount(), useSavingsWallet, p2PService, btcWalletService, @@ -914,6 +1044,8 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe // set offer state openOffer.setState(OpenOffer.State.AVAILABLE); + openOffer.setScheduledTxHashes(null); + openOffer.setScheduledAmount(null); requestPersistence(); resultHandler.handleResult(transaction); @@ -1397,7 +1529,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe synchronized (openOffers) { contained = openOffers.contains(openOffer); } - if (contained && !openOffer.isDeactivated() && openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() != null) { + if (contained && !openOffer.isDeactivated() && openOffer.getOffer().getOfferPayload().getReserveTxKeyImages() != null && !openOffer.getOffer().getOfferPayload().getReserveTxKeyImages().isEmpty()) { // TODO It is not clear yet if it is better for the node and the network to send out all add offer // messages in one go or to spread it over a delay. With power users who have 100-200 offers that can have // some significant impact to user experience and the network diff --git a/core/src/main/java/haveno/core/offer/placeoffer/PlaceOfferModel.java b/core/src/main/java/haveno/core/offer/placeoffer/PlaceOfferModel.java index e1662b6ce5..30c4b2233c 100644 --- a/core/src/main/java/haveno/core/offer/placeoffer/PlaceOfferModel.java +++ b/core/src/main/java/haveno/core/offer/placeoffer/PlaceOfferModel.java @@ -21,8 +21,8 @@ import haveno.common.crypto.KeyRing; import haveno.common.taskrunner.Model; import haveno.core.account.witness.AccountAgeWitnessService; import haveno.core.filter.FilterManager; -import haveno.core.offer.Offer; import haveno.core.offer.OfferBookService; +import haveno.core.offer.OpenOffer; import haveno.core.offer.messages.SignOfferResponse; import haveno.core.support.dispute.arbitration.arbitrator.ArbitratorManager; import haveno.core.support.dispute.mediation.mediator.MediatorManager; @@ -44,7 +44,7 @@ import java.math.BigInteger; @Getter public class PlaceOfferModel implements Model { // Immutable - private final Offer offer; + private final OpenOffer openOffer; private final BigInteger reservedFundsForOffer; private final boolean useSavingsWallet; private final P2PService p2PService; @@ -72,7 +72,7 @@ public class PlaceOfferModel implements Model { @Setter private SignOfferResponse signOfferResponse; - public PlaceOfferModel(Offer offer, + public PlaceOfferModel(OpenOffer openOffer, BigInteger reservedFundsForOffer, boolean useSavingsWallet, P2PService p2PService, @@ -87,7 +87,7 @@ public class PlaceOfferModel implements Model { KeyRing keyRing, FilterManager filterManager, AccountAgeWitnessService accountAgeWitnessService) { - this.offer = offer; + this.openOffer = openOffer; this.reservedFundsForOffer = reservedFundsForOffer; this.useSavingsWallet = useSavingsWallet; this.p2PService = p2PService; diff --git a/core/src/main/java/haveno/core/offer/placeoffer/PlaceOfferProtocol.java b/core/src/main/java/haveno/core/offer/placeoffer/PlaceOfferProtocol.java index b89e47939a..3875b5cc8e 100644 --- a/core/src/main/java/haveno/core/offer/placeoffer/PlaceOfferProtocol.java +++ b/core/src/main/java/haveno/core/offer/placeoffer/PlaceOfferProtocol.java @@ -84,17 +84,17 @@ public class PlaceOfferProtocol { // TODO (woodser): switch to fluent public void handleSignOfferResponse(SignOfferResponse response, NodeAddress sender) { - log.debug("handleSignOfferResponse() " + model.getOffer().getId()); + log.debug("handleSignOfferResponse() " + model.getOpenOffer().getOffer().getId()); model.setSignOfferResponse(response); - if (!model.getOffer().getOfferPayload().getArbitratorSigner().equals(sender)) { + if (!model.getOpenOffer().getOffer().getOfferPayload().getArbitratorSigner().equals(sender)) { log.warn("Ignoring sign offer response from different sender"); return; } // ignore if timer already stopped if (timeoutTimer == null) { - log.warn("Ignoring sign offer response from arbitrator because timeout has expired for offer " + model.getOffer().getId()); + log.warn("Ignoring sign offer response from arbitrator because timeout has expired for offer " + model.getOpenOffer().getOffer().getId()); return; } @@ -112,7 +112,7 @@ public class PlaceOfferProtocol { }, (errorMessage) -> { if (model.isOfferAddedToOfferBook()) { - model.getOfferBookService().removeOffer(model.getOffer().getOfferPayload(), + model.getOfferBookService().removeOffer(model.getOpenOffer().getOffer().getOfferPayload(), () -> { model.setOfferAddedToOfferBook(false); log.debug("OfferPayload removed from offer book."); @@ -141,7 +141,7 @@ public class PlaceOfferProtocol { if (timeoutTimer != null) { log.error(errorMessage); stopTimeoutTimer(); - model.getOffer().setErrorMessage(errorMessage); + model.getOpenOffer().getOffer().setErrorMessage(errorMessage); errorMessageHandler.handleErrorMessage(errorMessage); } } diff --git a/core/src/main/java/haveno/core/offer/placeoffer/tasks/AddToOfferBook.java b/core/src/main/java/haveno/core/offer/placeoffer/tasks/AddToOfferBook.java index cdb187e116..3c4325dc74 100644 --- a/core/src/main/java/haveno/core/offer/placeoffer/tasks/AddToOfferBook.java +++ b/core/src/main/java/haveno/core/offer/placeoffer/tasks/AddToOfferBook.java @@ -34,20 +34,20 @@ public class AddToOfferBook extends Task { protected void run() { try { runInterceptHook(); - checkNotNull(model.getSignOfferResponse().getSignedOfferPayload().getArbitratorSignature(), "Offer's arbitrator signature is null: " + model.getOffer().getId()); + checkNotNull(model.getSignOfferResponse().getSignedOfferPayload().getArbitratorSignature(), "Offer's arbitrator signature is null: " + model.getOpenOffer().getOffer().getId()); model.getOfferBookService().addOffer(new Offer(model.getSignOfferResponse().getSignedOfferPayload()), () -> { model.setOfferAddedToOfferBook(true); complete(); }, errorMessage -> { - model.getOffer().setErrorMessage("Could not add offer to offerbook.\n" + + model.getOpenOffer().getOffer().setErrorMessage("Could not add offer to offerbook.\n" + "Please check your network connection and try again."); failed(errorMessage); }); } catch (Throwable t) { - model.getOffer().setErrorMessage("An error occurred.\n" + + model.getOpenOffer().getOffer().setErrorMessage("An error occurred.\n" + "Error message:\n" + t.getMessage()); diff --git a/core/src/main/java/haveno/core/offer/placeoffer/tasks/CreateMakerFeeTx.java b/core/src/main/java/haveno/core/offer/placeoffer/tasks/CreateMakerFeeTx.java index 0753868eef..5f0402937e 100644 --- a/core/src/main/java/haveno/core/offer/placeoffer/tasks/CreateMakerFeeTx.java +++ b/core/src/main/java/haveno/core/offer/placeoffer/tasks/CreateMakerFeeTx.java @@ -40,7 +40,7 @@ public class CreateMakerFeeTx extends Task { @Override protected void run() { - Offer offer = model.getOffer(); + Offer offer = model.getOpenOffer().getOffer(); try { runInterceptHook(); diff --git a/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerProcessSignOfferResponse.java b/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerProcessSignOfferResponse.java index 30af3bfea9..1616c7eff6 100644 --- a/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerProcessSignOfferResponse.java +++ b/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerProcessSignOfferResponse.java @@ -33,7 +33,7 @@ public class MakerProcessSignOfferResponse extends Task { @Override protected void run() { - Offer offer = model.getOffer(); + Offer offer = model.getOpenOffer().getOffer(); try { runInterceptHook(); @@ -46,7 +46,7 @@ public class MakerProcessSignOfferResponse extends Task { } // set arbitrator signature for maker's offer - model.getOffer().getOfferPayload().setArbitratorSignature(model.getSignOfferResponse().getSignedOfferPayload().getArbitratorSignature()); + offer.getOfferPayload().setArbitratorSignature(model.getSignOfferResponse().getSignedOfferPayload().getArbitratorSignature()); offer.setState(Offer.State.AVAILABLE); complete(); } catch (Exception e) { diff --git a/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerReserveOfferFunds.java b/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerReserveOfferFunds.java index 7314f04f53..5b9fb4ae0c 100644 --- a/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerReserveOfferFunds.java +++ b/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerReserveOfferFunds.java @@ -41,7 +41,7 @@ public class MakerReserveOfferFunds extends Task { @Override protected void run() { - Offer offer = model.getOffer(); + Offer offer = model.getOpenOffer().getOffer(); try { runInterceptHook(); @@ -53,12 +53,15 @@ public class MakerReserveOfferFunds extends Task { BigInteger makerFee = offer.getMakerFee(); BigInteger sendAmount = offer.getDirection() == OfferDirection.BUY ? BigInteger.valueOf(0) : offer.getAmount(); BigInteger securityDeposit = offer.getDirection() == OfferDirection.BUY ? offer.getBuyerSecurityDeposit() : offer.getSellerSecurityDeposit(); - String returnAddress = model.getXmrWalletService().getNewAddressEntry(offer.getId(), XmrAddressEntry.Context.RESERVED_FOR_TRADE).getAddressString(); - MoneroTxWallet reserveTx = model.getXmrWalletService().createReserveTx(makerFee, sendAmount, securityDeposit, returnAddress); + String returnAddress = model.getXmrWalletService().getOrCreateAddressEntry(offer.getId(), XmrAddressEntry.Context.TRADE_PAYOUT).getAddressString(); + BigInteger exactOutputAmount = model.getOpenOffer().isSplitOutput() ? model.getOpenOffer().getOffer().getReserveAmount() : null; + XmrAddressEntry fundingEntry = model.getXmrWalletService().getAddressEntry(offer.getId(), XmrAddressEntry.Context.OFFER_FUNDING).orElse(null); + Integer preferredSubaddressIndex = model.getOpenOffer().isSplitOutput() && fundingEntry != null ? fundingEntry.getSubaddressIndex() : null; + MoneroTxWallet reserveTx = model.getXmrWalletService().createReserveTx(makerFee, sendAmount, securityDeposit, returnAddress, exactOutputAmount, preferredSubaddressIndex); // check for error in case creating reserve tx exceeded timeout // TODO: better way? - if (!model.getXmrWalletService().getAddressEntry(offer.getId(), XmrAddressEntry.Context.RESERVED_FOR_TRADE).isPresent()) { + if (!model.getXmrWalletService().getAddressEntry(offer.getId(), XmrAddressEntry.Context.TRADE_PAYOUT).isPresent()) { throw new RuntimeException("An error has occurred posting offer " + offer.getId() + " causing its subaddress entry to be deleted"); } diff --git a/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerSendSignOfferRequest.java b/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerSendSignOfferRequest.java index c0b3408781..36001439e7 100644 --- a/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerSendSignOfferRequest.java +++ b/core/src/main/java/haveno/core/offer/placeoffer/tasks/MakerSendSignOfferRequest.java @@ -55,14 +55,14 @@ public class MakerSendSignOfferRequest extends Task { @Override protected void run() { - Offer offer = model.getOffer(); + Offer offer = model.getOpenOffer().getOffer(); try { runInterceptHook(); // create request for arbitrator to sign offer - String returnAddress = model.getXmrWalletService().getAddressEntry(offer.getId(), XmrAddressEntry.Context.RESERVED_FOR_TRADE).get().getAddressString(); + String returnAddress = model.getXmrWalletService().getAddressEntry(offer.getId(), XmrAddressEntry.Context.TRADE_PAYOUT).get().getAddressString(); SignOfferRequest request = new SignOfferRequest( - model.getOffer().getId(), + offer.getId(), P2PService.getMyNodeAddress(), model.getKeyRing().getPubKeyRing(), model.getUser().getAccountId(), @@ -113,8 +113,8 @@ public class MakerSendSignOfferRequest extends Task { if (!ackMessage.getSourceUid().equals(request.getUid())) return; if (ackMessage.isSuccess()) { model.getP2PService().removeDecryptedDirectMessageListener(this); - model.getOffer().getOfferPayload().setArbitratorSigner(arbitratorNodeAddress); - model.getOffer().setState(Offer.State.OFFER_FEE_RESERVED); + model.getOpenOffer().getOffer().getOfferPayload().setArbitratorSigner(arbitratorNodeAddress); + model.getOpenOffer().getOffer().setState(Offer.State.OFFER_FEE_RESERVED); resultHandler.handleResult(); } else { errorMessageHandler.handleErrorMessage("Arbitrator nacked SignOfferRequest for offer " + request.getOfferId() + ": " + ackMessage.getErrorMessage()); @@ -127,7 +127,7 @@ public class MakerSendSignOfferRequest extends Task { sendSignOfferRequest(request, arbitratorNodeAddress, new SendDirectMessageListener() { @Override public void onArrived() { - log.info("{} arrived at arbitrator: offerId={}", request.getClass().getSimpleName(), model.getOffer().getId()); + log.info("{} arrived at arbitrator: offerId={}", request.getClass().getSimpleName(), model.getOpenOffer().getId()); } // if unavailable, try alternative arbitrator diff --git a/core/src/main/java/haveno/core/offer/placeoffer/tasks/ValidateOffer.java b/core/src/main/java/haveno/core/offer/placeoffer/tasks/ValidateOffer.java index 7c0e2cb0dd..fdf397f97d 100644 --- a/core/src/main/java/haveno/core/offer/placeoffer/tasks/ValidateOffer.java +++ b/core/src/main/java/haveno/core/offer/placeoffer/tasks/ValidateOffer.java @@ -37,7 +37,7 @@ public class ValidateOffer extends Task { @Override protected void run() { - Offer offer = model.getOffer(); + Offer offer = model.getOpenOffer().getOffer(); try { runInterceptHook(); diff --git a/core/src/main/java/haveno/core/trade/TradeManager.java b/core/src/main/java/haveno/core/trade/TradeManager.java index 58357f70c8..52bfdad392 100644 --- a/core/src/main/java/haveno/core/trade/TradeManager.java +++ b/core/src/main/java/haveno/core/trade/TradeManager.java @@ -123,6 +123,7 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi @Getter private final CoreNotificationService notificationService; private final OfferBookService offerBookService; + @Getter private final OpenOfferManager openOfferManager; private final ClosedTradableManager closedTradableManager; private final FailedTradesManager failedTradesManager; @@ -1093,8 +1094,6 @@ public class TradeManager implements PersistedDataHost, DecryptedDirectMessageLi if (entries == null) return false; - xmrWalletService.recoverAddressEntry(trade.getId(), entries.first, - XmrAddressEntry.Context.MULTI_SIG); xmrWalletService.recoverAddressEntry(trade.getId(), entries.second, XmrAddressEntry.Context.TRADE_PAYOUT); return true; diff --git a/core/src/main/java/haveno/core/trade/protocol/tasks/MakerSendInitTradeRequest.java b/core/src/main/java/haveno/core/trade/protocol/tasks/MakerSendInitTradeRequest.java index e29c204a10..a257d55070 100644 --- a/core/src/main/java/haveno/core/trade/protocol/tasks/MakerSendInitTradeRequest.java +++ b/core/src/main/java/haveno/core/trade/protocol/tasks/MakerSendInitTradeRequest.java @@ -70,7 +70,7 @@ public class MakerSendInitTradeRequest extends TradeTask { trade.getSelf().getReserveTxHash(), trade.getSelf().getReserveTxHex(), trade.getSelf().getReserveTxKey(), - model.getXmrWalletService().getAddressEntry(offer.getId(), XmrAddressEntry.Context.RESERVED_FOR_TRADE).get().getAddressString(), + model.getXmrWalletService().getAddressEntry(offer.getId(), XmrAddressEntry.Context.TRADE_PAYOUT).get().getAddressString(), null); // send request to arbitrator diff --git a/core/src/main/java/haveno/core/trade/protocol/tasks/MaybeSendSignContractRequest.java b/core/src/main/java/haveno/core/trade/protocol/tasks/MaybeSendSignContractRequest.java index 6b404b7f55..0b04ebc58e 100644 --- a/core/src/main/java/haveno/core/trade/protocol/tasks/MaybeSendSignContractRequest.java +++ b/core/src/main/java/haveno/core/trade/protocol/tasks/MaybeSendSignContractRequest.java @@ -31,6 +31,7 @@ import lombok.extern.slf4j.Slf4j; import monero.daemon.model.MoneroOutput; import monero.wallet.model.MoneroTxWallet; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Date; import java.util.List; @@ -71,9 +72,20 @@ public class MaybeSendSignContractRequest extends TradeTask { return; } - // create deposit tx and freeze inputs + // initialize progress steps trade.addInitProgressStep(); - MoneroTxWallet depositTx = trade.getXmrWalletService().createDepositTx(trade); + + // create deposit tx and freeze inputs + Integer subaddressIndex = null; + BigInteger exactOutputAmount = null; + if (trade instanceof MakerTrade) { + boolean isSplitOutputOffer = processModel.getOpenOfferManager().getOpenOfferById(trade.getId()).get().isSplitOutput(); + if (isSplitOutputOffer) { + exactOutputAmount = trade.getOffer().getReserveAmount(); + subaddressIndex = model.getXmrWalletService().getAddressEntry(trade.getId(), XmrAddressEntry.Context.OFFER_FUNDING).get().getSubaddressIndex(); + } + } + MoneroTxWallet depositTx = trade.getXmrWalletService().createDepositTx(trade, exactOutputAmount, subaddressIndex); // collect reserved key images List reservedKeyImages = new ArrayList(); diff --git a/core/src/main/java/haveno/core/trade/protocol/tasks/TakerReserveTradeFunds.java b/core/src/main/java/haveno/core/trade/protocol/tasks/TakerReserveTradeFunds.java index a274237b66..69f6bb5fd4 100644 --- a/core/src/main/java/haveno/core/trade/protocol/tasks/TakerReserveTradeFunds.java +++ b/core/src/main/java/haveno/core/trade/protocol/tasks/TakerReserveTradeFunds.java @@ -44,7 +44,7 @@ public class TakerReserveTradeFunds extends TradeTask { BigInteger sendAmount = trade.getOffer().getDirection() == OfferDirection.BUY ? trade.getOffer().getAmount() : BigInteger.valueOf(0); BigInteger securityDeposit = trade.getOffer().getDirection() == OfferDirection.BUY ? trade.getOffer().getSellerSecurityDeposit() : trade.getOffer().getBuyerSecurityDeposit(); String returnAddress = model.getXmrWalletService().getOrCreateAddressEntry(trade.getOffer().getId(), XmrAddressEntry.Context.TRADE_PAYOUT).getAddressString(); - MoneroTxWallet reserveTx = model.getXmrWalletService().createReserveTx(takerFee, sendAmount, securityDeposit, returnAddress); + MoneroTxWallet reserveTx = model.getXmrWalletService().createReserveTx(takerFee, sendAmount, securityDeposit, returnAddress, null, null); // collect reserved key images List reservedKeyImages = new ArrayList(); diff --git a/core/src/main/java/haveno/core/user/Preferences.java b/core/src/main/java/haveno/core/user/Preferences.java index b22e1bf774..8cccce0e0d 100644 --- a/core/src/main/java/haveno/core/user/Preferences.java +++ b/core/src/main/java/haveno/core/user/Preferences.java @@ -497,6 +497,11 @@ public final class Preferences implements PersistedDataHost, BridgeAddressProvid requestPersistence(); } + public void setSplitOfferOutput(boolean splitOfferOutput) { + prefPayload.setSplitOfferOutput(splitOfferOutput); + requestPersistence(); + } + public void setShowOwnOffersInOfferBook(boolean showOwnOffersInOfferBook) { prefPayload.setShowOwnOffersInOfferBook(showOwnOffersInOfferBook); requestPersistence(); @@ -797,6 +802,10 @@ public final class Preferences implements PersistedDataHost, BridgeAddressProvid return prefPayload.isUseTorForMonero(); } + public boolean getSplitOfferOutput() { + return prefPayload.isSplitOfferOutput(); + } + public double getBuyerSecurityDepositAsPercent(PaymentAccount paymentAccount) { double value = PaymentAccountUtil.isCryptoCurrencyAccount(paymentAccount) ? prefPayload.getBuyerSecurityDepositAsPercentForCrypto() : prefPayload.getBuyerSecurityDepositAsPercent(); @@ -861,6 +870,8 @@ public final class Preferences implements PersistedDataHost, BridgeAddressProvid void setUseTorForMonero(boolean useTorForMonero); + void setSplitOfferOutput(boolean splitOfferOutput); + void setShowOwnOffersInOfferBook(boolean showOwnOffersInOfferBook); void setMaxPriceDistanceInPercent(double maxPriceDistanceInPercent); diff --git a/core/src/main/java/haveno/core/user/PreferencesPayload.java b/core/src/main/java/haveno/core/user/PreferencesPayload.java index 7af4159d2f..6c8473a62d 100644 --- a/core/src/main/java/haveno/core/user/PreferencesPayload.java +++ b/core/src/main/java/haveno/core/user/PreferencesPayload.java @@ -59,6 +59,7 @@ public final class PreferencesPayload implements PersistableEnvelope { private Map dontShowAgainMap = new HashMap<>(); private boolean tacAccepted; private boolean useTorForMonero = true; + private boolean splitOfferOutput = false; private boolean showOwnOffersInOfferBook = true; @Nullable private TradeCurrency preferredTradeCurrency; @@ -161,6 +162,7 @@ public final class PreferencesPayload implements PersistableEnvelope { .putAllDontShowAgainMap(dontShowAgainMap) .setTacAccepted(tacAccepted) .setUseTorForMonero(useTorForMonero) + .setSplitOfferOutput(splitOfferOutput) .setShowOwnOffersInOfferBook(showOwnOffersInOfferBook) .setWithdrawalTxFeeInVbytes(withdrawalTxFeeInVbytes) .setUseCustomWithdrawalTxFee(useCustomWithdrawalTxFee) @@ -243,6 +245,7 @@ public final class PreferencesPayload implements PersistableEnvelope { Maps.newHashMap(proto.getDontShowAgainMapMap()), proto.getTacAccepted(), proto.getUseTorForMonero(), + proto.getSplitOfferOutput(), proto.getShowOwnOffersInOfferBook(), proto.hasPreferredTradeCurrency() ? TradeCurrency.fromProto(proto.getPreferredTradeCurrency()) : null, proto.getWithdrawalTxFeeInVbytes(), diff --git a/core/src/main/java/haveno/core/xmr/model/XmrAddressEntry.java b/core/src/main/java/haveno/core/xmr/model/XmrAddressEntry.java index 106dbbd807..451981fcd8 100644 --- a/core/src/main/java/haveno/core/xmr/model/XmrAddressEntry.java +++ b/core/src/main/java/haveno/core/xmr/model/XmrAddressEntry.java @@ -39,12 +39,10 @@ import java.util.Optional; public final class XmrAddressEntry implements PersistablePayload { public enum Context { ARBITRATOR, + BASE_ADDRESS, AVAILABLE, OFFER_FUNDING, - RESERVED_FOR_TRADE, - MULTI_SIG, - TRADE_PAYOUT, - BASE_ADDRESS + TRADE_PAYOUT } // keyPair can be null in case the object is created from deserialization as it is transient. @@ -120,11 +118,11 @@ public final class XmrAddressEntry implements PersistablePayload { } public boolean isOpenOffer() { - return context == Context.OFFER_FUNDING || context == Context.RESERVED_FOR_TRADE; + return context == Context.OFFER_FUNDING; } public boolean isTrade() { - return context == Context.MULTI_SIG || context == Context.TRADE_PAYOUT; + return context == Context.TRADE_PAYOUT; } public boolean isTradable() { diff --git a/core/src/main/java/haveno/core/xmr/wallet/XmrWalletService.java b/core/src/main/java/haveno/core/xmr/wallet/XmrWalletService.java index d6ed29fdd4..bc1012c367 100644 --- a/core/src/main/java/haveno/core/xmr/wallet/XmrWalletService.java +++ b/core/src/main/java/haveno/core/xmr/wallet/XmrWalletService.java @@ -44,7 +44,6 @@ import monero.wallet.model.MoneroOutputQuery; import monero.wallet.model.MoneroOutputWallet; import monero.wallet.model.MoneroSubaddress; import monero.wallet.model.MoneroSyncResult; -import monero.wallet.model.MoneroTransferQuery; import monero.wallet.model.MoneroTxConfig; import monero.wallet.model.MoneroTxQuery; import monero.wallet.model.MoneroTxWallet; @@ -327,27 +326,38 @@ public class XmrWalletService { * Create the reserve tx and freeze its inputs. The full amount is returned * to the sender's payout address less the trade fee. * - * @param returnAddress return address for reserved funds * @param tradeFee trade fee * @param sendAmount amount to give peer * @param securityDeposit security deposit amount + * @param returnAddress return address for reserved funds + * @param exactOutputAmount exact output amount to spend (optional) + * @param subaddressIndex preferred source subaddress to spend from (optional) * @return a transaction to reserve a trade */ - public MoneroTxWallet createReserveTx(BigInteger tradeFee, BigInteger sendAmount, BigInteger securityDeposit, String returnAddress) { + public MoneroTxWallet createReserveTx(BigInteger tradeFee, BigInteger sendAmount, BigInteger securityDeposit, String returnAddress, BigInteger exactOutputAmount, Integer subaddressIndex) { log.info("Creating reserve tx with return address={}", returnAddress); long time = System.currentTimeMillis(); - MoneroTxWallet reserveTx = createTradeTx(tradeFee, sendAmount, securityDeposit, returnAddress, true); - log.info("Done creating reserve tx in {} ms", System.currentTimeMillis() - time); - return reserveTx; + try { + MoneroTxWallet reserveTx = createTradeTx(tradeFee, sendAmount, securityDeposit, returnAddress, true, exactOutputAmount, subaddressIndex); + log.info("Done creating reserve tx in {} ms", System.currentTimeMillis() - time); + return reserveTx; + } catch (Exception e) { + + // retry creating reserve tx using funds outside subaddress + if (subaddressIndex != null) return createReserveTx(tradeFee, sendAmount, securityDeposit, returnAddress, exactOutputAmount, null); + else throw e; + } } /**s * Create the multisig deposit tx and freeze its inputs. * * @param trade the trade to create a deposit tx from + * @param exactOutputAmount exact output amount to spend (optional) + * @param subaddressIndex preferred source subaddress to spend from (optional) * @return MoneroTxWallet the multisig deposit tx */ - public MoneroTxWallet createDepositTx(Trade trade) { + public MoneroTxWallet createDepositTx(Trade trade, BigInteger exactOutputAmount, Integer subaddressIndex) { Offer offer = trade.getProcessModel().getOffer(); String multisigAddress = trade.getProcessModel().getMultisigAddress(); BigInteger tradeFee = trade instanceof MakerTrade ? trade.getOffer().getMakerFee() : trade.getTakerFee(); @@ -365,33 +375,60 @@ public class XmrWalletService { log.info("Creating deposit tx for trade {} {} with multisig address={}", trade.getClass().getSimpleName(), trade.getId(), multisigAddress); long time = System.currentTimeMillis(); - MoneroTxWallet tradeTx = createTradeTx(tradeFee, sendAmount, securityDeposit, multisigAddress, false); - log.info("Done creating deposit tx for trade {} {} in {} ms", trade.getClass().getSimpleName(), trade.getId(), System.currentTimeMillis() - time); - return tradeTx; + try { + MoneroTxWallet tradeTx = createTradeTx(tradeFee, sendAmount, securityDeposit, multisigAddress, false, exactOutputAmount, subaddressIndex); + log.info("Done creating deposit tx for trade {} {} in {} ms", trade.getClass().getSimpleName(), trade.getId(), System.currentTimeMillis() - time); + return tradeTx; + } catch (Exception e) { + + // retry creating deposit tx using funds outside subaddress + if (subaddressIndex != null) return createDepositTx(trade, exactOutputAmount, null); + else throw e; + } + } } - private MoneroTxWallet createTradeTx(BigInteger tradeFee, BigInteger sendAmount, BigInteger securityDeposit, String address, boolean isReserveTx) { + private MoneroTxWallet createTradeTx(BigInteger tradeFee, BigInteger sendAmount, BigInteger securityDeposit, String address, boolean isReserveTx, BigInteger exactOutputAmount, Integer subaddressIndex) { MoneroWallet wallet = getWallet(); synchronized (wallet) { // binary search to maximize security deposit and minimize potential dust + // TODO: binary search is hacky and slow over TOR connections, replace with destination paying tx fee MoneroTxWallet tradeTx = null; double appliedTolerance = 0.0; // percent of tolerance to apply, thereby decreasing security deposit double searchDiff = 1.0; // difference for next binary search - for (int i = 0; i < 10; i++) { + int maxSearches = 5 ; + for (int i = 0; i < maxSearches; i++) { try { BigInteger appliedSecurityDeposit = new BigDecimal(securityDeposit).multiply(new BigDecimal(1.0 - SECURITY_DEPOSIT_TOLERANCE * appliedTolerance)).toBigInteger(); BigInteger amount = sendAmount.add(isReserveTx ? tradeFee : appliedSecurityDeposit); - tradeTx = wallet.createTx(new MoneroTxConfig() + MoneroTxWallet testTx = wallet.createTx(new MoneroTxConfig() .setAccountIndex(0) + .setSubaddressIndices(subaddressIndex == null ? null : Arrays.asList(subaddressIndex)) // TODO monero-java: MoneroTxConfig.setSubadddressIndex(int) causes NPE with null subaddress, could setSubaddressIndices(null) as convenience .addDestination(HavenoUtils.getTradeFeeAddress(), isReserveTx ? appliedSecurityDeposit : tradeFee) // reserve tx charges security deposit if published .addDestination(address, amount)); + + // assert exact input if expected + if (exactOutputAmount == null) { + tradeTx = testTx; + } else { + BigInteger inputSum = BigInteger.valueOf(0); + for (MoneroOutputWallet txInput : testTx.getInputsWallet()) { + MoneroOutputWallet input = wallet.getOutputs(new MoneroOutputQuery().setKeyImage(txInput.getKeyImage())).get(0); + inputSum = inputSum.add(input.getAmount()); + } + if (inputSum.compareTo(exactOutputAmount) > 0) throw new RuntimeException("Spending too much since input sum is greater than output amount"); // continues binary search with less security deposit + else if (inputSum.equals(exactOutputAmount) && testTx.getInputs().size() == 1) tradeTx = testTx; + } appliedTolerance -= searchDiff; // apply less tolerance to increase security deposit if (appliedTolerance < 0.0) break; // can send full security deposit - } catch (MoneroError e) { + } catch (Exception e) { appliedTolerance += searchDiff; // apply more tolerance to decrease security deposit - if (appliedTolerance > 1.0) throw e; // not enough money + if (appliedTolerance > 1.0) { + if (tradeTx == null) throw e; + break; + } } searchDiff /= 2; } @@ -455,22 +492,21 @@ public class XmrWalletService { log.info("Trade tx fee {} is within tolerance, diff%={}", tx.getFee(), feeDiff); // verify transfer proof to fee address - String feeAddress = HavenoUtils.getTradeFeeAddress(); - MoneroCheckTx feeCheck = wallet.checkTxKey(txHash, txKey, feeAddress); - if (!feeCheck.isGood()) throw new RuntimeException("Invalid proof of trade fee"); + MoneroCheckTx tradeFeeCheck = wallet.checkTxKey(txHash, txKey, HavenoUtils.getTradeFeeAddress()); + if (!tradeFeeCheck.isGood()) throw new RuntimeException("Invalid proof to trade fee address"); - // verify transfer proof to return address - MoneroCheckTx returnCheck = wallet.checkTxKey(txHash, txKey, address); - if (!returnCheck.isGood()) throw new RuntimeException("Invalid proof of return funds"); + // verify transfer proof to address + MoneroCheckTx transferCheck = wallet.checkTxKey(txHash, txKey, address); + if (!transferCheck.isGood()) throw new RuntimeException("Invalid proof to transfer address"); // collect actual trade fee, send amount, and security deposit - BigInteger actualTradeFee = isReserveTx ? returnCheck.getReceivedAmount().subtract(sendAmount) : feeCheck.getReceivedAmount(); - actualSecurityDeposit = isReserveTx ? feeCheck.getReceivedAmount() : returnCheck.getReceivedAmount().subtract(sendAmount); - BigInteger actualSendAmount = returnCheck.getReceivedAmount().subtract(isReserveTx ? actualTradeFee : actualSecurityDeposit); + BigInteger actualTradeFee = isReserveTx ? transferCheck.getReceivedAmount().subtract(sendAmount) : tradeFeeCheck.getReceivedAmount(); + actualSecurityDeposit = isReserveTx ? tradeFeeCheck.getReceivedAmount() : transferCheck.getReceivedAmount().subtract(sendAmount); + BigInteger actualSendAmount = transferCheck.getReceivedAmount().subtract(isReserveTx ? actualTradeFee : actualSecurityDeposit); // verify trade fee if (!tradeFee.equals(actualTradeFee)) { - throw new RuntimeException("Trade fee is incorrect amount, expected=" + tradeFee + ", actual=" + actualTradeFee + ", return address check=" + JsonUtils.serialize(returnCheck) + ", fee address check=" + JsonUtils.serialize(feeCheck)); + throw new RuntimeException("Trade fee is incorrect amount, expected=" + tradeFee + ", actual=" + actualTradeFee + ", transfer address check=" + JsonUtils.serialize(transferCheck) + ", trade fee address check=" + JsonUtils.serialize(tradeFeeCheck)); } // verify sufficient security deposit @@ -478,9 +514,10 @@ public class XmrWalletService { if (actualSecurityDeposit.compareTo(minSecurityDeposit) < 0) throw new RuntimeException("Security deposit amount is not enough, needed " + minSecurityDeposit + " but was " + actualSecurityDeposit); // verify deposit amount + miner fee within dust tolerance - BigInteger minDepositAndFee = sendAmount.add(securityDeposit).subtract(new BigDecimal(tx.getFee()).multiply(new BigDecimal(1.0 - DUST_TOLERANCE)).toBigInteger()); - BigInteger actualDepositAndFee = actualSendAmount.add(actualSecurityDeposit).add(tx.getFee()); - if (actualDepositAndFee.compareTo(minDepositAndFee) < 0) throw new RuntimeException("Deposit amount + fee is not enough, needed " + minDepositAndFee + " but was " + actualDepositAndFee); + //BigInteger minDepositAndFee = sendAmount.add(securityDeposit).subtract(new BigDecimal(tx.getFee()).multiply(new BigDecimal(1.0 - DUST_TOLERANCE)).toBigInteger()); // TODO: improve when destination pays fee + BigInteger minDeposit = sendAmount.add(minSecurityDeposit); + BigInteger actualDeposit = actualSendAmount.add(actualSecurityDeposit); + if (actualDeposit.compareTo(minDeposit) < 0) throw new RuntimeException("Deposit amount + fee is not enough, needed " + minDeposit + " but was " + actualDeposit); } catch (Exception e) { log.warn("Error verifying trade tx with offer id=" + offerId + (tx == null ? "" : ", tx=" + tx) + ": " + e.getMessage()); throw e; @@ -928,11 +965,9 @@ public class XmrWalletService { public synchronized void resetAddressEntriesForOpenOffer(String offerId) { log.info("resetAddressEntriesForOpenOffer offerId={}", offerId); swapTradeEntryToAvailableEntry(offerId, XmrAddressEntry.Context.OFFER_FUNDING); - swapTradeEntryToAvailableEntry(offerId, XmrAddressEntry.Context.RESERVED_FOR_TRADE); } public synchronized void resetAddressEntriesForPendingTrade(String offerId) { - swapTradeEntryToAvailableEntry(offerId, XmrAddressEntry.Context.MULTI_SIG); // We swap also TRADE_PAYOUT to be sure all is cleaned up. There might be cases // where a user cannot send the funds // to an external wallet directly in the last step of the trade, but the funds @@ -951,20 +986,23 @@ public class XmrWalletService { return getAddressEntryListAsImmutableList().stream().filter(e -> address.equals(e.getAddressString())).filter(e -> context == e.getContext()).findAny(); } + public List getAddressEntries() { + return getAddressEntryListAsImmutableList().stream().collect(Collectors.toList()); + } + public List getAvailableAddressEntries() { return getAddressEntryListAsImmutableList().stream().filter(addressEntry -> XmrAddressEntry.Context.AVAILABLE == addressEntry.getContext()).collect(Collectors.toList()); } public List getAddressEntriesForOpenOffer() { return getAddressEntryListAsImmutableList().stream() - .filter(addressEntry -> XmrAddressEntry.Context.OFFER_FUNDING == addressEntry.getContext() || - XmrAddressEntry.Context.RESERVED_FOR_TRADE == addressEntry.getContext()) + .filter(addressEntry -> XmrAddressEntry.Context.OFFER_FUNDING == addressEntry.getContext()) .collect(Collectors.toList()); } public List getAddressEntriesForTrade() { return getAddressEntryListAsImmutableList().stream() - .filter(addressEntry -> XmrAddressEntry.Context.MULTI_SIG == addressEntry.getContext() || XmrAddressEntry.Context.TRADE_PAYOUT == addressEntry.getContext()) + .filter(addressEntry -> XmrAddressEntry.Context.TRADE_PAYOUT == addressEntry.getContext()) .collect(Collectors.toList()); } @@ -1015,7 +1053,7 @@ public class XmrWalletService { if (incomingTxs == null) incomingTxs = getTxsWithIncomingOutputs(subaddressIndex); int numUnspentOutputs = 0; for (MoneroTxWallet tx : incomingTxs) { - if (tx.getTransfers(new MoneroTransferQuery().setSubaddressIndex(subaddressIndex)).isEmpty()) continue; + //if (tx.getTransfers(new MoneroTransferQuery().setSubaddressIndex(subaddressIndex)).isEmpty()) continue; // TODO monero-project: transfers are occluded by transfers from/to same account, so this will return unused when used numUnspentOutputs += tx.isConfirmed() ? tx.getOutputsWallet(new MoneroOutputQuery().setAccountIndex(0).setSubaddressIndex(subaddressIndex)).size() : 1; // TODO: monero-project does not provide outputs for unconfirmed txs } return numUnspentOutputs; @@ -1026,11 +1064,11 @@ public class XmrWalletService { } public List getTxsWithIncomingOutputs(Integer subaddressIndex) { - List txs = wallet.getTxs(new MoneroTxQuery().setIncludeOutputs(true)); - return getTxsWithIncomingOutputs(subaddressIndex, txs); + return getTxsWithIncomingOutputs(subaddressIndex, null); } - public static List getTxsWithIncomingOutputs(Integer subaddressIndex, List txs) { + public List getTxsWithIncomingOutputs(Integer subaddressIndex, List txs) { + if (txs == null) txs = wallet.getTxs(new MoneroTxQuery().setIncludeOutputs(true)); List incomingTxs = new ArrayList<>(); for (MoneroTxWallet tx : txs) { boolean isIncoming = false; @@ -1078,7 +1116,7 @@ public class XmrWalletService { public Stream getAddressEntriesForAvailableBalanceStream() { Stream availableAndPayout = Stream.concat(getAddressEntries(XmrAddressEntry.Context.TRADE_PAYOUT).stream(), getFundedAvailableAddressEntries().stream()); Stream available = Stream.concat(availableAndPayout, getAddressEntries(XmrAddressEntry.Context.ARBITRATOR).stream()); - available = Stream.concat(available, getAddressEntries(XmrAddressEntry.Context.OFFER_FUNDING).stream()); + available = Stream.concat(available, getAddressEntries(XmrAddressEntry.Context.OFFER_FUNDING).stream().filter(entry -> !tradeManager.getOpenOfferManager().getOpenOfferById(entry.getOfferId()).isPresent())); return available.filter(addressEntry -> getBalanceForSubaddress(addressEntry.getSubaddressIndex()).compareTo(BigInteger.valueOf(0)) > 0); } diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index bb213443d5..46a098d836 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -80,7 +80,7 @@ shared.tradeCurrency=Trade currency shared.offerType=Offer type shared.details=Details shared.address=Address -shared.balanceWithCur=Balance in {0} +shared.balanceWithCur=Available balance in {0} shared.utxo=Unspent transaction output shared.txId=Transaction ID shared.confirmations=Confirmations @@ -196,6 +196,7 @@ shared.total=Total shared.totalsNeeded=Funds needed shared.tradeWalletAddress=Trade wallet address shared.tradeWalletBalance=Trade wallet balance +shared.reserveExactAmount=Reserve exact amount for offer. Splits wallet funds if necessary, requiring a mining fee and 10 confirmations (~20 minutes) before the offer is available. shared.makerTxFee=Maker: {0} shared.takerTxFee=Taker: {0} shared.iConfirm=I confirm diff --git a/daemon/src/main/java/haveno/daemon/grpc/GrpcOffersService.java b/daemon/src/main/java/haveno/daemon/grpc/GrpcOffersService.java index dadb55b907..61e2905872 100644 --- a/daemon/src/main/java/haveno/daemon/grpc/GrpcOffersService.java +++ b/daemon/src/main/java/haveno/daemon/grpc/GrpcOffersService.java @@ -158,6 +158,7 @@ class GrpcOffersService extends OffersImplBase { req.getMinAmount(), req.getBuyerSecurityDepositPct(), req.getTriggerPrice(), + req.getSplitOutput(), req.getPaymentAccountId(), offer -> { // This result handling consumer's accept operation will return diff --git a/desktop/src/main/java/haveno/desktop/main/funds/deposit/DepositListItem.java b/desktop/src/main/java/haveno/desktop/main/funds/deposit/DepositListItem.java index 36e4bc7d7b..ec05805d4a 100644 --- a/desktop/src/main/java/haveno/desktop/main/funds/deposit/DepositListItem.java +++ b/desktop/src/main/java/haveno/desktop/main/funds/deposit/DepositListItem.java @@ -95,7 +95,7 @@ class DepositListItem { } private void updateUsage(int subaddressIndex, List cachedTxs) { - numTxsWithOutputs = XmrWalletService.getTxsWithIncomingOutputs(addressEntry.getSubaddressIndex(), cachedTxs).size(); + numTxsWithOutputs = xmrWalletService.getTxsWithIncomingOutputs(addressEntry.getSubaddressIndex(), cachedTxs).size(); usage = subaddressIndex == 0 ? "Base address" : numTxsWithOutputs == 0 ? Res.get("funds.deposit.unused") : Res.get("funds.deposit.usedInTx", numTxsWithOutputs); } @@ -143,7 +143,7 @@ class DepositListItem { private MoneroTxWallet getTxWithFewestConfirmations(List allIncomingTxs) { // get txs with incoming outputs to subaddress index - List txs = XmrWalletService.getTxsWithIncomingOutputs(addressEntry.getSubaddressIndex(), allIncomingTxs); + List txs = xmrWalletService.getTxsWithIncomingOutputs(addressEntry.getSubaddressIndex(), allIncomingTxs); // get tx with fewest confirmations MoneroTxWallet highestTx = null; diff --git a/desktop/src/main/java/haveno/desktop/main/funds/deposit/DepositView.java b/desktop/src/main/java/haveno/desktop/main/funds/deposit/DepositView.java index c7f680561f..7bc3f900bd 100644 --- a/desktop/src/main/java/haveno/desktop/main/funds/deposit/DepositView.java +++ b/desktop/src/main/java/haveno/desktop/main/funds/deposit/DepositView.java @@ -312,9 +312,7 @@ public class DepositView extends ActivatableView { txsWithIncomingOutputs = xmrWalletService.getTxsWithIncomingOutputs(); // add available address entries and base address - xmrWalletService.getAvailableAddressEntries() - .forEach(e -> observableList.add(new DepositListItem(e, xmrWalletService, formatter, txsWithIncomingOutputs))); - xmrWalletService.getAddressEntries(XmrAddressEntry.Context.BASE_ADDRESS) + xmrWalletService.getAddressEntries() .forEach(e -> observableList.add(new DepositListItem(e, xmrWalletService, formatter, txsWithIncomingOutputs))); } diff --git a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferDataModel.java b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferDataModel.java index 543053a413..bc758b6e01 100644 --- a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferDataModel.java +++ b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferDataModel.java @@ -128,6 +128,8 @@ public abstract class MutableOfferDataModel extends OfferDataModel { private final Predicate> isNonZeroVolume = (v) -> v.get() != null && !v.get().isZero(); @Getter protected long triggerPrice; + @Getter + protected boolean splitOutput; /////////////////////////////////////////////////////////////////////////////////////////// @@ -165,6 +167,8 @@ public abstract class MutableOfferDataModel extends OfferDataModel { shortOfferId = Utilities.getShortId(offerId); addressEntry = xmrWalletService.getOrCreateAddressEntry(offerId, XmrAddressEntry.Context.OFFER_FUNDING); + splitOutput = preferences.getSplitOfferOutput(); + useMarketBasedPrice.set(preferences.isUsePercentageBasedPrice()); buyerSecurityDepositPct.set(Restrictions.getMinBuyerSecurityDepositAsPercent()); @@ -295,6 +299,7 @@ public abstract class MutableOfferDataModel extends OfferDataModel { openOfferManager.placeOffer(offer, useSavingsWallet, triggerPrice, + splitOutput, resultHandler, errorMessageHandler); } @@ -459,6 +464,11 @@ public abstract class MutableOfferDataModel extends OfferDataModel { } } + public boolean hasAvailableSplitOutput() { + BigInteger reserveAmount = totalToPay.get(); + return openOfferManager.hasAvailableOutput(reserveAmount); + } + /////////////////////////////////////////////////////////////////////////////////////////// // Utils /////////////////////////////////////////////////////////////////////////////////////////// @@ -672,6 +682,10 @@ public abstract class MutableOfferDataModel extends OfferDataModel { this.triggerPrice = triggerPrice; } + public void setSplitOutput(boolean splitOutput) { + this.splitOutput = splitOutput; + } + public boolean isUsingRoundedAtmCashAccount() { return PaymentMethod.isRoundedForAtmCash(paymentAccount.getPaymentMethod().getId()); } diff --git a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferView.java b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferView.java index 55741c10a1..3416ad1706 100644 --- a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferView.java +++ b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferView.java @@ -56,6 +56,7 @@ import haveno.desktop.main.overlays.windows.OfferDetailsWindow; import haveno.desktop.main.overlays.windows.QRCodeWindow; import haveno.desktop.main.portfolio.PortfolioView; import haveno.desktop.main.portfolio.openoffer.OpenOffersView; +import haveno.desktop.util.FormBuilder; import haveno.desktop.util.GUIUtil; import haveno.desktop.util.Layout; import javafx.beans.value.ChangeListener; @@ -70,6 +71,7 @@ import javafx.geometry.Pos; import javafx.geometry.VPos; import javafx.scene.Node; import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; import javafx.scene.control.ComboBox; import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; @@ -134,6 +136,7 @@ public abstract class MutableOfferView> exten private TextField currencyTextField; private AddressTextField addressTextField; private BalanceTextField balanceTextField; + private CheckBox splitOutputCheckbox; private FundsTextField totalToPayTextField; private Label amountDescriptionLabel, priceCurrencyLabel, priceDescriptionLabel, volumeDescriptionLabel, waitingForFundsLabel, marketBasedPriceLabel, percentagePriceDescriptionLabel, tradeFeeDescriptionLabel, @@ -418,6 +421,7 @@ public abstract class MutableOfferView> exten qrCodeImageView.setVisible(true); balanceTextField.setVisible(true); cancelButton2.setVisible(true); + splitOutputCheckbox.setVisible(true); } private void updateOfferElementsStyle() { @@ -1088,6 +1092,21 @@ public abstract class MutableOfferView> exten Res.get("shared.tradeWalletBalance")); balanceTextField.setVisible(false); + splitOutputCheckbox = FormBuilder.addLabelCheckBox(gridPane, ++gridRow, + Res.get("shared.reserveExactAmount")); + + GridPane.setHalignment(splitOutputCheckbox, HPos.LEFT); + + splitOutputCheckbox.setVisible(false); + splitOutputCheckbox.setSelected(preferences.getSplitOfferOutput()); + splitOutputCheckbox.setOnAction(event -> { + boolean selected = splitOutputCheckbox.isSelected(); + if (selected != preferences.getSplitOfferOutput()) { + preferences.setSplitOfferOutput(selected); + model.dataModel.setSplitOutput(selected); + } + }); + fundingHBox = new HBox(); fundingHBox.setVisible(false); fundingHBox.setManaged(false); diff --git a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java index 3b62b3c0ea..8a0b01e1b9 100644 --- a/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java +++ b/desktop/src/main/java/haveno/desktop/main/offer/MutableOfferViewModel.java @@ -115,6 +115,7 @@ public abstract class MutableOfferViewModel ext // If we would change the price representation in the domain we would not be backward compatible public final StringProperty price = new SimpleStringProperty(); public final StringProperty triggerPrice = new SimpleStringProperty(""); + public final BooleanProperty splitOutput = new SimpleBooleanProperty(true); final StringProperty tradeFee = new SimpleStringProperty(); final StringProperty tradeFeeInXmrWithFiat = new SimpleStringProperty(); final StringProperty tradeFeeCurrencyCode = new SimpleStringProperty(); @@ -778,6 +779,10 @@ public abstract class MutableOfferViewModel ext } } + public void onSplitOutputCheckboxChanged() { + dataModel.setSplitOutput(splitOutput.get()); + } + void onFixPriceToggleChange(boolean fixedPriceSelected) { inputIsMarketBasedPrice = !fixedPriceSelected; updateButtonDisableState(); diff --git a/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferViewModel.java b/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferViewModel.java index 1fc740a993..e8450aded5 100644 --- a/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferViewModel.java +++ b/desktop/src/main/java/haveno/desktop/main/portfolio/editoffer/EditOfferViewModel.java @@ -82,6 +82,7 @@ class EditOfferViewModel extends MutableOfferViewModel { triggerPrice.set(""); } onTriggerPriceTextFieldChanged(); + onSplitOutputCheckboxChanged(); } public void applyOpenOffer(OpenOffer openOffer) { diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto index 801798763e..ed074f113f 100644 --- a/proto/src/main/proto/grpc.proto +++ b/proto/src/main/proto/grpc.proto @@ -498,7 +498,8 @@ message PostOfferRequest { uint64 min_amount = 7 [jstype = JS_STRING]; double buyer_security_deposit_pct = 8; string trigger_price = 9; - string payment_account_id = 10; + bool split_output = 10; + string payment_account_id = 11; } message PostOfferReply { diff --git a/proto/src/main/proto/pb.proto b/proto/src/main/proto/pb.proto index c6e7c0f3ef..c566e98c8e 100644 --- a/proto/src/main/proto/pb.proto +++ b/proto/src/main/proto/pb.proto @@ -1303,12 +1303,10 @@ message XmrAddressEntry { enum Context { PB_ERROR = 0; ARBITRATOR = 1; - AVAILABLE = 2; - OFFER_FUNDING = 3; - RESERVED_FOR_TRADE = 4; - MULTI_SIG = 5; - TRADE_PAYOUT = 6; - BASE_ADDRESS = 7; + BASE_ADDRESS = 2; + AVAILABLE = 3; + OFFER_FUNDING = 4; + TRADE_PAYOUT = 5; } int32 subaddress_index = 7; @@ -1379,12 +1377,13 @@ message OpenOffer { Offer offer = 1; State state = 2; int64 trigger_price = 3; - bool auto_split = 4; + bool split_output = 4; repeated string scheduled_tx_hashes = 5; string scheduled_amount = 6; // BigInteger - string reserve_tx_hash = 7; - string reserve_tx_hex = 8; - string reserve_tx_key = 9; + string split_output_tx_hash = 7; + string reserve_tx_hash = 8; + string reserve_tx_hex = 9; + string reserve_tx_key = 10; } message Tradable { @@ -1711,6 +1710,7 @@ message PreferencesPayload { int32 clear_data_after_days = 59; string buy_screen_crypto_currency_code = 60; string sell_screen_crypto_currency_code = 61; + bool split_offer_output = 62; } message AutoConfirmSettings {