1
0
mirror of https://github.com/ACINQ/eclair.git synced 2024-11-20 02:27:32 +01:00

Make publishTransaction idempotent (#711)

Bitcoin core returns an error `missing inputs (code: -25)` if the tx that we want to publish has already been published and its output have been spent. When we receive this error, we try to get the tx, in order to know if it is in the blockchain, or if its inputs were spent by another tx.

Note: If the outputs of the tx were still unspent, bitcoin core would return "transaction already in block chain (code: -27)" and this is already handled.
This commit is contained in:
Pierre-Marie Padiou 2018-09-20 17:22:38 +02:00 committed by GitHub
parent 6f2a74e030
commit 8160e793e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 138 additions and 7 deletions

View File

@ -190,7 +190,6 @@ class ZmqWatcher(client: ExtendedBitcoinClient)(implicit ec: ExecutionContext =
import scala.concurrent.duration._
after(3 seconds, context.system.scheduler)(Future.successful({})).map(x => publish(tx, isRetry = true))
case t: Throwable if t.getMessage.contains("(code: -27)") => () // "transaction already in block chain (code: -27)" ignore error
case t: Throwable => log.error(s"cannot publish tx: reason=${t.getMessage} txid=${tx.txid} tx=$tx")
}
}

View File

@ -106,13 +106,27 @@ class ExtendedBitcoinClient(val rpcClient: BitcoinJsonRPCClient) {
future
}
def publishTransaction(hex: String)(implicit ec: ExecutionContext): Future[String] =
rpcClient.invoke("sendrawtransaction", hex) collect {
case JString(txid) => txid
}
/**
* Publish a transaction on the bitcoin network.
*
* Note that this method is idempotent, meaning that if the tx was already published a long time ago, then this is
* considered a success even if bitcoin core rejects this new attempt.
*
* @param tx
* @param ec
* @return
*/
def publishTransaction(tx: Transaction)(implicit ec: ExecutionContext): Future[String] =
publishTransaction(tx.toString())
rpcClient.invoke("sendrawtransaction", tx.toString()) collect {
case JString(txid) => txid
} recoverWith {
case JsonRPCError(Error(-27, _)) =>
// "transaction already in block chain (code: -27)" ignore error
Future.successful(tx.txid.toString())
case e@JsonRPCError(Error(-25, _)) =>
// "missing inputs (code: -25)" it may be that the tx has already been published and its output spent
getRawTransaction(tx.txid.toString()).map { case _ => tx.txid.toString() }.recoverWith { case _ => Future.failed[String](e) }
}
/**
* We need this to compute absolute timeouts expressed in number of blocks (where getBlockCount would be equivalent

View File

@ -0,0 +1,118 @@
/*
* Copyright 2018 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.bitcoind
import akka.actor.ActorSystem
import akka.actor.Status.Failure
import akka.pattern.pipe
import akka.testkit.{TestKit, TestProbe}
import com.typesafe.config.ConfigFactory
import fr.acinq.bitcoin.Transaction
import fr.acinq.eclair.blockchain.bitcoind.rpc.{BasicBitcoinJsonRPCClient, ExtendedBitcoinClient}
import grizzled.slf4j.Logging
import org.json4s.JsonAST._
import org.json4s.{DefaultFormats, JString}
import org.junit.runner.RunWith
import org.scalatest.junit.JUnitRunner
import org.scalatest.{BeforeAndAfterAll, FunSuiteLike}
import scala.collection.JavaConversions._
import scala.concurrent.ExecutionContext.Implicits.global
@RunWith(classOf[JUnitRunner])
class ExtendedBitcoinClientSpec extends TestKit(ActorSystem("test")) with BitcoindService with FunSuiteLike with BeforeAndAfterAll with Logging {
val commonConfig = ConfigFactory.parseMap(Map("eclair.chain" -> "regtest", "eclair.spv" -> false, "eclair.server.public-ips.1" -> "localhost", "eclair.bitcoind.port" -> 28333, "eclair.bitcoind.rpcport" -> 28332, "eclair.bitcoind.zmq" -> "tcp://127.0.0.1:28334", "eclair.router-broadcast-interval" -> "2 second", "eclair.auto-reconnect" -> false))
val config = ConfigFactory.load(commonConfig).getConfig("eclair")
implicit val formats = DefaultFormats
override def beforeAll(): Unit = {
startBitcoind()
}
override def afterAll(): Unit = {
stopBitcoind()
}
test("wait bitcoind ready") {
waitForBitcoindReady()
}
test("send transaction idempotent") {
val bitcoinClient = new BasicBitcoinJsonRPCClient(
user = config.getString("bitcoind.rpcuser"),
password = config.getString("bitcoind.rpcpassword"),
host = config.getString("bitcoind.host"),
port = config.getInt("bitcoind.rpcport"))
val sender = TestProbe()
bitcoinClient.invoke("getnewaddress").pipeTo(sender.ref)
val JString(address) = sender.expectMsgType[JString]
bitcoinClient.invoke("createrawtransaction", Array.empty, Map(address -> 6)).pipeTo(sender.ref)
val JString(noinputTx) = sender.expectMsgType[JString]
bitcoinClient.invoke("fundrawtransaction", noinputTx).pipeTo(sender.ref)
val json = sender.expectMsgType[JValue]
val JString(unsignedtx) = json \ "hex"
val JInt(changePos) = json \ "changepos"
bitcoinClient.invoke("signrawtransaction", unsignedtx).pipeTo(sender.ref)
val JString(signedTx) = sender.expectMsgType[JValue] \ "hex"
val tx = Transaction.read(signedTx)
val txid = tx.txid.toString()
// test starts here
val client = new ExtendedBitcoinClient(bitcoinClient)
// we publish it a first time
client.publishTransaction(tx).pipeTo(sender.ref)
sender.expectMsg(txid)
// we publish the tx a second time to test idempotence
client.publishTransaction(tx).pipeTo(sender.ref)
sender.expectMsg(txid)
// let's confirm the tx
bitcoinClient.invoke("generate", 1).pipeTo(sender.ref)
sender.expectMsgType[JValue]
// and publish the tx a third time to test idempotence
client.publishTransaction(tx).pipeTo(sender.ref)
sender.expectMsg(txid)
// now let's spent the output of the tx
val spendingTx = {
val pos = if (changePos == 0) 1 else 0
bitcoinClient.invoke("createrawtransaction", Array(Map("txid" -> txid, "vout" -> pos)), Map(address -> 5.99999)).pipeTo(sender.ref)
val JString(unsignedtx) = sender.expectMsgType[JValue]
bitcoinClient.invoke("signrawtransaction", unsignedtx).pipeTo(sender.ref)
val JString(signedTx) = sender.expectMsgType[JValue] \ "hex"
signedTx
}
bitcoinClient.invoke("sendrawtransaction", spendingTx).pipeTo(sender.ref)
val JString(spendingTxid) = sender.expectMsgType[JValue]
// and publish the tx a fourth time to test idempotence
client.publishTransaction(tx).pipeTo(sender.ref)
sender.expectMsg(txid)
// let's confirm the tx
bitcoinClient.invoke("generate", 1).pipeTo(sender.ref)
sender.expectMsgType[JValue]
// and publish the tx a fifth time to test idempotence
client.publishTransaction(tx).pipeTo(sender.ref)
sender.expectMsg(txid)
// this one should be rejected
client.publishTransaction(Transaction.read("02000000000101b9e2a3f518fd74e696d258fed3c78c43f84504e76c99212e01cf225083619acf00000000000d0199800136b34b00000000001600145464ce1e5967773922506e285780339d72423244040047304402206795df1fd93c285d9028c384aacf28b43679f1c3f40215fd7bd1abbfb816ee5a022047a25b8c128e692d4717b6dd7b805aa24ecbbd20cfd664ab37a5096577d4a15d014730440220770f44121ed0e71ec4b482dded976f2febd7500dfd084108e07f3ce1e85ec7f5022025b32dc0d551c47136ce41bfb80f5a10de95c0babb22a3ae2d38e6688b32fcb20147522102c2662ab3e4fa18a141d3be3317c6ee134aff10e6cd0a91282a25bf75c0481ebc2102e952dd98d79aa796289fa438e4fdeb06ed8589ff2a0f032b0cfcb4d7b564bc3252aea58d1120")).pipeTo(sender.ref)
sender.expectMsgType[Failure]
}
}