From 6dbe8b96ec0ecf4ccf665168fc2b906e976fb548 Mon Sep 17 00:00:00 2001 From: Igor Artamonov Date: Fri, 3 Jul 2020 00:06:48 -0400 Subject: [PATCH 1/3] solution: erc20 tokens config --- .../emeraldpay/dshackle/config/MainConfig.kt | 1 + .../dshackle/config/MainConfigReader.kt | 4 ++ .../dshackle/config/TokensConfig.kt | 55 ++++++++++++++++ .../dshackle/config/TokensConfigReader.kt | 63 +++++++++++++++++++ .../config/TokensConfigReaderSpec.groovy | 50 +++++++++++++++ src/test/resources/tokens/basic.yaml | 11 ++++ 6 files changed, 184 insertions(+) create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/config/TokensConfig.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/config/TokensConfigReader.kt create mode 100644 src/test/groovy/io/emeraldpay/dshackle/config/TokensConfigReaderSpec.groovy create mode 100644 src/test/resources/tokens/basic.yaml diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfig.kt index dac4bd1a..13915be9 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfig.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfig.kt @@ -22,5 +22,6 @@ class MainConfig { var cache: CacheConfig? = null var proxy: ProxyConfig? = null var upstreams: UpstreamsConfig? = null + var tokens: TokensConfig? = null } \ No newline at end of file diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfigReader.kt index 0ac3653a..2af182b8 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfigReader.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/MainConfigReader.kt @@ -32,6 +32,7 @@ class MainConfigReader( private val proxyConfigReader = ProxyConfigReader() private val upstreamsConfigReader = UpstreamsConfigReader(fileResolver) private val cacheConfigReader = CacheConfigReader() + private val tokensConfigReader = TokensConfigReader() fun read(input: InputStream): MainConfig? { val configNode = readNode(input) @@ -59,6 +60,9 @@ class MainConfigReader( cacheConfigReader.read(input)?.let { config.cache = it } + tokensConfigReader.read(input)?.let { + config.tokens = it + } return config } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/TokensConfig.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/TokensConfig.kt new file mode 100644 index 00000000..d9bd5e95 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/TokensConfig.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2020 EmeraldPay, Inc + * + * 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. + */ +package io.emeraldpay.dshackle.config + +import io.emeraldpay.dshackle.BlockchainType +import io.emeraldpay.grpc.Chain +import io.infinitape.etherjar.domain.Address + +class TokensConfig( + val tokens: List +) { + + class Token { + // reference id, used by get balance and others + var id: String? = null + var blockchain: Chain? = null + + // coin name + var name: String? = null + var type: Type? = null; + var address: String? = null + + fun validate(): String? { + return when { + id.isNullOrBlank() -> "id" + blockchain == null -> "blockchain" + name.isNullOrBlank() -> "name" + type == null -> type + address.isNullOrBlank() -> "address" + blockchain != null + && BlockchainType.fromBlockchain(blockchain!!) == BlockchainType.ETHEREUM + && !Address.isValidAddress(address) -> "address" + else -> null + } + } + } + + enum class Type { + ERC20 + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/emeraldpay/dshackle/config/TokensConfigReader.kt b/src/main/kotlin/io/emeraldpay/dshackle/config/TokensConfigReader.kt new file mode 100644 index 00000000..70bdbb38 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/config/TokensConfigReader.kt @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2020 EmeraldPay, Inc + * + * 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. + */ +package io.emeraldpay.dshackle.config + +import org.slf4j.LoggerFactory +import org.yaml.snakeyaml.nodes.MappingNode +import java.io.InputStream + +class TokensConfigReader : YamlConfigReader(), ConfigReader { + + private val log = LoggerFactory.getLogger(TokensConfigReader::class.java) + + fun read(input: InputStream): TokensConfig? { + val configNode = readNode(input) + return read(configNode) + } + + override fun read(input: MappingNode?): TokensConfig? { + val tokens = getList(input, "tokens")?.value?.map { node -> + val token = TokensConfig.Token() + token.id = getValueAsString(node, "id") + token.blockchain = getValueAsString(node, "blockchain")?.let { + getBlockchain(it) + } + token.address = getValueAsString(node, "address") + token.name = getValueAsString(node, "name") + token.type = getValueAsString(node, "type")?.let { + if (it.toUpperCase() == "ERC-20") { + TokensConfig.Type.ERC20 + } else { + log.warn("Invalid token type: $it") + null + } + } + token + }?.filter { token -> + val invalidField = token.validate() + if (invalidField != null) { + log.error("Failed to parse token ${token.id}. Invalid field: $invalidField") + false + } else { + true + } + } + return tokens?.let { + TokensConfig(it) + } + } + +} \ No newline at end of file diff --git a/src/test/groovy/io/emeraldpay/dshackle/config/TokensConfigReaderSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/config/TokensConfigReaderSpec.groovy new file mode 100644 index 00000000..4ef8c3a4 --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/config/TokensConfigReaderSpec.groovy @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2020 EmeraldPay, Inc + * + * 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. + */ +package io.emeraldpay.dshackle.config + +import io.emeraldpay.grpc.Chain +import spock.lang.Specification + +class TokensConfigReaderSpec extends Specification { + + TokensConfigReader reader = new TokensConfigReader() + + def "Read basic"() { + setup: + def config = this.class.getClassLoader().getResourceAsStream("tokens/basic.yaml") + when: + def act = reader.read(config) + + then: + act != null + act.tokens.size() == 2 + with(act.tokens[0]) { + id == "dai" + blockchain == Chain.ETHEREUM + name == "DAI" + type == TokensConfig.Type.ERC20 + address == "0x6B175474E89094C44Da98b954EedeAC495271d0F" + } + with(act.tokens[1]) { + id == "tether" + blockchain == Chain.ETHEREUM + name == "Tether" + type == TokensConfig.Type.ERC20 + address == "0xdac17f958d2ee523a2206206994597c13d831ec7" + } + + } +} diff --git a/src/test/resources/tokens/basic.yaml b/src/test/resources/tokens/basic.yaml new file mode 100644 index 00000000..aa512ddb --- /dev/null +++ b/src/test/resources/tokens/basic.yaml @@ -0,0 +1,11 @@ +tokens: + - id: dai + blockchain: ethereum + name: DAI + type: ERC-20 + address: 0x6B175474E89094C44Da98b954EedeAC495271d0F + - id: tether + blockchain: ethereum + name: Tether + type: ERC-20 + address: 0xdac17f958d2ee523a2206206994597c13d831ec7 \ No newline at end of file From b3c619e57ad6572faae275446a34e4d801a91083 Mon Sep 17 00:00:00 2001 From: Igor Artamonov Date: Mon, 6 Jul 2020 19:04:45 -0400 Subject: [PATCH 2/3] solution: track balance of a ERC20 token --- build.gradle | 2 + gradle.properties | 2 +- .../kotlin/io/emeraldpay/dshackle/Config.kt | 10 +- .../emeraldpay/dshackle/rpc/BlockchainRpc.kt | 16 +- .../dshackle/rpc/EthereumAddresses.kt | 28 +++ .../emeraldpay/dshackle/rpc/TrackAddress.kt | 2 +- .../dshackle/rpc/TrackBitcoinAddress.kt | 5 +- .../dshackle/rpc/TrackERC20Address.kt | 132 +++++++++++++ .../dshackle/rpc/TrackEthereumAddress.kt | 18 +- .../upstream/rpcclient/JsonRpcRequest.kt | 4 +- .../dshackle/rpc/EthereumAddressesSpec.groovy | 113 +++++++++++ .../dshackle/rpc/TrackERC20AddressSpec.groovy | 178 ++++++++++++++++++ .../dshackle/test/ReaderMock.groovy | 6 +- 13 files changed, 490 insertions(+), 26 deletions(-) create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/rpc/EthereumAddresses.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackERC20Address.kt create mode 100644 src/test/groovy/io/emeraldpay/dshackle/rpc/EthereumAddressesSpec.groovy create mode 100644 src/test/groovy/io/emeraldpay/dshackle/rpc/TrackERC20AddressSpec.groovy diff --git a/build.gradle b/build.gradle index 6249a6de..3a1f9473 100644 --- a/build.gradle +++ b/build.gradle @@ -90,6 +90,8 @@ dependencies { implementation "io.infinitape:etherjar-rpc-http:$etherjarVersion" implementation "io.infinitape:etherjar-rpc-ws:$etherjarVersion" implementation "io.infinitape:etherjar-tx:$etherjarVersion" + implementation "io.infinitape:etherjar-contract:$etherjarVersion" + implementation "io.infinitape:etherjar-erc20:$etherjarVersion" implementation 'org.bitcoinj:bitcoinj-core:0.15.8' implementation 'org.yaml:snakeyaml:1.24' diff --git a/gradle.properties b/gradle.properties index 587451be..ca017e75 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,7 +13,7 @@ springSecurtyVersion=5.3.2.RELEASE reactorVersion=3.3.5.RELEASE nettyVersion=4.1.49.Final # Our Libs -etherjarVersion=0.9.1 +etherjarVersion=0.10.0 # Testing spockVersion=1.3-groovy-2.5 diff --git a/src/main/kotlin/io/emeraldpay/dshackle/Config.kt b/src/main/kotlin/io/emeraldpay/dshackle/Config.kt index 3ec7a475..0479086d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/Config.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/Config.kt @@ -20,10 +20,7 @@ import com.fasterxml.jackson.core.Version import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.module.SimpleModule -import io.emeraldpay.dshackle.config.CacheConfig -import io.emeraldpay.dshackle.config.MainConfig -import io.emeraldpay.dshackle.config.MainConfigReader -import io.emeraldpay.dshackle.config.UpstreamsConfig +import io.emeraldpay.dshackle.config.* import org.slf4j.LoggerFactory import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Qualifier @@ -103,4 +100,9 @@ open class Config( return mainConfig.cache ?: CacheConfig() } + @Bean + open fun tokensConfig(@Autowired mainConfig: MainConfig): TokensConfig { + return mainConfig.tokens ?: TokensConfig(emptyList()) + } + } \ No newline at end of file diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/BlockchainRpc.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/BlockchainRpc.kt index f05388c8..516b44ad 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/BlockchainRpc.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/BlockchainRpc.kt @@ -64,9 +64,13 @@ class BlockchainRpc( override fun subscribeBalance(requestMono: Mono): Flux { return requestMono.flatMapMany { request -> val chain = Chain.byId(request.asset.chainValue) + val asset = request.asset.code.toLowerCase() try { - trackAddress.find { it.isSupported(chain) }?.subscribe(request) - ?: Flux.error(SilentException.UnsupportedBlockchain(chain)) + trackAddress.find { it.isSupported(chain, asset) }?.subscribe(request) + ?: Flux.error(SilentException.UnsupportedBlockchain(chain)) + .doOnSubscribe { + log.error("Balance for $chain:$asset is not supported") + } } catch (t: Throwable) { log.error("Internal error during Balance Subscription", t) Flux.error(IllegalStateException("Internal Error")) @@ -77,9 +81,13 @@ class BlockchainRpc( override fun getBalance(requestMono: Mono): Flux { return requestMono.flatMapMany { request -> val chain = Chain.byId(request.asset.chainValue) + val asset = request.asset.code.toLowerCase() try { - trackAddress.find { it.isSupported(chain) }?.getBalance(request) - ?: Flux.error(SilentException.UnsupportedBlockchain(chain)) + trackAddress.find { it.isSupported(chain, asset) }?.getBalance(request) + ?: Flux.error(SilentException.UnsupportedBlockchain(chain)) + .doOnSubscribe { + log.error("Balance for $chain:$asset is not supported") + } } catch (t: Throwable) { log.error("Internal error during Balance Request", t) Flux.error(IllegalStateException("Internal Error")) diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/EthereumAddresses.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/EthereumAddresses.kt new file mode 100644 index 00000000..aa51b6d0 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/EthereumAddresses.kt @@ -0,0 +1,28 @@ +package io.emeraldpay.dshackle.rpc + +import io.emeraldpay.api.proto.Common +import io.infinitape.etherjar.domain.Address +import org.slf4j.LoggerFactory +import reactor.core.publisher.Flux + +class EthereumAddresses { + + companion object { + private val log = LoggerFactory.getLogger(EthereumAddresses::class.java) + } + + fun extract(addresses: Common.AnyAddress): Flux
{ + return when (addresses.addrTypeCase) { + Common.AnyAddress.AddrTypeCase.ADDRESS_SINGLE -> + Flux.just(Address.from(addresses.addressSingle.address)) + Common.AnyAddress.AddrTypeCase.ADDRESS_MULTI -> + Flux.fromIterable(addresses.addressMulti.addressesList) + .map { Address.from(it.address) } + else -> { + log.error("Unsupported address type: ${addresses.addrTypeCase}") + Flux.empty() + } + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackAddress.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackAddress.kt index 8f2f060e..a841626c 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackAddress.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackAddress.kt @@ -25,7 +25,7 @@ import reactor.core.publisher.Mono */ interface TrackAddress { - fun isSupported(chain: Chain): Boolean + fun isSupported(chain: Chain, asset: String): Boolean fun getBalance(request: BlockchainOuterClass.BalanceRequest): Flux fun subscribe(request: BlockchainOuterClass.BalanceRequest): Flux diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackBitcoinAddress.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackBitcoinAddress.kt index 58896632..3ac705b5 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackBitcoinAddress.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackBitcoinAddress.kt @@ -41,8 +41,9 @@ class TrackBitcoinAddress( private val log = LoggerFactory.getLogger(TrackBitcoinAddress::class.java) } - override fun isSupported(chain: Chain): Boolean { - return BlockchainType.fromBlockchain(chain) == BlockchainType.BITCOIN && multistreamHolder.isAvailable(chain) + override fun isSupported(chain: Chain, asset: String): Boolean { + return (asset == "bitcoin" || asset == "btc" || asset == "satoshi") + && BlockchainType.fromBlockchain(chain) == BlockchainType.BITCOIN && multistreamHolder.isAvailable(chain) } fun allAddresses(request: BlockchainOuterClass.BalanceRequest): List? { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackERC20Address.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackERC20Address.kt new file mode 100644 index 00000000..64c9233e --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackERC20Address.kt @@ -0,0 +1,132 @@ +package io.emeraldpay.dshackle.rpc + +import io.emeraldpay.api.proto.BlockchainOuterClass +import io.emeraldpay.api.proto.Common +import io.emeraldpay.dshackle.BlockchainType +import io.emeraldpay.dshackle.SilentException +import io.emeraldpay.dshackle.config.TokensConfig +import io.emeraldpay.dshackle.upstream.MultistreamHolder +import io.emeraldpay.dshackle.upstream.Selector +import io.emeraldpay.dshackle.upstream.ethereum.EthereumMultistream +import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest +import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse +import io.emeraldpay.grpc.Chain +import io.infinitape.etherjar.domain.Address +import io.infinitape.etherjar.erc20.ERC20Token +import io.infinitape.etherjar.hex.Hex32 +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.stereotype.Service +import reactor.core.publisher.Flux +import reactor.core.publisher.Mono +import java.math.BigInteger +import javax.annotation.PostConstruct + +@Service +class TrackERC20Address( + @Autowired private val multistreamHolder: MultistreamHolder, + @Autowired private val tokensConfig: TokensConfig +) : TrackAddress { + + companion object { + private val log = LoggerFactory.getLogger(TrackERC20Address::class.java) + } + + private val ethereumAddresses = EthereumAddresses() + private val tokens: MutableMap = HashMap() + + @PostConstruct + fun init() { + tokensConfig.tokens.forEach { token -> + val chain = token.blockchain!! + val asset = token.name!!.toLowerCase() + val id = TokenId(chain, asset) + val definition = TokenDefinition( + chain, asset, + ERC20Token(Address.from(token.address)) + ) + tokens[id] = definition + log.info("Enable ERC20 balance for $chain:$asset") + } + } + + override fun isSupported(chain: Chain, asset: String): Boolean { + return tokens.containsKey(TokenId(chain, asset.toLowerCase())) && + BlockchainType.fromBlockchain(chain) == BlockchainType.ETHEREUM && multistreamHolder.isAvailable(chain) + } + + override fun getBalance(request: BlockchainOuterClass.BalanceRequest): Flux { + val chain = Chain.byId(request.asset.chainValue) + val asset = request.asset.code.toLowerCase() + val tokenDefinition = tokens[TokenId(chain, asset)] ?: return Flux.empty() + return ethereumAddresses.extract(request.address) + .map { TrackedAddress(chain, it, tokenDefinition.token, tokenDefinition.name) } + .flatMap { addr -> getBalance(addr).map(addr::withBalance) } + .map { buildResponse(it) } + } + + override fun subscribe(request: BlockchainOuterClass.BalanceRequest): Flux { + val chain = Chain.byId(request.asset.chainValue) + val asset = request.asset.code.toLowerCase() + val tokenDefinition = tokens[TokenId(chain, asset)] ?: return Flux.empty() + val head = multistreamHolder.getUpstream(chain)?.getHead()?.getFlux() ?: Flux.empty() + + return ethereumAddresses.extract(request.address) + .map { TrackedAddress(chain, it, tokenDefinition.token, tokenDefinition.name) } + .flatMap { addr -> + val current = getBalance(addr) + val updates = head.flatMap { getBalance(addr) } + Flux.concat(current, updates) + .distinctUntilChanged() + .map { addr.withBalance(it) } + } + .map { buildResponse(it) } + } + + fun getBalance(addr: TrackedAddress): Mono { + return getUpstream(addr.chain) + .getDirectApi(Selector.empty) + .flatMap { api -> + api.read(prepareEthCall(addr.token, addr.address)) + .flatMap(JsonRpcResponse::requireStringResult) + .map { + Hex32.from(it).asQuantity().value + } + } + } + + fun prepareEthCall(token: ERC20Token, target: Address): JsonRpcRequest { + val call = token + .readBalanceOf(target) + .toJson() + return JsonRpcRequest("eth_call", listOf(call, "latest")) + } + + fun getUpstream(chain: Chain): EthereumMultistream { + return multistreamHolder.getUpstream(chain)?.cast(EthereumMultistream::class.java) + ?: throw SilentException.UnsupportedBlockchain(chain) + } + + private fun buildResponse(address: TrackedAddress): BlockchainOuterClass.AddressBalance { + return BlockchainOuterClass.AddressBalance.newBuilder() + .setBalance(address.balance!!.toString(10)) + .setAsset(Common.Asset.newBuilder() + .setChainValue(address.chain.id) + .setCode(address.tokenName.toUpperCase())) + .setAddress(Common.SingleAddress.newBuilder().setAddress(address.address.toHex())) + .build() + } + + class TrackedAddress(val chain: Chain, + val address: Address, + val token: ERC20Token, + val tokenName: String, + val balance: BigInteger? = null + ) { + fun withBalance(balance: BigInteger) = TrackedAddress(chain, address, token, tokenName, balance) + } + + data class TokenId(val chain: Chain, val name: String) + data class TokenDefinition(val chain: Chain, val name: String, val token: ERC20Token) + +} \ No newline at end of file diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackEthereumAddress.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackEthereumAddress.kt index b953d448..c2e37e1d 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackEthereumAddress.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackEthereumAddress.kt @@ -38,9 +38,11 @@ class TrackEthereumAddress( ) : TrackAddress { private val log = LoggerFactory.getLogger(TrackEthereumAddress::class.java) + private val ethereumAddresses = EthereumAddresses() - override fun isSupported(chain: Chain): Boolean { - return BlockchainType.fromBlockchain(chain) == BlockchainType.ETHEREUM && multistreamHolder.isAvailable(chain) + override fun isSupported(chain: Chain, asset: String): Boolean { + return asset == "ether" && + BlockchainType.fromBlockchain(chain) == BlockchainType.ETHEREUM && multistreamHolder.isAvailable(chain) } override fun getBalance(request: BlockchainOuterClass.BalanceRequest): Flux { @@ -99,16 +101,8 @@ class TrackEthereumAddress( if (request.asset.code?.toLowerCase() != "ether") { return Flux.error(SilentException("Unsupported asset ${request.asset.code}")) } - return when (request.address.addrTypeCase) { - Common.AnyAddress.AddrTypeCase.ADDRESS_SINGLE -> - Flux.just(createAddress(request.address.addressSingle, chain)) - Common.AnyAddress.AddrTypeCase.ADDRESS_MULTI -> - Flux.fromIterable(request.address.addressMulti.addressesList) - .map { createAddress(it, chain) } - else -> { - log.error("Unsupported address type: ${request.address.addrTypeCase}") - Flux.empty() - } + return ethereumAddresses.extract(request.address).map { + TrackedAddress(chain, it) } } diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcRequest.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcRequest.kt index d49e168d..5339468e 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcRequest.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/rpcclient/JsonRpcRequest.kt @@ -48,5 +48,7 @@ class JsonRpcRequest( return result } - + override fun toString(): String { + return String(this.toJson()) + } } \ No newline at end of file diff --git a/src/test/groovy/io/emeraldpay/dshackle/rpc/EthereumAddressesSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/rpc/EthereumAddressesSpec.groovy new file mode 100644 index 00000000..f6a9ea0a --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/rpc/EthereumAddressesSpec.groovy @@ -0,0 +1,113 @@ +package io.emeraldpay.dshackle.rpc + +import io.emeraldpay.api.proto.Common +import io.infinitape.etherjar.domain.Address +import reactor.test.StepVerifier +import spock.lang.Specification + +import java.time.Duration + +class EthereumAddressesSpec extends Specification { + + EthereumAddresses ethereumAddresses = new EthereumAddresses() + + def "Extract single address"() { + setup: + def address = Common.AnyAddress.newBuilder() + .setAddressSingle( + Common.SingleAddress.newBuilder() + .setAddress("0xab5c66752a9e8167967685f1450532fb96d5d24f") + ) + .build() + when: + def act = ethereumAddresses.extract(address) + then: + StepVerifier.create(act) + .expectNext(Address.from("0xab5c66752a9e8167967685f1450532fb96d5d24f")) + .expectComplete().verify(Duration.ofSeconds(1)) + + } + + def "Extract single addresses passed as multi"() { + setup: + def address = Common.AnyAddress.newBuilder() + .setAddressMulti( + Common.MultiAddress.newBuilder() + .addAddresses( + Common.SingleAddress.newBuilder() + .setAddress("0xab5c66752a9e8167967685f1450532fb96d5d24f") + ) + ) + .build() + when: + def act = ethereumAddresses.extract(address) + then: + StepVerifier.create(act) + .expectNext(Address.from("0xab5c66752a9e8167967685f1450532fb96d5d24f")) + .expectComplete().verify(Duration.ofSeconds(1)) + + } + + def "Extract two addresses"() { + setup: + def address = Common.AnyAddress.newBuilder() + .setAddressMulti( + Common.MultiAddress.newBuilder() + .addAddresses( + Common.SingleAddress.newBuilder() + .setAddress("0xab5c66752a9e8167967685f1450532fb96d5d24f") + ) + .addAddresses( + Common.SingleAddress.newBuilder() + .setAddress("0xfdb16996831753d5331ff813c29a93c76834a0ad") + ) + ) + .build() + when: + def act = ethereumAddresses.extract(address) + then: + StepVerifier.create(act) + .expectNext(Address.from("0xab5c66752a9e8167967685f1450532fb96d5d24f")) + .expectNext(Address.from("0xfdb16996831753d5331ff813c29a93c76834a0ad")) + .expectComplete().verify(Duration.ofSeconds(1)) + } + + def "Extract fes addresses"() { + setup: + def address = Common.AnyAddress.newBuilder() + .setAddressMulti( + Common.MultiAddress.newBuilder() + .addAddresses( + Common.SingleAddress.newBuilder() + .setAddress("0xab5c66752a9e8167967685f1450532fb96d5d24f") + ) + .addAddresses( + Common.SingleAddress.newBuilder() + .setAddress("0xfdb16996831753d5331ff813c29a93c76834a0ad") + ) + .addAddresses( + Common.SingleAddress.newBuilder() + .setAddress("0x46705dfff24256421a05d056c29e81bdc09723b8") + ) + .addAddresses( + Common.SingleAddress.newBuilder() + .setAddress("0xadb2b42f6bd96f5c65920b9ac88619dce4166f94") + ) + .addAddresses( + Common.SingleAddress.newBuilder() + .setAddress("0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852") + ) + ) + .build() + when: + def act = ethereumAddresses.extract(address) + then: + StepVerifier.create(act) + .expectNext(Address.from("0xab5c66752a9e8167967685f1450532fb96d5d24f")) + .expectNext(Address.from("0xfdb16996831753d5331ff813c29a93c76834a0ad")) + .expectNext(Address.from("0x46705dfff24256421a05d056c29e81bdc09723b8")) + .expectNext(Address.from("0xadb2b42f6bd96f5c65920b9ac88619dce4166f94")) + .expectNext(Address.from("0x0d4a11d5eeaac28ec3f61d100daf4d40471f1852")) + .expectComplete().verify(Duration.ofSeconds(1)) + } +} diff --git a/src/test/groovy/io/emeraldpay/dshackle/rpc/TrackERC20AddressSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/rpc/TrackERC20AddressSpec.groovy new file mode 100644 index 00000000..9e1d2429 --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/rpc/TrackERC20AddressSpec.groovy @@ -0,0 +1,178 @@ +package io.emeraldpay.dshackle.rpc + +import io.emeraldpay.api.proto.BlockchainOuterClass +import io.emeraldpay.api.proto.Common +import io.emeraldpay.dshackle.config.TokensConfig +import io.emeraldpay.dshackle.test.EthereumUpstreamMock +import io.emeraldpay.dshackle.test.MultistreamHolderMock +import io.emeraldpay.dshackle.test.ReaderMock +import io.emeraldpay.dshackle.upstream.Multistream +import io.emeraldpay.dshackle.upstream.MultistreamHolder +import io.emeraldpay.dshackle.reader.Reader +import io.emeraldpay.dshackle.upstream.ethereum.EthereumUpstream +import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcRequest +import io.emeraldpay.dshackle.upstream.rpcclient.JsonRpcResponse +import io.emeraldpay.grpc.Chain +import io.infinitape.etherjar.domain.Address +import io.infinitape.etherjar.erc20.ERC20Token +import io.infinitape.etherjar.hex.HexData +import io.infinitape.etherjar.rpc.json.TransactionCallJson +import reactor.core.publisher.Mono +import spock.lang.Specification + +class TrackERC20AddressSpec extends Specification { + + def "Init with single token"() { + setup: + MultistreamHolder ups = Mock(MultistreamHolder) { + _ * isAvailable(Chain.ETHEREUM) >> true + } + TokensConfig tokens = new TokensConfig([ + new TokensConfig.Token().tap { + id = "dai" + blockchain = Chain.ETHEREUM + name = "DAI" + type = TokensConfig.Type.ERC20 + address = Address.from("0x6B175474E89094C44Da98b954EedeAC495271d0F") + } + ]) + TrackERC20Address track = new TrackERC20Address(ups, tokens) + when: + track.init() + def act = track.tokens + def supportDai = track.isSupported(Chain.ETHEREUM, "DAI") + def supportSai = track.isSupported(Chain.ETHEREUM, "SAI") + + then: + act.size() == 1 + with(act.keySet().first()) { + chain == Chain.ETHEREUM + name == "dai" + } + with(act.values().first()) { + chain == Chain.ETHEREUM + name == "dai" + token != null + } + supportDai + !supportSai + } + + def "Init without tokens"() { + setup: + MultistreamHolder ups = Mock(MultistreamHolder) { + _ * isAvailable(Chain.ETHEREUM) >> true + } + + TokensConfig tokens = new TokensConfig([]) + TrackERC20Address track = new TrackERC20Address(ups, tokens) + when: + track.init() + def act = track.tokens + def supportDai = track.isSupported(Chain.ETHEREUM, "dai") + def supportSai = track.isSupported(Chain.ETHEREUM, "sai") + + then: + act.size() == 0 + !supportDai + !supportSai + } + + def "Init with two tokens"() { + setup: + MultistreamHolder ups = Mock(MultistreamHolder) { + _ * isAvailable(Chain.ETHEREUM) >> true + } + + TokensConfig tokens = new TokensConfig([ + new TokensConfig.Token().tap { + id = "dai" + blockchain = Chain.ETHEREUM + name = "DAI" + type = TokensConfig.Type.ERC20 + address = Address.from("0x6B175474E89094C44Da98b954EedeAC495271d0F") + }, + new TokensConfig.Token().tap { + id = "sai" + blockchain = Chain.ETHEREUM + name = "SAI" + type = TokensConfig.Type.ERC20 + address = Address.from("0x54EedeAC495271d0F6B175474E89094C44Da98b9") + } + ]) + TrackERC20Address track = new TrackERC20Address(ups, tokens) + when: + track.init() + def act = track.tokens + def supportDai = track.isSupported(Chain.ETHEREUM, "dai") + def supportSai = track.isSupported(Chain.ETHEREUM, "sai") + + then: + act.size() == 2 + with(act[act.keySet().find { it.name == "dai" }]) { + chain == Chain.ETHEREUM + name == "dai" + } + with(act[act.keySet().find { it.name == "sai" }]) { + chain == Chain.ETHEREUM + name == "sai" + } + supportDai + supportSai + } + + def "Gets balance from upstream"() { + setup: + ReaderMock api = new ReaderMock() + .with( + new JsonRpcRequest("eth_call", [ + new TransactionCallJson().tap { json -> + json.setTo(Address.from("0x54EedeAC495271d0F6B175474E89094C44Da98b9")) + json.setData(HexData.from("0x70a0823100000000000000000000000016c15c65ad00b6dfbcc2cb8a7b6c2d0103a3883b")) + }, + "latest" + ]), + JsonRpcResponse.ok('"0x0000000000000000000000000000000000000000000000000000001f28d72868"') + ) + + EthereumUpstream upstream = new EthereumUpstreamMock(Chain.ETHEREUM, api) + MultistreamHolder ups = new MultistreamHolderMock(Chain.ETHEREUM, upstream) + TrackERC20Address track = new TrackERC20Address(ups, new TokensConfig([])) + + TrackERC20Address.TrackedAddress address = new TrackERC20Address.TrackedAddress( + Chain.ETHEREUM, + Address.from("0x16c15c65ad00b6dfbcc2cb8a7b6c2d0103a3883b"), + new ERC20Token(Address.from("0x54EedeAC495271d0F6B175474E89094C44Da98b9")), + "test", + BigInteger.valueOf(1234) + ) + when: + def act = track.getBalance(address).block() + + then: + act.toLong() == 0x1f28d72868 + } + + def "Builds response"() { + setup: + TrackERC20Address track = new TrackERC20Address(Stub(MultistreamHolder), new TokensConfig([])) + TrackERC20Address.TrackedAddress address = new TrackERC20Address.TrackedAddress( + Chain.ETHEREUM, + Address.from("0x16c15c65ad00b6dfbcc2cb8a7b6c2d0103a3883b"), + new ERC20Token(Address.from("0x54EedeAC495271d0F6B175474E89094C44Da98b9")), + "test", + BigInteger.valueOf(1234) + ) + when: + def act = track.buildResponse(address) + then: + act == BlockchainOuterClass.AddressBalance.newBuilder() + .setAddress(Common.SingleAddress.newBuilder().setAddress("0x16c15c65ad00b6dfbcc2cb8a7b6c2d0103a3883b")) + .setAsset(Common.Asset.newBuilder() + .setChain(Common.ChainRef.CHAIN_ETHEREUM) + .setCode("TEST") + ) + .setBalance("1234") + .build() + } +} diff --git a/src/test/groovy/io/emeraldpay/dshackle/test/ReaderMock.groovy b/src/test/groovy/io/emeraldpay/dshackle/test/ReaderMock.groovy index 9cb51595..b1da4d99 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/test/ReaderMock.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/test/ReaderMock.groovy @@ -32,6 +32,10 @@ class ReaderMock implements Reader { @Override Mono read(K key) { - return Mono.justOrEmpty(mapping.get(key)) + def value = mapping.get(key) + if (value == null) { + println("No mocked value for request: $key") + } + return Mono.justOrEmpty(value) } } From 0dbf25643dd3e3377b6b2437306b30eefc727ff1 Mon Sep 17 00:00:00 2001 From: Igor Artamonov Date: Mon, 6 Jul 2020 19:29:43 -0400 Subject: [PATCH 3/3] solution: docs for ERC-20 tokens --- README.adoc | 8 +++-- docs/06-methods.adoc | 11 +++--- docs/reference-configuration.adoc | 60 +++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/README.adoc b/README.adoc index 8ee5e049..88a4e5c5 100644 --- a/README.adoc +++ b/README.adoc @@ -43,9 +43,8 @@ image::dshackle-intro.png[alt="",width=80%,align="center"] == Roadmap - [ ] External logging -- [ ] Access to ERC-20 tokens on asset level -- [ ] Subscription to bitcoind notification over gRPC (instead of ZeroMQ) - [ ] Prometheus monitoring +- [ ] Subscription to bitcoind notification over gRPC (instead of ZeroMQ) - [ ] BIP-32 Pubkey - [ ] Lightweight sidecar node connector - [ ] Configurable upstream roles @@ -226,7 +225,10 @@ grpcurl -import-path ./proto/ -proto blockchain.proto -d '{\"asset\": {\"chain\" ... ---- -See other enhanced methods in the link:docs/06-methods.adoc[Documentation for Enhanced Methods] +The balance subscription works with main coin (_ether_, _bticoin_), or with tokens like ERC-20 if configured additionally. +See link:docs/reference-configuration.adoc[Configuration Reference]. + +See other enhanced methods in the link:docs/06-methods.adoc[Documentation for Enhanced Methods]. == Documentation diff --git a/docs/06-methods.adoc b/docs/06-methods.adoc index 61736092..77672e53 100644 --- a/docs/06-methods.adoc +++ b/docs/06-methods.adoc @@ -52,10 +52,10 @@ Where: - `chain` target chain (see reference for ids) - `items` as a list of independent requests, which may be executed in different nodes in parallels or in different order, with: - * `method` - a JSON RPC standard name, ex: `eth_getBlockByHash` - * `payload` - list of parameters for the methods, encoded as JSON string, ex. `["0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331", true]` +* `method` - a JSON RPC standard name, ex: `eth_getBlockByHash` +* `payload` - list of parameters for the methods, encoded as JSON string, ex. `["0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331", true]` - `Selector` and `AvailabilityEnum` are described in reference, in short they allow to specify which nodes must be selected - to execute the reques (i.e. "execute only on an archive node") +to execute the reques (i.e. "execute only on an archive node") .NativeCallReplyItem [source,proto] @@ -71,7 +71,7 @@ message NativeCallReplyItem { Where: -- `payload` is JSON response for a particular call, encoded into a string (when `succeed` is true) +- `payload` is JSON response for a particular call (`result` field), encoded into a string (`succeed` is true) - or `error` if request failed (`succeed` is false) NOTE: Reply Items comes right after their execution on an upstream, therefore streaming response. @@ -106,7 +106,8 @@ Where: === SubscribeBalance or GetBalance -Subscribes to changes (`SubscribeBalance`) or get current (`GetBalance`) balance for a single address or a set of addresses. +Subscribes to changes (`SubscribeBalance`) or get current (`GetBalance`) balance for a single address, or a set of addresses. +By default, it supports only main protocol coin (i.e. `bitcoin`, `ether`), but can be configured to support ERC-20 on Ethereum (see link:reference-configuration.adoc[Reference Configuration]) .Request [source,proto] diff --git a/docs/reference-configuration.adoc b/docs/reference-configuration.adoc index 01fa3af5..1147a8e5 100644 --- a/docs/reference-configuration.adoc +++ b/docs/reference-configuration.adoc @@ -46,6 +46,18 @@ proxy: - id: kovan blockchain: kovan +tokens: + - id: dai + blockchain: ethereum + name: DAI + type: ERC-20 + address: 0x6B175474E89094C44Da98b954EedeAC495271d0F + - id: tether + blockchain: ethereum + name: Tether + type: ERC-20 + address: 0xdac17f958d2ee523a2206206994597c13d831ec7 + cluster: defaults: - chains: @@ -130,6 +142,11 @@ cluster: | | Setup HTTP proxy that emulates all standard JSON RPC requests. See <> section +| `tokens` +| +| Configure tokens for tracking balance. See <> section + + | `cache` | | Caching configuration. See <> section. @@ -242,6 +259,49 @@ a| Routing paths for Proxy. The proxy will handle requests as `https://${HOST}:$ |=== +[#tokens] +== Tokens config + +[source,yaml] +---- +tokens: + - id: dai + blockchain: ethereum + name: DAI + type: ERC-20 + address: 0x6B175474E89094C44Da98b954EedeAC495271d0F + - id: tether + blockchain: ethereum + name: Tether + type: ERC-20 + address: 0xdac17f958d2ee523a2206206994597c13d831ec7 +---- + +Tokens config enables tracking of a balance amount in the configured tokens. +After making the configuration above you can request balance (`GetBalance`), or subscribe to balance changes (`SubscribeBalance`), using link:06-methods.adoc[enhanced protocol] + +.Token config +[cols="2a,7"] +|=== +| Option | Description + +| `id` +| Internal id for reference (used in logging, etc) + +| `blockchain` +| An ethereum-based blockchain where the contract is deployed + +| `name` +| Name of the token, used for balance response as asset code (as converted to UPPERCASE) + +| `type` +| Type of token. Only `ERC-20` is supported at this moment + +| `address` +| Address of the deployed contract + +|=== + [#cache] == Cache config