diff --git a/application/src/main/java/bisq/application/DefaultApplicationService.java b/application/src/main/java/bisq/application/DefaultApplicationService.java index 8736440004..66cd9f5cd4 100644 --- a/application/src/main/java/bisq/application/DefaultApplicationService.java +++ b/application/src/main/java/bisq/application/DefaultApplicationService.java @@ -102,7 +102,8 @@ public DefaultApplicationService(String[] args) { accountService = new AccountService(persistenceService); - userProfileService = new UserProfileService(persistenceService, keyPairService, identityService, networkService); + UserProfileService.Config userProfileServiceConfig = UserProfileService.Config.from(getConfig("bisq.userProfileServiceConfig")); + userProfileService = new UserProfileService(persistenceService, userProfileServiceConfig, keyPairService, identityService, networkService); chatService = new ChatService(persistenceService, identityService, networkService,userProfileService); tradeIntentListingsService = new TradeIntentListingsService(networkService); tradeIntentService = new TradeIntentService(networkService, identityService, tradeIntentListingsService, chatService); diff --git a/application/src/main/resources/Bisq.conf b/application/src/main/resources/Bisq.conf index 2d76331fe9..fecfd4d9c5 100644 --- a/application/src/main/resources/Bisq.conf +++ b/application/src/main/resources/Bisq.conf @@ -7,6 +7,19 @@ bisq { minPoolSize = 5 } + userProfileServiceConfig = { + btcMempoolProviders = [ + "https://mempool.emzy.de/api/", + "https://mempool.space/api/", + "https://markets.bisq.services/api/" + ] + bsqMempoolProviders = [ + "https://bisq.mempool.emzy.de/bisq/api/", + "https://bisq.markets/bisq/api/", + "https://markets.bisq.services/bisq/api/" + ] + } + networkServiceConfig = { // supportedTransportTypes = ["CLEAR", "TOR", "I2P"] supportedTransportTypes = ["CLEAR"] diff --git a/desktop/src/main/java/bisq/desktop/primary/main/content/social/profile/components/EntitlementSelection.java b/desktop/src/main/java/bisq/desktop/primary/main/content/social/profile/components/EntitlementSelection.java index b84f2d8032..bf707ec4fe 100644 --- a/desktop/src/main/java/bisq/desktop/primary/main/content/social/profile/components/EntitlementSelection.java +++ b/desktop/src/main/java/bisq/desktop/primary/main/content/social/profile/components/EntitlementSelection.java @@ -123,10 +123,10 @@ private CompletableFuture> onVerifyProofOfBurn(Entit }); } - private CompletableFuture> onVerifyBondedRole(EntitlementItem entitlementItem, String bondedRoleTxId, String bondedRoleSig) { + private CompletableFuture> onVerifyBondedRole(EntitlementItem entitlementItem, String bondedRoleTxId, String pubKeyHash, String bondedRoleSig) { return userProfileService.verifyBondedRole(bondedRoleTxId, bondedRoleSig, - model.keyPair.get().getPublic()) + pubKeyHash) .whenComplete((proof, throwable) -> { UIThread.run(() -> { if (throwable == null) { @@ -180,6 +180,9 @@ private static class Model implements bisq.desktop.common.view.Model { private final BooleanProperty tableVisible = new SimpleBooleanProperty(); private final ObjectProperty keyPair; private String minBurnAmount; + private String getPubKeyHash() { + return Hex.encode(DigestUtil.hash(keyPair.get().getPublic().getEncoded())); + } private Model(ObjectProperty keyPair) { this.keyPair = keyPair; @@ -312,7 +315,7 @@ protected void addContent() { GridPane.setMargin(messageLabel, new Insets(0, 0, 20, 0)); - String pubKeyHash = Hex.encode(DigestUtil.hash(model.keyPair.get().getPublic().getEncoded())); + String pubKeyHash = model.getPubKeyHash(); gridPane.addTextFieldWithCopyIcon(Res.get("social.createUserProfile.entitlement.popup.pubKeyHash"), pubKeyHash); gridPane.addTextFieldWithCopyIcon(Res.get("social.createUserProfile.entitlement.popup.minBurnAmount"), model.minBurnAmount); @@ -346,7 +349,7 @@ protected void addContent() { secondField = gridPane.addTextField(Res.get("social.createUserProfile.entitlement.popup.bondedRole.sig"), ""); onAction(() -> { actionButton.setDisable(true); //todo add busy animation - controller.onVerifyBondedRole(entitlementItem, firstField.getText(), secondField.getText()) + controller.onVerifyBondedRole(entitlementItem, firstField.getText(), model.getPubKeyHash(), secondField.getText()) .whenComplete((proof, throwable) -> { UIThread.run(() -> { if (throwable == null && proof.isPresent()) { diff --git a/network/src/main/java/bisq/network/http/common/BaseHttpClient.java b/network/src/main/java/bisq/network/http/common/BaseHttpClient.java index f0b4f1e227..9f2aa482ac 100644 --- a/network/src/main/java/bisq/network/http/common/BaseHttpClient.java +++ b/network/src/main/java/bisq/network/http/common/BaseHttpClient.java @@ -30,7 +30,7 @@ @Slf4j public abstract class BaseHttpClient implements HttpClient { public final String baseUrl; - protected final String userAgent; + public final String userAgent; protected final String uid; public boolean hasPendingRequest; diff --git a/security/src/main/java/bisq/security/KeyGeneration.java b/security/src/main/java/bisq/security/KeyGeneration.java index 10852bbca9..f3455eb2a3 100644 --- a/security/src/main/java/bisq/security/KeyGeneration.java +++ b/security/src/main/java/bisq/security/KeyGeneration.java @@ -17,7 +17,12 @@ package bisq.security; +import org.bouncycastle.jce.ECNamedCurveTable; +import org.bouncycastle.jce.ECPointUtil; import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.jce.provider.asymmetric.ec.EC5Util; +import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec; +import org.bouncycastle.math.ec.ECCurve; import java.security.*; import java.security.spec.ECGenParameterSpec; @@ -47,6 +52,17 @@ public static PublicKey generatePublic(byte[] encodedKey) throws GeneralSecurity return getKeyFactory().generatePublic(keySpec); } + public static PublicKey generatePublicFromCompressed(byte[] compressedKey) throws GeneralSecurityException { + ECNamedCurveParameterSpec params = ECNamedCurveTable.getParameterSpec("secp256k1"); + KeyFactory fact = KeyFactory.getInstance("ECDSA", "BC"); + ECCurve curve = params.getCurve(); + java.security.spec.EllipticCurve ellipticCurve = EC5Util.convertCurve(curve, params.getSeed()); + java.security.spec.ECPoint point = ECPointUtil.decodePoint(ellipticCurve, compressedKey); + java.security.spec.ECParameterSpec params2 = EC5Util.convertSpec(ellipticCurve, params); + java.security.spec.ECPublicKeySpec keySpec = new java.security.spec.ECPublicKeySpec(point,params2); + return fact.generatePublic(keySpec); + } + public static PrivateKey generatePrivate(byte[] encodedKey) throws GeneralSecurityException { EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encodedKey); return getKeyFactory().generatePrivate(keySpec); diff --git a/security/src/main/java/bisq/security/SignatureUtil.java b/security/src/main/java/bisq/security/SignatureUtil.java index d64302218c..3a9c081e40 100644 --- a/security/src/main/java/bisq/security/SignatureUtil.java +++ b/security/src/main/java/bisq/security/SignatureUtil.java @@ -17,8 +17,14 @@ package bisq.security; +import bisq.common.encoding.Hex; +import bisq.common.encoding.Base64; + import org.bouncycastle.jce.provider.BouncyCastleProvider; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.security.*; public class SignatureUtil { @@ -43,4 +49,35 @@ public static boolean verify(byte[] message, byte[] signature, PublicKey publicK sig.update(message); return sig.verify(signature); } + + // input: a base-64 bitcoin sig + // output a DER signature + public static byte [] bitcoinSigToDer(String bitcoinSig) { + String sigHex = Hex.encode(Base64.decode(bitcoinSig)); + String r = Integer.parseInt(sigHex.substring(2, 4), 16 ) > 127 ? + "00" + sigHex.substring(2, 66) : sigHex.substring(2, 66); + String s = Integer.parseInt(sigHex.substring(66, 68), 16 ) > 127 ? + "00" + sigHex.substring(66) : sigHex.substring(66); + String result = "02" + String.format("%02X", r.length() / 2) + r + + "02" + String.format("%02X", s.length() / 2) + s; + result = "30" + String.format("%02X", result.length() / 2) + result; + return Hex.decode(result); + } + + private static final String BITCOIN_SIGNED_MESSAGE_HEADER = "Bitcoin Signed Message:\n"; + private static final byte[] BITCOIN_SIGNED_MESSAGE_HEADER_BYTES = BITCOIN_SIGNED_MESSAGE_HEADER.getBytes(StandardCharsets.UTF_8); + + public static byte[] formatMessageForSigning(String message) { + try { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + bos.write(BITCOIN_SIGNED_MESSAGE_HEADER_BYTES.length); + bos.write(BITCOIN_SIGNED_MESSAGE_HEADER_BYTES); + byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8); + bos.write(messageBytes.length); + bos.write(messageBytes); + return DigestUtil.sha256(DigestUtil.sha256(bos.toByteArray())); + } catch (IOException e) { + throw new RuntimeException(e); // Cannot happen. + } + } } diff --git a/social/build.gradle b/social/build.gradle index 972808f283..305f682415 100644 --- a/social/build.gradle +++ b/social/build.gradle @@ -25,6 +25,7 @@ dependencies { implementation project(':settings') implementation 'com.github.chimp1984:jsocks' + implementation "com.google.code.gson:gson:2.8.5" implementation 'com.google.guava:guava' implementation 'com.typesafe:config' } diff --git a/social/src/main/java/bisq/social/chat/ChatService.java b/social/src/main/java/bisq/social/chat/ChatService.java index c5d6fc4880..287c3941dd 100644 --- a/social/src/main/java/bisq/social/chat/ChatService.java +++ b/social/src/main/java/bisq/social/chat/ChatService.java @@ -153,7 +153,7 @@ public CompletableFuture> addChannel(UserProfile userPro .map(entitlement -> (Entitlement.BondedRoleProof) entitlement.proof()) .map(bondedRoleProof -> userProfileService.verifyBondedRole(bondedRoleProof.txId(), bondedRoleProof.signature(), - userProfile.identity().pubKey().publicKey())) + userProfile.identity().id())) .map(future -> future.thenApply(optionalProof -> optionalProof.map(e -> { PublicChannel publicChannel = new PublicChannel(StringUtils.createUid(), channelName, userProfile); persistableStore.getPublicChannels().add(publicChannel); diff --git a/social/src/main/java/bisq/social/userprofile/BsqTxValidator.java b/social/src/main/java/bisq/social/userprofile/BsqTxValidator.java new file mode 100644 index 0000000000..8255c60d7f --- /dev/null +++ b/social/src/main/java/bisq/social/userprofile/BsqTxValidator.java @@ -0,0 +1,110 @@ +/* + * 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.social.userprofile; + +import bisq.common.data.Pair; +import bisq.common.encoding.Hex; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; + +import lombok.extern.slf4j.Slf4j; + +import java.util.Optional; + +@Slf4j +public class BsqTxValidator { + + public static boolean initialSanityChecks(String txId, String jsonTxt) { + if (jsonTxt == null || jsonTxt.length() == 0) { + return false; + } + JsonObject json = new Gson().fromJson(jsonTxt, JsonObject.class); + // there should always be "id" string element at the top level + if (json.get("id") == null) { + return false; + } + // txid should match what we requested + if (!txId.equals(json.get("id").getAsString())) { + return false; + } + return true; + } + + public static boolean isBsqTx(String url, String txId, String jsonTxt) { + return url.matches(".*bisq.*") && initialSanityChecks(txId, jsonTxt); + } + + public static boolean isProofOfBurn(String jsonTxt) { + JsonObject json = new Gson().fromJson(jsonTxt, JsonObject.class); + JsonElement txType = json.get("txType"); + if (txType == null) { + return false; + } + return txType.getAsString().equalsIgnoreCase("PROOF_OF_BURN"); + } + + public static boolean isLockup(String jsonTxt) { + JsonObject json = new Gson().fromJson(jsonTxt, JsonObject.class); + JsonElement txType = json.get("txType"); + if (txType == null) { + return false; + } + return txType.getAsString().equalsIgnoreCase("LOCKUP"); + } + + public static long getBurntAmount(String jsonTxt) { + JsonObject json = new Gson().fromJson(jsonTxt, JsonObject.class); + JsonElement burntFee = json.get("burntFee"); + if (burntFee == null) { + return 0; // no json element, assume zero burnt amount + } + return burntFee.getAsLong(); + } + + public static Optional getOpReturnData(String jsonTxt) { + try { + Pair vinAndVout = getVinAndVout(jsonTxt); + JsonArray voutArray = vinAndVout.second(); + for (JsonElement x : voutArray) { + JsonObject y = x.getAsJsonObject(); + if (y.get("txOutputType").getAsString().matches(".*OP_RETURN.*")) { + return Optional.of(y.get("opReturn").getAsString()); + } + } + } catch (JsonSyntaxException e) { + log.error("json error:", e); + } + return Optional.empty(); + } + + private static Pair getVinAndVout(String jsonTxt) throws JsonSyntaxException { + JsonObject json = new Gson().fromJson(jsonTxt, JsonObject.class); + if (json.get("inputs") == null || json.get("outputs") == null) { + throw new JsonSyntaxException("missing vin/vout"); + } + JsonArray jsonVin = json.get("inputs").getAsJsonArray(); + JsonArray jsonVout = json.get("outputs").getAsJsonArray(); + if (jsonVin == null || jsonVout == null || jsonVin.size() < 1 || jsonVout.size() < 1) { + throw new JsonSyntaxException("not enough vins/vouts"); + } + return new Pair<>(jsonVin, jsonVout); + } +} diff --git a/social/src/main/java/bisq/social/userprofile/BtcTxValidator.java b/social/src/main/java/bisq/social/userprofile/BtcTxValidator.java new file mode 100644 index 0000000000..2a830f4772 --- /dev/null +++ b/social/src/main/java/bisq/social/userprofile/BtcTxValidator.java @@ -0,0 +1,93 @@ +/* + * 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.social.userprofile; + +import bisq.common.data.Pair; +import bisq.common.encoding.Hex; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; + +import lombok.extern.slf4j.Slf4j; + +import java.security.PublicKey; + +@Slf4j +public class BtcTxValidator { + + private static int PUBLIC_KEY_LENGTH = 33; + + public static boolean initialSanityChecks(String txId, String jsonTxt) { + if (jsonTxt == null || jsonTxt.length() == 0) { + return false; + } + JsonObject json = new Gson().fromJson(jsonTxt, JsonObject.class); + // there should always be "txid" string element at the top level + if (json.get("txid") == null) { + return false; + } + // txid should match what we requested + if (!txId.equals(json.get("txid").getAsString())) { + return false; + } + return true; + } + + public static String getFirstInputPubKey(String jsonTxt) { + try { + Pair vinAndVout = getVinAndVout(jsonTxt); + JsonArray vinArray = vinAndVout.first(); + for (JsonElement x : vinArray) { + JsonObject vin = x.getAsJsonObject(); + // pubKey in witness or scriptsig (legacy or segwit txs) + JsonArray witnesses = vin.getAsJsonArray("witness"); + if (witnesses != null) { + String witnessPubKey = witnesses.get(1).getAsString(); + if (witnessPubKey.length() >= PUBLIC_KEY_LENGTH * 2) { + return witnessPubKey; + } + } + JsonElement scriptsig = vin.get("scriptsig"); + if (scriptsig != null) { + String scriptsigAsHex = scriptsig.getAsString(); + if (scriptsigAsHex.length() >= PUBLIC_KEY_LENGTH * 2) { + return scriptsigAsHex.substring(scriptsigAsHex.length() - PUBLIC_KEY_LENGTH * 2); + } + } + } + } catch (JsonSyntaxException e) { + log.error("json error:", e); + } + throw new JsonSyntaxException("could not find pubKey"); + } + + private static Pair getVinAndVout(String jsonTxt) throws JsonSyntaxException { + JsonObject json = new Gson().fromJson(jsonTxt, JsonObject.class); + if (json.get("vin") == null || json.get("vout") == null) { + throw new JsonSyntaxException("missing vin/vout"); + } + JsonArray jsonVin = json.get("vin").getAsJsonArray(); + JsonArray jsonVout = json.get("vout").getAsJsonArray(); + if (jsonVin == null || jsonVout == null || jsonVin.size() < 1 || jsonVout.size() < 1) { + throw new JsonSyntaxException("not enough vins/vouts"); + } + return new Pair<>(jsonVin, jsonVout); + } +} diff --git a/social/src/main/java/bisq/social/userprofile/UserProfileService.java b/social/src/main/java/bisq/social/userprofile/UserProfileService.java index c5cb49cd3b..6d92407884 100644 --- a/social/src/main/java/bisq/social/userprofile/UserProfileService.java +++ b/social/src/main/java/bisq/social/userprofile/UserProfileService.java @@ -17,8 +17,9 @@ package bisq.social.userprofile; - import bisq.common.data.Pair; +import bisq.common.encoding.Hex; +import bisq.common.encoding.Base64; import bisq.common.util.CollectionUtil; import bisq.common.util.StringUtils; import bisq.identity.IdentityService; @@ -28,23 +29,38 @@ import bisq.persistence.Persistence; import bisq.persistence.PersistenceClient; import bisq.persistence.PersistenceService; +import bisq.security.KeyGeneration; import bisq.security.KeyPairService; +import bisq.security.DigestUtil; +import bisq.security.SignatureUtil; + +import com.google.gson.JsonSyntaxException; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import java.io.IOException; +import java.security.GeneralSecurityException; import java.security.KeyPair; import java.security.PublicKey; -import java.util.HashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.concurrent.CompletableFuture; +import static bisq.security.SignatureUtil.formatMessageForSigning; +import static bisq.security.SignatureUtil.bitcoinSigToDer; +import static com.google.common.base.Preconditions.checkArgument; import static java.util.concurrent.CompletableFuture.supplyAsync; @Slf4j public class UserProfileService implements PersistenceClient { + public static record Config(List btcMempoolProviders, + List bsqMempoolProviders) { + public static Config from(com.typesafe.config.Config typeSafeConfig) { + List btcMempoolProviders = typeSafeConfig.getStringList("btcMempoolProviders"); + List bsqMempoolProviders = typeSafeConfig.getStringList("bsqMempoolProviders"); + return new UserProfileService.Config(btcMempoolProviders, bsqMempoolProviders); + } + } + @Getter private final UserProfileStore persistableStore = new UserProfileStore(); @Getter @@ -53,12 +69,15 @@ public class UserProfileService implements PersistenceClient { private final IdentityService identityService; private final NetworkService networkService; private final Object lock = new Object(); + private final Config config; public UserProfileService(PersistenceService persistenceService, + Config config, KeyPairService keyPairService, IdentityService identityService, NetworkService networkService) { persistence = persistenceService.getOrCreatePersistence(this, persistableStore); + this.config = config; this.keyPairService = keyPairService; this.identityService = identityService; this.networkService = networkService; @@ -126,53 +145,59 @@ public void selectUserProfile(UserProfile value) { }*/ public CompletableFuture> verifyProofOfBurn(Entitlement.Type type, String proofOfBurnTxId, String pubKeyHash) { - // todo impl - // https://github.com/bisq-network/bisq2/issues/68 - // - String userAgent = "Bisq 2"; - //todo add providers to Bisq.config - List providers = List.of("https://bisq.mempool.emzy.de/api/tx/", - "https://mempool.space/bisq/api/tx/", - "https://mempool.bisq.services/bisq/api/tx/"); - String url = CollectionUtil.getRandomElement(providers); - Set supportedTransportTypes = networkService.getSupportedTransportTypes(); - Transport.Type transportType; - if (supportedTransportTypes.contains(Transport.Type.CLEAR)) { - transportType = Transport.Type.CLEAR; - } else if (supportedTransportTypes.contains(Transport.Type.TOR)) { - transportType = Transport.Type.TOR; - } else { - throw new RuntimeException("I2P is not supported yet"); - } - BaseHttpClient httpClient = networkService.getHttpClient(url, userAgent, transportType); return supplyAsync(() -> { try { - String json = httpClient.get(proofOfBurnTxId, Optional.of(new Pair<>("User-Agent", userAgent))); - //todo parse json - boolean isBsqTx = true; //todo - boolean isProofOfBurn = true; //todo - String opReturn = pubKeyHash; //todo - long minBurnAmount = getMinBurnAmount(type); //todo verify if tx burned amount is >= minBurnAmount - if (isBsqTx && isProofOfBurn && pubKeyHash.equals(opReturn)) { - return Optional.of(new Entitlement.ProofOfBurnProof(proofOfBurnTxId)); - } else { - return Optional.empty(); - } + BaseHttpClient httpClient = getApiHttpClient(config.bsqMempoolProviders()); + String jsonBsqTx = httpClient.get("tx/" + proofOfBurnTxId, Optional.of(new Pair<>("User-Agent", httpClient.userAgent))); + checkArgument(BsqTxValidator.initialSanityChecks(proofOfBurnTxId, jsonBsqTx), "bsq tx sanity checks"); + checkArgument(BsqTxValidator.isBsqTx(httpClient.getBaseUrl(), proofOfBurnTxId, jsonBsqTx), "isBsqTx"); + checkArgument(BsqTxValidator.isProofOfBurn(jsonBsqTx), "is proof of burn transaction"); + checkArgument(BsqTxValidator.getBurntAmount(jsonBsqTx) >= getMinBurnAmount(type), "insufficient burn"); + BsqTxValidator.getOpReturnData(jsonBsqTx).ifPresentOrElse( + (opReturn) -> checkArgument(pubKeyHash.equalsIgnoreCase(opReturn), "opReturnMatches"), + () -> { throw new IllegalArgumentException("no opreturn found"); }); + return Optional.of(new Entitlement.ProofOfBurnProof(proofOfBurnTxId)); + } catch (IllegalArgumentException e) { + log.warn("check failed: {}", e.getMessage(), e); } catch (IOException e) { - e.printStackTrace(); - return Optional.empty(); + log.warn("mempool query failed:", e); } + return Optional.empty(); }); } - // result for proof of burn tx id 3bbb2f597e714257d4a2f573e9ebfff4ab631277186a40875dbbf4140e90b748 - // - /* - {"txid":"3bbb2f597e714257d4a2f573e9ebfff4ab631277186a40875dbbf4140e90b748","version":1,"locktime":0,"vin":[{"txid":"706d78ae02013837ca1ea83b2ac7cae2481a1a3de2c0a1401e78d4736ac6505b","vout":1,"prevout":{"scriptpubkey":"76a91499898c703b6c37d9d753883056bca076e430293d88ac","scriptpubkey_asm":"OP_DUP OP_HASH160 OP_PUSHBYTES_20 99898c703b6c37d9d753883056bca076e430293d OP_EQUALVERIFY OP_CHECKSIG","scriptpubkey_type":"p2pkh","scriptpubkey_address":"1EzqAPpc7MqTncf6atHMMfCS4UBX8vwzvB","value":1133333},"scriptsig":"473044022030e579d8282f15743824ab44aa43baf44b865c8bc9b0098115c59cd44a4a6f8d02205b4152057fab450e23ab65918267ef7d0f84948621a122ed3093b58e8c2dcd2c01210285f726704fd47100df1921f47bda36d266d31a38040bde3b2ffc0639b069bda6","scriptsig_asm":"OP_PUSHBYTES_71 3044022030e579d8282f15743824ab44aa43baf44b865c8bc9b0098115c59cd44a4a6f8d02205b4152057fab450e23ab65918267ef7d0f84948621a122ed3093b58e8c2dcd2c01 OP_PUSHBYTES_33 0285f726704fd47100df1921f47bda36d266d31a38040bde3b2ffc0639b069bda6","is_coinbase":false,"sequence":4294967295},{"txid":"706d78ae02013837ca1ea83b2ac7cae2481a1a3de2c0a1401e78d4736ac6505b","vout":2,"prevout":{"scriptpubkey":"76a914754b3c2861c4a28fbf993bf938c70b1429cb58b188ac","scriptpubkey_asm":"OP_DUP OP_HASH160 OP_PUSHBYTES_20 754b3c2861c4a28fbf993bf938c70b1429cb58b1 OP_EQUALVERIFY OP_CHECKSIG","scriptpubkey_type":"p2pkh","scriptpubkey_address":"1BhCBs46WpMkG9vYxyma9eiT5BMPJrbEiK","value":52393250},"scriptsig":"483045022100ea0aa77e1e58005996c7c62f08b933271c33c0c0d8ff5b0e6fdc171338d0ec5f022023ea101475313227165d0a4791273baabea5e2690cce5467c78307ec9a9d31180121029f0e29b99d820af0c0cd96bebea8a330d95b041eb3049438eec1b6e26bb5e330","scriptsig_asm":"OP_PUSHBYTES_72 3045022100ea0aa77e1e58005996c7c62f08b933271c33c0c0d8ff5b0e6fdc171338d0ec5f022023ea101475313227165d0a4791273baabea5e2690cce5467c78307ec9a9d311801 OP_PUSHBYTES_33 029f0e29b99d820af0c0cd96bebea8a330d95b041eb3049438eec1b6e26bb5e330","is_coinbase":false,"sequence":4294967295}],"vout":[{"scriptpubkey":"76a914926ed5b7088bfb50bf2a28ee782f92c76f1190fe88ac","scriptpubkey_asm":"OP_DUP OP_HASH160 OP_PUSHBYTES_20 926ed5b7088bfb50bf2a28ee782f92c76f1190fe OP_EQUALVERIFY OP_CHECKSIG","scriptpubkey_type":"p2pkh","scriptpubkey_address":"1EMGRwrmJ5EN95H5aXkjr9fG3HjnPAuM2A","value":1132333},{"scriptpubkey":"76a9140e54b44d63fa16782f11c2fe9ff580d3718d183a88ac","scriptpubkey_asm":"OP_DUP OP_HASH160 OP_PUSHBYTES_20 0e54b44d63fa16782f11c2fe9ff580d3718d183a OP_EQUALVERIFY OP_CHECKSIG","scriptpubkey_type":"p2pkh","scriptpubkey_address":"12Jmw3YwGCn83uZBzKSb5jnJWxcfaK2FnX","value":52390190},{"scriptpubkey":"6a161701544b4337cd600cad892f12383525087bebc8b591","scriptpubkey_asm":"OP_RETURN OP_PUSHBYTES_22 1701544b4337cd600cad892f12383525087bebc8b591","scriptpubkey_type":"op_return","value":0}],"size":406,"weight":1624,"fee":4060,"status":{"confirmed":true,"block_height":613546,"block_hash":"0000000000000000000496cd14e563bb739f09da09ffe42297e35a3be133383c","block_time":1579438021}} - */ - public CompletableFuture> verifyBondedRole(String txId, String signature, PublicKey publicKey) { - //todo - return CompletableFuture.completedFuture(Optional.of(new Entitlement.BondedRoleProof(txId, signature))); + public CompletableFuture> verifyBondedRole(String txId, String signature, String pubKeyHash) { + // inputs: txid, signature from Bisq1 + // process: get txinfo, grab pubkey from 1st output + // verify provided signature matches with pubkey from 1st output and hash of provided identity pubkey + return supplyAsync(() -> { + try { + BaseHttpClient httpClientBsq = getApiHttpClient(config.bsqMempoolProviders()); + BaseHttpClient httpClientBtc = getApiHttpClient(config.btcMempoolProviders()); + String jsonBsqTx = httpClientBsq.get("tx/" + txId, Optional.of(new Pair<>("User-Agent", httpClientBsq.userAgent))); + String jsonBtcTx = httpClientBtc.get("tx/" + txId, Optional.of(new Pair<>("User-Agent", httpClientBtc.userAgent))); + checkArgument(BsqTxValidator.initialSanityChecks(txId, jsonBsqTx), "bsq tx sanity checks"); + checkArgument(BtcTxValidator.initialSanityChecks(txId, jsonBtcTx), "btc tx sanity checks"); + checkArgument(BsqTxValidator.isBsqTx(httpClientBsq.getBaseUrl(), txId, jsonBsqTx), "isBsqTx"); + checkArgument(BsqTxValidator.isLockup(jsonBsqTx), "is lockup transaction"); + String signingPubkeyAsHex = BtcTxValidator.getFirstInputPubKey(jsonBtcTx); + PublicKey signingPubKey = KeyGeneration.generatePublicFromCompressed(Hex.decode(signingPubkeyAsHex)); + boolean signatureMatches = SignatureUtil.verify(formatMessageForSigning(pubKeyHash), bitcoinSigToDer(signature), signingPubKey); + checkArgument(signatureMatches, "signature"); + return Optional.of(new Entitlement.BondedRoleProof(txId, signature)); + } catch (IllegalArgumentException e) { + log.warn("check failed: {}", e.getMessage(), e); + } catch (IOException e) { + log.warn("mempool query failed:", e); + } catch (GeneralSecurityException e) { + log.warn("signature validation failed:", e); + } catch (JsonSyntaxException e) { + log.warn("json decoding failed:", e); + } catch (NullPointerException e) { + log.error("unexpected failure:", e); + } + return Optional.empty(); + }); } public CompletableFuture> verifyModerator(String invitationCode, PublicKey publicKey) { @@ -180,7 +205,6 @@ public CompletableFuture> verifyModerator(String inv return CompletableFuture.completedFuture(Optional.of(new Entitlement.ChannelAdminInvitationProof(invitationCode))); } - private CompletableFuture createDefaultUserProfile() { String keyId = StringUtils.createUid(); KeyPair keyPair = keyPairService.generateKeyPair(); @@ -198,4 +222,20 @@ public long getMinBurnAmount(Entitlement.Type type) { default -> 0; }; } + + private BaseHttpClient getApiHttpClient(List providerUrls) { + String userAgent = "Bisq 2"; + String url = CollectionUtil.getRandomElement(providerUrls); + Set supportedTransportTypes = networkService.getSupportedTransportTypes(); + Transport.Type transportType; + if (supportedTransportTypes.contains(Transport.Type.CLEAR)) { + transportType = Transport.Type.CLEAR; + } else if (supportedTransportTypes.contains(Transport.Type.TOR)) { + transportType = Transport.Type.TOR; + } else { + throw new RuntimeException("I2P is not supported yet"); + } + BaseHttpClient httpClient = networkService.getHttpClient(url, userAgent, transportType); + return httpClient; + } }