diff --git a/src/main/java/io/emeraldpay/dshackle/config/Upstreams.java b/src/main/java/io/emeraldpay/dshackle/config/Upstreams.java new file mode 100644 index 00000000..21e4facc --- /dev/null +++ b/src/main/java/io/emeraldpay/dshackle/config/Upstreams.java @@ -0,0 +1,334 @@ +package io.emeraldpay.dshackle.config; + +import org.yaml.snakeyaml.TypeDescription; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.introspector.MethodProperty; +import org.yaml.snakeyaml.introspector.Property; +import org.yaml.snakeyaml.introspector.PropertySubstitute; +import org.yaml.snakeyaml.nodes.MappingNode; +import org.yaml.snakeyaml.nodes.Node; +import org.yaml.snakeyaml.nodes.ScalarNode; + +import javax.annotation.Nullable; +import java.beans.IntrospectionException; +import java.beans.PropertyDescriptor; +import java.net.URI; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +public class Upstreams { + + private String version; + private List defaultOptions; + private List upstreams; + + public String getVersion() { + return version; + } + + public void setVersion(String version) { + this.version = version; + } + + public List getDefaultOptions() { + return defaultOptions; + } + + public void setDefaultOptions(List defaultOptions) { + this.defaultOptions = defaultOptions; + } + + public List getUpstreams() { + return upstreams; + } + + public void setUpstreams(List upstreams) { + this.upstreams = upstreams; + } + + public static class Options { + private Boolean disableSyncing = true; + private Integer minPeers = 1; + private Integer quorum = 1; + + public Boolean getDisableSyncing() { + return disableSyncing; + } + + public void setDisableSyncing(Boolean disableSyncing) { + this.disableSyncing = disableSyncing; + } + + public Integer getMinPeers() { + return minPeers; + } + + public void setMinPeers(Integer minPeers) { + this.minPeers = minPeers; + } + + public Integer getQuorum() { + return quorum; + } + + public void setQuorum(Integer quorum) { + this.quorum = quorum; + } + } + + public static class OptionsYaml extends TypeDescription { + + public OptionsYaml() { + super(Options.class); + super.substituteProperty(new PropertySubstitute("disable-syncing", Boolean.class, + "getDisableSyncing", "setDisableSyncing")); + super.substituteProperty(new PropertySubstitute("min-peers", Integer.class, + "getMinPeers", "setMinPeers")); + } + } + + + public static class DefaultOptions extends Options { + private List chains; + private Options options; + + public List getChains() { + return chains; + } + + public void setChains(List chains) { + this.chains = chains; + } + + public Options getOptions() { + return options; + } + + public void setOptions(Options options) { + this.options = options; + } + } + + public static class Upstream { + private String id; + private String chain; + + @Nullable + private String provider; + private List endpoints = Collections.emptyList(); + private Options options; + private boolean enabled = true; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getChain() { + return chain; + } + + public void setChain(String chain) { + this.chain = chain; + } + + @Nullable + public String getProvider() { + return provider; + } + + public void setProvider(@Nullable String provider) { + this.provider = provider; + } + + public List getEndpoints() { + return endpoints; + } + + public void setEndpoints(List endpoints) { + this.endpoints = endpoints; + } + + public Options getOptions() { + return options; + } + + public void setOptions(Options options) { + this.options = options; + } + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + } + + public static class Endpoint { + private EndpointType type; + private URI url; + @Nullable + private Auth auth; + private Boolean enabled = true; + @Nullable + private URI origin; + + public EndpointType getType() { + return type; + } + + public void setType(EndpointType type) { + this.type = type; + } + + public URI getUrl() { + return url; + } + + public void setUrl(URI url) { + this.url = url; + } + + @Nullable + public Auth getAuth() { + return auth; + } + + public void setAuth(@Nullable Auth auth) { + this.auth = auth; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + @Nullable + public URI getOrigin() { + return origin; + } + + public void setOrigin(@Nullable URI origin) { + this.origin = origin; + } + } + + public static class Auth { + private String type; + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + } + + public static class BasicAuth extends Auth { + private String key; + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + } + + public static class AuthYaml extends TypeDescription { + + public AuthYaml() { + super(Auth.class); + + } + + @Override + public Property getProperty(String name) { + if ("key".equals(name)) { + try { + return new MethodProperty(new PropertyDescriptor("key", BasicAuth.class, "getKey", "setKey")); + } catch (IntrospectionException e) { + e.printStackTrace(); + } + } + return super.getProperty(name); + } + + @Override + public Object newInstance(Node node) { + if (node instanceof MappingNode) { + MappingNode mappingNode = (MappingNode)node; + Optional type = mappingNode.getValue() + .stream() + .filter((n) -> n.getKeyNode() instanceof ScalarNode && n.getValueNode() instanceof ScalarNode) + .filter((n) -> { + ScalarNode sn = (ScalarNode)n.getKeyNode(); + return "type".equals(sn.getValue()); + }) + .map((n) -> (ScalarNode)n.getValueNode()) + .findFirst(); + if (type.isPresent()) { + if ("basic".equals(type.get().getValue())) { + return new BasicAuth(); + } else { + throw new IllegalArgumentException("Unsupported auth type: " + type.get().getValue()); + } + } else { + throw new IllegalArgumentException("Auth type is not set"); + } + } + throw new IllegalArgumentException("Auth is invalid"); + } + } + + public static enum EndpointType { + JSON_RPC("json-rpc"), + WEBSOCKET("ws", "websocket"), + DSHACKLE("dshackle"), + UNKNOWN("unknown"); + + private final String[] code; + + EndpointType(String ... code) { + this.code = code; + Arrays.sort(this.code); + } + + public static EndpointType byName(String code) { + code = code.toLowerCase(); + for (EndpointType t: EndpointType.values()) { + if (Arrays.binarySearch(t.code, code) >= 0) { + return t; + } + } + return UNKNOWN; + } + } + + public static class EndpointTypeYaml extends TypeDescription { + + public EndpointTypeYaml() { + super(EndpointType.class); + } + + @Override + public Object newInstance(Node node) { + if (node instanceof ScalarNode) { + return EndpointType.byName(((ScalarNode)node).getValue()); + } + throw new IllegalArgumentException("Invalid type: " + node.getClass()); + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/Configuration.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/Configuration.kt new file mode 100644 index 00000000..14204953 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/Configuration.kt @@ -0,0 +1,4 @@ +package io.emeraldpay.dshackle.config + +class Configuration { +} \ No newline at end of file diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsReader.kt new file mode 100644 index 00000000..ae644724 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/UpstreamsReader.kt @@ -0,0 +1,17 @@ +package io.emeraldpay.dshackle.config + +import org.yaml.snakeyaml.Yaml +import java.io.InputStream + +class UpstreamsReader { + + fun read(input: InputStream): Upstreams { + val yaml = Yaml() + yaml.addTypeDescription(Upstreams.EndpointTypeYaml()) + yaml.addTypeDescription(Upstreams.OptionsYaml()) + yaml.addTypeDescription(Upstreams.AuthYaml()) + return yaml.loadAs(input, Upstreams::class.java) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/Upstreams.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/Upstreams.kt index ec9a5761..1a730d0d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/Upstreams.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/Upstreams.kt @@ -1,12 +1,16 @@ package io.emeraldpay.dshackle.upstream import com.fasterxml.jackson.databind.ObjectMapper +import io.emeraldpay.dshackle.config.UpstreamsReader import io.emeraldpay.grpc.Chain import io.infinitape.etherjar.rpc.DefaultRpcClient import io.infinitape.etherjar.rpc.transport.DefaultRpcTransport +import org.apache.commons.lang3.StringUtils +import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.core.env.Environment import org.springframework.stereotype.Repository +import java.io.File import java.net.URI import javax.annotation.PostConstruct @@ -16,48 +20,72 @@ class Upstreams( @Autowired private val objectMapper: ObjectMapper ) { + private val log = LoggerFactory.getLogger(Upstreams::class.java) private var seq = 0 - private val chainMapping = HashMap() + private val chainMapping = HashMap>() + + private val chainNames = mapOf( + "ethereum" to Chain.ETHEREUM, + "ethereum-classic" to Chain.ETHEREUM_CLASSIC, + "morden" to Chain.MORDEN + ) @PostConstruct fun start() { - env.getProperty("upstream.ethereum")?.let { - val api = buildClient(it, Chain.ETHEREUM) - chainMapping[Chain.ETHEREUM] = Upstream(Chain.ETHEREUM, api) + val path = env.getProperty("upstreams.config") + if (StringUtils.isEmpty(path)) { + log.error("Path to upstreams is not set (upstreams.config)") + System.exit(1) } - env.getProperty("upstream.ethereumclassic")?.let { - val api = buildClient(it, Chain.ETHEREUM_CLASSIC) - val ws = if (env.containsProperty("upstream.ethereumclassic.ws")) { - buildWs(env.getProperty("upstream.ethereumclassic.ws")!!, Chain.ETHEREUM_CLASSIC) - } else { - null + val upstreamConfig = File(path) + val ok = upstreamConfig.exists() && upstreamConfig.isFile + if (!ok) { + log.error("Unable to setup upstreams from ${upstreamConfig.path}") + System.exit(1) + } + log.info("Read upstream configuration from ${upstreamConfig.path}") + val reader = UpstreamsReader() + val config = reader.read(upstreamConfig.inputStream()) + + config.upstreams.forEach { up -> + val chain = chainNames[up.chain] ?: return@forEach + var rpcApi: EthereumApi? = null + var wsApi: EthereumWs? = null + val urls = ArrayList() + up.endpoints.forEach { endpoint -> + if (endpoint.type == io.emeraldpay.dshackle.config.Upstreams.EndpointType.JSON_RPC) { + rpcApi = EthereumApi( + DefaultRpcClient(DefaultRpcTransport(endpoint.url)), + objectMapper, + chain + ) + } + if (endpoint.type == io.emeraldpay.dshackle.config.Upstreams.EndpointType.WEBSOCKET) { + wsApi = EthereumWs( + endpoint.url, + endpoint.origin ?: URI("http://localhost") + ) + } + urls.add(endpoint.url) + } + if (rpcApi != null) { + log.info("Info using ${chain.chainName} upstream, at ${urls.joinToString()}") + val current = chainMapping[chain] ?: ArrayList() + current.add(Upstream(chain, rpcApi!!, wsApi)) + chainMapping[chain] = current } - chainMapping[Chain.ETHEREUM_CLASSIC] = Upstream(Chain.ETHEREUM_CLASSIC, api, ws) } - env.getProperty("upstream.morden")?.let { - val api = buildClient(it, Chain.MORDEN) - chainMapping[Chain.MORDEN] = Upstream(Chain.MORDEN, api) - } - } - - private fun buildClient(url: String, chain: Chain): EthereumApi { - return EthereumApi( - DefaultRpcClient(DefaultRpcTransport(URI(url))), - objectMapper, - chain - ) - } - - private fun buildWs(url: String, chain: Chain): EthereumWs { - val ws = EthereumWs( - URI(url), - URI("http://localhost") - ) - ws.connect() - return ws } fun ethereumUpstream(chain: Chain): Upstream? { - return chainMapping[chain] + val list = chainMapping[chain] + if (list == null || list.isEmpty()) { + return null + } + val i = seq++ + if (seq >= Int.MAX_VALUE / 2) { + seq = 0 + } + return list.get(i % list.size) } } \ No newline at end of file diff --git a/src/test/groovy/io/emeraldpay/dshackle/config/UpstreamsReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/config/UpstreamsReaderSpec.groovy new file mode 100644 index 00000000..3146a42d --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/config/UpstreamsReaderSpec.groovy @@ -0,0 +1,57 @@ +package io.emeraldpay.dshackle.config + +import spock.lang.Specification + +class UpstreamsReaderSpec extends Specification { + + UpstreamsReader reader = new UpstreamsReader() + + def "Parse standard config"() { + setup: + def config = this.class.getClassLoader().getResourceAsStream("upstreams-basic.yaml") + when: + def act = reader.read(config) + then: + act != null + act.version == "v1" + with(act.defaultOptions) { + size() == 1 + with(get(0)) { + chains == ["ethereum"] + options.quorum == 1 + options.minPeers == 3 + options.disableSyncing + } + } + act.upstreams.size() == 2 + with(act.upstreams.get(0)) { + id == "local" + chain == "ethereum" + provider == "geth" + endpoints.size() == 2 + with(endpoints.get(0)) { + type == Upstreams.EndpointType.JSON_RPC + url == new URI("http://localhost:8545") + } + with(endpoints.get(1)) { + type == Upstreams.EndpointType.WEBSOCKET + url == new URI("ws://localhost:8546") + } + } + with(act.upstreams.get(1)) { + id == "infura" + chain == "ethereum" + provider == "infura" + endpoints.size() == 1 + with(endpoints.get(0)) { + type == Upstreams.EndpointType.JSON_RPC + url == new URI("https://mainnet.infura.io/v3/fa28c968191849c1aff541ad1d8511f2") + auth instanceof Upstreams.BasicAuth + with((Upstreams.BasicAuth)auth) { + key == "4fc258fe41a68149c199ad8f281f2015" + } + } + + } + } +} diff --git a/src/test/resources/upstreams-basic.yaml b/src/test/resources/upstreams-basic.yaml new file mode 100644 index 00000000..bdf878d4 --- /dev/null +++ b/src/test/resources/upstreams-basic.yaml @@ -0,0 +1,29 @@ +version: v1 + +defaultOptions: + - chains: + - ethereum + options: + disable-syncing: true + min-peers: 3 + quorum: 1 + +upstreams: + - id: local + chain: ethereum + provider: geth + endpoints: + - type: json-rpc + url: "http://localhost:8545" + - type: ws + url: "ws://localhost:8546" + origin: "http://localhost" + - id: infura + chain: ethereum + provider: infura + endpoints: + - type: json-rpc + url: "https://mainnet.infura.io/v3/fa28c968191849c1aff541ad1d8511f2" + auth: + type: basic + key: 4fc258fe41a68149c199ad8f281f2015 \ No newline at end of file