@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
@@ -22,5 +22,6 @@ class MainConfig {
|
||||
var cache: CacheConfig? = null
|
||||
var proxy: ProxyConfig? = null
|
||||
var upstreams: UpstreamsConfig? = null
|
||||
var tokens: TokensConfig? = null
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>? {
|
||||
|
||||
132
src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackERC20Address.kt
Normal file
132
src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackERC20Address.kt
Normal 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)
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,5 +48,7 @@ class JsonRpcRequest(
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
override fun toString(): String {
|
||||
return String(this.toJson())
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
11
src/test/resources/tokens/basic.yaml
Normal file
11
src/test/resources/tokens/basic.yaml
Normal 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
|
||||
Reference in New Issue
Block a user