feat: Add sign/verify message API endpoints for Issue #1392#2286
feat: Add sign/verify message API endpoints for Issue #1392#2286algsoch wants to merge 1 commit intoergoplatform:masterfrom
Conversation
- Add POST /wallet/signMessage endpoint to sign arbitrary messages - Add POST /wallet/verifySignature endpoint to verify message signatures - Implement ErgoSignature-based Schnorr signature scheme - Support optional address parameter for signing with specific keys - Return signature and public key in Base16 hex encoding - Full wallet integration with key derivation support
There was a problem hiding this comment.
Pull request overview
This PR implements message signing and verification endpoints for the Ergo wallet API, allowing users to sign arbitrary messages with wallet keys and verify signatures. The implementation uses Schnorr signatures via the existing ErgoSignature utility from the ergo-wallet library.
Key changes:
- Added
POST /wallet/signMessageendpoint to sign messages with wallet private keys - Added
POST /wallet/verifySignatureendpoint for stateless signature verification - Integrated signature functionality through the wallet actor pattern following existing architectural patterns
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| MessageSigningRequests.scala | New request models for sign and verify operations |
| WalletTypes.scala | Unrelated type wrapper additions (not part of message signing feature) |
| ErgoWalletService.scala | Core signMessage implementation with key derivation and Schnorr signing |
| ErgoWalletReader.scala | Added signMessage method to wallet reader interface |
| ErgoWalletActorMessages.scala | Added SignMessage actor message for communication |
| ErgoWalletActor.scala | Message handler for SignMessage actor events |
| WalletApiRoute.scala | HTTP route handlers for signMessage and verifySignature endpoints |
| ApiRequestsCodecs.scala | JSON decoders for new request types |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Try { | ||
| import org.ergoplatform.wallet.crypto.ErgoSignature | ||
| import sigma.serialization.{GroupElementSerializer, SigmaSerializer} | ||
|
|
||
| val messageBytes = req.message.getBytes("UTF-8") | ||
| val signatureBytes = Base16.decode(req.signature).get | ||
| val publicKeyBytes = Base16.decode(req.publicKey).get | ||
|
|
||
| // Parse the public key bytes into EcPointType | ||
| val publicKey = GroupElementSerializer.parse(SigmaSerializer.startReader(publicKeyBytes)) | ||
|
|
||
| // Verify signature using ErgoSignature | ||
| val verified = ErgoSignature.verify(messageBytes, signatureBytes, publicKey) | ||
|
|
||
| verified | ||
| } match { | ||
| case Success(verified) => ApiResponse(Json.obj("verified" -> verified.asJson)) | ||
| case Failure(e) => BadRequest(s"Verification failed: ${e.getMessage}") |
There was a problem hiding this comment.
Using .get on decode operations can throw exceptions. While these are caught by the outer Try block, it would be better to use pattern matching or flatMap for cleaner error handling. Consider changing this to return a more specific error message when decoding fails, such as "Invalid Base16 encoding for signature" or "Invalid Base16 encoding for public key".
| Try { | |
| import org.ergoplatform.wallet.crypto.ErgoSignature | |
| import sigma.serialization.{GroupElementSerializer, SigmaSerializer} | |
| val messageBytes = req.message.getBytes("UTF-8") | |
| val signatureBytes = Base16.decode(req.signature).get | |
| val publicKeyBytes = Base16.decode(req.publicKey).get | |
| // Parse the public key bytes into EcPointType | |
| val publicKey = GroupElementSerializer.parse(SigmaSerializer.startReader(publicKeyBytes)) | |
| // Verify signature using ErgoSignature | |
| val verified = ErgoSignature.verify(messageBytes, signatureBytes, publicKey) | |
| verified | |
| } match { | |
| case Success(verified) => ApiResponse(Json.obj("verified" -> verified.asJson)) | |
| case Failure(e) => BadRequest(s"Verification failed: ${e.getMessage}") | |
| import org.ergoplatform.wallet.crypto.ErgoSignature | |
| import sigma.serialization.{GroupElementSerializer, SigmaSerializer} | |
| val messageBytes = req.message.getBytes("UTF-8") | |
| Base16.decode(req.signature) match { | |
| case None => | |
| BadRequest("Invalid Base16 encoding for signature") | |
| case Some(signatureBytes) => | |
| Base16.decode(req.publicKey) match { | |
| case None => | |
| BadRequest("Invalid Base16 encoding for public key") | |
| case Some(publicKeyBytes) => | |
| Try { | |
| // Parse the public key bytes into EcPointType | |
| val publicKey = GroupElementSerializer.parse(SigmaSerializer.startReader(publicKeyBytes)) | |
| // Verify signature using ErgoSignature | |
| val verified = ErgoSignature.verify(messageBytes, signatureBytes, publicKey) | |
| verified | |
| } match { | |
| case Success(verified) => ApiResponse(Json.obj("verified" -> verified.asJson)) | |
| case Failure(e) => BadRequest(s"Verification failed: ${e.getMessage}") | |
| } | |
| } |
| def signMessageR: Route = (path("signMessage") & post & entity(as[SignMessageRequest])) { req => | ||
| val addressOpt = req.address.flatMap(addrStr => ErgoAddressEncoder(ergoSettings.chainSettings.addressPrefix).fromString(addrStr).toOption) | ||
| withWalletOp(_.signMessage(req.message, addressOpt.flatMap { | ||
| case p2pk: P2PKAddress => Some(p2pk) | ||
| case _ => None | ||
| })) { | ||
| case Success((signature, publicKey)) => | ||
| ApiResponse(Json.obj( | ||
| "signature" -> signature.asJson, | ||
| "publicKey" -> publicKey.asJson | ||
| )) | ||
| case Failure(e) => BadRequest(e.getMessage) | ||
| } | ||
| } | ||
|
|
||
| def verifySignatureR: Route = (path("verifySignature") & post & entity(as[VerifySignatureRequest])) { req => | ||
| Try { | ||
| import org.ergoplatform.wallet.crypto.ErgoSignature | ||
| import sigma.serialization.{GroupElementSerializer, SigmaSerializer} | ||
|
|
||
| val messageBytes = req.message.getBytes("UTF-8") | ||
| val signatureBytes = Base16.decode(req.signature).get | ||
| val publicKeyBytes = Base16.decode(req.publicKey).get | ||
|
|
||
| // Parse the public key bytes into EcPointType | ||
| val publicKey = GroupElementSerializer.parse(SigmaSerializer.startReader(publicKeyBytes)) | ||
|
|
||
| // Verify signature using ErgoSignature | ||
| val verified = ErgoSignature.verify(messageBytes, signatureBytes, publicKey) | ||
|
|
||
| verified | ||
| } match { | ||
| case Success(verified) => ApiResponse(Json.obj("verified" -> verified.asJson)) | ||
| case Failure(e) => BadRequest(s"Verification failed: ${e.getMessage}") | ||
| } | ||
| } |
There was a problem hiding this comment.
The new signMessage and verifySignature endpoints lack test coverage. The test file WalletApiRouteSpec.scala does not include tests for these new endpoints. Consider adding test cases covering: 1) successful message signing and verification, 2) signing with a specific address, 3) handling locked wallet error, 4) handling invalid address, 5) handling invalid signature/public key format in verification.
| package org.ergoplatform.nodeView.wallet | ||
|
|
||
| import org.ergoplatform.sdk.SecretString | ||
|
|
||
| /** | ||
| * Domain-specific type wrappers for wallet operations | ||
| * | ||
| * These types replace primitive types in communication between the API layer | ||
| * and core wallet logic, making the code more type-safe and self-documenting. | ||
| */ | ||
| object WalletTypes { | ||
|
|
||
| /** | ||
| * Wrapper for wallet encryption password | ||
| * | ||
| * Used when initializing, restoring, or unlocking a wallet | ||
| */ | ||
| final case class WalletPassword(value: SecretString) extends AnyVal | ||
|
|
||
| /** | ||
| * Wrapper for mnemonic password (BIP-39 passphrase) | ||
| * | ||
| * Optional password that can be used with a mnemonic for extra security | ||
| */ | ||
| final case class MnemonicPassword(value: SecretString) extends AnyVal | ||
|
|
||
| /** | ||
| * Wrapper for wallet mnemonic phrase | ||
| * | ||
| * The secret recovery phrase used to restore wallet | ||
| */ | ||
| final case class WalletMnemonic(value: SecretString) extends AnyVal | ||
|
|
||
| /** | ||
| * Wrapper for BIP-32 derivation path | ||
| * | ||
| * String representation of a hierarchical deterministic derivation path | ||
| */ | ||
| final case class DerivationPathString(value: String) extends AnyVal | ||
|
|
||
| /** | ||
| * Wrapper for scan identifier | ||
| * | ||
| * Numeric identifier for a wallet scan operation | ||
| */ | ||
| final case class ScanIdentifier(value: Int) extends AnyVal | ||
|
|
||
| /** | ||
| * Wrapper for minimum inclusion height | ||
| * | ||
| * Minimum blockchain height for transaction inclusion | ||
| */ | ||
| final case class MinInclusionHeight(value: Int) extends AnyVal | ||
|
|
||
| /** | ||
| * Wrapper for maximum inclusion height | ||
| * | ||
| * Maximum blockchain height for transaction inclusion | ||
| */ | ||
| final case class MaxInclusionHeight(value: Int) extends AnyVal | ||
|
|
||
| /** | ||
| * Wrapper for minimum confirmations count | ||
| * | ||
| * Minimum number of confirmations required for a transaction | ||
| */ | ||
| final case class MinConfirmations(value: Int) extends AnyVal | ||
|
|
||
| /** | ||
| * Wrapper for maximum confirmations count | ||
| * | ||
| * Maximum number of confirmations to consider for a transaction | ||
| */ | ||
| final case class MaxConfirmations(value: Int) extends AnyVal | ||
|
|
||
| /** | ||
| * Wrapper for box index parameter | ||
| * | ||
| * Index used when reading public keys or wallet boxes | ||
| */ | ||
| final case class BoxIndex(value: Int) extends AnyVal | ||
|
|
||
| /** | ||
| * Wrapper for target balance in nanoERG | ||
| * | ||
| * The amount of ERG requested for box collection | ||
| */ | ||
| final case class TargetBalance(value: Long) extends AnyVal | ||
|
|
||
| /** | ||
| * Wrapper for whether to use pre-1627 key derivation | ||
| * | ||
| * Flag indicating if legacy key derivation should be used | ||
| */ | ||
| final case class UsePre1627KeyDerivation(value: Boolean) extends AnyVal | ||
|
|
||
| /** | ||
| * Wrapper for unspent-only filter | ||
| * | ||
| * Flag indicating whether to return only unspent boxes | ||
| */ | ||
| final case class UnspentOnly(value: Boolean) extends AnyVal | ||
|
|
||
| /** | ||
| * Wrapper for consider-unconfirmed filter | ||
| * | ||
| * Flag indicating whether to consider mempool transactions | ||
| */ | ||
| final case class ConsiderUnconfirmed(value: Boolean) extends AnyVal | ||
|
|
||
| /** | ||
| * Wrapper for include-unconfirmed filter | ||
| * | ||
| * Flag indicating whether to include unconfirmed transactions | ||
| */ | ||
| final case class IncludeUnconfirmed(value: Boolean) extends AnyVal | ||
|
|
||
| /** | ||
| * Wrapper for sign transaction flag | ||
| * | ||
| * Indicates whether to sign a generated transaction | ||
| */ | ||
| final case class ShouldSignTransaction(value: Boolean) extends AnyVal | ||
|
|
||
| } |
There was a problem hiding this comment.
The WalletTypes.scala file appears to be a completely new file that is unrelated to message signing functionality. This file introduces domain-specific type wrappers for wallet operations but is not used anywhere in the codebase (no imports found) and is not mentioned in the PR description. This appears to be an unrelated change that should either be part of a separate PR or removed if accidentally included.
| package org.ergoplatform.nodeView.wallet | |
| import org.ergoplatform.sdk.SecretString | |
| /** | |
| * Domain-specific type wrappers for wallet operations | |
| * | |
| * These types replace primitive types in communication between the API layer | |
| * and core wallet logic, making the code more type-safe and self-documenting. | |
| */ | |
| object WalletTypes { | |
| /** | |
| * Wrapper for wallet encryption password | |
| * | |
| * Used when initializing, restoring, or unlocking a wallet | |
| */ | |
| final case class WalletPassword(value: SecretString) extends AnyVal | |
| /** | |
| * Wrapper for mnemonic password (BIP-39 passphrase) | |
| * | |
| * Optional password that can be used with a mnemonic for extra security | |
| */ | |
| final case class MnemonicPassword(value: SecretString) extends AnyVal | |
| /** | |
| * Wrapper for wallet mnemonic phrase | |
| * | |
| * The secret recovery phrase used to restore wallet | |
| */ | |
| final case class WalletMnemonic(value: SecretString) extends AnyVal | |
| /** | |
| * Wrapper for BIP-32 derivation path | |
| * | |
| * String representation of a hierarchical deterministic derivation path | |
| */ | |
| final case class DerivationPathString(value: String) extends AnyVal | |
| /** | |
| * Wrapper for scan identifier | |
| * | |
| * Numeric identifier for a wallet scan operation | |
| */ | |
| final case class ScanIdentifier(value: Int) extends AnyVal | |
| /** | |
| * Wrapper for minimum inclusion height | |
| * | |
| * Minimum blockchain height for transaction inclusion | |
| */ | |
| final case class MinInclusionHeight(value: Int) extends AnyVal | |
| /** | |
| * Wrapper for maximum inclusion height | |
| * | |
| * Maximum blockchain height for transaction inclusion | |
| */ | |
| final case class MaxInclusionHeight(value: Int) extends AnyVal | |
| /** | |
| * Wrapper for minimum confirmations count | |
| * | |
| * Minimum number of confirmations required for a transaction | |
| */ | |
| final case class MinConfirmations(value: Int) extends AnyVal | |
| /** | |
| * Wrapper for maximum confirmations count | |
| * | |
| * Maximum number of confirmations to consider for a transaction | |
| */ | |
| final case class MaxConfirmations(value: Int) extends AnyVal | |
| /** | |
| * Wrapper for box index parameter | |
| * | |
| * Index used when reading public keys or wallet boxes | |
| */ | |
| final case class BoxIndex(value: Int) extends AnyVal | |
| /** | |
| * Wrapper for target balance in nanoERG | |
| * | |
| * The amount of ERG requested for box collection | |
| */ | |
| final case class TargetBalance(value: Long) extends AnyVal | |
| /** | |
| * Wrapper for whether to use pre-1627 key derivation | |
| * | |
| * Flag indicating if legacy key derivation should be used | |
| */ | |
| final case class UsePre1627KeyDerivation(value: Boolean) extends AnyVal | |
| /** | |
| * Wrapper for unspent-only filter | |
| * | |
| * Flag indicating whether to return only unspent boxes | |
| */ | |
| final case class UnspentOnly(value: Boolean) extends AnyVal | |
| /** | |
| * Wrapper for consider-unconfirmed filter | |
| * | |
| * Flag indicating whether to consider mempool transactions | |
| */ | |
| final case class ConsiderUnconfirmed(value: Boolean) extends AnyVal | |
| /** | |
| * Wrapper for include-unconfirmed filter | |
| * | |
| * Flag indicating whether to include unconfirmed transactions | |
| */ | |
| final case class IncludeUnconfirmed(value: Boolean) extends AnyVal | |
| /** | |
| * Wrapper for sign transaction flag | |
| * | |
| * Indicates whether to sign a generated transaction | |
| */ | |
| final case class ShouldSignTransaction(value: Boolean) extends AnyVal | |
| } |
| } | ||
|
|
||
| def signMessageR: Route = (path("signMessage") & post & entity(as[SignMessageRequest])) { req => | ||
| val addressOpt = req.address.flatMap(addrStr => ErgoAddressEncoder(ergoSettings.chainSettings.addressPrefix).fromString(addrStr).toOption) |
There was a problem hiding this comment.
The code is creating a new ErgoAddressEncoder instance instead of using the implicit addressEncoder already defined at line 36. This is inefficient and inconsistent with the rest of the codebase. Use the implicit encoder instead: addressEncoder.fromString(addrStr).toOption.
| val addressOpt = req.address.flatMap(addrStr => ErgoAddressEncoder(ergoSettings.chainSettings.addressPrefix).fromString(addrStr).toOption) | |
| val addressOpt = req.address.flatMap(addrStr => addressEncoder.fromString(addrStr).toOption) |
| withWalletOp(_.signMessage(req.message, addressOpt.flatMap { | ||
| case p2pk: P2PKAddress => Some(p2pk) | ||
| case _ => None | ||
| })) { |
There was a problem hiding this comment.
When a non-P2PK address is provided, the code silently falls back to using the first available key (addressOpt becomes None). This could be confusing for API users who provide a valid Ergo address that isn't a P2PK address. Consider returning a more informative error message such as "Address must be a P2PK address for message signing" when the address is valid but not P2PK.
| case class SignMessageRequest(message: String, address: Option[String]) | ||
|
|
||
| /** | ||
| * A request to verify a signed message | ||
| * | ||
| * @param message - original message | ||
| * @param signature - signature bytes in Base16 encoding | ||
| * @param publicKey - public key bytes in Base16 encoding | ||
| */ | ||
| case class VerifySignatureRequest(message: String, signature: String, publicKey: String) |
There was a problem hiding this comment.
The SignMessageRequest and VerifySignatureRequest case classes don't validate their inputs. Empty strings are valid for message, signature, and publicKey fields, which could lead to confusing behavior. Consider adding input validation to ensure: 1) message is not empty, 2) signature and publicKey are valid Base16 strings with expected lengths (signature should be 112 hex chars for 56 bytes, publicKey should be 66 hex chars for 33 bytes).
| val secret = addressOpt match { | ||
| case Some(address) => | ||
| // Find the path for the given address | ||
| state.storage.readAllKeys().find(_.key.value == address.pubkey.value) match { | ||
| case Some(extKey) => | ||
| rootSecret.derive(extKey.path.toPrivateBranch) | ||
| case None => | ||
| throw new Exception(s"Address ${address.toString} not found in wallet") | ||
| } | ||
| case None => | ||
| // Use first available key | ||
| val firstPath = state.storage.readAllKeys().headOption match { | ||
| case Some(extKey) => extKey.path | ||
| case None => throw new Exception("No keys available in wallet") | ||
| } | ||
| rootSecret.derive(firstPath.toPrivateBranch) |
There was a problem hiding this comment.
The signMessage method calls state.storage.readAllKeys() potentially multiple times (once to find a specific address, or once to get the first key). This could be inefficient if the wallet has many keys. Consider reading all keys once and reusing the result, especially since this is called within a Try block that may execute frequently.
Add sign/verify message API endpoints (Issue #1392)
Summary
This PR implements message signing and verification endpoints for the Ergo wallet, allowing users to sign arbitrary messages with their wallet keys and verify signatures. This is useful for authentication, proof of ownership, and other cryptographic use cases.
Closes: #1392
Changes
New API Endpoints
1.
POST /wallet/signMessageSigns an arbitrary UTF-8 message using a wallet key.
Request Body:
{ "message": "Hello Ergo!", "address": "9f4QF8AD1nQ3nJahQVkMj8hFSVVzVom77b52JU7EW71Zexg6N8v" // optional }Response:
{ "signature": "0x...", // Base16-encoded Schnorr signature (56 bytes) "publicKey": "0x..." // Base16-encoded public key (33 bytes) }Features:
addressparameter to sign with a specific key2.
POST /wallet/verifySignatureVerifies a message signature against a public key.
Request Body:
{ "message": "Hello Ergo!", "signature": "0x...", // Base16-encoded signature from signMessage "publicKey": "0x..." // Base16-encoded public key from signMessage }Response:
{ "verified": true }Features:
Implementation Details
Files Modified:
MessageSigningRequests.scala(NEW)SignMessageRequest- Request model for signingVerifySignatureRequest- Request model for verificationWalletApiRoute.scalasignMessageRroute handlerverifySignatureRroute handlerErgoWalletReader.scalasignMessage()method declarationErgoWalletActorMessages.scalaSignMessagecase class for actor communicationErgoWalletActor.scalaSignMessageErgoWalletService.scalasignMessage()with key derivationErgoSignature.sign()for Schnorr signaturesApiRequestsCodecs.scalaCryptography
ErgoSignaturefromergo-walletSecurity Considerations
Testing
Manual Testing
Test 1: Sign a message
Expected Output:
{ "signature": "a1b2c3d4...", "publicKey": "03abc123..." }Test 2: Verify signature
Expected Output:
{ "verified": true }Test 3: Wrong signature
Expected Output:
{ "verified": false }Test 4: Sign with specific address
Error Handling
Wallet locked:
{ "error": 400, "reason": "Unable to sign message, wallet is locked", "detail": "..." }Address not found:
{ "error": 400, "reason": "Address 9f4QF... not found in wallet", "detail": "..." }Invalid signature format:
{ "error": 400, "reason": "Verification failed: Invalid signature hex encoding", "detail": "..." }Use Cases
Backward Compatibility
✅ No breaking changes
✅ New endpoints, existing APIs unchanged
✅ No database migrations required
✅ Optional feature, existing workflows unaffected
Checklist
ErgoSignaturefromergo-wallet