diff --git a/server/app/com/xsn/explorer/services/TransactionService.scala b/server/app/com/xsn/explorer/services/TransactionService.scala index ae8d8e2..a6085db 100644 --- a/server/app/com/xsn/explorer/services/TransactionService.scala +++ b/server/app/com/xsn/explorer/services/TransactionService.scala @@ -5,15 +5,14 @@ import javax.inject.Inject import com.alexitc.playsonify.core.FutureApplicationResult import com.alexitc.playsonify.core.FutureOr.Implicits.{FutureOps, OrOps} import com.xsn.explorer.errors.TransactionFormatError -import com.xsn.explorer.models.TransactionId -import org.scalactic.{One, Or} -import play.api.libs.json.JsValue +import com.xsn.explorer.models.{TransactionDetails, TransactionId} +import org.scalactic.{Good, One, Or} -import scala.concurrent.ExecutionContext +import scala.concurrent.{ExecutionContext, Future} class TransactionService @Inject() (xsnService: XSNService)(implicit ec: ExecutionContext) { - def getTransaction(txidString: String): FutureApplicationResult[JsValue] = { + def getTransaction(txidString: String): FutureApplicationResult[TransactionDetails] = { val result = for { txid <- { val maybe = TransactionId.from(txidString) @@ -21,7 +20,19 @@ class TransactionService @Inject() (xsnService: XSNService)(implicit ec: Executi } transaction <- xsnService.getTransaction(txid).toFutureOr - } yield transaction + + previousMaybe <- transaction + .vin + .map(_.txid) + .map(xsnService.getTransaction) + .map { f => f.toFutureOr.map(Option.apply).toFuture } + .getOrElse { Future.successful(Good(Option.empty))} + .toFutureOr + } yield { + previousMaybe + .map { previous => TransactionDetails.from(transaction, previous) } + .getOrElse { TransactionDetails.from(transaction) } + } result.toFuture } diff --git a/server/app/com/xsn/explorer/services/XSNService.scala b/server/app/com/xsn/explorer/services/XSNService.scala index b8dba80..3ffb219 100644 --- a/server/app/com/xsn/explorer/services/XSNService.scala +++ b/server/app/com/xsn/explorer/services/XSNService.scala @@ -7,7 +7,7 @@ import com.alexitc.playsonify.models.ApplicationError import com.xsn.explorer.config.RPCConfig import com.xsn.explorer.errors.{TransactionNotFoundError, XSNMessageError, XSNUnexpectedResponseError} import com.xsn.explorer.executors.ExternalServiceExecutionContext -import com.xsn.explorer.models.TransactionId +import com.xsn.explorer.models.{Transaction, TransactionId} import org.scalactic.{Bad, Good} import org.slf4j.LoggerFactory import play.api.libs.json.{JsNull, JsValue} @@ -17,7 +17,7 @@ import scala.util.Try trait XSNService { - def getTransaction(txid: TransactionId): FutureApplicationResult[JsValue] + def getTransaction(txid: TransactionId): FutureApplicationResult[Transaction] } class XSNServiceRPCImpl @Inject() ( @@ -32,18 +32,17 @@ class XSNServiceRPCImpl @Inject() ( .withAuth(rpcConfig.username.string, rpcConfig.password.string, WSAuthScheme.BASIC) .withHttpHeaders("Content-Type" -> "text/plain") - // TODO: cache successful results - override def getTransaction(txid: TransactionId): FutureApplicationResult[JsValue] = { + override def getTransaction(txid: TransactionId): FutureApplicationResult[Transaction] = { server .post(s"""{ "jsonrpc": "1.0", "method": "getrawtransaction", "params": ["${txid.string}", 1] }""") .map { response => - val maybe = Try(response.json) - .toOption + val maybe = Option(response) + .filter(_.status == 200) + .flatMap { r => Try(r.json).toOption } .flatMap { json => (json \ "result") - .asOpt[JsValue] - .filter(_ != JsNull) + .asOpt[Transaction] .map { Good(_) } .orElse { mapError(json) @@ -53,7 +52,7 @@ class XSNServiceRPCImpl @Inject() ( } maybe.getOrElse { - logger.warn(s"Unexpected response from XSN Server, txid = ${txid.string}, response = ${response.body}") + logger.warn(s"Unexpected response from XSN Server, txid = ${txid.string}, status = ${response.status}, response = ${response.body}") Bad(XSNUnexpectedResponseError).accumulating } diff --git a/server/test/com/xsn/explorer/services/XSNServiceRPCImplSpec.scala b/server/test/com/xsn/explorer/services/XSNServiceRPCImplSpec.scala index e81ed2a..339a9bf 100644 --- a/server/test/com/xsn/explorer/services/XSNServiceRPCImplSpec.scala +++ b/server/test/com/xsn/explorer/services/XSNServiceRPCImplSpec.scala @@ -9,13 +9,13 @@ import org.mockito.Mockito._ import org.scalactic.Bad import org.scalatest.concurrent.ScalaFutures import org.scalatest.mockito.MockitoSugar -import org.scalatest.{MustMatchers, WordSpec} -import play.api.libs.json.Json +import org.scalatest.{MustMatchers, OptionValues, WordSpec} +import play.api.libs.json.{JsNull, Json} import play.api.libs.ws.{WSClient, WSRequest, WSResponse} import scala.concurrent.Future -class XSNServiceRPCImplSpec extends WordSpec with MustMatchers with ScalaFutures with MockitoSugar { +class XSNServiceRPCImplSpec extends WordSpec with MustMatchers with ScalaFutures with MockitoSugar with OptionValues { val ws = mock[WSClient] val ec = Executors.externalServiceEC @@ -35,7 +35,7 @@ class XSNServiceRPCImplSpec extends WordSpec with MustMatchers with ScalaFutures val txid = TransactionId.from("024aba1d535cfe5dd3ea465d46a828a57b00e1df012d7a2d158e0f7484173f7c").get "getTransaction" should { - "handle a successful result" in { + "handle coinbase" in { val responseBody = """ |{ @@ -86,6 +86,98 @@ class XSNServiceRPCImplSpec extends WordSpec with MustMatchers with ScalaFutures whenReady(service.getTransaction(txid)) { result => result.isGood mustEqual true + + val tx = result.get + tx.id.string mustEqual "024aba1d535cfe5dd3ea465d46a828a57b00e1df012d7a2d158e0f7484173f7c" + tx.vin.isEmpty mustEqual true + tx.vout.size mustEqual 1 + } + } + "handle non-coinbase result" in { + val responseBody = + """ + |{ + | "result": { + | "hex": "0100000001d967897603771672654db507a02ceb65dea8a682d2333ee819cac80950ec5c58020000006a473044022059a0cc21ad24ae18726d128c85328a0b54dab62aeb41ffbcad368ece6fdf9d2602200e477332401ce1296d379dc5f797720e854e40fc5af0a268f585e7dae64d38e5012103624fbfb0079e85bbc9aaeba6f48581326ad01194b3c54ce22852a27b1d2892d1ffffffff03000000000000000000220935d7946a00001976a9143cc9ede1da2d7351aaebaf6a25d2657e0b05a71688ac220935d7946a00001976a9143cc9ede1da2d7351aaebaf6a25d2657e0b05a71688ac00000000", + | "txid": "0834641a7d30d8a2d2b451617599670445ee94ed7736e146c13be260c576c641", + | "size": 234, + | "version": 1, + | "locktime": 0, + | "vin": [ + | { + | "txid": "585cec5009c8ca19e83e33d282a6a8de65eb2ca007b54d6572167703768967d9", + | "vout": 2, + | "scriptSig": { + | "asm": "3044022059a0cc21ad24ae18726d128c85328a0b54dab62aeb41ffbcad368ece6fdf9d2602200e477332401ce1296d379dc5f797720e854e40fc5af0a268f585e7dae64d38e5[ALL] 03624fbfb0079e85bbc9aaeba6f48581326ad01194b3c54ce22852a27b1d2892d1", + | "hex": "473044022059a0cc21ad24ae18726d128c85328a0b54dab62aeb41ffbcad368ece6fdf9d2602200e477332401ce1296d379dc5f797720e854e40fc5af0a268f585e7dae64d38e5012103624fbfb0079e85bbc9aaeba6f48581326ad01194b3c54ce22852a27b1d2892d1" + | }, + | "sequence": 4294967295 + | } + | ], + | "vout": [ + | { + | "value": 0.00000000, + | "valueSat": 0, + | "n": 0, + | "scriptPubKey": { + | "asm": "", + | "hex": "", + | "type": "nonstandard" + | } + | }, + | { + | "value": 1171874.98281250, + | "valueSat": 117187498281250, + | "n": 1, + | "scriptPubKey": { + | "asm": "OP_DUP OP_HASH160 3cc9ede1da2d7351aaebaf6a25d2657e0b05a716 OP_EQUALVERIFY OP_CHECKSIG", + | "hex": "76a9143cc9ede1da2d7351aaebaf6a25d2657e0b05a71688ac", + | "reqSigs": 1, + | "type": "pubkeyhash", + | "addresses": [ + | "XgEGH3y7RfeKEdn2hkYEvBnrnmGBr7zvjL" + | ] + | } + | }, + | { + | "value": 1171874.98281250, + | "valueSat": 117187498281250, + | "n": 2, + | "scriptPubKey": { + | "asm": "OP_DUP OP_HASH160 3cc9ede1da2d7351aaebaf6a25d2657e0b05a716 OP_EQUALVERIFY OP_CHECKSIG", + | "hex": "76a9143cc9ede1da2d7351aaebaf6a25d2657e0b05a71688ac", + | "reqSigs": 1, + | "type": "pubkeyhash", + | "addresses": [ + | "XgEGH3y7RfeKEdn2hkYEvBnrnmGBr7zvjL" + | ] + | } + | } + | ], + | "blockhash": "b72dd1655408e9307ef5874be20422ee71029333283e2360975bc6073bdb2b81", + | "height": 809, + | "confirmations": 1950, + | "time": 1520318120, + | "blocktime": 1520318120 + | }, + | "error": null, + | "id": null + |} + """.stripMargin.trim + + val json = Json.parse(responseBody) + + when(response.status).thenReturn(200) + when(response.json).thenReturn(json) + when(request.post[String](anyString())(any())).thenReturn(Future.successful(response)) + + whenReady(service.getTransaction(txid)) { result => + result.isGood mustEqual true + + val tx = result.get + tx.id.string mustEqual "0834641a7d30d8a2d2b451617599670445ee94ed7736e146c13be260c576c641" + tx.vin.value.txid.string mustEqual "585cec5009c8ca19e83e33d282a6a8de65eb2ca007b54d6572167703768967d9" + tx.vout.size mustEqual 3 } } @@ -115,6 +207,16 @@ class XSNServiceRPCImplSpec extends WordSpec with MustMatchers with ScalaFutures } } + "handle non successful status" in { + when(response.status).thenReturn(403) + when(response.json).thenReturn(JsNull) + when(request.post[String](anyString())(any())).thenReturn(Future.successful(response)) + + whenReady(service.getTransaction(txid)) { result => + result mustEqual Bad(XSNUnexpectedResponseError).accumulating + } + } + "handle unexpected error" in { val responseBody = """{"result":null,"error":{},"id":null}""" val json = Json.parse(responseBody) diff --git a/server/test/controllers/TransactionsControllerSpec.scala b/server/test/controllers/TransactionsControllerSpec.scala index 4c00be5..acfd1b3 100644 --- a/server/test/controllers/TransactionsControllerSpec.scala +++ b/server/test/controllers/TransactionsControllerSpec.scala @@ -3,62 +3,67 @@ package controllers import com.alexitc.playsonify.PublicErrorRenderer import com.alexitc.playsonify.core.FutureApplicationResult import com.xsn.explorer.errors.TransactionNotFoundError -import com.xsn.explorer.models.TransactionId +import com.xsn.explorer.models._ import com.xsn.explorer.services.XSNService import controllers.common.MyAPISpec import org.scalactic.{Bad, Good} import play.api.inject.bind -import play.api.libs.json.{JsValue, Json} +import play.api.libs.json.JsValue import play.api.test.Helpers._ import scala.concurrent.Future class TransactionsControllerSpec extends MyAPISpec { + val coinbaseTx: Transaction = Transaction( + id = TransactionId.from("024aba1d535cfe5dd3ea465d46a828a57b00e1df012d7a2d158e0f7484173f7c").get, + size = 98, + blockhash = Blockhash.from("000003fb382f6892ae96594b81aa916a8923c70701de4e7054aac556c7271ef7").get, + time = 1520276270L, + blocktime = 1520276270L, + confirmations = 5347, + vin = None, + vout = List( + TransactionVOUT(n = 0, address = Address.from("XdJnCKYNwzCz8ATv8Eu75gonaHyfr9qXg9"), value = 0, scriptPubKeyType = "pubkey")) + ) + + val nonCoinbaseTx: Transaction = Transaction( + id = TransactionId.from("0834641a7d30d8a2d2b451617599670445ee94ed7736e146c13be260c576c641").get, + size = 234, + blockhash = Blockhash.from("b72dd1655408e9307ef5874be20422ee71029333283e2360975bc6073bdb2b81").get, + time = 1520318120, + blocktime = 1520318120, + confirmations = 1950, + vin = Some( + TransactionVIN(TransactionId.from("585cec5009c8ca19e83e33d282a6a8de65eb2ca007b54d6572167703768967d9").get, 2)), + vout = List( + TransactionVOUT(n = 1, value = BigDecimal("1171874.98281250"), scriptPubKeyType = "pubkeyhash", address = Address.from("XgEGH3y7RfeKEdn2hkYEvBnrnmGBr7zvjL")), + TransactionVOUT(n = 2, value = BigDecimal("1171874.98281250"), scriptPubKeyType = "pubkeyhash", address = Address.from("XgEGH3y7RfeKEdn2hkYEvBnrnmGBr7zvjL"))) + ) + + val nonCoinbasePreviousTx: Transaction = Transaction( + id = TransactionId.from("585cec5009c8ca19e83e33d282a6a8de65eb2ca007b54d6572167703768967d9").get, + size = 235, + blockhash = Blockhash.from("cc4ccf19cfb9fa373ba8da68c7d25266d675a2414db603edb3cc88f866a782ea").get, + time = 1520314409, + blocktime = 1520314409, + confirmations = 11239, + vin = Some( + TransactionVIN(TransactionId.from("fd74206866fc4ed986d39084eb9f20de6cb324b028693f33d60897ac995fff4f").get, 2)), + vout = List( + TransactionVOUT(n = 1, value = BigDecimal("2343749.96562500"), scriptPubKeyType = "pubkeyhash", address = Address.from("XgEGH3y7RfeKEdn2hkYEvBnrnmGBr7zvjL")), + TransactionVOUT(n = 2, value = BigDecimal("2343749.96562500"), scriptPubKeyType = "pubkeyhash", address = Address.from("XgEGH3y7RfeKEdn2hkYEvBnrnmGBr7zvjL"))) + ) + val customXSNService = new XSNService { val map = Map( - TransactionId.from("024aba1d535cfe5dd3ea465d46a828a57b00e1df012d7a2d158e0f7484173f7c").get -> - s""" - |{ - | "blockhash": "000003fb382f6892ae96594b81aa916a8923c70701de4e7054aac556c7271ef7", - | "blocktime": 1520276270, - | "confirmations": 5347, - | "height": 1, - | "hex": "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff03510101ffffffff010000000000000000232103e8c52f2c5155771492907095753a43ce776e1fa7c5e769a67a9f3db4467ec029ac00000000", - | "locktime": 0, - | "size": 98, - | "time": 1520276270, - | "txid": "024aba1d535cfe5dd3ea465d46a828a57b00e1df012d7a2d158e0f7484173f7c", - | "version": 1, - | "vin": [ - | { - | "coinbase": "510101", - | "sequence": 4294967295 - | } - | ], - | "vout": [ - | { - | "n": 0, - | "scriptPubKey": { - | "addresses": [ - | "XdJnCKYNwzCz8ATv8Eu75gonaHyfr9qXg9" - | ], - | "asm": "03e8c52f2c5155771492907095753a43ce776e1fa7c5e769a67a9f3db4467ec029 OP_CHECKSIG", - | "hex": "2103e8c52f2c5155771492907095753a43ce776e1fa7c5e769a67a9f3db4467ec029ac", - | "reqSigs": 1, - | "type": "pubkey" - | }, - | "value": 0, - | "valueSat": 0 - | } - | ] - |} - """.stripMargin.trim + "024aba1d535cfe5dd3ea465d46a828a57b00e1df012d7a2d158e0f7484173f7c" -> coinbaseTx, + "0834641a7d30d8a2d2b451617599670445ee94ed7736e146c13be260c576c641" -> nonCoinbaseTx, + "585cec5009c8ca19e83e33d282a6a8de65eb2ca007b54d6572167703768967d9" -> nonCoinbasePreviousTx ) - override def getTransaction(txid: TransactionId): FutureApplicationResult[JsValue] = { - val result = map.get(txid) - .map(s => Json.toJson(s)) + override def getTransaction(txid: TransactionId): FutureApplicationResult[Transaction] = { + val result = map.get(txid.string) .map(Good(_)) .getOrElse { Bad(TransactionNotFoundError).accumulating @@ -75,14 +80,55 @@ class TransactionsControllerSpec extends MyAPISpec { "GET /transactions/:txid" should { def url(txid: String) = s"/transactions/$txid" - "return an existing transaction" in { - val txid = "024aba1d535cfe5dd3ea465d46a828a57b00e1df012d7a2d158e0f7484173f7c" - val response = GET(url(txid)) + "return coinbase transaction" in { + val tx = coinbaseTx + val response = GET(url(tx.id.string)) + + status(response) mustEqual OK + val json = contentAsJson(response) + (json \ "id").as[String] mustEqual tx.id.string + (json \ "blockhash").as[String] mustEqual tx.blockhash.string + (json \ "size").as[Int] mustEqual tx.size + (json \ "time").as[Long] mustEqual tx.time + (json \ "blocktime").as[Long] mustEqual tx.blocktime + (json \ "confirmations").as[Int] mustEqual tx.confirmations + + val outputJsonList = (json \ "output").as[List[JsValue]] + outputJsonList.size mustEqual 1 + + val outputJson = outputJsonList.head + (outputJson \ "address").as[String] mustEqual tx.vout.head.address.get.string + (outputJson \ "value").as[BigDecimal] mustEqual tx.vout.head.value + } + + "return non-coinbase transaction" in { + val tx = nonCoinbaseTx + val details = TransactionDetails.from(nonCoinbaseTx, nonCoinbasePreviousTx) + val response = GET(url(tx.id.string)) status(response) mustEqual OK - // TODO: Match result - //val json = contentAsJson(response) - //(json \ "txid").as[String] mustEqual txid + val json = contentAsJson(response) + (json \ "id").as[String] mustEqual tx.id.string + (json \ "blockhash").as[String] mustEqual tx.blockhash.string + (json \ "size").as[Int] mustEqual tx.size + (json \ "time").as[Long] mustEqual tx.time + (json \ "blocktime").as[Long] mustEqual tx.blocktime + (json \ "confirmations").as[Int] mustEqual tx.confirmations + + val inputJson = (json \ "input").as[JsValue] + (inputJson \ "address").as[String] mustEqual details.input.get.address.string + (inputJson \ "value").as[BigDecimal] mustEqual details.input.get.value + + val outputJsonList = (json \ "output").as[List[JsValue]] + outputJsonList.size mustEqual 2 + + val outputJson = outputJsonList.head + (outputJson \ "address").as[String] mustEqual details.output.head.address.string + (outputJson \ "value").as[BigDecimal] mustEqual details.output.head.value + + val outputJson2 = outputJsonList.drop(1).head + (outputJson2 \ "address").as[String] mustEqual details.output.drop(1).head.address.string + (outputJson2 \ "value").as[BigDecimal] mustEqual details.output.drop(1).head.value } "fail on wrong transaction format" in {