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..d5150353c6e --- /dev/null +++ b/assets/src/main/java/bisq/asset/CryptoNoteUtils.java @@ -0,0 +1,269 @@ +/* + * 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 getRawSpendKeyAndViewKey(String address) throws CryptoNoteUtils.CryptoNoteException { + try { + // See https://monerodocs.org/public-address/standard-address/ + byte[] decoded = CryptoNoteUtils.MoneroBase58.decode(address); + // Standard addresses are of length 69 and addresses with integrated payment ID of length 77. + + if (decoded.length <= 65) { + throw new CryptoNoteUtils.CryptoNoteException("The address we received is too short. address=" + address); + } + + // If the length is not as expected but still can be truncated we log an error and continue. + if (decoded.length != 69 && decoded.length != 77) { + System.out.println("The address we received is not in the expected format. address=" + address); + } + + // We remove the network type byte, the checksum (4 bytes) and optionally the payment ID (8 bytes if present) + // So extract the 64 bytes after the first byte, which are the 32 byte public spend key + the 32 byte public view key + byte[] slice = Arrays.copyOfRange(decoded, 1, 65); + return Utils.HEX.encode(slice); + } catch (CryptoNoteUtils.CryptoNoteException e) { + throw new CryptoNoteUtils.CryptoNoteException(e); + } + } + + public static class CryptoNoteException extends Exception { + CryptoNoteException(String msg) { + super(msg); + } + + public CryptoNoteException(CryptoNoteException exception) { + super(exception); + } + } + + 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 CryptoNoteException { + + 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 CryptoNoteException("invalid character " + character); + } + result = result.add(order.multiply(BigInteger.valueOf(digit))); + if (result.compareTo(UINT64_MAX) > 0) { + throw new CryptoNoteException("64-bit unsigned integer overflow " + result.toString()); + } + } + + BigInteger maxCapacity = BigInteger.ONE.shiftLeft(8 * decodedLength); + if (result.compareTo(maxCapacity) >= 0) { + throw new CryptoNoteException("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 CryptoNoteException { + 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 CryptoNoteException { + byte[] decoded = decode(address); + + int checksumSize = 4; + if (decoded.length < checksumSize) { + throw new CryptoNoteException("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 CryptoNoteException(String.format("invalid checksum %08X, expected %08X", checksum, expected)); + } + + return prefix; + } + } +} + diff --git a/assets/src/main/java/bisq/asset/coins/LiquidBitcoin.java b/assets/src/main/java/bisq/asset/coins/LiquidBitcoin.java index 3c0be367b06..a84659db901 100644 --- a/assets/src/main/java/bisq/asset/coins/LiquidBitcoin.java +++ b/assets/src/main/java/bisq/asset/coins/LiquidBitcoin.java @@ -20,7 +20,6 @@ import bisq.asset.AltCoinAccountDisclaimer; import bisq.asset.Coin; import bisq.asset.LiquidBitcoinAddressValidator; -import bisq.asset.RegexAddressValidator; @AltCoinAccountDisclaimer("account.altcoin.popup.liquidbitcoin.msg") public class LiquidBitcoin extends Coin { diff --git a/cli/src/main/java/bisq/cli/CliMain.java b/cli/src/main/java/bisq/cli/CliMain.java index 46dfee10726..f229fcd4538 100644 --- a/cli/src/main/java/bisq/cli/CliMain.java +++ b/cli/src/main/java/bisq/cli/CliMain.java @@ -23,17 +23,12 @@ import bisq.proto.grpc.GetFundingAddressesRequest; import bisq.proto.grpc.GetOffersRequest; import bisq.proto.grpc.GetPaymentAccountsRequest; -import bisq.proto.grpc.GetVersionGrpc; import bisq.proto.grpc.GetVersionRequest; import bisq.proto.grpc.LockWalletRequest; -import bisq.proto.grpc.OffersGrpc; -import bisq.proto.grpc.PaymentAccountsGrpc; import bisq.proto.grpc.RemoveWalletPasswordRequest; import bisq.proto.grpc.SetWalletPasswordRequest; import bisq.proto.grpc.UnlockWalletRequest; -import bisq.proto.grpc.WalletsGrpc; -import io.grpc.ManagedChannelBuilder; import io.grpc.StatusRuntimeException; import joptsimple.OptionParser; @@ -43,7 +38,6 @@ import java.io.PrintStream; import java.util.List; -import java.util.concurrent.TimeUnit; import lombok.extern.slf4j.Slf4j; @@ -136,7 +130,7 @@ public static void run(String[] args) { GrpcStubs grpcStubs = new GrpcStubs(host, port, password); var versionService = grpcStubs.versionService; var offersService = grpcStubs.offersService; - var paymentAccountsService = grpcStubs.paymentAccountsService; + var paymentAccountsService = grpcStubs.paymentAccountsService; var walletsService = grpcStubs.walletsService; try { diff --git a/common/src/main/java/bisq/common/proto/ProtoUtil.java b/common/src/main/java/bisq/common/proto/ProtoUtil.java index e605d12e330..5f79abe02a2 100644 --- a/common/src/main/java/bisq/common/proto/ProtoUtil.java +++ b/common/src/main/java/bisq/common/proto/ProtoUtil.java @@ -69,18 +69,23 @@ 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); + log.debug("Invalid value for enum " + enumType.getSimpleName() + ": " + name); result = Enums.getIfPresent(enumType, "UNDEFINED").orNull(); - log.error("We try to lookup for an enum entry with name 'UNDEFINED' and use that if available, " + + log.debug("We try to lookup for an enum entry with name 'UNDEFINED' and use that if available, " + "otherwise the enum is null. enum={}", result); return result; } 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(); @@ -95,7 +100,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 f0256e45d5b..4747e3feb48 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/app/BisqSetup.java b/core/src/main/java/bisq/core/app/BisqSetup.java index 67fd39bc291..9440a98559b 100644 --- a/core/src/main/java/bisq/core/app/BisqSetup.java +++ b/core/src/main/java/bisq/core/app/BisqSetup.java @@ -808,13 +808,13 @@ private void initDomainServices() { filterManager.addListener(filter -> { if (filter != null && filterWarningHandler != null) { if (filter.getSeedNodes() != null && !filter.getSeedNodes().isEmpty()) { - log.warn(Res.get("popup.warning.nodeBanned", Res.get("popup.warning.seed"))); + log.info(Res.get("popup.warning.nodeBanned", Res.get("popup.warning.seed"))); // Let's keep that more silent. Might be used in case a node is unstable and we don't want to confuse users. // filterWarningHandler.accept(Res.get("popup.warning.nodeBanned", Res.get("popup.warning.seed"))); } if (filter.getPriceRelayNodes() != null && !filter.getPriceRelayNodes().isEmpty()) { - log.warn(Res.get("popup.warning.nodeBanned", Res.get("popup.warning.priceRelay"))); + log.info(Res.get("popup.warning.nodeBanned", Res.get("popup.warning.priceRelay"))); // Let's keep that more silent. Might be used in case a node is unstable and we don't want to confuse users. // filterWarningHandler.accept(Res.get("popup.warning.nodeBanned", Res.get("popup.warning.priceRelay"))); } diff --git a/core/src/main/java/bisq/core/app/misc/ExecutableForAppWithP2p.java b/core/src/main/java/bisq/core/app/misc/ExecutableForAppWithP2p.java index b13c5e9e6d0..b31875323aa 100644 --- a/core/src/main/java/bisq/core/app/misc/ExecutableForAppWithP2p.java +++ b/core/src/main/java/bisq/core/app/misc/ExecutableForAppWithP2p.java @@ -29,6 +29,7 @@ import bisq.network.p2p.seed.SeedNodeRepository; import bisq.common.UserThread; +import bisq.common.app.DevEnv; import bisq.common.config.Config; import bisq.common.handlers.ResultHandler; import bisq.common.setup.GracefulShutDownHandler; @@ -116,6 +117,10 @@ public void gracefulShutDown(ResultHandler resultHandler) { } public void startShutDownInterval(GracefulShutDownHandler gracefulShutDownHandler) { + if (DevEnv.isDevMode() || injector.getInstance(Config.class).useLocalhostForP2P) { + return; + } + List seedNodeAddresses = new ArrayList<>(injector.getInstance(SeedNodeRepository.class).getSeedNodeAddresses()); seedNodeAddresses.sort(Comparator.comparing(NodeAddress::getFullAddress)); diff --git a/core/src/main/java/bisq/core/filter/Filter.java b/core/src/main/java/bisq/core/filter/Filter.java index d2f89824321..a22ecda78af 100644 --- a/core/src/main/java/bisq/core/filter/Filter.java +++ b/core/src/main/java/bisq/core/filter/Filter.java @@ -87,6 +87,9 @@ public final class Filter implements ProtectedStoragePayload, ExpirablePayload { private transient PublicKey ownerPubKey; + // added at v1.3.8 + private final boolean disableAutoConf; + // After we have created the signature from the filter data we clone it and apply the signature static Filter cloneWithSig(Filter filter, String signatureAsBase64) { return new Filter(filter.getBannedOfferIds(), @@ -111,7 +114,8 @@ static Filter cloneWithSig(Filter filter, String signatureAsBase64) { filter.getExtraDataMap(), signatureAsBase64, filter.getSignerPubKeyAsHex(), - filter.getBannedPrivilegedDevPubKeys()); + filter.getBannedPrivilegedDevPubKeys(), + filter.isDisableAutoConf()); } // Used for signature verification as we created the sig without the signatureAsBase64 field we set it to null again @@ -138,7 +142,8 @@ static Filter cloneWithoutSig(Filter filter) { filter.getExtraDataMap(), null, filter.getSignerPubKeyAsHex(), - filter.getBannedPrivilegedDevPubKeys()); + filter.getBannedPrivilegedDevPubKeys(), + filter.isDisableAutoConf()); } public Filter(List bannedOfferIds, @@ -160,7 +165,8 @@ public Filter(List bannedOfferIds, List btcFeeReceiverAddresses, PublicKey ownerPubKey, String signerPubKeyAsHex, - List bannedPrivilegedDevPubKeys) { + List bannedPrivilegedDevPubKeys, + boolean disableAutoConf) { this(bannedOfferIds, bannedNodeAddress, bannedPaymentAccounts, @@ -183,7 +189,8 @@ public Filter(List bannedOfferIds, null, null, signerPubKeyAsHex, - bannedPrivilegedDevPubKeys); + bannedPrivilegedDevPubKeys, + disableAutoConf); } @@ -214,7 +221,8 @@ public Filter(List bannedOfferIds, @Nullable Map extraDataMap, @Nullable String signatureAsBase64, String signerPubKeyAsHex, - List bannedPrivilegedDevPubKeys) { + List bannedPrivilegedDevPubKeys, + boolean disableAutoConf) { this.bannedOfferIds = bannedOfferIds; this.bannedNodeAddress = bannedNodeAddress; this.bannedPaymentAccounts = bannedPaymentAccounts; @@ -238,6 +246,7 @@ public Filter(List bannedOfferIds, this.signatureAsBase64 = signatureAsBase64; this.signerPubKeyAsHex = signerPubKeyAsHex; this.bannedPrivilegedDevPubKeys = bannedPrivilegedDevPubKeys; + this.disableAutoConf = disableAutoConf; // ownerPubKeyBytes can be null when called from tests if (ownerPubKeyBytes != null) { @@ -273,7 +282,8 @@ public protobuf.StoragePayload toProtoMessage() { .setOwnerPubKeyBytes(ByteString.copyFrom(ownerPubKeyBytes)) .setSignerPubKeyAsHex(signerPubKeyAsHex) .setCreationDate(creationDate) - .addAllBannedPrivilegedDevPubKeys(bannedPrivilegedDevPubKeys); + .addAllBannedPrivilegedDevPubKeys(bannedPrivilegedDevPubKeys) + .setDisableAutoConf(disableAutoConf); Optional.ofNullable(signatureAsBase64).ifPresent(builder::setSignatureAsBase64); Optional.ofNullable(extraDataMap).ifPresent(builder::putAllExtraData); @@ -309,7 +319,8 @@ public static Filter fromProto(protobuf.Filter proto) { CollectionUtils.isEmpty(proto.getExtraDataMap()) ? null : proto.getExtraDataMap(), proto.getSignatureAsBase64(), proto.getSignerPubKeyAsHex(), - ProtoUtil.protocolStringListToList(proto.getBannedPrivilegedDevPubKeysList()) + ProtoUtil.protocolStringListToList(proto.getBannedPrivilegedDevPubKeysList()), + proto.getDisableAutoConf() ); } @@ -349,6 +360,7 @@ public String toString() { ",\n btcFeeReceiverAddresses=" + btcFeeReceiverAddresses + ",\n creationDate=" + creationDate + ",\n extraDataMap=" + extraDataMap + + ",\n disableAutoConf=" + disableAutoConf + "\n}"; } } diff --git a/core/src/main/java/bisq/core/provider/PriceNodeHttpClient.java b/core/src/main/java/bisq/core/provider/PriceNodeHttpClient.java index 1a9510f4677..fbe396f1d8a 100644 --- a/core/src/main/java/bisq/core/provider/PriceNodeHttpClient.java +++ b/core/src/main/java/bisq/core/provider/PriceNodeHttpClient.java @@ -18,13 +18,13 @@ package bisq.core.provider; import bisq.network.Socks5ProxyProvider; -import bisq.network.http.HttpClient; +import bisq.network.http.HttpClientImpl; import javax.inject.Inject; import javax.annotation.Nullable; -public class PriceNodeHttpClient extends HttpClient { +public class PriceNodeHttpClient extends HttpClientImpl { @Inject public PriceNodeHttpClient(@Nullable Socks5ProxyProvider socks5ProxyProvider) { super(socks5ProxyProvider); diff --git a/core/src/main/java/bisq/core/trade/Trade.java b/core/src/main/java/bisq/core/trade/Trade.java index 6071e59d504..a00fe2dca89 100644 --- a/core/src/main/java/bisq/core/trade/Trade.java +++ b/core/src/main/java/bisq/core/trade/Trade.java @@ -42,6 +42,7 @@ import bisq.core.trade.protocol.TradeProtocol; import bisq.core.trade.statistics.ReferralIdService; import bisq.core.trade.statistics.TradeStatisticsManager; +import bisq.core.trade.txproof.AssetTxProofResult; import bisq.core.user.User; import bisq.network.p2p.DecryptedMessageWithPubKey; @@ -66,9 +67,11 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import javafx.beans.property.IntegerProperty; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyStringProperty; +import javafx.beans.property.SimpleIntegerProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; @@ -158,6 +161,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 +432,25 @@ 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 + // Generic tx proof result. We persist name if AssetTxProofResult enum. Other fields in the enum are not persisted + // as they are not very relevant as historical data (e.g. number of confirmations) + @Nullable + @Getter + private AssetTxProofResult assetTxProofResult; + // ObjectProperty with AssetTxProofResult does not notify changeListeners. Probably because AssetTxProofResult is + // an enum and enum does not support EqualsAndHashCode. Alternatively we could add a addListener and removeListener + // method and a listener interface, but the IntegerProperty seems to be less boilerplate. + @Getter + transient final private IntegerProperty assetTxProofResultUpdateProperty = new SimpleIntegerProperty(); + + /////////////////////////////////////////////////////////////////////////////////////////// // Constructor, initialization /////////////////////////////////////////////////////////////////////////////////////////// @@ -538,6 +561,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.name())); + return builder.build(); } @@ -570,6 +596,14 @@ 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())); + + AssetTxProofResult persistedAssetTxProofResult = ProtoUtil.enumFromProto(AssetTxProofResult.class, proto.getAssetTxProofResult()); + // We do not want to show the user the last pending state when he starts up the app again, so we clear it. + if (persistedAssetTxProofResult == AssetTxProofResult.PENDING) { + persistedAssetTxProofResult = null; + } + trade.setAssetTxProofResult(persistedAssetTxProofResult); trade.chatMessages.addAll(proto.getChatMessageList().stream() .map(ChatMessage::fromPayloadProto) @@ -731,6 +765,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 +887,12 @@ public void setErrorMessage(String errorMessage) { errorMessageProperty.set(errorMessage); } + public void setAssetTxProofResult(@Nullable AssetTxProofResult assetTxProofResult) { + this.assetTxProofResult = assetTxProofResult; + assetTxProofResultUpdateProperty.set(assetTxProofResultUpdateProperty.get() + 1); + persist(); + } + /////////////////////////////////////////////////////////////////////////////////////////// // Getter @@ -1064,19 +1118,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 +1201,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..8d3c2d738ca 100644 --- a/core/src/main/java/bisq/core/trade/TradeManager.java +++ b/core/src/main/java/bisq/core/trade/TradeManager.java @@ -46,6 +46,8 @@ import bisq.core.trade.messages.TradeMessage; import bisq.core.trade.statistics.ReferralIdService; import bisq.core.trade.statistics.TradeStatisticsManager; +import bisq.core.trade.txproof.AssetTxProofResult; +import bisq.core.trade.txproof.xmr.XmrTxProofService; import bisq.core.user.User; import bisq.core.util.Validator; @@ -57,6 +59,7 @@ import bisq.network.p2p.SendMailboxMessageListener; import bisq.common.ClockWatcher; +import bisq.common.UserThread; import bisq.common.config.Config; import bisq.common.crypto.KeyRing; import bisq.common.handlers.ErrorMessageHandler; @@ -65,8 +68,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 +94,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; @@ -111,6 +111,8 @@ import javax.annotation.Nullable; +import static com.google.common.base.Preconditions.checkArgument; + public class TradeManager implements PersistedDataHost { private static final Logger log = LoggerFactory.getLogger(TradeManager.class); @@ -129,6 +131,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 +174,7 @@ public TradeManager(User user, TradeStatisticsManager tradeStatisticsManager, ReferralIdService referralIdService, AccountAgeWitnessService accountAgeWitnessService, + XmrTxProofService xmrTxProofService, ArbitratorManager arbitratorManager, MediatorManager mediatorManager, RefundAgentManager refundAgentManager, @@ -192,6 +197,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; @@ -280,6 +286,7 @@ public void onUpdatedDataReceived() { } public void shutDown() { + xmrTxProofService.shutDown(); } private void initPendingTrades() { @@ -322,6 +329,13 @@ private void initPendingTrades() { addTradeToFailedTradesList.add(trade); } } + + if (trade.getState() == Trade.State.SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG) { + // This state can be only appear at a SellerTrade + checkArgument(trade instanceof SellerTrade, "Trade must be instance of SellerTrade"); + // We delay a bit as at startup lots of stuff is happening + UserThread.runAfter(() -> maybeStartXmrTxProofServices((SellerTrade) trade), 1); + } } ); @@ -346,6 +360,37 @@ private void initPendingTrades() { pendingTradesInitialized.set(true); } + public void maybeStartXmrTxProofServices(SellerTrade sellerTrade) { + xmrTxProofService.maybeStartRequests(sellerTrade, tradableList.getList(), + assetTxProofResult -> { + if (assetTxProofResult == AssetTxProofResult.COMPLETED) { + log.info("###########################################################################################"); + log.info("We auto-confirm trade {} as our all our services for the tx proof completed successfully", sellerTrade.getShortId()); + log.info("###########################################################################################"); + autoConfirmFiatPaymentReceived(sellerTrade); + } + }, + (errorMessage, throwable) -> { + log.error(errorMessage); + }); + } + + private void autoConfirmFiatPaymentReceived(SellerTrade sellerTrade) { + onFiatPaymentReceived(sellerTrade, + () -> { + }, errorMessage -> { + }); + } + + public void onFiatPaymentReceived(SellerTrade sellerTrade, + ResultHandler resultHandler, + ErrorMessageHandler errorMessageHandler) { + sellerTrade.onFiatPaymentReceived(resultHandler, errorMessageHandler); + + //TODO move to trade protocol task + accountAgeWitnessService.maybeSignWitness(sellerTrade); + } + private void initPendingTrade(Trade trade) { initTrade(trade, trade.getProcessModel().isUseSavingsWallet(), trade.getProcessModel().getFundsNeededForTradeAsLong()); diff --git a/core/src/main/java/bisq/core/trade/TradeModule.java b/core/src/main/java/bisq/core/trade/TradeModule.java index a190a19f20b..422ec13a83c 100644 --- a/core/src/main/java/bisq/core/trade/TradeModule.java +++ b/core/src/main/java/bisq/core/trade/TradeModule.java @@ -26,6 +26,8 @@ import bisq.core.trade.statistics.ReferralIdService; import bisq.core.trade.statistics.TradeStatistics2StorageService; import bisq.core.trade.statistics.TradeStatisticsManager; +import bisq.core.trade.txproof.AssetTxProofHttpClient; +import bisq.core.trade.txproof.xmr.XmrTxProofHttpClient; import bisq.common.app.AppModule; import bisq.common.config.Config; @@ -55,6 +57,9 @@ protected void configure() { bind(SignedWitnessService.class).in(Singleton.class); bind(SignedWitnessStorageService.class).in(Singleton.class); bind(ReferralIdService.class).in(Singleton.class); + + bind(AssetTxProofHttpClient.class).to(XmrTxProofHttpClient.class); + bindConstant().annotatedWith(named(DUMP_STATISTICS)).to(config.dumpStatistics); bindConstant().annotatedWith(named(DUMP_DELAYED_PAYOUT_TXS)).to(config.dumpDelayedPayoutTxs); bindConstant().annotatedWith(named(ALLOW_FAULTY_DELAYED_TXS)).to(config.allowFaultyDelayedTxs); 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..29ca5bc63ea 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 @@ -17,6 +17,7 @@ package bisq.core.trade.protocol.tasks.seller; +import bisq.core.trade.SellerTrade; import bisq.core.trade.Trade; import bisq.core.trade.messages.CounterCurrencyTransferStartedMessage; import bisq.core.trade.protocol.tasks.TradeTask; @@ -26,6 +27,7 @@ import lombok.extern.slf4j.Slf4j; +import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; @Slf4j @@ -49,7 +51,21 @@ 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); + } + + checkArgument(trade instanceof SellerTrade, "Trade must be instance of SellerTrade"); + // We return early in the service if its not XMR. We prefer to not have additional checks outside... + processModel.getTradeManager().maybeStartXmrTxProofServices((SellerTrade) trade); + processModel.removeMailboxMessageAfterProcessing(trade); trade.setState(Trade.State.SELLER_RECEIVED_FIAT_PAYMENT_INITIATED_MSG); diff --git a/core/src/main/java/bisq/core/trade/txproof/AssetTxProofHttpClient.java b/core/src/main/java/bisq/core/trade/txproof/AssetTxProofHttpClient.java new file mode 100644 index 00000000000..3700ab21e7f --- /dev/null +++ b/core/src/main/java/bisq/core/trade/txproof/AssetTxProofHttpClient.java @@ -0,0 +1,23 @@ +/* + * 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.txproof; + +import bisq.network.http.HttpClient; + +public interface AssetTxProofHttpClient extends HttpClient { +} diff --git a/core/src/main/java/bisq/core/trade/txproof/AssetTxProofModel.java b/core/src/main/java/bisq/core/trade/txproof/AssetTxProofModel.java new file mode 100644 index 00000000000..a153a99b289 --- /dev/null +++ b/core/src/main/java/bisq/core/trade/txproof/AssetTxProofModel.java @@ -0,0 +1,21 @@ +/* + * 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.txproof; + +public interface AssetTxProofModel { +} diff --git a/core/src/main/java/bisq/core/trade/txproof/AssetTxProofParser.java b/core/src/main/java/bisq/core/trade/txproof/AssetTxProofParser.java new file mode 100644 index 00000000000..ae0a803ff3a --- /dev/null +++ b/core/src/main/java/bisq/core/trade/txproof/AssetTxProofParser.java @@ -0,0 +1,22 @@ +/* + * 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.txproof; + +public interface AssetTxProofParser { + R parse(T model, String jsonTxt); +} diff --git a/core/src/main/java/bisq/core/trade/txproof/AssetTxProofRequest.java b/core/src/main/java/bisq/core/trade/txproof/AssetTxProofRequest.java new file mode 100644 index 00000000000..44835749b0d --- /dev/null +++ b/core/src/main/java/bisq/core/trade/txproof/AssetTxProofRequest.java @@ -0,0 +1,31 @@ +/* + * 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.txproof; + +import bisq.common.handlers.FaultHandler; + +import java.util.function.Consumer; + +public interface AssetTxProofRequest { + interface Result { + } + + void requestFromService(Consumer resultHandler, FaultHandler faultHandler); + + void terminate(); +} diff --git a/core/src/main/java/bisq/core/trade/txproof/AssetTxProofRequestsPerTrade.java b/core/src/main/java/bisq/core/trade/txproof/AssetTxProofRequestsPerTrade.java new file mode 100644 index 00000000000..8d1e969dbb6 --- /dev/null +++ b/core/src/main/java/bisq/core/trade/txproof/AssetTxProofRequestsPerTrade.java @@ -0,0 +1,28 @@ +/* + * 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.txproof; + +import bisq.common.handlers.FaultHandler; + +import java.util.function.Consumer; + +public interface AssetTxProofRequestsPerTrade { + void requestFromAllServices(Consumer resultHandler, FaultHandler faultHandler); + + void terminate(); +} diff --git a/core/src/main/java/bisq/core/trade/txproof/AssetTxProofResult.java b/core/src/main/java/bisq/core/trade/txproof/AssetTxProofResult.java new file mode 100644 index 00000000000..0fc61d7cfd0 --- /dev/null +++ b/core/src/main/java/bisq/core/trade/txproof/AssetTxProofResult.java @@ -0,0 +1,84 @@ +/* + * 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.txproof; + +import lombok.Getter; + +public enum AssetTxProofResult { + UNDEFINED, + + FEATURE_DISABLED, + TRADE_LIMIT_EXCEEDED, + INVALID_DATA, // Peer provided invalid data. Might be a scam attempt (e.g. txKey reused) + PAYOUT_TX_ALREADY_PUBLISHED, + + REQUESTS_STARTED(false), + PENDING(false), + + // All services completed with a success state + COMPLETED, + + // Any service had an error (network, API service) + ERROR, + + // Any service failed. Might be that the tx is invalid. + FAILED; + + @Getter + private int numSuccessResults; + @Getter + private int numRequiredSuccessResults; + @Getter + private String details = ""; + // If isTerminal is set it means that we stop the service + @Getter + private final boolean isTerminal; + + AssetTxProofResult() { + this(true); + } + + AssetTxProofResult(boolean isTerminal) { + this.isTerminal = isTerminal; + } + + + public AssetTxProofResult numSuccessResults(int numSuccessResults) { + this.numSuccessResults = numSuccessResults; + return this; + } + + public AssetTxProofResult numRequiredSuccessResults(int numRequiredSuccessResults) { + this.numRequiredSuccessResults = numRequiredSuccessResults; + return this; + } + + public AssetTxProofResult details(String details) { + this.details = details; + return this; + } + + @Override + public String toString() { + return "AssetTxProofResult{" + + "\n numSuccessResults=" + numSuccessResults + + ",\n requiredSuccessResults=" + numRequiredSuccessResults + + ",\n details='" + details + '\'' + + "\n} " + super.toString(); + } +} diff --git a/core/src/main/java/bisq/core/trade/txproof/AssetTxProofService.java b/core/src/main/java/bisq/core/trade/txproof/AssetTxProofService.java new file mode 100644 index 00000000000..56e2a7462e8 --- /dev/null +++ b/core/src/main/java/bisq/core/trade/txproof/AssetTxProofService.java @@ -0,0 +1,34 @@ +/* + * 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.txproof; + +import bisq.core.trade.Trade; + +import bisq.common.handlers.FaultHandler; + +import java.util.List; +import java.util.function.Consumer; + +public interface AssetTxProofService { + void maybeStartRequests(Trade trade, + List activeTrades, + Consumer resultHandler, + FaultHandler faultHandler); + + void shutDown(); +} diff --git a/core/src/main/java/bisq/core/trade/txproof/xmr/DevTestXmrTxProofHttpClient.java b/core/src/main/java/bisq/core/trade/txproof/xmr/DevTestXmrTxProofHttpClient.java new file mode 100644 index 00000000000..b415d354c44 --- /dev/null +++ b/core/src/main/java/bisq/core/trade/txproof/xmr/DevTestXmrTxProofHttpClient.java @@ -0,0 +1,206 @@ +/* + * 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.txproof.xmr; + +import bisq.core.trade.txproof.AssetTxProofHttpClient; + +import bisq.network.Socks5ProxyProvider; +import bisq.network.http.HttpClientImpl; + +import bisq.common.app.DevEnv; + +import javax.inject.Inject; + +import java.util.Date; + +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; + +import static bisq.core.trade.txproof.xmr.XmrTxProofParser.MAX_DATE_TOLERANCE; + +/** + * This should help to test error scenarios in dev testing the app. This is additional to unit test which test the + * correct data but do not test the context of the results and how it behaves in the UI. + * + * You have to change the binding in TradeModule to + * bind(AssetTxProofHttpClient.class).to(DevTestXmrTxProofHttpClient.class); to use that class. + * + * This class can be removed once done testing, but as multiple devs are testing its useful to share it for now. + */ +@Slf4j +public class DevTestXmrTxProofHttpClient extends HttpClientImpl implements AssetTxProofHttpClient { + enum ApiInvalidDetails { + EMPTY_JSON, + MISSING_DATA, + MISSING_STATUS, + UNHANDLED_STATUS, + MISSING_ADDRESS, + MISSING_TX_ID, + MISSING_VIEW_KEY, + MISSING_TS, + MISSING_CONF, + EXCEPTION + } + + @Inject + public DevTestXmrTxProofHttpClient(@Nullable Socks5ProxyProvider socks5ProxyProvider) { + super(socks5ProxyProvider); + } + + private static int counter; + + @Override + public String requestWithGET(String param, + @Nullable String headerKey, + @Nullable String headerValue) { + + XmrTxProofRequest.Result result = XmrTxProofRequest.Result.PENDING; + XmrTxProofRequest.Detail detail = XmrTxProofRequest.Detail.TX_NOT_FOUND; + ApiInvalidDetails apiInvalidDetails = ApiInvalidDetails.EXCEPTION; + + int delay = counter == 0 ? 2000 : 100; + try { + Thread.sleep(delay); + } catch (InterruptedException e) { + e.printStackTrace(); + } + + if (counter >= 2) { + detail = XmrTxProofRequest.Detail.PENDING_CONFIRMATIONS.numConfirmations(counter - 2); + } + counter++; + switch (result) { + case PENDING: + switch (detail) { + case TX_NOT_FOUND: + return validJson().replace("success", + "fail"); + case PENDING_CONFIRMATIONS: + return validJson().replace("201287", + String.valueOf(detail.getNumConfirmations())); + default: + return null; + } + case SUCCESS: + return validJson(); + case FAILED: + switch (detail) { + case TX_HASH_INVALID: + return validJson().replace("5e665addf6d7c6300670e8a89564ed12b5c1a21c336408e2835668f9a6a0d802", + "-"); + case TX_KEY_INVALID: + return validJson().replace("f3ce66c9d395e5e460c8802b2c3c1fff04e508434f9738ee35558aac4678c906", + "-"); + case ADDRESS_INVALID: + return validJson().replace("590f7263428051068bb45cdfcf93407c15b6e291d20c92d0251fcfbf53cc745cdf53319f7d6d7a8e21ea39041aabf31d220a32a875e3ca2087a777f1201c0571", + "-"); + case NO_MATCH_FOUND: + return validJson().replace("match\": true", + "match\": false"); + case AMOUNT_NOT_MATCHING: + return validJson().replace("8902597360000", + "18902597360000"); + case TRADE_DATE_NOT_MATCHING: + DevEnv.setDevMode(false); + long date = (new Date(1574922644 * 1000L).getTime() - (MAX_DATE_TOLERANCE * 1000L + 1)) / 1000; + return validJson().replace("1574922644", + String.valueOf(date)); + default: + return null; + } + case ERROR: + switch (detail) { + case CONNECTION_FAILURE: + // Not part of parser level testing + return null; + case API_INVALID: + switch (apiInvalidDetails) { + case EMPTY_JSON: + return null; + case MISSING_DATA: + return validJson().replace("data", + "missing"); + case MISSING_STATUS: + return validJson().replace("status", + "missing"); + case UNHANDLED_STATUS: + return validJson().replace("success", + "missing"); + case MISSING_ADDRESS: + return validJson().replace("address", + "missing"); + case MISSING_TX_ID: + return validJson().replace("tx_hash", + "missing"); + case MISSING_VIEW_KEY: + return validJson().replace("viewkey", + "missing"); + case MISSING_TS: + return validJson().replace("tx_timestamp", + "missing"); + case MISSING_CONF: + return validJson().replace("tx_confirmations", + "missing"); + case EXCEPTION: + return validJson().replace("} ", + ""); + default: + return null; + } + + case NO_RESULTS_TIMEOUT: + // Not part of parser level testing + return null; + default: + return null; + } + default: + return null; + } + } + + private String validJson() { + return "{\n" + + " \"data\": {\n" + + " \"address\": \"590f7263428051068bb45cdfcf93407c15b6e291d20c92d0251fcfbf53cc745cdf53319f7d6d7a8e21ea39041aabf31d220a32a875e3ca2087a777f1201c0571\",\n" + + " \"outputs\": [\n" + + " {\n" + + " \"amount\": 8902597360000,\n" + + " \"match\": true,\n" + + " \"output_idx\": 0,\n" + + " \"output_pubkey\": \"2b6d2296f2591c198cd1aa47de9a5d74270963412ed30bbcc63b8eff29f0d43e\"\n" + + " },\n" + + " {\n" + + " \"amount\": 0,\n" + + " \"match\": false,\n" + + " \"output_idx\": 1,\n" + + " \"output_pubkey\": \"f53271624847507d80b746e91e689e88bc41678d55246275f5ad3c0f7e8a9ced\"\n" + + " }\n" + + " ],\n" + + " \"tx_confirmations\": 201287,\n" + + " \"tx_hash\": \"5e665addf6d7c6300670e8a89564ed12b5c1a21c336408e2835668f9a6a0d802\",\n" + + " \"tx_prove\": true,\n" + + " \"tx_timestamp\": 1574922644,\n" + + " \"viewkey\": \"f3ce66c9d395e5e460c8802b2c3c1fff04e508434f9738ee35558aac4678c906\"\n" + + " },\n" + + " \"status\": \"success\"\n" + + "} "; + } + +} diff --git a/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofHttpClient.java b/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofHttpClient.java new file mode 100644 index 00000000000..dce87473837 --- /dev/null +++ b/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofHttpClient.java @@ -0,0 +1,35 @@ +/* + * 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.txproof.xmr; + +import bisq.core.trade.txproof.AssetTxProofHttpClient; + +import bisq.network.Socks5ProxyProvider; +import bisq.network.http.HttpClientImpl; + +import javax.inject.Inject; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class XmrTxProofHttpClient extends HttpClientImpl implements AssetTxProofHttpClient { + @Inject + public XmrTxProofHttpClient(Socks5ProxyProvider socks5ProxyProvider) { + super(socks5ProxyProvider); + } +} diff --git a/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofModel.java b/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofModel.java new file mode 100644 index 00000000000..f9451448af5 --- /dev/null +++ b/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofModel.java @@ -0,0 +1,103 @@ +/* + * 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.txproof.xmr; + +import bisq.core.monetary.Volume; +import bisq.core.payment.payload.AssetsAccountPayload; +import bisq.core.payment.payload.PaymentAccountPayload; +import bisq.core.trade.Trade; +import bisq.core.trade.txproof.AssetTxProofModel; +import bisq.core.user.AutoConfirmSettings; + +import bisq.common.app.DevEnv; + +import org.bitcoinj.core.Coin; + +import com.google.common.annotations.VisibleForTesting; + +import java.util.Date; + +import lombok.Value; +import lombok.extern.slf4j.Slf4j; + +import static com.google.common.base.Preconditions.checkNotNull; + +@SuppressWarnings("SpellCheckingInspection") +@Slf4j +@Value +public class XmrTxProofModel implements AssetTxProofModel { + // 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 serviceAddress; + private final AutoConfirmSettings autoConfirmSettings; + private final String tradeId; + private final String txHash; + private final String txKey; + private final String recipientAddress; + private final long amount; + private final Date tradeDate; + + XmrTxProofModel(Trade trade, String serviceAddress, AutoConfirmSettings autoConfirmSettings) { + this.serviceAddress = serviceAddress; + this.autoConfirmSettings = autoConfirmSettings; + + Coin tradeAmount = trade.getTradeAmount(); + Volume volume = checkNotNull(trade.getOffer()).getVolumeByAmount(tradeAmount); + amount = DevEnv.isDevMode() ? + XmrTxProofModel.DEV_AMOUNT : // For dev testing we need to add the matching address to the dev tx key and dev view key + volume != null ? volume.getValue() * 10000L : 0L; // XMR satoshis have 12 decimal places vs. bitcoin's 8 + PaymentAccountPayload sellersPaymentAccountPayload = checkNotNull(trade.getContract()).getSellerPaymentAccountPayload(); + recipientAddress = DevEnv.isDevMode() ? + XmrTxProofModel.DEV_ADDRESS : // For dev testing we need to add the matching address to the dev tx key and dev view key + ((AssetsAccountPayload) sellersPaymentAccountPayload).getAddress(); + txHash = trade.getCounterCurrencyTxId(); + txKey = trade.getCounterCurrencyExtraData(); + tradeDate = trade.getDate(); + tradeId = trade.getId(); + } + + // NumRequiredConfirmations is read just in time. If user changes autoConfirmSettings during requests it will + // be reflected at next result parsing. + int getNumRequiredConfirmations() { + return autoConfirmSettings.getRequiredConfirmations(); + } + + // Used only for testing + // TODO Use mocking framework in testing to avoid that constructor... + @VisibleForTesting + XmrTxProofModel(String tradeId, + String txHash, + String txKey, + String recipientAddress, + long amount, + Date tradeDate, + AutoConfirmSettings autoConfirmSettings) { + this.tradeId = tradeId; + this.txHash = txHash; + this.txKey = txKey; + this.recipientAddress = recipientAddress; + this.amount = amount; + this.tradeDate = tradeDate; + this.autoConfirmSettings = autoConfirmSettings; + this.serviceAddress = autoConfirmSettings.getServiceAddresses().get(0); + } +} diff --git a/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofParser.java b/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofParser.java new file mode 100644 index 00000000000..cf7b7835cfc --- /dev/null +++ b/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofParser.java @@ -0,0 +1,174 @@ +/* + * 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.txproof.xmr; + +import bisq.core.trade.txproof.AssetTxProofParser; + +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 +public class XmrTxProofParser implements AssetTxProofParser { + public static final long MAX_DATE_TOLERANCE = TimeUnit.HOURS.toSeconds(2); + + XmrTxProofParser() { + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + @SuppressWarnings("SpellCheckingInspection") + @Override + public XmrTxProofRequest.Result parse(XmrTxProofModel model, String jsonTxt) { + String txHash = model.getTxHash(); + try { + JsonObject json = new Gson().fromJson(jsonTxt, JsonObject.class); + if (json == null) { + return XmrTxProofRequest.Result.ERROR.with(XmrTxProofRequest.Detail.API_INVALID.error("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 XmrTxProofRequest.Result.ERROR.with(XmrTxProofRequest.Detail.API_INVALID.error("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 or if request + // contained invalid data. + // We return TX_NOT_FOUND which will cause a retry later + return XmrTxProofRequest.Result.PENDING.with(XmrTxProofRequest.Detail.TX_NOT_FOUND); + } else if (!jsonStatus.matches("success")) { + return XmrTxProofRequest.Result.ERROR.with(XmrTxProofRequest.Detail.API_INVALID.error("Unhandled status value")); + } + + // validate that the address matches + JsonElement jsonAddress = jsonData.get("address"); + if (jsonAddress == null) { + return XmrTxProofRequest.Result.ERROR.with(XmrTxProofRequest.Detail.API_INVALID.error("Missing address field")); + } else { + String expectedAddressHex = CryptoNoteUtils.getRawSpendKeyAndViewKey(model.getRecipientAddress()); + if (!jsonAddress.getAsString().equalsIgnoreCase(expectedAddressHex)) { + log.warn("Address from json result (convertToRawHex):\n{}\nExpected (convertToRawHex):\n{}\nRecipient address:\n{}", + jsonAddress.getAsString(), expectedAddressHex, model.getRecipientAddress()); + return XmrTxProofRequest.Result.FAILED.with(XmrTxProofRequest.Detail.ADDRESS_INVALID); + } + } + + // validate that the txHash matches + JsonElement jsonTxHash = jsonData.get("tx_hash"); + if (jsonTxHash == null) { + return XmrTxProofRequest.Result.ERROR.with(XmrTxProofRequest.Detail.API_INVALID.error("Missing tx_hash field")); + } else { + if (!jsonTxHash.getAsString().equalsIgnoreCase(txHash)) { + log.warn("txHash {}, expected: {}", jsonTxHash.getAsString(), txHash); + return XmrTxProofRequest.Result.FAILED.with(XmrTxProofRequest.Detail.TX_HASH_INVALID); + } + } + + // validate that the txKey matches + JsonElement jsonViewkey = jsonData.get("viewkey"); + if (jsonViewkey == null) { + return XmrTxProofRequest.Result.ERROR.with(XmrTxProofRequest.Detail.API_INVALID.error("Missing viewkey field")); + } else { + if (!jsonViewkey.getAsString().equalsIgnoreCase(model.getTxKey())) { + log.warn("viewkey {}, expected: {}", jsonViewkey.getAsString(), model.getTxKey()); + return XmrTxProofRequest.Result.FAILED.with(XmrTxProofRequest.Detail.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 XmrTxProofRequest.Result.ERROR.with(XmrTxProofRequest.Detail.API_INVALID.error("Missing tx_timestamp field")); + } else { + long tradeDateSeconds = model.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 > MAX_DATE_TOLERANCE && !DevEnv.isDevMode()) { + log.warn("tx_timestamp {}, tradeDate: {}, difference {}", + jsonTimestamp.getAsLong(), tradeDateSeconds, difference); + return XmrTxProofRequest.Result.FAILED.with(XmrTxProofRequest.Detail.TRADE_DATE_NOT_MATCHING); + } + } + + // calculate how many confirms are still needed + int confirmations; + JsonElement jsonConfirmations = jsonData.get("tx_confirmations"); + if (jsonConfirmations == null) { + return XmrTxProofRequest.Result.ERROR.with(XmrTxProofRequest.Detail.API_INVALID.error("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 == model.getAmount(); + if (amountMatches) { + break; + } + } + } + + // None of the outputs had a match entry + if (!anyMatchFound) { + return XmrTxProofRequest.Result.FAILED.with(XmrTxProofRequest.Detail.NO_MATCH_FOUND); + } + + // None of the outputs had a match entry + if (!amountMatches) { + return XmrTxProofRequest.Result.FAILED.with(XmrTxProofRequest.Detail.AMOUNT_NOT_MATCHING); + } + + int confirmsRequired = model.getNumRequiredConfirmations(); + if (confirmations < confirmsRequired) { + return XmrTxProofRequest.Result.PENDING.with(XmrTxProofRequest.Detail.PENDING_CONFIRMATIONS.numConfirmations(confirmations)); + } else { + return XmrTxProofRequest.Result.SUCCESS; + } + + } catch (JsonParseException | NullPointerException e) { + return XmrTxProofRequest.Result.ERROR.with(XmrTxProofRequest.Detail.API_INVALID.error(e.toString())); + } catch (CryptoNoteUtils.CryptoNoteException e) { + return XmrTxProofRequest.Result.ERROR.with(XmrTxProofRequest.Detail.ADDRESS_INVALID.error(e.toString())); + } + } +} diff --git a/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofRequest.java b/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofRequest.java new file mode 100644 index 00000000000..c853af2410c --- /dev/null +++ b/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofRequest.java @@ -0,0 +1,279 @@ +/* + * 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.txproof.xmr; + +import bisq.core.trade.txproof.AssetTxProofHttpClient; +import bisq.core.trade.txproof.AssetTxProofParser; +import bisq.core.trade.txproof.AssetTxProofRequest; + +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.EqualsAndHashCode; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import org.jetbrains.annotations.NotNull; + +import javax.annotation.Nullable; + +/** + * Requests for the XMR tx proof for a particular trade from a particular service. + * Repeats every 90 sec requests if tx is not confirmed or found yet until MAX_REQUEST_PERIOD of 12 hours is reached. + */ +@Slf4j +@EqualsAndHashCode +class XmrTxProofRequest implements AssetTxProofRequest { + + /////////////////////////////////////////////////////////////////////////////////////////// + // Enums + /////////////////////////////////////////////////////////////////////////////////////////// + + enum Result implements AssetTxProofRequest.Result { + PENDING, // Tx not visible in network yet, unconfirmed or not enough confirmations + SUCCESS, // Proof succeeded + FAILED, // Proof failed + ERROR; // Error from service, does not mean that proof failed + + @Nullable + @Getter + private Detail detail; + + Result with(Detail detail) { + this.detail = detail; + return this; + } + + @Override + public String toString() { + return "Result{" + + "\n detail=" + detail + + "\n} " + super.toString(); + } + } + + enum Detail { + // Pending + TX_NOT_FOUND, // Tx not visible in network yet. Could be also other error + PENDING_CONFIRMATIONS, + + // Error states + CONNECTION_FAILURE, + API_INVALID, + + // Failure states + TX_HASH_INVALID, + TX_KEY_INVALID, + ADDRESS_INVALID, + NO_MATCH_FOUND, + AMOUNT_NOT_MATCHING, + TRADE_DATE_NOT_MATCHING, + NO_RESULTS_TIMEOUT; + + @Getter + private int numConfirmations; + @Nullable + @Getter + private String errorMsg; + + public Detail error(String errorMsg) { + this.errorMsg = errorMsg; + return this; + } + + public Detail numConfirmations(int numConfirmations) { + this.numConfirmations = numConfirmations; + return this; + } + + @Override + public String toString() { + return "Detail{" + + "\n numConfirmations=" + numConfirmations + + ",\n errorMsg='" + errorMsg + '\'' + + "\n} " + super.toString(); + } + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Static fields + /////////////////////////////////////////////////////////////////////////////////////////// + + private static final long REPEAT_REQUEST_PERIOD = TimeUnit.SECONDS.toMillis(90); + private static final long MAX_REQUEST_PERIOD = TimeUnit.HOURS.toMillis(12); + + /////////////////////////////////////////////////////////////////////////////////////////// + // Class fields + /////////////////////////////////////////////////////////////////////////////////////////// + + private final ListeningExecutorService executorService = Utilities.getListeningExecutorService( + "XmrTransferProofRequester", 3, 5, 10 * 60); + + private final AssetTxProofHttpClient httpClient; + private final AssetTxProofParser parser; + private final XmrTxProofModel model; + private final long firstRequest; + + private boolean terminated; + @Getter + @Nullable + private Result result; + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Constructor + /////////////////////////////////////////////////////////////////////////////////////////// + + XmrTxProofRequest(AssetTxProofHttpClient httpClient, + XmrTxProofModel model) { + this.httpClient = httpClient; + this.parser = new XmrTxProofParser(); + this.model = model; + + httpClient.setBaseUrl("http://" + model.getServiceAddress()); + if (model.getServiceAddress().matches("^192.*|^localhost.*")) { + log.info("Ignoring Socks5 proxy for local net address: {}", model.getServiceAddress()); + httpClient.setIgnoreSocks5Proxy(true); + } + + terminated = false; + firstRequest = System.currentTimeMillis(); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + @SuppressWarnings("SpellCheckingInspection") + @Override + public void requestFromService(Consumer resultHandler, FaultHandler faultHandler) { + 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.warn("Not starting {} as we have already terminated.", this); + return; + } + + // Timeout handing is delegated to the connection timeout handling in httpClient. + + ListenableFuture future = executorService.submit(() -> { + Thread.currentThread().setName("XmrTransferProofRequest-" + this.getShortId()); + String param = "/api/outputs?txhash=" + model.getTxHash() + + "&address=" + model.getRecipientAddress() + + "&viewkey=" + model.getTxKey() + + "&txprove=1"; + log.info("Param {} for {}", param, this); + String json = httpClient.requestWithGET(param, "User-Agent", "bisq/" + Version.VERSION); + try { + String prettyJson = new GsonBuilder().setPrettyPrinting().create().toJson(new JsonParser().parse(json)); + log.info("Response json from {}\n{}", this, prettyJson); + } catch (Throwable error) { + log.error("Pretty rint caused a {}}: raw josn={}", error, json); + } + + Result result = parser.parse(model, json); + log.info("Result from {}\n{}", this, result); + return result; + }); + + Futures.addCallback(future, new FutureCallback<>() { + public void onSuccess(Result result) { + XmrTxProofRequest.this.result = result; + + if (terminated) { + log.warn("We received {} but {} was terminated already. We do not process result.", result, this); + return; + } + + switch (result) { + case PENDING: + if (isTimeOutReached()) { + log.warn("{} took too long without a success or failure/error result We give up. " + + "Might be that the transaction was never published.", this); + // If we reached out timeout we return with an error. + UserThread.execute(() -> resultHandler.accept(XmrTxProofRequest.Result.ERROR.with(Detail.NO_RESULTS_TIMEOUT))); + } else { + UserThread.runAfter(() -> requestFromService(resultHandler, faultHandler), REPEAT_REQUEST_PERIOD, TimeUnit.MILLISECONDS); + // We update our listeners + UserThread.execute(() -> resultHandler.accept(result)); + } + break; + case SUCCESS: + log.info("{} succeeded", result); + UserThread.execute(() -> resultHandler.accept(result)); + terminate(); + break; + case FAILED: + case ERROR: + UserThread.execute(() -> resultHandler.accept(result)); + terminate(); + break; + default: + log.warn("Unexpected result {}", result); + break; + } + } + + public void onFailure(@NotNull Throwable throwable) { + String errorMessage = this + " failed with error " + throwable.toString(); + faultHandler.handleFault(errorMessage, throwable); + UserThread.execute(() -> + resultHandler.accept(XmrTxProofRequest.Result.ERROR.with(Detail.CONNECTION_FAILURE.error(errorMessage)))); + } + }); + } + + @Override + public void terminate() { + terminated = true; + } + + // Convenient for logging + @Override + public String toString() { + return "Request at: " + model.getServiceAddress() + " for trade: " + model.getTradeId(); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // Private + /////////////////////////////////////////////////////////////////////////////////////////// + + private String getShortId() { + return Utilities.getShortId(model.getTradeId()) + " @ " + + model.getServiceAddress().substring(0, 6); + } + + private boolean isTimeOutReached() { + return System.currentTimeMillis() - firstRequest > MAX_REQUEST_PERIOD; + } + +} diff --git a/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofRequestsPerTrade.java b/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofRequestsPerTrade.java new file mode 100644 index 00000000000..f49919232b5 --- /dev/null +++ b/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofRequestsPerTrade.java @@ -0,0 +1,238 @@ +/* + * 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.txproof.xmr; + +import bisq.core.locale.Res; +import bisq.core.trade.Trade; +import bisq.core.trade.txproof.AssetTxProofHttpClient; +import bisq.core.trade.txproof.AssetTxProofRequestsPerTrade; +import bisq.core.trade.txproof.AssetTxProofResult; +import bisq.core.user.AutoConfirmSettings; + +import bisq.common.handlers.FaultHandler; + +import org.bitcoinj.core.Coin; + +import javafx.beans.value.ChangeListener; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +/** + * Handles the XMR tx proof requests for multiple services per trade. + */ +@Slf4j +class XmrTxProofRequestsPerTrade implements AssetTxProofRequestsPerTrade { + @Getter + private final Trade trade; + private final AutoConfirmSettings autoConfirmSettings; + private final AssetTxProofHttpClient httpClient; + + private int numRequiredSuccessResults; + private final Set requests = new HashSet<>(); + + private int numSuccessResults; + private ChangeListener tradeStateListener; + private AutoConfirmSettings.Listener autoConfirmSettingsListener; + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Constructor + /////////////////////////////////////////////////////////////////////////////////////////// + + XmrTxProofRequestsPerTrade(AssetTxProofHttpClient httpClient, + Trade trade, + AutoConfirmSettings autoConfirmSettings) { + this.httpClient = httpClient; + this.trade = trade; + this.autoConfirmSettings = autoConfirmSettings; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void requestFromAllServices(Consumer resultHandler, FaultHandler faultHandler) { + // We set serviceAddresses at request time. If user changes AutoConfirmSettings after request has started + // it will have no impact on serviceAddresses and numRequiredSuccessResults. + // Thought numRequiredConfirmations can be changed during request process and will be read from + // autoConfirmSettings at result parsing. + List serviceAddresses = autoConfirmSettings.getServiceAddresses(); + numRequiredSuccessResults = serviceAddresses.size(); + + if (isTradeAmountAboveLimit(trade)) { + callResultHandlerAndMaybeTerminate(resultHandler, AssetTxProofResult.TRADE_LIMIT_EXCEEDED); + return; + } + + if (trade.isPayoutPublished()) { + callResultHandlerAndMaybeTerminate(resultHandler, AssetTxProofResult.PAYOUT_TX_ALREADY_PUBLISHED); + return; + } + + // We will stop all our services if the user changes the enable state in the AutoConfirmSettings + autoConfirmSettingsListener = () -> { + if (!autoConfirmSettings.isEnabled()) { + callResultHandlerAndMaybeTerminate(resultHandler, AssetTxProofResult.FEATURE_DISABLED); + } + }; + autoConfirmSettings.addListener(autoConfirmSettingsListener); + if (!autoConfirmSettings.isEnabled()) { + callResultHandlerAndMaybeTerminate(resultHandler, AssetTxProofResult.FEATURE_DISABLED); + return; + } + + tradeStateListener = (observable, oldValue, newValue) -> { + if (trade.isPayoutPublished()) { + callResultHandlerAndMaybeTerminate(resultHandler, AssetTxProofResult.PAYOUT_TX_ALREADY_PUBLISHED); + } + }; + trade.stateProperty().addListener(tradeStateListener); + + callResultHandlerAndMaybeTerminate(resultHandler, AssetTxProofResult.REQUESTS_STARTED); + + for (String serviceAddress : serviceAddresses) { + XmrTxProofModel model = new XmrTxProofModel(trade, serviceAddress, autoConfirmSettings); + XmrTxProofRequest request = new XmrTxProofRequest(httpClient, model); + + log.info("{} created", request); + requests.add(request); + + request.requestFromService(result -> { + AssetTxProofResult assetTxProofResult; + if (trade.isPayoutPublished()) { + assetTxProofResult = AssetTxProofResult.PAYOUT_TX_ALREADY_PUBLISHED; + callResultHandlerAndMaybeTerminate(resultHandler, assetTxProofResult); + return; + } + + switch (result) { + case PENDING: + // We expect repeated PENDING results with different details + assetTxProofResult = getAssetTxProofResultForPending(result); + break; + case SUCCESS: + numSuccessResults++; + if (numSuccessResults < numRequiredSuccessResults) { + // Request is success but not all have completed yet. + int remaining = numRequiredSuccessResults - numSuccessResults; + log.info("{} succeeded. We have {} remaining request(s) open.", + request, remaining); + assetTxProofResult = getAssetTxProofResultForPending(result); + } else { + // All our services have returned a SUCCESS result so we + // have completed on the service level. + log.info("All {} tx proof requests for trade {} have been successful.", + numRequiredSuccessResults, trade.getShortId()); + assetTxProofResult = AssetTxProofResult.COMPLETED; + } + break; + case FAILED: + log.warn("{} failed. " + + "This might not mean that the XMR transfer was invalid but you have to check yourself " + + "if the XMR transfer was correct. {}", + request, result); + + assetTxProofResult = AssetTxProofResult.FAILED; + break; + case ERROR: + default: + log.warn("{} resulted in an error. " + + "This might not mean that the XMR transfer was invalid but can be a network or " + + "service problem. {}", + request, result); + + assetTxProofResult = AssetTxProofResult.ERROR; + break; + } + + callResultHandlerAndMaybeTerminate(resultHandler, assetTxProofResult); + }, + faultHandler); + } + } + + @Override + public void terminate() { + requests.forEach(XmrTxProofRequest::terminate); + requests.clear(); + if (tradeStateListener != null) { + trade.stateProperty().removeListener(tradeStateListener); + } + if (autoConfirmSettingsListener != null) { + autoConfirmSettings.removeListener(autoConfirmSettingsListener); + } + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Private + /////////////////////////////////////////////////////////////////////////////////////////// + + private void callResultHandlerAndMaybeTerminate(Consumer resultHandler, + AssetTxProofResult assetTxProofResult) { + resultHandler.accept(assetTxProofResult); + if (assetTxProofResult.isTerminal()) { + terminate(); + } + } + + private AssetTxProofResult getAssetTxProofResultForPending(XmrTxProofRequest.Result result) { + XmrTxProofRequest.Detail detail = result.getDetail(); + int numConfirmations = detail != null ? detail.getNumConfirmations() : 0; + log.info("{} returned with numConfirmations {}", + result, numConfirmations); + + String detailString = ""; + if (XmrTxProofRequest.Detail.PENDING_CONFIRMATIONS == detail) { + detailString = Res.get("portfolio.pending.autoConf.state.confirmations", + numConfirmations, autoConfirmSettings.getRequiredConfirmations()); + + } else if (XmrTxProofRequest.Detail.TX_NOT_FOUND == detail) { + detailString = Res.get("portfolio.pending.autoConf.state.txNotFound"); + } + + return AssetTxProofResult.PENDING + .numSuccessResults(numSuccessResults) + .numRequiredSuccessResults(numRequiredSuccessResults) + .details(detailString); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Validation + /////////////////////////////////////////////////////////////////////////////////////////// + + private boolean isTradeAmountAboveLimit(Trade trade) { + Coin tradeAmount = trade.getTradeAmount(); + Coin tradeLimit = Coin.valueOf(autoConfirmSettings.getTradeLimit()); + if (tradeAmount != null && tradeAmount.isGreaterThan(tradeLimit)) { + log.warn("Trade amount {} is higher than limit from auto-conf setting {}.", + tradeAmount.toFriendlyString(), tradeLimit.toFriendlyString()); + return true; + } + return false; + } +} diff --git a/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofService.java b/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofService.java new file mode 100644 index 00000000000..bd63e1df114 --- /dev/null +++ b/core/src/main/java/bisq/core/trade/txproof/xmr/XmrTxProofService.java @@ -0,0 +1,237 @@ +/* + * 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.txproof.xmr; + +import bisq.core.btc.setup.WalletsSetup; +import bisq.core.filter.FilterManager; +import bisq.core.locale.Res; +import bisq.core.payment.payload.AssetsAccountPayload; +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.trade.txproof.AssetTxProofHttpClient; +import bisq.core.trade.txproof.AssetTxProofResult; +import bisq.core.trade.txproof.AssetTxProofService; +import bisq.core.user.AutoConfirmSettings; +import bisq.core.user.Preferences; + +import bisq.network.p2p.P2PService; + +import bisq.common.app.DevEnv; +import bisq.common.handlers.FaultHandler; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.stream.Stream; + +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 implements AssetTxProofService { + private final FilterManager filterManager; + private final Preferences preferences; + private final ClosedTradableManager closedTradableManager; + private final FailedTradesManager failedTradesManager; + private final P2PService p2PService; + private final WalletsSetup walletsSetup; + private final AssetTxProofHttpClient httpClient; + private final Map servicesByTradeId = new HashMap<>(); + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Constructor + /////////////////////////////////////////////////////////////////////////////////////////// + + @SuppressWarnings("WeakerAccess") + @Inject + public XmrTxProofService(FilterManager filterManager, + Preferences preferences, + ClosedTradableManager closedTradableManager, + FailedTradesManager failedTradesManager, + P2PService p2PService, + WalletsSetup walletsSetup, + AssetTxProofHttpClient httpClient) { + this.filterManager = filterManager; + this.preferences = preferences; + this.closedTradableManager = closedTradableManager; + this.failedTradesManager = failedTradesManager; + this.p2PService = p2PService; + this.walletsSetup = walletsSetup; + this.httpClient = httpClient; + + filterManager.filterProperty().addListener((observable, oldValue, newValue) -> { + if (isAutoConfDisabledByFilter()) { + servicesByTradeId.values().stream().map(XmrTxProofRequestsPerTrade::getTrade).forEach(trade -> + trade.setAssetTxProofResult(AssetTxProofResult.FEATURE_DISABLED + .details(Res.get("portfolio.pending.autoConf.state.filterDisabledFeature")))); + shutDown(); + } + }); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // API + /////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public void maybeStartRequests(Trade trade, + List activeTrades, + Consumer resultHandler, + FaultHandler faultHandler) { + if (!isXmrBuyer(trade)) { + return; + } + + String txId = trade.getCounterCurrencyTxId(); + String txHash = trade.getCounterCurrencyExtraData(); + if (is32BitHexStringInValid(txId) || is32BitHexStringInValid(txHash)) { + trade.setAssetTxProofResult(AssetTxProofResult.INVALID_DATA.details(Res.get("portfolio.pending.autoConf.state.txKeyOrTxIdInvalid"))); + return; + } + + if (!networkAndWalletReady()) { + return; + } + + Optional optionalAutoConfirmSettings = preferences.findAutoConfirmSettings("XMR"); + if (!optionalAutoConfirmSettings.isPresent()) { + // Not expected + log.error("autoConfirmSettings is not present"); + return; + } + AutoConfirmSettings autoConfirmSettings = optionalAutoConfirmSettings.get(); + + if (isAutoConfDisabledByFilter()) { + trade.setAssetTxProofResult(AssetTxProofResult.FEATURE_DISABLED + .details(Res.get("portfolio.pending.autoConf.state.filterDisabledFeature"))); + return; + } + + if (wasTxKeyReUsed(trade, activeTrades)) { + trade.setAssetTxProofResult(AssetTxProofResult.INVALID_DATA + .details(Res.get("portfolio.pending.autoConf.state.xmr.txKeyReused"))); + return; + } + + XmrTxProofRequestsPerTrade service = new XmrTxProofRequestsPerTrade(httpClient, + trade, + autoConfirmSettings); + servicesByTradeId.put(trade.getId(), service); + service.requestFromAllServices( + assetTxProofResult -> { + trade.setAssetTxProofResult(assetTxProofResult); + + if (assetTxProofResult.isTerminal()) { + servicesByTradeId.remove(trade.getId()); + } + + resultHandler.accept(assetTxProofResult); + }, + faultHandler); + } + + @Override + public void shutDown() { + servicesByTradeId.values().forEach(XmrTxProofRequestsPerTrade::terminate); + servicesByTradeId.clear(); + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Validation + /////////////////////////////////////////////////////////////////////////////////////////// + + 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 is32BitHexStringInValid(String hexString) { + if (hexString == null || hexString.isEmpty() || !hexString.matches("[a-fA-F0-9]{64}")) { + log.warn("Invalid hexString: {}", hexString); + return true; + } + + return false; + } + + private boolean networkAndWalletReady() { + return p2PService.isBootstrapped() && + walletsSetup.isDownloadComplete() && + walletsSetup.hasSufficientPeersForBroadcast(); + } + + private boolean isAutoConfDisabledByFilter() { + return filterManager.getFilter() != null && + filterManager.getFilter().isDisableAutoConf(); + } + + private boolean wasTxKeyReUsed(Trade trade, List activeTrades) { + // For dev testing we reuse test data so we ignore that check + 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) { + log.warn("Peer used the XMR tx key already at another trade with trade ID {}. " + + "This might be a scam attempt.", t.getId()); + } + return alreadyUsed; + }); + } +} 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..81fb2a57058 --- /dev/null +++ b/core/src/main/java/bisq/core/user/AutoConfirmSettings.java @@ -0,0 +1,129 @@ +/* + * 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 org.bitcoinj.core.Coin; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import lombok.Getter; + +@Getter +public final class AutoConfirmSettings implements PersistablePayload { + public interface Listener { + void onChange(); + } + + private boolean enabled; + private int requiredConfirmations; + private long tradeLimit; + private List serviceAddresses; + private String currencyCode; + private List listeners = new CopyOnWriteArrayList<>(); + + static AutoConfirmSettings getDefaultForXmr(List serviceAddresses) { + return new AutoConfirmSettings( + false, + 5, + Coin.COIN.value, + serviceAddresses, + "XMR"); + } + + 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()); + } + + public void addListener(Listener listener) { + listeners.add(listener); + } + + public void removeListener(Listener listener) { + listeners.remove(listener); + } + + private void notifyListeners() { + listeners.forEach(Listener::onChange); + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + notifyListeners(); + } + + public void setRequiredConfirmations(int requiredConfirmations) { + this.requiredConfirmations = requiredConfirmations; + notifyListeners(); + } + + public void setTradeLimit(long tradeLimit) { + this.tradeLimit = tradeLimit; + notifyListeners(); + } + + public void setServiceAddresses(List serviceAddresses) { + this.serviceAddresses = serviceAddresses; + notifyListeners(); + } + + public void setCurrencyCode(String currencyCode) { + this.currencyCode = currencyCode; + notifyListeners(); + } +} diff --git a/core/src/main/java/bisq/core/user/Preferences.java b/core/src/main/java/bisq/core/user/Preferences.java index bb2a736df21..d267cfa604e 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,6 +33,7 @@ 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; @@ -59,6 +60,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Optional; import java.util.Random; import java.util.stream.Collectors; @@ -122,6 +124,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; @@ -307,6 +320,10 @@ public void readPersisted() { setUsePriceNotifications(true); } + if (prefPayload.getAutoConfirmSettingsList().isEmpty()) { + getAutoConfirmSettingsList().add(AutoConfirmSettings.getDefaultForXmr(getDefaultXmrProofProviders())); + } + // We set the capability in CoreNetworkCapabilities if the program argument is set. // If we have set it in the preferences view we handle it here. CoreNetworkCapabilities.maybeApplyDaoFullMode(config); @@ -396,6 +413,40 @@ public void setTacAcceptedV120(boolean tacAccepted) { persist(); } + public Optional findAutoConfirmSettings(String currencyCode) { + return prefPayload.getAutoConfirmSettingsList().stream() + .filter(e -> e.getCurrencyCode().equals(currencyCode)) + .findAny(); + } + + public void setAutoConfServiceAddresses(String currencyCode, List serviceAddresses) { + findAutoConfirmSettings(currencyCode).ifPresent(e -> { + e.setServiceAddresses(serviceAddresses); + persist(); + }); + } + + public void setAutoConfEnabled(String currencyCode, boolean enabled) { + findAutoConfirmSettings(currencyCode).ifPresent(e -> { + e.setEnabled(enabled); + persist(); + }); + } + + public void setAutoConfRequiredConfirmations(String currencyCode, int requiredConfirmations) { + findAutoConfirmSettings(currencyCode).ifPresent(e -> { + e.setRequiredConfirmations(requiredConfirmations); + persist(); + }); + } + + public void setAutoConfTradeLimit(String currencyCode, long tradeLimit) { + findAutoConfirmSettings(currencyCode).ifPresent(e -> { + e.setTradeLimit(tradeLimit); + persist(); + }); + } + private void persist() { if (initialReadDone) storage.queueUpForSave(prefPayload); @@ -738,7 +789,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 +889,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 +1019,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/java/bisq/core/util/validation/IntegerValidator.java b/core/src/main/java/bisq/core/util/validation/IntegerValidator.java index d2392e142e5..d904eb497a3 100644 --- a/core/src/main/java/bisq/core/util/validation/IntegerValidator.java +++ b/core/src/main/java/bisq/core/util/validation/IntegerValidator.java @@ -32,6 +32,11 @@ public class IntegerValidator extends InputValidator { public IntegerValidator() { } + public IntegerValidator(int minValue, int maxValue) { + this.minValue = minValue; + this.maxValue = maxValue; + } + public ValidationResult validate(String input) { ValidationResult validationResult = super.validate(input); if (!validationResult.isValid) diff --git a/core/src/main/resources/i18n/displayStrings.properties b/core/src/main/resources/i18n/displayStrings.properties index 2fbb4683c67..1474fb0628e 100644 --- a/core/src/main/resources/i18n/displayStrings.properties +++ b/core/src/main/resources/i18n/displayStrings.properties @@ -570,6 +570,34 @@ 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.xmr.txKeyReused=Transaction key re-used. Please open a dispute. +portfolio.pending.autoConf.state.confirmations=Confirmations: {0}/{1} +portfolio.pending.autoConf.state.txNotFound=Transaction not seen in mem-pool yet +portfolio.pending.autoConf.state.txKeyOrTxIdInvalid=No valid transaction ID / transaction key +portfolio.pending.autoConf.state.filterDisabledFeature=Disabled by developers. + +# suppress inspection "UnusedProperty" +portfolio.pending.autoConf.state.FEATURE_DISABLED=Auto-confirm feature is disabled. {0} +# suppress inspection "UnusedProperty" +portfolio.pending.autoConf.state.TRADE_LIMIT_EXCEEDED=Trade amount exceeds auto-confirm amount limit +# suppress inspection "UnusedProperty" +portfolio.pending.autoConf.state.INVALID_DATA=Peer provided invalid data. {0} +# suppress inspection "UnusedProperty" +portfolio.pending.autoConf.state.PAYOUT_TX_ALREADY_PUBLISHED=Payout transaction was already published. +# suppress inspection "UnusedProperty" +portfolio.pending.autoConf.state.REQUESTS_STARTED=Proof requests started +# suppress inspection "UnusedProperty" +portfolio.pending.autoConf.state.PENDING=Service results: {0}/{1}; {2} +# suppress inspection "UnusedProperty" +portfolio.pending.autoConf.state.COMPLETED=Proof at all services succeeded +# suppress inspection "UnusedProperty" +portfolio.pending.autoConf.state.ERROR=An error at a service request occurred. +# suppress inspection "UnusedProperty" +portfolio.pending.autoConf.state.FAILED=A service returned with a failure. + 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 +664,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 +751,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 +1091,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 +1274,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 #################################################################### @@ -1316,35 +1365,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\ @@ -2460,6 +2492,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 @@ -2520,6 +2553,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 @@ -2662,7 +2699,7 @@ popup.warning.lockedUpFunds=You have locked up funds from a failed trade.\n\ Trade ID: {2}.\n\n\ Please open a support ticket by selecting the trade in the open trades screen and pressing \"alt + o\" or \"option + o\"." -popup.warning.nodeBanned=One of the {0} nodes got banned. Please restart your application to be sure to not be connected to the banned node. +popup.warning.nodeBanned=One of the {0} nodes got banned. popup.warning.priceRelay=price relay popup.warning.seed=seed popup.warning.mandatoryUpdate.trading=Please update to the latest Bisq version. \ diff --git a/core/src/test/java/bisq/core/trade/txproof/xmr/XmrTxProofParserTest.java b/core/src/test/java/bisq/core/trade/txproof/xmr/XmrTxProofParserTest.java new file mode 100644 index 00000000000..af91a7d4d33 --- /dev/null +++ b/core/src/test/java/bisq/core/trade/txproof/xmr/XmrTxProofParserTest.java @@ -0,0 +1,178 @@ +package bisq.core.trade.txproof.xmr; + +import bisq.core.user.AutoConfirmSettings; + +import java.time.Instant; + +import java.util.Collections; +import java.util.Date; + +import org.junit.Before; +import org.junit.Test; + +import static bisq.core.trade.txproof.xmr.XmrTxProofParser.MAX_DATE_TOLERANCE; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertSame; + +public class XmrTxProofParserTest { + private XmrTxProofModel xmrTxProofModel; + private String recipientAddressHex = "e957dac72bcec80d59b2fecacfa7522223b6a5df895b7e388e60297e85f3f867b42f43e8d9f086a99a997704ceb92bd9cd99d33952de90c9f5f93c82c62360ae"; + private String txHash = "488e48ab0c7e69028d19f787ec57fd496ff114caba9ab265bfd41a3ea0e4687d"; + private String txKey = "6c336e52ed537676968ee319af6983c80b869ca6a732b5962c02748b486f8f0f"; + private XmrTxProofParser parser; + private Date tradeDate; + + @Before + public void prepareMocksAndObjects() { + long amount = 100000000000L; + tradeDate = new Date(1574922644000L); + String serviceAddress = "127.0.0.1:8081"; + AutoConfirmSettings autoConfirmSettings = new AutoConfirmSettings(true, + 10, + 1, + Collections.singletonList(serviceAddress), + "XMR"); + + // TODO using the mocking framework would be better... + String recipientAddress = "4ATyxmFGU7h3EWu5kYR6gy6iCNFCftbsjATfbuBBjsRHJM4KTwEyeiyVNNUmsfpK1kdRxs8QoPLsZanGqe1Mby43LeyWNMF"; + xmrTxProofModel = new XmrTxProofModel( + "dummyTest", + txHash, + txKey, + recipientAddress, + amount, + tradeDate, + autoConfirmSettings); + + parser = new XmrTxProofParser(); + } + + @Test + public void testJsonRoot() { + // checking what happens when bad input is provided + assertSame(parser.parse(xmrTxProofModel, + "invalid json data").getDetail(), XmrTxProofRequest.Detail.API_INVALID); + assertSame(parser.parse(xmrTxProofModel, + "").getDetail(), XmrTxProofRequest.Detail.API_INVALID); + assertSame(parser.parse(xmrTxProofModel, + "[]").getDetail(), XmrTxProofRequest.Detail.API_INVALID); + assertSame(parser.parse(xmrTxProofModel, + "{}").getDetail(), XmrTxProofRequest.Detail.API_INVALID); + } + + @Test + public void testJsonTopLevel() { + // testing the top level fields: data and status + assertSame(parser.parse(xmrTxProofModel, + "{'data':{'title':''},'status':'fail'}") + .getDetail(), XmrTxProofRequest.Detail.TX_NOT_FOUND); + assertSame(parser.parse(xmrTxProofModel, + "{'data':{'title':''},'missingstatus':'success'}") + .getDetail(), XmrTxProofRequest.Detail.API_INVALID); + assertSame(parser.parse(xmrTxProofModel, + "{'missingdata':{'title':''},'status':'success'}") + .getDetail(), XmrTxProofRequest.Detail.API_INVALID); + } + + @Test + public void testJsonAddress() { + assertSame(parser.parse(xmrTxProofModel, + "{'data':{'missingaddress':'irrelevant'},'status':'success'}") + .getDetail(), XmrTxProofRequest.Detail.API_INVALID); + assertSame(parser.parse(xmrTxProofModel, + "{'data':{'address':'e957dac7'},'status':'success'}") + .getDetail(), XmrTxProofRequest.Detail.ADDRESS_INVALID); + } + + @Test + public void testJsonTxHash() { + String missing_tx_hash = "{'data':{'address':'" + recipientAddressHex + "'}, 'status':'success'}"; + assertSame(parser.parse(xmrTxProofModel, missing_tx_hash).getDetail(), XmrTxProofRequest.Detail.API_INVALID); + + String invalid_tx_hash = "{'data':{'address':'" + recipientAddressHex + "', 'tx_hash':'488e48'}, 'status':'success'}"; + assertSame(parser.parse(xmrTxProofModel, invalid_tx_hash).getDetail(), XmrTxProofRequest.Detail.TX_HASH_INVALID); + } + + @Test + public void testJsonTxKey() { + String missing_tx_key = "{'data':{'address':'" + recipientAddressHex + "', " + + "'tx_hash':'" + txHash + "'}, 'status':'success'}"; + assertSame(parser.parse(xmrTxProofModel, missing_tx_key).getDetail(), XmrTxProofRequest.Detail.API_INVALID); + + String invalid_tx_key = "{'data':{'address':'" + recipientAddressHex + "', " + + "'tx_hash':'" + txHash + "', " + + "'viewkey':'cdce04'}, 'status':'success'}"; + assertSame(parser.parse(xmrTxProofModel, invalid_tx_key).getDetail(), XmrTxProofRequest.Detail.TX_KEY_INVALID); + } + + @Test + public void testJsonTxTimestamp() { + String missing_tx_timestamp = "{'data':{'address':'" + recipientAddressHex + "', " + + "'tx_hash':'" + txHash + "'," + + "'viewkey':'" + txKey + "'}, 'status':'success'}"; + assertSame(parser.parse(xmrTxProofModel, missing_tx_timestamp).getDetail(), XmrTxProofRequest.Detail.API_INVALID); + + String invalid_tx_timestamp = "{'data':{'address':'" + recipientAddressHex + "', " + + "'tx_hash':'" + txHash + "', " + + "'viewkey':'" + txKey + "'," + + "'tx_timestamp':'12345'}, 'status':'success'}"; + assertSame(parser.parse(xmrTxProofModel, invalid_tx_timestamp).getDetail(), XmrTxProofRequest.Detail.TRADE_DATE_NOT_MATCHING); + + long tradeTimeSec = tradeDate.getTime() / 1000; + String ts = String.valueOf(tradeTimeSec - MAX_DATE_TOLERANCE - 1); + String invalid_tx_timestamp_1ms_too_old = "{'data':{'address':'" + recipientAddressHex + "', " + + "'tx_hash':'" + txHash + "', " + + "'viewkey':'" + txKey + "'," + + "'tx_timestamp':'" + ts + "'}, 'status':'success'}"; + assertSame(parser.parse(xmrTxProofModel, invalid_tx_timestamp_1ms_too_old).getDetail(), XmrTxProofRequest.Detail.TRADE_DATE_NOT_MATCHING); + + ts = String.valueOf(tradeTimeSec - MAX_DATE_TOLERANCE); + String valid_tx_timestamp_exact_MAX_DATE_TOLERANCE = "{'data':{'address':'" + recipientAddressHex + "', " + + "'tx_hash':'" + txHash + "', " + + "'viewkey':'" + txKey + "'," + + "'tx_timestamp':'" + ts + "'}, 'status':'success'}"; + parser.parse(xmrTxProofModel, valid_tx_timestamp_exact_MAX_DATE_TOLERANCE); + assertNotSame(parser.parse(xmrTxProofModel, valid_tx_timestamp_exact_MAX_DATE_TOLERANCE).getDetail(), XmrTxProofRequest.Detail.TRADE_DATE_NOT_MATCHING); + + ts = String.valueOf(tradeTimeSec - MAX_DATE_TOLERANCE + 1); + String valid_tx_timestamp_less_than_MAX_DATE_TOLERANCE = "{'data':{'address':'" + recipientAddressHex + "', " + + "'tx_hash':'" + txHash + "', " + + "'viewkey':'" + txKey + "'," + + "'tx_timestamp':'" + ts + "'}, 'status':'success'}"; + assertNotSame(parser.parse(xmrTxProofModel, valid_tx_timestamp_less_than_MAX_DATE_TOLERANCE).getDetail(), XmrTxProofRequest.Detail.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':'" + epochDate + "'}" + + "}"; + assertSame(parser.parse(xmrTxProofModel, json), XmrTxProofRequest.Result.SUCCESS); + json = json.replaceFirst("777", "0"); + + assertSame(parser.parse(xmrTxProofModel, json).getDetail(), XmrTxProofRequest.Detail.PENDING_CONFIRMATIONS); + + json = json.replaceFirst("100000000000", "100000000001"); + assertSame(parser.parse(xmrTxProofModel, json).getDetail(), XmrTxProofRequest.Detail.AMOUNT_NOT_MATCHING); + + // Revert change of amount + json = json.replaceFirst("100000000001", "100000000000"); + json = json.replaceFirst("'match':true", "'match':false"); + assertSame(parser.parse(xmrTxProofModel, json).getDetail(), XmrTxProofRequest.Detail.NO_MATCH_FOUND); + } + + @Test + public void testJsonFail() { + String failedJson = "{\"data\":null,\"message\":\"Cant parse tx hash: a\",\"status\":\"error\"}"; + assertSame(parser.parse(xmrTxProofModel, failedJson).getDetail(), XmrTxProofRequest.Detail.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 b2bf0fc6450..6fc717b263c 100644 --- a/core/src/test/java/bisq/core/user/UserPayloadModelVOTest.java +++ b/core/src/test/java/bisq/core/user/UserPayloadModelVOTest.java @@ -62,7 +62,8 @@ public void testRoundtripFull() { null, null, null, - null)); + null, + false)); vo.setRegisteredArbitrator(ArbitratorTest.getArbitratorMock()); vo.setRegisteredMediator(MediatorTest.getMediatorMock()); diff --git a/core/src/test/java/bisq/core/util/FeeReceiverSelectorTest.java b/core/src/test/java/bisq/core/util/FeeReceiverSelectorTest.java index a8488e9c1e3..ce2f3f6f838 100644 --- a/core/src/test/java/bisq/core/util/FeeReceiverSelectorTest.java +++ b/core/src/test/java/bisq/core/util/FeeReceiverSelectorTest.java @@ -121,6 +121,7 @@ private static Filter filterWithReceivers(List btcFeeReceiverAddresses) null, null, null, - null); + null, + 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 0b76f0d672b..0784ff10853 100644 --- a/desktop/src/main/java/bisq/desktop/main/MainView.java +++ b/desktop/src/main/java/bisq/desktop/main/MainView.java @@ -89,6 +89,7 @@ import javafx.beans.binding.ObjectBinding; import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleStringProperty; import javafx.beans.property.StringProperty; import javafx.beans.value.ChangeListener; @@ -190,6 +191,8 @@ protected void initialize() { JFXBadge portfolioButtonWithBadge = new JFXBadge(portfolioButton); JFXBadge supportButtonWithBadge = new JFXBadge(supportButton); + JFXBadge settingsButtonWithBadge = new JFXBadge(settingsButton); + settingsButtonWithBadge.getStyleClass().add("new"); Locale locale = GlobalSettings.getLocale(); DecimalFormat currencyFormat = (DecimalFormat) NumberFormat.getNumberInstance(locale); @@ -320,8 +323,8 @@ protected Tooltip computeValue() { primaryNav.getStyleClass().add("nav-primary"); HBox.setHgrow(primaryNav, Priority.SOMETIMES); - HBox secondaryNav = new HBox(supportButtonWithBadge, getNavigationSpacer(), settingsButton, - getNavigationSpacer(), getNavigationSpacer()); + HBox secondaryNav = new HBox(supportButtonWithBadge, getNavigationSpacer(), settingsButtonWithBadge, + getNavigationSpacer(), accountButton, getNavigationSpacer(), daoButton); secondaryNav.getStyleClass().add("nav-secondary"); HBox.setHgrow(secondaryNav, Priority.SOMETIMES); @@ -364,6 +367,7 @@ protected Tooltip computeValue() { setupBadge(portfolioButtonWithBadge, model.getNumPendingTrades(), model.getShowPendingTradesNotification()); setupBadge(supportButtonWithBadge, model.getNumOpenSupportTickets(), model.getShowOpenSupportTicketsNotification()); + 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 02f72916e7a..6f468f70121 100644 --- a/desktop/src/main/java/bisq/desktop/main/MainViewModel.java +++ b/desktop/src/main/java/bisq/desktop/main/MainViewModel.java @@ -33,6 +33,7 @@ import bisq.desktop.main.presentation.AccountPresentation; import bisq.desktop.main.presentation.DaoPresentation; import bisq.desktop.main.presentation.MarketPricePresentation; +import bisq.desktop.main.presentation.SettingsPresentation; import bisq.desktop.main.shared.PriceFeedComboBoxItem; import bisq.desktop.util.DisplayUtils; import bisq.desktop.util.GUIUtil; @@ -112,6 +113,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 @@ -154,7 +156,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, @@ -177,6 +181,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; @@ -253,6 +258,7 @@ public void onSetupComplete() { marketPricePresentation.setup(); daoPresentation.setup(); accountPresentation.setup(); + settingsPresentation.setup(); if (DevEnv.isDevMode()) { preferences.setShowOwnOffersInOfferBook(true); @@ -679,8 +685,8 @@ public BooleanProperty getShowDaoUpdatesNotification() { return daoPresentation.getShowDaoUpdatesNotification(); } - public BooleanProperty getShowAccountUpdatesNotification() { - return accountPresentation.getShowAccountUpdatesNotification(); + public BooleanProperty getShowSettingsUpdatesNotification() { + return settingsPresentation.getShowSettingsUpdatesNotification(); } private void maybeShowPopupsFromQueue() { 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 cad11360b64..bf98f05ea19 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 @@ -153,6 +153,8 @@ private void addContent() { Res.get("filterWindow.preventPublicBtcNetwork")); CheckBox disableDaoCheckBox = addLabelCheckBox(gridPane, ++rowIndex, Res.get("filterWindow.disableDao")); + CheckBox disableAutoConfCheckBox = addLabelCheckBox(gridPane, ++rowIndex, + Res.get("filterWindow.disableAutoConf")); InputTextField disableDaoBelowVersionTF = addInputTextField(gridPane, ++rowIndex, Res.get("filterWindow.disableDaoBelowVersion")); InputTextField disableTradeBelowVersionTF = addInputTextField(gridPane, ++rowIndex, @@ -179,6 +181,7 @@ private void addContent() { preventPublicBtcNetworkCheckBox.setSelected(filter.isPreventPublicBtcNetwork()); disableDaoCheckBox.setSelected(filter.isDisableDao()); + disableAutoConfCheckBox.setSelected(filter.isDisableAutoConf()); disableDaoBelowVersionTF.setText(filter.getDisableDaoBelowVersion()); disableTradeBelowVersionTF.setText(filter.getDisableTradeBelowVersion()); } @@ -211,7 +214,8 @@ private void addContent() { readAsList(btcFeeReceiverAddressesTF), filterManager.getOwnerPubKey(), signerPubKeyAsHex, - readAsList(bannedPrivilegedDevPubKeysTF) + readAsList(bannedPrivilegedDevPubKeysTF), + disableAutoConfCheckBox.isSelected() ); filterManager.addDevFilter(newFilter, privKeyString); 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..8862664104f --- /dev/null +++ b/desktop/src/main/java/bisq/desktop/main/overlays/windows/SetXmrTxKeyWindow.java @@ -0,0 +1,120 @@ +/* + * 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.txproof.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; +import static javafx.beans.binding.Bindings.createBooleanBinding; + +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); + } + + actionButton.disableProperty().bind(createBooleanBinding(() -> { + String txHash = txHashInputTextField.getText(); + String txKey = txKeyInputTextField.getText(); + + // If a field is empty we allow to continue. We do not enforce that users send the data. + if (txHash.isEmpty() || txKey.isEmpty()) { + return false; + } + + // Otherwise we require that input is valid + return !txHashInputTextField.getValidator().validate(txHash).isValid || + !txKeyInputTextField.getValidator().validate(txKey).isValid; + }, + txHashInputTextField.textProperty(), txKeyInputTextField.textProperty())); + + 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..538c1f66d5f 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 @@ -22,6 +22,7 @@ import bisq.desktop.main.MainView; import bisq.desktop.main.overlays.Overlay; import bisq.desktop.util.DisplayUtils; +import bisq.desktop.util.GUIUtil; import bisq.desktop.util.Layout; import bisq.core.account.witness.AccountAgeWitnessService; @@ -33,13 +34,13 @@ import bisq.core.trade.Contract; import bisq.core.trade.Trade; import bisq.core.trade.TradeManager; +import bisq.core.trade.txproof.AssetTxProofResult; 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 +69,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 +161,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 +235,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 && + trade.getAssetTxProofResult() != AssetTxProofResult.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"), + GUIUtil.getProofResultAsString(trade.getAssetTxProofResult())); + } 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..19e2ec7e1ad 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,15 +188,13 @@ 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); } public void onFiatPaymentReceived(ResultHandler resultHandler, ErrorMessageHandler errorMessageHandler) { checkNotNull(getTrade(), "trade must not be null"); - checkArgument(getTrade() instanceof SellerTrade, "Check failed: trade not instanceof SellerTrade"); - ((SellerTrade) getTrade()).onFiatPaymentReceived(resultHandler, errorMessageHandler); + checkArgument(getTrade() instanceof SellerTrade, "Trade must be instance of SellerTrade"); + tradeManager.onFiatPaymentReceived((SellerTrade) getTrade(), resultHandler, errorMessageHandler); } public void onWithdrawRequest(String toAddress, @@ -703,5 +701,9 @@ public boolean isBootstrappedOrShowPopup() { public void addTradeToFailedTrades() { tradeManager.addTradeToFailedTrades(selectedTrade); } + + public boolean isSignWitnessTrade() { + return accountAgeWitnessService.isSignWitnessTrade(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..562c716ebec 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; @@ -88,6 +90,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 +387,112 @@ 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; + } + + 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..b0d6295cfc2 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 @@ -37,6 +37,7 @@ import bisq.core.btc.wallet.BtcWalletService; import bisq.core.btc.wallet.Restrictions; import bisq.core.locale.Res; +import bisq.core.trade.txproof.AssetTxProofResult; import bisq.core.user.DontShowAgainLookup; import bisq.core.util.coin.CoinFormatter; import bisq.core.util.coin.CoinUtil; @@ -51,6 +52,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 +61,7 @@ import javafx.scene.layout.Priority; import javafx.geometry.Insets; +import javafx.geometry.Pos; import org.spongycastle.crypto.params.KeyParameter; @@ -103,9 +107,20 @@ 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); + autoConfBadge.setVisible(AssetTxProofResult.COMPLETED == trade.getAssetTxProofResult()); + + 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..4bff3492dfe 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 @@ -24,6 +24,7 @@ import bisq.desktop.main.portfolio.pendingtrades.PendingTradesViewModel; import bisq.desktop.main.portfolio.pendingtrades.steps.TradeStepView; import bisq.desktop.util.DisplayUtils; +import bisq.desktop.util.GUIUtil; import bisq.desktop.util.Layout; import bisq.core.locale.CurrencyUtil; @@ -42,6 +43,7 @@ import bisq.core.payment.payload.WesternUnionAccountPayload; import bisq.core.trade.Contract; import bisq.core.trade.Trade; +import bisq.core.trade.txproof.AssetTxProofResult; import bisq.core.user.DontShowAgainLookup; import bisq.common.Timer; @@ -59,6 +61,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 +77,9 @@ public class SellerStep3View extends TradeStepView { private BusyAnimation busyAnimation; private Subscription tradeStatePropertySubscription; private Timer timeoutTimer; + private TextFieldWithCopyIcon assetTxProofResultField; + private final ChangeListener proofResultListener; + /////////////////////////////////////////////////////////////////////////////////////////// // Constructor, Initialisation @@ -80,6 +87,10 @@ public class SellerStep3View extends TradeStepView { public SellerStep3View(PendingTradesViewModel model) { super(model); + + proofResultListener = (observable, oldValue, newValue) -> { + applyAssetTxProofResult(trade.getAssetTxProofResult()); + }; } @Override @@ -139,6 +150,14 @@ public void activate() { } } }); + + // we listen for updates on the trade autoConfirmResult field + if (assetTxProofResultField != null) { + trade.getAssetTxProofResultUpdateProperty().addListener(proofResultListener); + applyAssetTxProofResult(trade.getAssetTxProofResult()); + } + + applyAssetTxProofResult(trade.getAssetTxProofResult()); } @Override @@ -152,8 +171,13 @@ public void deactivate() { busyAnimation.stop(); - if (timeoutTimer != null) + if (timeoutTimer != null) { timeoutTimer.stop(); + } + + if (assetTxProofResultField != null) { + trade.getAssetTxProofResultUpdateProperty().removeListener(proofResultListener); + } } /////////////////////////////////////////////////////////////////////////////////////////// @@ -162,7 +186,6 @@ public void deactivate() { @Override protected void addContent() { - gridPane.getColumnConstraints().get(1).setHgrow(Priority.ALWAYS); addTradeInfoBlock(); @@ -206,6 +229,12 @@ protected void addContent() { GridPane.setRowSpan(titledGroupBg, 4); } + if (isBlockChain && trade.getOffer().getCurrencyCode().equals("XMR")) { + assetTxProofResultField = 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 +245,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 +337,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 +394,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,14 +407,12 @@ 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.onFiatPaymentReceived(() -> { // In case the first send failed we got the support button displayed. // If it succeeds at a second try we remove the support button again. @@ -402,10 +443,14 @@ else if (paymentAccountPayload instanceof SepaInstantAccountPayload) } } + private void applyAssetTxProofResult(AssetTxProofResult result) { + String txt = GUIUtil.getProofResultAsString(result); + assetTxProofResultField.setText(txt); + assetTxProofResultField.setTooltip(new Tooltip(txt)); + } + @Override 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..60ab77a3418 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 @@ -29,11 +29,13 @@ import bisq.desktop.util.GUIUtil; import bisq.desktop.util.ImageUtil; import bisq.desktop.util.Layout; +import bisq.desktop.util.validation.BtcValidator; import bisq.desktop.util.validation.RegexValidator; import bisq.core.btc.wallet.Restrictions; import bisq.core.dao.DaoFacade; import bisq.core.dao.governance.asset.AssetService; +import bisq.core.filter.Filter; import bisq.core.filter.FilterManager; import bisq.core.locale.Country; import bisq.core.locale.CountryUtil; @@ -48,6 +50,7 @@ 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 +96,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 +104,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 +112,18 @@ public class PreferencesView extends ActivatableViewAndModel preferredTradeCurrencyComboBox; private ToggleButton showOwnOffersInOfferBook, useAnimations, useDarkMode, sortMarketCurrenciesNumerically, - avoidStandbyMode, useCustomFee; + avoidStandbyMode, useCustomFee, autoConfirmXmrToggle; private int gridRow = 0; + private int displayCurrenciesGridRowIndex = 0; private InputTextField transactionFeeInputTextField, ignoreTradersListInputTextField, ignoreDustThresholdInputTextField, - /*referralIdInputTextField,*/ - rpcUserTextField, blockNotifyPortTextField; + autoConfRequiredConfirmationsTf, autoConfServiceAddressTf, autoConfTradeLimitTf, /*referralIdInputTextField,*/ + rpcUserTextField, blockNotifyPortTextField; private ToggleButton isDaoFullNodeToggleButton; private PasswordTextField rpcPwTextField; private TitledGroupBg daoOptionsTitledGroupBg; private ChangeListener transactionFeeFocusedListener; + private ChangeListener autoConfServiceAddressFocusOutListener, autoConfRequiredConfirmationsFocusOutListener; private final Preferences preferences; private final FeeService feeService; //private final ReferralIdService referralIdService; @@ -133,7 +137,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,12 +148,15 @@ public class PreferencesView extends ActivatableViewAndModel tradeCurrencies; private InputTextField deviationInputTextField; private ChangeListener deviationListener, ignoreTradersListListener, ignoreDustThresholdListener, - /*referralIdListener,*/ rpcUserListener, rpcPwListener, blockNotifyPortListener; + rpcUserListener, rpcPwListener, blockNotifyPortListener, + autoConfTradeLimitListener, autoConfServiceAddressListener; private ChangeListener deviationFocusedListener; private ChangeListener useCustomFeeCheckboxListener; private ChangeListener transactionFeeChangeListener; private final boolean daoOptionsSet; private final boolean displayStandbyModeFeature; + private ChangeListener filterChangeListener; + /////////////////////////////////////////////////////////////////////////////////////////// // Constructor, initialisation @@ -164,11 +170,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,12 +204,12 @@ public void initialize() { allFiatCurrencies.removeAll(fiatCurrencies); initializeGeneralOptions(); - initializeSeparator(); - initializeDisplayCurrencies(); initializeDisplayOptions(); if (DevEnv.isDaoActivated()) initializeDaoOptions(); - + initializeSeparator(); + initializeAutoConfirmOptions(); + initializeDisplayCurrencies(); } @@ -214,6 +222,7 @@ protected void activate() { activateGeneralOptions(); activateDisplayCurrencies(); activateDisplayPreferences(); + activateAutoConfirmPreferences(); if (DevEnv.isDaoActivated()) activateDaoPreferences(); } @@ -223,6 +232,7 @@ protected void deactivate() { deactivateGeneralOptions(); deactivateDisplayCurrencies(); deactivateDisplayPreferences(); + deactivateAutoConfirmPreferences(); if (DevEnv.isDaoActivated()) deactivateDaoPreferences(); } @@ -232,7 +242,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 +395,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 +594,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 +615,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 +652,90 @@ private void initializeDaoOptions() { }; } + private void initializeAutoConfirmOptions() { + GridPane autoConfirmGridPane = new GridPane(); + GridPane.setHgrow(autoConfirmGridPane, Priority.ALWAYS); + root.add(autoConfirmGridPane, 2, displayCurrenciesGridRowIndex, 2, 10); + addTitledGroupBg(autoConfirmGridPane, 0, 4, Res.get("setting.preferences.autoConfirmXMR"), 0); + int localRowIndex = 0; + autoConfirmXmrToggle = addSlideToggleButton(autoConfirmGridPane, localRowIndex, Res.get("setting.preferences.autoConfirmEnabled"), Layout.FIRST_ROW_DISTANCE); + + autoConfRequiredConfirmationsTf = addInputTextField(autoConfirmGridPane, ++localRowIndex, Res.get("setting.preferences.autoConfirmRequiredConfirmations")); + autoConfRequiredConfirmationsTf.setValidator(new IntegerValidator(0, DevEnv.isDevMode() ? 100000000 : 1000)); + + autoConfTradeLimitTf = addInputTextField(autoConfirmGridPane, ++localRowIndex, Res.get("setting.preferences.autoConfirmMaxTradeSize")); + autoConfTradeLimitTf.setValidator(new BtcValidator(formatter)); + + autoConfServiceAddressTf = addInputTextField(autoConfirmGridPane, ++localRowIndex, Res.get("setting.preferences.autoConfirmServiceAddresses")); + autoConfServiceAddressTf.setValidator(GUIUtil.addressRegexValidator()); + autoConfServiceAddressTf.setErrorMessage(Res.get("validation.invalidAddressList")); + GridPane.setHgrow(autoConfServiceAddressTf, Priority.ALWAYS); + displayCurrenciesGridRowIndex += 4; + + autoConfServiceAddressListener = (observable, oldValue, newValue) -> { + if (!newValue.equals(oldValue) && autoConfServiceAddressTf.getValidator().validate(newValue).isValid) { + 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).isEmpty()) { + serviceAddresses = Preferences.getDefaultXmrProofProviders(); + } + preferences.setAutoConfServiceAddresses("XMR", serviceAddresses); + } + }; + + autoConfTradeLimitListener = (observable, oldValue, newValue) -> { + if (!newValue.equals(oldValue) && autoConfTradeLimitTf.getValidator().validate(newValue).isValid) { + Coin amountAsCoin = ParsingUtils.parseToCoin(newValue, formatter); + preferences.setAutoConfTradeLimit("XMR", amountAsCoin.value); + } + }; + + autoConfServiceAddressFocusOutListener = (observable, oldValue, newValue) -> { + if (oldValue && !newValue) { + log.info("Service address focus out, check and re-display default option"); + if (autoConfServiceAddressTf.getText().isEmpty()) { + preferences.findAutoConfirmSettings("XMR").ifPresent(autoConfirmSettings -> { + List serviceAddresses = autoConfirmSettings.getServiceAddresses(); + autoConfServiceAddressTf.setText(String.join(", ", serviceAddresses)); + }); + } + } + }; + + // We use a focus out handler to not update the data during entering text as that might lead to lower than + // intended numbers which could be lead in the worst case to auto completion as number of confirmations is + // reached. E.g. user had value 10 and wants to change it to 15 and deletes the 0, so current value would be 1. + // If the service result just comes in at that moment the service might be considered complete as 1 is at that + // moment used. We read the data just in time to make changes more flexible, otherwise user would need to + // restart to apply changes from the number of confirmations settings. + // Other fields like service addresses and limits are not affected and are taken at service start and cannot be + // changed for already started services. + autoConfRequiredConfirmationsFocusOutListener = (observable, oldValue, newValue) -> { + if (oldValue && !newValue) { + String txt = autoConfRequiredConfirmationsTf.getText(); + if (autoConfRequiredConfirmationsTf.getValidator().validate(txt).isValid) { + int requiredConfirmations = Integer.parseInt(txt); + preferences.setAutoConfRequiredConfirmations("XMR", requiredConfirmations); + } else { + preferences.findAutoConfirmSettings("XMR") + .ifPresent(e -> autoConfRequiredConfirmationsTf + .setText(String.valueOf(e.getRequiredConfirmations()))); + } + } + }; + + filterChangeListener = (observable, oldValue, newValue) -> { + autoConfirmGridPane.setDisable(newValue != null && newValue.isDisableAutoConf()); + }; + autoConfirmGridPane.setDisable(filterManager.getFilter() != null && filterManager.getFilter().isDisableAutoConf()); + } + /////////////////////////////////////////////////////////////////////////////////////////// // 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 +780,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 +903,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 +982,26 @@ private void activateDaoPreferences() { blockNotifyPortTextField.textProperty().addListener(blockNotifyPortListener); } + private void activateAutoConfirmPreferences() { + preferences.findAutoConfirmSettings("XMR").ifPresent(autoConfirmSettings -> { + autoConfirmXmrToggle.setSelected(autoConfirmSettings.isEnabled()); + autoConfRequiredConfirmationsTf.setText(String.valueOf(autoConfirmSettings.getRequiredConfirmations())); + autoConfTradeLimitTf.setText(formatter.formatCoin(Coin.valueOf(autoConfirmSettings.getTradeLimit()))); + autoConfServiceAddressTf.setText(String.join(", ", autoConfirmSettings.getServiceAddresses())); + autoConfRequiredConfirmationsTf.focusedProperty().addListener(autoConfRequiredConfirmationsFocusOutListener); + autoConfTradeLimitTf.textProperty().addListener(autoConfTradeLimitListener); + autoConfServiceAddressTf.textProperty().addListener(autoConfServiceAddressListener); + autoConfServiceAddressTf.focusedProperty().addListener(autoConfServiceAddressFocusOutListener); + autoConfirmXmrToggle.setOnAction(e -> { + preferences.setAutoConfEnabled(autoConfirmSettings.getCurrencyCode(), autoConfirmXmrToggle.isSelected()); + }); + filterManager.filterProperty().addListener(filterChangeListener); + }); + } + 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 +1021,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 +1067,15 @@ private void deactivateDaoPreferences() { rpcPwTextField.textProperty().removeListener(rpcPwListener); blockNotifyPortTextField.textProperty().removeListener(blockNotifyPortListener); } + + private void deactivateAutoConfirmPreferences() { + preferences.findAutoConfirmSettings("XMR").ifPresent(autoConfirmSettings -> { + autoConfirmXmrToggle.setOnAction(null); + autoConfTradeLimitTf.textProperty().removeListener(autoConfTradeLimitListener); + autoConfServiceAddressTf.textProperty().removeListener(autoConfServiceAddressListener); + autoConfServiceAddressTf.focusedProperty().removeListener(autoConfServiceAddressFocusOutListener); + autoConfRequiredConfirmationsTf.focusedProperty().removeListener(autoConfRequiredConfirmationsFocusOutListener); + filterManager.filterProperty().removeListener(filterChangeListener); + }); + } } diff --git a/desktop/src/main/java/bisq/desktop/util/GUIUtil.java b/desktop/src/main/java/bisq/desktop/util/GUIUtil.java index 04ae97f7b39..3fb3ad6b27f 100644 --- a/desktop/src/main/java/bisq/desktop/util/GUIUtil.java +++ b/desktop/src/main/java/bisq/desktop/util/GUIUtil.java @@ -46,6 +46,7 @@ import bisq.core.provider.fee.FeeService; import bisq.core.provider.price.MarketPrice; import bisq.core.provider.price.PriceFeedService; +import bisq.core.trade.txproof.AssetTxProofResult; import bisq.core.user.DontShowAgainLookup; import bisq.core.user.Preferences; import bisq.core.user.User; @@ -143,6 +144,8 @@ import org.jetbrains.annotations.NotNull; +import javax.annotation.Nullable; + import static bisq.desktop.util.FormBuilder.addTopLabelComboBoxComboBox; import static com.google.common.base.Preconditions.checkArgument; @@ -1157,4 +1160,32 @@ public static RegexValidator addressRegexValidator() { onionV2RegexPattern, onionV3RegexPattern, ipv4RegexPattern, ipv6RegexPattern, fqdnRegexPattern)); return regexValidator; } + + public static String getProofResultAsString(@Nullable AssetTxProofResult result) { + if (result == null) { + return ""; + } + String key = "portfolio.pending.autoConf.state." + result.name(); + switch (result) { + case UNDEFINED: + return ""; + case FEATURE_DISABLED: + return Res.get(key, result.getDetails()); + case TRADE_LIMIT_EXCEEDED: + return Res.get(key); + case INVALID_DATA: + return Res.get(key, result.getDetails()); + case PAYOUT_TX_ALREADY_PUBLISHED: + case REQUESTS_STARTED: + return Res.get(key); + case PENDING: + return Res.get(key, result.getNumSuccessResults(), result.getNumRequiredSuccessResults(), result.getDetails()); + case COMPLETED: + case ERROR: + case FAILED: + return Res.get(key); + default: + return result.name(); + } + } } diff --git a/p2p/src/main/java/bisq/network/http/HttpClient.java b/p2p/src/main/java/bisq/network/http/HttpClient.java index 3740a0977f0..5f33db2a23b 100644 --- a/p2p/src/main/java/bisq/network/http/HttpClient.java +++ b/p2p/src/main/java/bisq/network/http/HttpClient.java @@ -17,191 +17,24 @@ package bisq.network.http; -import bisq.network.Socks5ProxyProvider; - -import bisq.common.app.Version; - -import org.apache.http.client.methods.CloseableHttpResponse; -import org.apache.http.client.methods.HttpGet; -import org.apache.http.client.protocol.HttpClientContext; -import org.apache.http.config.Registry; -import org.apache.http.config.RegistryBuilder; -import org.apache.http.conn.socket.ConnectionSocketFactory; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; -import org.apache.http.ssl.SSLContexts; - -import com.runjva.sourceforge.jsocks.protocol.Socks5Proxy; - -import javax.inject.Inject; - -import java.net.HttpURLConnection; -import java.net.InetSocketAddress; -import java.net.URL; - -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; - -import java.util.UUID; - -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; import javax.annotation.Nullable; -import static com.google.common.base.Preconditions.checkNotNull; - -// TODO close connection if failing -@Slf4j -public class HttpClient { - @Nullable - private Socks5ProxyProvider socks5ProxyProvider; - @Getter - private String baseUrl; - private boolean ignoreSocks5Proxy; - private final String uid; - - @Inject - public HttpClient(@Nullable Socks5ProxyProvider socks5ProxyProvider) { - this.socks5ProxyProvider = socks5ProxyProvider; - uid = UUID.randomUUID().toString(); - } - - public HttpClient(String baseUrl) { - this.baseUrl = baseUrl; - uid = UUID.randomUUID().toString(); - } - - public void setBaseUrl(String baseUrl) { - this.baseUrl = baseUrl; - } - - public void setIgnoreSocks5Proxy(boolean ignoreSocks5Proxy) { - this.ignoreSocks5Proxy = ignoreSocks5Proxy; - } - - public String requestWithGET(String param, @Nullable String headerKey, @Nullable String headerValue) throws IOException { - checkNotNull(baseUrl, "baseUrl must be set before calling requestWithGET"); - - Socks5Proxy socks5Proxy = null; - if (socks5ProxyProvider != null) { - // We use the custom socks5ProxyHttp. If not set we request socks5ProxyProvider.getSocks5ProxyBtc() - // which delivers the btc proxy if set, otherwise the internal proxy. - socks5Proxy = socks5ProxyProvider.getSocks5ProxyHttp(); - if (socks5Proxy == null) - socks5Proxy = socks5ProxyProvider.getSocks5Proxy(); - } - if (ignoreSocks5Proxy || socks5Proxy == null || baseUrl.contains("localhost")) { - log.debug("Use clear net for HttpClient. socks5Proxy={}, ignoreSocks5Proxy={}, baseUrl={}", - socks5Proxy, ignoreSocks5Proxy, baseUrl); - return requestWithGETNoProxy(param, headerKey, headerValue); - } else { - log.debug("Use socks5Proxy for HttpClient: " + socks5Proxy); - return requestWithGETProxy(param, socks5Proxy, headerKey, headerValue); - } - } - - /** - * Make an HTTP Get request directly (not routed over socks5 proxy). - */ - public String requestWithGETNoProxy(String param, @Nullable String headerKey, @Nullable String headerValue) throws IOException { - HttpURLConnection connection = null; - log.debug("Executing HTTP request " + baseUrl + param + " proxy: none."); - URL url = new URL(baseUrl + param); - try { - connection = (HttpURLConnection) url.openConnection(); - connection.setRequestMethod("GET"); - connection.setConnectTimeout(10_000); - connection.setReadTimeout(10_000); - connection.setRequestProperty("User-Agent", "bisq/" + Version.VERSION); - if (headerKey != null && headerValue != null) - connection.setRequestProperty(headerKey, headerValue); - - if (connection.getResponseCode() == 200) { - return convertInputStreamToString(connection.getInputStream()); - } else { - String error = convertInputStreamToString(connection.getErrorStream()); - connection.getErrorStream().close(); - throw new HttpException(error); - } - } catch (Throwable t) { - final String message = "Error at requestWithGETNoProxy with URL: " + (baseUrl + param) + ". Throwable=" + t.getMessage(); - log.error(message); - throw new IOException(message); - } finally { - try { - if (connection != null) - connection.getInputStream().close(); - } catch (Throwable ignore) { - } - } - } - - public String getUid() { - return uid; - } - - - /** - * Make an HTTP Get request routed over socks5 proxy. - */ - private String requestWithGETProxy(String param, Socks5Proxy socks5Proxy, @Nullable String headerKey, @Nullable String headerValue) throws IOException { - log.debug("requestWithGETProxy param=" + param); - // This code is adapted from: - // http://stackoverflow.com/a/25203021/5616248 - - // Register our own SocketFactories to override createSocket() and connectSocket(). - // connectSocket does NOT resolve hostname before passing it to proxy. - Registry reg = RegistryBuilder.create() - .register("http", new SocksConnectionSocketFactory()) - .register("https", new SocksSSLConnectionSocketFactory(SSLContexts.createSystemDefault())).build(); - - // Use FakeDNSResolver if not resolving DNS locally. - // This prevents a local DNS lookup (which would be ignored anyway) - PoolingHttpClientConnectionManager cm = socks5Proxy.resolveAddrLocally() ? - new PoolingHttpClientConnectionManager(reg) : - new PoolingHttpClientConnectionManager(reg, new FakeDnsResolver()); - try (CloseableHttpClient httpclient = HttpClients.custom().setConnectionManager(cm).build()) { - InetSocketAddress socksAddress = new InetSocketAddress(socks5Proxy.getInetAddress(), socks5Proxy.getPort()); - - // remove me: Use this to test with system-wide Tor proxy, or change port for another proxy. - // InetSocketAddress socksAddress = new InetSocketAddress("127.0.0.1", 9050); +public interface HttpClient { + void setBaseUrl(String baseUrl); - HttpClientContext context = HttpClientContext.create(); - context.setAttribute("socks.address", socksAddress); + void setIgnoreSocks5Proxy(boolean ignoreSocks5Proxy); - HttpGet request = new HttpGet(baseUrl + param); - if (headerKey != null && headerValue != null) - request.setHeader(headerKey, headerValue); + String requestWithGET(String param, + @Nullable String headerKey, + @Nullable String headerValue) throws IOException; - log.debug("Executing request " + request + " proxy: " + socksAddress); - try (CloseableHttpResponse response = httpclient.execute(request, context)) { - return convertInputStreamToString(response.getEntity().getContent()); - } - } catch (Throwable t) { - throw new IOException("Error at requestWithGETProxy with URL: " + (baseUrl + param) + ". Throwable=" + t.getMessage()); - } - } + String requestWithGETNoProxy(String param, + @Nullable String headerKey, + @Nullable String headerValue) throws IOException; - private String convertInputStreamToString(InputStream inputStream) throws IOException { - BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); - StringBuilder stringBuilder = new StringBuilder(); - String line; - while ((line = bufferedReader.readLine()) != null) { - stringBuilder.append(line); - } - return stringBuilder.toString(); - } + String getUid(); - @Override - public String toString() { - return "HttpClient{" + - "socks5ProxyProvider=" + socks5ProxyProvider + - ", baseUrl='" + baseUrl + '\'' + - ", ignoreSocks5Proxy=" + ignoreSocks5Proxy + - '}'; - } + String getBaseUrl(); } diff --git a/p2p/src/main/java/bisq/network/http/HttpClientImpl.java b/p2p/src/main/java/bisq/network/http/HttpClientImpl.java new file mode 100644 index 00000000000..e85c7e7efff --- /dev/null +++ b/p2p/src/main/java/bisq/network/http/HttpClientImpl.java @@ -0,0 +1,220 @@ +/* + * 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.network.http; + +import bisq.network.Socks5ProxyProvider; + +import bisq.common.app.Version; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.protocol.HttpClientContext; +import org.apache.http.config.Registry; +import org.apache.http.config.RegistryBuilder; +import org.apache.http.conn.socket.ConnectionSocketFactory; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.http.ssl.SSLContexts; + +import com.runjva.sourceforge.jsocks.protocol.Socks5Proxy; + +import javax.inject.Inject; + +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.URL; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; + +import static com.google.common.base.Preconditions.checkNotNull; + +// TODO close connection if failing +@Slf4j +public class HttpClientImpl implements HttpClient { + @Nullable + private Socks5ProxyProvider socks5ProxyProvider; + @Getter + private String baseUrl; + private boolean ignoreSocks5Proxy; + private final String uid; + + @Inject + public HttpClientImpl(@Nullable Socks5ProxyProvider socks5ProxyProvider) { + this.socks5ProxyProvider = socks5ProxyProvider; + uid = UUID.randomUUID().toString(); + } + + public HttpClientImpl(String baseUrl) { + this.baseUrl = baseUrl; + uid = UUID.randomUUID().toString(); + } + + @Override + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + @Override + public void setIgnoreSocks5Proxy(boolean ignoreSocks5Proxy) { + this.ignoreSocks5Proxy = ignoreSocks5Proxy; + } + + @Override + public String requestWithGET(String param, + @Nullable String headerKey, + @Nullable String headerValue) throws IOException { + checkNotNull(baseUrl, "baseUrl must be set before calling requestWithGET"); + + Socks5Proxy socks5Proxy = null; + if (socks5ProxyProvider != null) { + // We use the custom socks5ProxyHttp. If not set we request socks5ProxyProvider.getSocks5ProxyBtc() + // which delivers the btc proxy if set, otherwise the internal proxy. + socks5Proxy = socks5ProxyProvider.getSocks5ProxyHttp(); + if (socks5Proxy == null) + socks5Proxy = socks5ProxyProvider.getSocks5Proxy(); + } + if (ignoreSocks5Proxy || socks5Proxy == null || baseUrl.contains("localhost")) { + log.debug("Use clear net for HttpClient. socks5Proxy={}, ignoreSocks5Proxy={}, baseUrl={}", + socks5Proxy, ignoreSocks5Proxy, baseUrl); + return requestWithGETNoProxy(param, headerKey, headerValue); + } else { + log.debug("Use socks5Proxy for HttpClient: " + socks5Proxy); + return doRequestWithGETProxy(param, socks5Proxy, headerKey, headerValue); + } + } + + /** + * Make an HTTP Get request directly (not routed over socks5 proxy). + */ + @Override + public String requestWithGETNoProxy(String param, + @Nullable String headerKey, + @Nullable String headerValue) throws IOException { + HttpURLConnection connection = null; + log.debug("Executing HTTP request " + baseUrl + param + " proxy: none."); + URL url = new URL(baseUrl + param); + try { + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout((int) TimeUnit.SECONDS.toMillis(30)); + connection.setReadTimeout((int) TimeUnit.SECONDS.toMillis(30)); + connection.setRequestProperty("User-Agent", "bisq/" + Version.VERSION); + if (headerKey != null && headerValue != null) + connection.setRequestProperty(headerKey, headerValue); + + if (connection.getResponseCode() == 200) { + return convertInputStreamToString(connection.getInputStream()); + } else { + String error = convertInputStreamToString(connection.getErrorStream()); + connection.getErrorStream().close(); + throw new HttpException(error); + } + } catch (Throwable t) { + final String message = "Error at requestWithGETNoProxy with URL: " + (baseUrl + param) + ". Throwable=" + t.getMessage(); + log.error(message); + throw new IOException(message); + } finally { + try { + if (connection != null) + connection.getInputStream().close(); + } catch (Throwable ignore) { + } + } + } + + @Override + public String getUid() { + return uid; + } + + + /** + * Make an HTTP Get request routed over socks5 proxy. + */ + private String doRequestWithGETProxy(String param, + Socks5Proxy socks5Proxy, + @Nullable String headerKey, + @Nullable String headerValue) throws IOException { + log.debug("requestWithGETProxy param=" + param); + // This code is adapted from: + // http://stackoverflow.com/a/25203021/5616248 + + // Register our own SocketFactories to override createSocket() and connectSocket(). + // connectSocket does NOT resolve hostname before passing it to proxy. + Registry reg = RegistryBuilder.create() + .register("http", new SocksConnectionSocketFactory()) + .register("https", new SocksSSLConnectionSocketFactory(SSLContexts.createSystemDefault())).build(); + + // Use FakeDNSResolver if not resolving DNS locally. + // This prevents a local DNS lookup (which would be ignored anyway) + PoolingHttpClientConnectionManager cm = socks5Proxy.resolveAddrLocally() ? + new PoolingHttpClientConnectionManager(reg) : + new PoolingHttpClientConnectionManager(reg, new FakeDnsResolver()); + try (CloseableHttpClient httpclient = HttpClients.custom().setConnectionManager(cm).build()) { + InetSocketAddress socksAddress = new InetSocketAddress(socks5Proxy.getInetAddress(), socks5Proxy.getPort()); + + // remove me: Use this to test with system-wide Tor proxy, or change port for another proxy. + // InetSocketAddress socksAddress = new InetSocketAddress("127.0.0.1", 9050); + + HttpClientContext context = HttpClientContext.create(); + context.setAttribute("socks.address", socksAddress); + + HttpGet request = new HttpGet(baseUrl + param); + if (headerKey != null && headerValue != null) + request.setHeader(headerKey, headerValue); + + log.debug("Executing request " + request + " proxy: " + socksAddress); + try (CloseableHttpResponse response = httpclient.execute(request, context)) { + return convertInputStreamToString(response.getEntity().getContent()); + } + } catch (Throwable t) { + throw new IOException("Error at requestWithGETProxy with URL: " + (baseUrl + param) + ". Throwable=" + t.getMessage()); + } + } + + private String convertInputStreamToString(InputStream inputStream) throws IOException { + BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); + StringBuilder stringBuilder = new StringBuilder(); + String line; + while ((line = bufferedReader.readLine()) != null) { + stringBuilder.append(line); + } + return stringBuilder.toString(); + } + + @Override + public String toString() { + return "HttpClient{" + + "socks5ProxyProvider=" + socks5ProxyProvider + + ", baseUrl='" + baseUrl + '\'' + + ", ignoreSocks5Proxy=" + ignoreSocks5Proxy + + '}'; + } +} diff --git a/p2p/src/main/java/bisq/network/p2p/P2PModule.java b/p2p/src/main/java/bisq/network/p2p/P2PModule.java index 61c63f1de02..896fe8098c2 100644 --- a/p2p/src/main/java/bisq/network/p2p/P2PModule.java +++ b/p2p/src/main/java/bisq/network/p2p/P2PModule.java @@ -18,6 +18,8 @@ package bisq.network.p2p; import bisq.network.Socks5ProxyProvider; +import bisq.network.http.HttpClient; +import bisq.network.http.HttpClientImpl; import bisq.network.p2p.network.Connection; import bisq.network.p2p.network.NetworkNode; import bisq.network.p2p.peers.BanList; @@ -69,6 +71,7 @@ protected void configure() { bind(BanList.class).in(Singleton.class); bind(NetworkNode.class).toProvider(NetworkNodeProvider.class).in(Singleton.class); bind(Socks5ProxyProvider.class).in(Singleton.class); + bind(HttpClient.class).to(HttpClientImpl.class); requestStaticInjection(Connection.class); diff --git a/proto/src/main/proto/pb.proto b/proto/src/main/proto/pb.proto index 61bf6152318..2f334e06e03 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 { @@ -641,6 +642,7 @@ message Filter { int64 creation_date = 21; string signer_pub_key_as_hex = 22; repeated string bannedPrivilegedDevPubKeys = 23; + bool disable_auto_conf = 24; } // not used anymore from v0.6 on. But leave it for receiving TradeStatistics objects from older @@ -1394,6 +1396,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; + string asset_tx_proof_result = 38; // name of AssetTxProofResult enum } message BuyerAsMakerTrade { @@ -1554,6 +1558,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; } ///////////////////////////////////////////////////////////////////////////////////////////