diff --git a/common/src/main/java/bisq/common/crypto/EquihashProofOfWorkService.java b/common/src/main/java/bisq/common/crypto/EquihashProofOfWorkService.java new file mode 100644 index 0000000000..25da45688e --- /dev/null +++ b/common/src/main/java/bisq/common/crypto/EquihashProofOfWorkService.java @@ -0,0 +1,76 @@ +/* + * 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.common.crypto; + +import com.google.common.primitives.Longs; + +import org.apache.commons.lang3.StringUtils; + +import java.util.Arrays; +import java.util.concurrent.CompletableFuture; + +import lombok.extern.slf4j.Slf4j; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +@Slf4j +public class EquihashProofOfWorkService extends ProofOfWorkService { + /** Rough cost of one Hashcash iteration compared to solving an Equihash-90-5 puzzle of unit difficulty. */ + private static final double DIFFICULTY_SCALE_FACTOR = 3.0e-5; + + EquihashProofOfWorkService(int version) { + super(version); + } + + @Override + public CompletableFuture mint(String itemId, byte[] challenge, int log2Difficulty) { + double difficulty = adjustedDifficulty(log2Difficulty); + log.info("Got adjusted difficulty: {}", difficulty); + + return CompletableFuture.supplyAsync(() -> { + long ts = System.currentTimeMillis(); + byte[] solution = new Equihash(90, 5, difficulty).puzzle(challenge).findSolution().serialize(); + long counter = Longs.fromByteArray(Arrays.copyOf(solution, 8)); + var proofOfWork = new ProofOfWork(solution, counter, challenge, log2Difficulty, + System.currentTimeMillis() - ts, getVersion()); + log.info("Completed minting proofOfWork: {}", proofOfWork); + return proofOfWork; + }); + } + + @Override + public byte[] getChallenge(String itemId, String ownerId) { + checkArgument(!StringUtils.contains(itemId, '\0')); + checkArgument(!StringUtils.contains(ownerId, '\0')); + return Hash.getSha256Hash(checkNotNull(itemId) + "\0" + checkNotNull(ownerId)); + } + + @Override + boolean verify(ProofOfWork proofOfWork) { + double difficulty = adjustedDifficulty(proofOfWork.getNumLeadingZeros()); + + var puzzle = new Equihash(90, 5, difficulty).puzzle(proofOfWork.getChallenge()); + return puzzle.deserializeSolution(proofOfWork.getPayload()).verify(); + } + + private static double adjustedDifficulty(int log2Difficulty) { + return Equihash.adjustDifficulty(Math.scalb(DIFFICULTY_SCALE_FACTOR, log2Difficulty), + Equihash.EQUIHASH_n_5_MEAN_SOLUTION_COUNT_PER_NONCE); + } +} diff --git a/common/src/main/java/bisq/common/crypto/HashCashService.java b/common/src/main/java/bisq/common/crypto/HashCashService.java index ed0e30f0c5..bebbdd8246 100644 --- a/common/src/main/java/bisq/common/crypto/HashCashService.java +++ b/common/src/main/java/bisq/common/crypto/HashCashService.java @@ -35,40 +35,56 @@ import lombok.extern.slf4j.Slf4j; * See https://www.hashcash.org/papers/hashcash.pdf */ @Slf4j -public class HashCashService { +public class HashCashService extends ProofOfWorkService { // Default validations. Custom implementations might use tolerance. private static final BiPredicate isChallengeValid = Arrays::equals; private static final BiPredicate isDifficultyValid = Integer::equals; - public static CompletableFuture mint(byte[] payload, - byte[] challenge, - int difficulty) { + HashCashService() { + super(0); + } + + @Override + public CompletableFuture mint(String itemId, byte[] challenge, int log2Difficulty) { + byte[] payload = getBytes(itemId); + return mint(payload, challenge, log2Difficulty); + } + + @Override + public byte[] getChallenge(String itemId, String ownerId) { + return getBytes(itemId + ownerId); + } + + static CompletableFuture mint(byte[] payload, + byte[] challenge, + int difficulty) { return HashCashService.mint(payload, challenge, difficulty, HashCashService::testDifficulty); } - public static boolean verify(ProofOfWork proofOfWork) { + @Override + boolean verify(ProofOfWork proofOfWork) { return verify(proofOfWork, proofOfWork.getChallenge(), proofOfWork.getNumLeadingZeros()); } - public static boolean verify(ProofOfWork proofOfWork, - byte[] controlChallenge, - int controlDifficulty) { + static boolean verify(ProofOfWork proofOfWork, + byte[] controlChallenge, + int controlDifficulty) { return HashCashService.verify(proofOfWork, controlChallenge, controlDifficulty, HashCashService::testDifficulty); } - public static boolean verify(ProofOfWork proofOfWork, - byte[] controlChallenge, - int controlDifficulty, - BiPredicate challengeValidation, - BiPredicate difficultyValidation) { + static boolean verify(ProofOfWork proofOfWork, + byte[] controlChallenge, + int controlDifficulty, + BiPredicate challengeValidation, + BiPredicate difficultyValidation) { return HashCashService.verify(proofOfWork, controlChallenge, controlDifficulty, @@ -139,7 +155,7 @@ public class HashCashService { // Utils /////////////////////////////////////////////////////////////////////////////////////////// - public static byte[] getBytes(String value) { + private static byte[] getBytes(String value) { return value.getBytes(StandardCharsets.UTF_8); } diff --git a/common/src/main/java/bisq/common/crypto/ProofOfWorkService.java b/common/src/main/java/bisq/common/crypto/ProofOfWorkService.java new file mode 100644 index 0000000000..419c6587ef --- /dev/null +++ b/common/src/main/java/bisq/common/crypto/ProofOfWorkService.java @@ -0,0 +1,72 @@ +/* + * This file is part of Bisq. + * + * Bisq is free software: you can redistribute it and/or modify it + * under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or (at + * your option) any later version. + * + * Bisq is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public + * License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with Bisq. If not, see . + */ + +package bisq.common.crypto; + +import com.google.common.base.Preconditions; + +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiPredicate; + +import lombok.Getter; + +public abstract class ProofOfWorkService { + private static class InstanceHolder { + private static final ProofOfWorkService[] INSTANCES = { + new HashCashService(), + new EquihashProofOfWorkService(1) + }; + } + + public static Optional forVersion(int version) { + return version >= 0 && version < InstanceHolder.INSTANCES.length ? + Optional.of(InstanceHolder.INSTANCES[version]) : Optional.empty(); + } + + @Getter + private final int version; + + ProofOfWorkService(int version) { + this.version = version; + } + + public abstract CompletableFuture mint(String itemId, byte[] challenge, int log2Difficulty); + + public abstract byte[] getChallenge(String itemId, String ownerId); + + abstract boolean verify(ProofOfWork proofOfWork); + + public CompletableFuture mint(String itemId, String ownerId, int log2Difficulty) { + return mint(itemId, getChallenge(itemId, ownerId), log2Difficulty); + } + + public boolean verify(ProofOfWork proofOfWork, + String itemId, + String ownerId, + int controlLog2Difficulty, + BiPredicate challengeValidation, + BiPredicate difficultyValidation) { + + Preconditions.checkArgument(proofOfWork.getVersion() == version); + + byte[] controlChallenge = getChallenge(itemId, ownerId); + return challengeValidation.test(proofOfWork.getChallenge(), controlChallenge) && + difficultyValidation.test(proofOfWork.getNumLeadingZeros(), controlLog2Difficulty) && + verify(proofOfWork); + } +} diff --git a/common/src/test/java/bisq/common/crypto/HashCashServiceTest.java b/common/src/test/java/bisq/common/crypto/HashCashServiceTest.java index 115483d25f..9c4fcb1f53 100644 --- a/common/src/test/java/bisq/common/crypto/HashCashServiceTest.java +++ b/common/src/test/java/bisq/common/crypto/HashCashServiceTest.java @@ -36,7 +36,7 @@ public class HashCashServiceTest { @Test public void testDiffIncrease() throws ExecutionException, InterruptedException { StringBuilder stringBuilder = new StringBuilder(); - for (int i = 0; i < 12; i++) { + for (int i = 0; i < 9; i++) { run(i, stringBuilder); } log.info(stringBuilder.toString()); @@ -70,7 +70,7 @@ public class HashCashServiceTest { double size = tokens.size(); long ts2 = System.currentTimeMillis(); long averageCounter = Math.round(tokens.stream().mapToLong(ProofOfWork::getCounter).average().orElse(0)); - boolean allValid = tokens.stream().allMatch(HashCashService::verify); + boolean allValid = tokens.stream().allMatch(new HashCashService()::verify); assertTrue(allValid); double time1 = (System.currentTimeMillis() - ts) / size; double time2 = (System.currentTimeMillis() - ts2) / size; diff --git a/core/src/main/java/bisq/core/filter/FilterManager.java b/core/src/main/java/bisq/core/filter/FilterManager.java index 5d80591b39..c95c605192 100644 --- a/core/src/main/java/bisq/core/filter/FilterManager.java +++ b/core/src/main/java/bisq/core/filter/FilterManager.java @@ -37,8 +37,9 @@ import bisq.common.app.DevEnv; import bisq.common.app.Version; import bisq.common.config.Config; import bisq.common.config.ConfigFileEditor; -import bisq.common.crypto.HashCashService; +import bisq.common.crypto.ProofOfWorkService; import bisq.common.crypto.KeyRing; +import bisq.common.crypto.ProofOfWork; import org.bitcoinj.core.ECKey; import org.bitcoinj.core.Sha256Hash; @@ -498,13 +499,23 @@ public class FilterManager { } checkArgument(offer.getBsqSwapOfferPayload().isPresent(), "Offer payload must be BsqSwapOfferPayload"); - return HashCashService.verify(offer.getBsqSwapOfferPayload().get().getProofOfWork(), - HashCashService.getBytes(offer.getId() + offer.getOwnerNodeAddress().toString()), + ProofOfWork pow = offer.getBsqSwapOfferPayload().get().getProofOfWork(); + var service = ProofOfWorkService.forVersion(pow.getVersion()); + if (!service.isPresent() || !getEnabledPowVersions().contains(pow.getVersion())) { + return false; + } + return service.get().verify(offer.getBsqSwapOfferPayload().get().getProofOfWork(), + offer.getId(), offer.getOwnerNodeAddress().toString(), filter.getPowDifficulty(), challengeValidation, difficultyValidation); } + public List getEnabledPowVersions() { + Filter filter = getFilter(); + return filter != null && !filter.getEnabledPowVersions().isEmpty() ? filter.getEnabledPowVersions() : List.of(0); + } + /////////////////////////////////////////////////////////////////////////////////////////// // Private diff --git a/core/src/main/java/bisq/core/offer/bsq_swap/OpenBsqSwapOfferService.java b/core/src/main/java/bisq/core/offer/bsq_swap/OpenBsqSwapOfferService.java index 3f202abc30..962983b820 100644 --- a/core/src/main/java/bisq/core/offer/bsq_swap/OpenBsqSwapOfferService.java +++ b/core/src/main/java/bisq/core/offer/bsq_swap/OpenBsqSwapOfferService.java @@ -43,7 +43,7 @@ import bisq.network.p2p.P2PService; import bisq.common.UserThread; import bisq.common.app.Version; -import bisq.common.crypto.HashCashService; +import bisq.common.crypto.ProofOfWorkService; import bisq.common.crypto.PubKeyRing; import bisq.common.handlers.ErrorMessageHandler; import bisq.common.handlers.ResultHandler; @@ -61,6 +61,7 @@ import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.NoSuchElementException; import java.util.Objects; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; @@ -195,13 +196,11 @@ public class OpenBsqSwapOfferService { amount.value, minAmount.value); - NodeAddress makerAddress = p2PService.getAddress(); + NodeAddress makerAddress = Objects.requireNonNull(p2PService.getAddress()); offerUtil.validateBasicOfferData(PaymentMethod.BSQ_SWAP, "BSQ"); - byte[] payload = HashCashService.getBytes(offerId); - byte[] challenge = HashCashService.getBytes(offerId + Objects.requireNonNull(makerAddress)); - int difficulty = getPowDifficulty(); - HashCashService.mint(payload, challenge, difficulty) + int log2Difficulty = getPowDifficulty(); + getPowService().mint(offerId, makerAddress.getFullAddress(), log2Difficulty) .whenComplete((proofOfWork, throwable) -> { // We got called from a non user thread... UserThread.execute(() -> { @@ -347,11 +346,9 @@ public class OpenBsqSwapOfferService { openOfferManager.removeOpenOffer(openOffer); String newOfferId = OfferUtil.getOfferIdWithMutationCounter(openOffer.getId()); - byte[] payload = HashCashService.getBytes(newOfferId); NodeAddress nodeAddress = Objects.requireNonNull(openOffer.getOffer().getMakerNodeAddress()); - byte[] challenge = HashCashService.getBytes(newOfferId + nodeAddress); - int difficulty = getPowDifficulty(); - HashCashService.mint(payload, challenge, difficulty) + int log2Difficulty = getPowDifficulty(); + getPowService().mint(newOfferId, nodeAddress.getFullAddress(), log2Difficulty) .whenComplete((proofOfWork, throwable) -> { // We got called from a non user thread... UserThread.execute(() -> { @@ -393,4 +390,16 @@ public class OpenBsqSwapOfferService { private int getPowDifficulty() { return filterManager.getFilter() != null ? filterManager.getFilter().getPowDifficulty() : 0; } + + private ProofOfWorkService getPowService() { + var service = filterManager.getEnabledPowVersions().stream() + .flatMap(v -> ProofOfWorkService.forVersion(v).stream()) + .findFirst(); + if (!service.isPresent()) { + // We cannot exit normally, else we get caught in an infinite loop generating invalid PoWs. + throw new NoSuchElementException("Could not find a suitable PoW version to use."); + } + log.info("Selected PoW version {}, service instance {}", service.get().getVersion(), service.get()); + return service.get(); + } }