From b9d2bbb78b3facfa127acc154d115b793bb57606 Mon Sep 17 00:00:00 2001 From: catbref Date: Mon, 15 Jun 2020 14:07:09 +0100 Subject: [PATCH] New /websockets/blocks & some controller/block tidying Controller.onBlockMinted() now .onNewBlock(BlockData) which saves having to fetch from repository. Controller.onNewBlock also takes care of updating Controller's cached chain tip, requesting SysTray refresh, broadcasting new tip info to peers and notifying websockets. BlockMinter and Controller.actuallySynchronize updated to use unified .onNewBlock. BlocksWebsocket also returns blocks on demand, given either integer block height or base58 block signature. Added support to return ApiError via websockets. --- src/main/java/org/qortal/api/ApiError.java | 6 + .../java/org/qortal/api/ApiErrorRoot.java | 20 ++++ .../org/qortal/api/ApiErrorTypeAdapter.java | 32 +++++ src/main/java/org/qortal/api/ApiService.java | 2 + .../qortal/api/websocket/ApiWebSocket.java | 16 +++ .../qortal/api/websocket/BlocksWebSocket.java | 109 ++++++++++++++++++ .../java/org/qortal/block/BlockMinter.java | 4 +- .../org/qortal/controller/BlockNotifier.java | 43 +++++++ .../org/qortal/controller/Controller.java | 35 +++--- 9 files changed, 245 insertions(+), 22 deletions(-) create mode 100644 src/main/java/org/qortal/api/ApiErrorRoot.java create mode 100644 src/main/java/org/qortal/api/ApiErrorTypeAdapter.java create mode 100644 src/main/java/org/qortal/api/websocket/BlocksWebSocket.java create mode 100644 src/main/java/org/qortal/controller/BlockNotifier.java diff --git a/src/main/java/org/qortal/api/ApiError.java b/src/main/java/org/qortal/api/ApiError.java index 14ab1f4f..f39ff7a0 100644 --- a/src/main/java/org/qortal/api/ApiError.java +++ b/src/main/java/org/qortal/api/ApiError.java @@ -5,6 +5,12 @@ import static java.util.stream.Collectors.toMap; import java.util.Map; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; + +@XmlAccessorType(XmlAccessType.NONE) +@XmlRootElement public enum ApiError { // COMMON // UNKNOWN(0, 500), diff --git a/src/main/java/org/qortal/api/ApiErrorRoot.java b/src/main/java/org/qortal/api/ApiErrorRoot.java new file mode 100644 index 00000000..b531e023 --- /dev/null +++ b/src/main/java/org/qortal/api/ApiErrorRoot.java @@ -0,0 +1,20 @@ +package org.qortal.api; + +import javax.xml.bind.annotation.XmlElement; +import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter; + +public class ApiErrorRoot { + + private ApiError apiError; + + @XmlJavaTypeAdapter(ApiErrorTypeAdapter.class) + @XmlElement(name = "error") + public ApiError getApiError() { + return this.apiError; + } + + public void setApiError(ApiError apiError) { + this.apiError = apiError; + } + +} diff --git a/src/main/java/org/qortal/api/ApiErrorTypeAdapter.java b/src/main/java/org/qortal/api/ApiErrorTypeAdapter.java new file mode 100644 index 00000000..2447f03c --- /dev/null +++ b/src/main/java/org/qortal/api/ApiErrorTypeAdapter.java @@ -0,0 +1,32 @@ +package org.qortal.api; + +import javax.xml.bind.annotation.adapters.XmlAdapter; + +public class ApiErrorTypeAdapter extends XmlAdapter { + + public static class AdaptedApiError { + public int code; + public String description; + } + + @Override + public ApiError unmarshal(AdaptedApiError adaptedInput) throws Exception { + if (adaptedInput == null) + return null; + + return ApiError.fromCode(adaptedInput.code); + } + + @Override + public AdaptedApiError marshal(ApiError output) throws Exception { + if (output == null) + return null; + + AdaptedApiError adaptedOutput = new AdaptedApiError(); + adaptedOutput.code = output.getCode(); + adaptedOutput.description = output.name(); + + return adaptedOutput; + } + +} diff --git a/src/main/java/org/qortal/api/ApiService.java b/src/main/java/org/qortal/api/ApiService.java index cfe4f575..c8d1d27d 100644 --- a/src/main/java/org/qortal/api/ApiService.java +++ b/src/main/java/org/qortal/api/ApiService.java @@ -23,6 +23,7 @@ import org.glassfish.jersey.servlet.ServletContainer; import org.qortal.api.resource.AnnotationPostProcessor; import org.qortal.api.resource.ApiDefinition; import org.qortal.api.websocket.ActiveChatsWebSocket; +import org.qortal.api.websocket.BlocksWebSocket; import org.qortal.api.websocket.ChatMessagesWebSocket; import org.qortal.settings.Settings; @@ -125,6 +126,7 @@ public class ApiService { rewriteHandler.addRule(new RedirectPatternRule("/api-documentation", "/api-documentation/")); // redirect to add trailing slash if missing } + context.addServlet(BlocksWebSocket.class, "/websockets/blocks"); context.addServlet(ActiveChatsWebSocket.class, "/websockets/chat/active/*"); context.addServlet(ChatMessagesWebSocket.class, "/websockets/chat/messages"); diff --git a/src/main/java/org/qortal/api/websocket/ApiWebSocket.java b/src/main/java/org/qortal/api/websocket/ApiWebSocket.java index 9780fb70..9209c5b9 100644 --- a/src/main/java/org/qortal/api/websocket/ApiWebSocket.java +++ b/src/main/java/org/qortal/api/websocket/ApiWebSocket.java @@ -1,6 +1,7 @@ package org.qortal.api.websocket; import java.io.IOException; +import java.io.StringWriter; import java.io.Writer; import java.util.Collection; import java.util.Map; @@ -14,6 +15,8 @@ import org.eclipse.jetty.websocket.api.Session; import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest; import org.eclipse.persistence.jaxb.JAXBContextFactory; import org.eclipse.persistence.jaxb.MarshallerProperties; +import org.qortal.api.ApiError; +import org.qortal.api.ApiErrorRoot; interface ApiWebSocket { @@ -27,6 +30,19 @@ interface ApiWebSocket { return uriTemplatePathSpec.getPathParams(this.getPathInfo(session)); } + default void sendError(Session session, ApiError apiError) { + ApiErrorRoot apiErrorRoot = new ApiErrorRoot(); + apiErrorRoot.setApiError(apiError); + + StringWriter stringWriter = new StringWriter(); + try { + marshall(stringWriter, apiErrorRoot); + session.getRemote().sendString(stringWriter.toString()); + } catch (IOException e) { + // Remote end probably closed + } + } + default void marshall(Writer writer, Object object) throws IOException { Marshaller marshaller = createMarshaller(object.getClass()); diff --git a/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java b/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java new file mode 100644 index 00000000..398cdd33 --- /dev/null +++ b/src/main/java/org/qortal/api/websocket/BlocksWebSocket.java @@ -0,0 +1,109 @@ +package org.qortal.api.websocket; + +import java.io.IOException; +import java.io.StringWriter; + +import org.eclipse.jetty.websocket.api.Session; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketClose; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketConnect; +import org.eclipse.jetty.websocket.api.annotations.OnWebSocketMessage; +import org.eclipse.jetty.websocket.api.annotations.WebSocket; +import org.eclipse.jetty.websocket.servlet.WebSocketServlet; +import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; +import org.qortal.api.ApiError; +import org.qortal.controller.BlockNotifier; +import org.qortal.data.block.BlockData; +import org.qortal.repository.DataException; +import org.qortal.repository.Repository; +import org.qortal.repository.RepositoryManager; +import org.qortal.utils.Base58; + +@WebSocket +@SuppressWarnings("serial") +public class BlocksWebSocket extends WebSocketServlet implements ApiWebSocket { + + @Override + public void configure(WebSocketServletFactory factory) { + factory.register(BlocksWebSocket.class); + } + + @OnWebSocketConnect + public void onWebSocketConnect(Session session) { + BlockNotifier.Listener listener = blockData -> onNotify(session, blockData); + BlockNotifier.getInstance().register(session, listener); + } + + @OnWebSocketClose + public void onWebSocketClose(Session session, int statusCode, String reason) { + BlockNotifier.getInstance().deregister(session); + } + + @OnWebSocketMessage + public void onWebSocketMessage(Session session, String message) { + // We're expecting either a base58 block signature or an integer block height + if (message.length() > 128) { + // Try base58 block signature + byte[] signature; + + try { + signature = Base58.decode(message); + } catch (NumberFormatException e) { + sendError(session, ApiError.INVALID_SIGNATURE); + return; + } + + try (final Repository repository = RepositoryManager.getRepository()) { + BlockData blockData = repository.getBlockRepository().fromSignature(signature); + if (blockData == null) { + sendError(session, ApiError.BLOCK_UNKNOWN); + return; + } + + onNotify(session, blockData); + } catch (DataException e) { + sendError(session, ApiError.REPOSITORY_ISSUE); + } + + return; + } + + if (message.length() > 10) + // Bigger than max integer value, so probably a ping - silently ignore + return; + + // Try integer + int height; + + try { + height = Integer.parseInt(message); + } catch (NumberFormatException e) { + sendError(session, ApiError.INVALID_HEIGHT); + return; + } + + try (final Repository repository = RepositoryManager.getRepository()) { + BlockData blockData = repository.getBlockRepository().fromHeight(height); + if (blockData == null) { + sendError(session, ApiError.BLOCK_UNKNOWN); + return; + } + + onNotify(session, blockData); + } catch (DataException e) { + sendError(session, ApiError.REPOSITORY_ISSUE); + } + } + + private void onNotify(Session session, BlockData blockData) { + StringWriter stringWriter = new StringWriter(); + + try { + this.marshall(stringWriter, blockData); + + session.getRemote().sendString(stringWriter.toString()); + } catch (IOException e) { + // No output this time + } + } + +} diff --git a/src/main/java/org/qortal/block/BlockMinter.java b/src/main/java/org/qortal/block/BlockMinter.java index bffee187..3542be5e 100644 --- a/src/main/java/org/qortal/block/BlockMinter.java +++ b/src/main/java/org/qortal/block/BlockMinter.java @@ -200,6 +200,7 @@ public class BlockMinter extends Thread { } boolean newBlockMinted = false; + Block newBlock = null; try { // Clear repository session state so we have latest view of data @@ -235,7 +236,6 @@ public class BlockMinter extends Thread { final int parentHeight = previousBlock.getBlockData().getHeight(); final byte[] parentBlockSignature = previousBlock.getSignature(); - Block newBlock = null; BigInteger bestWeight = null; for (int bi = 0; bi < goodBlocks.size(); ++bi) { @@ -306,7 +306,7 @@ public class BlockMinter extends Thread { } if (newBlockMinted) - Controller.getInstance().onBlockMinted(); + Controller.getInstance().onNewBlock(newBlock.getBlockData()); } } catch (DataException e) { LOGGER.warn("Repository issue while running block minter", e); diff --git a/src/main/java/org/qortal/controller/BlockNotifier.java b/src/main/java/org/qortal/controller/BlockNotifier.java new file mode 100644 index 00000000..d4278d05 --- /dev/null +++ b/src/main/java/org/qortal/controller/BlockNotifier.java @@ -0,0 +1,43 @@ +package org.qortal.controller; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jetty.websocket.api.Session; +import org.qortal.data.block.BlockData; + +public class BlockNotifier { + + private static BlockNotifier instance; + + @FunctionalInterface + public interface Listener { + void notify(BlockData blockData); + } + + private Map listenersBySession = new HashMap<>(); + + private BlockNotifier() { + } + + public static synchronized BlockNotifier getInstance() { + if (instance == null) + instance = new BlockNotifier(); + + return instance; + } + + public synchronized void register(Session session, Listener listener) { + this.listenersBySession.put(session, listener); + } + + public synchronized void deregister(Session session) { + this.listenersBySession.remove(session); + } + + public synchronized void onNewBlock(BlockData blockData) { + for (Listener listener : this.listenersBySession.values()) + listener.notify(blockData); + } + +} diff --git a/src/main/java/org/qortal/controller/Controller.java b/src/main/java/org/qortal/controller/Controller.java index 0606d98a..ea410c86 100644 --- a/src/main/java/org/qortal/controller/Controller.java +++ b/src/main/java/org/qortal/controller/Controller.java @@ -128,6 +128,7 @@ public class Controller extends Thread { private ExecutorService newTransactionExecutor = Executors.newSingleThreadExecutor(); private volatile BlockData chainTip = null; + private ExecutorService newBlockExecutor = Executors.newSingleThreadExecutor(); private long repositoryBackupTimestamp = startTime; // ms private long ntpCheckTimestamp = startTime; // ms @@ -237,7 +238,7 @@ public class Controller extends Thread { return this.chainTip; } - /** Cache new blockchain tip, and also wipe cache of online accounts. */ + /** Cache new blockchain tip. */ public void setChainTip(BlockData blockData) { this.chainTip = blockData; } @@ -601,18 +602,17 @@ public class Controller extends Thread { try (final Repository repository = RepositoryManager.getRepository()) { newChainTip = repository.getBlockRepository().getLastBlock(); - this.setChainTip(newChainTip); } catch (DataException e) { LOGGER.warn(String.format("Repository issue when trying to fetch post-synchronization chain tip: %s", e.getMessage())); return syncResult; } if (!Arrays.equals(newChainTip.getSignature(), priorChainTip.getSignature())) { - // Broadcast our new chain tip - Network.getInstance().broadcast(recipientPeer -> Network.getInstance().buildHeightMessage(recipientPeer, newChainTip)); - // Reset our cache of inferior chains inferiorChainSignatures.clear(); + + // Update chain-tip, notify peers, websockets, etc. + this.onNewBlock(newChainTip); } return syncResult; @@ -754,22 +754,17 @@ public class Controller extends Thread { requestSysTrayUpdate = true; } - public void onBlockMinted() { - // Broadcast our new height info - BlockData latestBlockData; - - try (final Repository repository = RepositoryManager.getRepository()) { - latestBlockData = repository.getBlockRepository().getLastBlock(); - this.setChainTip(latestBlockData); - } catch (DataException e) { - LOGGER.warn(String.format("Repository issue when trying to fetch post-mint chain tip: %s", e.getMessage())); - return; - } - - Network network = Network.getInstance(); - network.broadcast(peer -> network.buildHeightMessage(peer, latestBlockData)); - + public void onNewBlock(BlockData latestBlockData) { + this.setChainTip(latestBlockData); requestSysTrayUpdate = true; + + // Broadcast our new height info and notify websocket listeners + this.newBlockExecutor.execute(() -> { + Network network = Network.getInstance(); + network.broadcast(peer -> network.buildHeightMessage(peer, latestBlockData)); + + BlockNotifier.getInstance().onNewBlock(latestBlockData); + }); } /** Callback for when we've received a new transaction via API or peer. */