Browse Source

server: Add GET /blocks/:blockhash

scalafmt-draft
Alexis Hernandez 7 years ago
parent
commit
9a34faa615
  1. 11
      server/app/com/xsn/explorer/models/BlockDetails.scala
  2. 9
      server/app/com/xsn/explorer/models/BlockReward.scala
  3. 9
      server/app/com/xsn/explorer/models/BlockRewards.scala
  4. 56
      server/app/com/xsn/explorer/services/BlockService.scala
  5. 77
      server/app/com/xsn/explorer/services/logic/BlockLogic.scala
  6. 16
      server/app/controllers/BlocksController.scala
  7. 2
      server/conf/routes
  8. 252
      server/test/controllers/BlocksControllerSpec.scala

11
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]
}

9
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]
}

9
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]
}

56
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
}
}

77
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
}
}
}

16
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)
}
}

2
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)

252
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
)
}
}
Loading…
Cancel
Save