Merge pull request #28 from emeraldpay/feat/erc20

ERC20
This commit is contained in:
Igor Artamonov
2020-07-06 19:43:28 -04:00
committed by GitHub
22 changed files with 745 additions and 34 deletions

View File

@@ -43,9 +43,8 @@ image::dshackle-intro.png[alt="",width=80%,align="center"]
== Roadmap == Roadmap
- [ ] External logging - [ ] External logging
- [ ] Access to ERC-20 tokens on asset level
- [ ] Subscription to bitcoind notification over gRPC (instead of ZeroMQ)
- [ ] Prometheus monitoring - [ ] Prometheus monitoring
- [ ] Subscription to bitcoind notification over gRPC (instead of ZeroMQ)
- [ ] BIP-32 Pubkey - [ ] BIP-32 Pubkey
- [ ] Lightweight sidecar node connector - [ ] Lightweight sidecar node connector
- [ ] Configurable upstream roles - [ ] 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 == Documentation

View File

@@ -90,6 +90,8 @@ dependencies {
implementation "io.infinitape:etherjar-rpc-http:$etherjarVersion" implementation "io.infinitape:etherjar-rpc-http:$etherjarVersion"
implementation "io.infinitape:etherjar-rpc-ws:$etherjarVersion" implementation "io.infinitape:etherjar-rpc-ws:$etherjarVersion"
implementation "io.infinitape:etherjar-tx:$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.bitcoinj:bitcoinj-core:0.15.8'
implementation 'org.yaml:snakeyaml:1.24' implementation 'org.yaml:snakeyaml:1.24'

View File

@@ -52,10 +52,10 @@ Where:
- `chain` target chain (see reference for ids) - `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: - `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` * `method` - a JSON RPC standard name, ex: `eth_getBlockByHash`
* `payload` - list of parameters for the methods, encoded as JSON string, ex. `["0xe670ec64341771606e55d6b4ca35a1a6b75ee3d5145a99d05921026d1527331", true]` * `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 - `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 .NativeCallReplyItem
[source,proto] [source,proto]
@@ -71,7 +71,7 @@ message NativeCallReplyItem {
Where: 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) - or `error` if request failed (`succeed` is false)
NOTE: Reply Items comes right after their execution on an upstream, therefore streaming response. NOTE: Reply Items comes right after their execution on an upstream, therefore streaming response.
@@ -106,7 +106,8 @@ Where:
=== SubscribeBalance or GetBalance === 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 .Request
[source,proto] [source,proto]

View File

@@ -46,6 +46,18 @@ proxy:
- id: kovan - id: kovan
blockchain: 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: cluster:
defaults: defaults:
- chains: - chains:
@@ -130,6 +142,11 @@ cluster:
| |
| Setup HTTP proxy that emulates all standard JSON RPC requests. See <<proxy>> section | Setup HTTP proxy that emulates all standard JSON RPC requests. See <<proxy>> section
| `tokens`
|
| Configure tokens for tracking balance. See <<tokens>> section
| `cache` | `cache`
| |
| Caching configuration. See <<cache>> section. | Caching configuration. See <<cache>> 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]
== Cache config == Cache config

View File

@@ -13,7 +13,7 @@ springSecurtyVersion=5.3.2.RELEASE
reactorVersion=3.3.5.RELEASE reactorVersion=3.3.5.RELEASE
nettyVersion=4.1.49.Final nettyVersion=4.1.49.Final
# Our Libs # Our Libs
etherjarVersion=0.9.1 etherjarVersion=0.10.0
# Testing # Testing
spockVersion=1.3-groovy-2.5 spockVersion=1.3-groovy-2.5

View File

@@ -20,10 +20,7 @@ import com.fasterxml.jackson.core.Version
import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.module.SimpleModule
import io.emeraldpay.dshackle.config.CacheConfig import io.emeraldpay.dshackle.config.*
import io.emeraldpay.dshackle.config.MainConfig
import io.emeraldpay.dshackle.config.MainConfigReader
import io.emeraldpay.dshackle.config.UpstreamsConfig
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier import org.springframework.beans.factory.annotation.Qualifier
@@ -103,4 +100,9 @@ open class Config(
return mainConfig.cache ?: CacheConfig() return mainConfig.cache ?: CacheConfig()
} }
@Bean
open fun tokensConfig(@Autowired mainConfig: MainConfig): TokensConfig {
return mainConfig.tokens ?: TokensConfig(emptyList())
}
} }

View File

@@ -22,5 +22,6 @@ class MainConfig {
var cache: CacheConfig? = null var cache: CacheConfig? = null
var proxy: ProxyConfig? = null var proxy: ProxyConfig? = null
var upstreams: UpstreamsConfig? = null var upstreams: UpstreamsConfig? = null
var tokens: TokensConfig? = null
} }

View File

@@ -32,6 +32,7 @@ class MainConfigReader(
private val proxyConfigReader = ProxyConfigReader() private val proxyConfigReader = ProxyConfigReader()
private val upstreamsConfigReader = UpstreamsConfigReader(fileResolver) private val upstreamsConfigReader = UpstreamsConfigReader(fileResolver)
private val cacheConfigReader = CacheConfigReader() private val cacheConfigReader = CacheConfigReader()
private val tokensConfigReader = TokensConfigReader()
fun read(input: InputStream): MainConfig? { fun read(input: InputStream): MainConfig? {
val configNode = readNode(input) val configNode = readNode(input)
@@ -59,6 +60,9 @@ class MainConfigReader(
cacheConfigReader.read(input)?.let { cacheConfigReader.read(input)?.let {
config.cache = it config.cache = it
} }
tokensConfigReader.read(input)?.let {
config.tokens = it
}
return config return config
} }

View File

@@ -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<Token>
) {
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
}
}

View File

@@ -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<TokensConfig> {
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<MappingNode>(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)
}
}
}

View File

@@ -64,9 +64,13 @@ class BlockchainRpc(
override fun subscribeBalance(requestMono: Mono<BlockchainOuterClass.BalanceRequest>): Flux<BlockchainOuterClass.AddressBalance> { override fun subscribeBalance(requestMono: Mono<BlockchainOuterClass.BalanceRequest>): Flux<BlockchainOuterClass.AddressBalance> {
return requestMono.flatMapMany { request -> return requestMono.flatMapMany { request ->
val chain = Chain.byId(request.asset.chainValue) val chain = Chain.byId(request.asset.chainValue)
val asset = request.asset.code.toLowerCase()
try { try {
trackAddress.find { it.isSupported(chain) }?.subscribe(request) trackAddress.find { it.isSupported(chain, asset) }?.subscribe(request)
?: Flux.error(SilentException.UnsupportedBlockchain(chain)) ?: Flux.error<BlockchainOuterClass.AddressBalance>(SilentException.UnsupportedBlockchain(chain))
.doOnSubscribe {
log.error("Balance for $chain:$asset is not supported")
}
} catch (t: Throwable) { } catch (t: Throwable) {
log.error("Internal error during Balance Subscription", t) log.error("Internal error during Balance Subscription", t)
Flux.error<BlockchainOuterClass.AddressBalance>(IllegalStateException("Internal Error")) Flux.error<BlockchainOuterClass.AddressBalance>(IllegalStateException("Internal Error"))
@@ -77,9 +81,13 @@ class BlockchainRpc(
override fun getBalance(requestMono: Mono<BlockchainOuterClass.BalanceRequest>): Flux<BlockchainOuterClass.AddressBalance> { override fun getBalance(requestMono: Mono<BlockchainOuterClass.BalanceRequest>): Flux<BlockchainOuterClass.AddressBalance> {
return requestMono.flatMapMany { request -> return requestMono.flatMapMany { request ->
val chain = Chain.byId(request.asset.chainValue) val chain = Chain.byId(request.asset.chainValue)
val asset = request.asset.code.toLowerCase()
try { try {
trackAddress.find { it.isSupported(chain) }?.getBalance(request) trackAddress.find { it.isSupported(chain, asset) }?.getBalance(request)
?: Flux.error(SilentException.UnsupportedBlockchain(chain)) ?: Flux.error<BlockchainOuterClass.AddressBalance>(SilentException.UnsupportedBlockchain(chain))
.doOnSubscribe {
log.error("Balance for $chain:$asset is not supported")
}
} catch (t: Throwable) { } catch (t: Throwable) {
log.error("Internal error during Balance Request", t) log.error("Internal error during Balance Request", t)
Flux.error<BlockchainOuterClass.AddressBalance>(IllegalStateException("Internal Error")) Flux.error<BlockchainOuterClass.AddressBalance>(IllegalStateException("Internal Error"))

View File

@@ -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<Address> {
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()
}
}
}
}

View File

@@ -25,7 +25,7 @@ import reactor.core.publisher.Mono
*/ */
interface TrackAddress { interface TrackAddress {
fun isSupported(chain: Chain): Boolean fun isSupported(chain: Chain, asset: String): Boolean
fun getBalance(request: BlockchainOuterClass.BalanceRequest): Flux<BlockchainOuterClass.AddressBalance> fun getBalance(request: BlockchainOuterClass.BalanceRequest): Flux<BlockchainOuterClass.AddressBalance>
fun subscribe(request: BlockchainOuterClass.BalanceRequest): Flux<BlockchainOuterClass.AddressBalance> fun subscribe(request: BlockchainOuterClass.BalanceRequest): Flux<BlockchainOuterClass.AddressBalance>

View File

@@ -41,8 +41,9 @@ class TrackBitcoinAddress(
private val log = LoggerFactory.getLogger(TrackBitcoinAddress::class.java) private val log = LoggerFactory.getLogger(TrackBitcoinAddress::class.java)
} }
override fun isSupported(chain: Chain): Boolean { override fun isSupported(chain: Chain, asset: String): Boolean {
return BlockchainType.fromBlockchain(chain) == BlockchainType.BITCOIN && multistreamHolder.isAvailable(chain) return (asset == "bitcoin" || asset == "btc" || asset == "satoshi")
&& BlockchainType.fromBlockchain(chain) == BlockchainType.BITCOIN && multistreamHolder.isAvailable(chain)
} }
fun allAddresses(request: BlockchainOuterClass.BalanceRequest): List<String>? { fun allAddresses(request: BlockchainOuterClass.BalanceRequest): List<String>? {

View File

@@ -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<TokenId, TokenDefinition> = 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<BlockchainOuterClass.AddressBalance> {
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<BlockchainOuterClass.AddressBalance> {
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<BigInteger> {
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)
}

View File

@@ -38,9 +38,11 @@ class TrackEthereumAddress(
) : TrackAddress { ) : TrackAddress {
private val log = LoggerFactory.getLogger(TrackEthereumAddress::class.java) private val log = LoggerFactory.getLogger(TrackEthereumAddress::class.java)
private val ethereumAddresses = EthereumAddresses()
override fun isSupported(chain: Chain): Boolean { override fun isSupported(chain: Chain, asset: String): Boolean {
return BlockchainType.fromBlockchain(chain) == BlockchainType.ETHEREUM && multistreamHolder.isAvailable(chain) return asset == "ether" &&
BlockchainType.fromBlockchain(chain) == BlockchainType.ETHEREUM && multistreamHolder.isAvailable(chain)
} }
override fun getBalance(request: BlockchainOuterClass.BalanceRequest): Flux<BlockchainOuterClass.AddressBalance> { override fun getBalance(request: BlockchainOuterClass.BalanceRequest): Flux<BlockchainOuterClass.AddressBalance> {
@@ -99,16 +101,8 @@ class TrackEthereumAddress(
if (request.asset.code?.toLowerCase() != "ether") { if (request.asset.code?.toLowerCase() != "ether") {
return Flux.error(SilentException("Unsupported asset ${request.asset.code}")) return Flux.error(SilentException("Unsupported asset ${request.asset.code}"))
} }
return when (request.address.addrTypeCase) { return ethereumAddresses.extract(request.address).map {
Common.AnyAddress.AddrTypeCase.ADDRESS_SINGLE -> TrackedAddress(chain, it)
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()
}
} }
} }

View File

@@ -48,5 +48,7 @@ class JsonRpcRequest(
return result return result
} }
override fun toString(): String {
return String(this.toJson())
}
} }

View File

@@ -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"
}
}
}

View File

@@ -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))
}
}

View File

@@ -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()
}
}

View File

@@ -32,6 +32,10 @@ class ReaderMock<K, D> implements Reader<K, D> {
@Override @Override
Mono<D> read(K key) { Mono<D> 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)
} }
} }

View File

@@ -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