From 96a6f22a5bd5ecb5ef3a5640a004627603268650 Mon Sep 17 00:00:00 2001 From: Igor Artamonov Date: Tue, 15 Sep 2020 22:55:58 -0400 Subject: [PATCH] solution: support xpub addresses for bitcoin --- README.adoc | 1 - .../dshackle/rpc/TrackBitcoinAddress.kt | 47 +-- .../upstream/bitcoin/AddressActiveCheck.kt | 35 +++ .../upstream/bitcoin/BitcoinMultistream.kt | 8 + .../upstream/bitcoin/EsploraClient.kt | 38 ++- .../upstream/bitcoin/XpubAddresses.kt | 103 +++++++ .../rpc/TrackBitcoinAddressSpec.groovy | 53 +++- .../upstream/bitcoin/EsploraClientSpec.groovy | 51 ++++ .../upstream/bitcoin/XpubAddressesSpec.groovy | 175 +++++++++++ src/test/resources/bitcoin/esplora-txs-1.json | 287 ++++++++++++++++++ 10 files changed, 764 insertions(+), 34 deletions(-) create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/AddressActiveCheck.kt create mode 100644 src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/XpubAddresses.kt create mode 100644 src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/XpubAddressesSpec.groovy create mode 100644 src/test/resources/bitcoin/esplora-txs-1.json diff --git a/README.adoc b/README.adoc index 6846a1d2..b9662393 100644 --- a/README.adoc +++ b/README.adoc @@ -45,7 +45,6 @@ image::dshackle-intro.png[alt="",width=80%,align="center"] - [ ] External logging - [ ] Prometheus monitoring - [ ] Subscription to bitcoind notification over gRPC (instead of ZeroMQ) -- [ ] BIP-32 Pubkey - [ ] Lightweight sidecar node connector - [ ] Configurable upstream roles diff --git a/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackBitcoinAddress.kt b/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackBitcoinAddress.kt index 717cc081..4b914022 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackBitcoinAddress.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/rpc/TrackBitcoinAddress.kt @@ -47,25 +47,43 @@ class TrackBitcoinAddress( && BlockchainType.fromBlockchain(chain) == BlockchainType.BITCOIN && multistreamHolder.isAvailable(chain) } - fun allAddresses(request: BlockchainOuterClass.BalanceRequest): List? { + fun allAddresses(api: BitcoinMultistream, request: BlockchainOuterClass.BalanceRequest): Flux { if (!request.hasAddress()) { - return null + return Flux.empty() } return when { + request.address.hasAddressXpub() -> { + val xpubAddresses = api.getXpubAddresses() + ?: return Flux.error(IllegalStateException("Xpub verification is not available")) + + val addressXpub = request.address.addressXpub + if (!addressXpub.xpub.isValidUtf8) { + return Flux.error(IllegalArgumentException("Invalid xpub string")) + } + val xpub = addressXpub.xpub.toStringUtf8() + val start = Math.max(0, addressXpub.start).toInt() + val limit = Math.min(100, Math.max(1, addressXpub.limit)).toInt() + xpubAddresses.activeAddresses(xpub, start, limit) + .map { it.toString() } + .doOnError { t -> log.error("Failed to process xpub. ${t.javaClass}:${t.message}") } + } request.address.hasAddressSingle() -> { - listOf(request.address.addressSingle.address) + Flux.just(request.address.addressSingle.address) } request.address.hasAddressMulti() -> { - request.address.addressMulti.addressesList - .map { addr -> addr.address } - .sorted() + Flux.fromIterable( + request.address.addressMulti.addressesList + .map { addr -> addr.address } + //TODO why sorted? + .sorted() + ) } - else -> null + else -> Flux.error(IllegalArgumentException("Unsupported address type")) } } - fun requestBalances(chain: Chain, api: BitcoinMultistream, addresses: List, includeUtxo: Boolean): Flux { - return Flux.fromIterable(addresses) + fun requestBalances(chain: Chain, api: BitcoinMultistream, addresses: Flux, includeUtxo: Boolean): Flux { + return addresses .map { Address(chain, it) } .flatMap { address -> balanceForAddress(api, address, includeUtxo) @@ -103,10 +121,7 @@ class TrackBitcoinAddress( val chain = Chain.byId(request.asset.chainValue) val upstream = multistreamHolder.getUpstream(chain)?.cast(BitcoinMultistream::class.java) ?: return Flux.error(SilentException.UnsupportedBlockchain(request.asset.chainValue)) - val addresses = allAddresses(request) ?: return Flux.error(SilentException("Unsupported address")) - if (addresses.isEmpty()) { - return Flux.empty() - } + val addresses = allAddresses(upstream, request) ?: return Flux.error(SilentException("Unsupported address")) return requestBalances(chain, upstream, addresses, request.includeUtxo) .map(this@TrackBitcoinAddress::buildResponse) } @@ -116,10 +131,7 @@ class TrackBitcoinAddress( val chain = Chain.byId(request.asset.chainValue) val upstream = multistreamHolder.getUpstream(chain)?.cast(BitcoinMultistream::class.java) ?: return Flux.error(SilentException.UnsupportedBlockchain(request.asset.chainValue)) - val addresses = allAddresses(request) ?: return Flux.error(SilentException("Unsupported address")) - if (addresses.isEmpty()) { - return Flux.empty() - } + val addresses = allAddresses(upstream, request) ?: return Flux.error(SilentException("Unsupported address")) val initial = requestBalances(chain, upstream, addresses, request.includeUtxo) val following = upstream.getHead().getFlux() .flatMap { block -> @@ -164,6 +176,7 @@ class TrackBitcoinAddress( open class BalanceUtxo(val txid: String, val vout: Int, val value: Long) + //TODO use bitcoin class for address class Address(val chain: Chain, val address: String) { val network = if (chain == Chain.BITCOIN) { MainNetParams() diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/AddressActiveCheck.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/AddressActiveCheck.kt new file mode 100644 index 00000000..66426ca8 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/AddressActiveCheck.kt @@ -0,0 +1,35 @@ +/** + * 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.upstream.bitcoin + +import org.bitcoinj.core.Address +import org.slf4j.LoggerFactory +import reactor.core.publisher.Mono + +open class AddressActiveCheck( + private val esploraClient: EsploraClient +) { + + companion object { + private val log = LoggerFactory.getLogger(AddressActiveCheck::class.java) + } + + open fun isActive(address: Address): Mono { + //TODO cache with bloom filter + return esploraClient.getTransactions(address).map { it.isNotEmpty() } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/BitcoinMultistream.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/BitcoinMultistream.kt index b5e1eace..f845729f 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/BitcoinMultistream.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/BitcoinMultistream.kt @@ -40,6 +40,8 @@ open class BitcoinMultistream( private var head: Head = EmptyHead() private var esplora = upstreams.find { it.esploraClient != null }?.esploraClient private var reader = BitcoinReader(this, head, esplora) + private var addressActiveCheck: AddressActiveCheck? = null + private var xpubAddresses: XpubAddresses? = null override fun init() { if (upstreams.size > 0) { @@ -48,6 +50,10 @@ open class BitcoinMultistream( super.init() } + open fun getXpubAddresses(): XpubAddresses? { + return xpubAddresses + } + override fun updateHead(): Head { head.let { if (it is Lifecycle) { @@ -85,6 +91,8 @@ open class BitcoinMultistream( super.onUpstreamsUpdated() esplora = upstreams.find { it.esploraClient != null }?.esploraClient reader = BitcoinReader(this, this.head, esplora) + addressActiveCheck = esplora?.let { AddressActiveCheck(it) } + xpubAddresses = addressActiveCheck?.let { XpubAddresses(it) } } override fun setHead(head: Head) { diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClient.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClient.kt index 74ab7bfb..3ecf533f 100644 --- a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClient.kt +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClient.kt @@ -80,17 +80,7 @@ class EsploraClient( val response = httpClient .get() .uri("$url/address/$address/utxo") - - val json = response - .response { header, bytes -> - if (header.status().code() != 200) { - Mono.error(EsploraException("HTTP Code: ${header.status().code()}")) - } else { - bytes.aggregate().asByteArray() - } - } - .single() - .map { bytes -> String(bytes) } + val json = parseResponse(response) return json.map { val parsed = Global.objectMapper.readerFor(EsploraUnspent::class.java) @@ -99,6 +89,32 @@ class EsploraClient( } } + fun getTransactions(address: Address): Mono>> { + val response = httpClient + .get() + .uri("$url/address/$address/txs") + val json = parseResponse(response) + + return json.map { + val parsed = Global.objectMapper.readerFor(Map::class.java) + .readValues>(it); + parsed.readAll() + } + } + + private fun parseResponse(response: HttpClient.ResponseReceiver<*>): Mono { + return response + .response { header, bytes -> + if (header.status().code() != 200) { + Mono.error(EsploraException("HTTP Code: ${header.status().code()} for ${header.fullPath()}")) + } else { + bytes.aggregate().asByteArray() + } + } + .single() + .map { bytes -> String(bytes) } + } + class EsploraException(msg: String) : Exception(msg) } \ No newline at end of file diff --git a/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/XpubAddresses.kt b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/XpubAddresses.kt new file mode 100644 index 00000000..e91012c6 --- /dev/null +++ b/src/main/kotlin/io/emeraldpay/dshackle/upstream/bitcoin/XpubAddresses.kt @@ -0,0 +1,103 @@ +/** + * 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.upstream.bitcoin + +import org.bitcoinj.core.Address +import org.bitcoinj.core.ECKey +import org.bitcoinj.core.NetworkParameters +import org.bitcoinj.crypto.ChildNumber +import org.bitcoinj.crypto.DeterministicKey +import org.bitcoinj.crypto.HDKeyDerivation +import org.bitcoinj.params.MainNetParams +import org.bitcoinj.params.TestNet3Params +import org.bitcoinj.script.Script +import org.slf4j.LoggerFactory +import reactor.core.publisher.Flux +import reactor.util.function.Tuples +import java.util.concurrent.atomic.AtomicInteger + +open class XpubAddresses( + private val addressActiveCheck: AddressActiveCheck +) { + + companion object { + private val log = LoggerFactory.getLogger(XpubAddresses::class.java) + private val MAINNET = MainNetParams() + private val TESTNET = TestNet3Params() + private val INACTIVE_LIMIT = 20 + } + + open fun allAddresses(xpub: String, start: Int, limit: Int): Flux
{ + // versions: + // https://electrum.readthedocs.io/en/latest/xpub_version_bytes.html + // TODO doesn't support SH keys right now. should? + val prefix = xpub.substring(0, 4) + val type: Script.ScriptType + val network: NetworkParameters + + when (prefix) { + "xpub" -> { + type = Script.ScriptType.P2PKH + network = MAINNET + } + "zpub" -> { + type = Script.ScriptType.P2WPKH + network = MAINNET + } + "tpub" -> { + type = Script.ScriptType.P2PKH + network = TESTNET + } + "vpub" -> { + type = Script.ScriptType.P2WPKH + network = TESTNET + } + else -> return Flux.error(IllegalArgumentException("Unsupported type: $prefix")) + } + + val key: DeterministicKey + try { + key = DeterministicKey.deserializeB58(xpub, network) + } catch (t: Throwable) { + return Flux.error(t) + } + + return Flux.range(start, limit) + .map { HDKeyDerivation.deriveChildKey(key, ChildNumber(it, false)) } + .map { Address.fromKey(network, ECKey.fromPublicOnly(it.pubKeyPoint), type) } + } + + open fun activeAddresses(xpub: String, start: Int, limit: Int): Flux
{ + val lastActive = AtomicInteger(0) + return this.allAddresses(xpub, start, limit) + .zipWith(Flux.range(0, limit)) + .takeUntil { + it.t2 - lastActive.get() >= INACTIVE_LIMIT + } + .concatMap { toCheck -> + addressActiveCheck.isActive(toCheck.t1) + .doOnNext { active -> if (active) lastActive.set(toCheck.t2) } + .map { Tuples.of(toCheck.t1, it) } + } + .filter { + it.t2 + } + .map { + it.t1 + } + } + +} \ No newline at end of file diff --git a/src/test/groovy/io/emeraldpay/dshackle/rpc/TrackBitcoinAddressSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/rpc/TrackBitcoinAddressSpec.groovy index 8fe856a9..34062d3e 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/rpc/TrackBitcoinAddressSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/rpc/TrackBitcoinAddressSpec.groovy @@ -16,6 +16,7 @@ package io.emeraldpay.dshackle.rpc import com.fasterxml.jackson.databind.ObjectMapper +import com.google.protobuf.ByteString import io.emeraldpay.api.proto.BlockchainOuterClass import io.emeraldpay.api.proto.Common import io.emeraldpay.dshackle.Global @@ -27,8 +28,12 @@ import io.emeraldpay.dshackle.upstream.Head import io.emeraldpay.dshackle.upstream.MultistreamHolder import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinMultistream import io.emeraldpay.dshackle.upstream.bitcoin.BitcoinReader +import io.emeraldpay.dshackle.upstream.bitcoin.XpubAddresses import io.emeraldpay.dshackle.upstream.bitcoin.data.SimpleUnspent import io.emeraldpay.grpc.Chain +import org.bitcoinj.core.Address +import org.bitcoinj.params.MainNetParams +import org.bitcoinj.params.TestNet3Params import reactor.core.publisher.Flux import reactor.core.publisher.Mono import reactor.core.publisher.TopicProcessor @@ -111,7 +116,7 @@ class TrackBitcoinAddressSpec extends Specification { ) .build() when: - def act = track.allAddresses(req) + def act = track.allAddresses(Stub(BitcoinMultistream), req).collectList().block() then: act == ["16rCmCmbuWDhPjWTrpQGaU3EPdZF7MTdUk"] } @@ -132,20 +137,58 @@ class TrackBitcoinAddressSpec extends Specification { ) .build() when: - def act = track.allAddresses(req) + def act = track.allAddresses(Stub(BitcoinMultistream), req).collectList().block() then: act == ["16rCmCmbuWDhPjWTrpQGaU3EPdZF7MTdUk", "1K7xkspJg7DDKNwzXgoRSDCUxiFsRegsSK", "3BMqADKWoWHPASsUdHvnUL6E1jpZkMnLZz", "bc1qdthqvt6cllzej7uhdddrltdfsmnt7d0gl5ue5n"] } - def "Null for no address provided"() { + def "Empty for no address provided"() { setup: TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder)) def req = BlockchainOuterClass.BalanceRequest.newBuilder() .build() when: - def act = track.allAddresses(req) + def act = track.allAddresses(Stub(BitcoinMultistream), req).collectList().block() then: - act == null + act == [] + } + + def "Use active for xpub"() { + setup: + TrackBitcoinAddress track = new TrackBitcoinAddress(Stub(MultistreamHolder)) + def req = BlockchainOuterClass.BalanceRequest.newBuilder() + .setAddress( + Common.AnyAddress.newBuilder() + .setAddressXpub( + // seed: chimney battle code relief era plug finish video patch dream pumpkin govern destroy fresh color + Common.XpubAddress.newBuilder() + .setXpub(ByteString.copyFromUtf8("zpub6tz4F49K5B4m7r7EyBKYM9K44eGECaQ2AfrCybq1w7ALFatz9856vrXxAPSrteDA4d5sjUPW3ACNq8wB2V3ugXVJxvAPAYPAYHsVm3VAncL")) + .setLimit(25) + ) + ) + .build() + def xpubAddresses = Mock(XpubAddresses) { + 1 * activeAddresses( + "zpub6tz4F49K5B4m7r7EyBKYM9K44eGECaQ2AfrCybq1w7ALFatz9856vrXxAPSrteDA4d5sjUPW3ACNq8wB2V3ugXVJxvAPAYPAYHsVm3VAncL", + 0, + 25 + ) >> Flux.fromIterable([ + "bc1q25590fu8djhw9lvxxqz8ufjyfwup9h54u8fl6t", + "bc1q3k6e6vawd5l5syu9nlxn2xsch9afgunl8dnz94", + "bc1qu7hd6wycy686kakfps9c093szufjpwnh6rjs9s" + ]).map { Address.fromString(MainNetParams.get(), it) } + } + def multistream = Mock(BitcoinMultistream) { + 1 * getXpubAddresses() >> xpubAddresses + } + when: + def act = track.allAddresses(multistream, req).collectList().block() + then: + act == [ + "bc1q25590fu8djhw9lvxxqz8ufjyfwup9h54u8fl6t", + "bc1q3k6e6vawd5l5syu9nlxn2xsch9afgunl8dnz94", + "bc1qu7hd6wycy686kakfps9c093szufjpwnh6rjs9s" + ] } def "Build proto for common balance"() { diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClientSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClientSpec.groovy index 5b046841..59f4240c 100644 --- a/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClientSpec.groovy +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/EsploraClientSpec.groovy @@ -18,6 +18,7 @@ package io.emeraldpay.dshackle.upstream.bitcoin import io.emeraldpay.dshackle.upstream.bitcoin.data.EsploraUnspent import org.bitcoinj.core.Address import org.bitcoinj.params.MainNetParams +import org.bitcoinj.params.TestNet3Params import org.mockserver.integration.ClientAndServer import org.mockserver.model.HttpRequest import org.mockserver.model.HttpResponse @@ -80,4 +81,54 @@ class EsploraClientSpec extends Specification { actTotal == 309841L } + def "get txs"() { + setup: + def responseJson = this.class.getClassLoader().getResourceAsStream("bitcoin/esplora-txs-1.json").text + mockServer.when( + HttpRequest.request() + .withMethod("GET") + .withPath("/address/tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx/txs") + ).respond( + HttpResponse.response(responseJson) + ) + def client = new EsploraClient(new URI("http://localhost:23001"), null, null) + when: + def act = client.getTransactions(Address.fromString(TestNet3Params.get(), "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx")) + + then: + StepVerifier.create(act) + .expectNextMatches { list -> + def ok = list.size() == 6 + if (!ok) println("invalid size") + ok = ok && list[0].txid == "a738f4bd63f785d58acd2e83e8c8c5e84e68dabacac3a1142ee47cb188103aab" + ok = ok && list[1].txid == "4a7066adb2cd8e37f1578d6991f14c12e677e8bc0556992a73a91a26d263f89a" + ok = ok && list[2].txid == "16aa2d98e37e50c4c007a815a3cb8c20026a3df467781a7e97206a730cf4ef01" + ok = ok && list[3].txid == "caaea0ca92343a2d7115c44a0f58cb6574b9349905799854e1b5a6c3b3f33587" + ok = ok && list[4].txid == "a386377a406465423275f51d6dc71a2c245acc55c356a7e851a03b508827bc1e" + ok = ok && list[5].txid == "7944cfcd3d04c58a81aaa7067616f8ac368581a847e03447d5d2917cc75b67d2" + ok + } + .expectComplete() + .verify(Duration.ofSeconds(3)) + } + + def "get txs when empty"() { + setup: + mockServer.when( + HttpRequest.request() + .withMethod("GET") + .withPath("/address/tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx/txs") + ).respond( + HttpResponse.response("[]") + ) + def client = new EsploraClient(new URI("http://localhost:23001"), null, null) + when: + def act = client.getTransactions(Address.fromString(TestNet3Params.get(), "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx")) + + then: + StepVerifier.create(act) + .expectNext([]) + .expectComplete() + .verify(Duration.ofSeconds(3)) + } } diff --git a/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/XpubAddressesSpec.groovy b/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/XpubAddressesSpec.groovy new file mode 100644 index 00000000..f9be8eda --- /dev/null +++ b/src/test/groovy/io/emeraldpay/dshackle/upstream/bitcoin/XpubAddressesSpec.groovy @@ -0,0 +1,175 @@ +/** + * 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.upstream.bitcoin + +import org.bitcoinj.core.Address +import org.bitcoinj.params.MainNetParams +import reactor.core.publisher.Mono +import reactor.test.StepVerifier +import spock.lang.Specification + +import java.time.Duration + +class XpubAddressesSpec extends Specification { + + + def "list all for mainnet"() { + setup: + XpubAddresses addresses = new XpubAddresses(Stub(AddressActiveCheck)) + + when: + // seed sustain pyramid victory drum primary silver safe wrestle section leg caught praise spring prepare input + // account 0 + def act = addresses.allAddresses( + "zpub6tsHyQGj1UmDcw9UavB31HSChH9rqMi8Qje5KPbnczzhFSZykmm3afivpVytxWubaabDih6AbGGhioiTCg5PssYXuVGiZ1vTVaMYvvQvvmz", + 0, + 4 + ).collectList().block() + + then: + act.size() == 4 + act[0].toString() == "bc1qd6p79j20w2zy5zagptf5ksjkdmhx4d5sykrz0e" + act[1].toString() == "bc1q54um5vzdjuwt6qvzvk2y5em72jedpktrvr24a3" + act[2].toString() == "bc1q3yl03ugy6l5k3te5x340m70hyeqjzmmvksnuc8" + act[3].toString() == "bc1qp0cgpkxl52r82ev9k2wpdzzk4m4nflwurj5yr8" + } + + def "list all for testnet"() { + setup: + XpubAddresses addresses = new XpubAddresses(Stub(AddressActiveCheck)) + + when: + // seed sustain pyramid victory drum primary silver safe wrestle section leg caught praise spring prepare input + // account 1 + def act = addresses.allAddresses( + "vpub5aEsY5bNGvjkHPjdMiz8FbdoiT81kmF3JwuJ1LRdoFb7DxNh1qAHQCmMeDnr6RG2EcCwzGohem3oESxZGa6YWLPW79ryCyMrdYj54uUzNNq", + 0, + 4 + ).collectList().block() + + then: + act.size() == 4 + act[0].toString() == "tb1q2phmcxl4tflgcrnm00jvrkh4a8n876vkjcv97m" + act[1].toString() == "tb1qnhn0psv0uqtmg64tm258kqu0thurjvutcc80xs" + act[2].toString() == "tb1qg62jwv77ul329pe4wcchr83zz02egwfh6syqsr" + act[3].toString() == "tb1q8yg5qknrl5wmc98lw8dyfgrzamzaq8c9l0g44d" + } + + def "list starting from some"() { + setup: + XpubAddresses addresses = new XpubAddresses(Stub(AddressActiveCheck)) + + when: + // seed sustain pyramid victory drum primary silver safe wrestle section leg caught praise spring prepare input + // account 2 + def act = addresses.allAddresses( + "zpub6tyKtxsQL16XpzxwFJSrVTxpKF4z8GL2WTqvdErC2iQBSEAzLqJNc8h6hg4EHMSPEavFDneR3JQjWjbZL7vtQrXMwN9bD4c9K9s1Z55aZfB", + 10, + 4 + ).collectList().block() + + then: + act.size() == 4 + act[0].toString() == "bc1q3dzm92mnqvkyadwl3x5cqdvxaqa5h9c37qdlgs" + act[1].toString() == "bc1q3kqug4cx95a02yhwn6geelftmw3zklrgmhjll8" + act[2].toString() == "bc1q6znmcw8zzjv3asdylgkmt0k0xvhzl9q5awh0sq" + act[3].toString() == "bc1q3fzlxvw803r8j44culrt7jz7q5y7zakushujmq" + } + + def "list when only fist is active"() { + setup: + AddressActiveCheck check = Mock(AddressActiveCheck) { + 1 * isActive(Address.fromString(MainNetParams.get(), "bc1qaexx257l7sgm62szw2ulj6n2v99t5ph9ekkul3")) >> Mono.just(true) + 20 * isActive(_) >> Mono.just(false) + } + XpubAddresses addresses = new XpubAddresses(check) + + // seed sustain pyramid victory drum primary silver safe wrestle section leg caught praise spring prepare input + // account 2 + when: + def act = addresses.activeAddresses( + "zpub6tyKtxsQL16XpzxwFJSrVTxpKF4z8GL2WTqvdErC2iQBSEAzLqJNc8h6hg4EHMSPEavFDneR3JQjWjbZL7vtQrXMwN9bD4c9K9s1Z55aZfB", + 0, + 50 + ).map { it.toString() } + + then: + StepVerifier.create(act) + .expectNext("bc1qaexx257l7sgm62szw2ulj6n2v99t5ph9ekkul3") + .expectComplete() + .verify(Duration.ofSeconds(1)) + } + + def "list when only 3rd is active"() { + setup: + AddressActiveCheck check = Mock(AddressActiveCheck) { + 1 * isActive(Address.fromString(MainNetParams.get(), "bc1qah84zz7aavf3f5eyx5f29809y6xugqyphq0wtz")) >> Mono.just(true) + // 2 times before, 20 times after + 22 * isActive(_) >> Mono.just(false) + } + XpubAddresses addresses = new XpubAddresses(check) + + // seed sustain pyramid victory drum primary silver safe wrestle section leg caught praise spring prepare input + // account 2 + when: + def act = addresses.activeAddresses( + "zpub6tyKtxsQL16XpzxwFJSrVTxpKF4z8GL2WTqvdErC2iQBSEAzLqJNc8h6hg4EHMSPEavFDneR3JQjWjbZL7vtQrXMwN9bD4c9K9s1Z55aZfB", + 0, + 50 + ).map { it.toString() } + + then: + StepVerifier.create(act) + .expectNext("bc1qah84zz7aavf3f5eyx5f29809y6xugqyphq0wtz") + .expectComplete() + .verify(Duration.ofSeconds(1)) + } + + def "list when only 22 is active"() { + setup: + AddressActiveCheck check = Mock(AddressActiveCheck) { + // 2 + 1 * isActive(Address.fromString(MainNetParams.get(), "bc1qah84zz7aavf3f5eyx5f29809y6xugqyphq0wtz")) >> Mono.just(true) + // 11 + 1 * isActive(Address.fromString(MainNetParams.get(), "bc1q3kqug4cx95a02yhwn6geelftmw3zklrgmhjll8")) >> Mono.just(true) + // 22 + 1 * isActive(Address.fromString(MainNetParams.get(), "bc1qf7m2rrmrksj34vhgxmm04y43dlj7c5f58f8ku6")) >> Mono.just(true) + // 0..1 = 2 + // + 3..10 = 8 + // + 12..21 = 10 + // + 20 + 40 * isActive(_) >> Mono.just(false) + } + XpubAddresses addresses = new XpubAddresses(check) + + // seed sustain pyramid victory drum primary silver safe wrestle section leg caught praise spring prepare input + // account 2 + when: + def act = addresses.activeAddresses( + "zpub6tyKtxsQL16XpzxwFJSrVTxpKF4z8GL2WTqvdErC2iQBSEAzLqJNc8h6hg4EHMSPEavFDneR3JQjWjbZL7vtQrXMwN9bD4c9K9s1Z55aZfB", + 0, + 100 + ).map { it.toString() } + + then: + StepVerifier.create(act) + .expectNext("bc1qah84zz7aavf3f5eyx5f29809y6xugqyphq0wtz") + .expectNext("bc1q3kqug4cx95a02yhwn6geelftmw3zklrgmhjll8") + .expectNext("bc1qf7m2rrmrksj34vhgxmm04y43dlj7c5f58f8ku6") + .expectComplete() + .verify(Duration.ofSeconds(1)) + } +} diff --git a/src/test/resources/bitcoin/esplora-txs-1.json b/src/test/resources/bitcoin/esplora-txs-1.json new file mode 100644 index 00000000..252a96a2 --- /dev/null +++ b/src/test/resources/bitcoin/esplora-txs-1.json @@ -0,0 +1,287 @@ +[ + { + "txid": "a738f4bd63f785d58acd2e83e8c8c5e84e68dabacac3a1142ee47cb188103aab", + "version": 1, + "locktime": 0, + "vin": [ + { + "txid": "16aa2d98e37e50c4c007a815a3cb8c20026a3df467781a7e97206a730cf4ef01", + "vout": 1, + "prevout": { + "scriptpubkey": "00142757c732c931d7722a6bdaf99ee9955303116520", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 2757c732c931d7722a6bdaf99ee9955303116520", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx", + "value": 120000 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "304502210084f12093ae826635c6bce4915e927dd7a2285b37d0b51148c6d6177ce72e459d02201bf0b3d7cde090d2dbc4766b7a8b3d947e1bdb02dcf03ce894ff1a105278eb8901", + "03a3aa29d96671671b065b35de511c03ea5592eafb5de7a07542633af2d42f49ea" + ], + "is_coinbase": false, + "sequence": 4294967293 + } + ], + "vout": [ + { + "scriptpubkey": "00142757c732c931d7722a6bdaf99ee9955303116520", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 2757c732c931d7722a6bdaf99ee9955303116520", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx", + "value": 119568 + } + ], + "size": 192, + "weight": 438, + "fee": 432, + "status": { + "confirmed": true, + "block_height": 1831865, + "block_hash": "000000000000014e643687acc0a975f9aa7319f1e9bb428e0f1d618ed6aba370", + "block_time": 1599447368 + } + }, + { + "txid": "4a7066adb2cd8e37f1578d6991f14c12e677e8bc0556992a73a91a26d263f89a", + "version": 1, + "locktime": 0, + "vin": [ + { + "txid": "a386377a406465423275f51d6dc71a2c245acc55c356a7e851a03b508827bc1e", + "vout": 1, + "prevout": { + "scriptpubkey": "00142757c732c931d7722a6bdaf99ee9955303116520", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 2757c732c931d7722a6bdaf99ee9955303116520", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx", + "value": 120000 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "3045022100c5af6d33efc9a564c0e955160e0b8def089cc6c933ddf08967eac25d4ddfc09a02205ed59dcc068251dee669ca19425c0c83208d1b1c1f5b7147f4f0b7cb2d15a87201", + "03a3aa29d96671671b065b35de511c03ea5592eafb5de7a07542633af2d42f49ea" + ], + "is_coinbase": false, + "sequence": 4294967295 + } + ], + "vout": [ + { + "scriptpubkey": "00142757c732c931d7722a6bdaf99ee9955303116520", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 2757c732c931d7722a6bdaf99ee9955303116520", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx", + "value": 119568 + } + ], + "size": 192, + "weight": 438, + "fee": 432, + "status": { + "confirmed": true, + "block_height": 1831846, + "block_hash": "0000000000000104a5b1241385c2cfb728128ce692ab8bd8970a0372761f8887", + "block_time": 1599439188 + } + }, + { + "txid": "16aa2d98e37e50c4c007a815a3cb8c20026a3df467781a7e97206a730cf4ef01", + "version": 2, + "locktime": 1831845, + "vin": [ + { + "txid": "a386377a406465423275f51d6dc71a2c245acc55c356a7e851a03b508827bc1e", + "vout": 0, + "prevout": { + "scriptpubkey": "00140fc773ca476039acb48162e26190b3b93cda6762", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 0fc773ca476039acb48162e26190b3b93cda6762", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "tb1qplrh8jj8vqu6edypvt3xry9nhy7d5emzyj3a3h", + "value": 1619316 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "3044022067011a2a7b6823146ff8291eed673e2e52703e07b8b99cf78b0fbb968e5ca980022018244b0584f4851ec9d09296007898c00f05afdcc6c2d8eafb48e95923be609801", + "031117740bfada16dbe932219d5d5ddbb0a829d83ccbfe5eb7734d8646f9a63479" + ], + "is_coinbase": false, + "sequence": 4294967293 + } + ], + "vout": [ + { + "scriptpubkey": "0014a871aa972c4ef9255a610b3b849598991ba9df1c", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 a871aa972c4ef9255a610b3b849598991ba9df1c", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "tb1q4pc649evfmuj2knppvacf9vcnyd6nhculm5lsk", + "value": 1499175 + }, + { + "scriptpubkey": "00142757c732c931d7722a6bdaf99ee9955303116520", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 2757c732c931d7722a6bdaf99ee9955303116520", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx", + "value": 120000 + } + ], + "size": 222, + "weight": 561, + "fee": 141, + "status": { + "confirmed": true, + "block_height": 1831846, + "block_hash": "0000000000000104a5b1241385c2cfb728128ce692ab8bd8970a0372761f8887", + "block_time": 1599439188 + } + }, + { + "txid": "caaea0ca92343a2d7115c44a0f58cb6574b9349905799854e1b5a6c3b3f33587", + "version": 1, + "locktime": 0, + "vin": [ + { + "txid": "7944cfcd3d04c58a81aaa7067616f8ac368581a847e03447d5d2917cc75b67d2", + "vout": 1, + "prevout": { + "scriptpubkey": "00142757c732c931d7722a6bdaf99ee9955303116520", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 2757c732c931d7722a6bdaf99ee9955303116520", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx", + "value": 120000 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "304402200fe43b74f1326e51d5c718b5c0a9319c0241de9329e7d39e613218f66bf72f1202205918d5b29b7d6887a4f61fe3f7c330beb306e195b49e7149f5de1554f8aaa10501", + "03a3aa29d96671671b065b35de511c03ea5592eafb5de7a07542633af2d42f49ea" + ], + "is_coinbase": false, + "sequence": 4294967294 + } + ], + "vout": [ + { + "scriptpubkey": "00146d2112c30c7ef1bf66a4ff2e1bfa0304c3b8f118", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 6d2112c30c7ef1bf66a4ff2e1bfa0304c3b8f118", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "tb1qd5s39scv0mcm7e4yluhph7srqnpm3ugcx2mc5d", + "value": 119568 + } + ], + "size": 191, + "weight": 437, + "fee": 432, + "status": { + "confirmed": true, + "block_height": 1831820, + "block_hash": "0000000009f11ea4cd3a3393d1cc355a63ea37a5a2cbf983e34cbb7f37590eb2", + "block_time": 1599426488 + } + }, + { + "txid": "a386377a406465423275f51d6dc71a2c245acc55c356a7e851a03b508827bc1e", + "version": 2, + "locktime": 1831819, + "vin": [ + { + "txid": "7944cfcd3d04c58a81aaa7067616f8ac368581a847e03447d5d2917cc75b67d2", + "vout": 0, + "prevout": { + "scriptpubkey": "0014da239eb3f9727ccbfe73823e427fb7289e7c192a", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 da239eb3f9727ccbfe73823e427fb7289e7c192a", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "tb1qmg3eavlewf7vhlnnsglyylah9z08cxf2a0g2nv", + "value": 1739457 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "3044022039020c3e01db872751e8c55f584aea2820b4e7c04c5a859ac785983aa2aaf612022012e8b0f6b052cecbcbe452dc0c8b6093f0520b355d206476c934ee879e892cfe01", + "023a49d61f3162fbba038809868a3c943de0549bae2f197d78ea8670452595cece" + ], + "is_coinbase": false, + "sequence": 4294967293 + } + ], + "vout": [ + { + "scriptpubkey": "00140fc773ca476039acb48162e26190b3b93cda6762", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 0fc773ca476039acb48162e26190b3b93cda6762", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "tb1qplrh8jj8vqu6edypvt3xry9nhy7d5emzyj3a3h", + "value": 1619316 + }, + { + "scriptpubkey": "00142757c732c931d7722a6bdaf99ee9955303116520", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 2757c732c931d7722a6bdaf99ee9955303116520", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx", + "value": 120000 + } + ], + "size": 222, + "weight": 561, + "fee": 141, + "status": { + "confirmed": true, + "block_height": 1831820, + "block_hash": "0000000009f11ea4cd3a3393d1cc355a63ea37a5a2cbf983e34cbb7f37590eb2", + "block_time": 1599426488 + } + }, + { + "txid": "7944cfcd3d04c58a81aaa7067616f8ac368581a847e03447d5d2917cc75b67d2", + "version": 2, + "locktime": 1831428, + "vin": [ + { + "txid": "86f3a772c109695ae67fe15e30df8929d2b71a2beef86ac51df406150d991a4b", + "vout": 0, + "prevout": { + "scriptpubkey": "00141d7aa33e35e539b16b11167627e818fd436ea907", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 1d7aa33e35e539b16b11167627e818fd436ea907", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "tb1qr4a2x034u5umz6c3zemz06qcl4pka2g8cc2qaf", + "value": 1859598 + }, + "scriptsig": "", + "scriptsig_asm": "", + "witness": [ + "3044022020fa758e75fa4f67665b14cc50f871883a80c1c1f4f45c403e7f8c5631b8734002200ddde3f72dbe88975365777c15da85c44eaa6ba53384194b351709f015f4bf2001", + "02f0db65b4318ee91f58d1f7da51fb16c46e3a01f6f3bd07af1b8be63e525e3e45" + ], + "is_coinbase": false, + "sequence": 4294967293 + } + ], + "vout": [ + { + "scriptpubkey": "0014da239eb3f9727ccbfe73823e427fb7289e7c192a", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 da239eb3f9727ccbfe73823e427fb7289e7c192a", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "tb1qmg3eavlewf7vhlnnsglyylah9z08cxf2a0g2nv", + "value": 1739457 + }, + { + "scriptpubkey": "00142757c732c931d7722a6bdaf99ee9955303116520", + "scriptpubkey_asm": "OP_0 OP_PUSHBYTES_20 2757c732c931d7722a6bdaf99ee9955303116520", + "scriptpubkey_type": "v0_p2wpkh", + "scriptpubkey_address": "tb1qyatuwvkfx8thy2ntmtuea6v42vp3zefqvll8kx", + "value": 120000 + } + ], + "size": 222, + "weight": 561, + "fee": 141, + "status": { + "confirmed": true, + "block_height": 1831429, + "block_hash": "00000000000000235978c8a27a20881376bb94b831d2072d80500d2350115be1", + "block_time": 1599254339 + } + } +] \ No newline at end of file