diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 124315b5..040e1aa4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,7 @@ Run `git submodule update --init --recursive` to fetch it. If your tests are failing make sure you pulled the ethereum/tests submodule and run a local geth node in background with -`geth --dev --http --ipcpath /tmp/geth.ipc` as we are running some tests +`geth --dev --http --ws --ipcpath /tmp/geth.ipc` as we are running some tests against a local live node. Other static test data is available in `fixtures/` diff --git a/README.md b/README.md index 982e9104..b5737019 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ The goal is to have 100% API documentation available. The test suite expects working local HTTP and IPC endpoints with a prefunded developer account, e.g.: ```shell -geth --dev --http --ipcpath /tmp/geth.ipc & +geth --dev --http --ws --ipcpath /tmp/geth.ipc & ``` It also expects an `$INFURA_TOKEN` in environment to test some ENS queries on mainnet. diff --git a/bin/setup b/bin/setup index f1c2f9b2..51542ca8 100755 --- a/bin/setup +++ b/bin/setup @@ -6,4 +6,4 @@ rufo . yard doc rspec -echo "Tests fail? Run \`geth --dev --http --ipcpath /tmp/geth.ipc\` in background and try again." +echo "Tests fail? Run \`geth --dev --http --ws --ipcpath /tmp/geth.ipc\` in background and try again." diff --git a/eth.gemspec b/eth.gemspec index 56c341ab..b290a6a2 100644 --- a/eth.gemspec +++ b/eth.gemspec @@ -51,4 +51,7 @@ Gem::Specification.new do |spec| # scrypt for encrypted key derivation spec.add_dependency "scrypt", "~> 3.0" + + # websocket for websocket client + spec.add_dependency "websocket-client-simple", "~> 0.6.0" end diff --git a/lib/eth/client.rb b/lib/eth/client.rb index b75bccad..6b4e0187 100644 --- a/lib/eth/client.rb +++ b/lib/eth/client.rb @@ -47,10 +47,12 @@ class ContractExecutionError < StandardError; end # @param host [String] either an HTTP/S host or an IPC path. # @return [Eth::Client::Ipc] an IPC client. # @return [Eth::Client::Http] an HTTP client. + # @return [Eth::Client::Ws] an WebSocket client. # @raise [ArgumentError] in case it cannot determine the client type. - def self.create(host) + def self.create(host, options = {}) return Client::Ipc.new host if host.end_with? ".ipc" return Client::Http.new host if host.start_with? "http" + return Client::Ws.new(host, options) if host.start_with? "ws" raise ArgumentError, "Unable to detect client type!" end @@ -519,3 +521,4 @@ def marshal(params) # Load the client/* libraries require "eth/client/http" require "eth/client/ipc" +require "eth/client/ws" diff --git a/lib/eth/client/ws.rb b/lib/eth/client/ws.rb new file mode 100644 index 00000000..284f8b90 --- /dev/null +++ b/lib/eth/client/ws.rb @@ -0,0 +1,88 @@ +# Copyright (c) 2016-2023 The Ruby-Eth Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "websocket-client-simple" +require "logger" + +# Provides the {Eth} module. +module Eth + + # Provides an WebSocket client. + class Client::Ws < Client + + # The host of the WebSocket endpoint. + attr_reader :host + + # The port of the HTTP endpoint. + attr_reader :port + + # The full URI of the HTTP endpoint, including path. + attr_reader :uri + + # Attribute indicator for SSL. + attr_reader :ssl + + # Constructor for the WebSocket Client. Should not be used; use + # {Client.create} intead. + # + # @param host [String] an URI pointing to an HTTP RPC-API. + def initialize(host, options = {}) + super(host) + uri = URI.parse(host) + raise ArgumentError, "Unable to parse the WebSocket-URI!" unless ["ws", "wss"].include? uri.scheme + @host = uri.host + @port = uri.port + @ssl = uri.scheme == "wss" + @uri = URI("#{uri.scheme}://#{@host}:#{@port}#{uri.path}") + setup_websocket(options[:logger]) + end + + # Sends an RPC request to the connected WebSocket client. + # + # @param payload [Hash] the RPC request parameters. + # @return [Integer] Number of bytes sent by this method. + def send_request(payload) + @ws.send(payload.to_json) + end + + # Checks if the WebSocket connection is open. + # + # @return [Boolean] true if the connection is open, false otherwise. + def open? + @ws.open? + end + + private + + def setup_websocket(logger) + @ws = WebSocket::Client::Simple.connect @uri.to_s + + @ws.on :message do |msg| + msg.data + end + + @ws.on :open do + logger.info "websocket open (#{@host})" + end + + @ws.on :close do |e| + logger.info "websocket close (#{e.inspect})" + end + + @ws.on :error do |e| + logger.error "websocket error (#{e.inspect})" + end + end + end +end diff --git a/spec/eth/client_spec.rb b/spec/eth/client_spec.rb index 46439e07..a04e4167 100644 --- a/spec/eth/client_spec.rb +++ b/spec/eth/client_spec.rb @@ -2,11 +2,12 @@ describe Client do - # run `geth --dev --http --ipcpath /tmp/geth.ipc` + # run `geth --dev --http --ws --ipcpath /tmp/geth.ipc` # to provide both http and ipc to pass these tests. let(:geth_ipc_path) { "/tmp/geth.ipc" } let(:geth_http_path) { "http://127.0.0.1:8545" } let(:geth_http_authed_path) { "http://username:password@127.0.0.1:8545" } + let(:geth_dev_ws_path) { "ws://127.0.0.1:8546" } subject(:geth_ipc) { Client.create geth_ipc_path } subject(:geth_http) { Client.create geth_http_path } subject(:geth_http_authed) { Client.create geth_http_authed_path } @@ -14,6 +15,10 @@ # it expects an $INFURA_TOKEN in environment let(:infura_api) { "https://mainnet.infura.io/v3/#{ENV["INFURA_TOKEN"]}" } subject(:infura_mainnet) { Client.create infura_api } + let(:logger) { Logger.new(STDOUT, level: Logger::FATAL) } + subject(:geth_dev_ipc) { Client.create geth_dev_ipc_path } + subject(:geth_dev_http) { Client.create geth_dev_http_path } + subject(:geth_dev_ws) { Client.create(geth_dev_ws_path, { logger: logger }) } describe ".create .initialize" do it "creates an ipc client" do @@ -31,6 +36,15 @@ expect(geth_http.ssl).to be_falsy end + it "creates an ws client" do + expect(geth_dev_ws).to be + expect(geth_dev_ws).to be_instance_of Client::Ws + expect(geth_dev_ws.host).to eq "127.0.0.1" + expect(geth_dev_ws.port).to eq 8546 + expect(geth_dev_ws.uri.to_s).to eq geth_dev_ws_path + expect(geth_dev_ws.ssl).to be_falsy + end + it "connects to an infura api" do expect(infura_mainnet).to be expect(infura_mainnet).to be_instance_of Client::Http @@ -424,4 +438,76 @@ expect(geth_ipc.is_valid_signature(contract, hashed, signature)).to be true end end + + describe ".send" do + it "should set up the WebSocket connection" do + expect(geth_dev_ws.instance_variable_get("@ws")).to be_instance_of(WebSocket::Client::Simple::Client) + end + + it "should send a message to the WebSocket server and receive a response" do + payload = { + id: 1, + jsonrpc: "2.0", + method: "eth_subscribe", + params: ["newHeads"], + } + received_data = nil + + geth_dev_ws.instance_variable_get("@ws").on :message do |msg| + received_data = JSON.parse(msg.data) + end + + # Wait for the connection to be established + start_time = Time.now + loop do + break if geth_dev_ws.instance_variable_get("@ws").open? || (Time.now - start_time > 3) + end + + geth_dev_ws.send_request(payload) + + # Wait for the response to be received + start_time = Time.now + loop do + break if received_data || (Time.now - start_time > 3) + end + + expect(received_data["id"]).to eq(payload[:id]) + expect(received_data["jsonrpc"]).to eq(payload[:jsonrpc]) + expect(received_data["result"]).to start_with("0x") + + contract = Eth::Contract.from_file(file: "spec/fixtures/contracts/dummy.sol") + geth_http.deploy_and_wait(contract) + + expect(received_data["method"]).to eq("eth_subscription") + expect(received_data["params"]["subscription"]).to start_with("0x") + expect(received_data["params"]["result"]["parentHash"]).to start_with("0x") + end + end + + describe ".open?" do + it "checks if the WebSocket connection is open" do + expect(geth_dev_ws.open?).to be false + + start_time = Time.now + loop do + break if geth_dev_ws.instance_variable_get("@ws").open? || (Time.now - start_time > 3) + end + + expect(geth_dev_ws.open?).to be true + end + end + + describe ".close?" do + it "checks if the WebSocket connection is close" do + start_time = Time.now + loop do + break if geth_dev_ws.instance_variable_get("@ws").open? || (Time.now - start_time > 3) + end + + ws = geth_dev_ws.instance_variable_get("@ws") + expect(ws.closed?).to be false + ws.close + expect(ws.closed?).to be true + end + end end