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
- [ ] 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

View File

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

View File

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

View File

@@ -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 <<proxy>> section
| `tokens`
|
| Configure tokens for tracking balance. See <<tokens>> section
| `cache`
|
| 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 config

View File

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

View File

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

View File

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

View File

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

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> {
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<BlockchainOuterClass.AddressBalance>(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<BlockchainOuterClass.AddressBalance>(IllegalStateException("Internal Error"))
@@ -77,9 +81,13 @@ class BlockchainRpc(
override fun getBalance(requestMono: Mono<BlockchainOuterClass.BalanceRequest>): Flux<BlockchainOuterClass.AddressBalance> {
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<BlockchainOuterClass.AddressBalance>(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<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 {
fun isSupported(chain: Chain): Boolean
fun isSupported(chain: Chain, asset: String): Boolean
fun getBalance(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)
}
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<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 {
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<BlockchainOuterClass.AddressBalance> {
@@ -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)
}
}

View File

@@ -48,5 +48,7 @@ class JsonRpcRequest(
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
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