mirror of
https://github.com/haveno-dex/haveno.git
synced 2024-11-16 15:58:08 +00:00
support scheduling offers with locked funds
This commit is contained in:
parent
2da77de41b
commit
fa15612586
25 changed files with 386 additions and 201 deletions
|
@ -44,7 +44,7 @@ import static java.lang.String.format;
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
import static protobuf.Offer.State.OFFER_FEE_PAID;
|
||||
import static protobuf.Offer.State.OFFER_FEE_RESERVED;
|
||||
import static protobuf.OfferPayload.Direction.BUY;
|
||||
import static protobuf.OpenOffer.State.AVAILABLE;
|
||||
|
||||
|
@ -168,7 +168,7 @@ public class TakeBuyBTCOfferTest extends AbstractTradeTest {
|
|||
sleep(5000);
|
||||
continue;
|
||||
} else {
|
||||
assertEquals(OFFER_FEE_PAID.name(), trade.getOffer().getState());
|
||||
assertEquals(OFFER_FEE_RESERVED.name(), trade.getOffer().getState());
|
||||
EXPECTED_PROTOCOL_STATUS.setState(BUYER_SAW_ARRIVED_PAYMENT_SENT_MSG)
|
||||
.setPhase(PAYMENT_SENT)
|
||||
.setFiatSent(true);
|
||||
|
|
|
@ -46,7 +46,7 @@ import static java.lang.String.format;
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
import static protobuf.Offer.State.OFFER_FEE_PAID;
|
||||
import static protobuf.Offer.State.OFFER_FEE_RESERVED;
|
||||
import static protobuf.OfferPayload.Direction.SELL;
|
||||
import static protobuf.OpenOffer.State.AVAILABLE;
|
||||
|
||||
|
@ -220,7 +220,7 @@ public class TakeSellBTCOfferTest extends AbstractTradeTest {
|
|||
sleep(3000);
|
||||
|
||||
trade = aliceClient.getTrade(tradeId);
|
||||
assertEquals(OFFER_FEE_PAID.name(), trade.getOffer().getState());
|
||||
assertEquals(OFFER_FEE_RESERVED.name(), trade.getOffer().getState());
|
||||
EXPECTED_PROTOCOL_STATUS.setState(SELLER_SAW_ARRIVED_PAYOUT_TX_PUBLISHED_MSG)
|
||||
.setPhase(PAYOUT_PUBLISHED)
|
||||
.setPayoutPublished(true)
|
||||
|
|
|
@ -119,13 +119,12 @@ class CoreOffersService {
|
|||
}
|
||||
|
||||
Offer getMyOffer(String id) {
|
||||
Offer offer = offerBookService.getOffers().stream()
|
||||
return openOfferManager.getObservableList().stream()
|
||||
.map(OpenOffer::getOffer)
|
||||
.filter(o -> o.getId().equals(id))
|
||||
.filter(o -> o.isMyOffer(keyRing))
|
||||
.findAny().orElseThrow(() ->
|
||||
new IllegalStateException(format("offer with id '%s' not found", id)));
|
||||
setOpenOfferState(offer);
|
||||
return offer;
|
||||
}
|
||||
|
||||
List<Offer> getOffers(String direction, String currencyCode) {
|
||||
|
@ -144,8 +143,9 @@ class CoreOffersService {
|
|||
|
||||
List<Offer> getMyOffers(String direction, String currencyCode) {
|
||||
|
||||
// get my offers posted to books
|
||||
List<Offer> offers = offerBookService.getOffers().stream()
|
||||
// get my open offers
|
||||
List<Offer> offers = openOfferManager.getObservableList().stream()
|
||||
.map(OpenOffer::getOffer)
|
||||
.filter(o -> o.isMyOffer(keyRing))
|
||||
.filter(o -> offerMatchesDirectionAndCurrency(o, direction, currencyCode))
|
||||
.sorted(priceComparator(direction))
|
||||
|
@ -162,9 +162,6 @@ class CoreOffersService {
|
|||
}
|
||||
openOfferManager.removeOpenOffers(unreservedOpenOffers, null);
|
||||
|
||||
// set offer states
|
||||
for (Offer offer : offers) setOpenOfferState(offer);
|
||||
|
||||
return offers;
|
||||
}
|
||||
|
||||
|
@ -174,6 +171,7 @@ class CoreOffersService {
|
|||
// collect reserved key images and check for duplicate funds
|
||||
List<String> allKeyImages = new ArrayList<String>();
|
||||
for (Offer offer : offers) {
|
||||
if (offer.getOfferPayload().getReserveTxKeyImages() == null) continue;
|
||||
for (String keyImage : offer.getOfferPayload().getReserveTxKeyImages()) {
|
||||
if (!allKeyImages.add(keyImage)) {
|
||||
log.warn("Key image {} belongs to another offer, removing offer {}", keyImage, offer.getId()); // TODO (woodser): this is list, not set, so not checking for duplicates
|
||||
|
@ -192,6 +190,7 @@ class CoreOffersService {
|
|||
|
||||
// check for offers with spent key images
|
||||
for (Offer offer : offers) {
|
||||
if (offer.getOfferPayload().getReserveTxKeyImages() == null) continue;
|
||||
if (unreservedOffers.contains(offer)) continue;
|
||||
for (String keyImage : offer.getOfferPayload().getReserveTxKeyImages()) {
|
||||
if (spentKeyImages.contains(keyImage)) {
|
||||
|
@ -256,7 +255,6 @@ class CoreOffersService {
|
|||
boolean useSavingsWallet = true;
|
||||
//noinspection ConstantConditions
|
||||
placeOffer(offer,
|
||||
buyerSecurityDeposit,
|
||||
triggerPriceAsString,
|
||||
useSavingsWallet,
|
||||
transaction -> resultHandler.accept(offer),
|
||||
|
@ -308,14 +306,12 @@ class CoreOffersService {
|
|||
}
|
||||
|
||||
private void placeOffer(Offer offer,
|
||||
double buyerSecurityDeposit,
|
||||
String triggerPriceAsString,
|
||||
boolean useSavingsWallet,
|
||||
Consumer<Transaction> resultHandler,
|
||||
ErrorMessageHandler errorMessageHandler) {
|
||||
long triggerPriceAsLong = PriceUtil.getMarketPriceAsLong(triggerPriceAsString, offer.getCurrencyCode());
|
||||
openOfferManager.placeOffer(offer,
|
||||
buyerSecurityDeposit,
|
||||
useSavingsWallet,
|
||||
triggerPriceAsLong,
|
||||
resultHandler::accept,
|
||||
|
@ -331,11 +327,6 @@ class CoreOffersService {
|
|||
return offerOfWantedDirection && offerInWantedCurrency;
|
||||
}
|
||||
|
||||
private void setOpenOfferState(Offer offer) {
|
||||
Optional<OpenOffer> openOffer = openOfferManager.getOpenOfferById(offer.getId());
|
||||
if (openOffer.isPresent()) offer.setState(openOffer.get().getState() == OpenOffer.State.AVAILABLE ? Offer.State.AVAILABLE : Offer.State.NOT_AVAILABLE);
|
||||
}
|
||||
|
||||
private Comparator<Offer> priceComparator(String direction) {
|
||||
// A buyer probably wants to see sell orders in price ascending order.
|
||||
// A seller probably wants to see buy orders in price descending order.
|
||||
|
|
|
@ -20,9 +20,10 @@ package bisq.core.api.model;
|
|||
import bisq.core.offer.Offer;
|
||||
|
||||
import bisq.common.Payload;
|
||||
|
||||
import bisq.common.proto.ProtoUtil;
|
||||
import java.util.Objects;
|
||||
|
||||
import java.util.Optional;
|
||||
import javax.annotation.Nullable;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
|
@ -47,6 +48,7 @@ public class OfferInfo implements Payload {
|
|||
private final long minVolume;
|
||||
private final long txFee;
|
||||
private final long makerFee;
|
||||
@Nullable
|
||||
private final String offerFeePaymentTxId;
|
||||
private final long buyerSecurityDeposit;
|
||||
private final long sellerSecurityDeposit;
|
||||
|
@ -129,7 +131,7 @@ public class OfferInfo implements Payload {
|
|||
|
||||
@Override
|
||||
public bisq.proto.grpc.OfferInfo toProtoMessage() {
|
||||
return bisq.proto.grpc.OfferInfo.newBuilder()
|
||||
bisq.proto.grpc.OfferInfo.Builder builder = bisq.proto.grpc.OfferInfo.newBuilder()
|
||||
.setId(id)
|
||||
.setDirection(direction)
|
||||
.setPrice(price)
|
||||
|
@ -141,7 +143,6 @@ public class OfferInfo implements Payload {
|
|||
.setMinVolume(minVolume)
|
||||
.setMakerFee(makerFee)
|
||||
.setTxFee(txFee)
|
||||
.setOfferFeePaymentTxId(offerFeePaymentTxId)
|
||||
.setBuyerSecurityDeposit(buyerSecurityDeposit)
|
||||
.setSellerSecurityDeposit(sellerSecurityDeposit)
|
||||
.setTriggerPrice(triggerPrice)
|
||||
|
@ -151,8 +152,9 @@ public class OfferInfo implements Payload {
|
|||
.setBaseCurrencyCode(baseCurrencyCode)
|
||||
.setCounterCurrencyCode(counterCurrencyCode)
|
||||
.setDate(date)
|
||||
.setState(state)
|
||||
.build();
|
||||
.setState(state);
|
||||
Optional.ofNullable(offerFeePaymentTxId).ifPresent(builder::setOfferFeePaymentTxId);
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
|
@ -169,7 +171,7 @@ public class OfferInfo implements Payload {
|
|||
.withMinVolume(proto.getMinVolume())
|
||||
.withMakerFee(proto.getMakerFee())
|
||||
.withTxFee(proto.getTxFee())
|
||||
.withOfferFeePaymentTxId(proto.getOfferFeePaymentTxId())
|
||||
.withOfferFeePaymentTxId(ProtoUtil.stringOrNullFromProto(proto.getOfferFeePaymentTxId()))
|
||||
.withBuyerSecurityDeposit(proto.getBuyerSecurityDeposit())
|
||||
.withSellerSecurityDeposit(proto.getSellerSecurityDeposit())
|
||||
.withTriggerPrice(proto.getTriggerPrice())
|
||||
|
|
|
@ -413,13 +413,8 @@ public class XmrWalletService {
|
|||
System.out.println("Monero wallet balance: " + wallet.getBalance(0));
|
||||
System.out.println("Monero wallet unlocked balance: " + wallet.getUnlockedBalance(0));
|
||||
|
||||
// notify on balance changes
|
||||
wallet.addListener(new MoneroWalletListener() {
|
||||
@Override
|
||||
public void onBalancesChanged(BigInteger newBalance, BigInteger newUnlockedBalance) {
|
||||
notifyBalanceListeners();
|
||||
}
|
||||
});
|
||||
// register internal listener to notify external listeners
|
||||
wallet.addListener(new XmrWalletListener());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -759,6 +754,15 @@ public class XmrWalletService {
|
|||
return available.filter(addressEntry -> getBalanceForSubaddress(addressEntry.getSubaddressIndex()).isPositive());
|
||||
}
|
||||
|
||||
public void addWalletListener(MoneroWalletListenerI listener) {
|
||||
walletListeners.add(listener);
|
||||
}
|
||||
|
||||
public void removeWalletListener(MoneroWalletListenerI listener) {
|
||||
if (!walletListeners.contains(listener)) throw new RuntimeException("Listener is not registered with wallet");
|
||||
walletListeners.remove(listener);
|
||||
}
|
||||
|
||||
// TODO (woodser): update balance and other listening
|
||||
public void addBalanceListener(XmrBalanceListener listener) {
|
||||
balanceListeners.add(listener);
|
||||
|
@ -787,25 +791,21 @@ public class XmrWalletService {
|
|||
log.info("\n" + tracePrefix + ":" + sb.toString());
|
||||
}
|
||||
|
||||
// -------------------------------- HELPERS -------------------------------
|
||||
|
||||
/**
|
||||
* Wraps a MoneroWalletListener to notify the Haveno application.
|
||||
* Processes internally before notifying external listeners.
|
||||
*
|
||||
* TODO (woodser): this is no longer necessary since not syncing to thread?
|
||||
* TODO: no longer neccessary to execute on user thread?
|
||||
*/
|
||||
public class HavenoWalletListener extends MoneroWalletListener {
|
||||
|
||||
private MoneroWalletListener listener;
|
||||
|
||||
public HavenoWalletListener(MoneroWalletListener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
private class XmrWalletListener extends MoneroWalletListener {
|
||||
|
||||
@Override
|
||||
public void onSyncProgress(long height, long startHeight, long endHeight, double percentDone, String message) {
|
||||
UserThread.execute(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
listener.onSyncProgress(height, startHeight, endHeight, percentDone, message);
|
||||
for (MoneroWalletListenerI listener : walletListeners) listener.onSyncProgress(height, startHeight, endHeight, percentDone, message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -815,7 +815,7 @@ public class XmrWalletService {
|
|||
UserThread.execute(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
listener.onNewBlock(height);
|
||||
for (MoneroWalletListenerI listener : walletListeners) listener.onNewBlock(height);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -825,7 +825,8 @@ public class XmrWalletService {
|
|||
UserThread.execute(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
listener.onBalancesChanged(newBalance, newUnlockedBalance);
|
||||
for (MoneroWalletListenerI listener : walletListeners) listener.onBalancesChanged(newBalance, newUnlockedBalance);
|
||||
notifyBalanceListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -835,7 +836,7 @@ public class XmrWalletService {
|
|||
UserThread.execute(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
listener.onOutputReceived(output);
|
||||
for (MoneroWalletListenerI listener : walletListeners) listener.onOutputReceived(output);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -845,7 +846,7 @@ public class XmrWalletService {
|
|||
UserThread.execute(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
listener.onOutputSpent(output);
|
||||
for (MoneroWalletListenerI listener : walletListeners) listener.onOutputSpent(output);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -22,13 +22,13 @@ import bisq.core.locale.CurrencyUtil;
|
|||
import bisq.core.monetary.Altcoin;
|
||||
import bisq.core.monetary.Price;
|
||||
import bisq.core.monetary.Volume;
|
||||
import bisq.core.offer.OfferPayload.Direction;
|
||||
import bisq.core.offer.availability.OfferAvailabilityModel;
|
||||
import bisq.core.offer.availability.OfferAvailabilityProtocol;
|
||||
import bisq.core.payment.payload.PaymentMethod;
|
||||
import bisq.core.provider.price.MarketPrice;
|
||||
import bisq.core.provider.price.PriceFeedService;
|
||||
import bisq.core.util.VolumeUtil;
|
||||
|
||||
import bisq.network.p2p.NodeAddress;
|
||||
|
||||
import bisq.common.crypto.KeyRing;
|
||||
|
@ -82,6 +82,7 @@ public class Offer implements NetworkPayload, PersistablePayload {
|
|||
|
||||
public enum State {
|
||||
UNKNOWN,
|
||||
SCHEDULED,
|
||||
OFFER_FEE_RESERVED,
|
||||
AVAILABLE,
|
||||
NOT_AVAILABLE,
|
||||
|
@ -257,6 +258,11 @@ public class Offer implements NetworkPayload, PersistablePayload {
|
|||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
public void setState(Offer.State state) {
|
||||
try {
|
||||
throw new RuntimeException("Setting offer state: " + state);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
stateProperty().set(state);
|
||||
}
|
||||
|
||||
|
@ -277,6 +283,15 @@ public class Offer implements NetworkPayload, PersistablePayload {
|
|||
// Getter
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// get the amount needed for the maker to reserve the offer
|
||||
public Coin getReserveAmount() {
|
||||
Coin reserveAmount = getAmount();
|
||||
reserveAmount = reserveAmount.add(getDirection() == Direction.BUY ?
|
||||
getBuyerSecurityDeposit() :
|
||||
getSellerSecurityDeposit());
|
||||
return reserveAmount;
|
||||
}
|
||||
|
||||
// converted payload properties
|
||||
public Coin getTxFee() {
|
||||
return Coin.valueOf(offerPayload.getTxFee());
|
||||
|
|
|
@ -297,9 +297,7 @@ public final class OfferPayload implements ProtectedStoragePayload, ExpirablePay
|
|||
.setProtocolVersion(protocolVersion)
|
||||
.setArbitratorSigner(arbitratorSigner.toProtoMessage());
|
||||
|
||||
builder.setOfferFeePaymentTxId(checkNotNull(offerFeePaymentTxId,
|
||||
"OfferPayload is in invalid state: offerFeePaymentTxID is not set when adding to P2P network."));
|
||||
|
||||
Optional.ofNullable(offerFeePaymentTxId).ifPresent(builder::setOfferFeePaymentTxId);
|
||||
Optional.ofNullable(countryCode).ifPresent(builder::setCountryCode);
|
||||
Optional.ofNullable(bankId).ifPresent(builder::setBankId);
|
||||
Optional.ofNullable(acceptedBankIds).ifPresent(builder::addAllAcceptedBankIds);
|
||||
|
@ -313,7 +311,6 @@ public final class OfferPayload implements ProtectedStoragePayload, ExpirablePay
|
|||
}
|
||||
|
||||
public static OfferPayload fromProto(protobuf.OfferPayload proto) {
|
||||
checkArgument(!proto.getOfferFeePaymentTxId().isEmpty(), "OfferFeePaymentTxId must be set in PB.OfferPayload");
|
||||
List<String> acceptedBankIds = proto.getAcceptedBankIdsList().isEmpty() ?
|
||||
null : new ArrayList<>(proto.getAcceptedBankIdsList());
|
||||
List<String> acceptedCountryCodes = proto.getAcceptedCountryCodesList().isEmpty() ?
|
||||
|
@ -336,7 +333,7 @@ public final class OfferPayload implements ProtectedStoragePayload, ExpirablePay
|
|||
proto.getCounterCurrencyCode(),
|
||||
proto.getPaymentMethodId(),
|
||||
proto.getMakerPaymentAccountId(),
|
||||
proto.getOfferFeePaymentTxId(),
|
||||
ProtoUtil.stringOrNullFromProto(proto.getOfferFeePaymentTxId()),
|
||||
ProtoUtil.stringOrNullFromProto(proto.getCountryCode()),
|
||||
acceptedCountryCodes,
|
||||
ProtoUtil.stringOrNullFromProto(proto.getBankId()),
|
||||
|
|
|
@ -25,6 +25,7 @@ import bisq.common.Timer;
|
|||
import bisq.common.UserThread;
|
||||
import bisq.common.proto.ProtoUtil;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
@ -42,6 +43,7 @@ public final class OpenOffer implements Tradable {
|
|||
transient private Timer timeoutTimer;
|
||||
|
||||
public enum State {
|
||||
SCHEDULED,
|
||||
AVAILABLE,
|
||||
RESERVED,
|
||||
CLOSED,
|
||||
|
@ -59,10 +61,24 @@ public final class OpenOffer implements Tradable {
|
|||
private NodeAddress backupArbitrator;
|
||||
@Setter
|
||||
@Getter
|
||||
private boolean autoSplit;
|
||||
@Setter
|
||||
@Getter
|
||||
@Nullable
|
||||
private String scheduledAmount;
|
||||
@Setter
|
||||
@Getter
|
||||
@Nullable
|
||||
private List<String> scheduledTxHashes;
|
||||
@Nullable
|
||||
@Setter
|
||||
@Getter
|
||||
private String reserveTxHash;
|
||||
@Nullable
|
||||
@Setter
|
||||
@Getter
|
||||
private String reserveTxHex;
|
||||
@Nullable
|
||||
@Setter
|
||||
@Getter
|
||||
private String reserveTxKey;
|
||||
|
@ -77,26 +93,18 @@ public final class OpenOffer implements Tradable {
|
|||
transient private long mempoolStatus = -1;
|
||||
|
||||
public OpenOffer(Offer offer) {
|
||||
this(offer, 0);
|
||||
this(offer, 0, false);
|
||||
}
|
||||
|
||||
public OpenOffer(Offer offer, long triggerPrice) {
|
||||
this.offer = offer;
|
||||
this.triggerPrice = triggerPrice;
|
||||
state = State.AVAILABLE;
|
||||
this(offer, triggerPrice, false);
|
||||
}
|
||||
|
||||
public OpenOffer(Offer offer,
|
||||
long triggerPrice,
|
||||
String reserveTxHash,
|
||||
String reserveTxHex,
|
||||
String reserveTxKey) {
|
||||
public OpenOffer(Offer offer, long triggerPrice, boolean autoSplit) {
|
||||
this.offer = offer;
|
||||
this.triggerPrice = triggerPrice;
|
||||
state = State.AVAILABLE;
|
||||
this.reserveTxHash = reserveTxHash;
|
||||
this.reserveTxHex = reserveTxHex;
|
||||
this.reserveTxKey = reserveTxKey;
|
||||
this.autoSplit = autoSplit;
|
||||
state = State.SCHEDULED;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
@ -107,13 +115,18 @@ public final class OpenOffer implements Tradable {
|
|||
State state,
|
||||
@Nullable NodeAddress backupArbitrator,
|
||||
long triggerPrice,
|
||||
String reserveTxHash,
|
||||
String reserveTxHex,
|
||||
String reserveTxKey) {
|
||||
boolean autoSplit,
|
||||
@Nullable String scheduledAmount,
|
||||
@Nullable List<String> scheduledTxHashes,
|
||||
@Nullable String reserveTxHash,
|
||||
@Nullable String reserveTxHex,
|
||||
@Nullable String reserveTxKey) {
|
||||
this.offer = offer;
|
||||
this.state = state;
|
||||
this.backupArbitrator = backupArbitrator;
|
||||
this.triggerPrice = triggerPrice;
|
||||
this.autoSplit = autoSplit;
|
||||
this.scheduledTxHashes = scheduledTxHashes;
|
||||
this.reserveTxHash = reserveTxHash;
|
||||
this.reserveTxHex = reserveTxHex;
|
||||
this.reserveTxKey = reserveTxKey;
|
||||
|
@ -128,11 +141,14 @@ public final class OpenOffer implements Tradable {
|
|||
.setOffer(offer.toProtoMessage())
|
||||
.setTriggerPrice(triggerPrice)
|
||||
.setState(protobuf.OpenOffer.State.valueOf(state.name()))
|
||||
.setReserveTxHash(reserveTxHash)
|
||||
.setReserveTxHex(reserveTxHex)
|
||||
.setReserveTxKey(reserveTxKey);
|
||||
.setAutoSplit(autoSplit);
|
||||
|
||||
Optional.ofNullable(scheduledAmount).ifPresent(e -> builder.setScheduledAmount(scheduledAmount));
|
||||
Optional.ofNullable(backupArbitrator).ifPresent(nodeAddress -> builder.setBackupArbitrator(nodeAddress.toProtoMessage()));
|
||||
Optional.ofNullable(scheduledTxHashes).ifPresent(e -> builder.addAllScheduledTxHashes(scheduledTxHashes));
|
||||
Optional.ofNullable(reserveTxHash).ifPresent(e -> builder.setReserveTxHash(reserveTxHash));
|
||||
Optional.ofNullable(reserveTxHex).ifPresent(e -> builder.setReserveTxHex(reserveTxHex));
|
||||
Optional.ofNullable(reserveTxKey).ifPresent(e -> builder.setReserveTxKey(reserveTxKey));
|
||||
|
||||
return protobuf.Tradable.newBuilder().setOpenOffer(builder).build();
|
||||
}
|
||||
|
@ -142,6 +158,9 @@ public final class OpenOffer implements Tradable {
|
|||
ProtoUtil.enumFromProto(OpenOffer.State.class, proto.getState().name()),
|
||||
proto.hasBackupArbitrator() ? NodeAddress.fromProto(proto.getBackupArbitrator()) : null,
|
||||
proto.getTriggerPrice(),
|
||||
proto.getAutoSplit(),
|
||||
proto.getScheduledAmount(),
|
||||
proto.getScheduledTxHashesList(),
|
||||
proto.getReserveTxHash(),
|
||||
proto.getReserveTxHex(),
|
||||
proto.getReserveTxKey());
|
||||
|
@ -172,7 +191,7 @@ public final class OpenOffer implements Tradable {
|
|||
this.state = state;
|
||||
|
||||
// We keep it reserved for a limited time, if trade preparation fails we revert to available state
|
||||
if (this.state == State.RESERVED) {
|
||||
if (this.state == State.RESERVED) { // TODO (woodser): remove this?
|
||||
startTimeout();
|
||||
} else {
|
||||
stopTimeout();
|
||||
|
|
|
@ -38,6 +38,7 @@ import bisq.core.support.dispute.arbitration.arbitrator.ArbitratorManager;
|
|||
import bisq.core.support.dispute.mediation.mediator.Mediator;
|
||||
import bisq.core.support.dispute.mediation.mediator.MediatorManager;
|
||||
import bisq.core.trade.TradableList;
|
||||
import bisq.core.trade.TradeUtils;
|
||||
import bisq.core.trade.closed.ClosedTradableManager;
|
||||
import bisq.core.trade.handlers.TransactionResultHandler;
|
||||
import bisq.core.trade.statistics.TradeStatisticsManager;
|
||||
|
@ -85,6 +86,7 @@ import java.util.Map;
|
|||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
|
@ -92,6 +94,10 @@ import org.slf4j.Logger;
|
|||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import lombok.Getter;
|
||||
import monero.wallet.model.MoneroIncomingTransfer;
|
||||
import monero.wallet.model.MoneroTxQuery;
|
||||
import monero.wallet.model.MoneroTxWallet;
|
||||
import monero.wallet.model.MoneroWalletListener;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import javax.annotation.Nullable;
|
||||
|
@ -107,7 +113,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
private static final long REFRESH_INTERVAL_MS = TimeUnit.MINUTES.toMillis(6);
|
||||
|
||||
private final CoreContext coreContext;
|
||||
private final CreateOfferService createOfferService;
|
||||
private final KeyRing keyRing;
|
||||
private final User user;
|
||||
private final P2PService p2PService;
|
||||
|
@ -130,6 +135,7 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
private final SignedOfferList signedOffers = new SignedOfferList();
|
||||
private final PersistenceManager<SignedOfferList> signedOfferPersistenceManager;
|
||||
private final Map<String, PlaceOfferProtocol> placeOfferProtocols = new HashMap<String, PlaceOfferProtocol>();
|
||||
private BigInteger lastUnlockedBalance;
|
||||
private boolean stopped;
|
||||
private Timer periodicRepublishOffersTimer, periodicRefreshOffersTimer, retryRepublishOffersTimer;
|
||||
@Getter
|
||||
|
@ -142,7 +148,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
|
||||
@Inject
|
||||
public OpenOfferManager(CoreContext coreContext,
|
||||
CreateOfferService createOfferService,
|
||||
KeyRing keyRing,
|
||||
User user,
|
||||
P2PService p2PService,
|
||||
|
@ -162,7 +167,6 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
PersistenceManager<TradableList<OpenOffer>> persistenceManager,
|
||||
PersistenceManager<SignedOfferList> signedOfferPersistenceManager) {
|
||||
this.coreContext = coreContext;
|
||||
this.createOfferService = createOfferService;
|
||||
this.keyRing = keyRing;
|
||||
this.user = user;
|
||||
this.p2PService = p2PService;
|
||||
|
@ -223,6 +227,25 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
openOffers.stream()
|
||||
.forEach(openOffer -> OfferUtil.getInvalidMakerFeeTxErrorMessage(openOffer.getOffer(), btcWalletService)
|
||||
.ifPresent(errorMsg -> invalidOffers.add(new Tuple2<>(openOffer, errorMsg))));
|
||||
|
||||
// process unposted offers
|
||||
lastUnlockedBalance = xmrWalletService.getWallet().getUnlockedBalance(0);
|
||||
processUnpostedOffers((transaction) -> {}, (errMessage) -> {
|
||||
log.warn("Error processing unposted offers on new unlocked balance: " + errMessage);
|
||||
});
|
||||
|
||||
// register to process unposted offers when unlocked balance increases
|
||||
xmrWalletService.addWalletListener(new MoneroWalletListener() {
|
||||
@Override
|
||||
public void onBalancesChanged(BigInteger newBalance, BigInteger newUnlockedBalance) {
|
||||
if (lastUnlockedBalance.compareTo(newUnlockedBalance) < 0) {
|
||||
processUnpostedOffers((transaction) -> {}, (errMessage) -> {
|
||||
log.warn("Error processing unposted offers on new unlocked balance: " + errMessage);
|
||||
});
|
||||
}
|
||||
lastUnlockedBalance = newUnlockedBalance;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void cleanUpAddressEntries() {
|
||||
|
@ -384,55 +407,27 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
public void placeOffer(Offer offer,
|
||||
double buyerSecurityDeposit,
|
||||
boolean useSavingsWallet,
|
||||
long triggerPrice,
|
||||
TransactionResultHandler resultHandler,
|
||||
ErrorMessageHandler errorMessageHandler) {
|
||||
checkNotNull(offer.getMakerFee(), "makerFee must not be null");
|
||||
|
||||
Coin reservedFundsForOffer = createOfferService.getReservedFundsForOffer(offer.getDirection(),
|
||||
offer.getAmount(),
|
||||
buyerSecurityDeposit,
|
||||
createOfferService.getSellerSecurityDepositAsDouble(buyerSecurityDeposit));
|
||||
boolean autoSplit = false; // TODO: support in api
|
||||
|
||||
PlaceOfferModel model = new PlaceOfferModel(offer,
|
||||
reservedFundsForOffer,
|
||||
useSavingsWallet,
|
||||
p2PService,
|
||||
btcWalletService,
|
||||
xmrWalletService,
|
||||
tradeWalletService,
|
||||
offerBookService,
|
||||
arbitratorManager,
|
||||
mediatorManager,
|
||||
tradeStatisticsManager,
|
||||
user,
|
||||
keyRing,
|
||||
filterManager);
|
||||
PlaceOfferProtocol placeOfferProtocol = new PlaceOfferProtocol(
|
||||
model,
|
||||
transaction -> {
|
||||
// TODO (woodser): validate offer
|
||||
|
||||
// save reserve tx with open offer
|
||||
OpenOffer openOffer = new OpenOffer(offer, triggerPrice, model.getReserveTx().getHash(), model.getReserveTx().getFullHex(), model.getReserveTx().getKey());
|
||||
// create open offer
|
||||
OpenOffer openOffer = new OpenOffer(offer, triggerPrice, autoSplit);
|
||||
|
||||
// process open offer to schedule or post
|
||||
processUnpostedOffer(openOffer, (transaction) -> {
|
||||
openOffers.add(openOffer);
|
||||
requestPersistence();
|
||||
resultHandler.handleResult(transaction);
|
||||
if (!stopped) {
|
||||
startPeriodicRepublishOffersTimer();
|
||||
startPeriodicRefreshOffersTimer();
|
||||
} else {
|
||||
log.debug("We have stopped already. We ignore that placeOfferProtocol.placeOffer.onResult call.");
|
||||
}
|
||||
},
|
||||
errorMessageHandler
|
||||
);
|
||||
|
||||
synchronized (placeOfferProtocols) {
|
||||
placeOfferProtocols.put(offer.getId(), placeOfferProtocol);
|
||||
}
|
||||
placeOfferProtocol.placeOffer(); // TODO (woodser): if error placing offer (e.g. bad signature), remove protocol and unfreeze trade funds
|
||||
}, (errMessage) -> {
|
||||
errorMessageHandler.handleErrorMessage(errMessage);
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from offerbook
|
||||
|
@ -442,11 +437,8 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
removeOpenOffer(openOfferOptional.get(), resultHandler, errorMessageHandler);
|
||||
} else {
|
||||
log.warn("Offer was not found in our list of open offers. We still try to remove it from the offerbook.");
|
||||
errorMessageHandler.handleErrorMessage("Offer was not found in our list of open offers. " +
|
||||
"We still try to remove it from the offerbook.");
|
||||
offerBookService.removeOffer(offer.getOfferPayload(),
|
||||
() -> offer.setState(Offer.State.REMOVED),
|
||||
null);
|
||||
errorMessageHandler.handleErrorMessage("Offer was not found in our list of open offers. " + "We still try to remove it from the offerbook.");
|
||||
offerBookService.removeOffer(offer.getOfferPayload(), () -> offer.setState(Offer.State.REMOVED), null);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -569,7 +561,9 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
}
|
||||
|
||||
private void onRemoved(@NotNull OpenOffer openOffer, ResultHandler resultHandler, Offer offer) {
|
||||
if (offer.getOfferPayload().getReserveTxKeyImages() != null) {
|
||||
for (String frozenKeyImage : offer.getOfferPayload().getReserveTxKeyImages()) xmrWalletService.getWallet().thawOutput(frozenKeyImage);
|
||||
}
|
||||
offer.setState(Offer.State.REMOVED);
|
||||
openOffer.setState(OpenOffer.State.CANCELED);
|
||||
openOffers.remove(openOffer);
|
||||
|
@ -622,6 +616,166 @@ public class OpenOfferManager implements PeerManager.Listener, DecryptedDirectMe
|
|||
return signedOffers.stream().filter(e -> e.getOfferId().equals(offerId)).findFirst();
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Place offer helpers
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private void processUnpostedOffers(TransactionResultHandler resultHandler, // TODO (woodser): transaction not needed with result handler
|
||||
ErrorMessageHandler errorMessageHandler) {
|
||||
List<String> errorMessages = new ArrayList<String>();
|
||||
for (OpenOffer scheduledOffer : openOffers.getObservableList()) {
|
||||
if (scheduledOffer.getState() != OpenOffer.State.SCHEDULED) continue;
|
||||
CountDownLatch latch = new CountDownLatch(openOffers.list.size());
|
||||
processUnpostedOffer(scheduledOffer, (transaction) -> {
|
||||
latch.countDown();
|
||||
}, errorMessage -> {
|
||||
latch.countDown();
|
||||
errorMessages.add(errorMessage);
|
||||
});
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
requestPersistence();
|
||||
if (errorMessages.size() > 0) errorMessageHandler.handleErrorMessage(errorMessages.toString());
|
||||
else resultHandler.handleResult(null);
|
||||
}
|
||||
|
||||
private void processUnpostedOffer(OpenOffer openOffer, TransactionResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
|
||||
try {
|
||||
|
||||
// get offer reserve amount
|
||||
Coin offerReserveAmountCoin = openOffer.getOffer().getReserveAmount();
|
||||
BigInteger offerReserveAmount = ParsingUtils.centinerosToAtomicUnits(offerReserveAmountCoin.value);
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// otherwise sign and post offer
|
||||
else {
|
||||
signAndPostOffer(openOffer, offerReserveAmountCoin, true, resultHandler, errorMessageHandler);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// handle unscheduled offer
|
||||
if (openOffer.getScheduledTxHashes() == null) {
|
||||
|
||||
// check for sufficient balance - scheduled offers amount
|
||||
if (xmrWalletService.getWallet().getBalance(0).subtract(getScheduledAmount()).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 = new BigInteger("0");
|
||||
for (MoneroTxWallet lockedTx : lockedTxs) {
|
||||
if (isTxScheduled(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 Error("Not enough funds to schedule offer");
|
||||
|
||||
// schedule txs
|
||||
openOffer.setScheduledTxHashes(scheduledTxHashes);
|
||||
openOffer.setScheduledAmount(scheduledAmount.toString());
|
||||
openOffer.getOffer().setState(Offer.State.SCHEDULED);
|
||||
}
|
||||
|
||||
// handle result
|
||||
resultHandler.handleResult(null);
|
||||
} catch (Exception e) {
|
||||
errorMessageHandler.handleErrorMessage(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private BigInteger getScheduledAmount() {
|
||||
BigInteger scheduledAmount = new BigInteger("0");
|
||||
for (OpenOffer openOffer : openOffers.getObservableList()) {
|
||||
if (openOffer.getState() != OpenOffer.State.SCHEDULED) continue;
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
return scheduledAmount;
|
||||
}
|
||||
|
||||
private boolean isTxScheduled(String txHash) {
|
||||
for (OpenOffer openOffer : openOffers.getObservableList()) {
|
||||
if (openOffer.getState() != OpenOffer.State.SCHEDULED) continue;
|
||||
if (openOffer.getScheduledTxHashes() == null) continue;
|
||||
for (String scheduledTxHash : openOffer.getScheduledTxHashes()) {
|
||||
if (txHash.equals(scheduledTxHash)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private void signAndPostOffer(OpenOffer openOffer,
|
||||
Coin offerReserveAmount, // TODO: switch to BigInteger
|
||||
boolean useSavingsWallet, // TODO: remove this
|
||||
TransactionResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) {
|
||||
|
||||
// create model
|
||||
PlaceOfferModel model = new PlaceOfferModel(openOffer.getOffer(),
|
||||
offerReserveAmount,
|
||||
useSavingsWallet,
|
||||
p2PService,
|
||||
btcWalletService,
|
||||
xmrWalletService,
|
||||
tradeWalletService,
|
||||
offerBookService,
|
||||
arbitratorManager,
|
||||
mediatorManager,
|
||||
tradeStatisticsManager,
|
||||
user,
|
||||
keyRing,
|
||||
filterManager);
|
||||
|
||||
// create protocol
|
||||
PlaceOfferProtocol placeOfferProtocol = new PlaceOfferProtocol(model,
|
||||
transaction -> {
|
||||
|
||||
// set reserve tx on open offer
|
||||
openOffer.setReserveTxHash(model.getReserveTx().getHash());
|
||||
openOffer.setReserveTxHex(model.getReserveTx().getHash());
|
||||
openOffer.setReserveTxKey(model.getReserveTx().getKey());
|
||||
|
||||
// set offer state
|
||||
openOffer.setState(OpenOffer.State.AVAILABLE);
|
||||
|
||||
resultHandler.handleResult(transaction);
|
||||
if (!stopped) {
|
||||
startPeriodicRepublishOffersTimer();
|
||||
startPeriodicRefreshOffersTimer();
|
||||
} else {
|
||||
log.debug("We have stopped already. We ignore that placeOfferProtocol.placeOffer.onResult call.");
|
||||
}
|
||||
},
|
||||
errorMessageHandler);
|
||||
|
||||
// run protocol
|
||||
synchronized (placeOfferProtocols) {
|
||||
placeOfferProtocols.put(openOffer.getOffer().getId(), placeOfferProtocol);
|
||||
}
|
||||
placeOfferProtocol.placeOffer(); // TODO (woodser): if error placing offer (e.g. bad signature), remove protocol and unfreeze trade funds
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Arbitrator Signs Offer
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
@ -19,7 +19,7 @@ package bisq.core.offer.placeoffer;
|
|||
|
||||
import bisq.core.offer.messages.SignOfferResponse;
|
||||
import bisq.core.offer.placeoffer.tasks.AddToOfferBook;
|
||||
import bisq.core.offer.placeoffer.tasks.MakerReservesTradeFunds;
|
||||
import bisq.core.offer.placeoffer.tasks.MakerReservesOfferFunds;
|
||||
import bisq.core.offer.placeoffer.tasks.MakerSendsSignOfferRequest;
|
||||
import bisq.core.offer.placeoffer.tasks.MakerProcessesSignOfferResponse;
|
||||
import bisq.core.offer.placeoffer.tasks.ValidateOffer;
|
||||
|
@ -56,7 +56,6 @@ public class PlaceOfferProtocol {
|
|||
// Called from UI
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// TODO (woodser): this returns before offer is placed
|
||||
public void placeOffer() {
|
||||
log.debug("placeOffer() " + model.getOffer().getId());
|
||||
TaskRunner<PlaceOfferModel> taskRunner = new TaskRunner<>(model,
|
||||
|
@ -71,7 +70,7 @@ public class PlaceOfferProtocol {
|
|||
);
|
||||
taskRunner.addTasks(
|
||||
ValidateOffer.class,
|
||||
MakerReservesTradeFunds.class,
|
||||
MakerReservesOfferFunds.class,
|
||||
MakerSendsSignOfferRequest.class
|
||||
);
|
||||
|
||||
|
|
|
@ -29,9 +29,9 @@ import java.util.List;
|
|||
import monero.daemon.model.MoneroOutput;
|
||||
import monero.wallet.model.MoneroTxWallet;
|
||||
|
||||
public class MakerReservesTradeFunds extends Task<PlaceOfferModel> {
|
||||
public class MakerReservesOfferFunds extends Task<PlaceOfferModel> {
|
||||
|
||||
public MakerReservesTradeFunds(TaskRunner taskHandler, PlaceOfferModel model) {
|
||||
public MakerReservesOfferFunds(TaskRunner taskHandler, PlaceOfferModel model) {
|
||||
super(taskHandler, model);
|
||||
}
|
||||
|
||||
|
@ -43,7 +43,7 @@ public class MakerReservesTradeFunds extends Task<PlaceOfferModel> {
|
|||
try {
|
||||
runInterceptHook();
|
||||
|
||||
// freeze trade funds and get reserve tx
|
||||
// freeze offer funds and get reserve tx
|
||||
String returnAddress = model.getXmrWalletService().getOrCreateAddressEntry(offer.getId(), XmrAddressEntry.Context.TRADE_PAYOUT).getAddressString();
|
||||
BigInteger makerFee = ParsingUtils.coinToAtomicUnits(offer.getMakerFee());
|
||||
BigInteger depositAmount = ParsingUtils.coinToAtomicUnits(model.getReservedFundsForOffer());
|
|
@ -901,7 +901,7 @@ public abstract class Trade implements Tradable, Model {
|
|||
}
|
||||
|
||||
// create block listener
|
||||
depositTxListener = processModel.getXmrWalletService().new HavenoWalletListener(new MoneroWalletListener() { // TODO (woodser): separate into own class file
|
||||
depositTxListener = new MoneroWalletListener() {
|
||||
|
||||
Long unlockHeight = null;
|
||||
|
||||
|
@ -939,14 +939,14 @@ public abstract class Trade implements Tradable, Model {
|
|||
if (unlockHeight != null && height == unlockHeight) {
|
||||
log.info("Multisig deposits unlocked for trade {}", getId());
|
||||
setConfirmedState(); // TODO (woodser): bisq "confirmed" = xmr unlocked after 10 confirmations
|
||||
havenoWallet.removeListener(depositTxListener); // remove listener when notified
|
||||
xmrWalletService.removeWalletListener(depositTxListener); // remove listener when notified
|
||||
depositTxListener = null; // prevent re-applying trade state in subsequent requests
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// register wallet listener
|
||||
havenoWallet.addListener(depositTxListener);
|
||||
xmrWalletService.addWalletListener(depositTxListener);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
|
|
|
@ -27,6 +27,7 @@ import bisq.core.offer.OfferPayload;
|
|||
import bisq.core.support.dispute.arbitration.arbitrator.Arbitrator;
|
||||
import bisq.core.trade.messages.InitTradeRequest;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
|
||||
/**
|
||||
* Collection of utilities for trading.
|
||||
|
@ -173,4 +174,12 @@ public class TradeUtils {
|
|||
//
|
||||
// return new Tuple2<>(multiSigAddress.getAddressString(), payoutAddress);
|
||||
}
|
||||
|
||||
public static void waitForLatch(CountDownLatch latch) {
|
||||
try {
|
||||
latch.await();
|
||||
} catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package bisq.core.trade.protocol;
|
|||
|
||||
import bisq.core.trade.ArbitratorTrade;
|
||||
import bisq.core.trade.Trade;
|
||||
import bisq.core.trade.TradeUtils;
|
||||
import bisq.core.trade.messages.DepositRequest;
|
||||
import bisq.core.trade.messages.InitMultisigRequest;
|
||||
import bisq.core.trade.messages.InitTradeRequest;
|
||||
|
@ -59,7 +60,7 @@ public class ArbitratorProtocol extends DisputeProtocol {
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -87,7 +88,7 @@ public class ArbitratorProtocol extends DisputeProtocol {
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -116,7 +117,7 @@ public class ArbitratorProtocol extends DisputeProtocol {
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -144,7 +145,7 @@ public class ArbitratorProtocol extends DisputeProtocol {
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ package bisq.core.trade.protocol;
|
|||
import bisq.core.trade.BuyerAsMakerTrade;
|
||||
import bisq.core.trade.Trade;
|
||||
import bisq.core.trade.Trade.State;
|
||||
import bisq.core.trade.TradeUtils;
|
||||
import bisq.core.trade.messages.DelayedPayoutTxSignatureRequest;
|
||||
import bisq.core.trade.messages.DepositResponse;
|
||||
import bisq.core.trade.messages.DepositTxAndDelayedPayoutTxMessage;
|
||||
|
@ -100,7 +101,7 @@ public class BuyerAsMakerProtocol extends BuyerProtocol implements MakerProtocol
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -129,7 +130,7 @@ public class BuyerAsMakerProtocol extends BuyerProtocol implements MakerProtocol
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -158,7 +159,7 @@ public class BuyerAsMakerProtocol extends BuyerProtocol implements MakerProtocol
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -188,7 +189,7 @@ public class BuyerAsMakerProtocol extends BuyerProtocol implements MakerProtocol
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
} else {
|
||||
EasyBind.subscribe(trade.stateProperty(), state -> {
|
||||
if (state == State.CONTRACT_SIGNATURE_REQUESTED) handleSignContractResponse(message, sender);
|
||||
|
@ -222,7 +223,7 @@ public class BuyerAsMakerProtocol extends BuyerProtocol implements MakerProtocol
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -254,7 +255,7 @@ public class BuyerAsMakerProtocol extends BuyerProtocol implements MakerProtocol
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
} else {
|
||||
EasyBind.subscribe(trade.stateProperty(), state -> {
|
||||
if (state == State.MAKER_RECEIVED_DEPOSIT_TX_PUBLISHED_MSG) handlePaymentAccountPayloadRequest(request, sender);
|
||||
|
|
|
@ -22,6 +22,7 @@ import bisq.core.offer.Offer;
|
|||
import bisq.core.trade.BuyerAsTakerTrade;
|
||||
import bisq.core.trade.Trade;
|
||||
import bisq.core.trade.Trade.State;
|
||||
import bisq.core.trade.TradeUtils;
|
||||
import bisq.core.trade.handlers.TradeResultHandler;
|
||||
import bisq.core.trade.messages.DelayedPayoutTxSignatureRequest;
|
||||
import bisq.core.trade.messages.DepositResponse;
|
||||
|
@ -33,7 +34,6 @@ import bisq.core.trade.messages.PaymentReceivedMessage;
|
|||
import bisq.core.trade.messages.SignContractRequest;
|
||||
import bisq.core.trade.messages.SignContractResponse;
|
||||
import bisq.core.trade.messages.TradeMessage;
|
||||
import bisq.core.trade.protocol.TakerProtocol.TakerEvent;
|
||||
import bisq.core.trade.protocol.tasks.ApplyFilter;
|
||||
import bisq.core.trade.protocol.tasks.ProcessDepositResponse;
|
||||
import bisq.core.trade.protocol.tasks.ProcessInitMultisigRequest;
|
||||
|
@ -116,7 +116,7 @@ public class BuyerAsTakerProtocol extends BuyerProtocol implements TakerProtocol
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -145,7 +145,7 @@ public class BuyerAsTakerProtocol extends BuyerProtocol implements TakerProtocol
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -174,7 +174,7 @@ public class BuyerAsTakerProtocol extends BuyerProtocol implements TakerProtocol
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -204,7 +204,7 @@ public class BuyerAsTakerProtocol extends BuyerProtocol implements TakerProtocol
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
} else {
|
||||
EasyBind.subscribe(trade.stateProperty(), state -> {
|
||||
if (state != State.CONTRACT_SIGNATURE_REQUESTED) return;
|
||||
|
@ -239,7 +239,7 @@ public class BuyerAsTakerProtocol extends BuyerProtocol implements TakerProtocol
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -271,7 +271,7 @@ public class BuyerAsTakerProtocol extends BuyerProtocol implements TakerProtocol
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
} else {
|
||||
EasyBind.subscribe(trade.stateProperty(), state -> {
|
||||
if (state == State.MAKER_RECEIVED_DEPOSIT_TX_PUBLISHED_MSG) handlePaymentAccountPayloadRequest(request, sender);
|
||||
|
|
|
@ -19,6 +19,7 @@ package bisq.core.trade.protocol;
|
|||
|
||||
import bisq.core.trade.BuyerTrade;
|
||||
import bisq.core.trade.Trade;
|
||||
import bisq.core.trade.TradeUtils;
|
||||
import bisq.core.trade.messages.DelayedPayoutTxSignatureRequest;
|
||||
import bisq.core.trade.messages.DepositTxAndDelayedPayoutTxMessage;
|
||||
import bisq.core.trade.messages.PaymentReceivedMessage;
|
||||
|
@ -26,7 +27,6 @@ import bisq.core.trade.messages.TradeMessage;
|
|||
import bisq.core.trade.protocol.tasks.ApplyFilter;
|
||||
import bisq.core.trade.protocol.tasks.SetupDepositTxsListener;
|
||||
import bisq.core.trade.protocol.tasks.TradeTask;
|
||||
import bisq.core.trade.protocol.tasks.UpdateMultisigWithTradingPeer;
|
||||
import bisq.core.trade.protocol.tasks.buyer.BuyerPreparesPaymentSentMessage;
|
||||
import bisq.core.trade.protocol.tasks.buyer.BuyerProcessesPaymentReceivedMessage;
|
||||
import bisq.core.trade.protocol.tasks.buyer.BuyerSendsPaymentSentMessage;
|
||||
|
@ -182,7 +182,7 @@ public abstract class BuyerProtocol extends DisputeProtocol {
|
|||
handleTaskRunnerFault(peer, message, errorMessage);
|
||||
})))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ package bisq.core.trade.protocol;
|
|||
import bisq.core.trade.SellerAsMakerTrade;
|
||||
import bisq.core.trade.Trade;
|
||||
import bisq.core.trade.Trade.State;
|
||||
import bisq.core.trade.TradeUtils;
|
||||
import bisq.core.trade.messages.PaymentSentMessage;
|
||||
import bisq.core.trade.messages.DepositResponse;
|
||||
import bisq.core.trade.messages.DepositTxMessage;
|
||||
|
@ -100,7 +101,7 @@ public class SellerAsMakerProtocol extends SellerProtocol implements MakerProtoc
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -129,7 +130,7 @@ public class SellerAsMakerProtocol extends SellerProtocol implements MakerProtoc
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -158,7 +159,7 @@ public class SellerAsMakerProtocol extends SellerProtocol implements MakerProtoc
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -188,7 +189,7 @@ public class SellerAsMakerProtocol extends SellerProtocol implements MakerProtoc
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
} else {
|
||||
EasyBind.subscribe(trade.stateProperty(), state -> {
|
||||
if (state == State.CONTRACT_SIGNATURE_REQUESTED) handleSignContractResponse(message, sender);
|
||||
|
@ -222,7 +223,7 @@ public class SellerAsMakerProtocol extends SellerProtocol implements MakerProtoc
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -254,7 +255,7 @@ public class SellerAsMakerProtocol extends SellerProtocol implements MakerProtoc
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
} else {
|
||||
EasyBind.subscribe(trade.stateProperty(), state -> {
|
||||
if (state == State.MAKER_RECEIVED_DEPOSIT_TX_PUBLISHED_MSG) handlePaymentAccountPayloadRequest(request, sender);
|
||||
|
|
|
@ -22,6 +22,7 @@ import bisq.core.offer.Offer;
|
|||
import bisq.core.trade.SellerAsTakerTrade;
|
||||
import bisq.core.trade.Trade;
|
||||
import bisq.core.trade.Trade.State;
|
||||
import bisq.core.trade.TradeUtils;
|
||||
import bisq.core.trade.handlers.TradeResultHandler;
|
||||
import bisq.core.trade.messages.PaymentSentMessage;
|
||||
import bisq.core.trade.messages.DepositResponse;
|
||||
|
@ -108,7 +109,7 @@ public class SellerAsTakerProtocol extends SellerProtocol implements TakerProtoc
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -137,7 +138,7 @@ public class SellerAsTakerProtocol extends SellerProtocol implements TakerProtoc
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -166,7 +167,7 @@ public class SellerAsTakerProtocol extends SellerProtocol implements TakerProtoc
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -196,7 +197,7 @@ public class SellerAsTakerProtocol extends SellerProtocol implements TakerProtoc
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
} else {
|
||||
EasyBind.subscribe(trade.stateProperty(), state -> {
|
||||
if (state != State.CONTRACT_SIGNATURE_REQUESTED) return;
|
||||
|
@ -231,7 +232,7 @@ public class SellerAsTakerProtocol extends SellerProtocol implements TakerProtoc
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -263,7 +264,7 @@ public class SellerAsTakerProtocol extends SellerProtocol implements TakerProtoc
|
|||
}))
|
||||
.withTimeout(TRADE_TIMEOUT))
|
||||
.executeTasks();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
} else {
|
||||
EasyBind.subscribe(trade.stateProperty(), state -> {
|
||||
if (state == State.MAKER_RECEIVED_DEPOSIT_TX_PUBLISHED_MSG) handlePaymentAccountPayloadRequest(request, sender);
|
||||
|
|
|
@ -20,6 +20,7 @@ package bisq.core.trade.protocol;
|
|||
import bisq.core.offer.Offer;
|
||||
import bisq.core.trade.Trade;
|
||||
import bisq.core.trade.TradeManager;
|
||||
import bisq.core.trade.TradeUtils;
|
||||
import bisq.core.trade.handlers.TradeResultHandler;
|
||||
import bisq.core.trade.messages.PaymentSentMessage;
|
||||
import bisq.core.trade.messages.DepositTxAndDelayedPayoutTxMessage;
|
||||
|
@ -236,7 +237,7 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D
|
|||
);
|
||||
startTimeout(TRADE_TIMEOUT);
|
||||
taskRunner.run();
|
||||
wait(latch);
|
||||
TradeUtils.waitForLatch(latch);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -368,14 +369,6 @@ public abstract class TradeProtocol implements DecryptedDirectMessageListener, D
|
|||
// Timeout
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
protected void wait(CountDownLatch latch) {
|
||||
try {
|
||||
latch.await();
|
||||
} catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
protected void startTimeout(long timeoutSec) {
|
||||
stopTimeout();
|
||||
timeoutTimer = UserThread.runAfter(() -> {
|
||||
|
|
|
@ -56,7 +56,6 @@ public class OpenOfferManagerTest {
|
|||
when(p2PService.getPeerManager()).thenReturn(mock(PeerManager.class));
|
||||
|
||||
final OpenOfferManager manager = new OpenOfferManager(coreContext,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
p2PService,
|
||||
|
@ -103,7 +102,6 @@ public class OpenOfferManagerTest {
|
|||
when(p2PService.getPeerManager()).thenReturn(mock(PeerManager.class));
|
||||
|
||||
final OpenOfferManager manager = new OpenOfferManager(coreContext,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
p2PService,
|
||||
|
@ -144,7 +142,6 @@ public class OpenOfferManagerTest {
|
|||
|
||||
|
||||
final OpenOfferManager manager = new OpenOfferManager(coreContext,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
p2PService,
|
||||
|
|
|
@ -39,7 +39,7 @@ public class TradableListTest {
|
|||
|
||||
// test adding an OpenOffer and convert toProto
|
||||
Offer offer = new Offer(offerPayload);
|
||||
OpenOffer openOffer = new OpenOffer(offer, 0, "", "", "");
|
||||
OpenOffer openOffer = new OpenOffer(offer, 0);
|
||||
openOfferTradableList.add(openOffer);
|
||||
message = (protobuf.PersistableEnvelope) openOfferTradableList.toProtoMessage();
|
||||
assertEquals(message.getMessageCase(), TRADABLE_LIST);
|
||||
|
|
|
@ -24,7 +24,7 @@ import bisq.desktop.components.TitledGroupBg;
|
|||
import bisq.core.offer.availability.tasks.ProcessOfferAvailabilityResponse;
|
||||
import bisq.core.offer.availability.tasks.SendOfferAvailabilityRequest;
|
||||
import bisq.core.offer.placeoffer.tasks.AddToOfferBook;
|
||||
import bisq.core.offer.placeoffer.tasks.MakerReservesTradeFunds;
|
||||
import bisq.core.offer.placeoffer.tasks.MakerReservesOfferFunds;
|
||||
import bisq.core.offer.placeoffer.tasks.ValidateOffer;
|
||||
import bisq.core.trade.protocol.tasks.ApplyFilter;
|
||||
import bisq.core.trade.protocol.tasks.VerifyPeersAccountAgeWitness;
|
||||
|
@ -109,7 +109,7 @@ public class DebugView extends InitializableView<GridPane, Void> {
|
|||
addGroup("PlaceOfferProtocol",
|
||||
FXCollections.observableArrayList(Arrays.asList(
|
||||
ValidateOffer.class,
|
||||
MakerReservesTradeFunds.class,
|
||||
MakerReservesOfferFunds.class,
|
||||
AddToOfferBook.class)
|
||||
));
|
||||
|
||||
|
|
|
@ -315,7 +315,6 @@ public abstract class MutableOfferDataModel extends OfferDataModel {
|
|||
|
||||
void onPlaceOffer(Offer offer, TransactionResultHandler resultHandler) {
|
||||
openOfferManager.placeOffer(offer,
|
||||
buyerSecurityDeposit.get(),
|
||||
useSavingsWallet,
|
||||
triggerPrice,
|
||||
resultHandler,
|
||||
|
|
|
@ -1450,11 +1450,12 @@ message Offer {
|
|||
enum State {
|
||||
PB_ERROR = 0;
|
||||
UNKNOWN = 1;
|
||||
OFFER_FEE_PAID = 2;
|
||||
AVAILABLE = 3;
|
||||
NOT_AVAILABLE = 4;
|
||||
REMOVED = 5;
|
||||
MAKER_OFFLINE = 6;
|
||||
SCHEDULED = 2;
|
||||
OFFER_FEE_RESERVED = 3;
|
||||
AVAILABLE = 4;
|
||||
NOT_AVAILABLE = 5;
|
||||
REMOVED = 6;
|
||||
MAKER_OFFLINE = 7;
|
||||
}
|
||||
|
||||
OfferPayload offer_payload = 1;
|
||||
|
@ -1474,20 +1475,24 @@ message SignedOffer {
|
|||
message OpenOffer {
|
||||
enum State {
|
||||
PB_ERROR = 0;
|
||||
AVAILABLE = 1;
|
||||
RESERVED = 2;
|
||||
CLOSED = 3;
|
||||
CANCELED = 4;
|
||||
DEACTIVATED = 5;
|
||||
SCHEDULED = 1;
|
||||
AVAILABLE = 2;
|
||||
RESERVED = 3;
|
||||
CLOSED = 4;
|
||||
CANCELED = 5;
|
||||
DEACTIVATED = 6;
|
||||
}
|
||||
|
||||
Offer offer = 1;
|
||||
State state = 2;
|
||||
NodeAddress backup_arbitrator = 3;
|
||||
int64 trigger_price = 4;
|
||||
string reserve_tx_hash = 5;
|
||||
string reserve_tx_hex = 6;
|
||||
string reserve_tx_key = 7;
|
||||
bool auto_split = 5;
|
||||
repeated string scheduled_tx_hashes = 6;
|
||||
string scheduled_amount = 7; // BigInteger
|
||||
string reserve_tx_hash = 8;
|
||||
string reserve_tx_hex = 9;
|
||||
string reserve_tx_key = 10;
|
||||
}
|
||||
|
||||
message Tradable {
|
||||
|
|
Loading…
Reference in a new issue