diff --git a/assets/src/main/java/bisq/asset/CryptoNoteAddressValidator.java b/assets/src/main/java/bisq/asset/CryptoNoteAddressValidator.java index 6409e5a4f8e..295c1e0af8a 100644 --- a/assets/src/main/java/bisq/asset/CryptoNoteAddressValidator.java +++ b/assets/src/main/java/bisq/asset/CryptoNoteAddressValidator.java @@ -17,13 +17,6 @@ package bisq.asset; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; - -import java.math.BigInteger; - -import java.util.Map; - /** * {@link AddressValidator} for Base58-encoded Cryptonote addresses. * @@ -46,7 +39,7 @@ public CryptoNoteAddressValidator(long... validPrefixes) { @Override public AddressValidationResult validate(String address) { try { - long prefix = MoneroBase58.decodeAddress(address, this.validateChecksum); + long prefix = CryptoNoteUtils.MoneroBase58.decodeAddress(address, this.validateChecksum); for (long validPrefix : this.validPrefixes) { if (prefix == validPrefix) { return AddressValidationResult.validAddress(); @@ -58,208 +51,3 @@ public AddressValidationResult validate(String address) { } } } - -class Keccak { - - private static final int BLOCK_SIZE = 136; - private static final int LONGS_PER_BLOCK = BLOCK_SIZE / 8; - private static final int KECCAK_ROUNDS = 24; - private static final long[] KECCAKF_RNDC = { - 0x0000000000000001L, 0x0000000000008082L, 0x800000000000808aL, - 0x8000000080008000L, 0x000000000000808bL, 0x0000000080000001L, - 0x8000000080008081L, 0x8000000000008009L, 0x000000000000008aL, - 0x0000000000000088L, 0x0000000080008009L, 0x000000008000000aL, - 0x000000008000808bL, 0x800000000000008bL, 0x8000000000008089L, - 0x8000000000008003L, 0x8000000000008002L, 0x8000000000000080L, - 0x000000000000800aL, 0x800000008000000aL, 0x8000000080008081L, - 0x8000000000008080L, 0x0000000080000001L, 0x8000000080008008L - }; - private static final int[] KECCAKF_ROTC = { - 1, 3, 6, 10, 15, 21, 28, 36, 45, 55, 2, 14, - 27, 41, 56, 8, 25, 43, 62, 18, 39, 61, 20, 44 - }; - private static final int[] KECCAKF_PILN = { - 10, 7, 11, 17, 18, 3, 5, 16, 8, 21, 24, 4, - 15, 23, 19, 13, 12, 2, 20, 14, 22, 9, 6, 1 - }; - - private static long rotateLeft(long value, int shift) { - return (value << shift) | (value >>> (64 - shift)); - } - - private static void keccakf(long[] st, int rounds) { - long[] bc = new long[5]; - - for (int round = 0; round < rounds; ++round) { - for (int i = 0; i < 5; ++i) { - bc[i] = st[i] ^ st[i + 5] ^ st[i + 10] ^ st[i + 15] ^ st[i + 20]; - } - - for (int i = 0; i < 5; i++) { - long t = bc[(i + 4) % 5] ^ rotateLeft(bc[(i + 1) % 5], 1); - for (int j = 0; j < 25; j += 5) { - st[j + i] ^= t; - } - } - - long t = st[1]; - for (int i = 0; i < 24; ++i) { - int j = KECCAKF_PILN[i]; - bc[0] = st[j]; - st[j] = rotateLeft(t, KECCAKF_ROTC[i]); - t = bc[0]; - } - - for (int j = 0; j < 25; j += 5) { - for (int i = 0; i < 5; i++) { - bc[i] = st[j + i]; - } - for (int i = 0; i < 5; i++) { - st[j + i] ^= (~bc[(i + 1) % 5]) & bc[(i + 2) % 5]; - } - } - - st[0] ^= KECCAKF_RNDC[round]; - } - } - - public static ByteBuffer keccak1600(ByteBuffer input) { - input.order(ByteOrder.LITTLE_ENDIAN); - - int fullBlocks = input.remaining() / BLOCK_SIZE; - long[] st = new long[25]; - for (int block = 0; block < fullBlocks; ++block) { - for (int index = 0; index < LONGS_PER_BLOCK; ++index) { - st[index] ^= input.getLong(); - } - keccakf(st, KECCAK_ROUNDS); - } - - ByteBuffer lastBlock = ByteBuffer.allocate(144).order(ByteOrder.LITTLE_ENDIAN); - lastBlock.put(input); - lastBlock.put((byte)1); - int paddingOffset = BLOCK_SIZE - 1; - lastBlock.put(paddingOffset, (byte)(lastBlock.get(paddingOffset) | 0x80)); - lastBlock.rewind(); - - for (int index = 0; index < LONGS_PER_BLOCK; ++index) { - st[index] ^= lastBlock.getLong(); - } - - keccakf(st, KECCAK_ROUNDS); - - ByteBuffer result = ByteBuffer.allocate(32); - result.slice().order(ByteOrder.LITTLE_ENDIAN).asLongBuffer().put(st, 0, 4); - return result; - } -} - -class MoneroBase58 { - - private static final String ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; - private static final BigInteger ALPHABET_SIZE = BigInteger.valueOf(ALPHABET.length()); - private static final int FULL_DECODED_BLOCK_SIZE = 8; - private static final int FULL_ENCODED_BLOCK_SIZE = 11; - private static final BigInteger UINT64_MAX = new BigInteger("18446744073709551615"); - private static final Map DECODED_CHUNK_LENGTH = Map.of( 2, 1, - 3, 2, - 5, 3, - 6, 4, - 7, 5, - 9, 6, - 10, 7, - 11, 8); - - private static void decodeChunk(String input, - int inputOffset, - int inputLength, - byte[] decoded, - int decodedOffset, - int decodedLength) throws Exception { - - BigInteger result = BigInteger.ZERO; - - BigInteger order = BigInteger.ONE; - for (int index = inputOffset + inputLength; index != inputOffset; order = order.multiply(ALPHABET_SIZE)) { - char character = input.charAt(--index); - int digit = ALPHABET.indexOf(character); - if (digit == -1) { - throw new Exception("invalid character " + character); - } - result = result.add(order.multiply(BigInteger.valueOf(digit))); - if (result.compareTo(UINT64_MAX) > 0) { - throw new Exception("64-bit unsigned integer overflow " + result.toString()); - } - } - - BigInteger maxCapacity = BigInteger.ONE.shiftLeft(8 * decodedLength); - if (result.compareTo(maxCapacity) >= 0) { - throw new Exception("capacity overflow " + result.toString()); - } - - for (int index = decodedOffset + decodedLength; index != decodedOffset; result = result.shiftRight(8)) { - decoded[--index] = result.byteValue(); - } - } - - private static byte[] decode(String input) throws Exception { - if (input.length() == 0) { - return new byte[0]; - } - - int chunks = input.length() / FULL_ENCODED_BLOCK_SIZE; - int lastEncodedSize = input.length() % FULL_ENCODED_BLOCK_SIZE; - int lastChunkSize = lastEncodedSize > 0 ? DECODED_CHUNK_LENGTH.get(lastEncodedSize) : 0; - - byte[] result = new byte[chunks * FULL_DECODED_BLOCK_SIZE + lastChunkSize]; - int inputOffset = 0; - int resultOffset = 0; - for (int chunk = 0; chunk < chunks; ++chunk, - inputOffset += FULL_ENCODED_BLOCK_SIZE, - resultOffset += FULL_DECODED_BLOCK_SIZE) { - decodeChunk(input, inputOffset, FULL_ENCODED_BLOCK_SIZE, result, resultOffset, FULL_DECODED_BLOCK_SIZE); - } - if (lastChunkSize > 0) { - decodeChunk(input, inputOffset, lastEncodedSize, result, resultOffset, lastChunkSize); - } - - return result; - } - - private static long readVarInt(ByteBuffer buffer) { - long result = 0; - for (int shift = 0; ; shift += 7) { - byte current = buffer.get(); - result += (current & 0x7fL) << shift; - if ((current & 0x80L) == 0) { - break; - } - } - return result; - } - - public static long decodeAddress(String address, boolean validateChecksum) throws Exception { - byte[] decoded = decode(address); - - int checksumSize = 4; - if (decoded.length < checksumSize) { - throw new Exception("invalid length"); - } - - ByteBuffer decodedAddress = ByteBuffer.wrap(decoded, 0, decoded.length - checksumSize); - - long prefix = readVarInt(decodedAddress.slice()); - if (!validateChecksum) { - return prefix; - } - - ByteBuffer fastHash = Keccak.keccak1600(decodedAddress.slice()); - int checksum = fastHash.getInt(); - int expected = ByteBuffer.wrap(decoded, decoded.length - checksumSize, checksumSize).getInt(); - if (checksum != expected) { - throw new Exception(String.format("invalid checksum %08X, expected %08X", checksum, expected)); - } - - return prefix; - } -} diff --git a/assets/src/main/java/bisq/asset/CryptoNoteUtils.java b/assets/src/main/java/bisq/asset/CryptoNoteUtils.java new file mode 100644 index 00000000000..425fbe19b2e --- /dev/null +++ b/assets/src/main/java/bisq/asset/CryptoNoteUtils.java @@ -0,0 +1,247 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.asset; + +import org.bitcoinj.core.Utils; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import java.math.BigInteger; + +import java.util.Arrays; +import java.util.Map; + +public class CryptoNoteUtils { + public static String convertToRawHex(String address) { + try { + byte[] decoded = MoneroBase58.decode(address); + // omit the type (1st byte) and checksum (last 4 byte) + byte[] slice = Arrays.copyOfRange(decoded, 1, decoded.length - 4); + return Utils.HEX.encode(slice); + } catch (Exception e) { + e.printStackTrace(); + } + return null; + } + + static class Keccak { + private static final int BLOCK_SIZE = 136; + private static final int LONGS_PER_BLOCK = BLOCK_SIZE / 8; + private static final int KECCAK_ROUNDS = 24; + private static final long[] KECCAKF_RNDC = { + 0x0000000000000001L, 0x0000000000008082L, 0x800000000000808aL, + 0x8000000080008000L, 0x000000000000808bL, 0x0000000080000001L, + 0x8000000080008081L, 0x8000000000008009L, 0x000000000000008aL, + 0x0000000000000088L, 0x0000000080008009L, 0x000000008000000aL, + 0x000000008000808bL, 0x800000000000008bL, 0x8000000000008089L, + 0x8000000000008003L, 0x8000000000008002L, 0x8000000000000080L, + 0x000000000000800aL, 0x800000008000000aL, 0x8000000080008081L, + 0x8000000000008080L, 0x0000000080000001L, 0x8000000080008008L + }; + private static final int[] KECCAKF_ROTC = { + 1, 3, 6, 10, 15, 21, 28, 36, 45, 55, 2, 14, + 27, 41, 56, 8, 25, 43, 62, 18, 39, 61, 20, 44 + }; + private static final int[] KECCAKF_PILN = { + 10, 7, 11, 17, 18, 3, 5, 16, 8, 21, 24, 4, + 15, 23, 19, 13, 12, 2, 20, 14, 22, 9, 6, 1 + }; + + private static long rotateLeft(long value, int shift) { + return (value << shift) | (value >>> (64 - shift)); + } + + private static void keccakf(long[] st, int rounds) { + long[] bc = new long[5]; + + for (int round = 0; round < rounds; ++round) { + for (int i = 0; i < 5; ++i) { + bc[i] = st[i] ^ st[i + 5] ^ st[i + 10] ^ st[i + 15] ^ st[i + 20]; + } + + for (int i = 0; i < 5; i++) { + long t = bc[(i + 4) % 5] ^ rotateLeft(bc[(i + 1) % 5], 1); + for (int j = 0; j < 25; j += 5) { + st[j + i] ^= t; + } + } + + long t = st[1]; + for (int i = 0; i < 24; ++i) { + int j = KECCAKF_PILN[i]; + bc[0] = st[j]; + st[j] = rotateLeft(t, KECCAKF_ROTC[i]); + t = bc[0]; + } + + for (int j = 0; j < 25; j += 5) { + for (int i = 0; i < 5; i++) { + bc[i] = st[j + i]; + } + for (int i = 0; i < 5; i++) { + st[j + i] ^= (~bc[(i + 1) % 5]) & bc[(i + 2) % 5]; + } + } + + st[0] ^= KECCAKF_RNDC[round]; + } + } + + static ByteBuffer keccak1600(ByteBuffer input) { + input.order(ByteOrder.LITTLE_ENDIAN); + + int fullBlocks = input.remaining() / BLOCK_SIZE; + long[] st = new long[25]; + for (int block = 0; block < fullBlocks; ++block) { + for (int index = 0; index < LONGS_PER_BLOCK; ++index) { + st[index] ^= input.getLong(); + } + keccakf(st, KECCAK_ROUNDS); + } + + ByteBuffer lastBlock = ByteBuffer.allocate(144).order(ByteOrder.LITTLE_ENDIAN); + lastBlock.put(input); + lastBlock.put((byte) 1); + int paddingOffset = BLOCK_SIZE - 1; + lastBlock.put(paddingOffset, (byte) (lastBlock.get(paddingOffset) | 0x80)); + lastBlock.rewind(); + + for (int index = 0; index < LONGS_PER_BLOCK; ++index) { + st[index] ^= lastBlock.getLong(); + } + + keccakf(st, KECCAK_ROUNDS); + + ByteBuffer result = ByteBuffer.allocate(32); + result.slice().order(ByteOrder.LITTLE_ENDIAN).asLongBuffer().put(st, 0, 4); + return result; + } + } + + static class MoneroBase58 { + + private static final String ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + private static final BigInteger ALPHABET_SIZE = BigInteger.valueOf(ALPHABET.length()); + private static final int FULL_DECODED_BLOCK_SIZE = 8; + private static final int FULL_ENCODED_BLOCK_SIZE = 11; + private static final BigInteger UINT64_MAX = new BigInteger("18446744073709551615"); + private static final Map DECODED_CHUNK_LENGTH = Map.of(2, 1, + 3, 2, + 5, 3, + 6, 4, + 7, 5, + 9, 6, + 10, 7, + 11, 8); + + private static void decodeChunk(String input, + int inputOffset, + int inputLength, + byte[] decoded, + int decodedOffset, + int decodedLength) throws Exception { + + BigInteger result = BigInteger.ZERO; + + BigInteger order = BigInteger.ONE; + for (int index = inputOffset + inputLength; index != inputOffset; order = order.multiply(ALPHABET_SIZE)) { + char character = input.charAt(--index); + int digit = ALPHABET.indexOf(character); + if (digit == -1) { + throw new Exception("invalid character " + character); + } + result = result.add(order.multiply(BigInteger.valueOf(digit))); + if (result.compareTo(UINT64_MAX) > 0) { + throw new Exception("64-bit unsigned integer overflow " + result.toString()); + } + } + + BigInteger maxCapacity = BigInteger.ONE.shiftLeft(8 * decodedLength); + if (result.compareTo(maxCapacity) >= 0) { + throw new Exception("capacity overflow " + result.toString()); + } + + for (int index = decodedOffset + decodedLength; index != decodedOffset; result = result.shiftRight(8)) { + decoded[--index] = result.byteValue(); + } + } + + public static byte[] decode(String input) throws Exception { + if (input.length() == 0) { + return new byte[0]; + } + + int chunks = input.length() / FULL_ENCODED_BLOCK_SIZE; + int lastEncodedSize = input.length() % FULL_ENCODED_BLOCK_SIZE; + int lastChunkSize = lastEncodedSize > 0 ? DECODED_CHUNK_LENGTH.get(lastEncodedSize) : 0; + + byte[] result = new byte[chunks * FULL_DECODED_BLOCK_SIZE + lastChunkSize]; + int inputOffset = 0; + int resultOffset = 0; + for (int chunk = 0; chunk < chunks; ++chunk, + inputOffset += FULL_ENCODED_BLOCK_SIZE, + resultOffset += FULL_DECODED_BLOCK_SIZE) { + decodeChunk(input, inputOffset, FULL_ENCODED_BLOCK_SIZE, result, resultOffset, FULL_DECODED_BLOCK_SIZE); + } + if (lastChunkSize > 0) { + decodeChunk(input, inputOffset, lastEncodedSize, result, resultOffset, lastChunkSize); + } + + return result; + } + + private static long readVarInt(ByteBuffer buffer) { + long result = 0; + for (int shift = 0; ; shift += 7) { + byte current = buffer.get(); + result += (current & 0x7fL) << shift; + if ((current & 0x80L) == 0) { + break; + } + } + return result; + } + + static long decodeAddress(String address, boolean validateChecksum) throws Exception { + byte[] decoded = decode(address); + + int checksumSize = 4; + if (decoded.length < checksumSize) { + throw new Exception("invalid length"); + } + + ByteBuffer decodedAddress = ByteBuffer.wrap(decoded, 0, decoded.length - checksumSize); + + long prefix = readVarInt(decodedAddress.slice()); + if (!validateChecksum) { + return prefix; + } + + ByteBuffer fastHash = Keccak.keccak1600(decodedAddress.slice()); + int checksum = fastHash.getInt(); + int expected = ByteBuffer.wrap(decoded, decoded.length - checksumSize, checksumSize).getInt(); + if (checksum != expected) { + throw new Exception(String.format("invalid checksum %08X, expected %08X", checksum, expected)); + } + + return prefix; + } + } +} + diff --git a/common/src/main/java/bisq/common/proto/ProtoUtil.java b/common/src/main/java/bisq/common/proto/ProtoUtil.java index 6fd7b0f2e5d..da3cf029776 100644 --- a/common/src/main/java/bisq/common/proto/ProtoUtil.java +++ b/common/src/main/java/bisq/common/proto/ProtoUtil.java @@ -66,6 +66,10 @@ public static byte[] byteArrayOrNullFromProto(ByteString proto) { */ @Nullable public static > E enumFromProto(Class enumType, String name) { + if (name == null) { + return null; + } + E result = Enums.getIfPresent(enumType, name).orNull(); if (result == null) { log.error("Invalid value for enum " + enumType.getSimpleName() + ": " + name); @@ -77,7 +81,8 @@ public static > E enumFromProto(Class enumType, String name return result; } - public static Iterable collectionToProto(Collection collection, Class messageType) { + public static Iterable collectionToProto(Collection collection, + Class messageType) { return collection.stream() .map(e -> { final Message message = e.toProtoMessage(); @@ -92,7 +97,8 @@ public static Iterable collectionToProto(Collection Iterable collectionToProto(Collection collection, Function extra) { + public static Iterable collectionToProto(Collection collection, + Function extra) { return collection.stream().map(o -> extra.apply(o.toProtoMessage())).collect(Collectors.toList()); } } diff --git a/core/src/main/java/bisq/core/account/witness/AccountAgeWitnessService.java b/core/src/main/java/bisq/core/account/witness/AccountAgeWitnessService.java index f4620c7d0e2..884fbf266d5 100644 --- a/core/src/main/java/bisq/core/account/witness/AccountAgeWitnessService.java +++ b/core/src/main/java/bisq/core/account/witness/AccountAgeWitnessService.java @@ -34,12 +34,16 @@ import bisq.core.support.dispute.Dispute; import bisq.core.support.dispute.DisputeResult; import bisq.core.support.dispute.arbitration.TraderDataItem; +import bisq.core.trade.Contract; import bisq.core.trade.Trade; +import bisq.core.trade.messages.TraderSignedWitnessMessage; import bisq.core.trade.protocol.TradingPeer; import bisq.core.user.User; import bisq.network.p2p.BootstrapListener; +import bisq.network.p2p.NodeAddress; import bisq.network.p2p.P2PService; +import bisq.network.p2p.SendMailboxMessageListener; import bisq.network.p2p.storage.P2PDataStorage; import bisq.network.p2p.storage.persistence.AppendOnlyDataStoreService; @@ -73,6 +77,7 @@ import java.util.Optional; import java.util.Random; import java.util.Set; +import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -852,4 +857,58 @@ public void signSameNameAccounts() { public Set getUnsignedSignerPubKeys() { return signedWitnessService.getUnsignedSignerPubKeys(); } + + public boolean isSignWitnessTrade(Trade trade) { + checkNotNull(trade, "trade must not be null"); + checkNotNull(trade.getOffer(), "offer must not be null"); + Contract contract = checkNotNull(trade.getContract()); + PaymentAccountPayload sellerPaymentAccountPayload = contract.getSellerPaymentAccountPayload(); + AccountAgeWitness myWitness = getMyWitness(sellerPaymentAccountPayload); + + getAccountAgeWitnessUtils().witnessDebugLog(trade, myWitness); + + return accountIsSigner(myWitness) && + !peerHasSignedWitness(trade) && + tradeAmountIsSufficient(trade.getTradeAmount()); + } + + public void maybeSignWitness(Trade trade) { + if (isSignWitnessTrade(trade)) { + var signedWitnessOptional = traderSignPeersAccountAgeWitness(trade); + signedWitnessOptional.ifPresent(signedWitness -> sendSignedWitnessToPeer(signedWitness, trade)); + } + } + + private void sendSignedWitnessToPeer(SignedWitness signedWitness, Trade trade) { + if (trade == null) return; + + NodeAddress tradingPeerNodeAddress = trade.getTradingPeerNodeAddress(); + var traderSignedWitnessMessage = new TraderSignedWitnessMessage(UUID.randomUUID().toString(), trade.getId(), + tradingPeerNodeAddress, signedWitness); + + p2PService.sendEncryptedMailboxMessage( + tradingPeerNodeAddress, + trade.getProcessModel().getTradingPeer().getPubKeyRing(), + traderSignedWitnessMessage, + new SendMailboxMessageListener() { + @Override + public void onArrived() { + log.info("SendMailboxMessageListener onArrived tradeId={} at peer {} SignedWitness {}", + trade.getId(), tradingPeerNodeAddress, signedWitness); + } + + @Override + public void onStoredInMailbox() { + log.info("SendMailboxMessageListener onStoredInMailbox tradeId={} at peer {} SignedWitness {}", + trade.getId(), tradingPeerNodeAddress, signedWitness); + } + + @Override + public void onFault(String errorMessage) { + log.error("SendMailboxMessageListener onFault tradeId={} at peer {} SignedWitness {}", + trade.getId(), tradingPeerNodeAddress, signedWitness); + } + } + ); + } } diff --git a/core/src/main/java/bisq/core/filter/Filter.java b/core/src/main/java/bisq/core/filter/Filter.java index 0c2e39b0953..99b7b2526e9 100644 --- a/core/src/main/java/bisq/core/filter/Filter.java +++ b/core/src/main/java/bisq/core/filter/Filter.java @@ -109,6 +109,9 @@ public final class Filter implements ProtectedStoragePayload, ExpirablePayload { @Nullable private final List btcFeeReceiverAddresses; + // added after v1.3.7 + private final boolean disableAutoConf; + public Filter(List bannedOfferIds, List bannedNodeAddress, List bannedPaymentAccounts, @@ -125,7 +128,8 @@ public Filter(List bannedOfferIds, @Nullable List mediators, @Nullable List refundAgents, @Nullable List bannedSignerPubKeys, - @Nullable List btcFeeReceiverAddresses) { + @Nullable List btcFeeReceiverAddresses, + boolean disableAutoConf) { this.bannedOfferIds = bannedOfferIds; this.bannedNodeAddress = bannedNodeAddress; this.bannedPaymentAccounts = bannedPaymentAccounts; @@ -143,6 +147,7 @@ public Filter(List bannedOfferIds, this.refundAgents = refundAgents; this.bannedSignerPubKeys = bannedSignerPubKeys; this.btcFeeReceiverAddresses = btcFeeReceiverAddresses; + this.disableAutoConf = disableAutoConf; } @@ -170,7 +175,8 @@ public Filter(List bannedOfferIds, @Nullable List mediators, @Nullable List refundAgents, @Nullable List bannedSignerPubKeys, - @Nullable List btcFeeReceiverAddresses) { + @Nullable List btcFeeReceiverAddresses, + boolean disableAutoConf) { this(bannedOfferIds, bannedNodeAddress, bannedPaymentAccounts, @@ -187,7 +193,8 @@ public Filter(List bannedOfferIds, mediators, refundAgents, bannedSignerPubKeys, - btcFeeReceiverAddresses); + btcFeeReceiverAddresses, + disableAutoConf); this.signatureAsBase64 = signatureAsBase64; this.ownerPubKeyBytes = ownerPubKeyBytes; this.extraDataMap = ExtraDataMapValidator.getValidatedExtraDataMap(extraDataMap); @@ -209,7 +216,8 @@ public protobuf.StoragePayload toProtoMessage() { .setSignatureAsBase64(signatureAsBase64) .setOwnerPubKeyBytes(ByteString.copyFrom(ownerPubKeyBytes)) .setPreventPublicBtcNetwork(preventPublicBtcNetwork) - .setDisableDao(disableDao); + .setDisableDao(disableDao) + .setDisableAutoConf(disableAutoConf); Optional.ofNullable(bannedCurrencies).ifPresent(builder::addAllBannedCurrencies); Optional.ofNullable(bannedPaymentMethods).ifPresent(builder::addAllBannedPaymentMethods); @@ -252,7 +260,8 @@ public static Filter fromProto(protobuf.Filter proto) { CollectionUtils.isEmpty(proto.getBannedSignerPubKeysList()) ? null : new ArrayList<>(proto.getBannedSignerPubKeysList()), CollectionUtils.isEmpty(proto.getBtcFeeReceiverAddressesList()) ? null : - new ArrayList<>(proto.getBtcFeeReceiverAddressesList())); + new ArrayList<>(proto.getBtcFeeReceiverAddressesList()), + proto.getDisableAutoConf()); } @@ -293,6 +302,7 @@ public String toString() { ",\n refundAgents=" + refundAgents + ",\n bannedSignerPubKeys=" + bannedSignerPubKeys + ",\n btcFeeReceiverAddresses=" + btcFeeReceiverAddresses + + ",\n disableAutoConf=" + disableAutoConf + "\n}"; } } diff --git a/core/src/main/java/bisq/core/trade/Trade.java b/core/src/main/java/bisq/core/trade/Trade.java index 6071e59d504..bbb092662a0 100644 --- a/core/src/main/java/bisq/core/trade/Trade.java +++ b/core/src/main/java/bisq/core/trade/Trade.java @@ -38,6 +38,7 @@ import bisq.core.support.dispute.refund.RefundResultState; import bisq.core.support.dispute.refund.refundagent.RefundAgentManager; import bisq.core.support.messages.ChatMessage; +import bisq.core.trade.autoconf.AssetTxProofResult; import bisq.core.trade.protocol.ProcessModel; import bisq.core.trade.protocol.TradeProtocol; import bisq.core.trade.statistics.ReferralIdService; @@ -158,6 +159,7 @@ public enum State { SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG(Phase.FIAT_SENT), // #################### Phase FIAT_RECEIVED + // note that this state can also be triggered by auto confirmation feature SELLER_CONFIRMED_IN_UI_FIAT_PAYMENT_RECEIPT(Phase.FIAT_RECEIVED), // #################### Phase PAYOUT_PAID @@ -428,6 +430,21 @@ public static protobuf.Trade.TradePeriodState toProtoMessage(Trade.TradePeriodSt private long refreshInterval; private static final long MAX_REFRESH_INTERVAL = 4 * ChronoUnit.HOURS.getDuration().toMillis(); + // Added at v1.3.8 + // We use that for the XMR txKey but want to keep it generic to be flexible for other payment methods or assets. + @Getter + @Setter + private String counterCurrencyExtraData; + + // Added at v1.3.8 + @Nullable + private AssetTxProofResult assetTxProofResult; + + @Getter + // This observable property can be used for UI to show a notification to user of the XMR proof status + transient final private ObjectProperty assetTxProofResultProperty = new SimpleObjectProperty<>(); + + /////////////////////////////////////////////////////////////////////////////////////////// // Constructor, initialization /////////////////////////////////////////////////////////////////////////////////////////// @@ -538,6 +555,9 @@ public Message toProtoMessage() { Optional.ofNullable(mediationResultState).ifPresent(e -> builder.setMediationResultState(MediationResultState.toProtoMessage(mediationResultState))); Optional.ofNullable(refundResultState).ifPresent(e -> builder.setRefundResultState(RefundResultState.toProtoMessage(refundResultState))); Optional.ofNullable(delayedPayoutTxBytes).ifPresent(e -> builder.setDelayedPayoutTxBytes(ByteString.copyFrom(delayedPayoutTxBytes))); + Optional.ofNullable(counterCurrencyExtraData).ifPresent(e -> builder.setCounterCurrencyExtraData(counterCurrencyExtraData)); + Optional.ofNullable(assetTxProofResult).ifPresent(e -> builder.setAssetTxProofResult(assetTxProofResult.toProtoMessage())); + return builder.build(); } @@ -570,6 +590,8 @@ public static Trade fromProto(Trade trade, protobuf.Trade proto, CoreProtoResolv trade.setDelayedPayoutTxBytes(ProtoUtil.byteArrayOrNullFromProto(proto.getDelayedPayoutTxBytes())); trade.setLockTime(proto.getLockTime()); trade.setLastRefreshRequestDate(proto.getLastRefreshRequestDate()); + trade.setCounterCurrencyExtraData(ProtoUtil.stringOrNullFromProto(proto.getCounterCurrencyExtraData())); + trade.setAssetTxProofResult(AssetTxProofResult.fromProto(proto.getAssetTxProofResult(), checkNotNull(trade.getOffer()).getCurrencyCode())); trade.chatMessages.addAll(proto.getChatMessageList().stream() .map(ChatMessage::fromPayloadProto) @@ -731,6 +753,20 @@ public void appendErrorMessage(String msg) { errorMessage = errorMessage == null ? msg : errorMessage + "\n" + msg; } + public boolean allowedRefresh() { + var allowRefresh = new Date().getTime() > lastRefreshRequestDate + getRefreshInterval(); + if (!allowRefresh) { + log.info("Refresh not allowed, last refresh at {}", lastRefreshRequestDate); + } + return allowRefresh; + } + + public void logRefresh() { + var time = new Date().getTime(); + log.debug("Log refresh at {}", time); + lastRefreshRequestDate = time; + } + /////////////////////////////////////////////////////////////////////////////////////////// // Model implementation @@ -839,6 +875,11 @@ public void setErrorMessage(String errorMessage) { errorMessageProperty.set(errorMessage); } + public void setAssetTxProofResult(AssetTxProofResult assetTxProofResult) { + this.assetTxProofResult = assetTxProofResult; + assetTxProofResultProperty.setValue(assetTxProofResult); + } + /////////////////////////////////////////////////////////////////////////////////////////// // Getter @@ -1051,6 +1092,12 @@ public String getErrorMessage() { return errorMessageProperty.get(); } + @Nullable + public AssetTxProofResult getAssetTxProofResult() { + return assetTxProofResult != null ? assetTxProofResult : AssetTxProofResult.fromCurrencyCode(checkNotNull(offer).getCurrencyCode()); + } + + public byte[] getArbitratorBtcPubKey() { // In case we are already in a trade the arbitrator can have been revoked and we still can complete the trade // Only new trades cannot start without any arbitrator @@ -1064,19 +1111,6 @@ public byte[] getArbitratorBtcPubKey() { return arbitratorBtcPubKey; } - public boolean allowedRefresh() { - var allowRefresh = new Date().getTime() > lastRefreshRequestDate + getRefreshInterval(); - if (!allowRefresh) { - log.info("Refresh not allowed, last refresh at {}", lastRefreshRequestDate); - } - return allowRefresh; - } - - public void logRefresh() { - var time = new Date().getTime(); - log.debug("Log refresh at {}", time); - lastRefreshRequestDate = time; - } /////////////////////////////////////////////////////////////////////////////////////////// // Private @@ -1160,6 +1194,8 @@ public String toString() { ",\n takerPaymentAccountId='" + takerPaymentAccountId + '\'' + ",\n errorMessage='" + errorMessage + '\'' + ",\n counterCurrencyTxId='" + counterCurrencyTxId + '\'' + + ",\n counterCurrencyExtraData='" + counterCurrencyExtraData + '\'' + + ",\n assetTxProofResult='" + assetTxProofResult + '\'' + ",\n chatMessages=" + chatMessages + ",\n txFee=" + txFee + ",\n takerFee=" + takerFee + diff --git a/core/src/main/java/bisq/core/trade/TradeManager.java b/core/src/main/java/bisq/core/trade/TradeManager.java index 31bb507e44f..29d608c4413 100644 --- a/core/src/main/java/bisq/core/trade/TradeManager.java +++ b/core/src/main/java/bisq/core/trade/TradeManager.java @@ -38,6 +38,7 @@ import bisq.core.support.dispute.arbitration.arbitrator.ArbitratorManager; import bisq.core.support.dispute.mediation.mediator.MediatorManager; import bisq.core.support.dispute.refund.refundagent.RefundAgentManager; +import bisq.core.trade.autoconf.xmr.XmrTxProofService; import bisq.core.trade.closed.ClosedTradableManager; import bisq.core.trade.failed.FailedTradesManager; import bisq.core.trade.handlers.TradeResultHandler; @@ -65,8 +66,6 @@ import bisq.common.proto.network.NetworkEnvelope; import bisq.common.proto.persistable.PersistedDataHost; import bisq.common.storage.Storage; -import bisq.common.util.Tuple2; -import bisq.common.util.Utilities; import org.bitcoinj.core.AddressFormatException; import org.bitcoinj.core.Coin; @@ -93,7 +92,6 @@ import java.util.ArrayList; import java.util.Date; import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -129,6 +127,8 @@ public class TradeManager implements PersistedDataHost { private final TradeStatisticsManager tradeStatisticsManager; private final ReferralIdService referralIdService; private final AccountAgeWitnessService accountAgeWitnessService; + @Getter + private final XmrTxProofService xmrTxProofService; private final ArbitratorManager arbitratorManager; private final MediatorManager mediatorManager; private final RefundAgentManager refundAgentManager; @@ -170,6 +170,7 @@ public TradeManager(User user, TradeStatisticsManager tradeStatisticsManager, ReferralIdService referralIdService, AccountAgeWitnessService accountAgeWitnessService, + XmrTxProofService xmrTxProofService, ArbitratorManager arbitratorManager, MediatorManager mediatorManager, RefundAgentManager refundAgentManager, @@ -192,6 +193,7 @@ public TradeManager(User user, this.tradeStatisticsManager = tradeStatisticsManager; this.referralIdService = referralIdService; this.accountAgeWitnessService = accountAgeWitnessService; + this.xmrTxProofService = xmrTxProofService; this.arbitratorManager = arbitratorManager; this.mediatorManager = mediatorManager; this.refundAgentManager = refundAgentManager; @@ -322,6 +324,11 @@ private void initPendingTrades() { addTradeToFailedTradesList.add(trade); } } + + if (trade.getState() == Trade.State.SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG && + trade.getCounterCurrencyExtraData() != null) { + xmrTxProofService.maybeStartRequestTxProofProcess(trade, tradableList.getList()); + } } ); diff --git a/core/src/main/java/bisq/core/trade/autoconf/AssetTxProofResult.java b/core/src/main/java/bisq/core/trade/autoconf/AssetTxProofResult.java new file mode 100644 index 00000000000..d8ea275e044 --- /dev/null +++ b/core/src/main/java/bisq/core/trade/autoconf/AssetTxProofResult.java @@ -0,0 +1,78 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.trade.autoconf; + +import bisq.core.trade.autoconf.xmr.XmrTxProofResult; + +import lombok.EqualsAndHashCode; +import lombok.Getter; + +import javax.annotation.Nullable; + +/** + * Base class for AutoConfirm implementations + */ +@EqualsAndHashCode +@Getter +public abstract class AssetTxProofResult { + + @Nullable + public static AssetTxProofResult fromCurrencyCode(String currencyCode) { + //noinspection SwitchStatementWithTooFewBranches + switch (currencyCode) { + case "XMR": + return new XmrTxProofResult(); + default: + return null; + } + } + + private final String stateName; + + protected AssetTxProofResult(String stateName) { + this.stateName = stateName; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + // We use fromProto as kind of factory method to get the specific AutoConfirmResult + @Nullable + public static AssetTxProofResult fromProto(protobuf.AutoConfirmResult proto, String currencyCode) { + //noinspection SwitchStatementWithTooFewBranches + switch (currencyCode) { + case "XMR": + return XmrTxProofResult.fromProto(proto); + default: + return null; + } + } + + public abstract protobuf.AutoConfirmResult toProtoMessage(); + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + abstract public boolean isSuccessState(); + + abstract public String getStatusAsDisplayString(); +} diff --git a/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofHttpClient.java b/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofHttpClient.java new file mode 100644 index 00000000000..b9e2f9ebe34 --- /dev/null +++ b/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofHttpClient.java @@ -0,0 +1,27 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.trade.autoconf.xmr; + +import bisq.network.Socks5ProxyProvider; +import bisq.network.http.HttpClient; + +class XmrTxProofHttpClient extends HttpClient { + XmrTxProofHttpClient(Socks5ProxyProvider socks5ProxyProvider) { + super(socks5ProxyProvider); + } +} diff --git a/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofModel.java b/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofModel.java new file mode 100644 index 00000000000..97162b26714 --- /dev/null +++ b/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofModel.java @@ -0,0 +1,62 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.trade.autoconf.xmr; + +import java.util.Date; + +import lombok.Value; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Value +public class XmrTxProofModel { + // Those are values from a valid tx which are set automatically if DevEnv.isDevMode is enabled + public static final String DEV_ADDRESS = "85q13WDADXE26W6h7cStpPMkn8tWpvWgHbpGWWttFEafGXyjsBTXxxyQms4UErouTY5sdKpYHVjQm6SagiCqytseDkzfgub"; + public static final String DEV_TX_KEY = "f3ce66c9d395e5e460c8802b2c3c1fff04e508434f9738ee35558aac4678c906"; + public static final String DEV_TX_HASH = "5e665addf6d7c6300670e8a89564ed12b5c1a21c336408e2835668f9a6a0d802"; + public static final long DEV_AMOUNT = 8902597360000L; + + private final String txHash; + private final String txKey; + private final String recipientAddress; + private final long amount; + private final Date tradeDate; + private final int confirmsRequired; + private final String serviceAddress; + + XmrTxProofModel( + String txHash, + String txKey, + String recipientAddress, + long amount, + Date tradeDate, + int confirmsRequired, + String serviceAddress) { + this.txHash = txHash; + this.txKey = txKey; + this.recipientAddress = recipientAddress; + this.amount = amount; + this.tradeDate = tradeDate; + this.confirmsRequired = confirmsRequired; + this.serviceAddress = serviceAddress; + } + + String getUID() { + return txHash + "|" + serviceAddress; + } +} diff --git a/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofParser.java b/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofParser.java new file mode 100644 index 00000000000..a89a049ce4b --- /dev/null +++ b/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofParser.java @@ -0,0 +1,159 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.trade.autoconf.xmr; + +import bisq.asset.CryptoNoteUtils; + +import bisq.common.app.DevEnv; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; + +import java.util.concurrent.TimeUnit; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +class XmrTxProofParser { + static XmrTxProofResult parse(XmrTxProofModel xmrTxProofModel, String jsonTxt) { + String txHash = xmrTxProofModel.getTxHash(); + try { + JsonObject json = new Gson().fromJson(jsonTxt, JsonObject.class); + if (json == null) { + return new XmrTxProofResult(XmrTxProofResult.State.API_INVALID, "Empty json"); + } + // there should always be "data" and "status" at the top level + if (json.get("data") == null || !json.get("data").isJsonObject() || json.get("status") == null) { + return new XmrTxProofResult(XmrTxProofResult.State.API_INVALID, "Missing data / status fields"); + } + JsonObject jsonData = json.get("data").getAsJsonObject(); + String jsonStatus = json.get("status").getAsString(); + if (jsonStatus.matches("fail")) { + // the API returns "fail" until the transaction has successfully reached the mempool. + // we return TX_NOT_FOUND which will cause a retry later + return new XmrTxProofResult(XmrTxProofResult.State.TX_NOT_FOUND); + } else if (!jsonStatus.matches("success")) { + return new XmrTxProofResult(XmrTxProofResult.State.API_FAILURE, "Unhandled status value"); + } + + // validate that the address matches + JsonElement jsonAddress = jsonData.get("address"); + if (jsonAddress == null) { + return new XmrTxProofResult(XmrTxProofResult.State.API_INVALID, "Missing address field"); + } else { + String expectedAddressHex = CryptoNoteUtils.convertToRawHex(xmrTxProofModel.getRecipientAddress()); + if (!jsonAddress.getAsString().equalsIgnoreCase(expectedAddressHex)) { + log.warn("address {}, expected: {}", jsonAddress.getAsString(), expectedAddressHex); + return new XmrTxProofResult(XmrTxProofResult.State.ADDRESS_INVALID); + } + } + + // validate that the txHash matches + JsonElement jsonTxHash = jsonData.get("tx_hash"); + if (jsonTxHash == null) { + return new XmrTxProofResult(XmrTxProofResult.State.API_INVALID, "Missing tx_hash field"); + } else { + if (!jsonTxHash.getAsString().equalsIgnoreCase(txHash)) { + log.warn("txHash {}, expected: {}", jsonTxHash.getAsString(), txHash); + return new XmrTxProofResult(XmrTxProofResult.State.TX_HASH_INVALID); + } + } + + // validate that the txKey matches + JsonElement jsonViewkey = jsonData.get("viewkey"); + if (jsonViewkey == null) { + return new XmrTxProofResult(XmrTxProofResult.State.API_INVALID, "Missing viewkey field"); + } else { + if (!jsonViewkey.getAsString().equalsIgnoreCase(xmrTxProofModel.getTxKey())) { + log.warn("viewkey {}, expected: {}", jsonViewkey.getAsString(), xmrTxProofModel.getTxKey()); + return new XmrTxProofResult(XmrTxProofResult.State.TX_KEY_INVALID); + } + } + + // validate that the txDate matches within tolerance + // (except that in dev mode we let this check pass anyway) + JsonElement jsonTimestamp = jsonData.get("tx_timestamp"); + if (jsonTimestamp == null) { + return new XmrTxProofResult(XmrTxProofResult.State.API_INVALID, "Missing tx_timestamp field"); + } else { + long tradeDateSeconds = xmrTxProofModel.getTradeDate().getTime() / 1000; + long difference = tradeDateSeconds - jsonTimestamp.getAsLong(); + // Accept up to 2 hours difference. Some tolerance is needed if users clock is out of sync + if (difference > TimeUnit.HOURS.toSeconds(2) && !DevEnv.isDevMode()) { + log.warn("tx_timestamp {}, tradeDate: {}, difference {}", + jsonTimestamp.getAsLong(), tradeDateSeconds, difference); + return new XmrTxProofResult(XmrTxProofResult.State.TRADE_DATE_NOT_MATCHING); + } + } + + // calculate how many confirms are still needed + int confirmations; + JsonElement jsonConfirmations = jsonData.get("tx_confirmations"); + if (jsonConfirmations == null) { + return new XmrTxProofResult(XmrTxProofResult.State.API_INVALID, "Missing tx_confirmations field"); + } else { + confirmations = jsonConfirmations.getAsInt(); + log.info("Confirmations: {}, xmr txHash: {}", confirmations, txHash); + } + + // iterate through the list of outputs, one of them has to match the amount we are trying to verify. + // check that the "match" field is true as well as validating the amount value + // (except that in dev mode we allow any amount as valid) + JsonArray jsonOutputs = jsonData.get("outputs").getAsJsonArray(); + boolean anyMatchFound = false; + boolean amountMatches = false; + for (int i = 0; i < jsonOutputs.size(); i++) { + JsonObject out = jsonOutputs.get(i).getAsJsonObject(); + if (out.get("match").getAsBoolean()) { + anyMatchFound = true; + long jsonAmount = out.get("amount").getAsLong(); + amountMatches = jsonAmount == xmrTxProofModel.getAmount(); + if (amountMatches) { + break; + } + } + } + + // None of the outputs had a match entry + if (!anyMatchFound) { + return new XmrTxProofResult(XmrTxProofResult.State.NO_MATCH_FOUND); + } + + // None of the outputs had a match entry + if (!amountMatches) { + return new XmrTxProofResult(XmrTxProofResult.State.AMOUNT_NOT_MATCHING); + } + + int confirmsRequired = xmrTxProofModel.getConfirmsRequired(); + if (confirmations < confirmsRequired) { + XmrTxProofResult xmrTxProofResult = new XmrTxProofResult(XmrTxProofResult.State.PENDING_CONFIRMATIONS); + xmrTxProofResult.setNumConfirmations(confirmations); + xmrTxProofResult.setRequiredConfirmations(confirmsRequired); + return xmrTxProofResult; + } else { + return new XmrTxProofResult(XmrTxProofResult.State.SINGLE_SERVICE_SUCCEEDED); + } + + } catch (JsonParseException | NullPointerException e) { + return new XmrTxProofResult(XmrTxProofResult.State.API_INVALID, e.toString()); + } + } +} diff --git a/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofRequest.java b/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofRequest.java new file mode 100644 index 00000000000..0cf8d809b2c --- /dev/null +++ b/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofRequest.java @@ -0,0 +1,139 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.trade.autoconf.xmr; + +import bisq.network.Socks5ProxyProvider; + +import bisq.common.UserThread; +import bisq.common.app.Version; +import bisq.common.handlers.FaultHandler; +import bisq.common.util.Utilities; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonParser; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; + +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import lombok.extern.slf4j.Slf4j; + +import org.jetbrains.annotations.NotNull; + +/** + * Requests for the XMR tx proof for a particular trade and one service. + * Repeats requests if tx is not confirmed yet. + */ +@Slf4j +class XmrTxProofRequest { + // these settings are not likely to change and therefore not put into Config + private static final long REPEAT_REQUEST_PERIOD = TimeUnit.SECONDS.toMillis(90); + private static final long MAX_REQUEST_PERIOD = TimeUnit.HOURS.toMillis(12); + + private final ListeningExecutorService executorService = Utilities.getListeningExecutorService( + "XmrTransferProofRequester", 3, 5, 10 * 60); + private final XmrTxProofHttpClient httpClient; + private final XmrTxProofModel xmrTxProofModel; + private final Consumer resultHandler; + private final FaultHandler faultHandler; + + private boolean terminated; + private final long firstRequest; + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Constructor + /////////////////////////////////////////////////////////////////////////////////////////// + + XmrTxProofRequest(Socks5ProxyProvider socks5ProxyProvider, + XmrTxProofModel xmrTxProofModel, + Consumer resultHandler, + FaultHandler faultHandler) { + this.httpClient = new XmrTxProofHttpClient(socks5ProxyProvider); + this.httpClient.setBaseUrl("http://" + xmrTxProofModel.getServiceAddress()); + if (xmrTxProofModel.getServiceAddress().matches("^192.*|^localhost.*")) { + log.info("Ignoring Socks5 proxy for local net address: {}", xmrTxProofModel.getServiceAddress()); + this.httpClient.setIgnoreSocks5Proxy(true); + } + this.xmrTxProofModel = xmrTxProofModel; + this.resultHandler = resultHandler; + this.faultHandler = faultHandler; + this.terminated = false; + firstRequest = System.currentTimeMillis(); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + // used by the service to abort further automatic retries + void stop() { + terminated = true; + } + + public void request() { + if (terminated) { + // the XmrTransferProofService has asked us to terminate i.e. not make any further api calls + // this scenario may happen if a re-request is scheduled from the callback below + log.info("Request() aborted, this object has been terminated. Service: {}", httpClient.getBaseUrl()); + return; + } + ListenableFuture future = executorService.submit(() -> { + Thread.currentThread().setName("XmrTransferProofRequest-" + xmrTxProofModel.getUID()); + String param = "/api/outputs?txhash=" + xmrTxProofModel.getTxHash() + + "&address=" + xmrTxProofModel.getRecipientAddress() + + "&viewkey=" + xmrTxProofModel.getTxKey() + + "&txprove=1"; + log.info("Requesting from {} with param {}", httpClient.getBaseUrl(), param); + String json = httpClient.requestWithGET(param, "User-Agent", "bisq/" + Version.VERSION); + String prettyJson = new GsonBuilder().setPrettyPrinting().create().toJson(new JsonParser().parse(json)); + log.info("Response json\n{}", prettyJson); + XmrTxProofResult xmrTxProofResult = XmrTxProofParser.parse(xmrTxProofModel, json); + log.info("xmrTxProofResult {}", xmrTxProofResult); + return xmrTxProofResult; + }); + + Futures.addCallback(future, new FutureCallback<>() { + public void onSuccess(XmrTxProofResult result) { + if (terminated) { + log.info("API terminated from higher level: {}", httpClient.getBaseUrl()); + return; + } + if (System.currentTimeMillis() - firstRequest > MAX_REQUEST_PERIOD) { + log.warn("We have tried requesting from {} for too long, giving up.", httpClient.getBaseUrl()); + return; + } + if (result.isPendingState()) { + UserThread.runAfter(() -> request(), REPEAT_REQUEST_PERIOD, TimeUnit.MILLISECONDS); + } + UserThread.execute(() -> resultHandler.accept(result)); + } + + public void onFailure(@NotNull Throwable throwable) { + String errorMessage = "Request to " + httpClient.getBaseUrl() + " failed"; + faultHandler.handleFault(errorMessage, throwable); + UserThread.execute(() -> resultHandler.accept( + new XmrTxProofResult(XmrTxProofResult.State.CONNECTION_FAIL, errorMessage))); + } + }); + } +} diff --git a/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofRequestService.java b/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofRequestService.java new file mode 100644 index 00000000000..91c562370c2 --- /dev/null +++ b/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofRequestService.java @@ -0,0 +1,86 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.trade.autoconf.xmr; + +import bisq.network.Socks5ProxyProvider; + +import bisq.common.handlers.FaultHandler; + +import javax.inject.Inject; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Consumer; + +import lombok.extern.slf4j.Slf4j; + +/** + * Handles the XMR tx proof requests for multiple trades and multiple services. + */ +@Slf4j +class XmrTxProofRequestService { + private final Map map = new HashMap<>(); + private final Socks5ProxyProvider socks5ProxyProvider; + + @Inject + private XmrTxProofRequestService(Socks5ProxyProvider provider) { + socks5ProxyProvider = provider; + } + + void requestProof(XmrTxProofModel xmrTxProofModel, + Consumer resultHandler, + FaultHandler faultHandler) { + String uid = xmrTxProofModel.getUID(); + if (map.containsKey(uid)) { + log.warn("We started a proof request for uid {} already", uid); + return; + } + log.info("requesting tx proof with uid {}", uid); + + XmrTxProofRequest requester = new XmrTxProofRequest( + socks5ProxyProvider, + xmrTxProofModel, + result -> { + if (result.getState() == XmrTxProofResult.State.SINGLE_SERVICE_SUCCEEDED) { + cleanup(uid); + } + resultHandler.accept(result); + }, + (errorMsg, throwable) -> { + cleanup(uid); + faultHandler.handleFault(errorMsg, throwable); + }); + map.put(uid, requester); + + requester.request(); + } + + void terminateRequest(XmrTxProofModel xmrTxProofModel) { + String uid = xmrTxProofModel.getUID(); + XmrTxProofRequest requester = map.getOrDefault(uid, null); + if (requester != null) { + log.info("Terminating API request for request with uid {}", uid); + requester.stop(); + cleanup(uid); + } + } + + private void cleanup(String key) { + map.remove(key); + } +} diff --git a/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofResult.java b/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofResult.java new file mode 100644 index 00000000000..f0776a2a1ab --- /dev/null +++ b/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofResult.java @@ -0,0 +1,207 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.trade.autoconf.xmr; + +import bisq.core.locale.Res; +import bisq.core.trade.autoconf.AssetTxProofResult; + +import bisq.common.proto.ProtoUtil; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; + +@Slf4j +@EqualsAndHashCode(callSuper = true) +@ToString +public class XmrTxProofResult extends AssetTxProofResult { + public enum State { + UNDEFINED, + + // Feature disable cases + FEATURE_DISABLED, + TRADE_LIMIT_EXCEEDED, + + // Pending state + REQUEST_STARTED, + TX_NOT_FOUND, + PENDING_SERVICE_RESULTS, + PENDING_CONFIRMATIONS, + + SINGLE_SERVICE_SUCCEEDED, // Individual service has delivered proof ok + + // Success state + ALL_SERVICES_SUCCEEDED, // All services delivered PROOF_OK + + // Error state + CONNECTION_FAIL, + API_FAILURE, + API_INVALID, + TX_KEY_REUSED, + TX_HASH_INVALID, + TX_KEY_INVALID, + ADDRESS_INVALID, + NO_MATCH_FOUND, + AMOUNT_NOT_MATCHING, + TRADE_DATE_NOT_MATCHING + } + + @Getter + private transient final State state; + @Setter + private transient int numConfirmations; + @Setter + private transient int requiredConfirmations; + @Nullable + private transient String errorMsg; + @Setter + private transient int pendingServiceResults; + @Setter + private transient int requiredServiceResults; + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Constructors + /////////////////////////////////////////////////////////////////////////////////////////// + + public XmrTxProofResult() { + this(State.UNDEFINED); + } + + XmrTxProofResult(State state) { + super(state.name()); + + this.state = state; + } + + XmrTxProofResult(State state, String errorMsg) { + this(state); + + this.errorMsg = errorMsg; + log.error(errorMsg); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public protobuf.AutoConfirmResult toProtoMessage() { + return protobuf.AutoConfirmResult.newBuilder().setStateName(state.name()).build(); + } + + public static XmrTxProofResult fromProto(protobuf.AutoConfirmResult proto) { + XmrTxProofResult.State state = ProtoUtil.enumFromProto(XmrTxProofResult.State.class, proto.getStateName()); + return state != null ? new XmrTxProofResult(state) : new XmrTxProofResult(State.UNDEFINED); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public String getStatusAsDisplayString() { + String key = "portfolio.pending.autoConf.state." + state; + switch (state) { + // Invalid protobuf data + case UNDEFINED: + return state.toString(); + + // Feature disable cases + case FEATURE_DISABLED: + case TRADE_LIMIT_EXCEEDED: + + // Pending state + case REQUEST_STARTED: + case TX_NOT_FOUND: // Tx still not confirmed and not in mempool + return Res.get(key); + case PENDING_SERVICE_RESULTS: + return Res.get(key, pendingServiceResults, requiredServiceResults); + case PENDING_CONFIRMATIONS: + return Res.get(key, numConfirmations, requiredConfirmations); + case SINGLE_SERVICE_SUCCEEDED: + + // Success state + case ALL_SERVICES_SUCCEEDED: + return Res.get(key); + + // Error state + case CONNECTION_FAIL: + case API_FAILURE: + case API_INVALID: + case TX_KEY_REUSED: + case TX_HASH_INVALID: + case TX_KEY_INVALID: + case ADDRESS_INVALID: + case NO_MATCH_FOUND: + case AMOUNT_NOT_MATCHING: + case TRADE_DATE_NOT_MATCHING: + return getErrorMsg(); + + default: + return state.toString(); + } + } + + @Override + public boolean isSuccessState() { + return (state == State.ALL_SERVICES_SUCCEEDED); + } + + boolean isErrorState() { + switch (state) { + case CONNECTION_FAIL: + case API_FAILURE: + case API_INVALID: + case TX_KEY_REUSED: + case TX_HASH_INVALID: + case TX_KEY_INVALID: + case ADDRESS_INVALID: + case NO_MATCH_FOUND: + case AMOUNT_NOT_MATCHING: + case TRADE_DATE_NOT_MATCHING: + return true; + + default: + return false; + } + } + + boolean isPendingState() { + switch (state) { + case REQUEST_STARTED: + case TX_NOT_FOUND: + case PENDING_SERVICE_RESULTS: + case PENDING_CONFIRMATIONS: + return true; + + default: + return false; + } + } + + private String getErrorMsg() { + return errorMsg != null ? errorMsg : state.name(); + } +} diff --git a/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofService.java b/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofService.java new file mode 100644 index 00000000000..bbd451ef9b7 --- /dev/null +++ b/core/src/main/java/bisq/core/trade/autoconf/xmr/XmrTxProofService.java @@ -0,0 +1,378 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.trade.autoconf.xmr; + +import bisq.core.account.witness.AccountAgeWitnessService; +import bisq.core.btc.setup.WalletsSetup; +import bisq.core.filter.FilterManager; +import bisq.core.monetary.Volume; +import bisq.core.payment.payload.AssetsAccountPayload; +import bisq.core.payment.payload.PaymentAccountPayload; +import bisq.core.trade.SellerTrade; +import bisq.core.trade.Trade; +import bisq.core.trade.closed.ClosedTradableManager; +import bisq.core.trade.failed.FailedTradesManager; +import bisq.core.user.Preferences; + +import bisq.network.p2p.P2PService; + +import bisq.common.app.DevEnv; + +import org.bitcoinj.core.Coin; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import javafx.beans.value.ChangeListener; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import static com.google.common.base.Preconditions.checkNotNull; + +/** + * Entry point for clients to request tx proof and trigger auto-confirm if all conditions + * are met. + */ +@Slf4j +@Singleton +public class XmrTxProofService { + private final FilterManager filterManager; + private final Preferences preferences; + private final XmrTxProofRequestService xmrTxProofRequestService; + private final AccountAgeWitnessService accountAgeWitnessService; + private final ClosedTradableManager closedTradableManager; + private final FailedTradesManager failedTradesManager; + private final P2PService p2PService; + private final WalletsSetup walletsSetup; + private final Map requestInfoByTxIdMap = new HashMap<>(); + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Constructor + /////////////////////////////////////////////////////////////////////////////////////////// + + @Inject + private XmrTxProofService(FilterManager filterManager, + Preferences preferences, + XmrTxProofRequestService xmrTxProofRequestService, + ClosedTradableManager closedTradableManager, + FailedTradesManager failedTradesManager, + P2PService p2PService, + WalletsSetup walletsSetup, + AccountAgeWitnessService accountAgeWitnessService) { + this.filterManager = filterManager; + this.preferences = preferences; + this.xmrTxProofRequestService = xmrTxProofRequestService; + this.closedTradableManager = closedTradableManager; + this.failedTradesManager = failedTradesManager; + this.p2PService = p2PService; + this.walletsSetup = walletsSetup; + this.accountAgeWitnessService = accountAgeWitnessService; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + public void maybeStartRequestTxProofProcess(Trade trade, List activeTrades) { + if (!dataValid(trade)) { + return; + } + + if (!isXmrBuyer(trade)) { + return; + } + + if (!featureEnabled(trade)) { + return; + } + + if (!networkAndWalletReady()) { + return; + } + + if (isTradeAmountAboveLimit(trade)) { + return; + } + + if (wasTxKeyReUsed(trade, activeTrades)) { + return; + } + + Coin tradeAmount = trade.getTradeAmount(); + Volume volume = checkNotNull(trade.getOffer()).getVolumeByAmount(tradeAmount); + // XMR satoshis have 12 decimal places vs. bitcoin's 8 + long amountXmr = volume != null ? volume.getValue() * 10000L : 0L; + + PaymentAccountPayload sellersPaymentAccountPayload = checkNotNull(trade.getContract()).getSellerPaymentAccountPayload(); + String recipientAddress = ((AssetsAccountPayload) sellersPaymentAccountPayload).getAddress(); + if (DevEnv.isDevMode()) { + // For dev testing we need to add the matching address to the dev tx key and dev view key + recipientAddress = XmrTxProofModel.DEV_ADDRESS; + amountXmr = XmrTxProofModel.DEV_AMOUNT; + } + int confirmsRequired = preferences.getAutoConfirmSettings().requiredConfirmations; + String txHash = trade.getCounterCurrencyTxId(); + String txKey = trade.getCounterCurrencyExtraData(); + List serviceAddresses = preferences.getAutoConfirmSettings().serviceAddresses; + + + ChangeListener listener = (observable, oldValue, newValue) -> { + if (trade.isPayoutPublished()) { + log.warn("Trade payout already published, shutting down all open API requests for trade {}", + trade.getShortId()); + cleanup(trade); + } + }; + trade.stateProperty().addListener(listener); + requestInfoByTxIdMap.put(trade.getId(), new RequestInfo(serviceAddresses.size(), listener)); // need result from each service address + + trade.setAssetTxProofResult(new XmrTxProofResult(XmrTxProofResult.State.REQUEST_STARTED)); + for (String serviceAddress : serviceAddresses) { + XmrTxProofModel xmrTxProofModel = new XmrTxProofModel( + txHash, + txKey, + recipientAddress, + amountXmr, + trade.getDate(), + confirmsRequired, + serviceAddress); + xmrTxProofRequestService.requestProof(xmrTxProofModel, + result -> { + if (!handleProofResult(result, trade)) { + xmrTxProofRequestService.terminateRequest(xmrTxProofModel); + } + }, + (errorMsg, throwable) -> { + log.warn(errorMsg); + } + ); + } + } + + private boolean handleProofResult(XmrTxProofResult result, Trade trade) { + // here we count the Trade's API results from all + // different serviceAddress and figure out when all have finished + if (!requestInfoByTxIdMap.containsKey(trade.getId())) { + // We have cleaned up our map in the meantime + return false; + } + + RequestInfo requestInfo = requestInfoByTxIdMap.get(trade.getId()); + + if (requestInfo.isInvalid()) { + log.info("Ignoring stale API result [{}], tradeId {} due to previous error", + result.getState(), trade.getShortId()); + return false; // terminate any pending responses + } + + if (trade.isPayoutPublished()) { + log.warn("Trade payout already published, shutting down all open API requests for trade {}", + trade.getShortId()); + cleanup(trade); + } + + if (result.isErrorState()) { + log.warn("Tx Proof Failure {}, shutting down all open API requests for this trade {}", + result.getState(), trade.getShortId()); + trade.setAssetTxProofResult(result); // this updates the GUI with the status.. + requestInfo.invalidate(); + return false; + } + + if (result.isPendingState()) { + log.info("Auto confirm received a {} message for tradeId {}, retry will happen automatically", + result.getState(), trade.getShortId()); + trade.setAssetTxProofResult(result); // this updates the GUI with the status.. + // Repeating the requests is handled in XmrTransferProofRequester + return true; + } + + + if (result.getState() == XmrTxProofResult.State.SINGLE_SERVICE_SUCCEEDED) { + int resultsCountdown = requestInfo.decrementAndGet(); + log.info("Received a {} result, remaining proofs needed: {}, tradeId {}", + result.getState(), resultsCountdown, trade.getShortId()); + if (requestInfo.hasPendingResults()) { + XmrTxProofResult assetTxProofResult = new XmrTxProofResult(XmrTxProofResult.State.PENDING_SERVICE_RESULTS); + assetTxProofResult.setPendingServiceResults(requestInfo.getPendingResults()); + assetTxProofResult.setRequiredServiceResults(requestInfo.getNumServices()); + trade.setAssetTxProofResult(assetTxProofResult); + return true; // not all APIs have confirmed yet + } + + // All our services have returned a PROOF_OK result so we have succeeded. + cleanup(trade); + trade.setAssetTxProofResult(new XmrTxProofResult(XmrTxProofResult.State.ALL_SERVICES_SUCCEEDED)); + log.info("Auto confirm was successful, transitioning trade {} to next step...", trade.getShortId()); + if (!trade.isPayoutPublished()) { + // Trade state update is handled in the trade protocol method triggered by the onFiatPaymentReceived call + // This triggers the completion of the trade with signing and publishing the payout tx + ((SellerTrade) trade).onFiatPaymentReceived(() -> { + }, + errorMessage -> { + }); + } + accountAgeWitnessService.maybeSignWitness(trade); + + return true; + } else { + //TODO check if that can happen + log.error("Unexpected state {}", result.getState()); + return false; + } + } + + private boolean dataValid(Trade trade) { + String txKey = trade.getCounterCurrencyExtraData(); + String txHash = trade.getCounterCurrencyTxId(); + + if (txKey == null || txKey.isEmpty()) { + return false; + } + + if (txHash == null || txHash.isEmpty()) { + return false; + } + + if (!txHash.matches("[a-fA-F0-9]{64}") || !txKey.matches("[a-fA-F0-9]{64}")) { + log.error("Validation failed: txHash {} txKey {}", txHash, txKey); + return false; + } + + return true; + } + + private boolean isXmrBuyer(Trade trade) { + if (!checkNotNull(trade.getOffer()).getCurrencyCode().equals("XMR")) { + return false; + } + + if (!(trade instanceof SellerTrade)) { + return false; + } + + return checkNotNull(trade.getContract()).getSellerPaymentAccountPayload() instanceof AssetsAccountPayload; + } + + private boolean networkAndWalletReady() { + return p2PService.isBootstrapped() && + walletsSetup.isDownloadComplete() && + walletsSetup.hasSufficientPeersForBroadcast(); + } + + private boolean featureEnabled(Trade trade) { + boolean isEnabled = preferences.getAutoConfirmSettings().enabled && !isAutoConfDisabledByFilter(); + if (!isEnabled) { + trade.setAssetTxProofResult(new XmrTxProofResult(XmrTxProofResult.State.FEATURE_DISABLED)); + } + return isEnabled; + } + + private boolean isAutoConfDisabledByFilter() { + return filterManager.getFilter() != null && + filterManager.getFilter().isDisableAutoConf(); + } + + private boolean isTradeAmountAboveLimit(Trade trade) { + Coin tradeAmount = trade.getTradeAmount(); + Coin tradeLimit = Coin.valueOf(preferences.getAutoConfirmSettings().tradeLimit); + if (tradeAmount != null && tradeAmount.isGreaterThan(tradeLimit)) { + log.warn("Trade amount {} is higher than settings limit {}, will not attempt auto-confirm", + tradeAmount.toFriendlyString(), tradeLimit.toFriendlyString()); + + trade.setAssetTxProofResult(new XmrTxProofResult(XmrTxProofResult.State.TRADE_LIMIT_EXCEEDED)); + return true; + } + return false; + } + + private void cleanup(Trade trade) { + trade.stateProperty().removeListener(requestInfoByTxIdMap.get(trade.getId()).getListener()); + requestInfoByTxIdMap.remove(trade.getId()); + } + + private boolean wasTxKeyReUsed(Trade trade, List activeTrades) { + if (DevEnv.isDevMode()) { + return false; + } + + // We need to prevent that a user tries to scam by reusing a txKey and txHash of a previous XMR trade with + // the same user (same address) and same amount. We check only for the txKey as a same txHash but different + // txKey is not possible to get a valid result at proof. + Stream failedAndOpenTrades = Stream.concat(activeTrades.stream(), failedTradesManager.getFailedTrades().stream()); + Stream closedTrades = closedTradableManager.getClosedTradables().stream() + .filter(tradable -> tradable instanceof Trade) + .map(tradable -> (Trade) tradable); + Stream allTrades = Stream.concat(failedAndOpenTrades, closedTrades); + String txKey = trade.getCounterCurrencyExtraData(); + return allTrades + .filter(t -> !t.getId().equals(trade.getId())) // ignore same trade + .anyMatch(t -> { + String extra = t.getCounterCurrencyExtraData(); + if (extra == null) { + return false; + } + + boolean alreadyUsed = extra.equals(txKey); + if (alreadyUsed) { + String message = "Peer used the XMR tx key already at another trade with trade ID " + + t.getId() + ". This might be a scam attempt."; + trade.setAssetTxProofResult(new XmrTxProofResult(XmrTxProofResult.State.TX_KEY_REUSED, message)); + } + return alreadyUsed; + }); + } + + @Getter + private static class RequestInfo { + private final int numServices; + private int pendingResults; + private ChangeListener listener; + + RequestInfo(int numServices, ChangeListener listener) { + this.numServices = numServices; + this.pendingResults = numServices; + this.listener = listener; + } + + int decrementAndGet() { + pendingResults--; + return pendingResults; + } + + void invalidate() { + pendingResults = -1; + } + + boolean isInvalid() { + return pendingResults < 0; + } + + boolean hasPendingResults() { + return pendingResults > 0; + } + } +} diff --git a/core/src/main/java/bisq/core/trade/messages/CounterCurrencyTransferStartedMessage.java b/core/src/main/java/bisq/core/trade/messages/CounterCurrencyTransferStartedMessage.java index 963947b48e7..416c7d74c22 100644 --- a/core/src/main/java/bisq/core/trade/messages/CounterCurrencyTransferStartedMessage.java +++ b/core/src/main/java/bisq/core/trade/messages/CounterCurrencyTransferStartedMessage.java @@ -21,6 +21,7 @@ import bisq.network.p2p.NodeAddress; import bisq.common.app.Version; +import bisq.common.proto.ProtoUtil; import bisq.common.util.Utilities; import com.google.protobuf.ByteString; @@ -41,17 +42,24 @@ public final class CounterCurrencyTransferStartedMessage extends TradeMessage im @Nullable private final String counterCurrencyTxId; + // Added after v1.3.7 + // We use that for the XMR txKey but want to keep it generic to be flexible for data of other payment methods or assets. + @Nullable + private String counterCurrencyExtraData; + public CounterCurrencyTransferStartedMessage(String tradeId, String buyerPayoutAddress, NodeAddress senderNodeAddress, byte[] buyerSignature, @Nullable String counterCurrencyTxId, + @Nullable String counterCurrencyExtraData, String uid) { this(tradeId, buyerPayoutAddress, senderNodeAddress, buyerSignature, counterCurrencyTxId, + counterCurrencyExtraData, uid, Version.getP2PMessageVersion()); } @@ -66,6 +74,7 @@ private CounterCurrencyTransferStartedMessage(String tradeId, NodeAddress senderNodeAddress, byte[] buyerSignature, @Nullable String counterCurrencyTxId, + @Nullable String counterCurrencyExtraData, String uid, int messageVersion) { super(messageVersion, tradeId, uid); @@ -73,6 +82,7 @@ private CounterCurrencyTransferStartedMessage(String tradeId, this.senderNodeAddress = senderNodeAddress; this.buyerSignature = buyerSignature; this.counterCurrencyTxId = counterCurrencyTxId; + this.counterCurrencyExtraData = counterCurrencyExtraData; } @Override @@ -85,16 +95,19 @@ public protobuf.NetworkEnvelope toProtoNetworkEnvelope() { .setUid(uid); Optional.ofNullable(counterCurrencyTxId).ifPresent(e -> builder.setCounterCurrencyTxId(counterCurrencyTxId)); + Optional.ofNullable(counterCurrencyExtraData).ifPresent(e -> builder.setCounterCurrencyExtraData(counterCurrencyExtraData)); return getNetworkEnvelopeBuilder().setCounterCurrencyTransferStartedMessage(builder).build(); } - public static CounterCurrencyTransferStartedMessage fromProto(protobuf.CounterCurrencyTransferStartedMessage proto, int messageVersion) { + public static CounterCurrencyTransferStartedMessage fromProto(protobuf.CounterCurrencyTransferStartedMessage proto, + int messageVersion) { return new CounterCurrencyTransferStartedMessage(proto.getTradeId(), proto.getBuyerPayoutAddress(), NodeAddress.fromProto(proto.getSenderNodeAddress()), proto.getBuyerSignature().toByteArray(), - proto.getCounterCurrencyTxId().isEmpty() ? null : proto.getCounterCurrencyTxId(), + ProtoUtil.stringOrNullFromProto(proto.getCounterCurrencyTxId()), + ProtoUtil.stringOrNullFromProto(proto.getCounterCurrencyExtraData()), proto.getUid(), messageVersion); } @@ -106,6 +119,7 @@ public String toString() { "\n buyerPayoutAddress='" + buyerPayoutAddress + '\'' + ",\n senderNodeAddress=" + senderNodeAddress + ",\n counterCurrencyTxId=" + counterCurrencyTxId + + ",\n counterCurrencyExtraData=" + counterCurrencyExtraData + ",\n uid='" + uid + '\'' + ",\n buyerSignature=" + Utilities.bytesAsHexString(buyerSignature) + "\n} " + super.toString(); diff --git a/core/src/main/java/bisq/core/trade/protocol/tasks/buyer/BuyerSendCounterCurrencyTransferStartedMessage.java b/core/src/main/java/bisq/core/trade/protocol/tasks/buyer/BuyerSendCounterCurrencyTransferStartedMessage.java index 0dbf2ce3cdd..40cd15750a8 100644 --- a/core/src/main/java/bisq/core/trade/protocol/tasks/buyer/BuyerSendCounterCurrencyTransferStartedMessage.java +++ b/core/src/main/java/bisq/core/trade/protocol/tasks/buyer/BuyerSendCounterCurrencyTransferStartedMessage.java @@ -54,6 +54,7 @@ protected void run() { processModel.getMyNodeAddress(), processModel.getPayoutTxSignature(), trade.getCounterCurrencyTxId(), + trade.getCounterCurrencyExtraData(), UUID.randomUUID().toString() ); NodeAddress peersNodeAddress = trade.getTradingPeerNodeAddress(); diff --git a/core/src/main/java/bisq/core/trade/protocol/tasks/seller/SellerProcessCounterCurrencyTransferStartedMessage.java b/core/src/main/java/bisq/core/trade/protocol/tasks/seller/SellerProcessCounterCurrencyTransferStartedMessage.java index 935d780c76e..db3a27ea3ca 100644 --- a/core/src/main/java/bisq/core/trade/protocol/tasks/seller/SellerProcessCounterCurrencyTransferStartedMessage.java +++ b/core/src/main/java/bisq/core/trade/protocol/tasks/seller/SellerProcessCounterCurrencyTransferStartedMessage.java @@ -49,7 +49,18 @@ protected void run() { // update to the latest peer address of our peer if the message is correct trade.setTradingPeerNodeAddress(processModel.getTempTradingPeerNodeAddress()); - trade.setCounterCurrencyTxId(message.getCounterCurrencyTxId()); + + String counterCurrencyTxId = message.getCounterCurrencyTxId(); + if (counterCurrencyTxId != null && counterCurrencyTxId.length() < 100) { + trade.setCounterCurrencyTxId(counterCurrencyTxId); + } + + String counterCurrencyExtraData = message.getCounterCurrencyExtraData(); + if (counterCurrencyExtraData != null && counterCurrencyExtraData.length() < 100) { + trade.setCounterCurrencyExtraData(counterCurrencyExtraData); + processModel.getTradeManager().getXmrTxProofService().maybeStartRequestTxProofProcess( + trade, processModel.getTradeManager().getTradableList()); + } processModel.removeMailboxMessageAfterProcessing(trade); trade.setState(Trade.State.SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG); diff --git a/core/src/main/java/bisq/core/user/AutoConfirmSettings.java b/core/src/main/java/bisq/core/user/AutoConfirmSettings.java new file mode 100644 index 00000000000..e83acb7c6d3 --- /dev/null +++ b/core/src/main/java/bisq/core/user/AutoConfirmSettings.java @@ -0,0 +1,72 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.core.user; + +import bisq.common.proto.persistable.PersistablePayload; + +import com.google.protobuf.Message; + +import java.util.ArrayList; +import java.util.List; + +public final class AutoConfirmSettings implements PersistablePayload { + public final boolean enabled; + public final int requiredConfirmations; + public final long tradeLimit; + public final List serviceAddresses; + public final String currencyCode; + + public AutoConfirmSettings(boolean enabled, + int requiredConfirmations, + long tradeLimit, + List serviceAddresses, + String currencyCode) { + this.enabled = enabled; + this.requiredConfirmations = requiredConfirmations; + this.tradeLimit = tradeLimit; + this.serviceAddresses = serviceAddresses; + this.currencyCode = currencyCode; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // PROTO BUFFER + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public Message toProtoMessage() { + return protobuf.AutoConfirmSettings.newBuilder() + .setEnabled(enabled) + .setRequiredConfirmations(requiredConfirmations) + .setTradeLimit(tradeLimit) + .addAllServiceAddresses(serviceAddresses) + .setCurrencyCode(currencyCode) + .build(); + } + + public static AutoConfirmSettings fromProto(protobuf.AutoConfirmSettings proto) { + List serviceAddresses = proto.getServiceAddressesList().isEmpty() ? + new ArrayList<>() : new ArrayList<>(proto.getServiceAddressesList()); + return new AutoConfirmSettings( + proto.getEnabled(), + proto.getRequiredConfirmations(), + proto.getTradeLimit(), + serviceAddresses, + proto.getCurrencyCode()); + } +} diff --git a/core/src/main/java/bisq/core/user/Preferences.java b/core/src/main/java/bisq/core/user/Preferences.java index bb2a736df21..1493e779c6c 100644 --- a/core/src/main/java/bisq/core/user/Preferences.java +++ b/core/src/main/java/bisq/core/user/Preferences.java @@ -17,8 +17,8 @@ package bisq.core.user; -import bisq.core.btc.nodes.LocalBitcoinNode; import bisq.core.btc.nodes.BtcNodes; +import bisq.core.btc.nodes.LocalBitcoinNode; import bisq.core.btc.wallet.Restrictions; import bisq.core.locale.Country; import bisq.core.locale.CountryUtil; @@ -33,12 +33,15 @@ import bisq.network.p2p.network.BridgeAddressProvider; +import bisq.common.app.DevEnv; import bisq.common.config.BaseCurrencyNetwork; import bisq.common.config.Config; import bisq.common.proto.persistable.PersistedDataHost; import bisq.common.storage.Storage; import bisq.common.util.Utilities; +import org.bitcoinj.core.Coin; + import javax.inject.Inject; import javax.inject.Named; import javax.inject.Singleton; @@ -122,6 +125,17 @@ public final class Preferences implements PersistedDataHost, BridgeAddressProvid new BlockChainExplorer("bsq.bisq.cc (@m52go)", "https://bsq.bisq.cc/tx.html?tx=", "https://bsq.bisq.cc/Address.html?addr=") )); + // list of XMR proof providers : this list will be used if no preference has been set + public static final List getDefaultXmrProofProviders() { + if (DevEnv.isDevMode()) { + return new ArrayList<>(Arrays.asList("78.47.61.90:8081")); + } else { + // TODO we need at least 2 for release + return new ArrayList<>(Arrays.asList( + "monero3bec7m26vx6si6qo7q7imlaoz45ot5m2b5z2ppgoooo6jx2rqd.onion")); + } + } + public static final boolean USE_SYMMETRIC_SECURITY_DEPOSIT = true; @@ -396,6 +410,35 @@ public void setTacAcceptedV120(boolean tacAccepted) { persist(); } + // AutoConfirmSettings is currently only used for XMR. Although it could + // potentially in the future be used for others too. In the interest of flexibility + // we store it as a list in the proto definition, but in practical terms the + // application is not coded to handle more than one entry. For now this API + // to get/set AutoConfirmSettings is the gatekeeper. If in the future we adapt + // the application to manage more than one altcoin AutoConfirmSettings then + // this API will need to incorporate lookup by coin. + public AutoConfirmSettings getAutoConfirmSettings() { + if (prefPayload.getAutoConfirmSettingsList().size() == 0) { + // default values for AutoConfirmSettings when persisted payload is empty: + prefPayload.getAutoConfirmSettingsList().add(new AutoConfirmSettings( + false, 5, Coin.COIN.value, getDefaultXmrProofProviders(), "XMR")); + } + return prefPayload.getAutoConfirmSettingsList().get(0); + } + + public void setAutoConfirmSettings(AutoConfirmSettings autoConfirmSettings) { + // see above comment regarding only one entry in this list currently + prefPayload.getAutoConfirmSettingsList().clear(); + prefPayload.getAutoConfirmSettingsList().add(autoConfirmSettings); + persist(); + } + + public void setAutoConfServiceAddresses(List serviceAddresses) { + AutoConfirmSettings x = this.getAutoConfirmSettings(); + this.setAutoConfirmSettings(new AutoConfirmSettings( + x.enabled, x.requiredConfirmations, x.tradeLimit, serviceAddresses, x.currencyCode)); + } + private void persist() { if (initialReadDone) storage.queueUpForSave(prefPayload); @@ -738,7 +781,9 @@ public ArrayList getBlockChainExplorers() { } } - public ArrayList getBsqBlockChainExplorers() { return BSQ_MAIN_NET_EXPLORERS; } + public ArrayList getBsqBlockChainExplorers() { + return BSQ_MAIN_NET_EXPLORERS; + } public boolean showAgain(String key) { return !prefPayload.getDontShowAgainMap().containsKey(key) || !prefPayload.getDontShowAgainMap().get(key); @@ -836,8 +881,7 @@ else if (change.wasRemoved() && change.getRemovedSize() == 1 && initialReadDone) } private boolean blockExplorerExists(ArrayList explorers, - BlockChainExplorer explorer) - { + BlockChainExplorer explorer) { if (explorer != null && explorers != null && explorers.size() > 0) for (int i = 0; i < explorers.size(); i++) if (explorers.get(i).name.equals(explorer.name)) @@ -967,5 +1011,7 @@ private interface ExcludesDelegateMethods { int getBlockNotifyPort(); void setTacAcceptedV120(boolean tacAccepted); + + void setAutoConfirmSettings(AutoConfirmSettings autoConfirmSettings); } } diff --git a/core/src/main/java/bisq/core/user/PreferencesPayload.java b/core/src/main/java/bisq/core/user/PreferencesPayload.java index 3e0997d68b4..fbf97ea575f 100644 --- a/core/src/main/java/bisq/core/user/PreferencesPayload.java +++ b/core/src/main/java/bisq/core/user/PreferencesPayload.java @@ -127,6 +127,9 @@ public final class PreferencesPayload implements UserThreadMappedPersistableEnve private int blockNotifyPort; private boolean tacAcceptedV120; + // Added at 1.3.8 + private List autoConfirmSettingsList = new ArrayList<>(); + /////////////////////////////////////////////////////////////////////////////////////////// // Constructor @@ -186,7 +189,11 @@ public Message toProtoMessage() { .setIgnoreDustThreshold(ignoreDustThreshold) .setBuyerSecurityDepositAsPercentForCrypto(buyerSecurityDepositAsPercentForCrypto) .setBlockNotifyPort(blockNotifyPort) - .setTacAcceptedV120(tacAcceptedV120); + .setTacAcceptedV120(tacAcceptedV120) + .addAllAutoConfirmSettings(autoConfirmSettingsList.stream() + .map(autoConfirmSettings -> ((protobuf.AutoConfirmSettings) autoConfirmSettings.toProtoMessage())) + .collect(Collectors.toList())); + Optional.ofNullable(backupDirectory).ifPresent(builder::setBackupDirectory); Optional.ofNullable(preferredTradeCurrency).ifPresent(e -> builder.setPreferredTradeCurrency((protobuf.TradeCurrency) e.toProtoMessage())); Optional.ofNullable(offerBookChartScreenCurrencyCode).ifPresent(builder::setOfferBookChartScreenCurrencyCode); @@ -274,6 +281,11 @@ public static PreferencesPayload fromProto(protobuf.PreferencesPayload proto, Co proto.getIgnoreDustThreshold(), proto.getBuyerSecurityDepositAsPercentForCrypto(), proto.getBlockNotifyPort(), - proto.getTacAcceptedV120()); + proto.getTacAcceptedV120(), + proto.getAutoConfirmSettingsList().isEmpty() ? new ArrayList<>() : + new ArrayList<>(proto.getAutoConfirmSettingsList().stream() + .map(AutoConfirmSettings::fromProto) + .collect(Collectors.toList())) + ); } } diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index 276eecf83b1..8a2af031599 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -570,6 +570,18 @@ portfolio.pending.step3_buyer.waitPaymentArrived=Wait until payment arrived portfolio.pending.step3_seller.confirmPaymentReceived=Confirm payment received portfolio.pending.step5.completed=Completed +portfolio.pending.step3_seller.autoConf.status.label=Auto-confirm status +portfolio.pending.autoConf=Auto-confirmed + +portfolio.pending.autoConf.state.FEATURE_DISABLED=Auto-confirm feature is disabled +portfolio.pending.autoConf.state.TRADE_LIMIT_EXCEEDED=Trade amount exceeds auto-confirm amount limit +portfolio.pending.autoConf.state.REQUEST_STARTED=Proof request started +portfolio.pending.autoConf.state.TX_NOT_FOUND=Transaction not confirmed yet +portfolio.pending.autoConf.state.PENDING_SERVICE_RESULTS=Pending service results: {0} of {1} +portfolio.pending.autoConf.state.PENDING_CONFIRMATIONS=Confirmations: {0} of {1} required +portfolio.pending.autoConf.state.SINGLE_SERVICE_SUCCEEDED=A service succeeded. Pending other services. +portfolio.pending.autoConf.state.ALL_SERVICES_SUCCEEDED=All proof services succeeded + portfolio.pending.step1.info=Deposit transaction has been published.\n{0} need to wait for at least one blockchain confirmation before starting the payment. portfolio.pending.step1.warn=The deposit transaction is still not confirmed. This sometimes happens in rare cases when the funding fee of one trader from an external wallet was too low. portfolio.pending.step1.openForDispute=The deposit transaction is still not confirmed. \ @@ -636,7 +648,13 @@ portfolio.pending.step2_buyer.fasterPaymentsHolderNameInfo=Some banks might veri portfolio.pending.step2_buyer.confirmStart.headline=Confirm that you have started the payment portfolio.pending.step2_buyer.confirmStart.msg=Did you initiate the {0} payment to your trading partner? portfolio.pending.step2_buyer.confirmStart.yes=Yes, I have started the payment - +portfolio.pending.step2_buyer.confirmStart.proof.warningTitle=You have not provided proof of payment +portfolio.pending.step2_buyer.confirmStart.proof.noneProvided=You have not entered the transaction ID and the transaction key.\n\n\ + By not providing this data the peer cannot use the auto-confirm feature to release the BTC as soon the XMR has been received.\n\ + Beside that, Bisq requires that the sender of the XMR transaction is able to provide this information to the mediator or arbitrator in case of a dispute.\n\ + See more details on the Bisq wiki: https://bisq.wiki/Trading_Monero#Auto-confirming_trades +portfolio.pending.step2_buyer.confirmStart.proof.invalidInput=Input is not a 32 byte hexadecimal value +portfolio.pending.step2_buyer.confirmStart.warningButton=Ignore and continue anyway portfolio.pending.step2_seller.waitPayment.headline=Wait for payment portfolio.pending.step2_seller.f2fInfo.headline=Buyer's contact information portfolio.pending.step2_seller.waitPayment.msg=The deposit transaction has at least one blockchain confirmation.\nYou need to wait until the BTC buyer starts the {0} payment. @@ -717,6 +735,8 @@ portfolio.pending.step3_seller.amountToReceive=Amount to receive portfolio.pending.step3_seller.yourAddress=Your {0} address portfolio.pending.step3_seller.buyersAddress=Buyers {0} address portfolio.pending.step3_seller.yourAccount=Your trading account +portfolio.pending.step3_seller.xmrTxHash=Transaction ID +portfolio.pending.step3_seller.xmrTxKey=Transaction key portfolio.pending.step3_seller.buyersAccount=Buyers trading account portfolio.pending.step3_seller.confirmReceipt=Confirm payment receipt portfolio.pending.step3_seller.buyerStartedPayment=The BTC buyer has started the {0} payment.\n{1} @@ -1055,6 +1075,11 @@ setting.preferences.explorer=Bitcoin block explorer setting.preferences.explorer.bsq=BSQ block explorer setting.preferences.deviation=Max. deviation from market price setting.preferences.avoidStandbyMode=Avoid standby mode +setting.preferences.autoConfirmXMR=XMR auto-confirm +setting.preferences.autoConfirmEnabled=Enabled +setting.preferences.autoConfirmRequiredConfirmations=Required confirmations +setting.preferences.autoConfirmMaxTradeSize=Max. trade amount (BTC) +setting.preferences.autoConfirmServiceAddresses=Service addresses setting.preferences.deviationToLarge=Values higher than {0}% are not allowed. setting.preferences.txFee=Withdrawal transaction fee (satoshis/byte) setting.preferences.useCustomValue=Use custom value @@ -1233,7 +1258,15 @@ setting.about.shortcuts.sendFilter=Set Filter (privileged activity) setting.about.shortcuts.sendPrivateNotification=Send private notification to peer (privileged activity) setting.about.shortcuts.sendPrivateNotification.value=Open peer info at avatar or dispute and press: {0} - +setting.info.headline=New XMR auto-confirm Feature +setting.info.msg=When selling BTC for XMR you can use the auto-confirm feature to verify that the correct amount of \ + XMR was sent to your wallet so that Bisq can automatically mark the trade as complete, making trades quicker for everyone.\n\n\ + Auto-confirm checks the XMR transaction on at least 2 XMR explorer nodes using the private transaction key provided \ + by the XMR sender. By default, Bisq uses explorer nodes run by Bisq contributors, but we recommend running your \ + own XMR explorer node for maximum privacy and security.\n\n\ + You can also set the maximum amount of BTC per trade to auto-confirm as well as the number of required \ + confirmations here in Settings.\n\n\ + See more details (including how to set up your own explorer node) on the Bisq wiki: https://bisq.wiki/Trading_Monero#Auto-confirming_trades #################################################################### # Account #################################################################### @@ -1315,35 +1348,18 @@ mediator or arbitrator in case of a dispute.\n\n\ There is no payment ID required, just the normal public address.\n\ If you are not sure about that process visit ArQmA discord channel (https://discord.gg/s9BQpJT) \ or the ArQmA forum (https://labs.arqma.com) to find more information. -account.altcoin.popup.xmr.msg=Trading XMR on Bisq requires that you understand and fulfill \ -the following requirements:\n\n\ -Prove payments: since Monero is a private coin, some transaction details aren't publicly available \ -in the blockchain, and, in case of a dispute, the mediator or arbitrator needs them to check if the \ -transaction was really made. In Bisq, the sender of the XMR transaction is the one responsible for \ -providing this information to the mediator or arbitrator in case of a dispute. In order to do that, \ -you must send XMR using a wallet that provides the information required to prove the payment was made, \ -which includes:\n\n\ +account.altcoin.popup.xmr.msg=Trading XMR on Bisq requires that you understand the following requirement.\n\n\ +If selling XMR, you must be able to provide the following information to a mediator or arbitrator in case of a dispute:\n\ - the transaction key (Tx Key, Tx Secret Key or Tx Private Key)\n\ - the transaction ID (Tx ID or Tx Hash)\n\ - the destination address (recipient's address)\n\n\ -This information can be found in the official Monero GUI & CLI wallets, MyMonero, and Exodus (desktop) \ -as well as in Cake Wallet, MyMonero, and Monerujo (mobile), in the following locations:\n\n\ -- Monero GUI: go to Transactions tab\n\ -- Monero CLI: use the command get_tx_key TXID. The flag store-tx-info must be enabled (enabled by default in new versions)\n\ -- Other wallets: go to Transactions history and search for Transaction key (Tx key or Secret key) and the destination address \ -in a sent transaction. Save recipient address option must be enabled in Cake Wallet settings.\n\n\ -If you are using a wallet different from the mentioned above, please be sure you can access those three pieces of information.\ -Since the transaction key and the destination address are stored in the Monero wallet software, and they cannot be recovered \ -in the Monero blockchain, you should never delete or restore your Monero wallet before a Bisq trade is completed. Failure to \ -provide the above data will result in losing the dispute case.\n\n\ -Check payments: with those three pieces of information, the verification that a quantity of Monero was sent to a specific \ -address can be accomplished the following way:\n\n\ -- Monero GUI: change wallet to Advanced mode and go to Advanced > Prove/check > Check Transaction\n\ -- Monero CLI: use the command check_tx_key TXID TXKEY ADDRESS\n\ -- XMR checktx tool (https://xmr.llcoins.net/checktx.html)\n\ -- Explore Monero website (https://www.exploremonero.com/receipt)\n\n\ -If you are still not sure about this process, visit (https://www.getmonero.org/resources/user-guides/prove-payment.html) \ -to find more information or ask a question on the Monero support subreddit (https://www.reddit.com/r/monerosupport/). +See the wiki for details on where to find this information on popular Monero wallets:\n\ +https://bisq.wiki/Trading_Monero#Proving_payments\n\n\ +Failure to provide the required transaction data will result in losing disputes.\n\n\ +Also note that Bisq now offers automatic confirming for XMR transactions to make trades quicker, \ +but you need to enable it in Settings.\n\n\ +See the wiki for more information about the auto-confirm feature:\n\ +https://bisq.wiki/Trading_Monero#Auto-confirming_trades # suppress inspection "TrailingSpacesInProperty" account.altcoin.popup.msr.msg=Trading MSR on Bisq requires that you understand and fulfill \ the following requirements:\n\n\ @@ -2443,6 +2459,7 @@ filterWindow.priceRelayNode=Filtered price relay nodes (comma sep. onion address filterWindow.btcNode=Filtered Bitcoin nodes (comma sep. addresses + port) filterWindow.preventPublicBtcNetwork=Prevent usage of public Bitcoin network filterWindow.disableDao=Disable DAO +filterWindow.disableAutoConf=Disable auto-confirm filterWindow.disableDaoBelowVersion=Min. version required for DAO filterWindow.disableTradeBelowVersion=Min. version required for trading filterWindow.add=Add filter @@ -2503,6 +2520,10 @@ sendPrivateNotificationWindow.send=Send private notification showWalletDataWindow.walletData=Wallet data showWalletDataWindow.includePrivKeys=Include private keys +setXMRTxKeyWindow.headline=Prove sending of XMR +setXMRTxKeyWindow.txHash=Transaction ID +setXMRTxKeyWindow.txKey=Transaction key + # We do not translate the tac because of the legal nature. We would need translations checked by lawyers # in each language which is too expensive atm. tacWindow.headline=User agreement diff --git a/core/src/test/java/bisq/core/trade/autoconf/xmr/XmrTxProofParserTest.java b/core/src/test/java/bisq/core/trade/autoconf/xmr/XmrTxProofParserTest.java new file mode 100644 index 00000000000..10c3c84ea2f --- /dev/null +++ b/core/src/test/java/bisq/core/trade/autoconf/xmr/XmrTxProofParserTest.java @@ -0,0 +1,160 @@ +package bisq.core.trade.autoconf.xmr; + +import java.time.Instant; + +import java.util.Date; + +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class XmrTxProofParserTest { + private XmrTxProofModel xmrTxProofModel; + private String recipientAddressHex = "e957dac72bcec80d59b2fecacfa7522223b6a5df895b7e388e60297e85f3f867b42f43e8d9f086a99a997704ceb92bd9cd99d33952de90c9f5f93c82c62360ae"; + private String txHash = "488e48ab0c7e69028d19f787ec57fd496ff114caba9ab265bfd41a3ea0e4687d"; + private String txKey = "6c336e52ed537676968ee319af6983c80b869ca6a732b5962c02748b486f8f0f"; + private String recipientAddress = "4ATyxmFGU7h3EWu5kYR6gy6iCNFCftbsjATfbuBBjsRHJM4KTwEyeiyVNNUmsfpK1kdRxs8QoPLsZanGqe1Mby43LeyWNMF"; + + @Before + public void prepareMocksAndObjects() { + long amount = 100000000000L; + Date tradeDate = Date.from(Instant.now()); + int confirmsRequired = 10; + String serviceAddress = "127.0.0.1:8081"; + + xmrTxProofModel = new XmrTxProofModel( + txHash, + txKey, + recipientAddress, + amount, + tradeDate, + confirmsRequired, + serviceAddress); + } + + @Test + public void testKey() { + assertTrue(xmrTxProofModel.getUID().contains(xmrTxProofModel.getTxHash())); + assertTrue(xmrTxProofModel.getUID().contains(xmrTxProofModel.getServiceAddress())); + assertFalse(xmrTxProofModel.getUID().contains(xmrTxProofModel.getRecipientAddress())); + } + + @Test + public void testJsonRoot() { + // checking what happens when bad input is provided + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, + "invalid json data").getState() == XmrTxProofResult.State.API_INVALID); + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, + "").getState() == XmrTxProofResult.State.API_INVALID); + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, + "[]").getState() == XmrTxProofResult.State.API_INVALID); + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, + "{}").getState() == XmrTxProofResult.State.API_INVALID); + } + + @Test + public void testJsonTopLevel() { + // testing the top level fields: data and status + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, + "{'data':{'title':''},'status':'fail'}" ) + .getState() == XmrTxProofResult.State.TX_NOT_FOUND); + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, + "{'data':{'title':''},'missingstatus':'success'}" ) + .getState() == XmrTxProofResult.State.API_INVALID); + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, + "{'missingdata':{'title':''},'status':'success'}" ) + .getState() == XmrTxProofResult.State.API_INVALID); + } + + @Test + public void testJsonAddress() { + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, + "{'data':{'missingaddress':'irrelevant'},'status':'success'}" ) + .getState() == XmrTxProofResult.State.API_INVALID); + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, + "{'data':{'address':'e957dac7'},'status':'success'}" ) + .getState() == XmrTxProofResult.State.ADDRESS_INVALID); + } + + @Test + public void testJsonTxHash() { + String missing_tx_hash = "{'data':{'address':'" + recipientAddressHex + "'}, 'status':'success'}"; + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, missing_tx_hash).getState() + == XmrTxProofResult.State.API_INVALID); + + String invalid_tx_hash = "{'data':{'address':'" + recipientAddressHex + "', 'tx_hash':'488e48'}, 'status':'success'}"; + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, invalid_tx_hash).getState() + == XmrTxProofResult.State.TX_HASH_INVALID); + } + + @Test + public void testJsonTxKey() { + String missing_tx_key = "{'data':{'address':'" + recipientAddressHex + "', " + + "'tx_hash':'" + txHash + "'}, 'status':'success'}"; + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, missing_tx_key).getState() + == XmrTxProofResult.State.API_INVALID); + + String invalid_tx_key = "{'data':{'address':'" + recipientAddressHex + "', " + + "'tx_hash':'" + txHash + "', " + + "'viewkey':'cdce04'}, 'status':'success'}"; + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, invalid_tx_key).getState() + == XmrTxProofResult.State.TX_KEY_INVALID); + } + + @Test + public void testJsonTxTimestamp() { + String missing_tx_timestamp = "{'data':{'address':'" + recipientAddressHex + "', " + + "'tx_hash':'" + txHash + "'," + + "'viewkey':'" + txKey + "'}, 'status':'success'}"; + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, missing_tx_timestamp).getState() + == XmrTxProofResult.State.API_INVALID); + + String invalid_tx_timestamp = "{'data':{'address':'" + recipientAddressHex + "', " + + "'tx_hash':'" + txHash + "', " + + "'viewkey':'" + txKey + "'," + + "'tx_timestamp':'12345'}, 'status':'success'}"; + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, invalid_tx_timestamp).getState() + == XmrTxProofResult.State.TRADE_DATE_NOT_MATCHING); + } + + @Test + public void testJsonTxConfirmation() { + long epochDate = Instant.now().toEpochMilli() / 1000; + String outputs = "'outputs':[" + + "{'amount':100000000000,'match':true,'output_idx':0,'output_pubkey':'972a2c9178876f1fae4ecd22f9d7c132a12706db8ffb5d1f223f9aa8ced75b61'}," + + "{'amount':0,'match':false,'output_idx':1,'output_pubkey':'658330d2d56c74aca3b40900c56cd0f0111e2876be677ade493d06d539a1bab0'}],"; + String json = "{'status':'success', 'data':{" + + "'address':'" + recipientAddressHex + "', " + + outputs + + "'tx_confirmations':777, " + + "'tx_hash':'" + txHash + "', " + + "'viewkey':'" + txKey + "', " + + "'tx_timestamp':'" + Long.toString(epochDate) + "'}" + + "}"; + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, json).getState() + == XmrTxProofResult.State.SINGLE_SERVICE_SUCCEEDED); + json = json.replaceFirst("777", "0"); + + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, json).getState() + == XmrTxProofResult.State.PENDING_CONFIRMATIONS); + + json = json.replaceFirst("100000000000", "100000000001"); + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, json).getState() + == XmrTxProofResult.State.AMOUNT_NOT_MATCHING); + + // Revert change of amount + json = json.replaceFirst("100000000001", "100000000000"); + json = json.replaceFirst("'match':true", "'match':false"); + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, json).getState() + == XmrTxProofResult.State.NO_MATCH_FOUND); + } + + @Test + public void testJsonFail() { + String failedJson = "{\"data\":null,\"message\":\"Cant parse tx hash: a\",\"status\":\"error\"}"; + assertTrue(XmrTxProofParser.parse(xmrTxProofModel, failedJson).getState() + == XmrTxProofResult.State.API_INVALID); + } +} diff --git a/core/src/test/java/bisq/core/user/UserPayloadModelVOTest.java b/core/src/test/java/bisq/core/user/UserPayloadModelVOTest.java index 67d09bb6586..d64576bf5d8 100644 --- a/core/src/test/java/bisq/core/user/UserPayloadModelVOTest.java +++ b/core/src/test/java/bisq/core/user/UserPayloadModelVOTest.java @@ -59,7 +59,8 @@ public void testRoundtripFull() { Lists.newArrayList(), Lists.newArrayList(), Lists.newArrayList(), - Lists.newArrayList())); + Lists.newArrayList(), + false)); vo.setRegisteredArbitrator(ArbitratorTest.getArbitratorMock()); vo.setRegisteredMediator(MediatorTest.getMediatorMock()); vo.setAcceptedArbitrators(Lists.newArrayList(ArbitratorTest.getArbitratorMock())); diff --git a/core/src/test/java/bisq/core/util/FeeReceiverSelectorTest.java b/core/src/test/java/bisq/core/util/FeeReceiverSelectorTest.java index c340c36ecdd..455e6cda19f 100644 --- a/core/src/test/java/bisq/core/util/FeeReceiverSelectorTest.java +++ b/core/src/test/java/bisq/core/util/FeeReceiverSelectorTest.java @@ -102,6 +102,6 @@ private static Filter filterWithReceivers(List btcFeeReceiverAddresses) null, null, null, null, false, null, false, null, null, null, null, null, - btcFeeReceiverAddresses); + btcFeeReceiverAddresses, false); } } diff --git a/desktop/src/main/java/bisq/desktop/bisq.css b/desktop/src/main/java/bisq/desktop/bisq.css index dc2e9ed7c86..e9ab97167cf 100644 --- a/desktop/src/main/java/bisq/desktop/bisq.css +++ b/desktop/src/main/java/bisq/desktop/bisq.css @@ -436,6 +436,10 @@ tree-table-view:focused { -fx-pref-width: 30; } +.jfx-badge.autoconf .badge-pane { + -fx-pref-width: 100; +} + .jfx-badge .badge-pane .label { -fx-font-weight: bold; -fx-font-size: 0.692em; diff --git a/desktop/src/main/java/bisq/desktop/main/MainView.java b/desktop/src/main/java/bisq/desktop/main/MainView.java index b5ee6590529..f977c95d0bc 100644 --- a/desktop/src/main/java/bisq/desktop/main/MainView.java +++ b/desktop/src/main/java/bisq/desktop/main/MainView.java @@ -195,6 +195,8 @@ protected void initialize() { daoButtonWithBadge.getStyleClass().add("new"); JFXBadge accountButtonWithBadge = new JFXBadge(accountButton); accountButtonWithBadge.getStyleClass().add("new"); + JFXBadge settingsButtonWithBadge = new JFXBadge(settingsButton); + settingsButtonWithBadge.getStyleClass().add("new"); Locale locale = GlobalSettings.getLocale(); DecimalFormat currencyFormat = (DecimalFormat) NumberFormat.getNumberInstance(locale); @@ -325,8 +327,9 @@ protected Tooltip computeValue() { primaryNav.getStyleClass().add("nav-primary"); HBox.setHgrow(primaryNav, Priority.SOMETIMES); - HBox secondaryNav = new HBox(supportButtonWithBadge, getNavigationSpacer(), settingsButton, - getNavigationSpacer(), accountButtonWithBadge, getNavigationSpacer(), daoButtonWithBadge); + HBox secondaryNav = new HBox(supportButtonWithBadge, getNavigationSeparator(), + settingsButtonWithBadge, getNavigationSeparator(), accountButtonWithBadge, + getNavigationSeparator(), daoButtonWithBadge); secondaryNav.getStyleClass().add("nav-secondary"); HBox.setHgrow(secondaryNav, Priority.SOMETIMES); @@ -371,6 +374,7 @@ protected Tooltip computeValue() { setupBadge(supportButtonWithBadge, model.getNumOpenSupportTickets(), model.getShowOpenSupportTicketsNotification()); setupBadge(daoButtonWithBadge, new SimpleStringProperty(Res.get("shared.new")), model.getShowDaoUpdatesNotification()); setupBadge(accountButtonWithBadge, new SimpleStringProperty(Res.get("shared.new")), model.getShowAccountUpdatesNotification()); + setupBadge(settingsButtonWithBadge, new SimpleStringProperty(Res.get("shared.new")), model.getShowSettingsUpdatesNotification()); navigation.addListener(viewPath -> { if (viewPath.size() != 2 || viewPath.indexOf(MainView.class) != 0) diff --git a/desktop/src/main/java/bisq/desktop/main/MainViewModel.java b/desktop/src/main/java/bisq/desktop/main/MainViewModel.java index da37762ac3a..6bda1d4126c 100644 --- a/desktop/src/main/java/bisq/desktop/main/MainViewModel.java +++ b/desktop/src/main/java/bisq/desktop/main/MainViewModel.java @@ -31,6 +31,7 @@ import bisq.desktop.main.overlays.windows.WalletPasswordWindow; import bisq.desktop.main.overlays.windows.downloadupdate.DisplayUpdateDownloadWindow; import bisq.desktop.main.presentation.AccountPresentation; +import bisq.desktop.main.presentation.SettingsPresentation; import bisq.desktop.main.presentation.DaoPresentation; import bisq.desktop.main.presentation.MarketPricePresentation; import bisq.desktop.main.shared.PriceFeedComboBoxItem; @@ -108,6 +109,7 @@ public class MainViewModel implements ViewModel, BisqSetup.BisqSetupListener { private final MarketPricePresentation marketPricePresentation; private final DaoPresentation daoPresentation; private final AccountPresentation accountPresentation; + private final SettingsPresentation settingsPresentation; private final P2PService p2PService; private final TradeManager tradeManager; @Getter @@ -150,7 +152,9 @@ public MainViewModel(BisqSetup bisqSetup, SupportTicketsPresentation supportTicketsPresentation, MarketPricePresentation marketPricePresentation, DaoPresentation daoPresentation, - AccountPresentation accountPresentation, P2PService p2PService, + AccountPresentation accountPresentation, + SettingsPresentation settingsPresentation, + P2PService p2PService, TradeManager tradeManager, Preferences preferences, PrivateNotificationManager privateNotificationManager, @@ -173,6 +177,7 @@ public MainViewModel(BisqSetup bisqSetup, this.marketPricePresentation = marketPricePresentation; this.daoPresentation = daoPresentation; this.accountPresentation = accountPresentation; + this.settingsPresentation = settingsPresentation; this.p2PService = p2PService; this.tradeManager = tradeManager; this.preferences = preferences; @@ -249,6 +254,7 @@ public void onSetupComplete() { marketPricePresentation.setup(); daoPresentation.setup(); accountPresentation.setup(); + settingsPresentation.setup(); if (DevEnv.isDevMode()) { preferences.setShowOwnOffersInOfferBook(true); @@ -658,6 +664,10 @@ public BooleanProperty getShowAccountUpdatesNotification() { return accountPresentation.getShowAccountUpdatesNotification(); } + public BooleanProperty getShowSettingsUpdatesNotification() { + return settingsPresentation.getShowSettingsUpdatesNotification(); + } + private void maybeAddNewTradeProtocolLaunchWindowToQueue() { String newTradeProtocolWithAccountSigningLaunchPopupKey = "newTradeProtocolWithAccountSigningLaunchPopup"; if (DontShowAgainLookup.showAgain(newTradeProtocolWithAccountSigningLaunchPopupKey)) { diff --git a/desktop/src/main/java/bisq/desktop/main/overlays/windows/FilterWindow.java b/desktop/src/main/java/bisq/desktop/main/overlays/windows/FilterWindow.java index 50dbf04f182..d1e9872daca 100644 --- a/desktop/src/main/java/bisq/desktop/main/overlays/windows/FilterWindow.java +++ b/desktop/src/main/java/bisq/desktop/main/overlays/windows/FilterWindow.java @@ -136,6 +136,7 @@ private void addContent() { InputTextField btcNodesInputTextField = addInputTextField(gridPane, ++rowIndex, Res.get("filterWindow.btcNode")); CheckBox preventPublicBtcNetworkCheckBox = addLabelCheckBox(gridPane, ++rowIndex, Res.get("filterWindow.preventPublicBtcNetwork")); CheckBox disableDaoCheckBox = addLabelCheckBox(gridPane, ++rowIndex, Res.get("filterWindow.disableDao")); + CheckBox disableAutoConfCheckBox = addLabelCheckBox(gridPane, ++rowIndex, Res.get("filterWindow.disableAutoConf")); InputTextField disableDaoBelowVersionInputTextField = addInputTextField(gridPane, ++rowIndex, Res.get("filterWindow.disableDaoBelowVersion")); InputTextField disableTradeBelowVersionInputTextField = addInputTextField(gridPane, ++rowIndex, Res.get("filterWindow.disableTradeBelowVersion")); @@ -157,6 +158,7 @@ private void addContent() { preventPublicBtcNetworkCheckBox.setSelected(filter.isPreventPublicBtcNetwork()); disableDaoCheckBox.setSelected(filter.isDisableDao()); + disableAutoConfCheckBox.setSelected(filter.isDisableAutoConf()); disableDaoBelowVersionInputTextField.setText(filter.getDisableDaoBelowVersion()); disableTradeBelowVersionInputTextField.setText(filter.getDisableTradeBelowVersion()); } @@ -180,7 +182,8 @@ private void addContent() { readAsList(mediatorsInputTextField), readAsList(refundAgentsInputTextField), readAsList(bannedSignerPubKeysInputTextField), - readAsList(btcFeeReceiverAddressesInputTextField) + readAsList(btcFeeReceiverAddressesInputTextField), + disableAutoConfCheckBox.isSelected() ), keyInputTextField.getText()) ) diff --git a/desktop/src/main/java/bisq/desktop/main/overlays/windows/SetXmrTxKeyWindow.java b/desktop/src/main/java/bisq/desktop/main/overlays/windows/SetXmrTxKeyWindow.java new file mode 100644 index 00000000000..518b5da8d59 --- /dev/null +++ b/desktop/src/main/java/bisq/desktop/main/overlays/windows/SetXmrTxKeyWindow.java @@ -0,0 +1,104 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.desktop.main.overlays.windows; + +import bisq.desktop.components.InputTextField; +import bisq.desktop.main.overlays.Overlay; +import bisq.desktop.util.validation.RegexValidator; + +import bisq.core.locale.Res; +import bisq.core.trade.autoconf.xmr.XmrTxProofModel; + +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; + +import javafx.geometry.HPos; +import javafx.geometry.Insets; + +import lombok.Getter; + +import javax.annotation.Nullable; + +import static bisq.common.app.DevEnv.isDevMode; +import static bisq.desktop.util.FormBuilder.addInputTextField; + +public class SetXmrTxKeyWindow extends Overlay { + + private InputTextField txHashInputTextField, txKeyInputTextField; + @Getter + private RegexValidator regexValidator; + + public SetXmrTxKeyWindow() { + type = Type.Attention; + } + + public void show() { + if (headLine == null) + headLine = Res.get("setXMRTxKeyWindow.headline"); + + width = 868; + createGridPane(); + addHeadLine(); + addContent(); + addButtons(); + + regexValidator = new RegexValidator(); + regexValidator.setPattern("[a-fA-F0-9]{64}"); + regexValidator.setErrorMessage(Res.get("portfolio.pending.step2_buyer.confirmStart.proof.invalidInput")); + txHashInputTextField.setValidator(regexValidator); + txKeyInputTextField.setValidator(regexValidator); + if (isDevMode()) { + // pre-populate the fields with test data when in dev mode + txHashInputTextField.setText(XmrTxProofModel.DEV_TX_HASH); + txKeyInputTextField.setText(XmrTxProofModel.DEV_TX_KEY); + } + + applyStyles(); + display(); + } + + @Override + protected void createGridPane() { + gridPane = new GridPane(); + gridPane.setHgap(5); + gridPane.setVgap(5); + gridPane.setPadding(new Insets(64, 64, 64, 64)); + gridPane.setPrefWidth(width); + + ColumnConstraints columnConstraints1 = new ColumnConstraints(); + columnConstraints1.setHalignment(HPos.RIGHT); + columnConstraints1.setHgrow(Priority.SOMETIMES); + gridPane.getColumnConstraints().addAll(columnConstraints1); + } + + @Nullable + public String getTxHash() { + return txHashInputTextField != null ? txHashInputTextField.getText() : null; + } + + @Nullable + public String getTxKey() { + return txKeyInputTextField != null ? txKeyInputTextField.getText() : null; + } + + private void addContent() { + txHashInputTextField = addInputTextField(gridPane, ++rowIndex, Res.get("setXMRTxKeyWindow.txHash"), 10); + txKeyInputTextField = addInputTextField(gridPane, ++rowIndex, Res.get("setXMRTxKeyWindow.txKey")); + } +} diff --git a/desktop/src/main/java/bisq/desktop/main/overlays/windows/TradeDetailsWindow.java b/desktop/src/main/java/bisq/desktop/main/overlays/windows/TradeDetailsWindow.java index 87c1e139201..7a13fcca02b 100644 --- a/desktop/src/main/java/bisq/desktop/main/overlays/windows/TradeDetailsWindow.java +++ b/desktop/src/main/java/bisq/desktop/main/overlays/windows/TradeDetailsWindow.java @@ -33,13 +33,13 @@ import bisq.core.trade.Contract; import bisq.core.trade.Trade; import bisq.core.trade.TradeManager; +import bisq.core.trade.autoconf.xmr.XmrTxProofResult; import bisq.core.util.FormattingUtils; import bisq.core.util.coin.CoinFormatter; import bisq.network.p2p.NodeAddress; import bisq.common.UserThread; -import bisq.common.util.Utilities; import org.bitcoinj.core.Utils; @@ -68,6 +68,7 @@ import org.slf4j.LoggerFactory; import static bisq.desktop.util.FormBuilder.*; +import static com.google.common.base.Preconditions.checkNotNull; public class TradeDetailsWindow extends Overlay { protected static final Logger log = LoggerFactory.getLogger(TradeDetailsWindow.class); @@ -159,8 +160,8 @@ private void addContent() { DisplayUtils.formatVolumeWithCode(trade.getTradeVolume())); addConfirmationLabelLabel(gridPane, ++rowIndex, Res.get("shared.tradePrice"), FormattingUtils.formatPrice(trade.getTradePrice())); - addConfirmationLabelLabel(gridPane, ++rowIndex, Res.get("shared.paymentMethod"), - Res.get(offer.getPaymentMethod().getId())); + String paymentMethodText = Res.get(offer.getPaymentMethod().getId()); + addConfirmationLabelLabel(gridPane, ++rowIndex, Res.get("shared.paymentMethod"), paymentMethodText); // second group rows = 6; @@ -233,11 +234,21 @@ private void addContent() { addConfirmationLabelTextFieldWithCopyIcon(gridPane, ++rowIndex, Res.get("tradeDetailsWindow.tradingPeersOnion"), trade.getTradingPeerNodeAddress().getFullAddress()); - addConfirmationLabelTextFieldWithCopyIcon(gridPane, ++rowIndex, - Res.get("tradeDetailsWindow.tradingPeersPubKeyHash"), - trade.getContract() != null ? Utils.HEX.encode(trade.getContract().getPeersPubKeyRing( - tradeManager.getKeyRing().getPubKeyRing()).getSignaturePubKeyBytes()) : - Res.get("shared.na")); + if (checkNotNull(trade.getOffer()).getCurrencyCode().equals("XMR") && + trade.getAssetTxProofResult() != null && + ((XmrTxProofResult) trade.getAssetTxProofResult()).getState() != XmrTxProofResult.State.UNDEFINED) { + // As the window is already overloaded we replace the tradingPeersPubKeyHash field with the auto-conf state + // if XMR is the currency + addConfirmationLabelTextFieldWithCopyIcon(gridPane, ++rowIndex, + Res.get("portfolio.pending.step3_seller.autoConf.status.label"), + trade.getAssetTxProofResult().getStatusAsDisplayString()); + } else { + addConfirmationLabelTextFieldWithCopyIcon(gridPane, ++rowIndex, + Res.get("tradeDetailsWindow.tradingPeersPubKeyHash"), + trade.getContract() != null ? Utils.HEX.encode(trade.getContract().getPeersPubKeyRing( + tradeManager.getKeyRing().getPubKeyRing()).getSignaturePubKeyBytes()) : + Res.get("shared.na")); + } if (contract != null) { if (buyerPaymentAccountPayload != null) { diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesDataModel.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesDataModel.java index 9f5276f9b97..e3cf3ec1140 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesDataModel.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesDataModel.java @@ -188,8 +188,6 @@ public void onPaymentStarted(ResultHandler resultHandler, ErrorMessageHandler er final Trade trade = getTrade(); checkNotNull(trade, "trade must not be null"); checkArgument(trade instanceof BuyerTrade, "Check failed: trade instanceof BuyerTrade"); - // TODO UI not impl yet - trade.setCounterCurrencyTxId(""); ((BuyerTrade) trade).onFiatPaymentStarted(resultHandler, errorMessageHandler); } @@ -703,5 +701,13 @@ public boolean isBootstrappedOrShowPopup() { public void addTradeToFailedTrades() { tradeManager.addTradeToFailedTrades(selectedTrade); } + + public boolean isSignWitnessTrade() { + return accountAgeWitnessService.isSignWitnessTrade(selectedTrade); + } + + public void maybeSignWitness() { + accountAgeWitnessService.maybeSignWitness(selectedTrade); + } } diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesViewModel.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesViewModel.java index 09b23eb8d13..ba7b285449c 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesViewModel.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/PendingTradesViewModel.java @@ -22,8 +22,6 @@ import bisq.desktop.util.DisplayUtils; import bisq.desktop.util.GUIUtil; -import bisq.core.account.sign.SignedWitness; -import bisq.core.account.witness.AccountAgeWitness; import bisq.core.account.witness.AccountAgeWitnessService; import bisq.core.btc.wallet.Restrictions; import bisq.core.locale.CurrencyUtil; @@ -34,17 +32,13 @@ import bisq.core.trade.Contract; import bisq.core.trade.Trade; import bisq.core.trade.closed.ClosedTradableManager; -import bisq.core.trade.messages.RefreshTradeStateRequest; -import bisq.core.trade.messages.TraderSignedWitnessMessage; import bisq.core.user.User; import bisq.core.util.FormattingUtils; import bisq.core.util.coin.BsqFormatter; import bisq.core.util.coin.CoinFormatter; import bisq.core.util.validation.BtcAddressValidator; -import bisq.network.p2p.NodeAddress; import bisq.network.p2p.P2PService; -import bisq.network.p2p.SendMailboxMessageListener; import bisq.common.ClockWatcher; import bisq.common.app.DevEnv; @@ -63,7 +57,6 @@ import javafx.beans.property.SimpleObjectProperty; import java.util.Date; -import java.util.UUID; import java.util.stream.Collectors; import lombok.Getter; @@ -370,63 +363,6 @@ public int getNumPastTrades(Trade trade) { .size(); } - /////////////////////////////////////////////////////////////////////////////////////////// - // AccountAgeWitness signing - /////////////////////////////////////////////////////////////////////////////////////////// - - - public boolean isSignWitnessTrade() { - checkNotNull(trade, "trade must not be null"); - checkNotNull(trade.getOffer(), "offer must not be null"); - AccountAgeWitness myWitness = accountAgeWitnessService.getMyWitness(dataModel.getSellersPaymentAccountPayload()); - - accountAgeWitnessService.getAccountAgeWitnessUtils().witnessDebugLog(trade, myWitness); - - return accountAgeWitnessService.accountIsSigner(myWitness) && - !accountAgeWitnessService.peerHasSignedWitness(trade) && - accountAgeWitnessService.tradeAmountIsSufficient(trade.getTradeAmount()); - } - - public void maybeSignWitness() { - if (isSignWitnessTrade()) { - var signedWitness = accountAgeWitnessService.traderSignPeersAccountAgeWitness(trade); - signedWitness.ifPresent(this::sendSignedWitnessToPeer); - } - } - - private void sendSignedWitnessToPeer(SignedWitness signedWitness) { - Trade trade = getTrade(); - if (trade == null) return; - - NodeAddress tradingPeerNodeAddress = trade.getTradingPeerNodeAddress(); - var traderSignedWitnessMessage = new TraderSignedWitnessMessage(UUID.randomUUID().toString(), trade.getId(), - tradingPeerNodeAddress, signedWitness); - - p2PService.sendEncryptedMailboxMessage( - tradingPeerNodeAddress, - trade.getProcessModel().getTradingPeer().getPubKeyRing(), - traderSignedWitnessMessage, - new SendMailboxMessageListener() { - @Override - public void onArrived() { - log.info("SendMailboxMessageListener onArrived tradeId={} at peer {} SignedWitness {}", - trade.getId(), tradingPeerNodeAddress, signedWitness); - } - - @Override - public void onStoredInMailbox() { - log.info("SendMailboxMessageListener onStoredInMailbox tradeId={} at peer {} SignedWitness {}", - trade.getId(), tradingPeerNodeAddress, signedWitness); - } - - @Override - public void onFault(String errorMessage) { - log.error("SendMailboxMessageListener onFault tradeId={} at peer {} SignedWitness {}", - trade.getId(), tradingPeerNodeAddress, signedWitness); - } - } - ); - } /////////////////////////////////////////////////////////////////////////////////////////// // States diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep2View.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep2View.java index 80826ccbde4..2f09cf258a9 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep2View.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep2View.java @@ -48,10 +48,12 @@ import bisq.desktop.components.paymentmethods.WeChatPayForm; import bisq.desktop.components.paymentmethods.WesternUnionForm; import bisq.desktop.main.overlays.popups.Popup; +import bisq.desktop.main.overlays.windows.SetXmrTxKeyWindow; import bisq.desktop.main.portfolio.pendingtrades.PendingTradesViewModel; import bisq.desktop.main.portfolio.pendingtrades.steps.TradeStepView; import bisq.desktop.util.DisplayUtils; import bisq.desktop.util.Layout; +import bisq.desktop.util.Transitions; import bisq.core.locale.CurrencyUtil; import bisq.core.locale.Res; @@ -72,6 +74,7 @@ import bisq.core.trade.DelayedPayoutTxValidation; import bisq.core.trade.Trade; import bisq.core.user.DontShowAgainLookup; +import bisq.core.util.validation.InputValidator; import bisq.common.Timer; import bisq.common.UserThread; @@ -88,6 +91,7 @@ import org.fxmisc.easybind.Subscription; import java.util.List; +import java.util.concurrent.TimeUnit; import static bisq.desktop.util.FormBuilder.addButtonBusyAnimationLabel; import static bisq.desktop.util.FormBuilder.addCompactTopLabelTextFieldWithCopyIcon; @@ -384,76 +388,126 @@ protected void applyOnDisputeOpened() { /////////////////////////////////////////////////////////////////////////////////////////// private void onPaymentStarted() { - if (model.dataModel.isBootstrappedOrShowPopup()) { - if (model.dataModel.getSellersPaymentAccountPayload() instanceof CashDepositAccountPayload) { - String key = "confirmPaperReceiptSent"; - if (!DevEnv.isDevMode() && DontShowAgainLookup.showAgain(key)) { - Popup popup = new Popup(); - popup.headLine(Res.get("portfolio.pending.step2_buyer.paperReceipt.headline")) - .feedback(Res.get("portfolio.pending.step2_buyer.paperReceipt.msg")) - .onAction(this::showConfirmPaymentStartedPopup) - .closeButtonText(Res.get("shared.no")) - .onClose(popup::hide) - .dontShowAgainId(key) - .show(); - } else { - showConfirmPaymentStartedPopup(); - } - } else if (model.dataModel.getSellersPaymentAccountPayload() instanceof WesternUnionAccountPayload) { - String key = "westernUnionMTCNSent"; - if (!DevEnv.isDevMode() && DontShowAgainLookup.showAgain(key)) { - String email = ((WesternUnionAccountPayload) model.dataModel.getSellersPaymentAccountPayload()).getEmail(); - Popup popup = new Popup(); - popup.headLine(Res.get("portfolio.pending.step2_buyer.westernUnionMTCNInfo.headline")) - .feedback(Res.get("portfolio.pending.step2_buyer.westernUnionMTCNInfo.msg", email)) - .onAction(this::showConfirmPaymentStartedPopup) - .actionButtonText(Res.get("shared.yes")) - .closeButtonText(Res.get("shared.no")) - .onClose(popup::hide) - .dontShowAgainId(key) - .show(); - } else { - showConfirmPaymentStartedPopup(); - } - } else if (model.dataModel.getSellersPaymentAccountPayload() instanceof MoneyGramAccountPayload) { - String key = "moneyGramMTCNSent"; - if (!DevEnv.isDevMode() && DontShowAgainLookup.showAgain(key)) { - String email = ((MoneyGramAccountPayload) model.dataModel.getSellersPaymentAccountPayload()).getEmail(); - Popup popup = new Popup(); - popup.headLine(Res.get("portfolio.pending.step2_buyer.moneyGramMTCNInfo.headline")) - .feedback(Res.get("portfolio.pending.step2_buyer.moneyGramMTCNInfo.msg", email)) - .onAction(this::showConfirmPaymentStartedPopup) - .actionButtonText(Res.get("shared.yes")) - .closeButtonText(Res.get("shared.no")) - .onClose(popup::hide) - .dontShowAgainId(key) - .show(); - } else { - showConfirmPaymentStartedPopup(); - } - } else if (model.dataModel.getSellersPaymentAccountPayload() instanceof HalCashAccountPayload) { - String key = "halCashCodeInfo"; - if (!DevEnv.isDevMode() && DontShowAgainLookup.showAgain(key)) { - String mobileNr = ((HalCashAccountPayload) model.dataModel.getSellersPaymentAccountPayload()).getMobileNr(); - Popup popup = new Popup(); - popup.headLine(Res.get("portfolio.pending.step2_buyer.halCashInfo.headline")) - .feedback(Res.get("portfolio.pending.step2_buyer.halCashInfo.msg", - model.dataModel.getTrade().getShortId(), mobileNr)) - .onAction(this::showConfirmPaymentStartedPopup) - .actionButtonText(Res.get("shared.yes")) - .closeButtonText(Res.get("shared.no")) - .onClose(popup::hide) - .dontShowAgainId(key) - .show(); - } else { - showConfirmPaymentStartedPopup(); - } + if (!model.dataModel.isBootstrappedOrShowPopup()) { + return; + } + + PaymentAccountPayload sellersPaymentAccountPayload = model.dataModel.getSellersPaymentAccountPayload(); + Trade trade = checkNotNull(model.dataModel.getTrade(), "trade must not be null"); + if (sellersPaymentAccountPayload instanceof CashDepositAccountPayload) { + String key = "confirmPaperReceiptSent"; + if (!DevEnv.isDevMode() && DontShowAgainLookup.showAgain(key)) { + Popup popup = new Popup(); + popup.headLine(Res.get("portfolio.pending.step2_buyer.paperReceipt.headline")) + .feedback(Res.get("portfolio.pending.step2_buyer.paperReceipt.msg")) + .onAction(this::showConfirmPaymentStartedPopup) + .closeButtonText(Res.get("shared.no")) + .onClose(popup::hide) + .dontShowAgainId(key) + .show(); } else { showConfirmPaymentStartedPopup(); } + } else if (sellersPaymentAccountPayload instanceof WesternUnionAccountPayload) { + String key = "westernUnionMTCNSent"; + if (!DevEnv.isDevMode() && DontShowAgainLookup.showAgain(key)) { + String email = ((WesternUnionAccountPayload) sellersPaymentAccountPayload).getEmail(); + Popup popup = new Popup(); + popup.headLine(Res.get("portfolio.pending.step2_buyer.westernUnionMTCNInfo.headline")) + .feedback(Res.get("portfolio.pending.step2_buyer.westernUnionMTCNInfo.msg", email)) + .onAction(this::showConfirmPaymentStartedPopup) + .actionButtonText(Res.get("shared.yes")) + .closeButtonText(Res.get("shared.no")) + .onClose(popup::hide) + .dontShowAgainId(key) + .show(); + } else { + showConfirmPaymentStartedPopup(); + } + } else if (sellersPaymentAccountPayload instanceof MoneyGramAccountPayload) { + String key = "moneyGramMTCNSent"; + if (!DevEnv.isDevMode() && DontShowAgainLookup.showAgain(key)) { + String email = ((MoneyGramAccountPayload) sellersPaymentAccountPayload).getEmail(); + Popup popup = new Popup(); + popup.headLine(Res.get("portfolio.pending.step2_buyer.moneyGramMTCNInfo.headline")) + .feedback(Res.get("portfolio.pending.step2_buyer.moneyGramMTCNInfo.msg", email)) + .onAction(this::showConfirmPaymentStartedPopup) + .actionButtonText(Res.get("shared.yes")) + .closeButtonText(Res.get("shared.no")) + .onClose(popup::hide) + .dontShowAgainId(key) + .show(); + } else { + showConfirmPaymentStartedPopup(); + } + } else if (sellersPaymentAccountPayload instanceof HalCashAccountPayload) { + String key = "halCashCodeInfo"; + if (!DevEnv.isDevMode() && DontShowAgainLookup.showAgain(key)) { + String mobileNr = ((HalCashAccountPayload) sellersPaymentAccountPayload).getMobileNr(); + Popup popup = new Popup(); + popup.headLine(Res.get("portfolio.pending.step2_buyer.halCashInfo.headline")) + .feedback(Res.get("portfolio.pending.step2_buyer.halCashInfo.msg", + trade.getShortId(), mobileNr)) + .onAction(this::showConfirmPaymentStartedPopup) + .actionButtonText(Res.get("shared.yes")) + .closeButtonText(Res.get("shared.no")) + .onClose(popup::hide) + .dontShowAgainId(key) + .show(); + } else { + showConfirmPaymentStartedPopup(); + } + } else if (sellersPaymentAccountPayload instanceof AssetsAccountPayload && + checkNotNull(trade.getOffer()).getCurrencyCode().equals("XMR")) { + SetXmrTxKeyWindow setXmrTxKeyWindow = new SetXmrTxKeyWindow(); + setXmrTxKeyWindow + .actionButtonText(Res.get("portfolio.pending.step2_buyer.confirmStart.headline")) + .onAction(() -> { + String txKey = setXmrTxKeyWindow.getTxKey(); + String txHash = setXmrTxKeyWindow.getTxHash(); + if (txKey == null || txHash == null || txKey.isEmpty() || txHash.isEmpty()) { + UserThread.runAfter(this::showProofWarningPopup, Transitions.DEFAULT_DURATION, TimeUnit.MILLISECONDS); + return; + } + + InputValidator.ValidationResult validateTxKey = setXmrTxKeyWindow.getRegexValidator().validate(txKey); + if (!validateTxKey.isValid) { + UserThread.runAfter(() -> new Popup().warning(validateTxKey.errorMessage).show(), + Transitions.DEFAULT_DURATION, TimeUnit.MILLISECONDS); + return; + } + + InputValidator.ValidationResult validateTxHash = setXmrTxKeyWindow.getRegexValidator().validate(txHash); + if (!validateTxHash.isValid) { + UserThread.runAfter(() -> new Popup().warning(validateTxHash.errorMessage).show(), + Transitions.DEFAULT_DURATION, TimeUnit.MILLISECONDS); + return; + } + + trade.setCounterCurrencyExtraData(txKey); + trade.setCounterCurrencyTxId(txHash); + showConfirmPaymentStartedPopup(); + }) + .closeButtonText(Res.get("shared.cancel")) + .onClose(setXmrTxKeyWindow::hide) + .show(); + } else { + showConfirmPaymentStartedPopup(); } } + private void showProofWarningPopup() { + Popup popup = new Popup(); + popup.headLine(Res.get("portfolio.pending.step2_buyer.confirmStart.proof.warningTitle")) + .confirmation(Res.get("portfolio.pending.step2_buyer.confirmStart.proof.noneProvided")) + .width(700) + .actionButtonText(Res.get("portfolio.pending.step2_buyer.confirmStart.warningButton")) + .onAction(this::showConfirmPaymentStartedPopup) + .closeButtonText(Res.get("shared.cancel")) + .onClose(popup::hide) + .show(); + } + private void showConfirmPaymentStartedPopup() { String key = "confirmPaymentStarted"; if (!DevEnv.isDevMode() && DontShowAgainLookup.showAgain(key)) { diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep4View.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep4View.java index 1f3a05ed6e9..e6137a1efa3 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep4View.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/buyer/BuyerStep4View.java @@ -51,6 +51,8 @@ import org.bitcoinj.core.Coin; import org.bitcoinj.core.Transaction; +import com.jfoenix.controls.JFXBadge; + import javafx.scene.control.Button; import javafx.scene.control.Label; import javafx.scene.layout.GridPane; @@ -58,6 +60,7 @@ import javafx.scene.layout.Priority; import javafx.geometry.Insets; +import javafx.geometry.Pos; import org.spongycastle.crypto.params.KeyParameter; @@ -103,9 +106,22 @@ public void deactivate() { protected void addContent() { gridPane.getColumnConstraints().get(1).setHgrow(Priority.SOMETIMES); - addTitledGroupBg(gridPane, gridRow, 5, Res.get("portfolio.pending.step5_buyer.groupTitle"), 0); - addCompactTopLabelTextField(gridPane, gridRow, getBtcTradeAmountLabel(), model.getTradeVolume(), Layout.TWICE_FIRST_ROW_DISTANCE); + TitledGroupBg completedTradeLabel = new TitledGroupBg(); + completedTradeLabel.setText(Res.get("portfolio.pending.step5_buyer.groupTitle")); + + JFXBadge autoConfBadge = new JFXBadge(new Label(""), Pos.BASELINE_RIGHT); + autoConfBadge.setText(Res.get("portfolio.pending.autoConf")); + autoConfBadge.getStyleClass().add("autoconf"); + HBox hBox2 = new HBox(1, completedTradeLabel, autoConfBadge); + GridPane.setMargin(hBox2, new Insets(18, -10, -12, -10)); + gridPane.getChildren().add(hBox2); + GridPane.setRowSpan(hBox2, 5); + if (trade.getAssetTxProofResult() != null && !trade.getAssetTxProofResult().isSuccessState()) { + autoConfBadge.setVisible(false); + } + + addCompactTopLabelTextField(gridPane, gridRow, getBtcTradeAmountLabel(), model.getTradeVolume(), Layout.TWICE_FIRST_ROW_DISTANCE); addCompactTopLabelTextField(gridPane, ++gridRow, getFiatTradeAmountLabel(), model.getFiatVolume()); addCompactTopLabelTextField(gridPane, ++gridRow, Res.get("portfolio.pending.step5_buyer.refunded"), model.getSecurityDeposit()); addCompactTopLabelTextField(gridPane, ++gridRow, Res.get("portfolio.pending.step5_buyer.tradeFee"), model.getTradeFee()); diff --git a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/seller/SellerStep3View.java b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/seller/SellerStep3View.java index 07ab8774ed4..d6c7d944d1d 100644 --- a/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/seller/SellerStep3View.java +++ b/desktop/src/main/java/bisq/desktop/main/portfolio/pendingtrades/steps/seller/SellerStep3View.java @@ -42,6 +42,7 @@ import bisq.core.payment.payload.WesternUnionAccountPayload; import bisq.core.trade.Contract; import bisq.core.trade.Trade; +import bisq.core.trade.autoconf.AssetTxProofResult; import bisq.core.user.DontShowAgainLookup; import bisq.common.Timer; @@ -59,6 +60,8 @@ import org.fxmisc.easybind.EasyBind; import org.fxmisc.easybind.Subscription; +import javafx.beans.value.ChangeListener; + import java.util.Optional; import static bisq.desktop.util.FormBuilder.addButtonBusyAnimationLabelAfterGroup; @@ -73,6 +76,8 @@ public class SellerStep3View extends TradeStepView { private BusyAnimation busyAnimation; private Subscription tradeStatePropertySubscription; private Timer timeoutTimer; + private TextFieldWithCopyIcon autoConfirmStatusField; + private final ChangeListener autoConfirmResultListener; /////////////////////////////////////////////////////////////////////////////////////////// // Constructor, Initialisation @@ -80,6 +85,11 @@ public class SellerStep3View extends TradeStepView { public SellerStep3View(PendingTradesViewModel model) { super(model); + + // we listen for updates on the trade autoConfirmResult field + autoConfirmResultListener = (observable, oldValue, newValue) -> { + autoConfirmStatusField.setText(newValue.getStatusAsDisplayString()); + }; } @Override @@ -139,6 +149,12 @@ public void activate() { } } }); + + // we listen for updates on the trade autoConfirmResult field + if (trade.getAssetTxProofResult() != null && autoConfirmStatusField != null) { + trade.getAssetTxProofResultProperty().addListener(autoConfirmResultListener); + autoConfirmStatusField.setText(trade.getAssetTxProofResult().getStatusAsDisplayString()); + } } @Override @@ -154,6 +170,8 @@ public void deactivate() { if (timeoutTimer != null) timeoutTimer.stop(); + + trade.getAssetTxProofResultProperty().removeListener(autoConfirmResultListener); } /////////////////////////////////////////////////////////////////////////////////////////// @@ -206,6 +224,12 @@ protected void addContent() { GridPane.setRowSpan(titledGroupBg, 4); } + if (isBlockChain && trade.getOffer().getCurrencyCode().equals("XMR")) { + autoConfirmStatusField = addTopLabelTextFieldWithCopyIcon(gridPane, gridRow, 1, + Res.get("portfolio.pending.step3_seller.autoConf.status.label"), + "", Layout.COMPACT_FIRST_ROW_AND_GROUP_DISTANCE).second; + } + TextFieldWithCopyIcon myPaymentDetailsTextField = addCompactTopLabelTextFieldWithCopyIcon(gridPane, ++gridRow, 0, myTitle, myPaymentDetails).second; myPaymentDetailsTextField.setMouseTransparent(false); @@ -216,6 +240,20 @@ protected void addContent() { peersPaymentDetailsTextField.setMouseTransparent(false); peersPaymentDetailsTextField.setTooltip(new Tooltip(peersPaymentDetails)); + String counterCurrencyTxId = trade.getCounterCurrencyTxId(); + String counterCurrencyExtraData = trade.getCounterCurrencyExtraData(); + if (counterCurrencyTxId != null && !counterCurrencyTxId.isEmpty() && + counterCurrencyExtraData != null && !counterCurrencyExtraData.isEmpty()) { + TextFieldWithCopyIcon txHashTextField = addCompactTopLabelTextFieldWithCopyIcon(gridPane, ++gridRow, + 0, Res.get("portfolio.pending.step3_seller.xmrTxHash"), counterCurrencyTxId).second; + txHashTextField.setMouseTransparent(false); + txHashTextField.setTooltip(new Tooltip(myPaymentDetails)); + + TextFieldWithCopyIcon txKeyDetailsTextField = addCompactTopLabelTextFieldWithCopyIcon(gridPane, gridRow, + 1, Res.get("portfolio.pending.step3_seller.xmrTxKey"), counterCurrencyExtraData).second; + txKeyDetailsTextField.setMouseTransparent(false); + txKeyDetailsTextField.setTooltip(new Tooltip(peersPaymentDetails)); + } Tuple4 tuple = addButtonBusyAnimationLabelAfterGroup(gridPane, ++gridRow, Res.get("portfolio.pending.step3_seller.confirmReceipt")); @@ -294,7 +332,7 @@ private void onPaymentReceived() { } } message += Res.get("portfolio.pending.step3_seller.onPaymentReceived.note"); - if (model.isSignWitnessTrade()) { + if (model.dataModel.isSignWitnessTrade()) { message += Res.get("portfolio.pending.step3_seller.onPaymentReceived.signer"); } new Popup() @@ -351,7 +389,7 @@ else if (paymentAccountPayload instanceof F2FAccountPayload) message += Res.get("portfolio.pending.step3_seller.bankCheck", optionalHolderName.get(), part); } - if (model.isSignWitnessTrade()) { + if (model.dataModel.isSignWitnessTrade()) { message += Res.get("portfolio.pending.step3_seller.onPaymentReceived.signer"); } } @@ -364,13 +402,13 @@ else if (paymentAccountPayload instanceof F2FAccountPayload) } private void confirmPaymentReceived() { - // confirmButton.setDisable(true); + log.info("User pressed the [Confirm payment receipt] button for Trade {}", trade.getShortId()); busyAnimation.play(); statusLabel.setText(Res.get("shared.sendingConfirmation")); if (!trade.isPayoutPublished()) trade.setState(Trade.State.SELLER_CONFIRMED_IN_UI_FIAT_PAYMENT_RECEIPT); - model.maybeSignWitness(); + model.dataModel.maybeSignWitness(); model.dataModel.onFiatPaymentReceived(() -> { // In case the first send failed we got the support button displayed. @@ -407,5 +445,3 @@ protected void deactivatePaymentButtons(boolean isDisabled) { confirmButton.setDisable(isDisabled); } } - - diff --git a/desktop/src/main/java/bisq/desktop/main/presentation/SettingsPresentation.java b/desktop/src/main/java/bisq/desktop/main/presentation/SettingsPresentation.java new file mode 100644 index 00000000000..73508b05386 --- /dev/null +++ b/desktop/src/main/java/bisq/desktop/main/presentation/SettingsPresentation.java @@ -0,0 +1,63 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.desktop.main.presentation; + +import bisq.core.user.Preferences; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; + +import javafx.collections.MapChangeListener; + + +@Singleton +public class SettingsPresentation { + + public static final String SETTINGS_NEWS = "settingsNews"; + + private Preferences preferences; + + private final SimpleBooleanProperty showNotification = new SimpleBooleanProperty(false); + + @Inject + public SettingsPresentation(Preferences preferences) { + + this.preferences = preferences; + + preferences.getDontShowAgainMapAsObservable().addListener((MapChangeListener) change -> { + if (change.getKey().equals(SETTINGS_NEWS)) { + showNotification.set(!change.wasAdded()); + } + }); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // Public + /////////////////////////////////////////////////////////////////////////////////////////// + + public BooleanProperty getShowSettingsUpdatesNotification() { + return showNotification; + } + + public void setup() { + showNotification.set(preferences.showAgain(SETTINGS_NEWS)); + } +} diff --git a/desktop/src/main/java/bisq/desktop/main/settings/SettingsView.java b/desktop/src/main/java/bisq/desktop/main/settings/SettingsView.java index 523b74dc5be..119566e467b 100644 --- a/desktop/src/main/java/bisq/desktop/main/settings/SettingsView.java +++ b/desktop/src/main/java/bisq/desktop/main/settings/SettingsView.java @@ -24,11 +24,14 @@ import bisq.desktop.common.view.View; import bisq.desktop.common.view.ViewLoader; import bisq.desktop.main.MainView; +import bisq.desktop.main.overlays.popups.Popup; +import bisq.desktop.main.presentation.SettingsPresentation; import bisq.desktop.main.settings.about.AboutView; import bisq.desktop.main.settings.network.NetworkSettingsView; import bisq.desktop.main.settings.preferences.PreferencesView; import bisq.core.locale.Res; +import bisq.core.user.Preferences; import javax.inject.Inject; @@ -46,13 +49,15 @@ public class SettingsView extends ActivatableView { Tab preferencesTab, networkTab, aboutTab; private final ViewLoader viewLoader; private final Navigation navigation; + private Preferences preferences; private Navigation.Listener navigationListener; private ChangeListener tabChangeListener; @Inject - public SettingsView(CachingViewLoader viewLoader, Navigation navigation) { + public SettingsView(CachingViewLoader viewLoader, Navigation navigation, Preferences preferences) { this.viewLoader = viewLoader; this.navigation = navigation; + this.preferences = preferences; } @Override @@ -82,6 +87,15 @@ else if (newValue == aboutTab) @Override protected void activate() { + // Hide new badge if user saw this section + preferences.dontShowAgain(SettingsPresentation.SETTINGS_NEWS, true); + String key = "autoConfirmInfo"; + new Popup() + .headLine(Res.get("setting.info.headline")) + .backgroundInfo(Res.get("setting.info.msg")) + .dontShowAgainId(key) + .show(); + root.getSelectionModel().selectedItemProperty().addListener(tabChangeListener); navigation.addListener(navigationListener); diff --git a/desktop/src/main/java/bisq/desktop/main/settings/preferences/PreferencesView.java b/desktop/src/main/java/bisq/desktop/main/settings/preferences/PreferencesView.java index b8e182c4a9b..7f80fdc27f5 100644 --- a/desktop/src/main/java/bisq/desktop/main/settings/preferences/PreferencesView.java +++ b/desktop/src/main/java/bisq/desktop/main/settings/preferences/PreferencesView.java @@ -44,10 +44,12 @@ import bisq.core.locale.Res; import bisq.core.locale.TradeCurrency; import bisq.core.provider.fee.FeeService; +import bisq.core.user.AutoConfirmSettings; import bisq.core.user.BlockChainExplorer; import bisq.core.user.Preferences; import bisq.core.util.FormattingUtils; import bisq.core.util.ParsingUtils; +import bisq.core.util.coin.CoinFormatter; import bisq.core.util.validation.IntegerValidator; import bisq.common.UserThread; @@ -93,6 +95,7 @@ import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.concurrent.TimeUnit; import static bisq.desktop.util.FormBuilder.*; @@ -100,9 +103,7 @@ @FxmlView public class PreferencesView extends ActivatableViewAndModel { - - // not supported yet - //private ComboBox btcDenominationComboBox; + private final CoinFormatter formatter; private ComboBox blockChainExplorerComboBox; private ComboBox bsqBlockChainExplorerComboBox; private ComboBox userLanguageComboBox; @@ -110,16 +111,18 @@ public class PreferencesView extends ActivatableViewAndModel preferredTradeCurrencyComboBox; private ToggleButton showOwnOffersInOfferBook, useAnimations, useDarkMode, sortMarketCurrenciesNumerically, - avoidStandbyMode, useCustomFee; + avoidStandbyMode, useCustomFee, autoConfirmXmr; private int gridRow = 0; + private int displayCurrenciesGridRowIndex = 0; private InputTextField transactionFeeInputTextField, ignoreTradersListInputTextField, ignoreDustThresholdInputTextField, - /*referralIdInputTextField,*/ + autoConfRequiredConfirmations, autoConfServiceAddress, autoConfTradeLimit, /*referralIdInputTextField,*/ rpcUserTextField, blockNotifyPortTextField; private ToggleButton isDaoFullNodeToggleButton; private PasswordTextField rpcPwTextField; private TitledGroupBg daoOptionsTitledGroupBg; private ChangeListener transactionFeeFocusedListener; + private ChangeListener autoConfFocusOutListener; private final Preferences preferences; private final FeeService feeService; //private final ReferralIdService referralIdService; @@ -133,7 +136,6 @@ public class PreferencesView extends ActivatableViewAndModel cryptoCurrenciesListView; private ComboBox cryptoCurrenciesComboBox; private Button resetDontShowAgainButton, resyncDaoFromGenesisButton, resyncDaoFromResourcesButton; - // private ListChangeListener displayCurrenciesListChangeListener; private ObservableList blockExplorers; private ObservableList bsqBlockChainExplorers; private ObservableList languageCodes; @@ -145,7 +147,8 @@ public class PreferencesView extends ActivatableViewAndModel tradeCurrencies; private InputTextField deviationInputTextField; private ChangeListener deviationListener, ignoreTradersListListener, ignoreDustThresholdListener, - /*referralIdListener,*/ rpcUserListener, rpcPwListener, blockNotifyPortListener; + /*referralIdListener,*/ rpcUserListener, rpcPwListener, blockNotifyPortListener, + autoConfRequiredConfirmationsListener, autoConfTradeLimitListener, autoConfServiceAddressListener; private ChangeListener deviationFocusedListener; private ChangeListener useCustomFeeCheckboxListener; private ChangeListener transactionFeeChangeListener; @@ -164,11 +167,13 @@ public PreferencesView(PreferencesViewModel model, FilterManager filterManager, DaoFacade daoFacade, Config config, + @Named(FormattingUtils.BTC_FORMATTER_KEY) CoinFormatter formatter, @Named(Config.RPC_USER) String rpcUser, @Named(Config.RPC_PASSWORD) String rpcPassword, @Named(Config.RPC_BLOCK_NOTIFICATION_PORT) int rpcBlockNotificationPort, @Named(Config.STORAGE_DIR) File storageDir) { super(model); + this.formatter = formatter; this.preferences = preferences; this.feeService = feeService; this.assetService = assetService; @@ -196,11 +201,12 @@ public void initialize() { allFiatCurrencies.removeAll(fiatCurrencies); initializeGeneralOptions(); - initializeSeparator(); - initializeDisplayCurrencies(); initializeDisplayOptions(); if (DevEnv.isDaoActivated()) initializeDaoOptions(); + initializeSeparator(); + initializeAutoConfirmOptions(); + initializeDisplayCurrencies(); } @@ -214,6 +220,7 @@ protected void activate() { activateGeneralOptions(); activateDisplayCurrencies(); activateDisplayPreferences(); + activateAutoConfirmPreferences(); if (DevEnv.isDaoActivated()) activateDaoPreferences(); } @@ -223,6 +230,7 @@ protected void deactivate() { deactivateGeneralOptions(); deactivateDisplayCurrencies(); deactivateDisplayPreferences(); + deactivateAutoConfirmPreferences(); if (DevEnv.isDaoActivated()) deactivateDaoPreferences(); } @@ -232,7 +240,7 @@ protected void deactivate() { /////////////////////////////////////////////////////////////////////////////////////////// private void initializeGeneralOptions() { - int titledGroupBgRowSpan = displayStandbyModeFeature ? 8 : 7; + int titledGroupBgRowSpan = displayStandbyModeFeature ? 9 : 8; TitledGroupBg titledGroupBg = addTitledGroupBg(root, gridRow, titledGroupBgRowSpan, Res.get("setting.preferences.general")); GridPane.setColumnSpan(titledGroupBg, 1); @@ -385,17 +393,15 @@ private void initializeSeparator() { } private void initializeDisplayCurrencies() { - int displayCurrenciesGridRowIndex = 0; - TitledGroupBg titledGroupBg = addTitledGroupBg(root, displayCurrenciesGridRowIndex, 9, - Res.get("setting.preferences.currenciesInList")); + TitledGroupBg titledGroupBg = addTitledGroupBg(root, displayCurrenciesGridRowIndex, 8, + Res.get("setting.preferences.currenciesInList"), Layout.GROUP_DISTANCE); GridPane.setColumnIndex(titledGroupBg, 2); GridPane.setColumnSpan(titledGroupBg, 2); - preferredTradeCurrencyComboBox = addComboBox(root, displayCurrenciesGridRowIndex++, Res.get("setting.preferences.prefCurrency"), - Layout.FIRST_ROW_DISTANCE); + Layout.FIRST_ROW_AND_GROUP_DISTANCE); GridPane.setColumnIndex(preferredTradeCurrencyComboBox, 2); preferredTradeCurrencyComboBox.setConverter(new StringConverter<>() { @@ -586,13 +592,14 @@ public CryptoCurrency fromString(String s) { return null; } }); + + displayCurrenciesGridRowIndex += listRowSpan; } private void initializeDisplayOptions() { TitledGroupBg titledGroupBg = addTitledGroupBg(root, ++gridRow, 5, Res.get("setting.preferences.displayOptions"), Layout.GROUP_DISTANCE); GridPane.setColumnSpan(titledGroupBg, 1); -// showOwnOffersInOfferBook = addLabelCheckBox(root, gridRow, Res.get("setting.preferences.showOwnOffers"), Layout.FIRST_ROW_AND_GROUP_DISTANCE); showOwnOffersInOfferBook = addSlideToggleButton(root, gridRow, Res.get("setting.preferences.showOwnOffers"), Layout.FIRST_ROW_AND_GROUP_DISTANCE); useAnimations = addSlideToggleButton(root, ++gridRow, Res.get("setting.preferences.useAnimations")); useDarkMode = addSlideToggleButton(root, ++gridRow, Res.get("setting.preferences.useDarkMode")); @@ -606,7 +613,7 @@ private void initializeDisplayOptions() { } private void initializeDaoOptions() { - daoOptionsTitledGroupBg = addTitledGroupBg(root, ++gridRow, 2, Res.get("setting.preferences.daoOptions"), Layout.GROUP_DISTANCE); + daoOptionsTitledGroupBg = addTitledGroupBg(root, ++gridRow, 3, Res.get("setting.preferences.daoOptions"), Layout.GROUP_DISTANCE); resyncDaoFromResourcesButton = addButton(root, gridRow, Res.get("setting.preferences.dao.resyncFromResources.label"), Layout.TWICE_FIRST_ROW_AND_GROUP_DISTANCE); resyncDaoFromResourcesButton.setMaxWidth(Double.MAX_VALUE); GridPane.setHgrow(resyncDaoFromResourcesButton, Priority.ALWAYS); @@ -643,24 +650,35 @@ private void initializeDaoOptions() { }; } + private void initializeAutoConfirmOptions() { + GridPane subGrid = new GridPane(); + GridPane.setHgrow(subGrid, Priority.ALWAYS); + root.add(subGrid, 2, displayCurrenciesGridRowIndex, 2, 10); + addTitledGroupBg(subGrid, 0, 4, Res.get("setting.preferences.autoConfirmXMR"), 0); + int localRowIndex = 0; + autoConfirmXmr = addSlideToggleButton(subGrid, localRowIndex, Res.get("setting.preferences.autoConfirmEnabled"), Layout.FIRST_ROW_DISTANCE); + autoConfRequiredConfirmations = addInputTextField(subGrid, ++localRowIndex, Res.get("setting.preferences.autoConfirmRequiredConfirmations")); + autoConfTradeLimit = addInputTextField(subGrid, ++localRowIndex, Res.get("setting.preferences.autoConfirmMaxTradeSize")); + autoConfServiceAddress = addInputTextField(subGrid, ++localRowIndex, Res.get("setting.preferences.autoConfirmServiceAddresses")); + GridPane.setHgrow(autoConfServiceAddress, Priority.ALWAYS); + displayCurrenciesGridRowIndex += 4; + + autoConfFocusOutListener = (observable, oldValue, newValue) -> { + if (oldValue && !newValue) { + log.info("Service address focus out, check and re-display default option"); + if (autoConfServiceAddress.getText().length() == 0) { + autoConfServiceAddress.setText(String.join(", ", + preferences.getAutoConfirmSettings().serviceAddresses)); + } + } + }; + } /////////////////////////////////////////////////////////////////////////////////////////// // Activate /////////////////////////////////////////////////////////////////////////////////////////// private void activateGeneralOptions() { - /* List baseCurrencyNetworks = Arrays.asList(BaseCurrencyNetwork.values()); - - // We allow switching to testnet to make it easier for users to test the testnet DAO version - // We only show mainnet and dao testnet. Testnet is rather un-usable for application testing when asics - // create 10000s of blocks per day. - baseCurrencyNetworks = baseCurrencyNetworks.stream() - .filter(e -> e.isMainnet() || e.isDaoBetaNet() || e.isDaoRegTest()) - .collect(Collectors.toList()); - selectBaseCurrencyNetworkComboBox.setItems(FXCollections.observableArrayList(baseCurrencyNetworks)); - selectBaseCurrencyNetworkComboBox.setOnAction(e -> onSelectNetwork()); - selectBaseCurrencyNetworkComboBox.getSelectionModel().select(BaseCurrencyNetwork.CURRENT_VALUE);*/ - boolean useCustomWithdrawalTxFee = preferences.isUseCustomWithdrawalTxFee(); useCustomFee.setSelected(useCustomWithdrawalTxFee); @@ -705,17 +723,6 @@ public String fromString(String string) { .show(); } } - // Should we apply the changed currency immediately to the language list? - // If so and the user selects a unknown language he might get lost and it is hard to find - // again the language he understands - /* if (selectedItem != null && !selectedItem.equals(preferences.getUserLanguage())) { - preferences.setUserLanguage(selectedItem); - UserThread.execute(() -> { - languageCodes.clear(); - languageCodes.addAll(LanguageUtil.getAllLanguageCodes()); - userLanguageComboBox.getSelectionModel().select(preferences.getUserLanguage()); - }); - }*/ }); userCountryComboBox.setItems(countries); @@ -839,9 +846,6 @@ private void activateDisplayPreferences() { useDarkMode.setSelected(preferences.getCssTheme() == 1); useDarkMode.setOnAction(e -> preferences.setCssTheme(useDarkMode.isSelected())); - // useStickyMarketPriceCheckBox.setSelected(preferences.isUseStickyMarketPrice()); - // useStickyMarketPriceCheckBox.setOnAction(e -> preferences.setUseStickyMarketPrice(useStickyMarketPriceCheckBox.isSelected())); - sortMarketCurrenciesNumerically.setSelected(preferences.isSortMarketCurrenciesNumerically()); sortMarketCurrenciesNumerically.setOnAction(e -> preferences.setSortMarketCurrenciesNumerically(sortMarketCurrenciesNumerically.isSelected())); @@ -921,9 +925,65 @@ private void activateDaoPreferences() { blockNotifyPortTextField.textProperty().addListener(blockNotifyPortListener); } + private void activateAutoConfirmPreferences() { + AutoConfirmSettings init = preferences.getAutoConfirmSettings(); + autoConfirmXmr.setSelected(init.enabled); + autoConfRequiredConfirmations.setText(String.valueOf(init.requiredConfirmations)); + autoConfTradeLimit.setText(formatter.formatCoin(Coin.valueOf(init.tradeLimit))); + autoConfServiceAddress.setText(String.join(", ", init.serviceAddresses)); + + autoConfirmXmr.setOnAction(e -> { + boolean enabled = autoConfirmXmr.isSelected(); + AutoConfirmSettings x = preferences.getAutoConfirmSettings(); + preferences.setAutoConfirmSettings( + new AutoConfirmSettings(enabled, x.requiredConfirmations, x.tradeLimit, x.serviceAddresses, x.currencyCode)); + }); + + autoConfServiceAddress.setValidator(GUIUtil.addressRegexValidator()); + autoConfServiceAddress.setErrorMessage(Res.get("validation.invalidAddressList")); + autoConfServiceAddressListener = (observable, oldValue, newValue) -> { + if (GUIUtil.addressRegexValidator().validate(newValue).isValid && !newValue.equals(oldValue)) { + List serviceAddresses = Arrays.asList(StringUtils.deleteWhitespace(newValue).split(",")); + // revert to default service providers when user empties the list + if (serviceAddresses.size() == 1 && serviceAddresses.get(0).length() == 0) + serviceAddresses = Preferences.getDefaultXmrProofProviders(); + preferences.setAutoConfServiceAddresses(serviceAddresses); + } + }; + + IntegerValidator validator = new IntegerValidator(); + validator.setMinValue(1); validator.setMaxValue(10000); + autoConfRequiredConfirmations.setValidator(validator); + autoConfRequiredConfirmationsListener = (observable, oldValue, newValue) -> { + try { + int value = Integer.parseInt(newValue); + if (!newValue.equals(oldValue)) { + AutoConfirmSettings x = preferences.getAutoConfirmSettings(); + preferences.setAutoConfirmSettings( + new AutoConfirmSettings(x.enabled, value, x.tradeLimit, x.serviceAddresses, x.currencyCode)); + } + } catch (Throwable ignore) { + } + }; + autoConfTradeLimitListener = (observable, oldValue, newValue) -> { + try { + Coin amountAsCoin = ParsingUtils.parseToCoin(newValue, formatter); + AutoConfirmSettings x = preferences.getAutoConfirmSettings(); + preferences.setAutoConfirmSettings( + new AutoConfirmSettings(x.enabled, x.requiredConfirmations, amountAsCoin.value, x.serviceAddresses, x.currencyCode)); + } catch (Throwable ignore) { + } + }; + + autoConfRequiredConfirmations.textProperty().addListener(autoConfRequiredConfirmationsListener); + autoConfTradeLimit.textProperty().addListener(autoConfTradeLimitListener); + autoConfServiceAddress.textProperty().addListener(autoConfServiceAddressListener); + autoConfServiceAddress.focusedProperty().addListener(autoConfFocusOutListener); + } + private void updateDaoFields() { boolean isDaoFullNode = isDaoFullNodeToggleButton.isSelected(); - GridPane.setRowSpan(daoOptionsTitledGroupBg, isDaoFullNode ? 5 : 2); + GridPane.setRowSpan(daoOptionsTitledGroupBg, isDaoFullNode ? 6 : 3); rpcUserTextField.setVisible(isDaoFullNode); rpcUserTextField.setManaged(isDaoFullNode); rpcPwTextField.setVisible(isDaoFullNode); @@ -943,22 +1003,6 @@ private void updateDaoFields() { blockNotifyPortTextField.setDisable(daoOptionsSet); } - /* private void onSelectNetwork() { - if (selectBaseCurrencyNetworkComboBox.getSelectionModel().getSelectedItem() != BaseCurrencyNetwork.CURRENT_VALUE) - selectNetwork(); - } - - private void selectNetwork() { - new Popup().warning(Res.get("settings.net.needRestart")) - .onAction(() -> { - bisqEnvironment.saveBaseCryptoNetwork(selectBaseCurrencyNetworkComboBox.getSelectionModel().getSelectedItem()); - UserThread.runAfter(BisqApp.getShutDownHandler(), 500, TimeUnit.MILLISECONDS); - }) - .actionButtonText(Res.get("shared.shutDown")) - .closeButtonText(Res.get("shared.cancel")) - .onClose(() -> selectBaseCurrencyNetworkComboBox.getSelectionModel().select(BaseCurrencyNetwork.CURRENT_VALUE)) - .show(); - }*/ /////////////////////////////////////////////////////////////////////////////////////////// // Deactivate @@ -1005,4 +1049,12 @@ private void deactivateDaoPreferences() { rpcPwTextField.textProperty().removeListener(rpcPwListener); blockNotifyPortTextField.textProperty().removeListener(blockNotifyPortListener); } + + private void deactivateAutoConfirmPreferences() { + autoConfirmXmr.setOnAction(null); + autoConfRequiredConfirmations.textProperty().removeListener(autoConfRequiredConfirmationsListener); + autoConfTradeLimit.textProperty().removeListener(autoConfTradeLimitListener); + autoConfServiceAddress.textProperty().removeListener(autoConfServiceAddressListener); + autoConfServiceAddress.focusedProperty().removeListener(autoConfFocusOutListener); + } } diff --git a/proto/src/main/proto/pb.proto b/proto/src/main/proto/pb.proto index b581cf4009f..9774e90b10e 100644 --- a/proto/src/main/proto/pb.proto +++ b/proto/src/main/proto/pb.proto @@ -293,6 +293,7 @@ message CounterCurrencyTransferStartedMessage { bytes buyer_signature = 4; string counter_currency_tx_id = 5; string uid = 6; + string counter_currency_extra_data = 7; } message FinalizePayoutTxRequest { @@ -638,6 +639,7 @@ message Filter { repeated string refundAgents = 18; repeated string bannedSignerPubKeys = 19; repeated string btc_fee_receiver_addresses = 20; + bool disable_auto_conf = 21; } // not used anymore from v0.6 on. But leave it for receiving TradeStatistics objects from older @@ -1390,6 +1392,8 @@ message Trade { PubKeyRing refund_agent_pub_key_ring = 34; RefundResultState refund_result_state = 35; int64 last_refresh_request_date = 36; + string counter_currency_extra_data = 37; + AutoConfirmResult asset_tx_proof_result = 38; } message BuyerAsMakerTrade { @@ -1550,6 +1554,15 @@ message PreferencesPayload { int32 block_notify_port = 53; int32 css_theme = 54; bool tac_accepted_v120 = 55; + repeated AutoConfirmSettings auto_confirm_settings = 56; +} + +message AutoConfirmSettings { + bool enabled = 1; + int32 required_confirmations = 2; + int64 trade_limit = 3; + repeated string service_addresses = 4; + string currency_code = 5; } /////////////////////////////////////////////////////////////////////////////////////////// @@ -1574,6 +1587,10 @@ message UserPayload { RefundAgent registered_refund_agent = 15; } +message AutoConfirmResult { + string stateName = 1; // name of state enum +} + /////////////////////////////////////////////////////////////////////////////////////////// // DAO ///////////////////////////////////////////////////////////////////////////////////////////