All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.cardanofoundation.hydra.client.HydraWSClient Maven / Gradle / Ivy

The newest version!
package org.cardanofoundation.hydra.client;

import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.cardanofoundation.hydra.core.model.HydraState;
import org.cardanofoundation.hydra.core.model.Tag;
import org.cardanofoundation.hydra.core.model.query.request.*;
import org.cardanofoundation.hydra.core.model.query.response.FailureResponse;
import org.cardanofoundation.hydra.core.model.query.response.GreetingsResponse;
import org.cardanofoundation.hydra.core.store.UTxOStore;
import org.cardanofoundation.hydra.core.utils.MoreJson;
import org.java_websocket.client.WebSocketClient;
import org.java_websocket.handshake.ServerHandshake;

import java.net.URI;
import java.util.List;
import java.util.StringJoiner;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeUnit;

import static java.lang.String.format;

@Slf4j
// not thread safe yet
public class HydraWSClient {

    private final static ResponseTagStateMapper RESPONSE_TAG_STATE_MAPPER = new ResponseTagStateMapper();

    private final ResponseTagHandlers responseTagHandlers;

    private final HydraWebSocketHandler hydraWebSocketHandler;

    private final List hydraStateEventListeners = new CopyOnWriteArrayList<>();

    private final List hydraQueryEventListeners = new CopyOnWriteArrayList<>();

    private final HydraClientOptions hydraClientOptions;

    @Getter
    private final UTxOStore utxoStore;

    @Getter
    private HydraState hydraState;


    public HydraWSClient(HydraClientOptions hydraClientOptions) {
        final URI hydraServerUri = createHydraServerUri(hydraClientOptions);
        log.info("hydra connection url:{}", hydraServerUri);
        this.hydraWebSocketHandler = new HydraWebSocketHandler(hydraServerUri);
        this.hydraClientOptions = hydraClientOptions;
        this.hydraState = HydraState.Unknown;
        this.utxoStore = hydraClientOptions.getUtxoStore();
        this.responseTagHandlers = new ResponseTagHandlers(utxoStore);
    }

    /**
     * Returns true if the websocket connection is open.
     * @return
     */
    public boolean isOpen() {
        return hydraWebSocketHandler.isOpen();
    }

    /**
     * Returns true if the websocket connection is closed.
     *
     * @return true if the websocket connection is closed
     */
    public boolean isClosed() {
        return hydraWebSocketHandler.isClosed();
    }

    /**
     * Returns true if the websocket connection is closing.
     *
     * @return true if the websocket connection is closing
     */
    public boolean isClosing() {
        return hydraWebSocketHandler.isClosing();
    }


    /**
     * Add a HydraQueryEventListener instance to the list of listeners.
     *
     * @param eventListener - listener to add
     * @return this instance
     */
    public HydraWSClient addHydraQueryEventListener(HydraQueryEventListener eventListener) {
        if (eventListener == null) {
            throw new IllegalArgumentException("HydraQueryEventListener instance cannot be null!");
        }

        hydraQueryEventListeners.add(eventListener);

        return this;
    }

    /**
     * Add a HydraStateEventListener instance to the list of listeners.
     *
     * @param eventListener - listener to add
     * @return this instance
     */
    public HydraWSClient addHydraStateEventListener(HydraStateEventListener eventListener) {
        if (eventListener == null) {
            throw new IllegalArgumentException("HydraStateEventListener instance cannot be null!");
        }

        hydraStateEventListeners.add(eventListener);

        return this;
    }

    /**
     * Remove a HydraQueryEventListener instance from the list of listeners.
     * @param eventListener - listener to remove
     * @return this instance
     */
    public HydraWSClient removeHydraQueryEventListener(HydraQueryEventListener eventListener) {
        if (eventListener == null) {
            throw new IllegalArgumentException("HydraQueryEventListener instance cannot be null!");
        }

        hydraQueryEventListeners.remove(eventListener);

        return this;
    }

    /**
     * Remove a HydraStateEventListener instance from the list of listeners.
     *
     * @param eventListener - listener to remove
     * @return this instance
     */
    public HydraWSClient removeHydraStateEventListener(HydraStateEventListener eventListener) {
        if (eventListener == null) {
            throw new IllegalArgumentException("HydraStateEventListener instance cannot be null!");
        }

        hydraStateEventListeners.remove(eventListener);

        return this;
    }

    /**
     * Remove all HydraQueryEventListener instances from the list of listeners.
     */
    public void clearAllHydraQueryEventListeners() {
        hydraQueryEventListeners.clear();
    }

    /**
     * Remove all HydraStateEventListener instances from the list of listeners.
     */
    public void clearAllHydraStateEventListeners() {
        hydraStateEventListeners.clear();
    }

    /**
     * Connect to the hydra server.
     */
    public void connect() {
        hydraWebSocketHandler.connect();
    }

    /**
     * Connect to the hydra server and block until the connection is established.
     * @throws InterruptedException
     */
    public void connectBlocking() throws InterruptedException {
        hydraWebSocketHandler.connectBlocking();
    }

    /**
     * Connect to the hydra server and block until the connection is established or the timeout is reached.
     * @param time
     * @param timeUnit
     * @throws InterruptedException
     */
    public void connectBlocking(int time, TimeUnit timeUnit) throws InterruptedException {
        hydraWebSocketHandler.connectBlocking(time, timeUnit);
    }

    /**
     * Close the websocket connection.
     */
    public void close() {
        hydraWebSocketHandler.close();
    }

    /**
     * Close the websocket connection with the code and message
     *
     * @param code - code to close the websocket connection with
     * @param message - message to pass on the websocket connection closing
     */
    public void close(int code, String message) {
        hydraWebSocketHandler.close(code, message);
    }

    /**
     * Close the websocket connection and block until the connection is closed.
     */
    public void closeBlocking() throws InterruptedException {
        hydraWebSocketHandler.closeBlocking();
    }

    private static URI createHydraServerUri(HydraClientOptions hydraClientOptions) {
        String serverURI = hydraClientOptions.getServerURI();
        if (!serverURI.startsWith("ws://") && !serverURI.startsWith("wss://")) {
            throw new IllegalArgumentException("Invalid web socket urlPath:" + serverURI);
        }

        if (serverURI.endsWith("?")) {
            return URI.create(serverURI);
        }

        var delim = "&";

        var urlPath = new StringJoiner(delim)
                .add(format("history=%s", (hydraClientOptions.isHistory() ? "yes" : "no")))
                .add(format("snapshot-utxo=%s", (hydraClientOptions.isSnapshotUtxo() ? "yes" : "no")))
                .add(format("tx-output=%s", hydraClientOptions.getTransactionFormat().name().toLowerCase()))
                .toString();

        return URI.create(format("%s?%s", serverURI, urlPath));
    }

    /**
     * Initializes a new Hydra head.
     * This command is a no-op when a Head is already open and the server will output an CommandFailed message should this happen.
     */
    public void init() {
        val request = new InitRequest();
        hydraWebSocketHandler.send(request.getRequestBody());
    }

    /**
     * Aborts already initialized Hydra head before it is opened.
     *
     * This can only be done BEFORE all participants have committed. Once opened, the head can't be aborted anymore, but it can be closed using:
     * close head request instead.
     */
    public void abort() {
        val request = new AbortHeadRequest();
        hydraWebSocketHandler.send(request.getRequestBody());
    }

    /**
     * Submit a transaction through the head. Note that the transaction is only broadcast among all Hydra head
     * participants if well-formed and valid.
     *
     * You should expect to get either TransactionValidResponse or TransactionInvalidResponse
     */
    public void submitTx(String transaction) {
        val request = new NewTxRequest(transaction);
        hydraWebSocketHandler.send(request.getRequestBody());
    }

    /**
     * Terminate a Hydra head with the latest known snapshot.
     * This request effectively moves the head from the Open state to the Close state where the contestation phase begin.
     *
     * As a result of closing a head, no more transactions can be submitted to the Hydra network via NewTx request.
     */
    public void closeHead() {
        val request = new CloseHeadRequest();
        hydraWebSocketHandler.send(request.getRequestBody());
    }

    /**
     * Challenge the latest snapshot announced as a result of a Hydra head closure from another participant.
     *
     * Note that this necessarily contest with the latest snapshot known of your local Hydra node.
     * Participants can only contest once.
     */
    public void contest() {
        val request = new ContestHeadRequest();
        hydraWebSocketHandler.send(request.getRequestBody());
    }

    /**
     * Finalize a Hydra head after the contestation period passed.
     *
     * This will distribute the final (as closed and maybe contested) head state back on the Cardano's layer 1.
     */
    public void fanOut() {
        val request = new FanoutRequest();
        hydraWebSocketHandler.send(request.getRequestBody());
    }

    /**
     * Asynchronously access the current UTxO set of the Hydra node.
     *
     * This eventually triggers a response with all UTxOs (last known snapshot) from the server.
     */
    public void getUTXO() {
        val request = new GetUTxORequest();
        hydraWebSocketHandler.send(request.getRequestBody());
    }

    private class HydraWebSocketHandler extends WebSocketClient {

        public HydraWebSocketHandler(URI serverUri) {
            super(serverUri);
        }

        @Override
        public void onOpen(ServerHandshake serverHandshake) {
            log.info("Connection Established!");
            log.debug("onOpen -> ServerHandshake: {}", serverHandshake);
            HydraWSClient.this.hydraState = HydraState.Unknown;
        }

        @Override
        public void onMessage(String message) {
            log.debug("Received: {}", message);

            val raw = MoreJson.read(message);
            val tagString = raw.get("tag").asText();

            val maybeTag = Tag.find(tagString);

            if (maybeTag.isEmpty()) {
                log.warn("We don't support tag:{} yet, json:{}", tagString, message);
                return;
            }

            val tag = maybeTag.orElseThrow();

            val maybeResponseHandler = responseTagHandlers.responseHandlerFor(tag);
            if (maybeResponseHandler.isEmpty()) {
                log.error("We don't have response handler for the following tag:{}", tag);
            }
            val responseHandler = maybeResponseHandler.orElseThrow();
            val queryResponse = responseHandler.apply(raw);

            // if we don't have history this means we need to use Greetings message to get hydra state data
            if (!hydraClientOptions.isHistory() && tag == Tag.Greetings) {
                val greetingsResponse = (GreetingsResponse) queryResponse;
                fireHydraStateChanged(HydraWSClient.this.hydraState, greetingsResponse.getHeadStatus());
            } else {
                RESPONSE_TAG_STATE_MAPPER.stateForTag(tag).ifPresent(newHydraState -> {
                    fireHydraStateChanged(HydraWSClient.this.hydraState, newHydraState);
                });
            }

            if (queryResponse instanceof FailureResponse failureResponse) {
                if (hydraClientOptions.isDoNotPropagateLowLevelFailures() && failureResponse.isLowLevelFailure()) {
                    log.warn("Low level consensus failure, ignoring...");
                    return;
                }
            }

            var listeners = List.copyOf(hydraQueryEventListeners);
            listeners.forEach(hydraQueryEventListener -> hydraQueryEventListener.onResponse(queryResponse));
            if (queryResponse.isFailure()) {
                listeners.forEach(hydraQueryEventListener -> hydraQueryEventListener.onFailure(queryResponse));
            } else {
                listeners.forEach(hydraQueryEventListener -> hydraQueryEventListener.onSuccess(queryResponse));
            }
        }

        @Override
        public void onClose(int code, String reason, boolean remote) {
            String formattedActor = remote ? "remote peer" : "client";
            String formattedReason = (reason == null || reason.isEmpty()) ? reason : ", Reason: " + reason;

            log.info("Connection closed by {}, Code: {}{}", formattedActor, code, formattedReason);
        }

        @Override
        public void onError(Exception e) {
            if (e == null) {
                log.error("Hydra websocket error: null");
                return;
            }
            log.error("Hydra websocket error: {}", e.getMessage());
        }
    }

    private void fireHydraStateChanged(HydraState currentState, HydraState newState) {
        if (currentState == newState) {
            return;
        }
        HydraWSClient.this.hydraState = newState;

        List.copyOf(hydraStateEventListeners).forEach(l -> l.onStateChanged(currentState, newState));
    }

}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy