1
0
Fork 0
mirror of https://github.com/ACINQ/eclair.git synced 2025-03-13 11:35:47 +01:00

Remove Electrum support (#1750)

Electrum support was provided for mobile wallets, server nodes should always
run a bitcoind node as this provides more control (especially for utxo
management for anchor outputs channels).

Since wallets will use https://github.com/acinq/eclair-kmp instead of eclair,
we can now remove Electrum and API fee providers from eclair.

We also removed 3rd-party fee API providers that were only used on wallets.
This commit is contained in:
Bastien Teinturier 2021-04-02 09:18:54 +02:00 committed by GitHub
parent 5729b28912
commit 89d2489296
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 122 additions and 10106 deletions

View file

@ -1,108 +0,0 @@
{
"electrum.acinq.co": {
"pruning": "-",
"s": "50002",
"version": "1.4"
},
"helicarrier.bauerj.eu": {
"pruning": "-",
"s": "50002",
"version": "1.4"
},
"e.keff.org": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"e2.keff.org": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"e3.keff.org": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"e8.keff.org": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"electrum-server.ninja": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"electrum-unlimited.criptolayer.net": {
"pruning": "-",
"s": "50002",
"version": "1.4"
},
"electrum.qtornado.com": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"fortress.qtornado.com": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"enode.duckdns.org": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"bitcoin.dragon.zone": {
"pruning": "-",
"s": "50004",
"t": "50003",
"version": "1.4"
},
"ecdsa.net" : {
"pruning": "-",
"s": "110",
"t": "50001",
"version": "1.4"
},
"e2.keff.org": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"electrum.hodlister.co": {
"pruning": "-",
"s": "50002",
"version": "1.4"
},
"electrum3.hodlister.co": {
"pruning": "-",
"s": "50002",
"version": "1.4"
},
"electrum4.hodlister.co": {
"pruning": "-",
"s": "50002",
"version": "1.4"
},
"electrum5.hodlister.co": {
"pruning": "-",
"s": "50002",
"version": "1.4"
},
"electrum6.hodlister.co": {
"pruning": "-",
"s": "50002",
"version": "1.4"
}
}

View file

@ -1,10 +0,0 @@
{
"127.0.0.1": {
"t": "51001",
"s": "51002"
},
"10.0.2.2": {
"t": "51001",
"s": "51002"
}
}

View file

@ -1,38 +0,0 @@
{
"hsmithsxurybd7uh.onion": {
"pruning": "-",
"s": "53012",
"t": "53011",
"version": "1.4"
},
"testnet.hsmiths.com": {
"pruning": "-",
"s": "53012",
"t": "53011",
"version": "1.4"
},
"testnet.qtornado.com": {
"pruning": "-",
"s": "51002",
"t": "51001",
"version": "1.4"
},
"testnet1.bauerj.eu": {
"pruning": "-",
"s": "50002",
"t": "50001",
"version": "1.4"
},
"tn.not.fyi": {
"pruning": "-",
"s": "55002",
"t": "55001",
"version": "1.4"
},
"bitcoin.cluelessperson.com": {
"pruning": "-",
"s": "51002",
"t": "51001",
"version": "1.4"
}
}

View file

@ -20,7 +20,6 @@ eclair {
// override this with a script/exe that will be called everytime a new database backup has been created
# backup-notify-script = "/absolute/path/to/script.sh"
watcher-type = "bitcoind" // other *experimental* values include "electrum"
watch-spent-window = 1 minute // at startup watches will be put back within that window to reduce herd effect; must be > 0s
bitcoind {
@ -91,7 +90,6 @@ eclair {
on-chain-fees {
min-feerate = 1 // minimum feerate in satoshis per byte
smoothing-window = 6 // 1 = no smoothing
provider-timeout = 5 seconds // max time we'll wait for answers from a fee provider before we fallback to the next one
default-feerates { // those are per target block, in satoshis per kilobyte
1 = 210000

View file

@ -19,7 +19,6 @@ package fr.acinq.eclair
import com.typesafe.config.{Config, ConfigFactory, ConfigValueType}
import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.bitcoin.{Block, ByteVector32, Crypto, Satoshi}
import fr.acinq.eclair.NodeParams.WatcherType
import fr.acinq.eclair.Setup.Seeds
import fr.acinq.eclair.blockchain.fee._
import fr.acinq.eclair.channel.Channel
@ -79,7 +78,6 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,
maxReconnectInterval: FiniteDuration,
chainHash: ByteVector32,
channelFlags: Byte,
watcherType: WatcherType,
watchSpentWindow: FiniteDuration,
paymentRequestExpiry: FiniteDuration,
multiPartPaymentExpiry: FiniteDuration,
@ -110,11 +108,10 @@ case class NodeParams(nodeKeyManager: NodeKeyManager,
object NodeParams extends Logging {
// @formatter:off
sealed trait WatcherType
object BITCOIND extends WatcherType
object ELECTRUM extends WatcherType
// @formatter:on
/**
* Order of precedence for the configuration parameters:
@ -123,7 +120,7 @@ object NodeParams extends Logging {
* 3) Optionally provided config
* 4) Default values in reference.conf
*/
def loadConfiguration(datadir: File) =
def loadConfiguration(datadir: File): Config =
ConfigFactory.parseProperties(System.getProperties)
.withFallback(ConfigFactory.parseFile(new File(datadir, "eclair.conf")))
.withFallback(ConfigFactory.load())
@ -214,11 +211,6 @@ object NodeParams extends Logging {
val color = ByteVector.fromValidHex(config.getString("node-color"))
require(color.size == 3, "color should be a 3-bytes hex buffer")
val watcherType = config.getString("watcher-type") match {
case "electrum" => ELECTRUM
case _ => BITCOIND
}
val watchSpentWindow = FiniteDuration(config.getDuration("watch-spent-window").getSeconds, TimeUnit.SECONDS)
require(watchSpentWindow > 0.seconds, "watch-spent-window must be strictly greater than 0")
@ -364,7 +356,6 @@ object NodeParams extends Logging {
maxReconnectInterval = FiniteDuration(config.getDuration("max-reconnect-interval").getSeconds, TimeUnit.SECONDS),
chainHash = chainHash,
channelFlags = config.getInt("channel-flags").toByte,
watcherType = watcherType,
watchSpentWindow = watchSpentWindow,
paymentRequestExpiry = FiniteDuration(config.getDuration("payment-request-expiry").getSeconds, TimeUnit.SECONDS),
multiPartPaymentExpiry = FiniteDuration(config.getDuration("multi-part-payment-expiry").getSeconds, TimeUnit.SECONDS),

View file

@ -22,17 +22,12 @@ import akka.pattern.after
import akka.util.Timeout
import com.softwaremill.sttp.okhttp.OkHttpFutureBackend
import fr.acinq.bitcoin.{Block, ByteVector32, Satoshi}
import fr.acinq.eclair.NodeParams.{BITCOIND, ELECTRUM}
import fr.acinq.eclair.Setup.Seeds
import fr.acinq.eclair.blockchain._
import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, BatchingBitcoinJsonRPCClient, ExtendedBitcoinClient}
import fr.acinq.eclair.blockchain.bitcoind.zmq.ZMQActor
import fr.acinq.eclair.blockchain.bitcoind.{BitcoinCoreWallet, ZmqWatcher}
import fr.acinq.eclair.blockchain.electrum.ElectrumClient.SSL
import fr.acinq.eclair.blockchain.electrum.ElectrumClientPool.ElectrumServerAddress
import fr.acinq.eclair.blockchain.electrum._
import fr.acinq.eclair.blockchain.electrum.db.sqlite.SqliteWalletDb
import fr.acinq.eclair.blockchain.fee.{ConstantFeeProvider, _}
import fr.acinq.eclair.blockchain.{EclairWallet, _}
import fr.acinq.eclair.blockchain.fee._
import fr.acinq.eclair.channel.Register
import fr.acinq.eclair.crypto.keymanager.{LocalChannelKeyManager, LocalNodeKeyManager}
import fr.acinq.eclair.db.Databases.FileBackup
@ -51,7 +46,6 @@ import scodec.bits.ByteVector
import java.io.File
import java.net.InetSocketAddress
import java.sql.DriverManager
import java.util.UUID
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.{AtomicLong, AtomicReference}
@ -129,9 +123,7 @@ class Setup(datadir: File,
val nodeParams = NodeParams.makeNodeParams(config, instanceId, nodeKeyManager, channelKeyManager, initTor(), databases, blockCount, feeEstimator, pluginParams)
pluginParams.foreach(param => logger.info(s"using plugin=${param.name}"))
val serverBindingAddress = new InetSocketAddress(
config.getString("server.binding-ip"),
config.getInt("server.port"))
val serverBindingAddress = new InetSocketAddress(config.getString("server.binding-ip"), config.getInt("server.port"))
// early checks
DBCompatChecker.checkDBCompatibility(nodeParams)
@ -141,76 +133,51 @@ class Setup(datadir: File,
logger.info(s"nodeid=${nodeParams.nodeId} alias=${nodeParams.alias}")
logger.info(s"using chain=$chain chainHash=${nodeParams.chainHash}")
val bitcoin = nodeParams.watcherType match {
case BITCOIND =>
val wallet = {
val name = config.getString("bitcoind.wallet")
if (!name.isBlank) Some(name) else None
val bitcoin = {
val wallet = {
val name = config.getString("bitcoind.wallet")
if (!name.isBlank) Some(name) else None
}
val bitcoinClient = new BasicBitcoinJsonRPCClient(
user = config.getString("bitcoind.rpcuser"),
password = config.getString("bitcoind.rpcpassword"),
host = config.getString("bitcoind.host"),
port = config.getInt("bitcoind.rpcport"),
wallet = wallet)
val future = for {
json <- bitcoinClient.invoke("getblockchaininfo").recover { case e => throw BitcoinRPCConnectionException(e) }
// Make sure wallet support is enabled in bitcoind.
_ <- bitcoinClient.invoke("getbalance").recover { case e => throw BitcoinWalletDisabledException(e) }
progress = (json \ "verificationprogress").extract[Double]
ibd = (json \ "initialblockdownload").extract[Boolean]
blocks = (json \ "blocks").extract[Long]
headers = (json \ "headers").extract[Long]
chainHash <- bitcoinClient.invoke("getblockhash", 0).map(_.extract[String]).map(s => ByteVector32.fromValidHex(s)).map(_.reverse)
bitcoinVersion <- bitcoinClient.invoke("getnetworkinfo").map(json => json \ "version").map(_.extract[Int])
unspentAddresses <- bitcoinClient.invoke("listunspent").collect { case JArray(values) =>
values
.filter(value => (value \ "spendable").extract[Boolean])
.map(value => (value \ "address").extract[String])
}
val bitcoinClient = new BasicBitcoinJsonRPCClient(
user = config.getString("bitcoind.rpcuser"),
password = config.getString("bitcoind.rpcpassword"),
host = config.getString("bitcoind.host"),
port = config.getInt("bitcoind.rpcport"),
wallet = wallet)
val future = for {
json <- bitcoinClient.invoke("getblockchaininfo").recover { case e => throw BitcoinRPCConnectionException(e) }
// Make sure wallet support is enabled in bitcoind.
_ <- bitcoinClient.invoke("getbalance").recover { case e => throw BitcoinWalletDisabledException(e) }
progress = (json \ "verificationprogress").extract[Double]
ibd = (json \ "initialblockdownload").extract[Boolean]
blocks = (json \ "blocks").extract[Long]
headers = (json \ "headers").extract[Long]
chainHash <- bitcoinClient.invoke("getblockhash", 0).map(_.extract[String]).map(s => ByteVector32.fromValidHex(s)).map(_.reverse)
bitcoinVersion <- bitcoinClient.invoke("getnetworkinfo").map(json => json \ "version").map(_.extract[Int])
unspentAddresses <- bitcoinClient.invoke("listunspent").collect { case JArray(values) =>
values
.filter(value => (value \ "spendable").extract[Boolean])
.map(value => (value \ "address").extract[String])
}
_ <- chain match {
case "mainnet" => bitcoinClient.invoke("getrawtransaction", "2157b554dcfda405233906e461ee593875ae4b1b97615872db6a25130ecc1dd6") // coinbase of #500000
case "testnet" => bitcoinClient.invoke("getrawtransaction", "8f38a0dd41dc0ae7509081e262d791f8d53ed6f884323796d5ec7b0966dd3825") // coinbase of #1500000
case "regtest" => Future.successful(())
}
} yield (progress, ibd, chainHash, bitcoinVersion, unspentAddresses, blocks, headers)
// blocking sanity checks
val (progress, initialBlockDownload, chainHash, bitcoinVersion, unspentAddresses, blocks, headers) = await(future, 30 seconds, "bicoind did not respond after 30 seconds")
assert(bitcoinVersion >= 180000, "Eclair requires Bitcoin Core 0.18.0 or higher")
assert(chainHash == nodeParams.chainHash, s"chainHash mismatch (conf=${nodeParams.chainHash} != bitcoind=$chainHash)")
if (chainHash != Block.RegtestGenesisBlock.hash) {
assert(unspentAddresses.forall(address => !isPay2PubkeyHash(address)), "Your wallet contains non-segwit UTXOs. You must send those UTXOs to a bech32 address to use Eclair (check out our README for more details).")
_ <- chain match {
case "mainnet" => bitcoinClient.invoke("getrawtransaction", "2157b554dcfda405233906e461ee593875ae4b1b97615872db6a25130ecc1dd6") // coinbase of #500000
case "testnet" => bitcoinClient.invoke("getrawtransaction", "8f38a0dd41dc0ae7509081e262d791f8d53ed6f884323796d5ec7b0966dd3825") // coinbase of #1500000
case "regtest" => Future.successful(())
}
assert(!initialBlockDownload, s"bitcoind should be synchronized (initialblockdownload=$initialBlockDownload)")
assert(progress > 0.999, s"bitcoind should be synchronized (progress=$progress)")
assert(headers - blocks <= 1, s"bitcoind should be synchronized (headers=$headers blocks=$blocks)")
logger.info(s"current blockchain height=$blocks")
blockCount.set(blocks)
Bitcoind(bitcoinClient)
case ELECTRUM =>
val addresses = config.hasPath("electrum") match {
case true =>
val host = config.getString("electrum.host")
val port = config.getInt("electrum.port")
val address = InetSocketAddress.createUnresolved(host, port)
val ssl = config.getString("electrum.ssl") match {
case "off" => SSL.OFF
case "loose" => SSL.LOOSE
case _ => SSL.STRICT // strict mode is the default when we specify a custom electrum server, we don't want to be MITMed
}
logger.info(s"override electrum default with server=$address ssl=$ssl")
Set(ElectrumServerAddress(address, ssl))
case false =>
val (addressesFile, sslEnabled) = (nodeParams.chainHash: @unchecked) match {
case Block.RegtestGenesisBlock.hash => ("/electrum/servers_regtest.json", false) // in regtest we connect in plaintext
case Block.TestnetGenesisBlock.hash => ("/electrum/servers_testnet.json", true)
case Block.LivenetGenesisBlock.hash => ("/electrum/servers_mainnet.json", true)
}
val stream = classOf[Setup].getResourceAsStream(addressesFile)
ElectrumClientPool.readServerAddresses(stream, sslEnabled)
}
val electrumClient = system.actorOf(SimpleSupervisor.props(Props(new ElectrumClientPool(blockCount, addresses, nodeParams.socksProxy_opt)), "electrum-client", SupervisorStrategy.Resume))
Electrum(electrumClient)
} yield (progress, ibd, chainHash, bitcoinVersion, unspentAddresses, blocks, headers)
// blocking sanity checks
val (progress, initialBlockDownload, chainHash, bitcoinVersion, unspentAddresses, blocks, headers) = await(future, 30 seconds, "bicoind did not respond after 30 seconds")
assert(bitcoinVersion >= 180000, "Eclair requires Bitcoin Core 0.18.0 or higher")
assert(chainHash == nodeParams.chainHash, s"chainHash mismatch (conf=${nodeParams.chainHash} != bitcoind=$chainHash)")
if (chainHash != Block.RegtestGenesisBlock.hash) {
assert(unspentAddresses.forall(address => !isPay2PubkeyHash(address)), "Your wallet contains non-segwit UTXOs. You must send those UTXOs to a bech32 address to use Eclair (check out our README for more details).")
}
assert(!initialBlockDownload, s"bitcoind should be synchronized (initialblockdownload=$initialBlockDownload)")
assert(progress > 0.999, s"bitcoind should be synchronized (progress=$progress)")
assert(headers - blocks <= 1, s"bitcoind should be synchronized (headers=$headers blocks=$blocks)")
logger.info(s"current blockchain height=$blocks")
blockCount.set(blocks)
bitcoinClient
}
def bootstrap: Future[Kit] = {
@ -241,14 +208,11 @@ class Setup(datadir: File,
}
minFeeratePerByte = FeeratePerByte(Satoshi(config.getLong("on-chain-fees.min-feerate")))
smoothFeerateWindow = config.getInt("on-chain-fees.smoothing-window")
readTimeout = FiniteDuration(config.getDuration("on-chain-fees.provider-timeout", TimeUnit.MILLISECONDS), TimeUnit.MILLISECONDS)
feeProvider = (nodeParams.chainHash, bitcoin) match {
case (Block.RegtestGenesisBlock.hash, _) =>
feeProvider = nodeParams.chainHash match {
case Block.RegtestGenesisBlock.hash =>
new FallbackFeeProvider(new ConstantFeeProvider(defaultFeerates) :: Nil, minFeeratePerByte)
case (_, Bitcoind(bitcoinClient)) =>
new FallbackFeeProvider(new SmoothFeeProvider(new BitcoinCoreFeeProvider(bitcoinClient, defaultFeerates), smoothFeerateWindow) :: Nil, minFeeratePerByte)
case _ =>
new FallbackFeeProvider(new SmoothFeeProvider(new BitgoFeeProvider(nodeParams.chainHash, readTimeout), smoothFeerateWindow) :: new SmoothFeeProvider(new EarnDotComFeeProvider(readTimeout), smoothFeerateWindow) :: Nil, minFeeratePerByte) // order matters!
new FallbackFeeProvider(new SmoothFeeProvider(new BitcoinCoreFeeProvider(bitcoin, defaultFeerates), smoothFeerateWindow) :: Nil, minFeeratePerByte)
}
_ = system.scheduler.schedule(0 seconds, 10 minutes)(feeProvider.getFeerates.onComplete {
case Success(feerates) =>
@ -266,29 +230,17 @@ class Setup(datadir: File,
})
_ <- feeratesRetrieved.future
watcher = bitcoin match {
case Bitcoind(bitcoinClient) =>
system.actorOf(SimpleSupervisor.props(Props(new ZMQActor(config.getString("bitcoind.zmqblock"), Some(zmqBlockConnected))), "zmqblock", SupervisorStrategy.Restart))
system.actorOf(SimpleSupervisor.props(Props(new ZMQActor(config.getString("bitcoind.zmqtx"), Some(zmqTxConnected))), "zmqtx", SupervisorStrategy.Restart))
system.actorOf(SimpleSupervisor.props(ZmqWatcher.props(nodeParams.chainHash, blockCount, new ExtendedBitcoinClient(new BatchingBitcoinJsonRPCClient(bitcoinClient))), "watcher", SupervisorStrategy.Resume))
case Electrum(electrumClient) =>
zmqBlockConnected.success(Done)
zmqTxConnected.success(Done)
system.actorOf(SimpleSupervisor.props(Props(new ElectrumWatcher(blockCount, electrumClient)), "watcher", SupervisorStrategy.Resume))
watcher = {
system.actorOf(SimpleSupervisor.props(Props(new ZMQActor(config.getString("bitcoind.zmqblock"), Some(zmqBlockConnected))), "zmqblock", SupervisorStrategy.Restart))
system.actorOf(SimpleSupervisor.props(Props(new ZMQActor(config.getString("bitcoind.zmqtx"), Some(zmqTxConnected))), "zmqtx", SupervisorStrategy.Restart))
system.actorOf(SimpleSupervisor.props(ZmqWatcher.props(nodeParams.chainHash, blockCount, new ExtendedBitcoinClient(new BatchingBitcoinJsonRPCClient(bitcoin))), "watcher", SupervisorStrategy.Resume))
}
router = system.actorOf(SimpleSupervisor.props(Router.props(nodeParams, watcher, Some(routerInitialized)), "router", SupervisorStrategy.Resume))
routerTimeout = after(FiniteDuration(config.getDuration("router.init-timeout").getSeconds, TimeUnit.SECONDS), using = system.scheduler)(Future.failed(new RuntimeException("Router initialization timed out")))
_ <- Future.firstCompletedOf(routerInitialized.future :: routerTimeout :: Nil)
wallet = bitcoin match {
case Bitcoind(bitcoinClient) => new BitcoinCoreWallet(bitcoinClient)
case Electrum(electrumClient) =>
val sqlite = DriverManager.getConnection(s"jdbc:sqlite:${new File(chaindir, "wallet.sqlite")}")
val walletDb = new SqliteWalletDb(sqlite)
val electrumWallet = system.actorOf(ElectrumWallet.props(channelSeed, electrumClient, ElectrumWallet.WalletParameters(nodeParams.chainHash, walletDb)), "electrum-wallet")
new ElectrumEclairWallet(electrumWallet, nodeParams.chainHash)
}
wallet = new BitcoinCoreWallet(bitcoin)
_ = wallet.getReceiveAddress.map(address => logger.info(s"initial wallet address=$address"))
// do not change the name of this actor. it is used in the configuration to specify a custom bounded mailbox
@ -385,15 +337,11 @@ class Setup(datadir: File,
}
// @formatter:off
object Setup {
final case class Seeds(nodeSeed: ByteVector, channelSeed: ByteVector)
}
sealed trait Bitcoin
case class Bitcoind(bitcoinClient: BasicBitcoinJsonRPCClient) extends Bitcoin
case class Electrum(electrumClient: ActorRef) extends Bitcoin
// @formatter:on
final case class Seeds(nodeSeed: ByteVector, channelSeed: ByteVector)
}
case class Kit(nodeParams: NodeParams,
system: ActorSystem,

View file

@ -17,50 +17,32 @@
package fr.acinq.eclair.blockchain
import akka.actor.ActorRef
import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.bitcoin.{ByteVector32, Satoshi, Script, ScriptWitness, Transaction}
import fr.acinq.bitcoin.{ByteVector32, Satoshi, Transaction}
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.channel.BitcoinEvent
import fr.acinq.eclair.transactions.Transactions.TransactionSigningKit
import fr.acinq.eclair.wire.protocol.ChannelAnnouncement
import scodec.bits.ByteVector
import scala.util.{Failure, Success, Try}
/**
* Created by PM on 19/01/2016.
*/
// @formatter:off
sealed trait Watch {
// @formatter:off
def replyTo: ActorRef
def event: BitcoinEvent
// @formatter:on
}
/**
* Watch for confirmation of a given transaction.
*
* @param replyTo actor to notify once the transaction is confirmed.
* @param txId txid of the transaction to watch.
* @param publicKeyScript when using electrum, we need to specify a public key script; any of the output scripts should work.
* @param minDepth number of confirmations.
* @param event channel event related to the transaction.
* @param replyTo actor to notify once the transaction is confirmed.
* @param txId txid of the transaction to watch.
* @param minDepth number of confirmations.
* @param event channel event related to the transaction.
*/
final case class WatchConfirmed(replyTo: ActorRef, txId: ByteVector32, publicKeyScript: ByteVector, minDepth: Long, event: BitcoinEvent) extends Watch
object WatchConfirmed {
// if we have the entire transaction, we can get the publicKeyScript from any of the outputs
def apply(replyTo: ActorRef, tx: Transaction, minDepth: Long, event: BitcoinEvent): WatchConfirmed = WatchConfirmed(replyTo, tx.txid, tx.txOut.map(_.publicKeyScript).headOption.getOrElse(ByteVector.empty), minDepth, event)
def extractPublicKeyScript(witness: ScriptWitness): ByteVector = Try(PublicKey(witness.stack.last)) match {
case Success(pubKey) =>
// if last element of the witness is a public key, then this is a p2wpkh
Script.write(Script.pay2wpkh(pubKey))
case Failure(_) =>
// otherwise this is a p2wsh
Script.write(Script.pay2wsh(witness.stack.last))
}
}
final case class WatchConfirmed(replyTo: ActorRef, txId: ByteVector32, minDepth: Long, event: BitcoinEvent) extends Watch
/**
* Watch for transactions spending the given outpoint.
@ -69,19 +51,14 @@ object WatchConfirmed {
* - we see a spending transaction in the mempool, but it is then replaced (RBF)
* - we see a spending transaction in the mempool, but a conflicting transaction "wins" and gets confirmed in a block
*
* @param replyTo actor to notify when the outpoint is spent.
* @param txId txid of the outpoint to watch.
* @param outputIndex index of the outpoint to watch.
* @param publicKeyScript electrum requires us to specify a public key script; the script of the outpoint must be provided.
* @param event channel event related to the outpoint.
* @param hints txids of potential spending transactions; most of the time we know the txs, and it allows for optimizations.
* This argument can safely be ignored by watcher implementations.
* @param replyTo actor to notify when the outpoint is spent.
* @param txId txid of the outpoint to watch.
* @param outputIndex index of the outpoint to watch.
* @param event channel event related to the outpoint.
* @param hints txids of potential spending transactions; most of the time we know the txs, and it allows for optimizations.
* This argument can safely be ignored by watcher implementations.
*/
final case class WatchSpent(replyTo: ActorRef, txId: ByteVector32, outputIndex: Int, publicKeyScript: ByteVector, event: BitcoinEvent, hints: Set[ByteVector32]) extends Watch
object WatchSpent {
// if we have the entire transaction, we can get the publicKeyScript from the relevant output
def apply(replyTo: ActorRef, tx: Transaction, outputIndex: Int, event: BitcoinEvent, hints: Set[ByteVector32]): WatchSpent = WatchSpent(replyTo, tx.txid, outputIndex, tx.txOut(outputIndex).publicKeyScript, event, hints)
}
final case class WatchSpent(replyTo: ActorRef, txId: ByteVector32, outputIndex: Int, event: BitcoinEvent, hints: Set[ByteVector32]) extends Watch
/**
* Watch for the first transaction spending the given outpoint. We assume that txid is already confirmed or in the
@ -90,17 +67,12 @@ object WatchSpent {
* NB: an event will be triggered only once when we see a transaction that spends the given outpoint. If you want to
* react to the transaction spending the outpoint, you should use [[WatchSpent]] instead.
*
* @param replyTo actor to notify when the outpoint is spent.
* @param txId txid of the outpoint to watch.
* @param outputIndex index of the outpoint to watch.
* @param publicKeyScript electrum requires us to specify a public key script; the script of the outpoint must be provided.
* @param event channel event related to the outpoint.
* @param replyTo actor to notify when the outpoint is spent.
* @param txId txid of the outpoint to watch.
* @param outputIndex index of the outpoint to watch.
* @param event channel event related to the outpoint.
*/
final case class WatchSpentBasic(replyTo: ActorRef, txId: ByteVector32, outputIndex: Int, publicKeyScript: ByteVector, event: BitcoinEvent) extends Watch
object WatchSpentBasic {
// if we have the entire transaction, we can get the publicKeyScript from the relevant output
def apply(replyTo: ActorRef, tx: Transaction, outputIndex: Int, event: BitcoinEvent): WatchSpentBasic = WatchSpentBasic(replyTo, tx.txid, outputIndex, tx.txOut(outputIndex).publicKeyScript, event)
}
final case class WatchSpentBasic(replyTo: ActorRef, txId: ByteVector32, outputIndex: Int, event: BitcoinEvent) extends Watch
// TODO: not implemented yet: notify me if confirmation number gets below minDepth?
final case class WatchLost(replyTo: ActorRef, txId: ByteVector32, minDepth: Long, event: BitcoinEvent) extends Watch
@ -138,6 +110,7 @@ final case class WatchEventSpentBasic(event: BitcoinEvent) extends WatchEvent
// TODO: not implemented yet.
final case class WatchEventLost(event: BitcoinEvent) extends WatchEvent
// @formatter:off
sealed trait PublishStrategy
object PublishStrategy {
case object JustPublish extends PublishStrategy
@ -145,10 +118,12 @@ object PublishStrategy {
override def toString = s"SetFeerate(target=$targetFeerate)"
}
}
// @formatter:on
/** Publish the provided tx as soon as possible depending on lock time, csv and publishing strategy. */
final case class PublishAsap(tx: Transaction, strategy: PublishStrategy)
// @formatter:off
sealed trait UtxoStatus
object UtxoStatus {
case object Unspent extends UtxoStatus
@ -160,5 +135,4 @@ final case class ValidateResult(c: ChannelAnnouncement, fundingTx: Either[Throwa
final case class GetTxWithMeta(txid: ByteVector32)
final case class GetTxWithMetaResponse(txid: ByteVector32, tx_opt: Option[Transaction], lastBlockTimestamp: Long)
// @formatter:on

View file

@ -138,7 +138,7 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend
val result = w match {
case _ if watches.contains(w) => Ignore // we ignore duplicates
case WatchSpentBasic(_, txid, outputIndex, _, _) =>
case WatchSpentBasic(_, txid, outputIndex, _) =>
// NB: we assume parent tx was published, we just need to make sure this particular output has not been spent
client.isTransactionOutputSpendable(txid, outputIndex, includeMempool = true).collect {
case false =>
@ -147,7 +147,7 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend
}
Keep
case WatchSpent(_, txid, outputIndex, _, _, hints) =>
case WatchSpent(_, txid, outputIndex, _, hints) =>
// first let's see if the parent tx was published or not
client.getTxConfirmations(txid).collect {
case Some(_) =>
@ -209,8 +209,7 @@ class ZmqWatcher(chainHash: ByteVector32, blockCount: AtomicLong, client: Extend
// time a parent's relative delays are satisfied, so we will eventually succeed.
csvTimeouts.foreach { case (parentTxId, csvTimeout) =>
log.info(s"txid=${tx.txid} has a relative timeout of $csvTimeout blocks, watching parentTxId=$parentTxId tx={}", tx)
val parentPublicKeyScript = Script.write(Script.pay2wsh(tx.txIn.find(_.outPoint.txid == parentTxId).get.witness.stack.last))
self ! WatchConfirmed(self, parentTxId, parentPublicKeyScript, minDepth = csvTimeout, BITCOIN_PARENT_TX_CONFIRMED(p))
self ! WatchConfirmed(self, parentTxId, minDepth = csvTimeout, BITCOIN_PARENT_TX_CONFIRMED(p))
}
} else if (cltvTimeout > blockCount) {
log.info(s"delaying publication of txid=${tx.txid} until block=$cltvTimeout (curblock=$blockCount)")

View file

@ -1,352 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum
import java.math.BigInteger
import fr.acinq.bitcoin.{Block, BlockHeader, ByteVector32, decodeCompact}
import fr.acinq.eclair.blockchain.electrum.db.HeaderDb
import grizzled.slf4j.Logging
import scala.annotation.tailrec
case class Blockchain(chainHash: ByteVector32,
checkpoints: Vector[CheckPoint],
headersMap: Map[ByteVector32, Blockchain.BlockIndex],
bestchain: Vector[Blockchain.BlockIndex],
orphans: Map[ByteVector32, BlockHeader] = Map()) {
import Blockchain._
require(chainHash == Block.LivenetGenesisBlock.hash || chainHash == Block.TestnetGenesisBlock.hash || chainHash == Block.RegtestGenesisBlock.hash, s"invalid chain hash $chainHash")
def tip = bestchain.last
def height = if (bestchain.isEmpty) 0 else bestchain.last.height
/**
* Build a chain of block indexes
*
* This is used in case of reorg to rebuilt the new best chain
*
* @param index last index of the chain
* @param acc accumulator
* @return the chain that starts at the genesis block and ends at index
*/
@tailrec
private def buildChain(index: BlockIndex, acc: Vector[BlockIndex] = Vector.empty[BlockIndex]): Vector[BlockIndex] = {
index.parent match {
case None => index +: acc
case Some(parent) => buildChain(parent, index +: acc)
}
}
/**
*
* @param height block height
* @return the encoded difficulty that a block at this height should have
*/
def getDifficulty(height: Int): Option[Long] = height match {
case value if value < RETARGETING_PERIOD * (checkpoints.length + 1) =>
// we're within our checkpoints
val checkpoint = checkpoints(height / RETARGETING_PERIOD - 1)
Some(checkpoint.nextBits)
case value if value % RETARGETING_PERIOD != 0 =>
// we're not at a retargeting height, difficulty is the same as for the previous block
getHeader(height - 1).map(_.bits)
case _ =>
// difficulty retargeting
for {
previous <- getHeader(height - 1)
firstBlock <- getHeader(height - RETARGETING_PERIOD)
} yield BlockHeader.calculateNextWorkRequired(previous, firstBlock.time)
}
def getHeader(height: Int): Option[BlockHeader] = if (!bestchain.isEmpty && height >= bestchain.head.height && height - bestchain.head.height < bestchain.size)
Some(bestchain(height - bestchain.head.height).header)
else None
}
object Blockchain extends Logging {
val RETARGETING_PERIOD = 2016 // on bitcoin, the difficulty re-targeting period is 2016 blocks
val MAX_REORG = 500 // we assume that there won't be a reorg of more than 500 blocks
/**
*
* @param header block header
* @param height block height
* @param parent parent block
* @param chainwork cumulative chain work up to and including this block
*/
case class BlockIndex(header: BlockHeader, height: Int, parent: Option[BlockIndex], chainwork: BigInt) {
lazy val hash = header.hash
lazy val blockId = header.blockId
lazy val logwork = if (chainwork == 0) 0.0 else Math.log(chainwork.doubleValue) / Math.log(2.0)
override def toString = s"BlockIndex($blockId, $height, ${parent.map(_.blockId)}, $logwork)"
}
/**
* Build an empty blockchain from a series of checkpoints
*
* @param chainhash chain we're on
* @param checkpoints list of checkpoints
* @return a blockchain instance
*/
def fromCheckpoints(chainhash: ByteVector32, checkpoints: Vector[CheckPoint]): Blockchain = {
Blockchain(chainhash, checkpoints, Map(), Vector())
}
/**
* Used in tests
*/
def fromGenesisBlock(chainhash: ByteVector32, genesis: BlockHeader): Blockchain = {
require(chainhash == Block.RegtestGenesisBlock.hash)
// the height of the genesis block is 0
val blockIndex = BlockIndex(genesis, 0, None, decodeCompact(genesis.bits)._1)
Blockchain(chainhash, Vector(), Map(blockIndex.hash -> blockIndex), Vector(blockIndex))
}
/**
* load an em
*
* @param chainHash
* @param headerDb
* @return
*/
def load(chainHash: ByteVector32, headerDb: HeaderDb): Blockchain = {
val checkpoints = CheckPoint.load(chainHash)
val checkpoints1 = headerDb.getTip match {
case Some((height, header)) =>
val newcheckpoints = for {h <- checkpoints.size * RETARGETING_PERIOD - 1 + RETARGETING_PERIOD to height - RETARGETING_PERIOD by RETARGETING_PERIOD} yield {
val cpheader = headerDb.getHeader(h).get
val nextDiff = headerDb.getHeader(h + 1).get.bits
CheckPoint(cpheader.hash, nextDiff)
}
checkpoints ++ newcheckpoints
case None => checkpoints
}
Blockchain.fromCheckpoints(chainHash, checkpoints1)
}
/**
* Validate a chunk of 2016 headers
*
* Used during initial sync to batch validate
*
* @param height height of the first header; must be a multiple of 2016
* @param headers headers.
* @throws Exception if this chunk is not valid and consistent with our checkpoints
*/
def validateHeadersChunk(blockchain: Blockchain, height: Int, headers: Seq[BlockHeader]): Unit = {
if (headers.isEmpty) return
require(height % RETARGETING_PERIOD == 0, s"header chunk height $height not a multiple of 2016")
require(BlockHeader.checkProofOfWork(headers.head))
headers.tail.foldLeft(headers.head) {
case (previous, current) =>
require(BlockHeader.checkProofOfWork(current))
require(current.hashPreviousBlock == previous.hash)
// on mainnet all blocks with a re-targeting window have the same difficulty target
// on testnet it doesn't hold, there can be a drop in difficulty if there are no blocks for 20 minutes
blockchain.chainHash match {
case Block.LivenetGenesisBlock | Block.RegtestGenesisBlock.hash => require(current.bits == previous.bits)
case _ => ()
}
current
}
val cpindex = (height / RETARGETING_PERIOD) - 1
if (cpindex < blockchain.checkpoints.length) {
// check that the first header in the chunk matches our checkpoint
val checkpoint = blockchain.checkpoints(cpindex)
require(headers(0).hashPreviousBlock == checkpoint.hash)
blockchain.chainHash match {
case Block.LivenetGenesisBlock.hash => require(headers(0).bits == checkpoint.nextBits)
case _ => ()
}
}
// if we have a checkpoint after this chunk, check that it is also satisfied
if (cpindex < blockchain.checkpoints.length - 1) {
require(headers.length == RETARGETING_PERIOD)
val nextCheckpoint = blockchain.checkpoints(cpindex + 1)
require(headers.last.hash == nextCheckpoint.hash)
blockchain.chainHash match {
case Block.LivenetGenesisBlock.hash =>
val diff = BlockHeader.calculateNextWorkRequired(headers.last, headers.head.time)
require(diff == nextCheckpoint.nextBits)
case _ => ()
}
}
}
def addHeadersChunk(blockchain: Blockchain, height: Int, headers: Seq[BlockHeader]): Blockchain = {
if (headers.length > RETARGETING_PERIOD) {
val blockchain1 = Blockchain.addHeadersChunk(blockchain, height, headers.take(RETARGETING_PERIOD))
return Blockchain.addHeadersChunk(blockchain1, height + RETARGETING_PERIOD, headers.drop(RETARGETING_PERIOD))
}
if (headers.isEmpty) return blockchain
validateHeadersChunk(blockchain, height, headers)
height match {
case _ if height == blockchain.checkpoints.length * RETARGETING_PERIOD =>
// append after our last checkpoint
// checkpoints are (block hash, * next * difficulty target), this is why:
// - we duplicate the first checkpoints because all headers in the first chunks on mainnet had the same difficulty target
// - we drop the last checkpoint
val chainwork = (blockchain.checkpoints(0) +: blockchain.checkpoints.dropRight(1)).map(t => BigInt(RETARGETING_PERIOD) * Blockchain.chainWork(t.nextBits)).sum
val blockIndex = BlockIndex(headers.head, height, None, chainwork + Blockchain.chainWork(headers.head))
val bestchain1 = headers.tail.foldLeft(Vector(blockIndex)) {
case (indexes, header) => indexes :+ BlockIndex(header, indexes.last.height + 1, Some(indexes.last), indexes.last.chainwork + Blockchain.chainWork(header))
}
val headersMap1 = blockchain.headersMap ++ bestchain1.map(bi => bi.hash -> bi)
blockchain.copy(bestchain = bestchain1, headersMap = headersMap1)
case _ if height < blockchain.checkpoints.length * RETARGETING_PERIOD =>
blockchain
case _ if height == blockchain.height + 1 =>
// attach at our best chain
require(headers.head.hashPreviousBlock == blockchain.bestchain.last.hash)
val blockIndex = BlockIndex(headers.head, height, None, blockchain.bestchain.last.chainwork + Blockchain.chainWork(headers.head))
val indexes = headers.tail.foldLeft(Vector(blockIndex)) {
case (indexes, header) => indexes :+ BlockIndex(header, indexes.last.height + 1, Some(indexes.last), indexes.last.chainwork + Blockchain.chainWork(header))
}
val bestchain1 = blockchain.bestchain ++ indexes
val headersMap1 = blockchain.headersMap ++ indexes.map(bi => bi.hash -> bi)
blockchain.copy(bestchain = bestchain1, headersMap = headersMap1)
// do nothing; headers have been validated
case _ => throw new IllegalArgumentException(s"cannot add headers chunk to an empty blockchain: not within our checkpoint")
}
}
def addHeader(blockchain: Blockchain, height: Int, header: BlockHeader): Blockchain = {
require(BlockHeader.checkProofOfWork(header), s"invalid proof of work for $header")
blockchain.headersMap.get(header.hashPreviousBlock) match {
case Some(parent) if parent.height == height - 1 =>
if (height % RETARGETING_PERIOD != 0 && (blockchain.chainHash == Block.LivenetGenesisBlock.hash || blockchain.chainHash == Block.RegtestGenesisBlock.hash)) {
// check difficulty target, which should be the same as for the parent block
// we only check this on mainnet, on testnet rules are much more lax
require(header.bits == parent.header.bits, s"header invalid difficulty target for ${header}, it should be ${parent.header.bits}")
}
val blockIndex = BlockIndex(header, height, Some(parent), parent.chainwork + Blockchain.chainWork(header))
val headersMap1 = blockchain.headersMap + (blockIndex.hash -> blockIndex)
val bestChain1 = if (parent == blockchain.bestchain.last) {
// simplest case: we add to our current best chain
logger.info(s"new tip at $blockIndex")
blockchain.bestchain :+ blockIndex
} else if (blockIndex.chainwork > blockchain.bestchain.last.chainwork) {
logger.info(s"new best chain at $blockIndex")
// we have a new best chain
buildChain(blockIndex)
} else {
logger.info(s"received header $blockIndex which is not on the best chain")
blockchain.bestchain
}
blockchain.copy(headersMap = headersMap1, bestchain = bestChain1)
case Some(parent) => throw new IllegalArgumentException(s"parent for $header at $height is not valid: $parent ")
case None if height < blockchain.height - 1000 => blockchain
case None => throw new IllegalArgumentException(s"cannot find parent for $header at $height")
}
}
def addHeaders(blockchain: Blockchain, height: Int, headers: Seq[BlockHeader]): Blockchain = {
if (headers.isEmpty) blockchain
else if (height % RETARGETING_PERIOD == 0) addHeadersChunk(blockchain, height, headers)
else {
@tailrec
def loop(bc: Blockchain, h: Int, hs: Seq[BlockHeader]): Blockchain = if (hs.isEmpty) bc else {
loop(Blockchain.addHeader(bc, h, hs.head), h + 1, hs.tail)
}
loop(blockchain, height, headers)
}
}
/**
* build a chain of block indexes
*
* @param index last index of the chain
* @param acc accumulator
* @return the chain that starts at the genesis block and ends at index
*/
@tailrec
def buildChain(index: BlockIndex, acc: Vector[BlockIndex] = Vector.empty[BlockIndex]): Vector[BlockIndex] = {
index.parent match {
case None => index +: acc
case Some(parent) => buildChain(parent, index +: acc)
}
}
def chainWork(target: BigInt): BigInt = BigInt(2).pow(256) / (target + BigInt(1))
def chainWork(bits: Long): BigInt = {
val (target, negative, overflow) = decodeCompact(bits)
if (target == BigInteger.ZERO || negative || overflow) BigInt(0) else chainWork(target)
}
def chainWork(header: BlockHeader): BigInt = chainWork(header.bits)
/**
* Optimize blockchain
*
* @param blockchain
* @param acc internal accumulator
* @return a (blockchain, indexes) tuple where headers that are old enough have been removed and new checkpoints added,
* and indexes is the list of header indexes that have been optimized out and must be persisted
*/
@tailrec
def optimize(blockchain: Blockchain, acc: Vector[BlockIndex] = Vector.empty[BlockIndex]) : (Blockchain, Vector[BlockIndex]) = {
if (blockchain.bestchain.size >= RETARGETING_PERIOD + MAX_REORG) {
val saveme = blockchain.bestchain.take(RETARGETING_PERIOD)
val headersMap1 = blockchain.headersMap -- saveme.map(_.hash)
val bestchain1 = blockchain.bestchain.drop(RETARGETING_PERIOD)
val checkpoints1 = blockchain.checkpoints :+ CheckPoint(saveme.last.hash, bestchain1.head.header.bits)
optimize(blockchain.copy(headersMap = headersMap1, bestchain = bestchain1, checkpoints = checkpoints1), acc ++ saveme)
} else {
(blockchain, acc)
}
}
/**
* Computes the difficulty target at a given height.
*
* @param blockchain blockchain
* @param height height for which we want the difficulty target
* @param headerDb header database
* @return the difficulty target for this height
*/
def getDifficulty(blockchain: Blockchain, height: Int, headerDb: HeaderDb): Option[Long] = {
blockchain.chainHash match {
case Block.LivenetGenesisBlock.hash =>
(height % RETARGETING_PERIOD) match {
case 0 =>
for {
parent <- blockchain.getHeader(height - 1) orElse headerDb.getHeader(height - 1)
previous <- blockchain.getHeader(height - 2016) orElse headerDb.getHeader(height - 2016)
target = BlockHeader.calculateNextWorkRequired(parent, previous.time)
} yield target
case _ => blockchain.getHeader(height - 1) orElse headerDb.getHeader(height - 1) map (_.bits)
}
case _ => None // no difficulty check on testnet
}
}
}

View file

@ -1,79 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum
import java.io.InputStream
import fr.acinq.bitcoin.{Block, ByteVector32, encodeCompact}
import fr.acinq.eclair.blockchain.electrum.db.HeaderDb
import org.json4s.JsonAST.{JArray, JInt, JString}
import org.json4s.jackson.JsonMethods
/**
*
* @param hash block hash
* @param nextBits difficulty target for the next block
*/
case class CheckPoint(hash: ByteVector32, nextBits: Long)
object CheckPoint {
import Blockchain.RETARGETING_PERIOD
/**
* Load checkpoints.
* There is one checkpoint every 2016 blocks (which is the difficulty adjustment period). They are used to check that
* we're on the right chain and to validate proof-of-work by checking the difficulty target
* @return an ordered list of checkpoints, with one checkpoint every 2016 blocks
*/
def load(chainHash: ByteVector32): Vector[CheckPoint] = (chainHash: @unchecked) match {
case Block.LivenetGenesisBlock.hash => load(classOf[CheckPoint].getResourceAsStream("/electrum/checkpoints_mainnet.json"))
case Block.TestnetGenesisBlock.hash => load(classOf[CheckPoint].getResourceAsStream("/electrum/checkpoints_testnet.json"))
case Block.RegtestGenesisBlock.hash => Vector.empty[CheckPoint] // no checkpoints on regtest
}
def load(stream: InputStream): Vector[CheckPoint] = {
val JArray(values) = JsonMethods.parse(stream)
val checkpoints = values.collect {
case JArray(JString(a) :: JInt(b) :: Nil) => CheckPoint(ByteVector32.fromValidHex(a).reverse, encodeCompact(b.bigInteger))
}
checkpoints.toVector
}
/**
* load checkpoints from our resources and header database
*
* @param chainHash chaim hash
* @param headerDb header db
* @return a series of checkpoints
*/
def load(chainHash: ByteVector32, headerDb: HeaderDb): Vector[CheckPoint] = {
val checkpoints = CheckPoint.load(chainHash)
val checkpoints1 = headerDb.getTip match {
case Some((height, header)) =>
val newcheckpoints = for {h <- checkpoints.size * RETARGETING_PERIOD - 1 + RETARGETING_PERIOD to height - RETARGETING_PERIOD by RETARGETING_PERIOD} yield {
// we * should * have these headers in our db
val cpheader = headerDb.getHeader(h).get
val nextDiff = headerDb.getHeader(h + 1).get.bits
CheckPoint(cpheader.hash, nextDiff)
}
checkpoints ++ newcheckpoints
case None => checkpoints
}
checkpoints1
}
}

View file

@ -1,665 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum
import java.net.{InetSocketAddress, SocketAddress}
import java.util
import akka.actor.{Actor, ActorLogging, ActorRef, Stash, Terminated}
import fr.acinq.bitcoin._
import fr.acinq.eclair.blockchain.bitcoind.rpc.{Error, JsonRPCRequest, JsonRPCResponse}
import fr.acinq.eclair.blockchain.electrum.ElectrumClient.SSL
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.tor.Socks5ProxyParams
import io.netty.bootstrap.Bootstrap
import io.netty.buffer.PooledByteBufAllocator
import io.netty.channel._
import io.netty.channel.nio.NioEventLoopGroup
import io.netty.channel.socket.SocketChannel
import io.netty.channel.socket.nio.NioSocketChannel
import io.netty.handler.codec.string.{LineEncoder, StringDecoder}
import io.netty.handler.codec.{LineBasedFrameDecoder, MessageToMessageDecoder, MessageToMessageEncoder}
import io.netty.handler.proxy.Socks5ProxyHandler
import io.netty.handler.ssl.SslContextBuilder
import io.netty.handler.ssl.util.InsecureTrustManagerFactory
import io.netty.resolver.{NoopAddressResolver, NoopAddressResolverGroup}
import io.netty.util.CharsetUtil
import org.json4s.JsonAST._
import org.json4s.jackson.JsonMethods
import org.json4s.{DefaultFormats, Formats, JInt, JLong, JString}
import scodec.bits.ByteVector
import scala.annotation.tailrec
import scala.concurrent.ExecutionContext
import scala.concurrent.duration._
import scala.util.{Failure, Success, Try}
/**
* For later optimizations, see http://normanmaurer.me/presentations/2014-facebook-eng-netty/slides.html
*/
class ElectrumClient(serverAddress: InetSocketAddress, ssl: SSL, socksProxy_opt: Option[Socks5ProxyParams] = None)(implicit val ec: ExecutionContext) extends Actor with Stash with ActorLogging {
import ElectrumClient._
implicit val formats = DefaultFormats
val b = new Bootstrap
b.group(workerGroup)
b.channel(classOf[NioSocketChannel])
b.option[java.lang.Boolean](ChannelOption.SO_KEEPALIVE, true)
b.option[java.lang.Boolean](ChannelOption.TCP_NODELAY, true)
b.option[java.lang.Integer](ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
b.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
b.handler(new ChannelInitializer[SocketChannel]() {
override def initChannel(ch: SocketChannel): Unit = {
ssl match {
case SSL.OFF => ()
case SSL.STRICT =>
val sslCtx = SslContextBuilder.forClient.build
val handler = sslCtx.newHandler(ch.alloc(), serverAddress.getHostName, serverAddress.getPort)
val sslParameters = handler.engine().getSSLParameters
sslParameters.setEndpointIdentificationAlgorithm("HTTPS")
handler.engine().setSSLParameters(sslParameters)
val enabledProtocols = if (handler.engine().getSupportedProtocols.contains("TLSv1.3")) {
"TLSv1.2" :: "TLSv1.3" :: Nil
} else {
"TLSv1.2" :: Nil
}
handler.engine().setEnabledProtocols(enabledProtocols.toArray)
ch.pipeline.addLast(handler)
case SSL.LOOSE =>
// INSECURE VERSION THAT DOESN'T CHECK CERTIFICATE
val sslCtx = SslContextBuilder.forClient().trustManager(InsecureTrustManagerFactory.INSTANCE).build()
ch.pipeline.addLast(sslCtx.newHandler(ch.alloc(), serverAddress.getHostName, serverAddress.getPort))
}
// inbound handlers
ch.pipeline.addLast(new LineBasedFrameDecoder(Int.MaxValue, true, true)) // JSON messages are separated by a new line
ch.pipeline.addLast(new StringDecoder(CharsetUtil.UTF_8))
ch.pipeline.addLast(new ElectrumResponseDecoder)
ch.pipeline.addLast(new ActorHandler(self))
// outbound handlers
ch.pipeline.addLast(new LineEncoder)
ch.pipeline.addLast(new JsonRPCRequestEncoder)
// error handler
ch.pipeline.addLast(new ExceptionHandler)
// optional proxy (must be the first handler)
socksProxy_opt.foreach(params => ch.pipeline().addFirst(new Socks5ProxyHandler(params.address)))
}
})
// don't try to resolve addresses if we're using a proxy
socksProxy_opt.foreach(params => b.resolver(NoopAddressResolverGroup.INSTANCE))
// Start the client.
log.debug("connecting to server={}", serverAddress)
val channelOpenFuture = b.connect(serverAddress.getHostName, serverAddress.getPort)
def errorHandler(t: Throwable): Unit = {
// generic errors don't need to be logged in most cases, what we actually want are errors that happened once we were
// properly connected and had exchanged version messages
log.debug("server={} connection error (reason={})", serverAddress, t.getMessage)
self ! Close
}
channelOpenFuture.addListeners(new ChannelFutureListener {
override def operationComplete(future: ChannelFuture): Unit = {
if (!future.isSuccess) {
errorHandler(future.cause())
} else {
future.channel().closeFuture().addListener(new ChannelFutureListener {
override def operationComplete(future: ChannelFuture): Unit = {
if (!future.isSuccess) {
errorHandler(future.cause())
} else {
log.debug("server={} channel closed: {}", serverAddress, future.channel())
self ! Close
}
}
})
}
}
})
/**
* This error handler catches all exceptions and kill the actor
* See https://stackoverflow.com/questions/30994095/how-to-catch-all-exception-in-netty
*/
class ExceptionHandler extends ChannelDuplexHandler {
override def connect(ctx: ChannelHandlerContext, remoteAddress: SocketAddress, localAddress: SocketAddress, promise: ChannelPromise): Unit = {
ctx.connect(remoteAddress, localAddress, promise.addListener(new ChannelFutureListener() {
override def operationComplete(future: ChannelFuture): Unit = {
if (!future.isSuccess) {
errorHandler(future.cause())
}
}
}))
}
override def write(ctx: ChannelHandlerContext, msg: scala.Any, promise: ChannelPromise): Unit = {
ctx.write(msg, promise.addListener(new ChannelFutureListener() {
override def operationComplete(future: ChannelFuture): Unit = {
if (!future.isSuccess) {
errorHandler(future.cause())
}
}
}))
}
override def exceptionCaught(ctx: ChannelHandlerContext, cause: Throwable): Unit = {
errorHandler(cause)
}
}
/**
* A decoder ByteBuf -> Either[Response, JsonRPCResponse]
*/
class ElectrumResponseDecoder extends MessageToMessageDecoder[String] {
override def decode(ctx: ChannelHandlerContext, msg: String, out: util.List[AnyRef]): Unit = {
val s = msg.asInstanceOf[String]
val r = parseResponse(s)
out.add(r)
}
}
/**
* An encoder JsonRPCRequest -> ByteBuf
*/
class JsonRPCRequestEncoder extends MessageToMessageEncoder[JsonRPCRequest] {
override def encode(ctx: ChannelHandlerContext, request: JsonRPCRequest, out: util.List[AnyRef]): Unit = {
import org.json4s.JsonDSL._
import org.json4s._
import org.json4s.jackson.JsonMethods._
log.debug("sending {} to {}", request, serverAddress)
val json = ("method" -> request.method) ~ ("params" -> request.params.map {
case s: String => new JString(s)
case b: ByteVector32 => new JString(b.toHex)
case f: FeeratePerKw => new JLong(f.toLong)
case b: Boolean => new JBool(b)
case t: Int => new JInt(t)
case t: Long => new JLong(t)
case t: Double => new JDouble(t)
}) ~ ("id" -> request.id) ~ ("jsonrpc" -> request.jsonrpc)
val serialized = compact(render(json))
out.add(serialized)
}
}
/**
* Forwards incoming messages to the underlying actor
*/
class ActorHandler(actor: ActorRef) extends ChannelInboundHandlerAdapter {
override def channelActive(ctx: ChannelHandlerContext): Unit = {
actor ! ctx
}
override def channelRead(ctx: ChannelHandlerContext, msg: Any): Unit = {
actor ! msg
}
}
var addressSubscriptions = Map.empty[String, Set[ActorRef]]
var scriptHashSubscriptions = Map.empty[ByteVector32, Set[ActorRef]]
val headerSubscriptions = collection.mutable.HashSet.empty[ActorRef]
val version = ServerVersion(CLIENT_NAME, PROTOCOL_VERSION)
val statusListeners = collection.mutable.HashSet.empty[ActorRef]
var reqId = 0
// we need to regularly send a ping in order not to get disconnected
val pingTrigger = context.system.scheduler.schedule(30 seconds, 30 seconds, self, Ping)
override def unhandled(message: Any): Unit = {
message match {
case Terminated(deadActor) =>
addressSubscriptions = addressSubscriptions.mapValues(subscribers => subscribers - deadActor).toMap
scriptHashSubscriptions = scriptHashSubscriptions.mapValues(subscribers => subscribers - deadActor).toMap
statusListeners -= deadActor
headerSubscriptions -= deadActor
case RemoveStatusListener(actor) => statusListeners -= actor
case PingResponse => ()
case Close =>
statusListeners.foreach(_ ! ElectrumDisconnected)
context.stop(self)
case _ => log.warning("server={} unhandled message {}", serverAddress, message)
}
}
override def postStop(): Unit = {
pingTrigger.cancel()
super.postStop()
}
/**
* send an electrum request to the server
*
* @param ctx connection to the electrumx server
* @param request electrum request
* @return the request id used to send the request
*/
def send(ctx: ChannelHandlerContext, request: Request): String = {
val electrumRequestId = "" + reqId
if (ctx.channel().isWritable) {
ctx.channel().writeAndFlush(makeRequest(request, electrumRequestId))
} else {
errorHandler(new RuntimeException(s"channel not writable"))
}
reqId = reqId + 1
electrumRequestId
}
def receive: Receive = disconnected
def disconnected: Receive = {
case ctx: ChannelHandlerContext =>
log.debug("connected to server={}", serverAddress)
send(ctx, version)
context become waitingForVersion(ctx)
case AddStatusListener(actor) => statusListeners += actor
}
def waitingForVersion(ctx: ChannelHandlerContext): Receive = {
case Right(json: JsonRPCResponse) =>
(parseJsonResponse(version, json): @unchecked) match {
case ServerVersionResponse(clientName, protocolVersion) =>
log.info("server={} clientName={} protocolVersion={}", serverAddress, clientName, protocolVersion)
send(ctx, HeaderSubscription(self))
headerSubscriptions += self
log.debug("waiting for tip from server={}", serverAddress)
context become waitingForTip(ctx)
case ServerError(request, error) =>
log.error("server={} sent error={} while processing request={}, disconnecting", serverAddress, error, request)
self ! Close
}
case AddStatusListener(actor) => statusListeners += actor
}
def waitingForTip(ctx: ChannelHandlerContext): Receive = {
case Right(json: JsonRPCResponse) =>
val (height, header) = parseBlockHeader(json.result)
log.debug("connected to server={}, tip={} height={}", serverAddress, header.hash, height)
statusListeners.foreach(_ ! ElectrumReady(height, header, serverAddress))
context become connected(ctx, height, header, Map())
case AddStatusListener(actor) => statusListeners += actor
}
def connected(ctx: ChannelHandlerContext, height: Int, tip: BlockHeader, requests: Map[String, (Request, ActorRef)]): Receive = {
case AddStatusListener(actor) =>
statusListeners += actor
actor ! ElectrumReady(height, tip, serverAddress)
case HeaderSubscription(actor) =>
headerSubscriptions += actor
actor ! HeaderSubscriptionResponse(height, tip)
context watch actor
case request: Request =>
val curReqId = send(ctx, request)
request match {
case AddressSubscription(address, actor) =>
addressSubscriptions = addressSubscriptions.updated(address, addressSubscriptions.getOrElse(address, Set()) + actor)
context watch actor
case ScriptHashSubscription(scriptHash, actor) =>
scriptHashSubscriptions = scriptHashSubscriptions.updated(scriptHash, scriptHashSubscriptions.getOrElse(scriptHash, Set()) + actor)
context watch actor
case _ => ()
}
context become connected(ctx, height, tip, requests + (curReqId -> (request, sender())))
case Right(json: JsonRPCResponse) =>
requests.get(json.id) match {
case Some((request, requestor)) =>
val response = parseJsonResponse(request, json)
log.debug("server={} sent response for reqId={} request={} response={}", serverAddress, json.id, request, response)
requestor ! response
case None =>
log.warning("server={} could not find requestor for reqId=${} response={}", serverAddress, json.id, json)
}
context become connected(ctx, height, tip, requests - json.id)
case Left(response: HeaderSubscriptionResponse) => headerSubscriptions.foreach(_ ! response)
case Left(response: AddressSubscriptionResponse) => addressSubscriptions.get(response.address).foreach(listeners => listeners.foreach(_ ! response))
case Left(response: ScriptHashSubscriptionResponse) => scriptHashSubscriptions.get(response.scriptHash).foreach(listeners => listeners.foreach(_ ! response))
case HeaderSubscriptionResponse(height, newtip) =>
log.info("server={} new tip={}", serverAddress, newtip)
context become connected(ctx, height, newtip, requests)
}
}
/**
* See the documentation at https://electrumx-spesmilo.readthedocs.io/en/latest/
*/
object ElectrumClient {
val CLIENT_NAME = "3.3.6" // client name that we will include in our "version" message
val PROTOCOL_VERSION = "1.4" // version of the protocol that we require
// this is expensive and shared with all clients
val workerGroup = new NioEventLoopGroup()
/**
* Utility function to converts a publicKeyScript to electrum's scripthash
*
* @param publicKeyScript public key script
* @return the hash of the public key script, as used by ElectrumX's hash-based methods
*/
def computeScriptHash(publicKeyScript: ByteVector): ByteVector32 = Crypto.sha256(publicKeyScript).reverse
// @formatter:off
case class AddStatusListener(actor: ActorRef)
case class RemoveStatusListener(actor: ActorRef)
sealed trait Request { def context_opt: Option[Any] = None }
sealed trait Response { def context_opt: Option[Any] = None }
case class ServerVersion(clientName: String, protocolVersion: String) extends Request
case class ServerVersionResponse(clientName: String, protocolVersion: String) extends Response
case object Ping extends Request
case object PingResponse extends Response
case class GetAddressHistory(address: String) extends Request
case class TransactionHistoryItem(height: Int, tx_hash: ByteVector32)
case class GetAddressHistoryResponse(address: String, history: Seq[TransactionHistoryItem]) extends Response
case class GetScriptHashHistory(scriptHash: ByteVector32) extends Request
case class GetScriptHashHistoryResponse(scriptHash: ByteVector32, history: List[TransactionHistoryItem]) extends Response
case class AddressListUnspent(address: String) extends Request
case class UnspentItem(tx_hash: ByteVector32, tx_pos: Int, value: Long, height: Long) {
lazy val outPoint = OutPoint(tx_hash.reverse, tx_pos)
}
case class AddressListUnspentResponse(address: String, unspents: Seq[UnspentItem]) extends Response
case class ScriptHashListUnspent(scriptHash: ByteVector32) extends Request
case class ScriptHashListUnspentResponse(scriptHash: ByteVector32, unspents: Seq[UnspentItem]) extends Response
case class BroadcastTransaction(tx: Transaction) extends Request
case class BroadcastTransactionResponse(tx: Transaction, error: Option[Error]) extends Response
case class GetTransactionIdFromPosition(height: Int, tx_pos: Int, merkle: Boolean = false) extends Request
case class GetTransactionIdFromPositionResponse(txid: ByteVector32, height: Int, tx_pos: Int, merkle: Seq[ByteVector32]) extends Response
case class GetTransaction(txid: ByteVector32, override val context_opt: Option[Any] = None) extends Request
case class GetTransactionResponse(tx: Transaction, override val context_opt: Option[Any]) extends Response
case class GetHeader(height: Int) extends Request
case class GetHeaderResponse(height: Int, header: BlockHeader) extends Response
object GetHeaderResponse {
def apply(t: (Int, BlockHeader)) = new GetHeaderResponse(t._1, t._2)
}
case class GetHeaders(start_height: Int, count: Int, cp_height: Int = 0) extends Request
case class GetHeadersResponse(start_height: Int, headers: Seq[BlockHeader], max: Int) extends Response {
override def toString = s"GetHeadersResponse($start_height, ${headers.length}, ${headers.headOption}, ${headers.lastOption}, $max)"
}
case class GetMerkle(txid: ByteVector32, height: Int, override val context_opt: Option[Any] = None) extends Request
case class GetMerkleResponse(txid: ByteVector32, merkle: List[ByteVector32], block_height: Int, pos: Int, override val context_opt: Option[Any]) extends Response {
lazy val root: ByteVector32 = {
@tailrec
def loop(pos: Int, hashes: Seq[ByteVector32]): ByteVector32 = {
if (hashes.length == 1) hashes(0)
else {
val h = if (pos % 2 == 1) Crypto.hash256(hashes(1) ++ hashes(0)) else Crypto.hash256(hashes(0) ++ hashes(1))
loop(pos / 2, h +: hashes.drop(2))
}
}
loop(pos, txid.reverse +: merkle.map(b => b.reverse))
}
}
case class AddressSubscription(address: String, actor: ActorRef) extends Request
case class AddressSubscriptionResponse(address: String, status: String) extends Response
case class ScriptHashSubscription(scriptHash: ByteVector32, actor: ActorRef) extends Request
case class ScriptHashSubscriptionResponse(scriptHash: ByteVector32, status: String) extends Response
case class HeaderSubscription(actor: ActorRef) extends Request
case class HeaderSubscriptionResponse(height: Int, header: BlockHeader) extends Response
object HeaderSubscriptionResponse {
def apply(t: (Int, BlockHeader)) = new HeaderSubscriptionResponse(t._1, t._2)
}
case class Header(block_height: Long, version: Long, prev_block_hash: ByteVector32, merkle_root: ByteVector32, timestamp: Long, bits: Long, nonce: Long) {
def blockHeader = BlockHeader(version, prev_block_hash.reverse, merkle_root.reverse, timestamp, bits, nonce)
lazy val block_hash: ByteVector32 = blockHeader.hash
lazy val block_id: ByteVector32 = block_hash.reverse
}
object Header {
def makeHeader(height: Long, header: BlockHeader) = ElectrumClient.Header(height, header.version, header.hashPreviousBlock.reverse, header.hashMerkleRoot.reverse, header.time, header.bits, header.nonce)
val RegtestGenesisHeader = makeHeader(0, Block.RegtestGenesisBlock.header)
val TestnetGenesisHeader = makeHeader(0, Block.TestnetGenesisBlock.header)
val LivenetGenesisHeader = makeHeader(0, Block.LivenetGenesisBlock.header)
}
case class TransactionHistory(history: Seq[TransactionHistoryItem]) extends Response
case class AddressStatus(address: String, status: String) extends Response
case class ServerError(request: Request, error: Error) extends Response
sealed trait ElectrumEvent
case class ElectrumReady(height: Int, tip: BlockHeader, serverAddress: InetSocketAddress) extends ElectrumEvent
object ElectrumReady {
def apply(t: (Int, BlockHeader), serverAddress: InetSocketAddress) = new ElectrumReady(t._1 , t._2, serverAddress)
}
case object ElectrumDisconnected extends ElectrumEvent
sealed trait SSL
object SSL {
case object OFF extends SSL
case object STRICT extends SSL
case object LOOSE extends SSL
}
case object Close
// @formatter:on
def parseResponse(input: String): Either[Response, JsonRPCResponse] = {
implicit val formats = DefaultFormats
val json = JsonMethods.parse(new String(input))
json \ "method" match {
case JString(method) =>
// this is a jsonrpc request, i.e. a subscription response
val JArray(params) = json \ "params"
Left(((method, params): @unchecked) match {
case ("blockchain.headers.subscribe", header :: Nil) => HeaderSubscriptionResponse(parseBlockHeader(header))
case ("blockchain.address.subscribe", JString(address) :: JNull :: Nil) => AddressSubscriptionResponse(address, "")
case ("blockchain.address.subscribe", JString(address) :: JString(status) :: Nil) => AddressSubscriptionResponse(address, status)
case ("blockchain.scripthash.subscribe", JString(scriptHashHex) :: JNull :: Nil) => ScriptHashSubscriptionResponse(ByteVector32.fromValidHex(scriptHashHex), "")
case ("blockchain.scripthash.subscribe", JString(scriptHashHex) :: JString(status) :: Nil) => ScriptHashSubscriptionResponse(ByteVector32.fromValidHex(scriptHashHex), status)
})
case _ => Right(parseJsonRpcResponse(json))
}
}
def parseJsonRpcResponse(json: JValue): JsonRPCResponse = {
implicit val formats = DefaultFormats
val result = json \ "result"
val error = json \ "error" match {
case JNull => None
case JNothing => None
case other =>
val message = other \ "message" match {
case JString(value) => value
case _ => ""
}
val code = other \ " code" match {
case JInt(value) => value.intValue
case JLong(value) => value.intValue
case _ => 0
}
Some(Error(code, message))
}
val id = json \ "id" match {
case JString(value) => value
case JInt(value) => value.toString()
case JLong(value) => value.toString
case _ => ""
}
JsonRPCResponse(result, error, id)
}
def longField(jvalue: JValue, field: String): Long = (jvalue \ field: @unchecked) match {
case JLong(value) => value.longValue
case JInt(value) => value.longValue
}
def intField(jvalue: JValue, field: String): Int = (jvalue \ field: @unchecked) match {
case JLong(value) => value.intValue
case JInt(value) => value.intValue
}
def parseBlockHeader(json: JValue): (Int, BlockHeader) = {
val height = intField(json, "height")
val JString(hex) = json \ "hex"
(height, BlockHeader.read(hex))
}
def makeRequest(request: Request, reqId: String): JsonRPCRequest = request match {
case ServerVersion(clientName, protocolVersion) => JsonRPCRequest(id = reqId, method = "server.version", params = clientName :: protocolVersion :: Nil)
case Ping => JsonRPCRequest(id = reqId, method = "server.ping", params = Nil)
case GetAddressHistory(address) => JsonRPCRequest(id = reqId, method = "blockchain.address.get_history", params = address :: Nil)
case GetScriptHashHistory(scripthash) => JsonRPCRequest(id = reqId, method = "blockchain.scripthash.get_history", params = scripthash.toHex :: Nil)
case AddressListUnspent(address) => JsonRPCRequest(id = reqId, method = "blockchain.address.listunspent", params = address :: Nil)
case ScriptHashListUnspent(scripthash) => JsonRPCRequest(id = reqId, method = "blockchain.scripthash.listunspent", params = scripthash.toHex :: Nil)
case AddressSubscription(address, _) => JsonRPCRequest(id = reqId, method = "blockchain.address.subscribe", params = address :: Nil)
case ScriptHashSubscription(scriptHash, _) => JsonRPCRequest(id = reqId, method = "blockchain.scripthash.subscribe", params = scriptHash.toString() :: Nil)
case BroadcastTransaction(tx) => JsonRPCRequest(id = reqId, method = "blockchain.transaction.broadcast", params = Transaction.write(tx).toHex :: Nil)
case GetTransactionIdFromPosition(height, tx_pos, merkle) => JsonRPCRequest(id = reqId, method = "blockchain.transaction.id_from_pos", params = height :: tx_pos :: merkle :: Nil)
case GetTransaction(txid, _) => JsonRPCRequest(id = reqId, method = "blockchain.transaction.get", params = txid :: Nil)
case HeaderSubscription(_) => JsonRPCRequest(id = reqId, method = "blockchain.headers.subscribe", params = Nil)
case GetHeader(height) => JsonRPCRequest(id = reqId, method = "blockchain.block.header", params = height :: Nil)
case GetHeaders(start_height, count, _) => JsonRPCRequest(id = reqId, method = "blockchain.block.headers", params = start_height :: count :: Nil)
case GetMerkle(txid, height, _) => JsonRPCRequest(id = reqId, method = "blockchain.transaction.get_merkle", params = txid :: height :: Nil)
}
def parseJsonResponse(request: Request, json: JsonRPCResponse): Response = {
implicit val formats: Formats = DefaultFormats
json.error match {
case Some(error) => (request: @unchecked) match {
case BroadcastTransaction(tx) => BroadcastTransactionResponse(tx, Some(error)) // for this request type, error are considered a "normal" response
case _ => ServerError(request, error)
}
case None => (request: @unchecked) match {
case _: ServerVersion =>
val JArray(jitems) = json.result
val JString(clientName) = jitems(0)
val JString(protocolVersion) = jitems(1)
ServerVersionResponse(clientName, protocolVersion)
case Ping => PingResponse
case GetAddressHistory(address) =>
val JArray(jitems) = json.result
val items = jitems.map(jvalue => {
val JString(tx_hash) = jvalue \ "tx_hash"
val height = intField(jvalue, "height")
TransactionHistoryItem(height, ByteVector32.fromValidHex(tx_hash))
})
GetAddressHistoryResponse(address, items)
case GetScriptHashHistory(scripthash) =>
val JArray(jitems) = json.result
val items = jitems.map(jvalue => {
val JString(tx_hash) = jvalue \ "tx_hash"
val height = intField(jvalue, "height")
TransactionHistoryItem(height, ByteVector32.fromValidHex(tx_hash))
})
GetScriptHashHistoryResponse(scripthash, items)
case AddressListUnspent(address) =>
val JArray(jitems) = json.result
val items = jitems.map(jvalue => {
val JString(tx_hash) = jvalue \ "tx_hash"
val tx_pos = intField(jvalue, "tx_pos")
val height = intField(jvalue, "height")
val value = longField(jvalue, "value")
UnspentItem(ByteVector32.fromValidHex(tx_hash), tx_pos, value, height)
})
AddressListUnspentResponse(address, items)
case ScriptHashListUnspent(scripthash) =>
val JArray(jitems) = json.result
val items = jitems.map(jvalue => {
val JString(tx_hash) = jvalue \ "tx_hash"
val tx_pos = intField(jvalue, "tx_pos")
val height = longField(jvalue, "height")
val value = longField(jvalue, "value")
UnspentItem(ByteVector32.fromValidHex(tx_hash), tx_pos, value, height)
})
ScriptHashListUnspentResponse(scripthash, items)
case GetTransactionIdFromPosition(height, tx_pos, false) =>
val JString(tx_hash) = json.result
GetTransactionIdFromPositionResponse(ByteVector32.fromValidHex(tx_hash), height, tx_pos, Nil)
case GetTransactionIdFromPosition(height, tx_pos, true) =>
val JString(tx_hash) = json.result \ "tx_hash"
val JArray(hashes) = json.result \ "merkle"
val leaves = hashes collect { case JString(value) => ByteVector32.fromValidHex(value) }
GetTransactionIdFromPositionResponse(ByteVector32.fromValidHex(tx_hash), height, tx_pos, leaves)
case GetTransaction(_, context_opt) =>
val JString(hex) = json.result
GetTransactionResponse(Transaction.read(hex), context_opt)
case AddressSubscription(address, _) => json.result match {
case JString(status) => AddressSubscriptionResponse(address, status)
case _ => AddressSubscriptionResponse(address, "")
}
case ScriptHashSubscription(scriptHash, _) => json.result match {
case JString(status) => ScriptHashSubscriptionResponse(scriptHash, status)
case _ => ScriptHashSubscriptionResponse(scriptHash, "")
}
case BroadcastTransaction(tx) =>
val JString(message) = json.result
// if we got here, it means that the server's response does not contain an error and message should be our
// transaction id. However, it seems that at least on testnet some servers still use an older version of the
// Electrum protocol and return an error message in the result field
Try(ByteVector32.fromValidHex(message)) match {
case Success(txid) if txid == tx.txid => BroadcastTransactionResponse(tx, None)
case Success(txid) => BroadcastTransactionResponse(tx, Some(Error(1, s"response txid $txid does not match request txid ${tx.txid}")))
case Failure(_) => BroadcastTransactionResponse(tx, Some(Error(1, message)))
}
case GetHeader(height) =>
val JString(hex) = json.result
GetHeaderResponse(height, BlockHeader.read(hex))
case GetHeaders(start_height, _, _) =>
val max = intField(json.result, "max")
val JString(hex) = json.result \ "hex"
val bin = ByteVector.fromValidHex(hex).toArray
val blockHeaders = bin.grouped(80).map(BlockHeader.read).toList
GetHeadersResponse(start_height, blockHeaders, max)
case GetMerkle(txid, _, context_opt) =>
val JArray(hashes) = json.result \ "merkle"
val leaves = hashes collect { case JString(value) => ByteVector32.fromValidHex(value) }
val blockHeight = intField(json.result, "block_height")
val JInt(pos) = json.result \ "pos"
GetMerkleResponse(txid, leaves, blockHeight, pos.toInt, context_opt)
}
}
}
}

View file

@ -1,241 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum
import java.io.InputStream
import java.net.InetSocketAddress
import java.util.concurrent.atomic.AtomicLong
import akka.actor.{Actor, ActorRef, FSM, OneForOneStrategy, Props, SupervisorStrategy, Terminated}
import fr.acinq.bitcoin.BlockHeader
import fr.acinq.eclair.blockchain.CurrentBlockCount
import fr.acinq.eclair.blockchain.electrum.ElectrumClient.SSL
import fr.acinq.eclair.blockchain.electrum.ElectrumClientPool.ElectrumServerAddress
import fr.acinq.eclair.tor.Socks5ProxyParams
import org.json4s.JsonAST.{JObject, JString}
import org.json4s.jackson.JsonMethods
import scala.concurrent.ExecutionContext
import scala.concurrent.duration._
import scala.util.Random
class ElectrumClientPool(blockCount: AtomicLong, serverAddresses: Set[ElectrumServerAddress], socksProxy_opt: Option[Socks5ProxyParams] = None)(implicit val ec: ExecutionContext) extends Actor with FSM[ElectrumClientPool.State, ElectrumClientPool.Data] {
import ElectrumClientPool._
val statusListeners = collection.mutable.HashSet.empty[ActorRef]
val addresses = collection.mutable.Map.empty[ActorRef, InetSocketAddress]
// on startup, we attempt to connect to a number of electrum clients
// they will send us an `ElectrumReady` message when they're connected, or
// terminate if they cannot connect
(0 until Math.min(MAX_CONNECTION_COUNT, serverAddresses.size)) foreach (_ => self ! Connect)
log.debug("starting electrum pool with serverAddresses={}", serverAddresses)
// custom supervision strategy: always stop Electrum clients when there's a problem, we will automatically reconnect
// to another client
override def supervisorStrategy: SupervisorStrategy = OneForOneStrategy(loggingEnabled = true) {
case _ => SupervisorStrategy.stop
}
startWith(Disconnected, DisconnectedData)
when(Disconnected) {
case Event(ElectrumClient.ElectrumReady(height, tip, _), _) if addresses.contains(sender) =>
sender ! ElectrumClient.HeaderSubscription(self)
handleHeader(sender, height, tip, None)
case Event(ElectrumClient.AddStatusListener(listener), _) =>
statusListeners += listener
stay
case Event(Terminated(actor), _) =>
log.debug("lost connection to {}", addresses(actor))
addresses -= actor
context.system.scheduler.scheduleOnce(5 seconds, self, Connect)
stay
}
when(Connected) {
case Event(ElectrumClient.ElectrumReady(height, tip, _), d: ConnectedData) if addresses.contains(sender) =>
sender ! ElectrumClient.HeaderSubscription(self)
handleHeader(sender, height, tip, Some(d))
case Event(ElectrumClient.HeaderSubscriptionResponse(height, tip), d: ConnectedData) if addresses.contains(sender) =>
handleHeader(sender, height, tip, Some(d))
case Event(request: ElectrumClient.Request, ConnectedData(master, _)) =>
master forward request
stay
case Event(ElectrumClient.AddStatusListener(listener), d: ConnectedData) if addresses.contains(d.master) =>
statusListeners += listener
listener ! ElectrumClient.ElectrumReady(d.tips(d.master), addresses(d.master))
stay
case Event(Terminated(actor), d: ConnectedData) =>
val address = addresses(actor)
addresses -= actor
context.system.scheduler.scheduleOnce(5 seconds, self, Connect)
val tips1 = d.tips - actor
if (tips1.isEmpty) {
log.info("lost connection to {}, no active connections left", address)
goto(Disconnected) using DisconnectedData // no more connections
} else if (d.master != actor) {
log.debug("lost connection to {}, we still have our master server", address)
stay using d.copy(tips = tips1) // we don't care, this wasn't our master
} else {
log.info("lost connection to our master server {}", address)
// we choose next best candidate as master
val tips1 = d.tips - actor
val (bestClient, bestTip) = tips1.toSeq.maxBy(_._2._1)
handleHeader(bestClient, bestTip._1, bestTip._2, Some(d.copy(tips = tips1)))
}
}
whenUnhandled {
case Event(Connect, _) =>
pickAddress(serverAddresses, addresses.values.toSet) match {
case Some(ElectrumServerAddress(address, ssl)) =>
val resolved = new InetSocketAddress(address.getHostName, address.getPort)
val client = context.actorOf(Props(new ElectrumClient(resolved, ssl, socksProxy_opt)))
client ! ElectrumClient.AddStatusListener(self)
// we watch each electrum client, they will stop on disconnection
context watch client
addresses += (client -> address)
case None => () // no more servers available
}
stay
case Event(ElectrumClient.ElectrumDisconnected, _) =>
stay // ignored, we rely on Terminated messages to detect disconnections
}
onTransition {
case Connected -> Disconnected =>
statusListeners.foreach(_ ! ElectrumClient.ElectrumDisconnected)
context.system.eventStream.publish(ElectrumClient.ElectrumDisconnected)
}
initialize()
private def handleHeader(connection: ActorRef, height: Int, tip: BlockHeader, data: Option[ConnectedData]) = {
val remoteAddress = addresses(connection)
// we update our block count even if it doesn't come from our current master
updateBlockCount(height)
data match {
case None =>
// as soon as we have a connection to an electrum server, we select it as master
log.info("selecting master {} at {}", remoteAddress, tip)
statusListeners.foreach(_ ! ElectrumClient.ElectrumReady(height, tip, remoteAddress))
context.system.eventStream.publish(ElectrumClient.ElectrumReady(height, tip, remoteAddress))
goto(Connected) using ConnectedData(connection, Map(connection -> (height, tip)))
case Some(d) if connection != d.master && height >= d.blockHeight + 2L =>
// we only switch to a new master if there is a significant difference with our current master, because
// we don't want to switch to a new master every time a new block arrives (some servers will be notified before others)
// we check that the current connection is not our master because on regtest when you generate several blocks at once
// (and maybe on testnet in some pathological cases where there's a block every second) it may seen like our master
// skipped a block and is suddenly at height + 2
log.info("switching to master {} at {}", remoteAddress, tip)
// we've switched to a new master, treat this as a disconnection/reconnection
// so users (wallet, watcher, ...) will reset their subscriptions
statusListeners.foreach(_ ! ElectrumClient.ElectrumDisconnected)
context.system.eventStream.publish(ElectrumClient.ElectrumDisconnected)
statusListeners.foreach(_ ! ElectrumClient.ElectrumReady(height, tip, remoteAddress))
context.system.eventStream.publish(ElectrumClient.ElectrumReady(height, tip, remoteAddress))
goto(Connected) using d.copy(master = connection, tips = d.tips + (connection -> (height, tip)))
case Some(d) =>
log.debug("received tip {} from {} at {}", tip, remoteAddress, height)
stay using d.copy(tips = d.tips + (connection -> (height, tip)))
}
}
private def updateBlockCount(blockCount: Long): Unit = {
// when synchronizing we don't want to advertise previous blocks
if (this.blockCount.get() < blockCount) {
log.debug("current blockchain height={}", blockCount)
context.system.eventStream.publish(CurrentBlockCount(blockCount))
this.blockCount.set(blockCount)
}
}
}
object ElectrumClientPool {
val MAX_CONNECTION_COUNT = 3
case class ElectrumServerAddress(address: InetSocketAddress, ssl: SSL)
/**
* Parses default electrum server list and extract addresses
*
* @param stream
* @param sslEnabled select plaintext/ssl ports
* @return
*/
def readServerAddresses(stream: InputStream, sslEnabled: Boolean): Set[ElectrumServerAddress] = try {
val JObject(values) = JsonMethods.parse(stream)
val addresses = values
.toMap
.filterKeys(!_.endsWith(".onion"))
.flatMap {
case (name, fields) =>
if (sslEnabled) {
// We don't authenticate seed servers (SSL.LOOSE), because:
// - we don't know them so authentication doesn't really bring anything
// - most of them have self-signed SSL certificates so it would always fail
fields \ "s" match {
case JString(port) => Some(ElectrumServerAddress(InetSocketAddress.createUnresolved(name, port.toInt), SSL.LOOSE))
case _ => None
}
} else {
fields \ "t" match {
case JString(port) => Some(ElectrumServerAddress(InetSocketAddress.createUnresolved(name, port.toInt), SSL.OFF))
case _ => None
}
}
}
addresses.toSet
} finally {
stream.close()
}
/**
*
* @param serverAddresses all addresses to choose from
* @param usedAddresses current connections
* @return a random address that we're not connected to yet
*/
def pickAddress(serverAddresses: Set[ElectrumServerAddress], usedAddresses: Set[InetSocketAddress]): Option[ElectrumServerAddress] = {
Random.shuffle(serverAddresses.filterNot(a => usedAddresses.contains(a.address)).toSeq).headOption
}
// @formatter:off
sealed trait State
case object Disconnected extends State
case object Connected extends State
sealed trait Data
case object DisconnectedData extends Data
case class ConnectedData(master: ActorRef, tips: Map[ActorRef, (Int, BlockHeader)]) extends Data {
def blockHeight = tips.get(master).map(_._1).getOrElse(0)
}
case object Connect
// @formatter:on
}

View file

@ -1,100 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum
import akka.actor.{ActorRef, ActorSystem}
import akka.pattern.ask
import fr.acinq.bitcoin.{ByteVector32, Crypto, Satoshi, Script, Transaction, TxOut}
import fr.acinq.eclair.addressToPublicKeyScript
import fr.acinq.eclair.blockchain.electrum.ElectrumClient.BroadcastTransaction
import fr.acinq.eclair.blockchain.electrum.ElectrumWallet._
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.blockchain.{EclairWallet, MakeFundingTxResponse, OnChainBalance}
import grizzled.slf4j.Logging
import scodec.bits.ByteVector
import scala.concurrent.{ExecutionContext, Future}
class ElectrumEclairWallet(val wallet: ActorRef, chainHash: ByteVector32)(implicit system: ActorSystem, ec: ExecutionContext, timeout: akka.util.Timeout) extends EclairWallet with Logging {
override def getBalance: Future[OnChainBalance] = (wallet ? GetBalance).mapTo[GetBalanceResponse].map(balance => OnChainBalance(balance.confirmed, balance.unconfirmed))
override def getReceiveAddress: Future[String] = (wallet ? GetCurrentReceiveAddress).mapTo[GetCurrentReceiveAddressResponse].map(_.address)
override def getReceivePubkey(receiveAddress: Option[String] = None): Future[Crypto.PublicKey] = Future.failed(new RuntimeException("Not implemented"))
def getXpub: Future[GetXpubResponse] = (wallet ? GetXpub).mapTo[GetXpubResponse]
override def makeFundingTx(pubkeyScript: ByteVector, amount: Satoshi, feeRatePerKw: FeeratePerKw): Future[MakeFundingTxResponse] = {
val tx = Transaction(version = 2, txIn = Nil, txOut = TxOut(amount, pubkeyScript) :: Nil, lockTime = 0)
(wallet ? CompleteTransaction(tx, feeRatePerKw)).mapTo[CompleteTransactionResponse].map {
case CompleteTransactionResponse(tx1, fee1, None) => MakeFundingTxResponse(tx1, 0, fee1)
case CompleteTransactionResponse(_, _, Some(error)) => throw error
}
}
override def commit(tx: Transaction): Future[Boolean] =
(wallet ? BroadcastTransaction(tx)) flatMap {
case ElectrumClient.BroadcastTransactionResponse(tx, None) =>
//tx broadcast successfully: commit tx
wallet ? CommitTransaction(tx)
case ElectrumClient.BroadcastTransactionResponse(tx, Some(error)) if error.message.contains("transaction already in block chain") =>
// tx was already in the blockchain, that's weird but it is OK
wallet ? CommitTransaction(tx)
case ElectrumClient.BroadcastTransactionResponse(_, Some(error)) =>
//tx broadcast failed: cancel tx
logger.error(s"cannot broadcast tx ${tx.txid}: $error")
wallet ? CancelTransaction(tx)
case ElectrumClient.ServerError(ElectrumClient.BroadcastTransaction(tx), error) =>
//tx broadcast failed: cancel tx
logger.error(s"cannot broadcast tx ${tx.txid}: $error")
wallet ? CancelTransaction(tx)
} map {
case CommitTransactionResponse(_) => true
case CancelTransactionResponse(_) => false
}
def sendPayment(amount: Satoshi, address: String, feeRatePerKw: FeeratePerKw): Future[String] = {
val publicKeyScript = Script.write(addressToPublicKeyScript(address, chainHash))
val tx = Transaction(version = 2, txIn = Nil, txOut = TxOut(amount, publicKeyScript) :: Nil, lockTime = 0)
(wallet ? CompleteTransaction(tx, feeRatePerKw))
.mapTo[CompleteTransactionResponse]
.flatMap {
case CompleteTransactionResponse(tx, _, None) => commit(tx).map {
case true => tx.txid.toString()
case false => throw new RuntimeException(s"could not commit tx=$tx")
}
case CompleteTransactionResponse(_, _, Some(error)) => throw error
}
}
def sendAll(address: String, feeRatePerKw: FeeratePerKw): Future[(Transaction, Satoshi)] = {
val publicKeyScript = Script.write(addressToPublicKeyScript(address, chainHash))
(wallet ? SendAll(publicKeyScript, feeRatePerKw))
.mapTo[SendAllResponse]
.map {
case SendAllResponse(tx, fee) => (tx, fee)
}
}
override def rollback(tx: Transaction): Future[Boolean] = (wallet ? CancelTransaction(tx)).map(_ => true)
override def doubleSpent(tx: Transaction): Future[Boolean] = {
(wallet ? IsDoubleSpent(tx)).mapTo[IsDoubleSpentResponse].map(_.isDoubleSpent)
}
}

View file

@ -1,246 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum
import akka.actor.{Actor, ActorLogging, ActorRef, Stash, Terminated}
import fr.acinq.bitcoin.{BlockHeader, ByteVector32, SatoshiLong, Script, Transaction, TxIn, TxOut}
import fr.acinq.eclair.blockchain._
import fr.acinq.eclair.blockchain.electrum.ElectrumClient.computeScriptHash
import fr.acinq.eclair.channel.{BITCOIN_FUNDING_DEPTHOK, BITCOIN_PARENT_TX_CONFIRMED}
import fr.acinq.eclair.transactions.Scripts
import fr.acinq.eclair.{ShortChannelId, TxCoordinates}
import java.util.concurrent.atomic.AtomicLong
import scala.collection.immutable.{Queue, SortedMap}
class ElectrumWatcher(blockCount: AtomicLong, client: ActorRef) extends Actor with Stash with ActorLogging {
client ! ElectrumClient.AddStatusListener(self)
override def unhandled(message: Any): Unit = message match {
case ValidateRequest(c) =>
log.info("blindly validating channel={}", c)
val pubkeyScript = Script.write(Script.pay2wsh(Scripts.multiSig2of2(c.bitcoinKey1, c.bitcoinKey2)))
val TxCoordinates(_, _, outputIndex) = ShortChannelId.coordinates(c.shortChannelId)
val fakeFundingTx = Transaction(
version = 2,
txIn = Seq.empty[TxIn],
txOut = List.fill(outputIndex + 1)(TxOut(0 sat, pubkeyScript)), // quick and dirty way to be sure that the outputIndex'th output is of the expected format
lockTime = 0)
sender ! ValidateResult(c, Right((fakeFundingTx, UtxoStatus.Unspent)))
case _ => log.warning("unhandled message {}", message)
}
def receive: Receive = disconnected(Set.empty, Queue.empty, SortedMap.empty, Queue.empty)
def disconnected(watches: Set[Watch], publishQueue: Queue[PublishAsap], block2tx: SortedMap[Long, Seq[Transaction]], getTxQueue: Queue[(GetTxWithMeta, ActorRef)]): Receive = {
case ElectrumClient.ElectrumReady(_, _, _) =>
client ! ElectrumClient.HeaderSubscription(self)
case ElectrumClient.HeaderSubscriptionResponse(height, header) =>
watches.foreach(self ! _)
publishQueue.foreach(self ! _)
getTxQueue.foreach { case (msg, origin) => self.tell(msg, origin) }
context become running(height, header, Set(), Map(), block2tx, Queue.empty)
case watch: Watch => context become disconnected(watches + watch, publishQueue, block2tx, getTxQueue)
case publish: PublishAsap => context become disconnected(watches, publishQueue :+ publish, block2tx, getTxQueue)
case getTx: GetTxWithMeta => context become disconnected(watches, publishQueue, block2tx, getTxQueue :+ (getTx, sender))
}
def running(height: Int, tip: BlockHeader, watches: Set[Watch], scriptHashStatus: Map[ByteVector32, String], block2tx: SortedMap[Long, Seq[Transaction]], sent: Queue[Transaction]): Receive = {
case ElectrumClient.HeaderSubscriptionResponse(_, newtip) if tip == newtip => ()
case ElectrumClient.HeaderSubscriptionResponse(newheight, newtip) =>
log.info(s"new tip: ${newtip.blockId} $height")
watches collect {
case watch: WatchConfirmed =>
val scriptHash = computeScriptHash(watch.publicKeyScript)
client ! ElectrumClient.GetScriptHashHistory(scriptHash)
}
val toPublish = block2tx.filterKeys(_ <= newheight)
toPublish.values.flatten.foreach(publish)
context become running(newheight, newtip, watches, scriptHashStatus, block2tx -- toPublish.keys, sent ++ toPublish.values.flatten)
case watch: Watch if watches.contains(watch) => ()
case watch@WatchSpent(_, txid, outputIndex, publicKeyScript, _, _) =>
val scriptHash = computeScriptHash(publicKeyScript)
log.info(s"added watch-spent on output=$txid:$outputIndex scriptHash=$scriptHash")
client ! ElectrumClient.ScriptHashSubscription(scriptHash, self)
context.watch(watch.replyTo)
context become running(height, tip, watches + watch, scriptHashStatus, block2tx, sent)
case watch@WatchSpentBasic(_, txid, outputIndex, publicKeyScript, _) =>
val scriptHash = computeScriptHash(publicKeyScript)
log.info(s"added watch-spent-basic on output=$txid:$outputIndex scriptHash=$scriptHash")
client ! ElectrumClient.ScriptHashSubscription(scriptHash, self)
context.watch(watch.replyTo)
context become running(height, tip, watches + watch, scriptHashStatus, block2tx, sent)
case watch@WatchConfirmed(_, txid, publicKeyScript, _, _) =>
val scriptHash = computeScriptHash(publicKeyScript)
log.info(s"added watch-confirmed on txid=$txid scriptHash=$scriptHash")
client ! ElectrumClient.ScriptHashSubscription(scriptHash, self)
context.watch(watch.replyTo)
context become running(height, tip, watches + watch, scriptHashStatus, block2tx, sent)
case Terminated(actor) =>
val watches1 = watches.filterNot(_.replyTo == actor)
context become running(height, tip, watches1, scriptHashStatus, block2tx, sent)
case ElectrumClient.ScriptHashSubscriptionResponse(scriptHash, status) =>
scriptHashStatus.get(scriptHash) match {
case Some(s) if s == status => log.debug(s"already have status=$status for scriptHash=$scriptHash")
case _ if status.isEmpty => log.info(s"empty status for scriptHash=$scriptHash")
case _ =>
log.info(s"new status=$status for scriptHash=$scriptHash")
client ! ElectrumClient.GetScriptHashHistory(scriptHash)
}
context become running(height, tip, watches, scriptHashStatus + (scriptHash -> status), block2tx, sent)
case ElectrumClient.GetScriptHashHistoryResponse(_, history) =>
// we retrieve the transaction before checking watches
// NB: height=-1 means that the tx is unconfirmed and at least one of its inputs is also unconfirmed. we need to take them into consideration if we want to handle unconfirmed txes (which is the case for turbo channels)
history.filter(_.height >= -1).foreach { item => client ! ElectrumClient.GetTransaction(item.tx_hash, Some(item)) }
case ElectrumClient.GetTransactionResponse(tx, Some(item: ElectrumClient.TransactionHistoryItem)) =>
// this is for WatchSpent/WatchSpentBasic
val watchSpentTriggered = tx.txIn.map(_.outPoint).flatMap(outPoint => watches.collect {
case WatchSpent(channel, txid, pos, _, event, _) if txid == outPoint.txid && pos == outPoint.index.toInt =>
log.info(s"output $txid:$pos spent by transaction ${tx.txid}")
channel ! WatchEventSpent(event, tx)
// NB: WatchSpent are permanent because we need to detect multiple spending of the funding tx
// They are never cleaned up but it is not a big deal for now (1 channel == 1 watch)
None
case w@WatchSpentBasic(channel, txid, pos, _, event) if txid == outPoint.txid && pos == outPoint.index.toInt =>
log.info(s"output $txid:$pos spent by transaction ${tx.txid}")
channel ! WatchEventSpentBasic(event)
Some(w)
}).flatten
// this is for WatchConfirmed
val watchConfirmedTriggered = watches.collect {
case w@WatchConfirmed(channel, txid, _, minDepth, BITCOIN_FUNDING_DEPTHOK) if txid == tx.txid && minDepth == 0 =>
// special case for mempool watches (min depth = 0)
val (dummyHeight, dummyTxIndex) = ElectrumWatcher.makeDummyShortChannelId(txid)
channel ! WatchEventConfirmed(BITCOIN_FUNDING_DEPTHOK, dummyHeight, dummyTxIndex, tx)
Some(w)
case WatchConfirmed(_, txid, _, minDepth, _) if txid == tx.txid && minDepth > 0 && item.height > 0 =>
// min depth > 0 here
val txheight = item.height
val confirmations = height - txheight + 1
log.info(s"txid=$txid was confirmed at height=$txheight and now has confirmations=$confirmations (currentHeight=$height)")
if (confirmations >= minDepth) {
// we need to get the tx position in the block
client ! ElectrumClient.GetMerkle(txid, txheight, Some(tx))
}
None
}.flatten
context become running(height, tip, watches -- watchSpentTriggered -- watchConfirmedTriggered, scriptHashStatus, block2tx, sent)
case ElectrumClient.GetMerkleResponse(tx_hash, _, txheight, pos, Some(tx: Transaction)) =>
val confirmations = height - txheight + 1
val triggered = watches.collect {
case w@WatchConfirmed(channel, txid, _, minDepth, event) if txid == tx_hash && confirmations >= minDepth =>
log.info(s"txid=$txid had confirmations=$confirmations in block=$txheight pos=$pos")
channel ! WatchEventConfirmed(event, txheight.toInt, pos, tx)
w
}
context become running(height, tip, watches -- triggered, scriptHashStatus, block2tx, sent)
case GetTxWithMeta(txid) => client ! ElectrumClient.GetTransaction(txid, Some(sender))
case ElectrumClient.GetTransactionResponse(tx, Some(origin: ActorRef)) => origin ! GetTxWithMetaResponse(tx.txid, Some(tx), tip.time)
case ElectrumClient.ServerError(ElectrumClient.GetTransaction(txid, Some(origin: ActorRef)), _) => origin ! GetTxWithMetaResponse(txid, None, tip.time)
case PublishAsap(tx, _) =>
val blockCount = this.blockCount.get()
val cltvTimeout = Scripts.cltvTimeout(tx)
val csvTimeouts = Scripts.csvTimeouts(tx)
if (csvTimeouts.nonEmpty) {
// watcher supports txs with multiple csv-delayed inputs: we watch all delayed parents and try to publish every
// time a parent's relative delays are satisfied, so we will eventually succeed.
csvTimeouts.foreach { case (parentTxId, csvTimeout) =>
log.info(s"txid=${tx.txid} has a relative timeout of $csvTimeout blocks, watching parentTxId=$parentTxId tx={}", tx)
val parentPublicKeyScript = WatchConfirmed.extractPublicKeyScript(tx.txIn.find(_.outPoint.txid == parentTxId).get.witness)
self ! WatchConfirmed(self, parentTxId, parentPublicKeyScript, minDepth = csvTimeout, BITCOIN_PARENT_TX_CONFIRMED(PublishAsap(tx, PublishStrategy.JustPublish)))
}
} else if (cltvTimeout > blockCount) {
log.info(s"delaying publication of txid=${tx.txid} until block=$cltvTimeout (curblock=$blockCount)")
val block2tx1 = block2tx.updated(cltvTimeout, block2tx.getOrElse(cltvTimeout, Seq.empty[Transaction]) :+ tx)
context become running(height, tip, watches, scriptHashStatus, block2tx1, sent)
} else {
publish(tx)
context become running(height, tip, watches, scriptHashStatus, block2tx, sent :+ tx)
}
case WatchEventConfirmed(BITCOIN_PARENT_TX_CONFIRMED(PublishAsap(tx, _)), _, _, _) =>
log.info(s"parent tx of txid=${tx.txid} has been confirmed")
val blockCount = this.blockCount.get()
val cltvTimeout = Scripts.cltvTimeout(tx)
if (cltvTimeout > blockCount) {
log.info(s"delaying publication of txid=${tx.txid} until block=$cltvTimeout (curblock=$blockCount)")
val block2tx1 = block2tx.updated(cltvTimeout, block2tx.getOrElse(cltvTimeout, Seq.empty[Transaction]) :+ tx)
context become running(height, tip, watches, scriptHashStatus, block2tx1, sent)
} else {
publish(tx)
context become running(height, tip, watches, scriptHashStatus, block2tx, sent :+ tx)
}
case ElectrumClient.BroadcastTransactionResponse(tx, error_opt) =>
error_opt match {
case None => log.info(s"broadcast succeeded for txid=${tx.txid} tx={}", tx)
case Some(error) if error.message.contains("transaction already in block chain") => log.info(s"broadcast ignored for txid=${tx.txid} tx={} (tx was already in blockchain)", tx)
case Some(error) => log.error(s"broadcast failed for txid=${tx.txid} tx=$tx with error=$error")
}
context become running(height, tip, watches, scriptHashStatus, block2tx, sent diff Seq(tx))
case ElectrumClient.ElectrumDisconnected =>
// we remember watches and keep track of tx that have not yet been published
// we also re-send the txs that we previously sent but hadn't yet received the confirmation
context become disconnected(watches, sent.map(tx => PublishAsap(tx, PublishStrategy.JustPublish)), block2tx, Queue.empty)
}
def publish(tx: Transaction): Unit = {
log.info("publishing tx={}", tx)
client ! ElectrumClient.BroadcastTransaction(tx)
}
}
object ElectrumWatcher {
/**
* @param txid funding transaction id
* @return a (blockHeight, txIndex) tuple that is extracted from the input source
* This is used to create unique "dummy" short channel ids for zero-conf channels
*/
def makeDummyShortChannelId(txid: ByteVector32): (Int, Int) = {
// we use a height of 0
// - to make sure that the tx will be marked as "confirmed"
// - to easily identify scids linked to 0-conf channels
//
// this gives us a probability of collisions of 0.1% for 5 0-conf channels and 1% for 20
// collisions mean that users may temporarily see incorrect numbers for their 0-conf channels (until they've been confirmed)
// if this ever becomes a problem we could just extract some bits for our dummy height instead of just returning 0
val height = 0
val txIndex = txid.bits.sliceToInt(0, 16, signed = false)
(height, txIndex)
}
}

View file

@ -1,41 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum.db
import fr.acinq.bitcoin.{BlockHeader, ByteVector32}
import fr.acinq.eclair.blockchain.electrum.ElectrumWallet.PersistentData
trait HeaderDb {
def addHeader(height: Int, header: BlockHeader): Unit
def addHeaders(startHeight: Int, headers: Seq[BlockHeader]): Unit
def getHeader(height: Int): Option[BlockHeader]
// used only in unit tests
def getHeader(blockHash: ByteVector32): Option[(Int, BlockHeader)]
def getHeaders(startHeight: Int, maxCount: Option[Int]): Seq[BlockHeader]
def getTip: Option[(Int, BlockHeader)]
}
trait WalletDb extends HeaderDb {
def persist(data: PersistentData): Unit
def readPersistentData(): Option[PersistentData]
}

View file

@ -1,218 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum.db.sqlite
import fr.acinq.bitcoin.{BlockHeader, ByteVector32, Transaction}
import fr.acinq.eclair.blockchain.electrum.ElectrumClient.{GetMerkleResponse, TransactionHistoryItem}
import fr.acinq.eclair.blockchain.electrum.ElectrumWallet.PersistentData
import fr.acinq.eclair.blockchain.electrum.db.WalletDb
import fr.acinq.eclair.blockchain.electrum.{ElectrumClient, ElectrumWallet}
import fr.acinq.eclair.db.sqlite.SqliteUtils
import java.sql.Connection
import scala.collection.immutable.Queue
class SqliteWalletDb(sqlite: Connection) extends WalletDb {
import SqliteUtils._
using(sqlite.createStatement()) { statement =>
statement.executeUpdate("CREATE TABLE IF NOT EXISTS headers (height INTEGER NOT NULL PRIMARY KEY, block_hash BLOB NOT NULL, header BLOB NOT NULL)")
statement.executeUpdate("CREATE TABLE IF NOT EXISTS wallet (data BLOB)")
}
override def addHeader(height: Int, header: BlockHeader): Unit = {
using(sqlite.prepareStatement("INSERT OR IGNORE INTO headers VALUES (?, ?, ?)")) { statement =>
statement.setInt(1, height)
statement.setBytes(2, header.hash.toArray)
statement.setBytes(3, BlockHeader.write(header).toArray)
statement.executeUpdate()
}
}
override def addHeaders(startHeight: Int, headers: Seq[BlockHeader]): Unit = {
using(sqlite.prepareStatement("INSERT OR IGNORE INTO headers VALUES (?, ?, ?)"), inTransaction = true) { statement =>
var height = startHeight
headers.foreach(header => {
statement.setInt(1, height)
statement.setBytes(2, header.hash.toArray)
statement.setBytes(3, BlockHeader.write(header).toArray)
statement.addBatch()
height = height + 1
})
val result = statement.executeBatch()
}
}
override def getHeader(height: Int): Option[BlockHeader] = {
using(sqlite.prepareStatement("SELECT header FROM headers WHERE height = ?")) { statement =>
statement.setInt(1, height)
val rs = statement.executeQuery()
if (rs.next()) {
Some(BlockHeader.read(rs.getBytes("header")))
} else {
None
}
}
}
override def getHeader(blockHash: ByteVector32): Option[(Int, BlockHeader)] = {
using(sqlite.prepareStatement("SELECT height, header FROM headers WHERE block_hash = ?")) { statement =>
statement.setBytes(1, blockHash.toArray)
val rs = statement.executeQuery()
if (rs.next()) {
Some((rs.getInt("height"), BlockHeader.read(rs.getBytes("header"))))
} else {
None
}
}
}
override def getHeaders(startHeight: Int, maxCount: Option[Int]): Seq[BlockHeader] = {
val query = "SELECT height, header FROM headers WHERE height >= ? ORDER BY height " + maxCount.map(m => s" LIMIT $m").getOrElse("")
using(sqlite.prepareStatement(query)) { statement =>
statement.setInt(1, startHeight)
val rs = statement.executeQuery()
var q: Queue[BlockHeader] = Queue()
while (rs.next()) {
q = q :+ BlockHeader.read(rs.getBytes("header"))
}
q
}
}
override def getTip: Option[(Int, BlockHeader)] = {
using(sqlite.prepareStatement("SELECT t.height, t.header FROM headers t INNER JOIN (SELECT MAX(height) AS maxHeight FROM headers) q ON t.height = q.maxHeight")) { statement =>
val rs = statement.executeQuery()
if (rs.next()) {
Some((rs.getInt("height"), BlockHeader.read(rs.getBytes("header"))))
} else {
None
}
}
}
override def persist(data: ElectrumWallet.PersistentData): Unit = {
val bin = SqliteWalletDb.serialize(data)
using(sqlite.prepareStatement("UPDATE wallet SET data=(?)")) { update =>
update.setBytes(1, bin)
if (update.executeUpdate() == 0) {
using(sqlite.prepareStatement("INSERT INTO wallet VALUES (?)")) { statement =>
statement.setBytes(1, bin)
statement.executeUpdate()
}
}
}
}
override def readPersistentData(): Option[ElectrumWallet.PersistentData] = {
using(sqlite.prepareStatement("SELECT data FROM wallet")) { statement =>
val rs = statement.executeQuery()
if (rs.next()) {
Option(rs.getBytes(1)).map(bin => SqliteWalletDb.deserializePersistentData(bin))
} else {
None
}
}
}
}
object SqliteWalletDb {
import fr.acinq.eclair.wire.protocol.CommonCodecs._
import scodec.Codec
import scodec.bits.BitVector
import scodec.codecs._
val proofCodec: Codec[GetMerkleResponse] = (
("txid" | bytes32) ::
("merkle" | listOfN(uint16, bytes32)) ::
("block_height" | uint24) ::
("pos" | uint24) ::
("context_opt" | provide(Option.empty[Any]))).as[GetMerkleResponse]
def serializeMerkleProof(proof: GetMerkleResponse): Array[Byte] = proofCodec.encode(proof).require.toByteArray
def deserializeMerkleProof(bin: Array[Byte]): GetMerkleResponse = proofCodec.decode(BitVector(bin)).require.value
val statusListCodec: Codec[List[(ByteVector32, String)]] = listOfN(uint16, bytes32 ~ cstring)
val statusCodec: Codec[Map[ByteVector32, String]] = Codec[Map[ByteVector32, String]](
(map: Map[ByteVector32, String]) => statusListCodec.encode(map.toList),
(wire: BitVector) => statusListCodec.decode(wire).map(_.map(_.toMap))
)
val heightsListCodec: Codec[List[(ByteVector32, Int)]] = listOfN(uint16, bytes32 ~ int32)
val heightsCodec: Codec[Map[ByteVector32, Int]] = Codec[Map[ByteVector32, Int]](
(map: Map[ByteVector32, Int]) => heightsListCodec.encode(map.toList),
(wire: BitVector) => heightsListCodec.decode(wire).map(_.map(_.toMap))
)
val txCodec: Codec[Transaction] = lengthDelimited(bytes.xmap(d => Transaction.read(d.toArray), d => Transaction.write(d)))
val transactionListCodec: Codec[List[(ByteVector32, Transaction)]] = listOfN(uint16, bytes32 ~ txCodec)
val transactionsCodec: Codec[Map[ByteVector32, Transaction]] = Codec[Map[ByteVector32, Transaction]](
(map: Map[ByteVector32, Transaction]) => transactionListCodec.encode(map.toList),
(wire: BitVector) => transactionListCodec.decode(wire).map(_.map(_.toMap))
)
val transactionHistoryItemCodec: Codec[ElectrumClient.TransactionHistoryItem] = (
("height" | int32) :: ("tx_hash" | bytes32)).as[ElectrumClient.TransactionHistoryItem]
val seqOfTransactionHistoryItemCodec: Codec[List[TransactionHistoryItem]] = listOfN[TransactionHistoryItem](uint16, transactionHistoryItemCodec)
val historyListCodec: Codec[List[(ByteVector32, List[ElectrumClient.TransactionHistoryItem])]] =
listOfN[(ByteVector32, List[ElectrumClient.TransactionHistoryItem])](uint16, bytes32 ~ seqOfTransactionHistoryItemCodec)
val historyCodec: Codec[Map[ByteVector32, List[ElectrumClient.TransactionHistoryItem]]] = Codec[Map[ByteVector32, List[ElectrumClient.TransactionHistoryItem]]](
(map: Map[ByteVector32, List[ElectrumClient.TransactionHistoryItem]]) => historyListCodec.encode(map.toList),
(wire: BitVector) => historyListCodec.decode(wire).map(_.map(_.toMap))
)
val proofsListCodec: Codec[List[(ByteVector32, GetMerkleResponse)]] = listOfN(uint16, bytes32 ~ proofCodec)
val proofsCodec: Codec[Map[ByteVector32, GetMerkleResponse]] = Codec[Map[ByteVector32, GetMerkleResponse]](
(map: Map[ByteVector32, GetMerkleResponse]) => proofsListCodec.encode(map.toList),
(wire: BitVector) => proofsListCodec.decode(wire).map(_.map(_.toMap))
)
/**
* change this value
* -if the new codec is incompatible with the old one
* - OR if you want to force a full sync from Electrum servers
*/
val version = 0x0000
val persistentDataCodec: Codec[PersistentData] = (
("version" | constant(BitVector.fromInt(version))) ::
("accountKeysCount" | int32) ::
("changeKeysCount" | int32) ::
("status" | statusCodec) ::
("transactions" | transactionsCodec) ::
("heights" | heightsCodec) ::
("history" | historyCodec) ::
("proofs" | proofsCodec) ::
("pendingTransactions" | listOfN(uint16, txCodec)) ::
("locks" | provide(Set.empty[Transaction]))).as[PersistentData]
def serialize(data: PersistentData): Array[Byte] = persistentDataCodec.encode(data).require.toByteArray
def deserializePersistentData(bin: Array[Byte]): PersistentData = persistentDataCodec.decode(BitVector(bin)).require.value
}

View file

@ -1,82 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.fee
import com.softwaremill.sttp._
import com.softwaremill.sttp.json4s._
import fr.acinq.bitcoin.{Block, ByteVector32, Satoshi}
import org.json4s.DefaultFormats
import org.json4s.JsonAST.{JInt, JValue}
import org.json4s.jackson.Serialization
import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Future}
class BitgoFeeProvider(chainHash: ByteVector32, readTimeOut: Duration)(implicit http: SttpBackend[Future, Nothing], ec: ExecutionContext) extends FeeProvider {
import BitgoFeeProvider._
implicit val formats = DefaultFormats
implicit val serialization = Serialization
val uri = chainHash match {
case Block.LivenetGenesisBlock.hash => uri"https://www.bitgo.com/api/v2/btc/tx/fee"
case _ => uri"https://test.bitgo.com/api/v2/tbtc/tx/fee"
}
override def getFeerates: Future[FeeratesPerKB] =
for {
res <- sttp.readTimeout(readTimeOut).get(uri)
.response(asJson[JValue])
.send()
feeRanges = parseFeeRanges(res.unsafeBody)
} yield extractFeerates(feeRanges)
}
object BitgoFeeProvider {
case class BlockTarget(block: Int, fee: Long)
def parseFeeRanges(json: JValue): Seq[BlockTarget] = {
val blockTargets = json \ "feeByBlockTarget"
blockTargets.foldField(Seq.empty[BlockTarget]) {
// BitGo returns estimates in Satoshi/KB, which is what we want
case (list, (strBlockTarget, JInt(feePerKB))) => list :+ BlockTarget(strBlockTarget.toInt, feePerKB.longValue)
}
}
def extractFeerate(feeRanges: Seq[BlockTarget], maxBlockDelay: Int): FeeratePerKB = {
// first we keep only fee ranges with a max block delay below the limit
val belowLimit = feeRanges.filter(_.block <= maxBlockDelay)
// out of all the remaining fee ranges, we select the one with the minimum higher bound
FeeratePerKB(Satoshi(belowLimit.map(_.fee).min))
}
def extractFeerates(feeRanges: Seq[BlockTarget]): FeeratesPerKB =
FeeratesPerKB(
mempoolMinFee = extractFeerate(feeRanges, 1008),
block_1 = extractFeerate(feeRanges, 1),
blocks_2 = extractFeerate(feeRanges, 2),
blocks_6 = extractFeerate(feeRanges, 6),
blocks_12 = extractFeerate(feeRanges, 12),
blocks_36 = extractFeerate(feeRanges, 36),
blocks_72 = extractFeerate(feeRanges, 72),
blocks_144 = extractFeerate(feeRanges, 144),
blocks_1008 = extractFeerate(feeRanges, 1008))
}

View file

@ -1,87 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.fee
import com.softwaremill.sttp._
import com.softwaremill.sttp.json4s._
import fr.acinq.bitcoin.Satoshi
import org.json4s.DefaultFormats
import org.json4s.JsonAST.{JArray, JInt, JValue}
import org.json4s.jackson.Serialization
import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Future}
/**
* Created by PM on 16/11/2017.
*/
class EarnDotComFeeProvider(readTimeOut: Duration)(implicit http: SttpBackend[Future, Nothing], ec: ExecutionContext) extends FeeProvider {
import EarnDotComFeeProvider._
implicit val formats = DefaultFormats
implicit val serialization = Serialization
val uri = uri"https://bitcoinfees.earn.com/api/v1/fees/list"
override def getFeerates: Future[FeeratesPerKB] =
for {
json <- sttp.readTimeout(readTimeOut).get(uri)
.response(asJson[JValue])
.send()
feeRanges = parseFeeRanges(json.unsafeBody)
} yield extractFeerates(feeRanges)
}
object EarnDotComFeeProvider {
case class FeeRange(minFee: Long, maxFee: Long, memCount: Long, minDelay: Long, maxDelay: Long)
def parseFeeRanges(json: JValue): Seq[FeeRange] = {
val JArray(items) = json \ "fees"
items.map(item => {
val JInt(minFee) = item \ "minFee"
val JInt(maxFee) = item \ "maxFee"
val JInt(memCount) = item \ "memCount"
val JInt(minDelay) = item \ "minDelay"
val JInt(maxDelay) = item \ "maxDelay"
// earn.com returns fees in Satoshi/byte and we want Satoshi/KiloByte
FeeRange(minFee = 1000 * minFee.toLong, maxFee = 1000 * maxFee.toLong, memCount = memCount.toLong, minDelay = minDelay.toLong, maxDelay = maxDelay.toLong)
})
}
def extractFeerate(feeRanges: Seq[FeeRange], maxBlockDelay: Int): FeeratePerKB = {
// first we keep only fee ranges with a max block delay below the limit
val belowLimit = feeRanges.filter(_.maxDelay <= maxBlockDelay)
// out of all the remaining fee ranges, we select the one with the minimum higher bound and make sure it is > 0
FeeratePerKB(Satoshi(Math.max(belowLimit.minBy(_.maxFee).maxFee, 1)))
}
def extractFeerates(feeRanges: Seq[FeeRange]): FeeratesPerKB =
FeeratesPerKB(
mempoolMinFee = extractFeerate(feeRanges, 1008),
block_1 = extractFeerate(feeRanges, 1),
blocks_2 = extractFeerate(feeRanges, 2),
blocks_6 = extractFeerate(feeRanges, 6),
blocks_12 = extractFeerate(feeRanges, 12),
blocks_36 = extractFeerate(feeRanges, 36),
blocks_72 = extractFeerate(feeRanges, 72),
blocks_144 = extractFeerate(feeRanges, 144),
blocks_1008 = extractFeerate(feeRanges, 1008))
}

View file

@ -475,7 +475,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
log.info(s"waiting for them to publish the funding tx for channelId=$channelId fundingTxid=${commitInput.outPoint.txid}")
val fundingMinDepth = Helpers.minDepthForFunding(nodeParams, fundingAmount)
watchFundingTx(commitments)
blockchain ! WatchConfirmed(self, commitInput.outPoint.txid, commitInput.txOut.publicKeyScript, fundingMinDepth, BITCOIN_FUNDING_DEPTHOK)
blockchain ! WatchConfirmed(self, commitInput.outPoint.txid, fundingMinDepth, BITCOIN_FUNDING_DEPTHOK)
goto(WAIT_FOR_FUNDING_CONFIRMED) using DATA_WAIT_FOR_FUNDING_CONFIRMED(commitments, None, initialRelayFees_opt, nodeParams.currentBlockHeight, None, Right(fundingSigned)) storing() sending fundingSigned
}
}
@ -514,7 +514,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
context.system.eventStream.publish(ChannelSignatureReceived(self, commitments))
log.info(s"publishing funding tx for channelId=$channelId fundingTxid=${commitInput.outPoint.txid}")
watchFundingTx(commitments)
blockchain ! WatchConfirmed(self, commitInput.outPoint.txid, commitInput.txOut.publicKeyScript, nodeParams.minDepthBlocks, BITCOIN_FUNDING_DEPTHOK)
blockchain ! WatchConfirmed(self, commitInput.outPoint.txid, nodeParams.minDepthBlocks, BITCOIN_FUNDING_DEPTHOK)
log.info(s"committing txid=${fundingTx.txid}")
// we will publish the funding tx only after the channel state has been written to disk because we want to
@ -618,7 +618,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
when(WAIT_FOR_FUNDING_LOCKED)(handleExceptions {
case Event(FundingLocked(_, nextPerCommitmentPoint), d@DATA_WAIT_FOR_FUNDING_LOCKED(commitments, shortChannelId, _, initialRelayFees_opt)) =>
// used to get the final shortChannelId, used in announcements (if minDepth >= ANNOUNCEMENTS_MINCONF this event will fire instantly)
blockchain ! WatchConfirmed(self, commitments.commitInput.outPoint.txid, commitments.commitInput.txOut.publicKeyScript, ANNOUNCEMENTS_MINCONF, BITCOIN_FUNDING_DEEPLYBURIED)
blockchain ! WatchConfirmed(self, commitments.commitInput.outPoint.txid, ANNOUNCEMENTS_MINCONF, BITCOIN_FUNDING_DEEPLYBURIED)
context.system.eventStream.publish(ShortChannelIdAssigned(self, commitments.channelId, shortChannelId, None))
// we create a channel_update early so that we can use it to send payments through this channel, but it won't be propagated to other nodes since the channel is not yet announced
val (feeBase, feeProportionalMillionths) = initialRelayFees_opt.getOrElse((nodeParams.feeBase, nodeParams.feeProportionalMillionth))
@ -1322,7 +1322,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
case Event(WatchEventSpent(BITCOIN_OUTPUT_SPENT, tx), d: DATA_CLOSING) =>
// one of the outputs of the local/remote/revoked commit was spent
// we just put a watch to be notified when it is confirmed
blockchain ! WatchConfirmed(self, tx, nodeParams.minDepthBlocks, BITCOIN_TX_CONFIRMED(tx))
blockchain ! WatchConfirmed(self, tx.txid, nodeParams.minDepthBlocks, BITCOIN_TX_CONFIRMED(tx))
// when a remote or local commitment tx containing outgoing htlcs is published on the network,
// we watch it in order to extract payment preimage if funds are pulled by the counterparty
// we can then use these preimages to fulfill origin htlcs
@ -1342,7 +1342,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
val revokedCommitPublished1 = d.revokedCommitPublished.map { rev =>
val (rev1, penaltyTxs) = Closing.claimRevokedHtlcTxOutputs(keyManager, d.commitments, rev, tx, nodeParams.onChainFeeConf.feeEstimator)
penaltyTxs.foreach(claimTx => blockchain ! PublishAsap(claimTx.tx, PublishStrategy.JustPublish))
penaltyTxs.foreach(claimTx => blockchain ! WatchSpent(self, tx, claimTx.input.outPoint.index.toInt, BITCOIN_OUTPUT_SPENT, hints = Set(claimTx.tx.txid)))
penaltyTxs.foreach(claimTx => blockchain ! WatchSpent(self, tx.txid, claimTx.input.outPoint.index.toInt, BITCOIN_OUTPUT_SPENT, hints = Set(claimTx.tx.txid)))
rev1
}
stay using d.copy(revokedCommitPublished = revokedCommitPublished1) storing()
@ -1356,7 +1356,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
val (localCommitPublished1, claimHtlcTx_opt) = Closing.claimLocalCommitHtlcTxOutput(localCommitPublished, keyManager, d.commitments, tx, nodeParams.onChainFeeConf.feeEstimator, nodeParams.onChainFeeConf.feeTargets)
claimHtlcTx_opt.foreach(claimHtlcTx => {
blockchain ! PublishAsap(claimHtlcTx.tx, PublishStrategy.JustPublish)
blockchain ! WatchConfirmed(self, claimHtlcTx.tx, nodeParams.minDepthBlocks, BITCOIN_TX_CONFIRMED(claimHtlcTx.tx))
blockchain ! WatchConfirmed(self, claimHtlcTx.tx.txid, nodeParams.minDepthBlocks, BITCOIN_TX_CONFIRMED(claimHtlcTx.tx))
})
Closing.updateLocalCommitPublished(localCommitPublished1, tx)
}),
@ -1529,7 +1529,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
Helpers.minDepthForFunding(nodeParams, d.commitments.commitInput.txOut.amount)
}
// we put back the watch (operation is idempotent) because the event may have been fired while we were in OFFLINE
blockchain ! WatchConfirmed(self, d.commitments.commitInput.outPoint.txid, d.commitments.commitInput.txOut.publicKeyScript, minDepth, BITCOIN_FUNDING_DEPTHOK)
blockchain ! WatchConfirmed(self, d.commitments.commitInput.outPoint.txid, minDepth, BITCOIN_FUNDING_DEPTHOK)
goto(WAIT_FOR_FUNDING_CONFIRMED)
case Event(_: ChannelReestablish, d: DATA_WAIT_FOR_FUNDING_LOCKED) =>
@ -1589,7 +1589,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
if (!d.buried) {
// even if we were just disconnected/reconnected, we need to put back the watch because the event may have been
// fired while we were in OFFLINE (if not, the operation is idempotent anyway)
blockchain ! WatchConfirmed(self, d.commitments.commitInput.outPoint.txid, d.commitments.commitInput.txOut.publicKeyScript, ANNOUNCEMENTS_MINCONF, BITCOIN_FUNDING_DEEPLYBURIED)
blockchain ! WatchConfirmed(self, d.commitments.commitInput.outPoint.txid, ANNOUNCEMENTS_MINCONF, BITCOIN_FUNDING_DEEPLYBURIED)
} else {
// channel has been buried enough, should we (re)send our announcement sigs?
d.channelAnnouncement match {
@ -1960,7 +1960,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
private def watchFundingTx(commitments: Commitments, additionalKnownSpendingTxs: Set[ByteVector32] = Set.empty): Unit = {
// TODO: should we wait for an acknowledgment from the watcher?
val knownSpendingTxs = Set(commitments.localCommit.publishableTxs.commitTx.tx.txid, commitments.remoteCommit.txid) ++ commitments.remoteNextCommitInfo.left.toSeq.map(_.nextRemoteCommit.txid).toSet ++ additionalKnownSpendingTxs
blockchain ! WatchSpent(self, commitments.commitInput.outPoint.txid, commitments.commitInput.outPoint.index.toInt, commitments.commitInput.txOut.publicKeyScript, BITCOIN_FUNDING_SPENT, knownSpendingTxs)
blockchain ! WatchSpent(self, commitments.commitInput.outPoint.txid, commitments.commitInput.outPoint.index.toInt, BITCOIN_FUNDING_SPENT, knownSpendingTxs)
// TODO: implement this? (not needed if we use a reasonable min_depth)
//blockchain ! WatchLost(self, commitments.commitInput.outPoint.txid, nodeParams.minDepthBlocks, BITCOIN_FUNDING_LOST)
}
@ -2143,7 +2143,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
private def doPublish(closingTx: ClosingTx): Unit = {
blockchain ! PublishAsap(closingTx.tx, PublishStrategy.JustPublish)
blockchain ! WatchConfirmed(self, closingTx.tx, nodeParams.minDepthBlocks, BITCOIN_TX_CONFIRMED(closingTx.tx))
blockchain ! WatchConfirmed(self, closingTx.tx.txid, nodeParams.minDepthBlocks, BITCOIN_TX_CONFIRMED(closingTx.tx))
}
private def spendLocalCurrent(d: HasCommitments) = {
@ -2185,7 +2185,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
*/
private def watchConfirmedIfNeeded(txs: Iterable[Transaction], irrevocablySpent: Map[OutPoint, Transaction]): Unit = {
val (skip, process) = txs.partition(Closing.inputsAlreadySpent(_, irrevocablySpent))
process.foreach(tx => blockchain ! WatchConfirmed(self, tx, nodeParams.minDepthBlocks, BITCOIN_TX_CONFIRMED(tx)))
process.foreach(tx => blockchain ! WatchConfirmed(self, tx.txid, nodeParams.minDepthBlocks, BITCOIN_TX_CONFIRMED(tx)))
skip.foreach(tx => log.info(s"no need to watch txid=${tx.txid}, it has already been confirmed"))
}
@ -2200,7 +2200,7 @@ class Channel(val nodeParams: NodeParams, val wallet: EclairWallet, remoteNodeId
require(output.txid == parentTx.txid && output.index < parentTx.txOut.size, s"output doesn't belong to the given parentTx: output=${output.txid}:${output.index} (expected txid=${parentTx.txid} index < ${parentTx.txOut.size})")
}
val (skip, process) = outputs.partition(irrevocablySpent.contains)
process.foreach(output => blockchain ! WatchSpent(self, parentTx, output.index.toInt, BITCOIN_OUTPUT_SPENT, hints = Set.empty))
process.foreach(output => blockchain ! WatchSpent(self, parentTx.txid, output.index.toInt, BITCOIN_OUTPUT_SPENT, hints = Set.empty))
skip.foreach(output => log.info(s"no need to watch output=${output.txid}:${output.index}, it has already been spent by txid=${irrevocablySpent.get(output).map(_.txid)}"))
}

View file

@ -16,14 +16,11 @@
package fr.acinq.eclair.router
import java.util.UUID
import akka.Done
import akka.actor.{Actor, ActorLogging, ActorRef, Props, Terminated}
import akka.event.DiagnosticLoggingAdapter
import akka.event.Logging.MDC
import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.bitcoin.Script.{pay2wsh, write}
import fr.acinq.bitcoin.{ByteVector32, Satoshi}
import fr.acinq.eclair.Logs.LogCategory
import fr.acinq.eclair._
@ -37,10 +34,10 @@ import fr.acinq.eclair.remote.EclairInternalsSerializer.RemoteTypes
import fr.acinq.eclair.router.Graph.GraphStructure.DirectedGraph
import fr.acinq.eclair.router.Graph.WeightRatios
import fr.acinq.eclair.router.Monitoring.{Metrics, Tags}
import fr.acinq.eclair.transactions.Scripts
import fr.acinq.eclair.wire.protocol._
import kamon.context.Context
import java.util.UUID
import scala.collection.immutable.SortedMap
import scala.concurrent.duration._
import scala.concurrent.{ExecutionContext, Promise}
@ -89,10 +86,9 @@ class Router(val nodeParams: NodeParams, watcher: ActorRef, initialized: Option[
initChannels.values.foreach { pc =>
val txid = pc.fundingTxid
val TxCoordinates(_, _, outputIndex) = ShortChannelId.coordinates(pc.ann.shortChannelId)
val fundingOutputScript = write(pay2wsh(Scripts.multiSig2of2(pc.ann.bitcoinKey1, pc.ann.bitcoinKey2)))
// avoid herd effect at startup because watch-spent are intensive in terms of rpc calls to bitcoind
context.system.scheduler.scheduleOnce(Random.nextLong(nodeParams.watchSpentWindow.toSeconds).seconds) {
watcher ! WatchSpentBasic(self, txid, outputIndex, fundingOutputScript, BITCOIN_FUNDING_EXTERNAL_CHANNEL_SPENT(pc.ann.shortChannelId))
watcher ! WatchSpentBasic(self, txid, outputIndex, BITCOIN_FUNDING_EXTERNAL_CHANNEL_SPENT(pc.ann.shortChannelId))
}
}

View file

@ -103,7 +103,7 @@ object Validation {
remoteOrigins_opt.foreach(_.foreach(o => sendDecision(o.peerConnection, GossipDecision.InvalidAnnouncement(c))))
None
} else {
watcher ! WatchSpentBasic(ctx.self, tx, outputIndex, BITCOIN_FUNDING_EXTERNAL_CHANNEL_SPENT(c.shortChannelId))
watcher ! WatchSpentBasic(ctx.self, tx.txid, outputIndex, BITCOIN_FUNDING_EXTERNAL_CHANNEL_SPENT(c.shortChannelId))
log.debug("added channel channelId={}", c.shortChannelId)
remoteOrigins_opt.foreach(_.foreach(o => sendDecision(o.peerConnection, GossipDecision.Accepted(c))))
val capacity = tx.txOut(outputIndex).amount

View file

@ -20,7 +20,6 @@ import fr.acinq.bitcoin.Crypto.PrivateKey
import fr.acinq.bitcoin.{Block, ByteVector32, Satoshi, SatoshiLong, Script}
import fr.acinq.eclair.FeatureSupport.Optional
import fr.acinq.eclair.Features._
import fr.acinq.eclair.NodeParams.BITCOIND
import fr.acinq.eclair.blockchain.fee.{FeeEstimator, FeeTargets, FeeratesPerKw, OnChainFeeConf, _}
import fr.acinq.eclair.channel.LocalParams
import fr.acinq.eclair.crypto.keymanager.{LocalChannelKeyManager, LocalNodeKeyManager}
@ -128,7 +127,6 @@ object TestConstants {
maxReconnectInterval = 1 hour,
chainHash = Block.RegtestGenesisBlock.hash,
channelFlags = 1,
watcherType = BITCOIND,
watchSpentWindow = 1 second,
paymentRequestExpiry = 1 hour,
multiPartPaymentExpiry = 30 seconds,
@ -233,7 +231,6 @@ object TestConstants {
maxReconnectInterval = 1 hour,
chainHash = Block.RegtestGenesisBlock.hash,
channelFlags = 1,
watcherType = BITCOIND,
watchSpentWindow = 1 second,
paymentRequestExpiry = 1 hour,
multiPartPaymentExpiry = 30 seconds,

View file

@ -18,28 +18,11 @@ package fr.acinq.eclair.blockchain
import fr.acinq.bitcoin.Crypto.{PrivateKey, PublicKey}
import fr.acinq.bitcoin.{OutPoint, SIGHASH_ALL, Satoshi, SatoshiLong, Script, ScriptFlags, ScriptWitness, SigVersion, Transaction, TxIn, TxOut}
import org.scalatest.funsuite.AnyFunSuiteLike
/**
* Created by PM on 27/01/2017.
*/
class WatcherSpec extends AnyFunSuiteLike {
test("extract pay2wpkh pubkey script") {
val commitTx = Transaction.read("020000000001010ba75314a116c1e585d1454d079598c5f00edc8a21ebd9e4f3b64e5c318ff2a30100000000e832a680012e850100000000001600147d2a3fc37dba8e946e0238d7eeb6fb602be658200400473044022010d4f249861bb9828ddfd2cda91dc10b8f8ffd0f15c8a4a85a2d373d52f5e0ff02205356242878121676e3e823ceb3dc075d18fed015053badc8f8d754b8959a9178014730440220521002cf241311facf541b689e7229977bfceffa0e4ded785b4e6197af80bfa202204a168d1f7ee59c73ae09c3e0a854b20262b9969fe4ed69b15796dca3ea286582014752210365375134360808be0b4756ba8a2995488310ac4c69571f2b600aaba3ec6cc2d32103a0d9c18794f16dfe01d6d6716bcd1e97ecff2f39451ec48e1899af40f20a18bc52aec3dd9520")
val claimMainTx = Transaction.read("020000000001012537488e9d066a8f3550cc9adc141a11668425e046e69e07f53bb831f3296cbf00000000000000000001bf8401000000000017a9143f398d81d3c42367b779ea869c7dd3b6826fbb7487024730440220477b961f6360ef6cb62a76898dcecbb130627c7e6a452646e3be601f04627c1f02202572313d0c0afecbfb0c7d0e47ba689427a54f3debaded6d406daa1f5da4918c01210291ed78158810ad867465377f5920036ea865a29b3a39a1b1808d0c3c351a4b4100000000")
assert(commitTx.txOut.head.publicKeyScript === WatchConfirmed.extractPublicKeyScript(claimMainTx.txIn.head.witness))
}
test("extract pay2wsh pubkey script") {
val commitTx = Transaction.read("02000000000101fb98507ff5f47bcc5b4497a145e631f68b2b5fcf2752598bc54c8f33696e1c73000000000017f15b80015b3f0f0000000000220020345fc26988f6252d9d93ee95f2198e820db1a4d7c7ec557e4cc5d7e60750cc21040047304402202fd9cbc8446a10193f378269bf12d321aa972743c0a011089aff522de2a1414d02204dd65bf43e41fe911c7180e5e036d609646a798fa5c3f288ede73679978df36b01483045022100fced8966c2527cb175521c4eb41aaaee96838420fa5fce3d4730c0da37f6253502202dc9667530a9f79bc6444b54335467d2043c4b996da5fbca7496e0fa64ccc1bd0147522103a16c06d8626bad5d6d8ea8fee980c287590b9dedeb5857a3d0cd6c4b4e95631c2103d872e26e43f723523d2d8eff5f93a1b344fe51eb76bcfd4906315ae2fe35389a52ae620acc20")
val claimMainDelayedTx = Transaction.read("02000000000101b285ffeb84c366f621fe33b6ff77a9b7578075b65e69c363d12c35aa422d98fd00000000009000000001e03e0f000000000017a9147407522166f1ed3030788b1b6a48803867d1797f8703483045022100fe9eefd010a80411ccae87590db3f54c1c04605170bdcd83c1e04222d474ef41022036db7fd3c07c0523c2cf72d80c7fe3bdc2d5028a8bc2864b478a707e8af627dc01004d63210298f7dada89d882c4ab971e7e914f4953249bad70333b29aa504bb67e5ce9239c67029000b275210328170f7e781c70ea679efc30383d3e03451ca350e2a8690f8ed3db9dabb3866768ac00000000")
assert(commitTx.txOut.head.publicKeyScript === WatchConfirmed.extractPublicKeyScript(claimMainDelayedTx.txIn.head.witness))
}
}
object WatcherSpec {
/**

View file

@ -121,11 +121,11 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
val outputIndex = 42
val utxo = OutPoint(txid.reverse, outputIndex)
val w1 = WatchSpent(null, txid, outputIndex, randomBytes32, BITCOIN_FUNDING_SPENT, hints = Set.empty)
val w2 = WatchSpent(null, txid, outputIndex, randomBytes32, BITCOIN_FUNDING_SPENT, hints = Set.empty)
val w3 = WatchSpentBasic(null, txid, outputIndex, randomBytes32, BITCOIN_FUNDING_SPENT)
val w4 = WatchSpentBasic(null, randomBytes32, 5, randomBytes32, BITCOIN_FUNDING_SPENT)
val w5 = WatchConfirmed(null, txid, randomBytes32, 3, BITCOIN_FUNDING_SPENT)
val w1 = WatchSpent(TestProbe().ref, txid, outputIndex, BITCOIN_FUNDING_SPENT, hints = Set.empty)
val w2 = WatchSpent(TestProbe().ref, txid, outputIndex, BITCOIN_FUNDING_SPENT, hints = Set.empty)
val w3 = WatchSpentBasic(TestProbe().ref, txid, outputIndex, BITCOIN_FUNDING_SPENT)
val w4 = WatchSpentBasic(TestProbe().ref, randomBytes32, 5, BITCOIN_FUNDING_SPENT)
val w5 = WatchConfirmed(TestProbe().ref, txid, 3, BITCOIN_FUNDING_SPENT)
// we test as if the collection was immutable
val m1 = addWatchedUtxos(m0, w1)
@ -158,14 +158,14 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
val tx = sendToAddress(address, Btc(1), probe)
val listener = TestProbe()
probe.send(watcher, WatchConfirmed(listener.ref, tx.txid, tx.txOut.head.publicKeyScript, 4, BITCOIN_FUNDING_DEPTHOK))
probe.send(watcher, WatchConfirmed(listener.ref, tx.txid, tx.txOut.head.publicKeyScript, 4, BITCOIN_FUNDING_DEPTHOK)) // setting the watch multiple times should be a no-op
probe.send(watcher, WatchConfirmed(listener.ref, tx.txid, 4, BITCOIN_FUNDING_DEPTHOK))
probe.send(watcher, WatchConfirmed(listener.ref, tx.txid, 4, BITCOIN_FUNDING_DEPTHOK)) // setting the watch multiple times should be a no-op
generateBlocks(5)
assert(listener.expectMsgType[WatchEventConfirmed].tx.txid === tx.txid)
listener.expectNoMsg(1 second)
// If we try to watch a transaction that has already been confirmed, we should immediately receive a WatchEventConfirmed.
probe.send(watcher, WatchConfirmed(listener.ref, tx.txid, tx.txOut.head.publicKeyScript, 4, BITCOIN_FUNDING_DEPTHOK))
probe.send(watcher, WatchConfirmed(listener.ref, tx.txid, 4, BITCOIN_FUNDING_DEPTHOK))
assert(listener.expectMsgType[WatchEventConfirmed].tx.txid === tx.txid)
listener.expectNoMsg(1 second)
})
@ -182,8 +182,8 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
val (tx1, tx2) = createUnspentTxChain(tx, priv)
val listener = TestProbe()
probe.send(watcher, WatchSpentBasic(listener.ref, tx, outputIndex, BITCOIN_FUNDING_SPENT))
probe.send(watcher, WatchSpent(listener.ref, tx, outputIndex, BITCOIN_FUNDING_SPENT, hints = Set.empty))
probe.send(watcher, WatchSpentBasic(listener.ref, tx.txid, outputIndex, BITCOIN_FUNDING_SPENT))
probe.send(watcher, WatchSpent(listener.ref, tx.txid, outputIndex, BITCOIN_FUNDING_SPENT, hints = Set.empty))
listener.expectNoMsg(1 second)
bitcoinClient.publishTransaction(tx1).pipeTo(probe.ref)
probe.expectMsg(tx1.txid)
@ -202,19 +202,19 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
probe.expectMsg(tx2.txid)
listener.expectNoMsg(1 second)
generateBlocks(1)
probe.send(watcher, WatchSpentBasic(listener.ref, tx1, 0, BITCOIN_FUNDING_SPENT))
probe.send(watcher, WatchSpent(listener.ref, tx1, 0, BITCOIN_FUNDING_SPENT, hints = Set.empty))
probe.send(watcher, WatchSpentBasic(listener.ref, tx1.txid, 0, BITCOIN_FUNDING_SPENT))
probe.send(watcher, WatchSpent(listener.ref, tx1.txid, 0, BITCOIN_FUNDING_SPENT, hints = Set.empty))
listener.expectMsgAllOf(
WatchEventSpentBasic(BITCOIN_FUNDING_SPENT),
WatchEventSpent(BITCOIN_FUNDING_SPENT, tx2)
)
// We use hints and see if we can find tx2
probe.send(watcher, WatchSpent(listener.ref, tx1, 0, BITCOIN_FUNDING_SPENT, hints = Set(tx2.txid)))
probe.send(watcher, WatchSpent(listener.ref, tx1.txid, 0, BITCOIN_FUNDING_SPENT, hints = Set(tx2.txid)))
listener.expectMsg(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx2))
// We should still find tx2 if the provided hint is wrong
probe.send(watcher, WatchSpent(listener.ref, tx1, 0, BITCOIN_FUNDING_SPENT, hints = Set(randomBytes32)))
probe.send(watcher, WatchSpent(listener.ref, tx1.txid, 0, BITCOIN_FUNDING_SPENT, hints = Set(randomBytes32)))
listener.expectMsg(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx2))
})
}
@ -235,8 +235,8 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
val tx2 = createSpendP2WPKH(tx1, priv, priv.publicKey, 10000 sat, 1, 0)
// setup watches before we publish transactions
probe.send(watcher, WatchSpent(probe.ref, tx1, outputIndex, BITCOIN_FUNDING_SPENT, hints = Set.empty))
probe.send(watcher, WatchConfirmed(probe.ref, tx1, 3, BITCOIN_FUNDING_SPENT))
probe.send(watcher, WatchSpent(probe.ref, tx1.txid, outputIndex, BITCOIN_FUNDING_SPENT, hints = Set.empty))
probe.send(watcher, WatchConfirmed(probe.ref, tx1.txid, 3, BITCOIN_FUNDING_SPENT))
bitcoinClient.publishTransaction(tx1).pipeTo(probe.ref)
probe.expectMsg(tx1.txid)
generateBlocks(1)
@ -281,7 +281,7 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
// tx2 has a relative delay but no absolute delay
val tx2 = createSpendP2WPKH(tx1, priv, priv.publicKey, 10000 sat, sequence = 2, lockTime = 0)
probe.send(watcher, WatchConfirmed(probe.ref, tx1, 1, BITCOIN_FUNDING_DEPTHOK))
probe.send(watcher, WatchConfirmed(probe.ref, tx1.txid, 1, BITCOIN_FUNDING_DEPTHOK))
probe.send(watcher, PublishAsap(tx2, PublishStrategy.JustPublish))
generateBlocks(1)
assert(probe.expectMsgType[WatchEventConfirmed].tx === tx1)
@ -293,8 +293,8 @@ class ZmqWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with Bitcoind
// tx3 has both relative and absolute delays
val tx3 = createSpendP2WPKH(tx2, priv, priv.publicKey, 10000 sat, sequence = 1, lockTime = blockCount.get + 5)
probe.send(watcher, WatchConfirmed(probe.ref, tx2, 1, BITCOIN_FUNDING_DEPTHOK))
probe.send(watcher, WatchSpent(probe.ref, tx2, 0, BITCOIN_FUNDING_SPENT, hints = Set.empty))
probe.send(watcher, WatchConfirmed(probe.ref, tx2.txid, 1, BITCOIN_FUNDING_DEPTHOK))
probe.send(watcher, WatchSpent(probe.ref, tx2.txid, 0, BITCOIN_FUNDING_SPENT, hints = Set.empty))
probe.send(watcher, PublishAsap(tx3, PublishStrategy.JustPublish))
generateBlocks(1)
assert(probe.expectMsgType[WatchEventConfirmed].tx === tx2)

View file

@ -1,27 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum
import fr.acinq.bitcoin.Block
import org.scalatest.funsuite.AnyFunSuite
class CheckPointSpec extends AnyFunSuite {
test("load checkpoint") {
val checkpoints = CheckPoint.load(Block.LivenetGenesisBlock.hash)
assert(!checkpoints.isEmpty)
}
}

View file

@ -1,119 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum
import java.net.InetSocketAddress
import java.util.concurrent.atomic.AtomicLong
import akka.actor.{ActorRef, Props}
import akka.testkit.TestProbe
import fr.acinq.bitcoin.{ByteVector32, Crypto, Transaction}
import fr.acinq.eclair.TestKitBaseClass
import fr.acinq.eclair.blockchain.electrum.ElectrumClient._
import grizzled.slf4j.Logging
import org.scalatest.funsuite.AnyFunSuiteLike
import scodec.bits._
import scala.concurrent.duration._
import scala.util.Random
class ElectrumClientPoolSpec extends TestKitBaseClass with AnyFunSuiteLike with Logging {
var pool: ActorRef = _
val probe = TestProbe()
// this is tx #2690 of block #500000
val referenceTx = Transaction.read("0200000001983c5b32ced1de5ae97d3ce9b7436f8bb0487d15bf81e5cae97b1e238dc395c6000000006a47304402205957c75766e391350eba2c7b752f0056cb34b353648ecd0992a8a81fc9bcfe980220629c286592842d152cdde71177cd83086619744a533f262473298cacf60193500121021b8b51f74dbf0ac1e766d162c8707b5e8d89fc59da0796f3b4505e7c0fb4cf31feffffff0276bd0101000000001976a914219de672ba773aa0bc2e15cdd9d2e69b734138fa88ac3e692001000000001976a914301706dede031e9fb4b60836e073a4761855f6b188ac09a10700")
val scriptHash = Crypto.sha256(referenceTx.txOut(0).publicKeyScript).reverse
val serverAddresses = {
val stream = classOf[ElectrumClientSpec].getResourceAsStream("/electrum/servers_mainnet.json")
val addresses = ElectrumClientPool.readServerAddresses(stream, sslEnabled = false)
stream.close()
addresses
}
implicit val timeout = 20 seconds
import concurrent.ExecutionContext.Implicits.global
test("pick a random, unused server address") {
val usedAddresses = Random.shuffle(serverAddresses.toSeq).take(serverAddresses.size / 2).map(_.address).toSet
for (_ <- 1 to 10) {
val Some(pick) = ElectrumClientPool.pickAddress(serverAddresses, usedAddresses)
assert(!usedAddresses.contains(pick.address))
}
}
test("init an electrumx connection pool") {
val random = new Random()
val stream = classOf[ElectrumClientSpec].getResourceAsStream("/electrum/servers_mainnet.json")
val addresses = random.shuffle(serverAddresses.toSeq).take(2).toSet + ElectrumClientPool.ElectrumServerAddress(new InetSocketAddress("electrum.acinq.co", 50002), SSL.STRICT)
stream.close()
assert(addresses.nonEmpty)
pool = system.actorOf(Props(new ElectrumClientPool(new AtomicLong(), addresses)), "electrum-client")
}
test("connect to an electrumx mainnet server") {
probe.send(pool, AddStatusListener(probe.ref))
// make sure our master is stable, if the first master that we select is behind the other servers we will switch
// during the first few seconds
awaitCond({
probe.expectMsgType[ElectrumReady](30 seconds)
probe.receiveOne(5 seconds) == null
}, max = 60 seconds, interval = 1000 millis)
}
test("get transaction") {
probe.send(pool, GetTransaction(referenceTx.txid))
val GetTransactionResponse(tx, _) = probe.expectMsgType[GetTransactionResponse](timeout)
assert(tx == referenceTx)
}
test("get merkle tree") {
probe.send(pool, GetMerkle(referenceTx.txid, 500000))
val response = probe.expectMsgType[GetMerkleResponse](timeout)
assert(response.txid == referenceTx.txid)
assert(response.block_height == 500000)
assert(response.pos == 2690)
assert(response.root == ByteVector32(hex"1f6231ed3de07345b607ec2a39b2d01bec2fe10dfb7f516ba4958a42691c9531"))
}
test("header subscription") {
val probe1 = TestProbe()
probe1.send(pool, HeaderSubscription(probe1.ref))
val HeaderSubscriptionResponse(_, header) = probe1.expectMsgType[HeaderSubscriptionResponse](timeout)
logger.info(s"received header for block ${header.blockId}")
}
test("scripthash subscription") {
val probe1 = TestProbe()
probe1.send(pool, ScriptHashSubscription(scriptHash, probe1.ref))
val ScriptHashSubscriptionResponse(scriptHash1, status) = probe1.expectMsgType[ScriptHashSubscriptionResponse](timeout)
assert(status != "")
}
test("get scripthash history") {
probe.send(pool, GetScriptHashHistory(scriptHash))
val GetScriptHashHistoryResponse(scriptHash1, history) = probe.expectMsgType[GetScriptHashHistoryResponse](timeout)
assert(history.contains((TransactionHistoryItem(500000, referenceTx.txid))))
}
test("list script unspents") {
probe.send(pool, ScriptHashListUnspent(scriptHash))
val ScriptHashListUnspentResponse(scriptHash1, unspents) = probe.expectMsgType[ScriptHashListUnspentResponse](timeout)
assert(unspents.isEmpty)
}
}

View file

@ -1,132 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum
import java.net.InetSocketAddress
import akka.actor.{ActorRef, Props}
import akka.testkit.TestProbe
import fr.acinq.bitcoin.{ByteVector32, Crypto, Transaction}
import fr.acinq.eclair.TestKitBaseClass
import grizzled.slf4j.Logging
import org.scalatest.funsuite.AnyFunSuiteLike
import scodec.bits._
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.duration._
class ElectrumClientSpec extends TestKitBaseClass with AnyFunSuiteLike with Logging {
import ElectrumClient._
var client: ActorRef = _
val probe = TestProbe()
// this is tx #2690 of block #500000
val referenceTx = Transaction.read("0200000001983c5b32ced1de5ae97d3ce9b7436f8bb0487d15bf81e5cae97b1e238dc395c6000000006a47304402205957c75766e391350eba2c7b752f0056cb34b353648ecd0992a8a81fc9bcfe980220629c286592842d152cdde71177cd83086619744a533f262473298cacf60193500121021b8b51f74dbf0ac1e766d162c8707b5e8d89fc59da0796f3b4505e7c0fb4cf31feffffff0276bd0101000000001976a914219de672ba773aa0bc2e15cdd9d2e69b734138fa88ac3e692001000000001976a914301706dede031e9fb4b60836e073a4761855f6b188ac09a10700")
val scriptHash = Crypto.sha256(referenceTx.txOut(0).publicKeyScript).reverse
val height = 500000
val position = 2690
val merkleProof = List(
hex"b500cd85cd6c7e0e570b82728dd516646536a477b61cc82056505d84a5820dc3",
hex"c98798c2e576566a92b23d2405f59d95c506966a6e26fecfb356d6447a199546",
hex"930d95c428546812fd11f8242904a9a1ba05d2140cd3a83be0e2ed794821c9ec",
hex"90c97965b12f4262fe9bf95bc37ff7d6362902745eaa822ecf0cf85801fa8b48",
hex"23792d51fddd6e439ed4c92ad9f19a9b73fc9d5c52bdd69039be70ad6619a1aa",
hex"4b73075f29a0abdcec2c83c2cfafc5f304d2c19dcacb50a88a023df725468760",
hex"f80225a32a5ce4ef0703822c6aa29692431a816dec77d9b1baa5b09c3ba29bfb",
hex"4858ac33f2022383d3b4dd674666a0880557d02a155073be93231a02ecbb81f4",
hex"eb5b142030ed4e0b55a8ba5a7b5b783a0a24e0c2fd67c1cfa2f7b308db00c38a",
hex"86858812c3837d209110f7ea79de485abdfd22039467a8aa15a8d85856ee7d30",
hex"de20eb85f2e9ad525a6fb5c618682b6bdce2fa83df836a698f31575c4e5b3d38",
hex"98bd1048e04ff1b0af5856d9890cd708d8d67ad6f3a01f777130fbc16810eeb3")
.map(ByteVector32(_))
override protected def beforeAll(): Unit = {
client = system.actorOf(Props(new ElectrumClient(new InetSocketAddress("electrum.acinq.co", 50002), SSL.STRICT)), "electrum-client")
}
test("connect to an electrumx mainnet server") {
probe.send(client, AddStatusListener(probe.ref))
probe.expectMsgType[ElectrumReady](15 seconds)
}
test("get transaction id from position") {
probe.send(client, GetTransactionIdFromPosition(height, position))
probe.expectMsg(GetTransactionIdFromPositionResponse(referenceTx.txid, height, position, Nil))
}
test("get transaction id from position with merkle proof") {
probe.send(client, GetTransactionIdFromPosition(height, position, merkle = true))
probe.expectMsg(GetTransactionIdFromPositionResponse(referenceTx.txid, height, position, merkleProof))
}
test("get transaction") {
probe.send(client, GetTransaction(referenceTx.txid))
val GetTransactionResponse(tx, _) = probe.expectMsgType[GetTransactionResponse]
assert(tx == referenceTx)
}
test("get header") {
probe.send(client, GetHeader(100000))
val GetHeaderResponse(height, header) = probe.expectMsgType[GetHeaderResponse]
assert(header.blockId == ByteVector32(hex"000000000003ba27aa200b1cecaad478d2b00432346c3f1f3986da1afd33e506"))
}
test("get headers") {
val start = (500000 / 2016) * 2016
probe.send(client, GetHeaders(start, 2016))
val GetHeadersResponse(start1, headers, _) = probe.expectMsgType[GetHeadersResponse]
assert(start1 == start)
assert(headers.size == 2016)
}
test("get merkle tree") {
probe.send(client, GetMerkle(referenceTx.txid, 500000))
val response = probe.expectMsgType[GetMerkleResponse]
assert(response.txid == referenceTx.txid)
assert(response.block_height == 500000)
assert(response.pos == 2690)
assert(response.root == ByteVector32(hex"1f6231ed3de07345b607ec2a39b2d01bec2fe10dfb7f516ba4958a42691c9531"))
}
test("header subscription") {
val probe1 = TestProbe()
probe1.send(client, HeaderSubscription(probe1.ref))
val HeaderSubscriptionResponse(_, header) = probe1.expectMsgType[HeaderSubscriptionResponse]
logger.info(s"received header for block ${header.blockId}")
}
test("scripthash subscription") {
val probe1 = TestProbe()
probe1.send(client, ScriptHashSubscription(scriptHash, probe1.ref))
val ScriptHashSubscriptionResponse(scriptHash1, status) = probe1.expectMsgType[ScriptHashSubscriptionResponse]
assert(status != "")
}
test("get scripthash history") {
probe.send(client, GetScriptHashHistory(scriptHash))
val GetScriptHashHistoryResponse(scriptHash1, history) = probe.expectMsgType[GetScriptHashHistoryResponse]
assert(history.contains(TransactionHistoryItem(500000, referenceTx.txid)))
}
test("list script unspents") {
probe.send(client, ScriptHashListUnspent(scriptHash))
val ScriptHashListUnspentResponse(scriptHash1, unspents) = probe.expectMsgType[ScriptHashListUnspentResponse]
assert(unspents.isEmpty)
}
}

View file

@ -1,233 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum
import fr.acinq.bitcoin.Crypto.PrivateKey
import fr.acinq.bitcoin.DeterministicWallet.{ExtendedPrivateKey, derivePrivateKey}
import fr.acinq.bitcoin._
import fr.acinq.eclair.blockchain.electrum.db.sqlite.SqliteWalletDb
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.transactions.{Scripts, Transactions}
import grizzled.slf4j.Logging
import org.scalatest.funsuite.AnyFunSuite
import scodec.bits.ByteVector
import java.sql.DriverManager
import scala.util.{Failure, Random, Success, Try}
class ElectrumWalletBasicSpec extends AnyFunSuite with Logging {
import ElectrumWallet._
import ElectrumWalletBasicSpec._
val swipeRange = 10
val dustLimit = 546 sat
val feerate = FeeratePerKw(20000 sat)
val minimumFee = 2000 sat
val master = DeterministicWallet.generate(ByteVector32(ByteVector.fill(32)(1)))
val accountMaster = accountKey(master, Block.RegtestGenesisBlock.hash)
val accountIndex = 0
val changeMaster = changeKey(master, Block.RegtestGenesisBlock.hash)
val changeIndex = 0
val firstAccountKeys = (0 until 10).map(i => derivePrivateKey(accountMaster, i)).toVector
val firstChangeKeys = (0 until 10).map(i => derivePrivateKey(changeMaster, i)).toVector
val params = ElectrumWallet.WalletParameters(Block.RegtestGenesisBlock.hash, new SqliteWalletDb(DriverManager.getConnection("jdbc:sqlite::memory:")))
val state = Data(params, Blockchain.fromCheckpoints(Block.RegtestGenesisBlock.hash, CheckPoint.load(Block.RegtestGenesisBlock.hash)), firstAccountKeys, firstChangeKeys)
.copy(status = (firstAccountKeys ++ firstChangeKeys).map(key => computeScriptHashFromPublicKey(key.publicKey) -> "").toMap)
def addFunds(data: Data, key: ExtendedPrivateKey, amount: Satoshi): Data = {
val tx = Transaction(version = 1, txIn = Nil, txOut = TxOut(amount, ElectrumWallet.computePublicKeyScript(key.publicKey)) :: Nil, lockTime = 0)
val scriptHash = ElectrumWallet.computeScriptHashFromPublicKey(key.publicKey)
val scriptHashHistory = data.history.getOrElse(scriptHash, List.empty[ElectrumClient.TransactionHistoryItem])
data.copy(
history = data.history.updated(scriptHash, ElectrumClient.TransactionHistoryItem(100, tx.txid) :: scriptHashHistory),
transactions = data.transactions + (tx.txid -> tx)
)
}
def addFunds(data: Data, keyamount: (ExtendedPrivateKey, Satoshi)): Data = {
val tx = Transaction(version = 1, txIn = Nil, txOut = TxOut(keyamount._2, ElectrumWallet.computePublicKeyScript(keyamount._1.publicKey)) :: Nil, lockTime = 0)
val scriptHash = ElectrumWallet.computeScriptHashFromPublicKey(keyamount._1.publicKey)
val scriptHashHistory = data.history.getOrElse(scriptHash, List.empty[ElectrumClient.TransactionHistoryItem])
data.copy(
history = data.history.updated(scriptHash, ElectrumClient.TransactionHistoryItem(100, tx.txid) :: scriptHashHistory),
transactions = data.transactions + (tx.txid -> tx)
)
}
def addFunds(data: Data, keyamounts: Seq[(ExtendedPrivateKey, Satoshi)]): Data = keyamounts.foldLeft(data)(addFunds)
test("compute addresses") {
val priv = PrivateKey.fromBase58("cRumXueoZHjhGXrZWeFoEBkeDHu2m8dW5qtFBCqSAt4LDR2Hnd8Q", Base58.Prefix.SecretKeyTestnet)._1
assert(Base58Check.encode(Base58.Prefix.PubkeyAddressTestnet, priv.publicKey.hash160) == "ms93boMGZZjvjciujPJgDAqeR86EKBf9MC")
assert(segwitAddress(priv, Block.RegtestGenesisBlock.hash) == "2MscvqgGXMTYJNAY3owdUtgWJaxPUjH38Cx")
}
test("implement BIP49") {
val mnemonics = "pizza afraid guess romance pair steel record jazz rubber prison angle hen heart engage kiss visual helmet twelve lady found between wave rapid twist".split(" ")
val seed = MnemonicCode.toSeed(mnemonics, "")
val master = DeterministicWallet.generate(seed)
val accountMaster = accountKey(master, Block.RegtestGenesisBlock.hash)
val firstKey = derivePrivateKey(accountMaster, 0)
assert(segwitAddress(firstKey, Block.RegtestGenesisBlock.hash) === "2MxJejujQJRRJdbfTKNQQ94YCnxJwRaE7yo")
}
test("complete transactions (enough funds)") {
val state1 = addFunds(state, state.accountKeys.head, 1 btc)
val (confirmed1, unconfirmed1) = state1.balance
val pub = PrivateKey(ByteVector32(ByteVector.fill(32)(1))).publicKey
val tx = Transaction(version = 2, txIn = Nil, txOut = TxOut(0.5 btc, Script.pay2pkh(pub)) :: Nil, lockTime = 0)
val (state2, tx1, fee1) = state1.completeTransaction(tx, feerate, minimumFee, dustLimit, allowSpendUnconfirmed = false)
val Some((_, _, Some(fee))) = state2.computeTransactionDelta(tx1)
assert(fee == fee1)
val state3 = state2.cancelTransaction(tx1)
assert(state3 == state1)
val state4 = state2.commitTransaction(tx1)
val (confirmed4, unconfirmed4) = state4.balance
assert(confirmed4 == confirmed1)
assert(unconfirmed1 - unconfirmed4 >= btc2satoshi(0.5 btc))
}
test("complete transactions (insufficient funds)") {
val state1 = addFunds(state, state.accountKeys.head, 5 btc)
val tx = Transaction(version = 2, txIn = Nil, txOut = TxOut(6 btc, Script.pay2pkh(state1.accountKeys(0).publicKey)) :: Nil, lockTime = 0)
intercept[IllegalArgumentException] {
state1.completeTransaction(tx, feerate, minimumFee, dustLimit, allowSpendUnconfirmed = false)
}
}
test("compute the effect of tx") {
val state1 = addFunds(state, state.accountKeys.head, 1 btc)
val tx = Transaction(version = 2, txIn = Nil, txOut = TxOut(0.5 btc, Script.pay2pkh(state1.accountKeys(0).publicKey)) :: Nil, lockTime = 0)
val (_, tx1, fee1) = state1.completeTransaction(tx, feerate, minimumFee, dustLimit, allowSpendUnconfirmed = false)
val Some((received, sent, Some(fee))) = state1.computeTransactionDelta(tx1)
assert(fee == fee1)
assert(sent - received - fee == btc2satoshi(0.5 btc))
}
test("use actual transaction weight to compute fees") {
val state1 = addFunds(state, (state.accountKeys(0), 5000000 sat) :: (state.accountKeys(1), 6000000 sat) :: (state.accountKeys(2), 4000000 sat) :: Nil)
{
val tx = Transaction(version = 2, txIn = Nil, txOut = TxOut(5000000 sat, Script.pay2pkh(state1.accountKeys(0).publicKey)) :: Nil, lockTime = 0)
val (state3, tx1, fee1) = state1.completeTransaction(tx, feerate, minimumFee, dustLimit, allowSpendUnconfirmed = true)
val Some((_, _, Some(fee))) = state3.computeTransactionDelta(tx1)
assert(fee == fee1)
val actualFeeRate = Transactions.fee2rate(fee, tx1.weight())
assert(isFeerateOk(actualFeeRate, feerate))
}
{
val tx = Transaction(version = 2, txIn = Nil, txOut = TxOut(5000000.sat - dustLimit, Script.pay2pkh(state1.accountKeys(0).publicKey)) :: Nil, lockTime = 0)
val (state3, tx1, fee1) = state1.completeTransaction(tx, feerate, minimumFee, dustLimit, allowSpendUnconfirmed = true)
val Some((_, _, Some(fee))) = state3.computeTransactionDelta(tx1)
assert(fee == fee1)
val actualFeeRate = Transactions.fee2rate(fee, tx1.weight())
assert(isFeerateOk(actualFeeRate, feerate))
}
{
// with a huge fee rate that will force us to use an additional input when we complete our tx
val tx = Transaction(version = 2, txIn = Nil, txOut = TxOut(3000000 sat, Script.pay2pkh(state1.accountKeys(0).publicKey)) :: Nil, lockTime = 0)
val (state3, tx1, fee1) = state1.completeTransaction(tx, feerate * 100, minimumFee, dustLimit, allowSpendUnconfirmed = true)
val Some((_, _, Some(fee))) = state3.computeTransactionDelta(tx1)
assert(fee == fee1)
val actualFeeRate = Transactions.fee2rate(fee, tx1.weight())
assert(isFeerateOk(actualFeeRate, feerate * 100))
}
{
// with a tiny fee rate that will force us to use an additional input when we complete our tx
val tx = Transaction(version = 2, txIn = Nil, txOut = TxOut(Btc(0.09), Script.pay2pkh(state1.accountKeys(0).publicKey)) :: Nil, lockTime = 0)
val (state3, tx1, fee1) = state1.completeTransaction(tx, feerate / 10, minimumFee / 10, dustLimit, allowSpendUnconfirmed = true)
val Some((_, _, Some(fee))) = state3.computeTransactionDelta(tx1)
assert(fee == fee1)
val actualFeeRate = Transactions.fee2rate(fee, tx1.weight())
assert(isFeerateOk(actualFeeRate, feerate / 10))
}
}
test("spend all our balance") {
val state1 = addFunds(state, state.accountKeys(0), 1 btc)
val state2 = addFunds(state1, state1.accountKeys(1), 2 btc)
val state3 = addFunds(state2, state2.changeKeys(0), 0.5 btc)
assert(state3.utxos.length == 3)
assert(state3.balance == (350000000 sat, 0 sat))
val (tx, fee) = state3.spendAll(Script.pay2wpkh(ByteVector.fill(20)(1)), feerate)
val Some((received, _, Some(fee1))) = state3.computeTransactionDelta(tx)
assert(received === 0.sat)
assert(fee == fee1)
assert(tx.txOut.map(_.amount).sum + fee == state3.balance._1 + state3.balance._2)
}
test("check that issue #1146 is fixed") {
val state3 = addFunds(state, state.changeKeys(0), 0.5 btc)
val pub1 = state.accountKeys(0).publicKey
val pub2 = state.accountKeys(1).publicKey
val redeemScript = Scripts.multiSig2of2(pub1, pub2)
val pubkeyScript = Script.pay2wsh(redeemScript)
val (tx, fee) = state3.spendAll(pubkeyScript, FeeratePerKw(750 sat))
val Some((received, _, Some(fee1))) = state3.computeTransactionDelta(tx)
assert(received === 0.sat)
assert(fee == fee1)
assert(tx.txOut.map(_.amount).sum + fee == state3.balance._1 + state3.balance._2)
val tx1 = Transaction(version = 2, txIn = Nil, txOut = TxOut(tx.txOut.map(_.amount).sum, pubkeyScript) :: Nil, lockTime = 0)
assert(Try(state3.completeTransaction(tx1, FeeratePerKw(750 sat), 0 sat, dustLimit, allowSpendUnconfirmed = true)).isSuccess)
}
test("fuzzy test") {
val random = new Random()
(0 to 10) foreach { _ =>
val funds = for (_ <- 0 until random.nextInt(10)) yield {
val index = random.nextInt(state.accountKeys.length)
val amount = dustLimit + random.nextInt(10000000).sat
(state.accountKeys(index), amount)
}
val state1 = addFunds(state, funds)
(0 until 30) foreach { _ =>
val amount = dustLimit + random.nextInt(10000000).sat
val tx = Transaction(version = 2, txIn = Nil, txOut = TxOut(amount, Script.pay2pkh(state1.accountKeys(0).publicKey)) :: Nil, lockTime = 0)
Try(state1.completeTransaction(tx, feerate, minimumFee, dustLimit, allowSpendUnconfirmed = true)) match {
case Success((_, tx1, _)) =>
tx1.txOut.foreach(o => require(o.amount >= dustLimit, "output is below dust limit"))
case Failure(cause) if cause.getMessage != null && cause.getMessage.contains("insufficient funds") => ()
case Failure(cause) => logger.error(s"unexpected $cause")
}
}
}
}
}
object ElectrumWalletBasicSpec {
/**
* @param actualFeeRate actual fee rate
* @param targetFeeRate target fee rate
* @return true if actual fee rate is within 10% of target
*/
def isFeerateOk(actualFeeRate: FeeratePerKw, targetFeeRate: FeeratePerKw): Boolean =
Math.abs(actualFeeRate.toLong - targetFeeRate.toLong) < 0.1 * (actualFeeRate + targetFeeRate).toLong
}

View file

@ -1,412 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum
import akka.actor.{ActorRef, Terminated}
import akka.testkit
import akka.testkit.{TestActor, TestFSMRef, TestProbe}
import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.bitcoin.DeterministicWallet.derivePrivateKey
import fr.acinq.bitcoin.{Block, BlockHeader, ByteVector32, Crypto, DeterministicWallet, MnemonicCode, OutPoint, Satoshi, SatoshiLong, Script, Transaction, TxIn, TxOut}
import fr.acinq.eclair.TestKitBaseClass
import fr.acinq.eclair.blockchain.bitcoind.rpc.Error
import fr.acinq.eclair.blockchain.electrum.ElectrumClient._
import fr.acinq.eclair.blockchain.electrum.ElectrumWallet._
import fr.acinq.eclair.blockchain.electrum.db.sqlite.SqliteWalletDb
import org.scalatest.funsuite.AnyFunSuiteLike
import scodec.bits.ByteVector
import java.net.InetSocketAddress
import java.sql.DriverManager
import scala.annotation.tailrec
import scala.concurrent.duration._
class ElectrumWalletSimulatedClientSpec extends TestKitBaseClass with AnyFunSuiteLike {
import ElectrumWalletSimulatedClientSpec._
val sender = TestProbe()
val entropy = ByteVector32(ByteVector.fill(32)(1))
val mnemonics = MnemonicCode.toMnemonics(entropy)
val seed = MnemonicCode.toSeed(mnemonics, "")
val listener = TestProbe()
system.eventStream.subscribe(listener.ref, classOf[WalletEvent])
val genesis = Block.RegtestGenesisBlock.header
// initial headers that we will sync when we connect to our mock server
var headers = makeHeaders(genesis, 2016 + 2000)
val client = TestProbe()
client.ignoreMsg {
case ElectrumClient.Ping => true
case _: AddStatusListener => true
case _: HeaderSubscription => true
}
client.setAutoPilot(new testkit.TestActor.AutoPilot {
override def run(sender: ActorRef, msg: Any): TestActor.AutoPilot = msg match {
case ScriptHashSubscription(scriptHash, replyTo) =>
replyTo ! ScriptHashSubscriptionResponse(scriptHash, "")
TestActor.KeepRunning
case GetHeaders(start, count, _) =>
sender ! GetHeadersResponse(start, headers.drop(start - 1).take(count), 2016)
TestActor.KeepRunning
case _ => TestActor.KeepRunning
}
})
val walletParameters = WalletParameters(Block.RegtestGenesisBlock.hash, new SqliteWalletDb(DriverManager.getConnection("jdbc:sqlite::memory:")), minimumFee = 5000 sat)
val wallet = TestFSMRef(new ElectrumWallet(seed, client.ref, walletParameters))
// wallet sends a receive address notification as soon as it is created
listener.expectMsgType[NewWalletReceiveAddress]
def reconnect: WalletReady = {
sender.send(wallet, ElectrumClient.ElectrumReady(wallet.stateData.blockchain.bestchain.last.height, wallet.stateData.blockchain.bestchain.last.header, InetSocketAddress.createUnresolved("0.0.0.0", 9735)))
awaitCond(wallet.stateName == ElectrumWallet.WAITING_FOR_TIP)
while (listener.msgAvailable) {
listener.receiveOne(100 milliseconds)
}
sender.send(wallet, ElectrumClient.HeaderSubscriptionResponse(wallet.stateData.blockchain.bestchain.last.height, wallet.stateData.blockchain.bestchain.last.header))
awaitCond(wallet.stateName == ElectrumWallet.RUNNING)
val ready = listener.expectMsgType[WalletReady]
ready
}
test("wait until wallet is ready") {
sender.send(wallet, ElectrumClient.ElectrumReady(2016, headers(2015), InetSocketAddress.createUnresolved("0.0.0.0", 9735)))
sender.send(wallet, ElectrumClient.HeaderSubscriptionResponse(2016, headers(2015)))
val ready = listener.expectMsgType[WalletReady]
assert(ready.timestamp == headers.last.time)
listener.expectMsgType[NewWalletReceiveAddress]
listener.send(wallet, GetXpub)
val GetXpubResponse(xpub, path) = listener.expectMsgType[GetXpubResponse]
assert(xpub == "upub5DffbMENbUsLcJbhufWvy1jourQfXfC6SoYyxhy2gPKeTSGzYHB3wKTnKH2LYCDemSzZwqzNcHNjnQZJCDn7Jy2LvvQeysQ6hrcK5ogp11B")
assert(path == "m/49'/1'/0'")
}
test("tell wallet is ready when a new block comes in, even if nothing else has changed") {
val last = wallet.stateData.blockchain.tip
assert(last.header == headers.last)
val header = makeHeader(last.header)
headers = headers :+ header
sender.send(wallet, ElectrumClient.HeaderSubscriptionResponse(last.height + 1, header))
assert(listener.expectMsgType[WalletReady].timestamp == header.time)
val NewWalletReceiveAddress(address) = listener.expectMsgType[NewWalletReceiveAddress]
assert(address == "2NDjBqJugL3gCtjWTToDgaWWogq9nYuYw31")
}
test("tell wallet is ready when it is reconnected, even if nothing has changed") {
// disconnect wallet
sender.send(wallet, ElectrumClient.ElectrumDisconnected)
awaitCond(wallet.stateName == ElectrumWallet.DISCONNECTED)
// reconnect wallet
val last = wallet.stateData.blockchain.tip
assert(last.header == headers.last)
sender.send(wallet, ElectrumClient.ElectrumReady(2016, headers(2015), InetSocketAddress.createUnresolved("0.0.0.0", 9735)))
sender.send(wallet, ElectrumClient.HeaderSubscriptionResponse(last.height, last.header))
awaitCond(wallet.stateName == ElectrumWallet.RUNNING)
// listener should be notified
assert(listener.expectMsgType[WalletReady].timestamp == last.header.time)
listener.expectMsgType[NewWalletReceiveAddress]
}
test("don't send the same ready message more then once") {
// listener should be notified
val last = wallet.stateData.blockchain.tip
assert(last.header == headers.last)
val header = makeHeader(last.header)
headers = headers :+ header
sender.send(wallet, ElectrumClient.HeaderSubscriptionResponse(last.height + 1, header))
assert(listener.expectMsgType[WalletReady].timestamp == header.time)
listener.expectMsgType[NewWalletReceiveAddress]
sender.send(wallet, ElectrumClient.HeaderSubscriptionResponse(last.height + 1, header))
listener.expectNoMsg(500 milliseconds)
}
test("disconnect if server sends a bad header") {
val last = wallet.stateData.blockchain.bestchain.last
val bad = makeHeader(last.header, 42L).copy(bits = Long.MaxValue)
// here we simulate a bad client
val probe = TestProbe()
val watcher = TestProbe()
watcher.watch(probe.ref)
watcher.setAutoPilot(new TestActor.AutoPilot {
override def run(sender: ActorRef, msg: Any): TestActor.AutoPilot = msg match {
case Terminated(actor) if actor == probe.ref =>
// if the client dies, we tell the wallet that it's been disconnected
wallet ! ElectrumClient.ElectrumDisconnected
TestActor.KeepRunning
}
})
probe.send(wallet, ElectrumClient.HeaderSubscriptionResponse(last.height + 1, bad))
watcher.expectTerminated(probe.ref)
awaitCond(wallet.stateName == ElectrumWallet.DISCONNECTED)
reconnect
}
test("disconnect if server sends an invalid transaction") {
while (client.msgAvailable) {
client.receiveOne(100 milliseconds)
}
val key = wallet.stateData.accountKeys(0)
val scriptHash = computeScriptHashFromPublicKey(key.publicKey)
wallet ! ScriptHashSubscriptionResponse(scriptHash, ByteVector32(ByteVector.fill(32)(1)).toHex)
client.expectMsg(GetScriptHashHistory(scriptHash))
val tx = Transaction(version = 2, txIn = Nil, txOut = TxOut(100000 sat, ElectrumWallet.computePublicKeyScript(key.publicKey)) :: Nil, lockTime = 0)
wallet ! GetScriptHashHistoryResponse(scriptHash, TransactionHistoryItem(2, tx.txid) :: Nil)
// wallet will generate a new address and the corresponding subscription
client.expectMsgType[ScriptHashSubscription]
while (listener.msgAvailable) {
listener.receiveOne(100 milliseconds)
}
client.expectMsg(GetTransaction(tx.txid))
wallet ! GetTransactionResponse(tx, None)
val TransactionReceived(_, _, Satoshi(100000), _, _, _) = listener.expectMsgType[TransactionReceived]
// we think we have some unconfirmed funds
val WalletReady(Satoshi(100000), _, _, _) = listener.expectMsgType[WalletReady]
client.expectMsg(GetMerkle(tx.txid, 2))
val probe = TestProbe()
val watcher = TestProbe()
watcher.watch(probe.ref)
watcher.setAutoPilot(new TestActor.AutoPilot {
override def run(sender: ActorRef, msg: Any): TestActor.AutoPilot = msg match {
case Terminated(actor) if actor == probe.ref =>
wallet ! ElectrumClient.ElectrumDisconnected
TestActor.KeepRunning
}
})
probe.send(wallet, GetMerkleResponse(tx.txid, ByteVector32(ByteVector.fill(32)(1)) :: Nil, 2, 0, None))
watcher.expectTerminated(probe.ref)
awaitCond(wallet.stateName == ElectrumWallet.DISCONNECTED)
val ready = reconnect
assert(ready.unconfirmedBalance === 0.sat)
}
test("clear status when we have pending history requests") {
while (client.msgAvailable) {
client.receiveOne(100 milliseconds)
}
// tell wallet that there is something for our first account key
val scriptHash = ElectrumWallet.computeScriptHashFromPublicKey(wallet.stateData.accountKeys(0).publicKey)
wallet ! ScriptHashSubscriptionResponse(scriptHash, "010101")
client.expectMsg(GetScriptHashHistory(scriptHash))
assert(wallet.stateData.status(scriptHash) == "010101")
// disconnect wallet
wallet ! ElectrumDisconnected
awaitCond(wallet.stateName == ElectrumWallet.DISCONNECTED)
assert(wallet.stateData.status.get(scriptHash).isEmpty)
reconnect
}
test("handle pending transaction requests") {
while (client.msgAvailable) {
client.receiveOne(100 milliseconds)
}
val key = wallet.stateData.accountKeys(1)
val scriptHash = computeScriptHashFromPublicKey(key.publicKey)
wallet ! ScriptHashSubscriptionResponse(scriptHash, ByteVector32(ByteVector.fill(32)(2)).toHex)
client.expectMsg(GetScriptHashHistory(scriptHash))
val tx = Transaction(version = 2, txIn = Nil, txOut = TxOut(100000 sat, ElectrumWallet.computePublicKeyScript(key.publicKey)) :: Nil, lockTime = 0)
wallet ! GetScriptHashHistoryResponse(scriptHash, TransactionHistoryItem(2, tx.txid) :: Nil)
// wallet will generate a new address and the corresponding subscription
client.expectMsgType[ScriptHashSubscription]
while (listener.msgAvailable) {
listener.receiveOne(100 milliseconds)
}
client.expectMsg(GetTransaction(tx.txid))
assert(wallet.stateData.pendingTransactionRequests == Set(tx.txid))
}
test("handle disconnect/reconnect events") {
val data = {
val master = DeterministicWallet.generate(seed)
val accountMaster = ElectrumWallet.accountKey(master, walletParameters.chainHash)
val changeMaster = ElectrumWallet.changeKey(master, walletParameters.chainHash)
val firstAccountKeys = (0 until walletParameters.swipeRange).map(i => derivePrivateKey(accountMaster, i)).toVector
val firstChangeKeys = (0 until walletParameters.swipeRange).map(i => derivePrivateKey(changeMaster, i)).toVector
val data1 = Data(walletParameters, Blockchain.fromGenesisBlock(Block.RegtestGenesisBlock.hash, Block.RegtestGenesisBlock.header), firstAccountKeys, firstChangeKeys)
val amount1 = 1000000 sat
val amount2 = 1500000 sat
// transactions that send funds to our wallet
val wallettxs = Seq(
addOutputs(emptyTx, amount1, data1.accountKeys(0).publicKey),
addOutputs(emptyTx, amount2, data1.accountKeys(1).publicKey),
addOutputs(emptyTx, amount2, data1.accountKeys(2).publicKey),
addOutputs(emptyTx, amount2, data1.accountKeys(3).publicKey)
)
val data2 = wallettxs.foldLeft(data1)(addTransaction)
// a tx that spend from our wallet to our wallet, plus change to our wallet
val tx1 = {
val tx = Transaction(version = 2,
txIn = TxIn(OutPoint(wallettxs(0), 0), signatureScript = Nil, sequence = TxIn.SEQUENCE_FINAL) :: Nil,
txOut = walletOutput(wallettxs(0).txOut(0).amount - 50000.sat, data2.accountKeys(2).publicKey) :: walletOutput(50000 sat, data2.changeKeys(0).publicKey) :: Nil,
lockTime = 0)
data2.signTransaction(tx)
}
// a tx that spend from our wallet to a random address, plus change to our wallet
val tx2 = {
val tx = Transaction(version = 2,
txIn = TxIn(OutPoint(wallettxs(1), 0), signatureScript = Nil, sequence = TxIn.SEQUENCE_FINAL) :: Nil,
txOut = TxOut(wallettxs(1).txOut(0).amount - 50000.sat, Script.pay2wpkh(fr.acinq.eclair.randomKey.publicKey)) :: walletOutput(50000 sat, data2.changeKeys(1).publicKey) :: Nil,
lockTime = 0)
data2.signTransaction(tx)
}
val data3 = Seq(tx1, tx2).foldLeft(data2)(addTransaction)
data3
}
// simulated electrum server that disconnects after a given number of messages
var counter = 0
var disconnectAfter = 10 // disconnect when counter % disconnectAfter == 0
client.setAutoPilot(new testkit.TestActor.AutoPilot {
override def run(sender: ActorRef, msg: Any): TestActor.AutoPilot = {
counter = msg match {
case _: ScriptHashSubscription => counter
case _ => counter + 1
}
msg match {
case ScriptHashSubscription(scriptHash, replyTo) =>
// we skip over these otherwise we would never converge (there are at least 20 such messages sent when we're
// reconnected, one for each account/change key)
replyTo ! ScriptHashSubscriptionResponse(scriptHash, data.status.getOrElse(scriptHash, ""))
TestActor.KeepRunning
case msg if counter % disconnectAfter == 0 =>
// disconnect
sender ! ElectrumClient.ElectrumDisconnected
// and reconnect
sender ! ElectrumClient.ElectrumReady(headers.length, headers.last, InetSocketAddress.createUnresolved("0.0.0.0", 9735))
sender ! ElectrumClient.HeaderSubscriptionResponse(headers.length, headers.last)
TestActor.KeepRunning
case request@GetTransaction(txid, context_opt) =>
data.transactions.get(txid) match {
case Some(tx) => sender ! GetTransactionResponse(tx, context_opt)
case None =>
sender ! ServerError(request, Error(0, s"unknwown tx $txid"))
}
TestActor.KeepRunning
case GetScriptHashHistory(scriptHash) =>
sender ! GetScriptHashHistoryResponse(scriptHash, data.history.getOrElse(scriptHash, List()))
TestActor.KeepRunning
case GetHeaders(start, count, _) =>
sender ! GetHeadersResponse(start, headers.drop(start - 1).take(count), 2016)
TestActor.KeepRunning
case HeaderSubscription(actor) => actor ! HeaderSubscriptionResponse(headers.length, headers.last)
TestActor.KeepRunning
case _ =>
TestActor.KeepRunning
}
}
})
val sender = TestProbe()
wallet ! ElectrumClient.ElectrumDisconnected
wallet ! ElectrumClient.ElectrumReady(headers.length, headers.last, InetSocketAddress.createUnresolved("0.0.0.0", 9735))
wallet ! ElectrumClient.HeaderSubscriptionResponse(headers.length, headers.last)
data.status.foreach { case (scriptHash, status) => sender.send(wallet, ScriptHashSubscriptionResponse(scriptHash, status)) }
val expected = data.transactions.keySet
awaitCond {
wallet.stateData.transactions.keySet == expected
}
}
}
object ElectrumWalletSimulatedClientSpec {
def makeHeader(previousHeader: BlockHeader, timestamp: Long): BlockHeader = {
var template = previousHeader.copy(hashPreviousBlock = previousHeader.hash, time = timestamp, nonce = 0)
while (!BlockHeader.checkProofOfWork(template)) {
template = template.copy(nonce = template.nonce + 1)
}
template
}
def makeHeader(previousHeader: BlockHeader): BlockHeader = makeHeader(previousHeader, previousHeader.time + 1)
def makeHeaders(previousHeader: BlockHeader, count: Int): Vector[BlockHeader] = {
@tailrec
def loop(acc: Vector[BlockHeader]): Vector[BlockHeader] = if (acc.length == count) acc else loop(acc :+ makeHeader(acc.last))
loop(Vector(makeHeader(previousHeader)))
}
val emptyTx = Transaction(version = 2, txIn = Nil, txOut = Nil, lockTime = 0)
def walletOutput(amount: Satoshi, key: PublicKey) = TxOut(amount, ElectrumWallet.computePublicKeyScript(key))
def addOutputs(tx: Transaction, amount: Satoshi, keys: PublicKey*): Transaction = keys.foldLeft(tx) { case (t, k) => t.copy(txOut = t.txOut :+ walletOutput(amount, k)) }
def addToHistory(history: Map[ByteVector32, List[ElectrumClient.TransactionHistoryItem]], scriptHash: ByteVector32, item: TransactionHistoryItem): Map[ByteVector32, List[ElectrumClient.TransactionHistoryItem]] = {
history.get(scriptHash) match {
case None => history + (scriptHash -> List(item))
case Some(items) if items.contains(item) => history
case _ => history.updated(scriptHash, history(scriptHash) :+ item)
}
}
def updateStatus(data: ElectrumWallet.Data): ElectrumWallet.Data = {
val status1 = data.history.mapValues(items => {
val status = items.map(i => s"${i.tx_hash}:${i.height}:").mkString("")
Crypto.sha256(ByteVector.view(status.getBytes())).toString()
}).toMap
data.copy(status = status1)
}
def addTransaction(data: ElectrumWallet.Data, tx: Transaction): ElectrumWallet.Data = {
data.transactions.get(tx.txid) match {
case Some(_) => data
case None =>
val history1 = tx.txOut.filter(o => data.isMine(o)).foldLeft(data.history) { case (a, b) =>
addToHistory(a, Crypto.sha256(b.publicKeyScript).reverse, TransactionHistoryItem(100000, tx.txid))
}
val data1 = data.copy(history = history1, transactions = data.transactions + (tx.txid -> tx))
val history2 = tx.txIn.filter(i => data1.isMine(i)).foldLeft(data1.history) { case (a, b) =>
addToHistory(a, ElectrumWallet.computeScriptHashFromPublicKey(extractPubKeySpentFrom(b).get), TransactionHistoryItem(100000, tx.txid))
}
val data2 = data1.copy(history = history2)
updateStatus(data2)
}
}
}

View file

@ -1,388 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum
import akka.actor.{ActorRef, Props}
import akka.testkit.TestProbe
import com.whisk.docker.DockerReadyChecker
import fr.acinq.bitcoin.{Block, Btc, BtcDouble, ByteVector32, DeterministicWallet, MnemonicCode, OutPoint, Satoshi, SatoshiLong, Script, ScriptFlags, ScriptWitness, SigVersion, Transaction, TxIn, TxOut}
import fr.acinq.eclair.TestKitBaseClass
import fr.acinq.eclair.blockchain.bitcoind.BitcoindService.BitcoinReq
import fr.acinq.eclair.blockchain.bitcoind.rpc.ExtendedBitcoinClient
import fr.acinq.eclair.blockchain.bitcoind.rpc.ExtendedBitcoinClient.{FundTransactionResponse, SignTransactionResponse}
import fr.acinq.eclair.blockchain.bitcoind.{BitcoinCoreWallet, BitcoindService}
import fr.acinq.eclair.blockchain.electrum.ElectrumClient.{BroadcastTransaction, BroadcastTransactionResponse, SSL}
import fr.acinq.eclair.blockchain.electrum.ElectrumClientPool.ElectrumServerAddress
import fr.acinq.eclair.blockchain.electrum.db.sqlite.SqliteWalletDb
import fr.acinq.eclair.blockchain.fee.FeeratePerKw
import fr.acinq.eclair.transactions.{Scripts, Transactions}
import fr.acinq.{bitcoin, eclair}
import grizzled.slf4j.Logging
import org.json4s.JsonAST.{JDecimal, JString, JValue}
import org.scalatest.BeforeAndAfterAll
import org.scalatest.funsuite.AnyFunSuiteLike
import scodec.bits.ByteVector
import java.net.InetSocketAddress
import java.sql.DriverManager
import java.util.concurrent.atomic.AtomicLong
import scala.concurrent.Await
import scala.concurrent.duration._
class ElectrumWalletSpec extends TestKitBaseClass with AnyFunSuiteLike with BitcoindService with ElectrumxService with BeforeAndAfterAll with Logging {
import ElectrumWallet._
val entropy = ByteVector32(ByteVector.fill(32)(1))
val mnemonics = MnemonicCode.toMnemonics(entropy)
val seed = MnemonicCode.toSeed(mnemonics, "")
logger.info(s"mnemonic codes for our wallet: $mnemonics")
val master = DeterministicWallet.generate(seed)
var wallet: ActorRef = _
var electrumClient: ActorRef = _
override def beforeAll(): Unit = {
logger.info("starting bitcoind")
startBitcoind()
super.beforeAll()
}
override def afterAll(): Unit = {
logger.info("stopping bitcoind")
stopBitcoind()
super.afterAll()
}
def getCurrentAddress(probe: TestProbe) = {
probe.send(wallet, GetCurrentReceiveAddress)
probe.expectMsgType[GetCurrentReceiveAddressResponse]
}
def getBalance(probe: TestProbe) = {
probe.send(wallet, GetBalance)
probe.expectMsgType[GetBalanceResponse]
}
test("generate 150 blocks") {
waitForBitcoindReady()
DockerReadyChecker.LogLineContains("INFO:BlockProcessor:height: 151").looped(attempts = 15, delay = 1 second)
}
test("wait until wallet is ready") {
electrumClient = system.actorOf(Props(new ElectrumClientPool(new AtomicLong(), Set(ElectrumServerAddress(new InetSocketAddress("localhost", electrumPort), SSL.OFF)))))
wallet = system.actorOf(Props(new ElectrumWallet(seed, electrumClient, WalletParameters(Block.RegtestGenesisBlock.hash, new SqliteWalletDb(DriverManager.getConnection("jdbc:sqlite::memory:")), minimumFee = 5000 sat))), "wallet")
val probe = TestProbe()
awaitCond({
probe.send(wallet, GetData)
val GetDataResponse(state) = probe.expectMsgType[GetDataResponse]
state.status.size == state.accountKeys.size + state.changeKeys.size
}, max = 30 seconds, interval = 1 second)
logger.info(s"wallet is ready")
}
test("receive funds") {
val probe = TestProbe()
val GetBalanceResponse(confirmed, unconfirmed) = getBalance(probe)
logger.info(s"initial balance: $confirmed $unconfirmed")
// send money to our wallet
val GetCurrentReceiveAddressResponse(address) = getCurrentAddress(probe)
logger.info(s"sending 1 btc to $address")
probe.send(bitcoincli, BitcoinReq("sendtoaddress", address, 1.0))
probe.expectMsgType[JValue]
awaitCond({
val GetBalanceResponse(_, unconfirmed1) = getBalance(probe)
unconfirmed1 == unconfirmed + 100000000.sat
}, max = 30 seconds, interval = 1 second)
// confirm our tx
generateBlocks(1)
awaitCond({
val GetBalanceResponse(confirmed1, _) = getBalance(probe)
confirmed1 == confirmed + 100000000.sat
}, max = 30 seconds, interval = 1 second)
val GetCurrentReceiveAddressResponse(address1) = getCurrentAddress(probe)
logger.info(s"sending 1 btc to $address1")
probe.send(bitcoincli, BitcoinReq("sendtoaddress", address1, 1.0))
probe.expectMsgType[JValue]
logger.info(s"sending 0.5 btc to $address1")
probe.send(bitcoincli, BitcoinReq("sendtoaddress", address1, 0.5))
probe.expectMsgType[JValue]
generateBlocks(1)
awaitCond({
val GetBalanceResponse(confirmed1, _) = getBalance(probe)
confirmed1 == confirmed + 250000000.sat
}, max = 30 seconds, interval = 1 second)
}
test("handle transactions with identical outputs to us") {
val probe = TestProbe()
val GetBalanceResponse(confirmed, unconfirmed) = getBalance(probe)
logger.info(s"initial balance: $confirmed $unconfirmed")
// send money to our wallet
val amount = 750000 sat
val GetCurrentReceiveAddressResponse(address) = getCurrentAddress(probe)
val tx = Transaction(version = 2,
txIn = Nil,
txOut = Seq(
TxOut(amount, fr.acinq.eclair.addressToPublicKeyScript(address, Block.RegtestGenesisBlock.hash)),
TxOut(amount, fr.acinq.eclair.addressToPublicKeyScript(address, Block.RegtestGenesisBlock.hash))
), lockTime = 0L)
val btcWallet = new BitcoinCoreWallet(bitcoinrpcclient)
val btcClient = new ExtendedBitcoinClient(bitcoinrpcclient)
val future = for {
FundTransactionResponse(tx1, _, _) <- btcWallet.fundTransaction(tx, lockUtxos = false, FeeratePerKw(10000 sat))
SignTransactionResponse(tx2, true) <- btcWallet.signTransaction(tx1)
txid <- btcClient.publishTransaction(tx2)
} yield txid
Await.result(future, 10 seconds)
awaitCond({
val GetBalanceResponse(_, unconfirmed1) = getBalance(probe)
unconfirmed1 == unconfirmed + amount + amount
}, max = 30 seconds, interval = 1 second)
generateBlocks(1)
awaitCond({
val GetBalanceResponse(confirmed1, _) = getBalance(probe)
confirmed1 == confirmed + amount + amount
}, max = 30 seconds, interval = 1 second)
}
test("receive 'confidence changed' notification") {
val probe = TestProbe()
val listener = TestProbe()
system.eventStream.subscribe(listener.ref, classOf[WalletEvent])
val GetCurrentReceiveAddressResponse(address) = getCurrentAddress(probe)
val GetBalanceResponse(confirmed, unconfirmed) = getBalance(probe)
logger.info(s"initial balance $confirmed $unconfirmed")
logger.info(s"sending 1 btc to $address")
probe.send(bitcoincli, BitcoinReq("sendtoaddress", address, 1.0))
val JString(txid) = probe.expectMsgType[JValue]
logger.info(s"$txid sent 1 btc to us at $address")
awaitCond({
val GetBalanceResponse(_, unconfirmed1) = getBalance(probe)
unconfirmed1 - unconfirmed === 100000000L.sat
}, max = 30 seconds, interval = 1 second)
val TransactionReceived(tx, 0, received, _, _, _) = listener.receiveOne(5 seconds)
assert(tx.txid === ByteVector32.fromValidHex(txid))
assert(received === 100000000.sat)
logger.info("generating a new block")
generateBlocks(1)
awaitCond({
val GetBalanceResponse(confirmed1, _) = getBalance(probe)
confirmed1 - confirmed === 100000000.sat
}, max = 30 seconds, interval = 1 second)
awaitCond({
val msg = listener.receiveOne(5 seconds)
msg match {
case TransactionConfidenceChanged(_, 1, _) => true
case _ => false
}
}, max = 30 seconds, interval = 1 second)
}
test("send money to someone else (we broadcast)") {
val probe = TestProbe()
val GetBalanceResponse(confirmed, _) = getBalance(probe)
// create a tx that sends money to Bitcoin Core's address
probe.send(bitcoincli, BitcoinReq("getnewaddress"))
val JString(address) = probe.expectMsgType[JValue]
val tx = Transaction(version = 2, txIn = Nil, txOut = TxOut(Btc(1), fr.acinq.eclair.addressToPublicKeyScript(address, Block.RegtestGenesisBlock.hash)) :: Nil, lockTime = 0L)
probe.send(wallet, CompleteTransaction(tx, FeeratePerKw(20000 sat)))
val CompleteTransactionResponse(tx1, _, None) = probe.expectMsgType[CompleteTransactionResponse]
// send it ourselves
logger.info(s"sending 1 btc to $address with tx ${tx1.txid}")
probe.send(wallet, BroadcastTransaction(tx1))
val BroadcastTransactionResponse(_, None) = probe.expectMsgType[BroadcastTransactionResponse]
generateBlocks(1)
awaitCond({
probe.send(bitcoincli, BitcoinReq("getreceivedbyaddress", address))
val JDecimal(value) = probe.expectMsgType[JValue]
value == BigDecimal(1.0)
}, max = 30 seconds, interval = 1 second)
awaitCond({
val GetBalanceResponse(confirmed1, _) = getBalance(probe)
logger.debug(s"current balance is $confirmed1")
confirmed1 < confirmed - 1.btc && confirmed1 > confirmed - 1.btc - 50000.sat
}, max = 30 seconds, interval = 1 second)
}
test("send money to ourselves (we broadcast)") {
val probe = TestProbe()
val GetBalanceResponse(confirmed, unconfirmed) = getBalance(probe)
logger.info(s"current balance is $confirmed $unconfirmed")
// create a tx that sends money to Bitcoin Core's address
probe.send(bitcoincli, BitcoinReq("getnewaddress"))
val JString(address) = probe.expectMsgType[JValue]
val tx = Transaction(version = 2, txIn = Nil, txOut = TxOut(Btc(1), fr.acinq.eclair.addressToPublicKeyScript(address, Block.RegtestGenesisBlock.hash)) :: Nil, lockTime = 0L)
probe.send(wallet, CompleteTransaction(tx, FeeratePerKw(20000 sat)))
val CompleteTransactionResponse(tx1, _, None) = probe.expectMsgType[CompleteTransactionResponse]
// send it ourselves
logger.info(s"sending 1 btc to $address with tx ${tx1.txid}")
probe.send(wallet, BroadcastTransaction(tx1))
val BroadcastTransactionResponse(_, None) = probe.expectMsgType[BroadcastTransactionResponse]
generateBlocks(1)
awaitCond({
val GetBalanceResponse(confirmed1, _) = getBalance(probe)
logger.info(s"current balance is $confirmed $unconfirmed")
confirmed1 < confirmed - 1.btc && confirmed1 > confirmed - 1.btc - 50000.sat
}, max = 30 seconds, interval = 1 second)
}
test("detect is a tx has been double-spent") {
val probe = TestProbe()
val GetBalanceResponse(confirmed, unconfirmed) = getBalance(probe)
logger.info(s"current balance is $confirmed $unconfirmed")
// create 2 transactions that spend the same wallet UTXO
val tx1 = {
probe.send(bitcoincli, BitcoinReq("getnewaddress"))
val JString(address) = probe.expectMsgType[JValue]
val tmp = Transaction(version = 2, txIn = Nil, txOut = TxOut(Btc(1), fr.acinq.eclair.addressToPublicKeyScript(address, Block.RegtestGenesisBlock.hash)) :: Nil, lockTime = 0L)
probe.send(wallet, CompleteTransaction(tmp, FeeratePerKw(20000 sat)))
val CompleteTransactionResponse(tx, _, None) = probe.expectMsgType[CompleteTransactionResponse]
probe.send(wallet, CancelTransaction(tx))
probe.expectMsg(CancelTransactionResponse(tx))
tx
}
val tx2 = {
probe.send(bitcoincli, BitcoinReq("getnewaddress"))
val JString(address) = probe.expectMsgType[JValue]
val tmp = Transaction(version = 2, txIn = Nil, txOut = TxOut(Btc(1), fr.acinq.eclair.addressToPublicKeyScript(address, Block.RegtestGenesisBlock.hash)) :: Nil, lockTime = 0L)
probe.send(wallet, CompleteTransaction(tmp, FeeratePerKw(20000 sat)))
val CompleteTransactionResponse(tx, _, None) = probe.expectMsgType[CompleteTransactionResponse]
probe.send(wallet, CancelTransaction(tx))
probe.expectMsg(CancelTransactionResponse(tx))
tx
}
probe.send(wallet, IsDoubleSpent(tx1))
probe.expectMsg(IsDoubleSpentResponse(tx1, isDoubleSpent = false))
probe.send(wallet, IsDoubleSpent(tx2))
probe.expectMsg(IsDoubleSpentResponse(tx2, isDoubleSpent = false))
// publish tx1
probe.send(wallet, BroadcastTransaction(tx1))
probe.expectMsg(BroadcastTransactionResponse(tx1, None))
awaitCond({
probe.send(wallet, GetData)
val data = probe.expectMsgType[GetDataResponse].state
data.heights.contains(tx1.txid) && data.transactions.contains(tx1.txid)
}, max = 30 seconds, interval = 1 second)
// as long as tx1 is unconfirmed tx2 won't be considered double-spent
probe.send(wallet, IsDoubleSpent(tx1))
probe.expectMsg(IsDoubleSpentResponse(tx1, isDoubleSpent = false))
probe.send(wallet, IsDoubleSpent(tx2))
probe.expectMsg(IsDoubleSpentResponse(tx2, isDoubleSpent = false))
generateBlocks(2)
awaitCond({
probe.send(wallet, GetData)
val data = probe.expectMsgType[GetDataResponse].state
data.heights.exists { case (txid, height) => txid == tx1.txid && data.transactions.contains(txid) && ElectrumWallet.computeDepth(data.blockchain.height, height) > 1 }
}, max = 30 seconds, interval = 1 second)
// tx2 is double-spent
probe.send(wallet, IsDoubleSpent(tx1))
probe.expectMsg(IsDoubleSpentResponse(tx1, isDoubleSpent = false))
probe.send(wallet, IsDoubleSpent(tx2))
probe.expectMsg(IsDoubleSpentResponse(tx2, isDoubleSpent = true))
}
test("use all available balance") {
val probe = TestProbe()
// send all our funds to ourself, so we have only one utxo which is the worse case here
val GetCurrentReceiveAddressResponse(address) = getCurrentAddress(probe)
probe.send(wallet, SendAll(Script.write(eclair.addressToPublicKeyScript(address, Block.RegtestGenesisBlock.hash)), FeeratePerKw(750 sat)))
val SendAllResponse(tx, _) = probe.expectMsgType[SendAllResponse]
probe.send(wallet, BroadcastTransaction(tx))
val BroadcastTransactionResponse(`tx`, None) = probe.expectMsgType[BroadcastTransactionResponse]
generateBlocks(1)
awaitCond({
probe.send(wallet, GetData)
val data = probe.expectMsgType[GetDataResponse].state
data.utxos.length == 1 && data.utxos(0).outPoint.txid == tx.txid
}, max = 30 seconds, interval = 1 second)
// send everything to a multisig 2-of-2, with the smallest possible fee rate
val priv = eclair.randomKey
val script = Script.pay2wsh(Scripts.multiSig2of2(priv.publicKey, priv.publicKey))
probe.send(wallet, SendAll(Script.write(script), FeeratePerKw.MinimumFeeratePerKw))
val SendAllResponse(tx1, _) = probe.expectMsgType[SendAllResponse]
probe.send(wallet, BroadcastTransaction(tx1))
val BroadcastTransactionResponse(`tx1`, None) = probe.expectMsgType[BroadcastTransactionResponse]
generateBlocks(1)
awaitCond({
probe.send(wallet, GetData)
val data = probe.expectMsgType[GetDataResponse].state
data.utxos.isEmpty
}, max = 30 seconds, interval = 1 second)
// send everything back to ourselves again
val tx2 = Transaction(version = 2,
txIn = TxIn(OutPoint(tx1, 0), signatureScript = Nil, sequence = TxIn.SEQUENCE_FINAL) :: Nil,
txOut = TxOut(Satoshi(0), eclair.addressToPublicKeyScript(address, Block.RegtestGenesisBlock.hash)) :: Nil,
lockTime = 0)
val sig = Transaction.signInput(tx2, 0, Scripts.multiSig2of2(priv.publicKey, priv.publicKey), bitcoin.SIGHASH_ALL, tx1.txOut(0).amount, SigVersion.SIGVERSION_WITNESS_V0, priv)
val tx3 = tx2.updateWitness(0, ScriptWitness(Seq(ByteVector.empty, sig, sig, Script.write(Scripts.multiSig2of2(priv.publicKey, priv.publicKey)))))
Transaction.correctlySpends(tx3, Seq(tx1), ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
val fee = Transactions.weight2fee(FeeratePerKw.MinimumFeeratePerKw, tx3.weight())
val tx4 = tx3.copy(txOut = tx3.txOut(0).copy(amount = tx1.txOut(0).amount - fee) :: Nil)
val sig1 = Transaction.signInput(tx4, 0, Scripts.multiSig2of2(priv.publicKey, priv.publicKey), bitcoin.SIGHASH_ALL, tx1.txOut(0).amount, SigVersion.SIGVERSION_WITNESS_V0, priv)
val tx5 = tx4.updateWitness(0, ScriptWitness(Seq(ByteVector.empty, sig1, sig1, Script.write(Scripts.multiSig2of2(priv.publicKey, priv.publicKey)))))
probe.send(wallet, BroadcastTransaction(tx5))
val BroadcastTransactionResponse(_, None) = probe.expectMsgType[BroadcastTransactionResponse]
awaitCond({
probe.send(wallet, GetData)
val data = probe.expectMsgType[GetDataResponse].state
data.utxos.length == 1 && data.utxos(0).outPoint.txid == tx5.txid
}, max = 30 seconds, interval = 1 second)
}
}

View file

@ -1,270 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum
import akka.actor.Props
import akka.testkit.TestProbe
import fr.acinq.bitcoin.{Btc, ByteVector32, SatoshiLong, Transaction, TxIn}
import fr.acinq.eclair.blockchain.WatcherSpec._
import fr.acinq.eclair.blockchain._
import fr.acinq.eclair.blockchain.bitcoind.BitcoindService
import fr.acinq.eclair.blockchain.bitcoind.BitcoindService.BitcoinReq
import fr.acinq.eclair.blockchain.bitcoind.rpc.ExtendedBitcoinClient
import fr.acinq.eclair.blockchain.electrum.ElectrumClient.SSL
import fr.acinq.eclair.blockchain.electrum.ElectrumClientPool.ElectrumServerAddress
import fr.acinq.eclair.channel.{BITCOIN_FUNDING_DEPTHOK, BITCOIN_FUNDING_SPENT}
import fr.acinq.eclair.{TestKitBaseClass, randomBytes32}
import grizzled.slf4j.Logging
import org.json4s.JsonAST.JValue
import org.scalatest.BeforeAndAfterAll
import org.scalatest.funsuite.AnyFunSuiteLike
import scodec.bits._
import java.net.InetSocketAddress
import java.util.concurrent.atomic.AtomicLong
import scala.concurrent.duration._
class ElectrumWatcherSpec extends TestKitBaseClass with AnyFunSuiteLike with BitcoindService with ElectrumxService with BeforeAndAfterAll with Logging {
override def beforeAll(): Unit = {
logger.info("starting bitcoind")
startBitcoind()
waitForBitcoindReady()
super.beforeAll()
}
override def afterAll(): Unit = {
logger.info("stopping bitcoind")
stopBitcoind()
super.afterAll()
}
val electrumAddress = ElectrumServerAddress(new InetSocketAddress("localhost", electrumPort), SSL.OFF)
test("watch for confirmed transactions") {
val (probe, listener) = (TestProbe(), TestProbe())
val blockCount = new AtomicLong()
val electrumClient = system.actorOf(Props(new ElectrumClientPool(blockCount, Set(electrumAddress))))
val watcher = system.actorOf(Props(new ElectrumWatcher(blockCount, electrumClient)))
val address = getNewAddress(probe)
val tx = sendToAddress(address, Btc(1), probe)
probe.send(watcher, WatchConfirmed(listener.ref, tx.txid, tx.txOut.head.publicKeyScript, 2, BITCOIN_FUNDING_DEPTHOK))
generateBlocks(2)
assert(listener.expectMsgType[WatchEventConfirmed].tx === tx)
probe.send(watcher, WatchConfirmed(listener.ref, tx, 4, BITCOIN_FUNDING_DEPTHOK))
generateBlocks(2)
assert(listener.expectMsgType[WatchEventConfirmed].tx === tx)
system.stop(watcher)
}
test("watch for spent transactions") {
val (probe, listener) = (TestProbe(), TestProbe())
val blockCount = new AtomicLong()
val electrumClient = system.actorOf(Props(new ElectrumClientPool(blockCount, Set(electrumAddress))))
val watcher = system.actorOf(Props(new ElectrumWatcher(blockCount, electrumClient)))
val address = getNewAddress(probe)
val priv = dumpPrivateKey(address, probe)
val tx = sendToAddress(address, Btc(1))
// create a tx that spends the previous output
val spendingTx = createSpendP2WPKH(tx, priv, priv.publicKey, 1000 sat, TxIn.SEQUENCE_FINAL, 0)
val outpointIndex = spendingTx.txIn.head.outPoint.index.toInt
probe.send(watcher, WatchSpent(listener.ref, tx.txid, outpointIndex, tx.txOut(outpointIndex).publicKeyScript, BITCOIN_FUNDING_SPENT, hints = Set.empty))
listener.expectNoMsg(1 second)
probe.send(bitcoincli, BitcoinReq("sendrawtransaction", spendingTx.toString))
probe.expectMsgType[JValue]
generateBlocks(2)
assert(listener.expectMsgType[WatchEventSpent].tx === spendingTx)
system.stop(watcher)
}
test("watch for mempool transactions (txs in mempool before we set the watch)") {
val (probe, listener) = (TestProbe(), TestProbe())
val blockCount = new AtomicLong()
val electrumClient = system.actorOf(Props(new ElectrumClientPool(blockCount, Set(electrumAddress))))
probe.send(electrumClient, ElectrumClient.AddStatusListener(probe.ref))
probe.expectMsgType[ElectrumClient.ElectrumReady]
val watcher = system.actorOf(Props(new ElectrumWatcher(blockCount, electrumClient)))
val address = getNewAddress(probe)
val priv1 = dumpPrivateKey(address, probe)
val tx = sendToAddress(address, Btc(1))
val priv2 = dumpPrivateKey(getNewAddress(probe), probe)
val priv3 = dumpPrivateKey(getNewAddress(probe), probe)
val tx1 = createSpendP2WPKH(tx, priv1, priv2.publicKey, 10000 sat, TxIn.SEQUENCE_FINAL, 0)
val tx2 = createSpendP2WPKH(tx1, priv2, priv3.publicKey, 10000 sat, TxIn.SEQUENCE_FINAL, 0)
probe.send(bitcoincli, BitcoinReq("sendrawtransaction", tx1.toString()))
probe.expectMsgType[JValue]
probe.send(bitcoincli, BitcoinReq("sendrawtransaction", tx2.toString()))
probe.expectMsgType[JValue]
// wait until tx1 and tx2 are in the mempool (as seen by our ElectrumX server)
awaitCond({
probe.send(electrumClient, ElectrumClient.GetScriptHashHistory(ElectrumClient.computeScriptHash(tx2.txOut.head.publicKeyScript)))
val ElectrumClient.GetScriptHashHistoryResponse(_, history) = probe.expectMsgType[ElectrumClient.GetScriptHashHistoryResponse]
history.map(_.tx_hash).toSet == Set(tx2.txid)
}, max = 30 seconds, interval = 5 seconds)
// then set watches
probe.send(watcher, WatchConfirmed(listener.ref, tx2, 0, BITCOIN_FUNDING_DEPTHOK))
assert(listener.expectMsgType[WatchEventConfirmed].tx === tx2)
probe.send(watcher, WatchSpent(listener.ref, tx1, 0, BITCOIN_FUNDING_SPENT, hints = Set.empty))
listener.expectMsg(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx2))
system.stop(watcher)
}
test("watch for mempool transactions (txs not yet in the mempool when we set the watch)") {
val (probe, listener) = (TestProbe(), TestProbe())
val blockCount = new AtomicLong()
val electrumClient = system.actorOf(Props(new ElectrumClientPool(blockCount, Set(electrumAddress))))
probe.send(electrumClient, ElectrumClient.AddStatusListener(probe.ref))
probe.expectMsgType[ElectrumClient.ElectrumReady]
val watcher = system.actorOf(Props(new ElectrumWatcher(blockCount, electrumClient)))
val address = getNewAddress(probe)
val priv = dumpPrivateKey(address, probe)
val tx = sendToAddress(address, Btc(1))
val (tx1, tx2) = createUnspentTxChain(tx, priv)
// here we set watches * before * we publish our transactions
probe.send(watcher, WatchSpent(listener.ref, tx1, 0, BITCOIN_FUNDING_SPENT, hints = Set.empty))
probe.send(watcher, WatchConfirmed(listener.ref, tx1, 0, BITCOIN_FUNDING_DEPTHOK))
probe.send(bitcoincli, BitcoinReq("sendrawtransaction", tx1.toString()))
probe.expectMsgType[JValue]
assert(listener.expectMsgType[WatchEventConfirmed].tx === tx1)
probe.send(bitcoincli, BitcoinReq("sendrawtransaction", tx2.toString()))
probe.expectMsgType[JValue]
listener.expectMsg(WatchEventSpent(BITCOIN_FUNDING_SPENT, tx2))
system.stop(watcher)
}
test("publish transactions with relative or absolute delays") {
import akka.pattern.pipe
val (probe, listener) = (TestProbe(), TestProbe())
val blockCount = new AtomicLong()
val bitcoinClient = new ExtendedBitcoinClient(bitcoinrpcclient)
val electrumClient = system.actorOf(Props(new ElectrumClientPool(blockCount, Set(electrumAddress))))
bitcoinClient.getBlockCount.pipeTo(probe.ref)
val initialBlockCount = probe.expectMsgType[Long]
probe.send(electrumClient, ElectrumClient.AddStatusListener(probe.ref))
awaitCond(probe.expectMsgType[ElectrumClient.ElectrumReady].height >= initialBlockCount, message = s"waiting for tip at $initialBlockCount")
val watcher = system.actorOf(Props(new ElectrumWatcher(blockCount, electrumClient)))
val recipient = dumpPrivateKey(getNewAddress(probe), probe).publicKey
val address1 = getNewAddress(probe)
val priv1 = dumpPrivateKey(address1, probe)
val tx1 = sendToAddress(address1, Btc(0.2))
val address2 = getNewAddress(probe)
val priv2 = dumpPrivateKey(address2, probe)
val tx2 = sendToAddress(address2, Btc(0.2))
generateBlocks(1)
for (tx <- Seq(tx1, tx2)) {
probe.send(watcher, WatchConfirmed(listener.ref, tx, 1, BITCOIN_FUNDING_DEPTHOK))
assert(listener.expectMsgType[WatchEventConfirmed].tx === tx)
}
// spend tx1 with an absolute delay but no relative delay
val spend1 = createSpendP2WPKH(tx1, priv1, recipient, 5000 sat, sequence = 0, lockTime = blockCount.get + 1)
probe.send(watcher, WatchSpent(listener.ref, tx1, spend1.txIn.head.outPoint.index.toInt, BITCOIN_FUNDING_SPENT, hints = Set.empty))
probe.send(watcher, PublishAsap(spend1, PublishStrategy.JustPublish))
// spend tx2 with a relative delay but no absolute delay
val spend2 = createSpendP2WPKH(tx2, priv2, recipient, 3000 sat, sequence = 1, lockTime = 0)
probe.send(watcher, WatchSpent(listener.ref, tx2, spend2.txIn.head.outPoint.index.toInt, BITCOIN_FUNDING_SPENT, hints = Set.empty))
probe.send(watcher, PublishAsap(spend2, PublishStrategy.JustPublish))
generateBlocks(1)
listener.expectMsgAllOf(WatchEventSpent(BITCOIN_FUNDING_SPENT, spend1), WatchEventSpent(BITCOIN_FUNDING_SPENT, spend2))
system.stop(watcher)
}
test("publish transactions with relative and absolute delays") {
import akka.pattern.pipe
val (probe, listener) = (TestProbe(), TestProbe())
val blockCount = new AtomicLong()
val bitcoinClient = new ExtendedBitcoinClient(bitcoinrpcclient)
val electrumClient = system.actorOf(Props(new ElectrumClientPool(blockCount, Set(electrumAddress))))
bitcoinClient.getBlockCount.pipeTo(probe.ref)
val initialBlockCount = probe.expectMsgType[Long]
probe.send(electrumClient, ElectrumClient.AddStatusListener(probe.ref))
awaitCond(probe.expectMsgType[ElectrumClient.ElectrumReady].height >= initialBlockCount, message = s"waiting for tip at $initialBlockCount")
val watcher = system.actorOf(Props(new ElectrumWatcher(blockCount, electrumClient)))
val recipient = dumpPrivateKey(getNewAddress(probe), probe).publicKey
val address = getNewAddress(probe)
val priv = dumpPrivateKey(address, probe)
val tx = sendToAddress(address, Btc(0.2))
generateBlocks(1)
probe.send(watcher, WatchConfirmed(listener.ref, tx, 1, BITCOIN_FUNDING_DEPTHOK))
assert(listener.expectMsgType[WatchEventConfirmed].tx === tx)
// spend tx with both relative and absolute delays
val spend = createSpendP2WPKH(tx, priv, recipient, 6000 sat, sequence = 1, lockTime = blockCount.get + 2)
probe.send(watcher, WatchSpent(listener.ref, tx, spend.txIn.head.outPoint.index.toInt, BITCOIN_FUNDING_SPENT, hints = Set.empty))
probe.send(watcher, PublishAsap(spend, PublishStrategy.JustPublish))
generateBlocks(2)
listener.expectMsg(WatchEventSpent(BITCOIN_FUNDING_SPENT, spend))
system.stop(watcher)
}
test("generate unique dummy scids") {
// generate 1000 dummy ids
val dummies = (0 until 20).map { _ =>
ElectrumWatcher.makeDummyShortChannelId(randomBytes32)
} toSet
// make sure that they are unique (we allow for 1 collision here, actual probability of a collision with the current impl. is 1%
// but that could change and we don't want to make this test impl. dependent)
// if this test fails it's very likely that the code that generates dummy scids is broken
assert(dummies.size >= 19)
}
test("get transaction") {
val blockCount = new AtomicLong()
val mainnetAddress = ElectrumServerAddress(new InetSocketAddress("electrum.acinq.co", 50002), SSL.STRICT)
val electrumClient = system.actorOf(Props(new ElectrumClientPool(blockCount, Set(mainnetAddress))))
val watcher = system.actorOf(Props(new ElectrumWatcher(blockCount, electrumClient)))
val probe = TestProbe()
{
// tx is in the blockchain
val txid = ByteVector32(hex"c0b18008713360d7c30dae0940d88152a4bbb10faef5a69fefca5f7a7e1a06cc")
probe.send(watcher, GetTxWithMeta(txid))
val res = probe.expectMsgType[GetTxWithMetaResponse]
assert(res.txid === txid)
assert(res.tx_opt === Some(Transaction.read("0100000001b5cbd7615a7494f60304695c180eb255113bd5effcf54aec6c7dfbca67f533a1010000006a473044022042115a5d1a489bbc9bd4348521b098025625c9b6c6474f84b96b11301da17a0602203ccb684b1d133ff87265a6017ef0fdd2d22dd6eef0725c57826f8aaadcc16d9d012103629aa3df53cad290078bbad26491f1e11f9c01697c65db0967561f6f142c993cffffffff02801015000000000017a914b8984d6344eed24689cdbc77adaf73c66c4fdd688734e9e818000000001976a91404607585722760691867b42d43701905736be47d88ac00000000")))
assert(res.lastBlockTimestamp > System.currentTimeMillis().millis.toSeconds - 7200) // this server should be in sync
}
{
// tx doesn't exist
val txid = ByteVector32(hex"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
probe.send(watcher, GetTxWithMeta(txid))
val res = probe.expectMsgType[GetTxWithMetaResponse]
assert(res.txid === txid)
assert(res.tx_opt === None)
assert(res.lastBlockTimestamp > System.currentTimeMillis().millis.toSeconds - 7200) // this server should be in sync
}
system.stop(watcher)
}
}

View file

@ -1,60 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum
import com.spotify.docker.client.{DefaultDockerClient, DockerClient}
import com.whisk.docker.impl.spotify.SpotifyDockerFactory
import com.whisk.docker.scalatest.DockerTestKit
import com.whisk.docker.{DockerContainer, DockerFactory}
import fr.acinq.eclair.TestUtils
import fr.acinq.eclair.blockchain.bitcoind.BitcoindService
import org.scalatest.Suite
import scala.concurrent.duration.DurationInt
trait ElectrumxService extends DockerTestKit {
self: Suite with BitcoindService =>
val electrumPort = TestUtils.availablePort
val electrumxContainer = if (System.getProperty("os.name").startsWith("Linux")) {
// "host" mode will let the container access the host network on linux
// we use our own docker image because other images on Docker lag behind and don't yet support 1.4
DockerContainer("acinq/electrumx")
.withNetworkMode("host")
.withEnv(s"DAEMON_URL=http://foo:bar@localhost:$bitcoindRpcPort", "COIN=BitcoinSegwit", "NET=regtest", s"TCP_PORT=$electrumPort")
//.withLogLineReceiver(LogLineReceiver(true, println))
} else {
// on windows or oxs, host mode is not available, but from docker 18.03 on host.docker.internal can be used instead
// host.docker.internal is not (yet ?) available on linux though
DockerContainer("acinq/electrumx")
.withPorts(electrumPort -> Some(electrumPort))
.withEnv(s"DAEMON_URL=http://foo:bar@host.docker.internal:$bitcoindRpcPort", "COIN=BitcoinSegwit", "NET=regtest", s"TCP_PORT=$electrumPort")
//.withLogLineReceiver(LogLineReceiver(true, println))
}
//override DockerKit timeouts
override val StartContainersTimeout = 60 seconds
override val StopContainersTimeout = 60 seconds
override def dockerContainers: List[DockerContainer] = electrumxContainer :: super.dockerContainers
private val client: DockerClient = DefaultDockerClient.fromEnv().build()
override implicit val dockerFactory: DockerFactory = new SpotifyDockerFactory(client)
}

View file

@ -1,129 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.electrum.db.sqlite
import fr.acinq.bitcoin.{Block, BlockHeader, OutPoint, Satoshi, Transaction, TxIn, TxOut}
import fr.acinq.eclair.blockchain.electrum.ElectrumClient
import fr.acinq.eclair.blockchain.electrum.ElectrumClient.GetMerkleResponse
import fr.acinq.eclair.blockchain.electrum.ElectrumWallet.PersistentData
import fr.acinq.eclair.{TestDatabases, randomBytes, randomBytes32}
import org.scalatest.funsuite.AnyFunSuite
import scodec.Codec
import scodec.bits.BitVector
import scala.util.Random
class SqliteWalletDbSpec extends AnyFunSuite {
val random = new Random()
def makeChildHeader(header: BlockHeader): BlockHeader = header.copy(hashPreviousBlock = header.hash, nonce = random.nextLong() & 0xffffffffL)
def makeHeaders(n: Int, acc: Seq[BlockHeader] = Seq(Block.RegtestGenesisBlock.header)): Seq[BlockHeader] = {
if (acc.size == n) acc else makeHeaders(n, acc :+ makeChildHeader(acc.last))
}
def randomTransaction = Transaction(version = 2,
txIn = TxIn(OutPoint(randomBytes32, random.nextInt(100)), signatureScript = Nil, sequence = TxIn.SEQUENCE_FINAL) :: Nil,
txOut = TxOut(Satoshi(random.nextInt(10000000)), randomBytes(20)) :: Nil,
0L
)
def randomHeight = if (random.nextBoolean()) random.nextInt(500000) else -1
def randomHistoryItem = ElectrumClient.TransactionHistoryItem(randomHeight, randomBytes32)
def randomHistoryItems = (0 to random.nextInt(100)).map(_ => randomHistoryItem).toList
def randomProof = GetMerkleResponse(randomBytes32, ((0 until 10).map(_ => randomBytes32)).toList, random.nextInt(100000), 0, None)
def randomPersistentData = {
val transactions = for (i <- 0 until random.nextInt(100)) yield randomTransaction
PersistentData(
accountKeysCount = 10,
changeKeysCount = 10,
status = (for (i <- 0 until random.nextInt(100)) yield randomBytes32 -> random.nextInt(100000).toHexString).toMap,
transactions = transactions.map(tx => tx.hash -> tx).toMap,
heights = transactions.map(tx => tx.hash -> randomHeight).toMap,
history = (for (i <- 0 until random.nextInt(100)) yield randomBytes32 -> randomHistoryItems).toMap,
proofs = (for (i <- 0 until random.nextInt(100)) yield randomBytes32 -> randomProof).toMap,
pendingTransactions = transactions.toList,
locks = (for (i <- 0 until random.nextInt(10)) yield randomTransaction).toSet
)
}
test("add/get/list headers") {
val db = new SqliteWalletDb(TestDatabases.sqliteInMemory())
val headers = makeHeaders(100)
db.addHeaders(2016, headers)
val headers1 = db.getHeaders(2016, None)
assert(headers1 === headers)
val headers2 = db.getHeaders(2016, Some(50))
assert(headers2 === headers.take(50))
var height = 2016
headers.foreach(header => {
val Some((height1, header1)) = db.getHeader(header.hash)
assert(height1 == height)
assert(header1 == header)
val Some(header2) = db.getHeader(height1)
assert(header2 == header)
height = height + 1
})
}
test("serialize persistent data") {
val db = new SqliteWalletDb(TestDatabases.sqliteInMemory())
assert(db.readPersistentData() == None)
for (i <- 0 until 50) {
val data = randomPersistentData
db.persist(data)
val Some(check) = db.readPersistentData()
assert(check === data.copy(locks = Set.empty[Transaction]))
}
}
test("read old persistent data") {
import SqliteWalletDb._
import scodec.codecs._
def setCodec[T](codec: Codec[T]): Codec[Set[T]] = listOfN(uint16, codec).xmap(_.toSet, _.toList)
val oldPersistentDataCodec: Codec[PersistentData] = (
("version" | constant(BitVector.fromInt(version))) ::
("accountKeysCount" | int32) ::
("changeKeysCount" | int32) ::
("status" | statusCodec) ::
("transactions" | transactionsCodec) ::
("heights" | heightsCodec) ::
("history" | historyCodec) ::
("proofs" | proofsCodec) ::
("pendingTransactions" | listOfN(uint16, txCodec)) ::
("locks" | setCodec(txCodec))).as[PersistentData]
for (_ <- 0 until 50) {
val data = randomPersistentData
val encoded = oldPersistentDataCodec.encode(data).require
val decoded = persistentDataCodec.decode(encoded).require.value
assert(decoded === data.copy(locks = Set.empty[Transaction]))
}
}
}

View file

@ -1,98 +0,0 @@
/*
* Copyright 2019 ACINQ SAS
*
* 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 fr.acinq.eclair.blockchain.fee
import akka.actor.ActorSystem
import akka.util.Timeout
import com.softwaremill.sttp.okhttp.OkHttpFutureBackend
import fr.acinq.bitcoin.{Block, SatoshiLong}
import fr.acinq.eclair.TestTags
import org.json4s.DefaultFormats
import org.scalatest.funsuite.AnyFunSuite
import scala.concurrent.Await
/**
* Created by PM on 27/01/2017.
*/
class BitgoFeeProviderSpec extends AnyFunSuite {
import BitgoFeeProvider._
import org.json4s.jackson.JsonMethods.parse
implicit val formats = DefaultFormats
val sample_response =
"""
{"feePerKb":136797,"cpfpFeePerKb":136797,"numBlocks":2,"confidence":80,"multiplier":1,"feeByBlockTarget":{"1":149453,"2":136797,"5":122390,"6":105566,"8":100149,"9":96254,"10":122151,"13":116855,"15":110860,"17":87402,"27":82635,"33":71098,"42":105782,"49":68182,"73":59207,"97":17336,"121":16577,"193":13545,"313":12268,"529":11122,"553":9139,"577":5395,"793":5070}}
"""
test("parse test") {
val json = parse(sample_response)
val feeRanges = parseFeeRanges(json)
assert(feeRanges.size === 23)
}
test("extract fee for a particular block delay") {
val json = parse(sample_response)
val feeRanges = parseFeeRanges(json)
val fee = extractFeerate(feeRanges, 6)
assert(fee === FeeratePerKB(105566 sat))
}
test("extract all fees") {
val json = parse(sample_response)
val feeRanges = parseFeeRanges(json)
val feerates = extractFeerates(feeRanges)
val ref = FeeratesPerKB(
mempoolMinFee = FeeratePerKB(5070 sat),
block_1 = FeeratePerKB(149453 sat),
blocks_2 = FeeratePerKB(136797 sat),
blocks_6 = FeeratePerKB(105566 sat),
blocks_12 = FeeratePerKB(96254 sat),
blocks_36 = FeeratePerKB(71098 sat),
blocks_72 = FeeratePerKB(68182 sat),
blocks_144 = FeeratePerKB(16577 sat),
blocks_1008 = FeeratePerKB(5070 sat))
assert(feerates === ref)
}
test("make sure API hasn't changed", TestTags.ExternalApi) {
import scala.concurrent.duration._
implicit val system = ActorSystem("test")
implicit val ec = system.dispatcher
implicit val sttp = OkHttpFutureBackend()
implicit val timeout = Timeout(30 seconds)
val bitgo = new BitgoFeeProvider(Block.LivenetGenesisBlock.hash, 5 seconds)
assert(Await.result(bitgo.getFeerates, timeout.duration).block_1.toLong > 0)
}
test("check that read timeout is enforced", TestTags.ExternalApi) {
import scala.concurrent.duration._
implicit val system = ActorSystem("test")
implicit val ec = system.dispatcher
implicit val sttp = OkHttpFutureBackend()
implicit val timeout = Timeout(30 second)
val bitgo = new BitgoFeeProvider(Block.LivenetGenesisBlock.hash, 1 millisecond)
val e = intercept[Exception] {
Await.result(bitgo.getFeerates, timeout.duration)
}
assert(e.getMessage.contains("timed out") || e.getMessage.contains("timeout"))
}
}

File diff suppressed because one or more lines are too long

View file

@ -21,7 +21,6 @@ import java.io.File
import akka.actor.{ActorSystem, Props, SupervisorStrategy}
import fr.acinq.eclair._
import fr.acinq.eclair.blockchain.bitcoind.zmq.ZMQActor._
import fr.acinq.eclair.blockchain.electrum.ElectrumClient.ElectrumEvent
import fr.acinq.eclair.channel.ChannelEvent
import fr.acinq.eclair.gui.controllers.{MainController, NotificationsController}
import fr.acinq.eclair.payment.PaymentEvent
@ -100,7 +99,6 @@ class FxApp extends Application with Logging {
system.eventStream.subscribe(guiUpdater, classOf[NetworkEvent])
system.eventStream.subscribe(guiUpdater, classOf[PaymentEvent])
system.eventStream.subscribe(guiUpdater, classOf[ZMQEvent])
system.eventStream.subscribe(guiUpdater, classOf[ElectrumEvent])
pKit.completeWith(setup.bootstrap)
pKit.future.onComplete {
case Success(kit) =>

View file

@ -23,7 +23,6 @@ import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.bitcoin._
import fr.acinq.eclair.CoinUtils
import fr.acinq.eclair.blockchain.bitcoind.zmq.ZMQActor.{ZMQConnected, ZMQDisconnected}
import fr.acinq.eclair.blockchain.electrum.ElectrumClient.{ElectrumDisconnected, ElectrumReady}
import fr.acinq.eclair.channel._
import fr.acinq.eclair.gui.controllers._
import fr.acinq.eclair.payment._
@ -230,12 +229,5 @@ class GUIUpdater(mainController: MainController) extends Actor with ActorLogging
log.debug("ZMQ connection DOWN")
runInGuiThread(() => mainController.showBlockerModal("Bitcoin Core"))
case _: ElectrumReady =>
log.debug("Electrum connection UP")
runInGuiThread(() => mainController.hideBlockerModal)
case ElectrumDisconnected =>
log.debug("Electrum connection DOWN")
runInGuiThread(() => mainController.showBlockerModal("Electrum"))
}
}

View file

@ -24,7 +24,7 @@ import java.util.Locale
import com.google.common.net.HostAndPort
import fr.acinq.bitcoin.Crypto.PublicKey
import fr.acinq.bitcoin.Satoshi
import fr.acinq.eclair.NodeParams.{BITCOIND, ELECTRUM}
import fr.acinq.eclair.NodeParams.BITCOIND
import fr.acinq.eclair.gui.stages._
import fr.acinq.eclair.gui.utils.{ContextMenuUtils, CopyAction, IndexedObservableList}
import fr.acinq.eclair.gui.{FxApp, Handlers}
@ -359,11 +359,7 @@ class MainController(val handlers: Handlers, val hostServices: HostServices) ext
labelApi.setText(s"${setup.config.getInt("api.port")}")
labelServer.setText(s"${setup.config.getInt("server.port")}")
val wallet = setup.nodeParams.watcherType match {
case BITCOIND => "Bitcoin-core"
case ELECTRUM => "Electrum"
}
bitcoinWallet.setText(wallet)
bitcoinWallet.setText("Bitcoin-core")
bitcoinChain.setText(s"${setup.chain.toUpperCase()}")
bitcoinChain.getStyleClass.add(setup.chain)