A gRPC/HTTP service for transaction signing and TMKMS (Tendermint Key Management System) management in the Saga ecosystem.
⚠️ Disclaimer: This software has not yet been battle-tested in production. While it includes comprehensive tests, use it at your own risk and perform thorough testing before deploying to any production environment.
- Transaction Signing: Signs Cosmos SDK transactions using private keys
- TMKMS Management: Manages Tendermint Key Management System (TMKMS) configuration for validator operations. Unlike traditional TMKMS which requires manual configuration file edits and service restarts, the signer enables dynamic management of consensus chains through API calls. It automatically generates TMKMS configuration files, manages the TMKMS process lifecycle (spawning, restarting on crashes, graceful shutdown), and performs atomic configuration updates to ensure TMKMS always sees a consistent state.
The signer service supports the same consensus key providers as TMKMS, allowing validators to use hardware-backed keys for enhanced security or file-based keys for simpler setups.
| Provider | Implemented | Tested | Description |
|---|---|---|---|
| File (Softsign) | ✅ | ✅ | File-based Ed25519 keys stored on disk |
| Fortanix DSM | ✅ | ✅ | Fortanix Data Security Manager cloud HSM |
| Ledger | ✅ | Ledger hardware wallet with Tendermint Validator app. Not tested because the Tendermint Validator app doesn't appear to be published in Ledger Live/Wallet anymore. | |
| YubiHSM | YubiHSM2 hardware security module. Not tested due to lack of hardware. TMKMS config generation supports both USB and HTTP adapters. GetConsensusKeys only works with HTTP adapter type. |
The signer service generates TMKMS configuration files that are compatible with all supported consensus key providers. Each provider requires specific configuration in the signer's config file - see internal/config/config.yaml for examples.
Note: Ledger is implemented but untested. YubiHSM implementation is partial - USB adapter type is not supported for GetConsensusKeys because the Go yubihsm-go library only supports HTTP connectors. Both should be thoroughly tested with actual hardware before use in production.
- Install Go (version 1.25 or later)
- Build the signer service:
make build
Run the signer service with a configuration file:
./build/signer --config config.yamlNote: The TMKMS binary path must be specified in the configuration file's tmkms.binary_path field (e.g., /usr/local/bin/tmkms). For now, only TMKMS 0.15.0 is supported.
See internal/config/config.yaml for an example configuration file.
Run the signer service using the Docker image:
docker run -d \
--name saga-signer \
-v /path/to/config.yaml:/etc/signer/config.yaml:ro \
-v /path/to/tx_keys:/var/signer/tx_keys:ro \
-v /path/to/consensus.key:/var/signer/consensus.key:ro \
-v /path/to/workdir:/var/signer/ \
-p 9090:9090 \
-p 8080:8080 \
-p 8081:8081 \
sagaxyz/signer:latestSee internal/config/config.yaml for an example configuration file.
Volumes:
/etc/signer/config.yaml(read-only): Configuration file/var/signer/tx_keys(read-only): Directory containing transaction signing key files/var/signer/consensus.key(read-only): Consensus key file/var/signer/: Working directory (read-write). This volume is only used for persistence - the signer will create thetmkmssymlink and necessary data and configuration files automatically.
Ports:
9090: gRPC server8080: HTTP gateway8081: Admin server (metrics and health)
The Docker image includes both the signer binary and the TMKMS binary, so no additional setup is required.
Run the unit tests:
make testIntegration tests require Rust to be installed since TMKMS needs to be built:
- Install Rust (https://rustup.rs/)
- Run integration tests:
make test-integration
The integration tests will automatically build TMKMS and run comprehensive tests against the full service stack.
- SetConsensusChains: Configure validator addresses for different chains to enable privval signing of consensus messages (via TMKMS). This method replaces the entire consensus chain configuration and is idempotent - calling it multiple times with the same data produces the same result. Returns an empty response indicating success.
- GetConsensusKeys: Returns the CometBFT consensus public keys for the specified chain IDs.
- SignTx: Sign transactions for broadcasting
- GetSignTxKeys: Returns the Cosmos SDK public keys for the specified key entries. Each entry includes a
key_idand optionalchain_id(currently unused, reserved for future use when keys may be chain-specific).
The service also exposes HTTP endpoints via gRPC-Gateway for REST API access to the same functionality:
- SetConsensusChains:
POST /v1/set-consensus-chains - GetConsensusKeys:
POST /v1/get-consensus-keys - SignTx:
POST /v1/sign-tx - GetSignTxKeys:
POST /v1/get-sign-tx-keys
SetConsensusChains:
Request:
{
"chains": [
{
"chain_id": "my-chain-1",
"validator_address": "[email protected]:26658"
},
{
"chain_id": "test-network-2",
"validator_address": "[email protected]:26658"
}
]
}Response:
{}GetConsensusKeys:
Request:
{
"chain_ids": ["my-chain-1", "test-network-2"]
}Response:
{
"chains": [
{
"chain_id": "my-chain-1",
"public_key": {
"sum": {
"ed25519": "dGVzdGNvbnNlbnN1c2tleWV4YW1wbGVmb3JkYXNoYm9hcmQ="
}
}
},
{
"chain_id": "test-network-2",
"public_key": {
"sum": {
"ed25519": "dGVzdGNvbnNlbnN1c2tleWV4YW1wbGVmb3JkYXNoYm9hcmQ="
}
}
}
]
}The ed25519 field contains the Ed25519 public key bytes encoded in base64 (32 bytes when decoded).
SignTx:
Request:
{
"key_id": "validator-key-1",
"chain_id": "my-chain-1",
"account_number": 12345,
"tx_body": {
"messages": [...],
"memo": "",
"timeout_height": "0"
},
"auth_info": {
"signer_infos": [...],
"fee": {
"amount": [...],
"gas_limit": "200000"
}
}
}Response:
{
"tx": {
"body": {
"messages": [...],
"memo": "",
"timeout_height": "0"
},
"auth_info": {
"signer_infos": [...],
"fee": {
"amount": [...],
"gas_limit": "200000"
}
},
"signatures": ["base64_encoded_signature_here"]
},
"tx_hash": "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"
}GetSignTxKeys:
Request:
{
"entries": [
{
"key_id": "validator-key-1",
"chain_id": ""
},
{
"key_id": "validator-key-2",
"chain_id": ""
}
]
}Response:
{
"keys": [
{
"key_id": "validator-key-1",
"chain_id": "",
"public_key": {
"@type": "/cosmos.crypto.secp256k1.PubKey",
"key": "AhyVB+S6Z1pE7ycwYglemafp5qIDXKAgQwac0GeeHAce"
}
},
{
"key_id": "validator-key-2",
"chain_id": "",
"public_key": {
"@type": "/cosmos.crypto.secp256k1.PubKey",
"key": "BhyVB+S6Z1pE7ycwYglemafp5qIDXKAgQwac0GeeHAcf"
}
}
]
}The chain_id field is optional and currently unused (reserved for future use when keys may be chain-specific).
The public keys are returned as Cosmos SDK secp256k1.PubKey, compatible with Cosmos SDK account types.
We provide a Grafana dashboard (monitoring/grafana-dashboard.json) to visualize service metrics:
- TMKMS Health: Monitor TMKMS health status and chain count over time
- API RED Metrics: Visualize API request metrics (Rate, Errors, Duration) for all API methods
We provide Prometheus alerting rules (monitoring/prometheus-alerts.yml) for monitoring the signer service:
- TMKMSUnhealthy: Fires when TMKMS health status is unhealthy (
tmkms_healthy == 0) for more than 5 minutes with page severity
The signer service exposes Prometheus metrics and health checks through an optional admin HTTP server:
- Metrics:
/metrics- Prometheus metrics endpoint - Health:
/health- Health check endpoint that reports the service health status
Configure the admin server in your config file by setting the listen_admin field (e.g., "localhost:8080").
The /metrics endpoint exposes Prometheus-formatted metrics for monitoring and alerting. The following metrics are available:
gRPC Metrics (standard metrics for all API methods):
grpc_server_handled_total: Total number of RPCs completed, labeled by method, service, and status codegrpc_server_handling_seconds: Histogram of response latency for all gRPC methods
SignTx-specific Metrics (with chainID labels):
grpc_server_handled_by_chain_total: Total number of SignTx RPCs completed, labeled by chainID and status codegrpc_server_handling_by_chain_seconds: Histogram of SignTx response latency, labeled by chainID
TMKMS Metrics:
tmkms_healthy: TMKMS health status gauge (1 if healthy, 0 if unhealthy)tmkms_chain_count: Number of consensus chains currently configured for TMKMS
The /health endpoint returns a JSON response with the current health status:
Example response (healthy):
{
"status": "ok",
"tmkms": {
"monitor": {
"healthy": true,
"for_seconds": 30,
"last_checked": "2025-11-01T03:00:00Z"
},
"chain_count": 2
}
}Example response (unhealthy):
{
"status": "tmkms_unhealthy",
"tmkms": {
"monitor": {
"healthy": false,
"for_seconds": 5,
"last_error": "ping failed: connection refused",
"last_checked": "2025-11-01T03:00:00Z"
},
"chain_count": 1
}
}Example response (TMKMS monitoring unavailable):
{
"status": "tmkms_status_unknown",
"tmkms": {
"chain_count": 0
}
}Field Descriptions:
-
status(string): Overall health status."ok"when healthy. -
tmkms.monitor.healthy(boolean, optional): Whether the monitor detected a healthy state.true= TMKMS is responding correctly,false= error detected. Present when TMKMS monitoring is functioning as expected. -
tmkms.monitor.for_seconds(integer, optional): How long (in seconds)monitor.healthyhad been that state when the last health check was performed. Resets to0when the health state changes. Present when TMKMS monitoring is functioning as expected. -
tmkms.monitor.last_error(string, optional): Error message from the last failed health check. Present whenhealthyisfalse. -
tmkms.monitor.last_checked(string, optional): ISO 8601 timestamp (RFC 3339 format) in UTC (GMT) of when the last health check was performed. Present when TMKMS monitoring is functioning as expected. -
tmkms.chain_count(integer): Number of consensus chains currently configured for TMKMS signing.
HTTP Status Codes:
200 OK: Service is healthy503 Service Unavailable: Service is unhealthy or TMKMS monitoring is unavailable