Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
initial signing API method
  • Loading branch information
kushti committed Jan 16, 2026
commit 9d93bcc83ecb092b2ac7e4a3b81689a806a070ff
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ devnet
.ensime_cache/
scorex.yaml

# LLM generated content (specs etc)
llm_generated

# scala build folders
target

Expand Down
2 changes: 1 addition & 1 deletion src/main/scala/org/ergoplatform/ErgoApp.scala
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ class ErgoApp(args: Args) extends ScorexLogging {

private val apiRoutes: Seq[ApiRoute] = Seq(
EmissionApiRoute(ergoSettings),
ErgoUtilsApiRoute(ergoSettings),
ErgoUtilsApiRoute(readersHolderRef, ergoSettings),
BlockchainApiRoute(readersHolderRef, ergoSettings, indexerOpt),
ErgoPeersApiRoute(
peerManagerRef,
Expand Down
124 changes: 120 additions & 4 deletions src/main/scala/org/ergoplatform/http/api/ErgoUtilsApiRoute.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import akka.http.scaladsl.model.StatusCodes
import akka.http.scaladsl.server.Route
import io.circe.Json
import io.circe.syntax._
import org.ergoplatform.http.api.ApiError.BadRequest
import org.ergoplatform.http.api.ApiError.{BadRequest, InternalError}
import org.ergoplatform.settings.{ErgoSettings, RESTApiSettings}
import org.ergoplatform.{ErgoAddressEncoder, P2PKAddress}
import scorex.core.api.http.{ApiResponse, ApiRoute}
Expand All @@ -17,8 +17,14 @@ import sigma.data.ProveDlog
import java.security.SecureRandom
import scala.util.Failure
import sigma.serialization.{ErgoTreeSerializer, GroupElementSerializer, SigmaSerializer}
import akka.actor.ActorRef
import akka.pattern.ask
import org.ergoplatform.nodeView.ErgoReadersHolder.{GetReaders, Readers}

class ErgoUtilsApiRoute(val ergoSettings: ErgoSettings)(
import scala.concurrent.duration._
import scala.concurrent.Await

class ErgoUtilsApiRoute(val readersHolder: ActorRef, val ergoSettings: ErgoSettings)(
implicit val context: ActorRefFactory
) extends ApiRoute
with ScorexEncoding {
Expand All @@ -40,7 +46,8 @@ class ErgoUtilsApiRoute(val ergoSettings: ErgoSettings)(
validateAddressPostR ~
validateAddressGetR ~
ergoTreeToAddressPostR ~
ergoTreeToAddressGetR
ergoTreeToAddressGetR ~
schnorrSignR
}

private def seed(length: Int): String = {
Expand Down Expand Up @@ -132,14 +139,123 @@ class ErgoUtilsApiRoute(val ergoSettings: ErgoSettings)(
case Left(ex) => ApiError(StatusCodes.BadRequest, ex.getMessage())
}
}

def schnorrSignR: Route = (post & path("schnorrSign") & entity(as[Json])) { json =>

import io.circe.generic.auto._

// Define case class for the request (without derivation path)
case class SchnorrSignRequest(address: String, message: String)

json.as[SchnorrSignRequest] match {
case Right(req) =>
// Validate hex encoding of message
scorex.util.encode.Base16.decode(req.message) match {
case scala.util.Success(messageBytes) =>
// Validate address format
ergoAddressEncoder.fromString(req.address) match {
case scala.util.Success(p2pkAddress: P2PKAddress) =>
try {
// Access wallet to get the private key
val readersFuture = (readersHolder ? GetReaders).mapTo[Readers]
val readers = Await.result(readersFuture, 5.seconds)
val walletReader = readers.w

// Find the private key for the given address by looking up the public key
val extKeysFuture = walletReader.allExtendedPublicKeys()
val extKeys = Await.result(extKeysFuture, 5.seconds)

extKeys.find(_.key.value.equals(p2pkAddress.pubkey.value)) match {
case Some(extKey) =>
val path = extKey.path
// Get the private key for the derivation path
val privateKeyFuture = walletReader.getPrivateKeyFromPath(path)
val privateKeyTry = Await.result(privateKeyFuture, 5.seconds)

privateKeyTry match {
case scala.util.Success(privateKeyInput) =>
// Extract public key from private key
val publicKeyPoint = privateKeyInput.publicImage.value
val publicKeyBytes = GroupElementSerializer.toBytes(publicKeyPoint)

// Generate the Schnorr signature following the specification
import sigma.crypto.CryptoConstants
import scorex.crypto.hash.Blake2b256
import java.security.SecureRandom
import org.bouncycastle.util.BigIntegers

// Generate a random nonce
val secureRandom = new SecureRandom()
val kBytes = new Array[Byte](32)
secureRandom.nextBytes(kBytes)
val kBI = BigInt(BigIntegers.fromUnsignedByteArray(kBytes))

// Calculate R = k*G (random point)
val rPoint = CryptoConstants.dlogGroup.exponentiate(CryptoConstants.dlogGroup.generator, kBI.bigInteger)

// Calculate challenge e = H(R || message || public_key)
val rBytes = GroupElementSerializer.toBytes(rPoint)
val challengeInput = rBytes ++ messageBytes ++ publicKeyBytes
val eFull = Blake2b256(challengeInput)
val eBI = BigInt(BigIntegers.fromUnsignedByteArray(eFull)) % CryptoConstants.groupOrder

// Calculate response z = k + e * s (mod n) where s is the private key
val privateKeyBI = privateKeyInput.w
val zBI = (kBI.bigInteger.add(eBI.bigInteger.multiply(privateKeyBI))).remainder(CryptoConstants.groupOrder)
val z = BigInt(zBI)

// Get the compressed form of R for the signature format
val rCompressed = GroupElementSerializer.toBytes(rPoint)

// Take the first byte as prefix, next 32 as a-component, and z as z-component
val prefixByte = rCompressed.head
val aComponent = rCompressed.tail // 32 bytes (compressed point without prefix)
val zComponent = BigIntegers.asUnsignedByteArray(32, z.bigInteger)


val formattedSignature = Array(prefixByte.toByte) ++ aComponent ++ zComponent

val response = Json.obj(
"signedMessage" -> scorex.util.encode.Base16.encode(messageBytes).asJson,
"signature" -> scorex.util.encode.Base16.encode(formattedSignature).asJson,
"publicKey" -> scorex.util.encode.Base16.encode(publicKeyBytes).asJson
)
ApiResponse(response)

case scala.util.Failure(exception) =>
BadRequest(s"Node does not have the secret key for the specified address - ${exception.getMessage}")
}
case None =>
BadRequest("Node does not have the secret key for the specified address")
}
} catch {
case _: Throwable =>
InternalError("WalletError")
}

case scala.util.Success(_) =>
BadRequest("InvalidAddressType")

case scala.util.Failure(_) =>
BadRequest("InvalidAddress")
}
case scala.util.Failure(e) =>
BadRequest("InvalidMessage")
}
case Left(ex) =>
InternalError(ex.getMessage())
}
}

}

object ErgoUtilsApiRoute {

def apply(
readersHolder: ActorRef,
ergoSettings: ErgoSettings
)(implicit context: ActorRefFactory): ErgoUtilsApiRoute = {
new ErgoUtilsApiRoute(ergoSettings)
new ErgoUtilsApiRoute(readersHolder, ergoSettings)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import akka.http.scaladsl.server.Route
import akka.http.scaladsl.testkit.ScalatestRouteTest
import de.heikoseeberger.akkahttpcirce.FailFastCirceSupport
import io.circe.Json
import io.circe.syntax._
import org.ergoplatform.utils.Stubs
import org.ergoplatform.{P2PKAddress, Pay2SAddress, Pay2SHAddress}
import org.ergoplatform.http.api.ErgoUtilsApiRoute
Expand All @@ -28,7 +29,7 @@ class UtilsApiRouteSpec extends AnyFlatSpec
val prefix = "/utils"

val restApiSettings = RESTApiSettings(new InetSocketAddress("localhost", 8080), None, None, 10.seconds, None)
val route: Route = ErgoUtilsApiRoute(settings).route
val route: Route = ErgoUtilsApiRoute(digestReadersRef, settings).route
val p2pkaddress = P2PKAddress(defaultMinerPk)(settings.addressEncoder)
val p2shaddress = Pay2SHAddress(feeProp)(settings.addressEncoder)
val p2saddress = Pay2SAddress(feeProp)(settings.addressEncoder)
Expand Down Expand Up @@ -153,5 +154,58 @@ class UtilsApiRouteSpec extends AnyFlatSpec
}
}

it should "return error for schnorrSign when wallet is not initialized" in {
val requestJson = Json.obj(
"address" -> p2pkaddress.toString.asJson,
"message" -> "02415748f8eef16c5ea6896cec3a8defccc8a0dace245248be66ffd6ff2159da32000000000003d09000000000694fa26d".asJson
)

Post(s"$prefix/schnorrSign", requestJson) ~> route ~> check {
status shouldBe StatusCodes.BadRequest
val response = responseAs[Json]
println(s"SchnorrSign response: $response")
}
}

it should "return error for schnorrSign with non-P2PK address" in {
val requestJson = Json.obj(
"address" -> p2shaddress.toString.asJson,
"message" -> "02415748f8eef16c5ea6896cec3a8defccc8a0dace245248be66ffd6ff2159da32000000000003d09000000000694fa26d".asJson
)

Post(s"$prefix/schnorrSign", requestJson) ~> route ~> check {
status shouldBe StatusCodes.BadRequest
println(responseAs[Json])
val response = responseAs[Json]
response.hcursor.downField("detail").as[String] shouldEqual Right("InvalidAddressType")
}
}

it should "return error for schnorrSign with invalid hex message" in {
val requestJson = Json.obj(
"address" -> p2pkaddress.toString.asJson,
"message" -> "invalid_hex_message".asJson
)

Post(s"$prefix/schnorrSign", requestJson) ~> route ~> check {
status shouldBe StatusCodes.BadRequest
val response = responseAs[Json]
response.hcursor.downField("detail").as[String] shouldEqual Right("InvalidMessage")
}
}

it should "return error for schnorrSign with invalid address" in {
val requestJson = Json.obj(
"address" -> "invalid_address".asJson,
"message" -> "02415748f8eef16c5ea6896cec3a8defccc8a0dace245248be66ffd6ff2159da32000000000003d09000000000694fa26d".asJson
)

Post(s"$prefix/schnorrSign", requestJson) ~> route ~> check {
status shouldBe StatusCodes.BadRequest
val response = responseAs[Json]
response.hcursor.downField("detail").as[String] shouldEqual Right("InvalidAddress")
}
}

}

Loading