Behaviour for key-custody signer backends — where the key lives and who computes the raw signature.
This is the custody axis only (local key, GCP/AWS/Azure KMS, Vault, MPC).
It is deliberately decoupled from the message axis (Ethereum tx, EIP-712
typed data, Hyperliquid action hash, raw Ed25519 message). Message hashing/serialization
lives in the caller (Onchain.Signer / an Ed25519 caller), never
in a backend, so one backend set serves every message format.
The pure-payload contract
sign_payload/2 signs exactly the bytes it is handed — it performs no
hashing of its own:
:secp256k1backends receive a 32-byte digest (e.g. the keccak of an Ethereum tx, or a pre-computed EIP-712 / Hyperliquid typed-data hash) and return aOnchain.Signaturestruct (r/s, optionalrecid— recovery is the caller's job).:ed25519backends receive the raw message bytes (Ed25519 hashes internally, SHA-512) and return a 64-byte signature.
Because the backend never hashes, plain Eth-tx signing, EIP-712, Hyperliquid, and Ed25519 all reuse the same backend with no per-venue backend change — the caller computes the digest/payload and hands it over.
Signature normalization & recovery — caller-side, not here
Two concerns that vary by transport, not by custody, stay out of the backend so each new secp256k1 backend inherits them for free:
- Low-s canonicalization (EIP-2 malleability). Local k256 already emits
low-s; DER-decoded KMS output (AWS/Azure) does not. The caller normalizes
with
Onchain.Recover.normalize_low_s/1aftersign_payload/2returns, so the invariant is explicit and backend-agnostic. - Recovery-bit search (
find_recid). secp256k1 backends may returnrecid: nil; the caller verifies the recid against the known address over the same digest the backend signed (Onchain.Recover.find_recid_from_digest/3).
config
An opaque, backend-specific term carrying everything the backend needs to
authenticate and address a key — a raw private-key binary for
Onchain.Signer.Secp256k1, a Cloud-KMS key-coordinate tuple for
Onchain.Signer.CloudKMS, an Ed25519 seed for
an Ed25519 backend. The runtime carries a backend as a
{module, config} pair.
Summary
Types
Opaque, backend-specific configuration (private key, KMS coordinates, seed, …).
{backend_module, config} — the runtime carrier for a signer backend.
Callbacks
The elliptic curve this backend signs on.
The curve-native public key for the backend's key.
Sign the exact payload bytes handed in — no internal hashing.
Functions
Reject a backend whose algorithm/1 is not expected.
Types
Callbacks
@callback algorithm(config()) :: :secp256k1 | :ed25519
The elliptic curve this backend signs on.
Determines how the caller prepares the payload: :secp256k1 ⇒ hand a 32-byte
digest; :ed25519 ⇒ hand the raw message bytes.
Callers must check this before dispatching: Onchain.Signer requires
:secp256k1 and an Ed25519 caller requires :ed25519. Use
expect_algorithm/3.
The curve-native public key for the backend's key.
Returns the public key, not a chain address — the caller derives the address
(e.g. secp256k1 ⇒ Onchain.Address.from_public_key/1). Returns the uncompressed SEC1 secp256k1 point (65
bytes including the leading 0x04 prefix, which Onchain.Address.from_public_key/1
strips) or the 32-byte Ed25519 public key.
@callback sign_payload(payload :: binary(), config()) :: {:ok, Onchain.Signature.t() | <<_::512>>} | {:error, term()}
Sign the exact payload bytes handed in — no internal hashing.
:secp256k1 ⇒ payload is a 32-byte digest; returns {:ok, %Onchain.Signature{}}
with an optional recovery ID. :ed25519 ⇒ payload is the raw message; returns
{:ok, <<_::512>>}.
Functions
@spec expect_algorithm(module(), config(), :secp256k1 | :ed25519) :: :ok | {:error, {:algorithm_mismatch, :secp256k1 | :ed25519, atom()}}
Reject a backend whose algorithm/1 is not expected.
Returns :ok on match, or
{:error, {:algorithm_mismatch, expected, got}} so a crossed-curve
{module, config} fails instead of mis-signing.