support reserving exact offer amount by splitting output

This commit is contained in:
woodser 2023-06-11 15:28:10 -04:00
parent 0bbb8a4183
commit 722b02f4c9
31 changed files with 424 additions and 173 deletions

View File

@ -405,6 +405,7 @@ public class CoreApi {
long minAmountAsLong,
double buyerSecurityDeposit,
String triggerPriceAsString,
boolean splitOutput,
String paymentAccountId,
Consumer<Offer> resultHandler,
ErrorMessageHandler errorMessageHandler) {
@ -417,6 +418,7 @@ public class CoreApi {
minAmountAsLong,
buyerSecurityDeposit,
triggerPriceAsString,
splitOutput,
paymentAccountId,
resultHandler,
errorMessageHandler);

View File

@ -165,6 +165,7 @@ public class CoreOffersService {
long minAmountAsLong,
double buyerSecurityDeposit,
String triggerPriceAsString,
boolean splitOutput,
String paymentAccountId,
Consumer<Offer> 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<Transaction> resultHandler,
ErrorMessageHandler errorMessageHandler) {
long triggerPriceAsLong = PriceUtil.getMarketPriceAsLong(triggerPriceAsString, offer.getCurrencyCode());
openOfferManager.placeOffer(offer,
useSavingsWallet,
triggerPriceAsLong,
splitOutput,
resultHandler::accept,
errorMessageHandler);
}

View File

@ -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;
}

View File

@ -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<String> 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<String> 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());

View File

@ -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<OpenOffer> 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<MoneroTxWallet> lockedTxs = xmrWalletService.getWallet().getTxs(new MoneroTxQuery().setIsLocked(true));
// get earliest unscheduled txs with sufficient incoming amount
List<String> scheduledTxHashes = new ArrayList<String>();
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<OpenOffer> 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<OpenOffer> openOffers, BigInteger reserveAmount, Integer subaddressIndex) {
List<MoneroTxWallet> 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<MoneroTxWallet> 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<OpenOffer> openOffers, List<MoneroTxWallet> 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<OpenOffer> 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<MoneroTxWallet> lockedTxs = xmrWalletService.getWallet().getTxs(new MoneroTxQuery().setIsLocked(true));
// get earliest unscheduled txs with sufficient incoming amount
List<String> scheduledTxHashes = new ArrayList<String>();
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<OpenOffer> 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<MoneroTxWallet> 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

View File

@ -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;

View File

@ -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);
}
}

View File

@ -34,20 +34,20 @@ public class AddToOfferBook extends Task<PlaceOfferModel> {
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());

View File

@ -40,7 +40,7 @@ public class CreateMakerFeeTx extends Task<PlaceOfferModel> {
@Override
protected void run() {
Offer offer = model.getOffer();
Offer offer = model.getOpenOffer().getOffer();
try {
runInterceptHook();

View File

@ -33,7 +33,7 @@ public class MakerProcessSignOfferResponse extends Task<PlaceOfferModel> {
@Override
protected void run() {
Offer offer = model.getOffer();
Offer offer = model.getOpenOffer().getOffer();
try {
runInterceptHook();
@ -46,7 +46,7 @@ public class MakerProcessSignOfferResponse extends Task<PlaceOfferModel> {
}
// 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) {

View File

@ -41,7 +41,7 @@ public class MakerReserveOfferFunds extends Task<PlaceOfferModel> {
@Override
protected void run() {
Offer offer = model.getOffer();
Offer offer = model.getOpenOffer().getOffer();
try {
runInterceptHook();
@ -53,12 +53,15 @@ public class MakerReserveOfferFunds extends Task<PlaceOfferModel> {
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");
}

View File

@ -55,14 +55,14 @@ public class MakerSendSignOfferRequest extends Task<PlaceOfferModel> {
@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<PlaceOfferModel> {
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<PlaceOfferModel> {
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

View File

@ -37,7 +37,7 @@ public class ValidateOffer extends Task<PlaceOfferModel> {
@Override
protected void run() {
Offer offer = model.getOffer();
Offer offer = model.getOpenOffer().getOffer();
try {
runInterceptHook();

View File

@ -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;

View File

@ -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

View File

@ -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<String> reservedKeyImages = new ArrayList<String>();

View File

@ -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<String> reservedKeyImages = new ArrayList<String>();

View File

@ -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);

View File

@ -59,6 +59,7 @@ public final class PreferencesPayload implements PersistableEnvelope {
private Map<String, Boolean> 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(),

View File

@ -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() {

View File

@ -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<XmrAddressEntry> getAddressEntries() {
return getAddressEntryListAsImmutableList().stream().collect(Collectors.toList());
}
public List<XmrAddressEntry> getAvailableAddressEntries() {
return getAddressEntryListAsImmutableList().stream().filter(addressEntry -> XmrAddressEntry.Context.AVAILABLE == addressEntry.getContext()).collect(Collectors.toList());
}
public List<XmrAddressEntry> 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<XmrAddressEntry> 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<MoneroTxWallet> getTxsWithIncomingOutputs(Integer subaddressIndex) {
List<MoneroTxWallet> txs = wallet.getTxs(new MoneroTxQuery().setIncludeOutputs(true));
return getTxsWithIncomingOutputs(subaddressIndex, txs);
return getTxsWithIncomingOutputs(subaddressIndex, null);
}
public static List<MoneroTxWallet> getTxsWithIncomingOutputs(Integer subaddressIndex, List<MoneroTxWallet> txs) {
public List<MoneroTxWallet> getTxsWithIncomingOutputs(Integer subaddressIndex, List<MoneroTxWallet> txs) {
if (txs == null) txs = wallet.getTxs(new MoneroTxQuery().setIncludeOutputs(true));
List<MoneroTxWallet> incomingTxs = new ArrayList<>();
for (MoneroTxWallet tx : txs) {
boolean isIncoming = false;
@ -1078,7 +1116,7 @@ public class XmrWalletService {
public Stream<XmrAddressEntry> getAddressEntriesForAvailableBalanceStream() {
Stream<XmrAddressEntry> availableAndPayout = Stream.concat(getAddressEntries(XmrAddressEntry.Context.TRADE_PAYOUT).stream(), getFundedAvailableAddressEntries().stream());
Stream<XmrAddressEntry> 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);
}

View File

@ -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

View File

@ -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

View File

@ -95,7 +95,7 @@ class DepositListItem {
}
private void updateUsage(int subaddressIndex, List<MoneroTxWallet> 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<MoneroTxWallet> allIncomingTxs) {
// get txs with incoming outputs to subaddress index
List<MoneroTxWallet> txs = XmrWalletService.getTxsWithIncomingOutputs(addressEntry.getSubaddressIndex(), allIncomingTxs);
List<MoneroTxWallet> txs = xmrWalletService.getTxsWithIncomingOutputs(addressEntry.getSubaddressIndex(), allIncomingTxs);
// get tx with fewest confirmations
MoneroTxWallet highestTx = null;

View File

@ -312,9 +312,7 @@ public class DepositView extends ActivatableView<VBox, Void> {
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)));
}

View File

@ -128,6 +128,8 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
private final Predicate<ObjectProperty<Volume>> 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());
}

View File

@ -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<M extends MutableOfferViewModel<?>> 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<M extends MutableOfferViewModel<?>> exten
qrCodeImageView.setVisible(true);
balanceTextField.setVisible(true);
cancelButton2.setVisible(true);
splitOutputCheckbox.setVisible(true);
}
private void updateOfferElementsStyle() {
@ -1088,6 +1092,21 @@ public abstract class MutableOfferView<M extends MutableOfferViewModel<?>> 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);

View File

@ -115,6 +115,7 @@ public abstract class MutableOfferViewModel<M extends MutableOfferDataModel> 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<M extends MutableOfferDataModel> ext
}
}
public void onSplitOutputCheckboxChanged() {
dataModel.setSplitOutput(splitOutput.get());
}
void onFixPriceToggleChange(boolean fixedPriceSelected) {
inputIsMarketBasedPrice = !fixedPriceSelected;
updateButtonDisableState();

View File

@ -82,6 +82,7 @@ class EditOfferViewModel extends MutableOfferViewModel<EditOfferDataModel> {
triggerPrice.set("");
}
onTriggerPriceTextFieldChanged();
onSplitOutputCheckboxChanged();
}
public void applyOpenOffer(OpenOffer openOffer) {

View File

@ -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 {

View File

@ -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 {