From 9a34faa6153f47fe92e36b9c8bbf8506a0f4f3f5 Mon Sep 17 00:00:00 2001 From: Alexis Hernandez Date: Fri, 16 Mar 2018 22:08:03 -0600 Subject: [PATCH] server: Add GET /blocks/:blockhash --- .../xsn/explorer/models/BlockDetails.scala | 11 + .../com/xsn/explorer/models/BlockReward.scala | 9 + .../xsn/explorer/models/BlockRewards.scala | 9 + .../xsn/explorer/services/BlockService.scala | 56 ++++ .../explorer/services/logic/BlockLogic.scala | 77 ++++++ server/app/controllers/BlocksController.scala | 16 ++ server/conf/routes | 2 + .../controllers/BlocksControllerSpec.scala | 252 ++++++++++++++++++ 8 files changed, 432 insertions(+) create mode 100644 server/app/com/xsn/explorer/models/BlockDetails.scala create mode 100644 server/app/com/xsn/explorer/models/BlockReward.scala create mode 100644 server/app/com/xsn/explorer/models/BlockRewards.scala create mode 100644 server/app/com/xsn/explorer/services/BlockService.scala create mode 100644 server/app/com/xsn/explorer/services/logic/BlockLogic.scala create mode 100644 server/app/controllers/BlocksController.scala create mode 100644 server/test/controllers/BlocksControllerSpec.scala diff --git a/server/app/com/xsn/explorer/models/BlockDetails.scala b/server/app/com/xsn/explorer/models/BlockDetails.scala new file mode 100644 index 0000000..8e49025 --- /dev/null +++ b/server/app/com/xsn/explorer/models/BlockDetails.scala @@ -0,0 +1,11 @@ +package com.xsn.explorer.models + +import play.api.libs.json.{Json, Writes} + +case class BlockDetails(block: Block, rewards: BlockRewards) + +object BlockDetails { + + implicit val reads: Writes[BlockDetails] = Json.writes[BlockDetails] + +} diff --git a/server/app/com/xsn/explorer/models/BlockReward.scala b/server/app/com/xsn/explorer/models/BlockReward.scala new file mode 100644 index 0000000..918c9be --- /dev/null +++ b/server/app/com/xsn/explorer/models/BlockReward.scala @@ -0,0 +1,9 @@ +package com.xsn.explorer.models + +import play.api.libs.json.{Json, Writes} + +case class BlockReward(address: Address, value: BigDecimal) + +object BlockReward { + implicit val writes: Writes[BlockReward] = Json.writes[BlockReward] +} diff --git a/server/app/com/xsn/explorer/models/BlockRewards.scala b/server/app/com/xsn/explorer/models/BlockRewards.scala new file mode 100644 index 0000000..ff0857c --- /dev/null +++ b/server/app/com/xsn/explorer/models/BlockRewards.scala @@ -0,0 +1,9 @@ +package com.xsn.explorer.models + +import play.api.libs.json.{Json, Writes} + +case class BlockRewards(coinstake: BlockReward, masternode: Option[BlockReward]) + +object BlockRewards { + implicit val writes: Writes[BlockRewards] = Json.writes[BlockRewards] +} diff --git a/server/app/com/xsn/explorer/services/BlockService.scala b/server/app/com/xsn/explorer/services/BlockService.scala new file mode 100644 index 0000000..0a34748 --- /dev/null +++ b/server/app/com/xsn/explorer/services/BlockService.scala @@ -0,0 +1,56 @@ +package com.xsn.explorer.services + +import javax.inject.Inject + +import com.alexitc.playsonify.core.FutureApplicationResult +import com.alexitc.playsonify.core.FutureOr.Implicits.{FutureOps, OrOps} +import com.xsn.explorer.errors.BlockNotFoundError +import com.xsn.explorer.models._ +import com.xsn.explorer.services.logic.{BlockLogic, TransactionLogic} + +import scala.concurrent.ExecutionContext + +class BlockService @Inject() ( + xsnService: XSNService, + blockLogic: BlockLogic, + transactionLogic: TransactionLogic)( + implicit ec: ExecutionContext) { + + def getDetails(blockhashString: String): FutureApplicationResult[BlockDetails] = { + val result = for { + blockhash <- blockLogic + .getBlockhash(blockhashString) + .toFutureOr + block <- xsnService + .getBlock(blockhash) + .toFutureOr + + coinstakeTxId <- blockLogic + .getCoinstakeTransactionId(block) + .toFutureOr + coinstakeTx <- xsnService + .getTransaction(coinstakeTxId) + .toFutureOr + coinstakeTxVIN <- transactionLogic + .getVIN(coinstakeTx, BlockNotFoundError) + .toFutureOr + + previousToCoinstakeTx <- xsnService + .getTransaction(coinstakeTxVIN.txid) + .toFutureOr + previousToCoinstakeVOUT <- transactionLogic + .getVOUT(coinstakeTxVIN, previousToCoinstakeTx, BlockNotFoundError) + .toFutureOr + + coinstakeAddress <- transactionLogic + .getAddress(previousToCoinstakeVOUT, BlockNotFoundError) + .toFutureOr + + rewards <- blockLogic + .getRewards(coinstakeTx, coinstakeAddress, previousToCoinstakeVOUT.value) + .toFutureOr + } yield BlockDetails(block, rewards) + + result.toFuture + } +} diff --git a/server/app/com/xsn/explorer/services/logic/BlockLogic.scala b/server/app/com/xsn/explorer/services/logic/BlockLogic.scala new file mode 100644 index 0000000..1b2c829 --- /dev/null +++ b/server/app/com/xsn/explorer/services/logic/BlockLogic.scala @@ -0,0 +1,77 @@ +package com.xsn.explorer.services.logic + +import com.alexitc.playsonify.core.ApplicationResult +import com.xsn.explorer.errors.{BlockNotFoundError, BlockhashFormatError} +import com.xsn.explorer.models._ +import org.scalactic.{Bad, Good, One, Or} + +class BlockLogic { + + def getBlockhash(string: String): ApplicationResult[Blockhash] = { + val maybe = Blockhash.from(string) + Or.from(maybe, One(BlockhashFormatError)) + } + + /** + * Get the coinstake transaction id for the given block. + * + * A PoS block contains at least 2 transactions: + * - the 1st one is empty + * - the 2nd one is the Coinstake transaction. + */ + def getCoinstakeTransactionId(block: Block): ApplicationResult[TransactionId] = { + val maybe = block.transactions.lift(1) + + Or.from(maybe, One(BlockNotFoundError)) + } + + /** + * Computes the rewards for a PoS coinstake transaction. + * + * There should be a coinstake reward and possibly a master node reward. + * + * The rewards are computed based on the transaction output which is expected to + * contain between 2 and 4 values: + * - the 1st one is empty + * - the 2nd one goes to the coinstake + * - the 3rd one (if present) will go to the coinstake if the address matches, otherwise it goes to master node. + * - the 4th one (if present) will go to the master node. + * + * While the previous format should be meet by the RPC server, we compute the rewards + * based on coinstake address. + * + * Sometimes there could be rounding errors, for example, when the input is not exactly divisible by 2, + * we return 0 in that case because the reward could be negative. + */ + def getRewards( + coinstakeTx: Transaction, + coinstakeAddress: Address, + coinstakeInput: BigDecimal): ApplicationResult[BlockRewards] = { + + // first vout is empty, useless + val coinstakeVOUT = coinstakeTx.vout.drop(1) + if (coinstakeVOUT.size >= 1 && coinstakeVOUT.size <= 3) { + val value = coinstakeVOUT + .filter(_.address contains coinstakeAddress) + .map(_.value) + .sum + + val coinstakeReward = BlockReward( + coinstakeAddress, + (value - coinstakeInput) max 0) + + val masternodeRewardOUT = coinstakeVOUT.filterNot(_.address contains coinstakeAddress) + val masternodeAddressMaybe = masternodeRewardOUT.flatMap(_.address).headOption + val masternodeRewardMaybe = masternodeAddressMaybe.map { masternodeAddress => + BlockReward( + masternodeAddress, + masternodeRewardOUT.filter(_.address contains masternodeAddress).map(_.value).sum + ) + } + + Good(BlockRewards(coinstakeReward, masternodeRewardMaybe)) + } else { + Bad(BlockNotFoundError).accumulating + } + } +} diff --git a/server/app/controllers/BlocksController.scala b/server/app/controllers/BlocksController.scala new file mode 100644 index 0000000..9de7bfd --- /dev/null +++ b/server/app/controllers/BlocksController.scala @@ -0,0 +1,16 @@ +package controllers + +import javax.inject.Inject + +import com.xsn.explorer.services.BlockService +import controllers.common.{MyJsonController, MyJsonControllerComponents} + +class BlocksController @Inject() ( + blockService: BlockService, + cc: MyJsonControllerComponents) + extends MyJsonController(cc) { + + def getDetails(blockhash: String) = publicNoInput { _ => + blockService.getDetails(blockhash) + } +} diff --git a/server/conf/routes b/server/conf/routes index bba2354..99083a8 100644 --- a/server/conf/routes +++ b/server/conf/routes @@ -6,3 +6,5 @@ GET /transactions/:txid controllers.TransactionsController.getTransaction(txid: String) GET /addresses/:address controllers.AddressesController.getDetails(address: String) + +GET /blocks/:blockhash controllers.BlocksController.getDetails(blockhash: String) diff --git a/server/test/controllers/BlocksControllerSpec.scala b/server/test/controllers/BlocksControllerSpec.scala new file mode 100644 index 0000000..97ddc7a --- /dev/null +++ b/server/test/controllers/BlocksControllerSpec.scala @@ -0,0 +1,252 @@ +package controllers + +import com.alexitc.playsonify.PublicErrorRenderer +import com.alexitc.playsonify.core.FutureApplicationResult +import com.xsn.explorer.errors.{BlockNotFoundError, TransactionNotFoundError} +import com.xsn.explorer.helpers.DummyXSNService +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 +import play.api.test.Helpers._ + +import scala.concurrent.Future + +class BlocksControllerSpec extends MyAPISpec { + + // PoS block + val posBlock = createBlock( + hash = Blockhash.from("b72dd1655408e9307ef5874be20422ee71029333283e2360975bc6073bdb2b81").get, + transactions = List( + TransactionId.from("7f12adbb63d443502cf151c76946d5faa0b1c662a5d67afc7da085c74e06f1ce").get, + TransactionId.from("0834641a7d30d8a2d2b451617599670445ee94ed7736e146c13be260c576c641").get + ) + ) + + val posBlockCoinstakeTx = createTx( + id = TransactionId.from("0834641a7d30d8a2d2b451617599670445ee94ed7736e146c13be260c576c641").get, + vin = TransactionVIN(TransactionId.from("585cec5009c8ca19e83e33d282a6a8de65eb2ca007b54d6572167703768967d9").get, 2), + vout = List( + TransactionVOUT(BigDecimal("0"), 0, "nonstandard", None), + TransactionVOUT(n = 1, value = BigDecimal("600"), scriptPubKeyType = "pubkeyhash", address = Address.from("XgEGH3y7RfeKEdn2hkYEvBnrnmGBr7zvjL")), + TransactionVOUT(n = 2, value = BigDecimal("600"), scriptPubKeyType = "pubkeyhash", address = Address.from("XgEGH3y7RfeKEdn2hkYEvBnrnmGBr7zvjL")), + TransactionVOUT(BigDecimal("10"), 3, "pubkeyhash", Some(Address.from("XnH3bC9NruJ4wnu4Dgi8F3wemmJtcxpKp6").get)) + ) + ) + + val posBlockCoinstakeTxInput = createTx( + id = TransactionId.from("585cec5009c8ca19e83e33d282a6a8de65eb2ca007b54d6572167703768967d9").get, + vin = TransactionVIN(TransactionId.from("fd74206866fc4ed986d39084eb9f20de6cb324b028693f33d60897ac995fff4f").get, 2), + vout = List( + TransactionVOUT(BigDecimal("0"), 0, "nonstandard", None), + TransactionVOUT(BigDecimal("1"), 1, "pubkeyhash", Some(Address.from("XgEGH3y7RfeKEdn2hkYEvBnrnmGBr7zvjL").get)), + TransactionVOUT(BigDecimal("1000"), 2, "pubkeyhash", Some(Address.from("XgEGH3y7RfeKEdn2hkYEvBnrnmGBr7zvjL").get)) + ) + ) + + // PoS block with rounding error + val posBlockRoundingError = createBlock( + hash = Blockhash.from("25762bf01143f7fe34912c926e0b95528b082c6323de35516de0fc321f5d8058").get, + transactions = List( + TransactionId.from("df275d713fcf5e78e7e8369d640201d46736c0d2255e31ce45bd5aa0206f861f").get, + TransactionId.from("0b761343c7be39116d5429953e0cfbf51bfe83400ab27d61084222451045116c").get + ) + ) + + val posBlockRoundingErrorCoinstakeTx = createTx( + id = TransactionId.from("0b761343c7be39116d5429953e0cfbf51bfe83400ab27d61084222451045116c").get, + vin = TransactionVIN(TransactionId.from("1860288a5a87c79e617f743af44600e050c28ddb7d929d93d43a9148e2ba6638").get, 1), + vout = List( + TransactionVOUT(BigDecimal("0"), 0, "nonstandard", None), + TransactionVOUT(n = 1, value = BigDecimal("292968.74570312"), scriptPubKeyType = "pubkeyhash", address = Address.from("XgEGH3y7RfeKEdn2hkYEvBnrnmGBr7zvjL")), + TransactionVOUT(n = 2, value = BigDecimal("292968.74570312"), scriptPubKeyType = "pubkeyhash", address = Address.from("XgEGH3y7RfeKEdn2hkYEvBnrnmGBr7zvjL")) + ) + ) + + val posBlockRoundingErrorCoinstakeTxInput = createTx( + id = TransactionId.from("1860288a5a87c79e617f743af44600e050c28ddb7d929d93d43a9148e2ba6638").get, + vin = TransactionVIN(TransactionId.from("ef157f5ec0b3a6cdf669ff799988ee94d9fa2af8adaf2408ae9e34b47310831f").get, 2), + vout = List( + TransactionVOUT(BigDecimal("0"), 0, "nonstandard", None), + TransactionVOUT(BigDecimal("585937.49140625"), 1, "pubkeyhash", Some(Address.from("XgEGH3y7RfeKEdn2hkYEvBnrnmGBr7zvjL").get)), + TransactionVOUT(BigDecimal("585937.49140625"), 2, "pubkeyhash", Some(Address.from("XgEGH3y7RfeKEdn2hkYEvBnrnmGBr7zvjL").get)) + ) + ) + + val customXSNService = new DummyXSNService { + val blocks = Map( + posBlock.hash -> posBlock, + posBlockRoundingError.hash -> posBlockRoundingError + ) + + override def getBlock(blockhash: Blockhash): FutureApplicationResult[Block] = { + val result = blocks.get(blockhash) + .map(Good(_)) + .getOrElse { + Bad(BlockNotFoundError).accumulating + } + + Future.successful(result) + } + + + val txs = Map( + posBlockCoinstakeTx.id -> posBlockCoinstakeTx, + posBlockCoinstakeTxInput.id -> posBlockCoinstakeTxInput, + posBlockRoundingErrorCoinstakeTx.id -> posBlockRoundingErrorCoinstakeTx, + posBlockRoundingErrorCoinstakeTxInput.id -> posBlockRoundingErrorCoinstakeTxInput + ) + + override def getTransaction(txid: TransactionId): FutureApplicationResult[Transaction] = { + val result = txs.get(txid) + .map(Good(_)) + .getOrElse { + Bad(TransactionNotFoundError).accumulating + } + + Future.successful(result) + } + } + + override val application = guiceApplicationBuilder + .overrides(bind[XSNService].to(customXSNService)) + .build() + + "GET /blocks/:blockhash" should { + def url(blockhash: String) = s"/blocks/$blockhash" + + "retrieve a PoS block" in { + val block = posBlock + val response = GET(url(block.hash.string)) + + status(response) mustEqual OK + + val json = contentAsJson(response) + val jsonBlock = (json \ "block").as[JsValue] + val jsonRewards = (json \ "rewards").as[JsValue] + + (jsonBlock \ "hash").as[Blockhash] mustEqual block.hash + (jsonBlock \ "size").as[Size] mustEqual block.size + (jsonBlock \ "bits").as[String] mustEqual block.bits + (jsonBlock \ "chainwork").as[String] mustEqual block.chainwork + (jsonBlock \ "difficulty").as[BigDecimal] mustEqual block.difficulty + (jsonBlock \ "confirmations").as[Confirmations] mustEqual block.confirmations + (jsonBlock \ "height").as[Height] mustEqual block.height + (jsonBlock \ "medianTime").as[Long] mustEqual block.medianTime + (jsonBlock \ "time").as[Long] mustEqual block.time + (jsonBlock \ "merkleRoot").as[Blockhash] mustEqual block.merkleRoot + (jsonBlock \ "version").as[Long] mustEqual block.version + (jsonBlock \ "nonce").as[Int] mustEqual block.nonce + (jsonBlock \ "previousBlockhash").as[Blockhash] mustEqual block.previousBlockhash + (jsonBlock \ "nextBlockhash").as[Blockhash] mustEqual block.nextBlockhash + + val jsonCoinstake = (jsonRewards \ "coinstake").as[JsValue] + (jsonCoinstake \ "address").as[String] mustEqual "XgEGH3y7RfeKEdn2hkYEvBnrnmGBr7zvjL" + (jsonCoinstake \ "value").as[BigDecimal] mustEqual BigDecimal("200") + + val jsonMasternode = (jsonRewards \ "masternode").as[JsValue] + (jsonMasternode \ "address").as[String] mustEqual "XnH3bC9NruJ4wnu4Dgi8F3wemmJtcxpKp6" + (jsonMasternode \ "value").as[BigDecimal] mustEqual BigDecimal("10") + } + + "retrieve a PoS block having a rounding error" in { + val block = posBlockRoundingError + val response = GET(url(block.hash.string)) + + status(response) mustEqual OK + + val json = contentAsJson(response) + val jsonBlock = (json \ "block").as[JsValue] + val jsonRewards = (json \ "rewards").as[JsValue] + + (jsonBlock \ "hash").as[Blockhash] mustEqual block.hash + (jsonBlock \ "size").as[Size] mustEqual block.size + (jsonBlock \ "bits").as[String] mustEqual block.bits + (jsonBlock \ "chainwork").as[String] mustEqual block.chainwork + (jsonBlock \ "difficulty").as[BigDecimal] mustEqual block.difficulty + (jsonBlock \ "confirmations").as[Confirmations] mustEqual block.confirmations + (jsonBlock \ "height").as[Height] mustEqual block.height + (jsonBlock \ "medianTime").as[Long] mustEqual block.medianTime + (jsonBlock \ "time").as[Long] mustEqual block.time + (jsonBlock \ "merkleRoot").as[Blockhash] mustEqual block.merkleRoot + (jsonBlock \ "version").as[Long] mustEqual block.version + (jsonBlock \ "nonce").as[Int] mustEqual block.nonce + (jsonBlock \ "previousBlockhash").as[Blockhash] mustEqual block.previousBlockhash + (jsonBlock \ "nextBlockhash").as[Blockhash] mustEqual block.nextBlockhash + + val jsonCoinstake = (jsonRewards \ "coinstake").as[JsValue] + (jsonCoinstake \ "address").as[String] mustEqual "XgEGH3y7RfeKEdn2hkYEvBnrnmGBr7zvjL" + (jsonCoinstake \ "value").as[BigDecimal] mustEqual BigDecimal("0") + + val jsonMasternode = (jsonRewards \ "masternode").asOpt[JsValue] + jsonMasternode.isEmpty mustEqual true + } + + "fail on the wrong blockhash format" in { + val response = GET(url("000125c06cedf38b07bff174bdb61027935dbcb34831d28cff40bedb519d5")) + + status(response) mustEqual BAD_REQUEST + + val json = contentAsJson(response) + val errorList = (json \ "errors").as[List[JsValue]] + + errorList.size mustEqual 1 + val error = errorList.head + + (error \ "type").as[String] mustEqual PublicErrorRenderer.FieldValidationErrorType + (error \ "field").as[String] mustEqual "blockhash" + (error \ "message").as[String].nonEmpty mustEqual true + } + + "fail on an unknown block" in { + val response = GET(url("000003dc4c2fc449dededaaad6efc33ce1b64b88a060652dc47edc63d6d6b524")) + + status(response) mustEqual BAD_REQUEST + + val json = contentAsJson(response) + val errorList = (json \ "errors").as[List[JsValue]] + + errorList.size mustEqual 1 + val error = errorList.head + + (error \ "type").as[String] mustEqual PublicErrorRenderer.FieldValidationErrorType + (error \ "field").as[String] mustEqual "blockhash" + (error \ "message").as[String].nonEmpty mustEqual true + } + } + + private def createTx(id: TransactionId, vin: TransactionVIN, vout: List[TransactionVOUT]): Transaction = { + Transaction( + id = id, + size = Size(234), + blockhash = Blockhash.from("b72dd1655408e9307ef5874be20422ee71029333283e2360975bc6073bdb2b81").get, + time = 1520318120, + blocktime = 1520318120, + confirmations = Confirmations(1950), + vin = Some(vin), + vout = vout + ) + } + + private def createBlock(hash: Blockhash, transactions: List[TransactionId]): Block = { + Block( + hash = hash, + transactions = transactions, + confirmations = Confirmations(11189), + size = Size(478), + height = Height(809), + version = 536870912, + merkleRoot = Blockhash.from("598cc6ba8238d87641b0dbd02485b7d635b5417429df3145c98c3ff8779ab4b8").get, + time = 1520318054, + medianTime = 1520318054, + nonce = 0, + bits = "1d011212", + chainwork = "00000000000000000000000000000000000000000000000000000084c71ff420", + difficulty = BigDecimal("0.9340526210769362"), + previousBlockhash = Blockhash.from("000003dc4c2fc449dededaaad6efc33ce1b64b88a060652dc47edc63d6d6b524").get, + nextBlockhash = Blockhash.from("000000125c06cedf38b07bff174bdb61027935dbcb34831d28cff40bedb519d5").get + ) + } +}