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

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