Skip to main content
The Grid Rust SDK separates building an activation from signing it. Anything that can produce a 64-byte Ed25519 signature over a byte string is a valid signer. That lets you keep private keys in an HSM or KMS and have the SDK call out to it for the one operation that needs the key. This guide shows how to plug an external signing oracle into GenClient by implementing the EddsaSigner trait. The worked example uses placeholders for the KMS call so you can drop in your provider’s SDK; the structure is the same for AWS CloudHSM, GCP KMS, Azure Key Vault Managed HSM, or HashiCorp Vault transit.

Prerequisites

  • Rust 1.75+ and a project that depends on the Grid client-sdk crate.
  • An external key store that can sign raw Ed25519 (RFC 8032) without adding any extra hashing or domain separation.
  • The 32-byte Ed25519 public key for that KMS-resident key, fetched once at boot.
AWS KMS specifically does not support Ed25519 as of this writing. Its asymmetric key specs are RSA, NIST P-curves, secp256k1, and SM2. If you need Ed25519 on AWS, use AWS CloudHSM (which exposes Ed25519 via PKCS#11) or AWS KMS External Key Store (XKS) backed by an HSM that does. Providers with native Ed25519 signing include GCP KMS (EC_SIGN_ED25519), Azure Key Vault Managed HSM (EdDSA), and HashiCorp Vault transit (ed25519).

How the SDK splits the work

EddsaSigner is the SDK’s signing-oracle interface. The trait has two required methods:
#[async_trait(?Send)]
pub trait EddsaSigner {
    type Error: std::error::Error + Send + Sync + 'static;

    fn public_key(&self) -> &GenPublicKey;
    async fn sign_bytes(&self, message: &[u8]) -> Result<EddsaSignature, Self::Error>;
}
The contract:
  • public_key() returns a cached GenPublicKey. It must not perform I/O; fetch the key once at construction and store it.
  • sign_bytes() signs message with raw Ed25519. The SDK supplies a canonical 82-byte activation pre-image; do not hash it again or add domain separation.
  • The default sign_activation() extracts that pre-image and forwards to sign_bytes(); you almost never override it.
That’s the whole adapter surface. Everything else (building the canonical activation, attaching the signature, submitting to RPC, polling for finality) stays in the SDK.

Step 1: Construct the public key handle

Fetch the public key from your KMS once at boot, then wrap it in a GenPublicKey. The wire format is the raw 32-byte Ed25519 public key.
use client_sdk::GenPublicKey;

let public_key_bytes: [u8; 32] = kms_get_public_key(&kms, &key_id).await?;
let public_key = GenPublicKey::from_bytes(&public_key_bytes)?;

// The canonical Grid address derived from this public key:
println!("address: {}", public_key.entity_id());
GenPublicKey::from_bytes does the curve check; if the KMS returned a key that isn’t a valid Ed25519 point, you find out here rather than at signing time.

Step 2: Implement EddsaSigner

A KMS adapter is a struct that holds your KMS client handle, the key identifier, and the cached public key. The mock signer in client-sdk/src/test_utils/mock_eddsa_signer.rs shows the same shape backed by ed25519-dalek.
use async_trait::async_trait;
use client_sdk::{EddsaSigner, EddsaSignature, GenPublicKey};
use thiserror::Error;

pub struct KmsSigner {
    kms: KmsClient,           // your provider's SDK client
    key_id: String,           // KMS key identifier
    public_key: GenPublicKey, // fetched once at construction
}

#[derive(Debug, Error)]
pub enum KmsSignerError {
    #[error("KMS sign failed: {0}")]
    Sign(#[source] kms_sdk::Error),
    #[error("KMS returned a {0}-byte signature; Ed25519 is 64")]
    InvalidLength(usize),
}

impl KmsSigner {
    pub async fn new(kms: KmsClient, key_id: String) -> Result<Self, KmsSignerError> {
        let pk_bytes = kms_get_public_key(&kms, &key_id).await
            .map_err(KmsSignerError::Sign)?;
        let public_key = GenPublicKey::from_bytes(&pk_bytes)
            .map_err(|e| KmsSignerError::Sign(e.into()))?;
        Ok(Self { kms, key_id, public_key })
    }
}

#[async_trait(?Send)]
impl EddsaSigner for KmsSigner {
    type Error = KmsSignerError;

    fn public_key(&self) -> &GenPublicKey {
        &self.public_key
    }

    async fn sign_bytes(&self, message: &[u8]) -> Result<EddsaSignature, Self::Error> {
        // Replace this call with your KMS's "sign raw Ed25519" API.
        // GCP KMS:    AsymmetricSign with digest=None and message=raw_bytes
        // Azure MHSM: SignRawEd25519
        // Vault:      transit/sign/<key>/ed25519 with hash_algorithm=none
        // CloudHSM:   PKCS#11 C_SignInit + C_Sign with CKM_EDDSA
        let sig_bytes: Vec<u8> = self.kms.sign_ed25519(&self.key_id, message).await
            .map_err(KmsSignerError::Sign)?;

        let sig: [u8; 64] = sig_bytes.as_slice().try_into()
            .map_err(|_| KmsSignerError::InvalidLength(sig_bytes.len()))?;
        Ok(EddsaSignature::new(sig))
    }
}
The trait is ?Send to keep the SDK WASM-compatible. If your KMS client is Send + Sync (it usually will be in a server), you can add those bounds at your own use sites without touching the trait.

Step 3: Submit a transfer

GenClient::sign_and_submit_activation is generic over EddsaSigner; pass your KMS adapter and the SDK does the rest:
use client_sdk::{ActivationPayload, GenClient, GenClientConfig};

let client = GenClient::connect(GenClientConfig::devnet()).await?;
let signer = KmsSigner::new(kms_client, "alias/gen-treasury".into()).await?;

let payload: ActivationPayload = build_transfer_payload(/* to, amount */);

let activation_id = client
    .sign_and_submit_activation(&signer, &payload)
    .await?;

let outcome = client.wait(activation_id).await?;
Internally the call:
  1. Builds the canonical unsigned activation against signer.public_key().
  2. Hands the 82-byte pre-image to signer.sign_bytes().
  3. Attaches the returned signature and submits to the RPC endpoint.
If your KMS adapter returns KmsSignerError::Sign(...), it surfaces as SdkError::EddsaSigner(_) with the original error preserved as the source. For payload construction, see Send a transfer programmatically (Rust).

Operational notes

  • Latency: A KMS round-trip per transaction is the dominant cost. Budget for it in any timeout you set (SubmitActivationRequest, wait strategy). For high-throughput agents, prefer regional endpoints and warm connections.
  • Public-key caching: Fetch the public key once at boot. The trait contract forbids I/O in public_key(), and re-fetching on every call would double KMS load.
  • Retries: sign_bytes runs after the activation pre-image is committed. If the KMS call fails transiently, it is safe to retry; the pre-image is byte-identical. Retries beyond the activation’s valid_until_block will be rejected by the mempool, so retry quickly or rebuild.
  • Authorization: Lock the KMS key policy down to the IAM role / service identity that runs your Grid client. Anyone who can call sign on that key can move funds from the corresponding account.
  • Audit: KMS providers log every sign call. Pair the KMS audit log with the resulting ActivationId (logged by the SDK) to get an end-to-end audit trail.

Bring-your-own without the trait

If your signing oracle can’t be wrapped in an async fn (offline air-gapped flow, hardware wallet with a confirm-on-device prompt, etc.): drive the steps manually:
  1. GenPublicKey::from_bytes: wrap the 32-byte public key.
  2. GenClient::build_signable_activation_with_options: produce the canonical unsigned activation.
  3. SignableGenExternalActivation::signable_buffer: extract the 82-byte pre-image.
  4. Sign those bytes externally with raw Ed25519 (RFC 8032).
  5. EddsaSignature::new(sig_bytes): wrap the 64-byte signature.
  6. SignableGenExternalActivation::into_signed: attach the signature.
  7. GenClient::submit_activation: submit.
This is the same flow the trait runs internally, exposed as primitives.

See also