From 9dfbb0d5a6a6049984b9d286d39fee1c8cc90aa4 Mon Sep 17 00:00:00 2001 From: duriancrepe Date: Sat, 2 Apr 2022 09:35:07 -0700 Subject: [PATCH] Add API functions to start and stop local Monero node --- .../java/bisq/common/crypto/KeyStorage.java | 2 +- core/src/main/java/bisq/core/api/CoreApi.java | 28 ++- .../api/CoreMoneroConnectionsService.java | 75 +++++--- .../bisq/core/api/CoreMoneroNodeService.java | 168 ++++++++++++++++++ .../core/api/MoneroNodeServiceListener.java | 24 +++ .../main/java/bisq/core/user/Preferences.java | 10 +- .../bisq/core/user/PreferencesPayload.java | 11 +- .../bisq/core/xmr/MoneroNodeSettings.java | 50 ++++++ .../daemon/grpc/GrpcMoneroNodeService.java | 156 ++++++++++++++++ .../java/bisq/daemon/grpc/GrpcServer.java | 4 +- proto/src/main/proto/grpc.proto | 42 +++++ proto/src/main/proto/pb.proto | 7 + 12 files changed, 546 insertions(+), 31 deletions(-) create mode 100644 core/src/main/java/bisq/core/api/CoreMoneroNodeService.java create mode 100644 core/src/main/java/bisq/core/api/MoneroNodeServiceListener.java create mode 100644 core/src/main/java/bisq/core/xmr/MoneroNodeSettings.java create mode 100644 daemon/src/main/java/bisq/daemon/grpc/GrpcMoneroNodeService.java diff --git a/common/src/main/java/bisq/common/crypto/KeyStorage.java b/common/src/main/java/bisq/common/crypto/KeyStorage.java index 3435d81481..c39089a05a 100644 --- a/common/src/main/java/bisq/common/crypto/KeyStorage.java +++ b/common/src/main/java/bisq/common/crypto/KeyStorage.java @@ -172,7 +172,7 @@ public class KeyStorage { // Most of the time (probably of slightly less than 255/256, around 99.61%) a bad password // will result in BadPaddingException before HMAC check. // See https://stackoverflow.com/questions/8049872/given-final-block-not-properly-padded - if (ce.getCause() instanceof BadPaddingException || ce.getMessage() == Encryption.HMAC_ERROR_MSG) + if (ce.getCause() instanceof BadPaddingException || Encryption.HMAC_ERROR_MSG.equals(ce.getMessage())) throw new IncorrectPasswordException("Incorrect password"); else throw ce; diff --git a/core/src/main/java/bisq/core/api/CoreApi.java b/core/src/main/java/bisq/core/api/CoreApi.java index e446aa6c60..da646183cf 100644 --- a/core/src/main/java/bisq/core/api/CoreApi.java +++ b/core/src/main/java/bisq/core/api/CoreApi.java @@ -36,6 +36,8 @@ import bisq.core.support.messages.ChatMessage; import bisq.core.trade.Trade; import bisq.core.trade.statistics.TradeStatistics3; import bisq.core.trade.statistics.TradeStatisticsManager; +import bisq.core.xmr.MoneroNodeSettings; + import bisq.common.app.Version; import bisq.common.config.Config; import bisq.common.crypto.IncorrectPasswordException; @@ -53,6 +55,7 @@ import javax.inject.Singleton; import com.google.common.util.concurrent.FutureCallback; +import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; @@ -94,6 +97,7 @@ public class CoreApi { private final TradeStatisticsManager tradeStatisticsManager; private final CoreNotificationService notificationService; private final CoreMoneroConnectionsService coreMoneroConnectionsService; + private final CoreMoneroNodeService coreMoneroNodeService; @Inject public CoreApi(Config config, @@ -109,7 +113,8 @@ public class CoreApi { CoreWalletsService walletsService, TradeStatisticsManager tradeStatisticsManager, CoreNotificationService notificationService, - CoreMoneroConnectionsService coreMoneroConnectionsService) { + CoreMoneroConnectionsService coreMoneroConnectionsService, + CoreMoneroNodeService coreMoneroNodeService) { this.config = config; this.appStartupState = appStartupState; this.coreAccountService = coreAccountService; @@ -124,6 +129,7 @@ public class CoreApi { this.tradeStatisticsManager = tradeStatisticsManager; this.notificationService = notificationService; this.coreMoneroConnectionsService = coreMoneroConnectionsService; + this.coreMoneroNodeService = coreMoneroNodeService; } @SuppressWarnings("SameReturnValue") @@ -235,6 +241,26 @@ public class CoreApi { coreMoneroConnectionsService.setAutoSwitch(autoSwitch); } + /////////////////////////////////////////////////////////////////////////////////////////// + // Monero node + /////////////////////////////////////////////////////////////////////////////////////////// + + public boolean isMoneroNodeRunning() { + return coreMoneroNodeService.isMoneroNodeRunning(); + } + + public MoneroNodeSettings getMoneroNodeSettings() { + return coreMoneroNodeService.getMoneroNodeSettings(); + } + + public void startMoneroNode(MoneroNodeSettings settings) throws IOException { + coreMoneroNodeService.startMoneroNode(settings); + } + + public void stopMoneroNode() { + coreMoneroNodeService.stopMoneroNode(); + } + /////////////////////////////////////////////////////////////////////////////////////////// // Wallets /////////////////////////////////////////////////////////////////////////////////////////// diff --git a/core/src/main/java/bisq/core/api/CoreMoneroConnectionsService.java b/core/src/main/java/bisq/core/api/CoreMoneroConnectionsService.java index 225fa83d33..e4cc7e7905 100644 --- a/core/src/main/java/bisq/core/api/CoreMoneroConnectionsService.java +++ b/core/src/main/java/bisq/core/api/CoreMoneroConnectionsService.java @@ -18,6 +18,7 @@ import javafx.beans.property.SimpleLongProperty; import javafx.beans.property.SimpleObjectProperty; import javax.inject.Inject; import javax.inject.Singleton; + import lombok.extern.slf4j.Slf4j; import monero.common.MoneroConnectionManager; import monero.common.MoneroConnectionManagerListener; @@ -36,12 +37,13 @@ public final class CoreMoneroConnectionsService { // TODO (woodser): support each network type, move to config, remove localhost authentication private static final List DEFAULT_CONNECTIONS = Arrays.asList( - new MoneroRpcConnection("http://localhost:38081", "superuser", "abctesting123").setPriority(1), // localhost is first priority + new MoneroRpcConnection("http://127.0.0.1:38081", "superuser", "abctesting123").setPriority(1), // localhost is first priority, use loopback address to match url generated by local node service new MoneroRpcConnection("http://haveno.exchange:38081", "", "").setPriority(2) ); private final Object lock = new Object(); private final CoreAccountService accountService; + private final CoreMoneroNodeService nodeService; private final MoneroConnectionManager connectionManager; private final EncryptedConnectionList connectionList; private final ObjectProperty> peers = new SimpleObjectProperty<>(); @@ -55,21 +57,23 @@ public final class CoreMoneroConnectionsService { @Inject public CoreMoneroConnectionsService(WalletsSetup walletsSetup, CoreAccountService accountService, + CoreMoneroNodeService nodeService, MoneroConnectionManager connectionManager, EncryptedConnectionList connectionList) { this.accountService = accountService; + this.nodeService = nodeService; this.connectionManager = connectionManager; this.connectionList = connectionList; // initialize after account open and basic setup walletsSetup.addSetupTaskHandler(() -> { // TODO: use something better than legacy WalletSetup for notification to initialize - + // initialize from connections read from disk initialize(); - + // listen for account to be opened or password changed accountService.addListener(new AccountServiceListener() { - + @Override public void onAccountOpened() { try { @@ -80,7 +84,7 @@ public final class CoreMoneroConnectionsService { throw new RuntimeException(e); } } - + @Override public void onPasswordChanged(String oldPassword, String newPassword) { log.info(getClass() + ".onPasswordChanged({}, {}) called", oldPassword, newPassword); @@ -91,12 +95,12 @@ public final class CoreMoneroConnectionsService { } // ------------------------ CONNECTION MANAGEMENT ------------------------- - + public MoneroDaemon getDaemon() { accountService.checkAccountOpen(); return this.daemon; } - + public void addListener(MoneroConnectionManagerListener listener) { synchronized (lock) { accountService.checkAccountOpen(); @@ -194,9 +198,9 @@ public final class CoreMoneroConnectionsService { connectionList.setAutoSwitch(autoSwitch); } } - + // ----------------------------- APP METHODS ------------------------------ - + public boolean isChainHeightSyncedWithinTolerance() { if (daemon == null) return false; Long targetHeight = daemon.getSyncInfo().getTargetHeight(); @@ -208,7 +212,7 @@ public final class CoreMoneroConnectionsService { log.warn("Our chain height: {} is out of sync with peer nodes chain height: {}", chainHeight.get(), targetHeight); return false; } - + public ReadOnlyIntegerProperty numPeersProperty() { return numPeers; } @@ -216,7 +220,7 @@ public final class CoreMoneroConnectionsService { public ReadOnlyObjectProperty> peerConnectionsProperty() { return peers; } - + public boolean hasSufficientPeersForBroadcast() { return numPeers.get() >= getMinBroadcastConnections(); } @@ -224,33 +228,33 @@ public final class CoreMoneroConnectionsService { public LongProperty chainHeightProperty() { return chainHeight; } - + public ReadOnlyDoubleProperty downloadPercentageProperty() { return downloadListener.percentageProperty(); } - + public int getMinBroadcastConnections() { return MIN_BROADCAST_CONNECTIONS; } - + public boolean isDownloadComplete() { return downloadPercentageProperty().get() == 1d; } - + /** * Signals that both the daemon and wallet have synced. - * + * * TODO: separate daemon and wallet download/done listeners */ public void doneDownload() { downloadListener.doneDownload(); } - + // ------------------------------- HELPERS -------------------------------- - + private void initialize() { synchronized (lock) { - + // reset connection manager's connections and listeners connectionManager.reset(); @@ -265,10 +269,11 @@ public final class CoreMoneroConnectionsService { } // restore last used connection - connectionList.getCurrentConnectionUri().ifPresentOrElse(connectionManager::setConnection, () -> { + var currentConnection = connectionList.getCurrentConnectionUri(); + currentConnection.ifPresentOrElse(connectionManager::setConnection, () -> { connectionManager.setConnection(DEFAULT_CONNECTIONS.get(0).getUri()); // default to localhost }); - + // initialize daemon daemon = new MoneroDaemonRpc(connectionManager.getConnection()); updateDaemonInfo(); @@ -283,6 +288,32 @@ public final class CoreMoneroConnectionsService { // run once if (!isInitialized) { + // initialize local monero node + nodeService.addListener(new MoneroNodeServiceListener() { + @Override + public void onNodeStarted(MoneroDaemonRpc daemon) { + log.info(getClass() + ".onNodeStarted() called"); + setConnection(daemon.getRpcConnection()); + } + + @Override + public void onNodeStopped() { + log.info(getClass() + ".onNodeStopped() called"); + checkConnection(); + } + }); + + // start local node if the last connection is local and not running + currentConnection.ifPresent(connection -> { + try { + if (nodeService.isMoneroNodeConnection(connection) && !nodeService.isMoneroNodeRunning()) { + nodeService.startMoneroNode(); + } + } catch (Exception e) { + log.warn("Unable to start local monero node: " + e.getMessage()); + } + }); + // register connection change listener connectionManager.addListener(this::onConnectionChanged); @@ -292,7 +323,7 @@ public final class CoreMoneroConnectionsService { } } } - + private void onConnectionChanged(MoneroRpcConnection currentConnection) { synchronized (lock) { if (currentConnection == null) { diff --git a/core/src/main/java/bisq/core/api/CoreMoneroNodeService.java b/core/src/main/java/bisq/core/api/CoreMoneroNodeService.java new file mode 100644 index 0000000000..f53e078f14 --- /dev/null +++ b/core/src/main/java/bisq/core/api/CoreMoneroNodeService.java @@ -0,0 +1,168 @@ +/* + * This file is part of Haveno. + * + * Haveno 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. + * + * Haveno 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 Haveno. If not, see . + */ +package bisq.core.api; + +import bisq.core.user.Preferences; +import bisq.core.xmr.MoneroNodeSettings; + +import bisq.common.config.Config; + +import javax.inject.Inject; +import javax.inject.Singleton; + +import java.net.URI; +import java.net.URISyntaxException; + +import java.io.File; +import java.io.IOException; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import lombok.extern.slf4j.Slf4j; + +import monero.common.MoneroRpcConnection; +import monero.daemon.MoneroDaemonRpc; + +/** + * Manages a Monero node instance or connection to an instance. + */ +@Slf4j +@Singleton +public class CoreMoneroNodeService { + + public static final String LOCAL_NODE_ADDRESS = "127.0.0.1"; // expected connection from local MoneroDaemonRpc + private static final String MONERO_NETWORK_TYPE = Config.baseCurrencyNetwork().getNetwork().toLowerCase(); + private static final String MONEROD_PATH = System.getProperty("user.dir") + File.separator + ".localnet" + File.separator + "monerod"; + private static final String MONEROD_DATADIR = System.getProperty("user.dir") + File.separator + ".localnet" + File.separator + MONERO_NETWORK_TYPE; + + private final Preferences preferences; + private final List listeners = new ArrayList<>(); + + // required arguments + private static final List MONEROD_ARGS = Arrays.asList( + MONEROD_PATH, + "--" + MONERO_NETWORK_TYPE, + "--no-igd", + "--hide-my-port", + "--rpc-login", "superuser:abctesting123" // TODO: remove authentication + ); + + // local monero node owned by this process + private MoneroDaemonRpc daemon; + + // local monero node for detecting running node not owned by this process + private MoneroDaemonRpc defaultMoneroDaemon; + + @Inject + public CoreMoneroNodeService(Preferences preferences) { + this.daemon = null; + this.preferences = preferences; + int rpcPort = 18081; // mainnet + if (Config.baseCurrencyNetwork().isTestnet()) { + rpcPort = 28081; + } else if (Config.baseCurrencyNetwork().isStagenet()) { + rpcPort = 38081; + } + // TODO: remove authentication + var defaultMoneroConnection = new MoneroRpcConnection("http://" + LOCAL_NODE_ADDRESS + ":" + rpcPort, "superuser", "abctesting123").setPriority(1); // localhost is first priority + defaultMoneroDaemon = new MoneroDaemonRpc(defaultMoneroConnection); + } + + public void addListener(MoneroNodeServiceListener listener) { + listeners.add(listener); + } + + public boolean removeListener(MoneroNodeServiceListener listener) { + return listeners.remove(listener); + } + + /** + * Returns whether a connection string URI is a local monero node. + */ + public boolean isMoneroNodeConnection(String connection) throws URISyntaxException { + var uri = new URI(connection); + return CoreMoneroNodeService.LOCAL_NODE_ADDRESS.equals(uri.getHost()); + } + + /** + * Returns whether the local monero node is running or local daemon connection is running + */ + public boolean isMoneroNodeRunning() { + return daemon != null || defaultMoneroDaemon.isConnected(); + } + + public MoneroNodeSettings getMoneroNodeSettings() { + return preferences.getMoneroNodeSettings(); + } + + /** + * Starts a local monero node from settings. + */ + public void startMoneroNode() throws IOException { + var settings = preferences.getMoneroNodeSettings(); + this.startMoneroNode(settings); + } + + /** + * Starts a local monero node. Throws MoneroError if the node cannot be started. + * Persists the settings to preferences if the node started successfully. + */ + public void startMoneroNode(MoneroNodeSettings settings) throws IOException { + if (isMoneroNodeRunning()) throw new IllegalStateException("Monero node already running"); + + log.info("Starting local Monero node: " + settings); + + var args = new ArrayList<>(MONEROD_ARGS); + + var dataDir = settings.getBlockchainPath(); + if (dataDir == null || dataDir.isEmpty()) { + dataDir = MONEROD_DATADIR; + } + args.add("--data-dir=" + dataDir); + + var bootstrapUrl = settings.getBootstrapUrl(); + if (bootstrapUrl != null && !bootstrapUrl.isEmpty()) { + args.add("--bootstrap-daemon-address=" + bootstrapUrl); + } + + var flags = settings.getStartupFlags(); + if (flags != null) { + args.addAll(flags); + } + + daemon = new MoneroDaemonRpc(args); + preferences.setMoneroNodeSettings(settings); + for (var listener : listeners) listener.onNodeStarted(daemon); + } + + /** + * Stops the current local monero node if owned by this process. + * Does not remove the last MoneroNodeSettings. + */ + public void stopMoneroNode() { + if (!isMoneroNodeRunning()) throw new IllegalStateException("Monero node is not running"); + if (daemon != null) { + daemon.stopProcess(); + daemon = null; + for (var listener : listeners) listener.onNodeStopped(); + } else { + defaultMoneroDaemon.stopProcess(); // throws MoneroError + } + } +} diff --git a/core/src/main/java/bisq/core/api/MoneroNodeServiceListener.java b/core/src/main/java/bisq/core/api/MoneroNodeServiceListener.java new file mode 100644 index 0000000000..b70650c4eb --- /dev/null +++ b/core/src/main/java/bisq/core/api/MoneroNodeServiceListener.java @@ -0,0 +1,24 @@ +/* + * This file is part of Haveno. + * + * Haveno 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. + * + * Haveno 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 Haveno. If not, see . + */ +package bisq.core.api; + +import monero.daemon.MoneroDaemonRpc; + +public class MoneroNodeServiceListener { + public void onNodeStarted(MoneroDaemonRpc daemon) {} + public void onNodeStopped() {} +} diff --git a/core/src/main/java/bisq/core/user/Preferences.java b/core/src/main/java/bisq/core/user/Preferences.java index 7b2ec455c6..c54141a80f 100644 --- a/core/src/main/java/bisq/core/user/Preferences.java +++ b/core/src/main/java/bisq/core/user/Preferences.java @@ -30,6 +30,7 @@ import bisq.core.locale.TradeCurrency; import bisq.core.payment.PaymentAccount; import bisq.core.payment.PaymentAccountUtil; import bisq.core.provider.fee.FeeService; +import bisq.core.xmr.MoneroNodeSettings; import bisq.network.p2p.network.BridgeAddressProvider; @@ -166,7 +167,6 @@ public final class Preferences implements PersistedDataHost, BridgeAddressProvid @Getter private final BooleanProperty useStandbyModeProperty = new SimpleBooleanProperty(prefPayload.isUseStandbyMode()); - /////////////////////////////////////////////////////////////////////////////////////////// // Constructor /////////////////////////////////////////////////////////////////////////////////////////// @@ -687,7 +687,7 @@ public final class Preferences implements PersistedDataHost, BridgeAddressProvid prefPayload.setTakeOfferSelectedPaymentAccountId(value); requestPersistence(); } - + public void setIgnoreDustThreshold(int value) { prefPayload.setIgnoreDustThreshold(value); requestPersistence(); @@ -708,6 +708,10 @@ public final class Preferences implements PersistedDataHost, BridgeAddressProvid requestPersistence(); } + public void setMoneroNodeSettings(MoneroNodeSettings settings) { + prefPayload.setMoneroNodeSettings(settings); + requestPersistence(); + } /////////////////////////////////////////////////////////////////////////////////////////// // Getter @@ -957,5 +961,7 @@ public final class Preferences implements PersistedDataHost, BridgeAddressProvid void setDenyApiTaker(boolean value); void setNotifyOnPreRelease(boolean value); + + void setMoneroNodeSettings(MoneroNodeSettings settings); } } diff --git a/core/src/main/java/bisq/core/user/PreferencesPayload.java b/core/src/main/java/bisq/core/user/PreferencesPayload.java index 943979164c..c5b1922d2e 100644 --- a/core/src/main/java/bisq/core/user/PreferencesPayload.java +++ b/core/src/main/java/bisq/core/user/PreferencesPayload.java @@ -23,6 +23,7 @@ import bisq.core.locale.FiatCurrency; import bisq.core.locale.TradeCurrency; import bisq.core.payment.PaymentAccount; import bisq.core.proto.CoreProtoResolver; +import bisq.core.xmr.MoneroNodeSettings; import bisq.common.proto.ProtoUtil; import bisq.common.proto.persistable.PersistableEnvelope; @@ -130,6 +131,8 @@ public final class PreferencesPayload implements PersistableEnvelope { private boolean denyApiTaker; private boolean notifyOnPreRelease; + private MoneroNodeSettings moneroNodeSettings; + /////////////////////////////////////////////////////////////////////////////////////////// // Constructor /////////////////////////////////////////////////////////////////////////////////////////// @@ -201,8 +204,7 @@ public final class PreferencesPayload implements PersistableEnvelope { Optional.ofNullable(tradeChartsScreenCurrencyCode).ifPresent(builder::setTradeChartsScreenCurrencyCode); Optional.ofNullable(buyScreenCurrencyCode).ifPresent(builder::setBuyScreenCurrencyCode); Optional.ofNullable(sellScreenCurrencyCode).ifPresent(builder::setSellScreenCurrencyCode); - Optional.ofNullable(selectedPaymentAccountForCreateOffer).ifPresent( - account -> builder.setSelectedPaymentAccountForCreateOffer(selectedPaymentAccountForCreateOffer.toProtoMessage())); + Optional.ofNullable(selectedPaymentAccountForCreateOffer).ifPresent(account -> builder.setSelectedPaymentAccountForCreateOffer(account.toProtoMessage())); Optional.ofNullable(bridgeAddresses).ifPresent(builder::addAllBridgeAddresses); Optional.ofNullable(customBridges).ifPresent(builder::setCustomBridges); Optional.ofNullable(referralId).ifPresent(builder::setReferralId); @@ -210,7 +212,7 @@ public final class PreferencesPayload implements PersistableEnvelope { Optional.ofNullable(rpcUser).ifPresent(builder::setRpcUser); Optional.ofNullable(rpcPw).ifPresent(builder::setRpcPw); Optional.ofNullable(takeOfferSelectedPaymentAccountId).ifPresent(builder::setTakeOfferSelectedPaymentAccountId); - + Optional.ofNullable(moneroNodeSettings).ifPresent(settings -> builder.setMoneroNodeSettings(settings.toProtoMessage())); return protobuf.PersistableEnvelope.newBuilder().setPreferencesPayload(builder).build(); } @@ -286,7 +288,8 @@ public final class PreferencesPayload implements PersistableEnvelope { proto.getHideNonAccountPaymentMethods(), proto.getShowOffersMatchingMyAccounts(), proto.getDenyApiTaker(), - proto.getNotifyOnPreRelease() + proto.getNotifyOnPreRelease(), + MoneroNodeSettings.fromProto(proto.getMoneroNodeSettings()) ); } } diff --git a/core/src/main/java/bisq/core/xmr/MoneroNodeSettings.java b/core/src/main/java/bisq/core/xmr/MoneroNodeSettings.java new file mode 100644 index 0000000000..7b40b8b72e --- /dev/null +++ b/core/src/main/java/bisq/core/xmr/MoneroNodeSettings.java @@ -0,0 +1,50 @@ +/* + * This file is part of Haveno. + * + * Haveno 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. + * + * Haveno 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 Haveno. If not, see . + */ +package bisq.core.xmr; + +import bisq.common.proto.persistable.PersistableEnvelope; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Data +@AllArgsConstructor +public class MoneroNodeSettings implements PersistableEnvelope { + + String blockchainPath; + String bootstrapUrl; + List startupFlags; + + public static MoneroNodeSettings fromProto(protobuf.MoneroNodeSettings proto) { + return new MoneroNodeSettings( + proto.getBlockchainPath(), + proto.getBootstrapUrl(), + proto.getStartupFlagsList()); + } + + @Override + public protobuf.MoneroNodeSettings toProtoMessage() { + return protobuf.MoneroNodeSettings.newBuilder() + .setBlockchainPath(blockchainPath) + .setBootstrapUrl(bootstrapUrl) + .addAllStartupFlags(startupFlags).build(); + } +} diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcMoneroNodeService.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcMoneroNodeService.java new file mode 100644 index 0000000000..a393e19a67 --- /dev/null +++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcMoneroNodeService.java @@ -0,0 +1,156 @@ +/* + * This file is part of Haveno. + * + * Haveno 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. + * + * Haveno 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 Haveno. If not, see . + */ +package bisq.daemon.grpc; + +import bisq.core.api.CoreApi; +import bisq.core.xmr.MoneroNodeSettings; + +import bisq.proto.grpc.GetMoneroNodeSettingsReply; +import bisq.proto.grpc.GetMoneroNodeSettingsRequest; +import bisq.proto.grpc.IsMoneroNodeRunningReply; +import bisq.proto.grpc.IsMoneroNodeRunningRequest; +import bisq.proto.grpc.MoneroNodeGrpc.MoneroNodeImplBase; +import bisq.proto.grpc.StartMoneroNodeReply; +import bisq.proto.grpc.StartMoneroNodeRequest; +import bisq.proto.grpc.StopMoneroNodeReply; +import bisq.proto.grpc.StopMoneroNodeRequest; + +import io.grpc.ServerInterceptor; +import io.grpc.stub.StreamObserver; + +import javax.inject.Inject; + +import java.util.HashMap; +import java.util.Optional; + +import lombok.extern.slf4j.Slf4j; + +import static bisq.daemon.grpc.interceptor.GrpcServiceRateMeteringConfig.getCustomRateMeteringInterceptor; +import static bisq.proto.grpc.MoneroNodeGrpc.getStartMoneroNodeMethod; +import static bisq.proto.grpc.MoneroNodeGrpc.getStopMoneroNodeMethod; +import static bisq.proto.grpc.MoneroNodeGrpc.getIsMoneroNodeRunningMethod; +import static bisq.proto.grpc.MoneroNodeGrpc.getGetMoneroNodeSettingsMethod; +import static java.util.concurrent.TimeUnit.SECONDS; + +import bisq.daemon.grpc.interceptor.CallRateMeteringInterceptor; +import bisq.daemon.grpc.interceptor.GrpcCallRateMeter; +import monero.common.MoneroError; + +@Slf4j +public class GrpcMoneroNodeService extends MoneroNodeImplBase { + + private final CoreApi coreApi; + private final GrpcExceptionHandler exceptionHandler; + + @Inject + public GrpcMoneroNodeService(CoreApi coreApi, GrpcExceptionHandler exceptionHandler) { + this.coreApi = coreApi; + this.exceptionHandler = exceptionHandler; + } + + @Override + public void isMoneroNodeRunning(IsMoneroNodeRunningRequest request, + StreamObserver responseObserver) { + try { + var reply = IsMoneroNodeRunningReply.newBuilder() + .setIsRunning(coreApi.isMoneroNodeRunning()) + .build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } catch (Throwable cause) { + exceptionHandler.handleException(log, cause, responseObserver); + } + } + + @Override + public void getMoneroNodeSettings(GetMoneroNodeSettingsRequest request, + StreamObserver responseObserver) { + try { + var settings = coreApi.getMoneroNodeSettings(); + var builder = GetMoneroNodeSettingsReply.newBuilder(); + if (settings != null) { + builder.setSettings(settings.toProtoMessage()); + } + var reply = builder.build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } catch (Throwable cause) { + exceptionHandler.handleException(log, cause, responseObserver); + } + } + + @Override + public void startMoneroNode(StartMoneroNodeRequest request, + StreamObserver responseObserver) { + try { + var settings = request.getSettings(); + coreApi.startMoneroNode(MoneroNodeSettings.fromProto(settings)); + var reply = StartMoneroNodeReply.newBuilder().build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } catch (MoneroError me) { + handleMoneroError(me, responseObserver); + } catch (Throwable cause) { + exceptionHandler.handleException(log, cause, responseObserver); + } + } + + @Override + public void stopMoneroNode(StopMoneroNodeRequest request, + StreamObserver responseObserver) { + try { + coreApi.stopMoneroNode(); + var reply = StopMoneroNodeReply.newBuilder().build(); + responseObserver.onNext(reply); + responseObserver.onCompleted(); + } catch (MoneroError me) { + handleMoneroError(me, responseObserver); + } catch (Throwable cause) { + exceptionHandler.handleException(log, cause, responseObserver); + } + } + + private void handleMoneroError(MoneroError me, StreamObserver responseObserver) { + // MoneroError is caused by the node startup failing, don't treat as unknown server error + // by wrapping with a handled exception type. + var headerLengthLimit = 8192; // MoneroErrors may print the entire monerod help text which causes a header overflow in grpc + if (me.getMessage().length() > headerLengthLimit) { + exceptionHandler.handleException(log, new IllegalStateException(me.getMessage().substring(0, headerLengthLimit - 1)), responseObserver); + } else { + exceptionHandler.handleException(log, new IllegalStateException(me), responseObserver); + } + } + + final ServerInterceptor[] interceptors() { + Optional rateMeteringInterceptor = rateMeteringInterceptor(); + return rateMeteringInterceptor.map(serverInterceptor -> + new ServerInterceptor[]{serverInterceptor}).orElseGet(() -> new ServerInterceptor[0]); + } + + private Optional rateMeteringInterceptor() { + return getCustomRateMeteringInterceptor(coreApi.getConfig().appDataDir, this.getClass()) + .or(() -> Optional.of(CallRateMeteringInterceptor.valueOf( + new HashMap<>() {{ + int allowedCallsPerTimeWindow = 10; + put(getIsMoneroNodeRunningMethod().getFullMethodName(), new GrpcCallRateMeter(allowedCallsPerTimeWindow, SECONDS)); + put(getGetMoneroNodeSettingsMethod().getFullMethodName(), new GrpcCallRateMeter(allowedCallsPerTimeWindow, SECONDS)); + put(getStartMoneroNodeMethod().getFullMethodName(), new GrpcCallRateMeter(allowedCallsPerTimeWindow, SECONDS)); + put(getStopMoneroNodeMethod().getFullMethodName(), new GrpcCallRateMeter(allowedCallsPerTimeWindow, SECONDS)); + }} + ))); + } +} diff --git a/daemon/src/main/java/bisq/daemon/grpc/GrpcServer.java b/daemon/src/main/java/bisq/daemon/grpc/GrpcServer.java index 549468bdff..70b10cafab 100644 --- a/daemon/src/main/java/bisq/daemon/grpc/GrpcServer.java +++ b/daemon/src/main/java/bisq/daemon/grpc/GrpcServer.java @@ -62,7 +62,8 @@ public class GrpcServer { GrpcTradesService tradesService, GrpcWalletsService walletsService, GrpcNotificationsService notificationsService, - GrpcMoneroConnectionsService moneroConnectionsService) { + GrpcMoneroConnectionsService moneroConnectionsService, + GrpcMoneroNodeService moneroNodeService) { this.server = ServerBuilder.forPort(config.apiPort) .executor(UserThread.getExecutor()) .addService(interceptForward(accountService, accountService.interceptors())) @@ -79,6 +80,7 @@ public class GrpcServer { .addService(interceptForward(walletsService, walletsService.interceptors())) .addService(interceptForward(notificationsService, notificationsService.interceptors())) .addService(interceptForward(moneroConnectionsService, moneroConnectionsService.interceptors())) + .addService(interceptForward(moneroNodeService, moneroNodeService.interceptors())) .intercept(passwordAuthInterceptor) .build(); coreContext.setApiUser(true); diff --git a/proto/src/main/proto/grpc.proto b/proto/src/main/proto/grpc.proto index 0eb765ebe0..9a2cc7f725 100644 --- a/proto/src/main/proto/grpc.proto +++ b/proto/src/main/proto/grpc.proto @@ -382,6 +382,48 @@ message SetAutoSwitchRequest { message SetAutoSwitchReply {} +/////////////////////////////////////////////////////////////////////////////////////////// +// MoneroNode +/////////////////////////////////////////////////////////////////////////////////////////// + +service MoneroNode { + rpc IsMoneroNodeRunning (IsMoneroNodeRunningRequest) returns (IsMoneroNodeRunningReply) { + } + rpc GetMoneroNodeSettings (GetMoneroNodeSettingsRequest) returns (GetMoneroNodeSettingsReply) { + } + rpc StartMoneroNode (StartMoneroNodeRequest) returns (StartMoneroNodeReply) { + } + rpc StopMoneroNode (StopMoneroNodeRequest) returns (StopMoneroNodeReply) { + } +} + +message IsMoneroNodeRunningRequest { +} + +message IsMoneroNodeRunningReply { + bool is_running = 1; +} + +message GetMoneroNodeSettingsRequest { +} + +message GetMoneroNodeSettingsReply { + MoneroNodeSettings settings = 1; // pb.proto +} + +message StartMoneroNodeRequest { + MoneroNodeSettings settings = 1; +} + +message StartMoneroNodeReply { +} + +message StopMoneroNodeRequest { +} + +message StopMoneroNodeReply { +} + /////////////////////////////////////////////////////////////////////////////////////////// // Offers /////////////////////////////////////////////////////////////////////////////////////////// diff --git a/proto/src/main/proto/pb.proto b/proto/src/main/proto/pb.proto index d6dee596bb..6043f2dab2 100644 --- a/proto/src/main/proto/pb.proto +++ b/proto/src/main/proto/pb.proto @@ -1814,6 +1814,7 @@ message PreferencesPayload { bool show_offers_matching_my_accounts = 55; bool deny_api_taker = 56; bool notify_on_pre_release = 57; + MoneroNodeSettings monero_node_settings = 58; } message AutoConfirmSettings { @@ -1824,6 +1825,12 @@ message AutoConfirmSettings { string currency_code = 5; } +message MoneroNodeSettings { + string blockchain_path = 1; + string bootstrap_url = 2; + repeated string startup_flags = 3; +} + /////////////////////////////////////////////////////////////////////////////////////////// // UserPayload ///////////////////////////////////////////////////////////////////////////////////////////