Onchain.Signer is a GenServer which can sign messages. The runtime carrier
is a {backend_module, config} pair implementing Onchain.Signer.Backend
(for instance Onchain.Signer.Secp256k1 with a local key, or
Onchain.Signer.CloudKMS with GCP Cloud KMS coordinates). A legacy
{module, function, args} MFA is also accepted so existing call sites
(Onchain.Signer.sign_direct/4, and start_link/1 handed a 3-tuple)
keep working. In either case, start the GenServer and call
Onchain.Signer.sign(MySigner, "message") to get a packed
r || s || v Ethereum signature (Onchain.Configuration.signature()).
The packed form is 65 bytes when EIP-155 v fits in one byte and longer
otherwise. At chain id 110, v is 255 or 256 depending on recovery parity,
producing 65 or 66 bytes respectively. Chain binding for legacy transactions
lives in that trailing v because Transaction.V1.add_signature/2 copies
it into the RLP v field; emitting a parity-only byte here would silently
drop EIP-155 replay protection. Typed transactions already take y-parity
from whatever width they receive.
This library never emits a packed Ethereum signature with s > n/2
(EIP-2). Low-s canonicalization is applied at the emission funnel, not by
the configured backend: both the {backend, config} path and the legacy
MFA path pass through Onchain.Recover.normalize_low_s/1 before the
recovery-bit search and EIP-155 packing. The MFA carrier remains available
for existing callers and cannot bypass the invariant.
Note: we also enforce that a given signer process knows its public key, such that we can verify signatures recovery bits. That is, since CloudKMS and other signing tools don't return a recovery bit, necessary for Ethereum, we test all 4 possible bits to make sure a signature recovers to the correct signer address, but we need to know what that address should be to accomplish this task.
Additionally, chain_id is used to return EIP-155 compliant signatures.
For stateless EIP-1559 writes, build_transaction/3, sign_transaction/3,
and encode_transaction/1 build and sign with an explicitly supplied private
key through the same backend carrier. send_transaction/3 also estimates gas
when omitted and broadcasts the encoded transaction. Signing returns the
complete signed %Onchain.Transaction.V2{}; encoding is a separate step.
API Functions
| Function | Arity | Description | Param Kinds |
|---|---|---|---|
send_transaction! | 3 | Build, sign, encode, and broadcast a transaction in one call. Raises on error. | to: value, calldata: value, opts: value |
send_transaction | 3 | Build, sign, encode, and broadcast a transaction in one call. | to: value, calldata: value, opts: value |
encode_transaction! | 1 | Encode a signed transaction to 0x-prefixed hex for broadcast. Raises on error. | signed_trx: value |
encode_transaction | 1 | Encode a signed transaction to 0x-prefixed hex for broadcast. | signed_trx: value |
sign_transaction! | 3 | Sign a transaction with a private key. Raises on error. | unsigned_trx: value, private_key: value, chain_id: value |
sign_transaction | 3 | Sign a transaction with a private key. | unsigned_trx: value, private_key: value, chain_id: value |
build_transaction! | 3 | Build an unsigned EIP-1559 transaction. Raises on error. | to: value, calldata: value, opts: value |
build_transaction | 3 | Build an unsigned EIP-1559 transaction. | to: value, calldata: value, opts: value |
address_from_key! | 1 | Derive checksummed Ethereum address from a private key. Raises on error. | private_key: value |
address_from_key | 1 | Derive checksummed Ethereum address from a private key. | private_key: value |
sign_direct | 4 | Sign a message directly with a backend carrier or MFA and known signer address. | message: value, address: value, signer_mfa: value, chain_id_or_name: value |
chain_id | 1 | Get the chain id configured for a signer process. | name: value |
address | 1 | Get the Ethereum address controlled by a signer process. | name: value |
sign_typed_data | 3 | Sign EIP-712 typed data the way eth_signTypedData_v4 / ethers signTypedData does. | typed: value, name: value, opts: value |
sign_message | 3 | Sign a message the way a wallet's personal_sign / ethers signMessage does. | message: value, name: value, opts: value |
sign | 3 | Sign a message with a running signer process. | message: value, name: value, opts: value |
start_link | 1 | Start a signer process backed by the provided signer backend carrier. | signer_options: value |
child_spec | 1 | Build the supervisor child specification for a signer process. | init_arg: value |
Summary
Functions
Gets the address for this signer.
Derive checksummed Ethereum address from a private key.
Derive checksummed Ethereum address from a private key. Raises on error.
Build an unsigned EIP-1559 transaction.
Build an unsigned EIP-1559 transaction. Raises on error.
Gets the chain id for this signer.
Returns a specification to start this module under a supervisor.
Encode a signed transaction to 0x-prefixed hex for broadcast.
Encode a signed transaction to 0x-prefixed hex for broadcast. Raises on error.
Build, sign, encode, and broadcast a transaction in one call.
Build, sign, encode, and broadcast a transaction in one call. Raises on error.
Signs a message using this signing key.
Directly sign a message, not using a signer process.
Signs a message as personal_sign / ethers signMessage / viem signMessage do.
Sign a transaction with a private key.
Sign a transaction with a private key. Raises on error.
Signs EIP-712 typed data as eth_signTypedData_v4 / ethers signTypedData /
viem signTypedData do.
Starts a new Onchain.Signer process.
Functions
@spec address(GenServer.server()) :: Onchain.Configuration.address()
Gets the address for this signer.
Examples
iex> signer_proc = Onchain.Test.Signer.start_signer()
iex> Onchain.Signer.address(signer_proc) |> Onchain.Hex.to_address()
"0x63Cc7c25e0cdb121aBb0fE477a6b9901889F99A7"
Derive checksummed Ethereum address from a private key.
Parameters
private_key- 32-byte binary or hex string (with or without 0x) (value)
Returns
EIP-55 checksummed address ({:ok, String.t()} | {:error, {:invalid_private_key, term()}})
# descripex:contract
%{
params: %{
private_key: %{
description: "32-byte binary or hex string (with or without 0x)",
kind: :value
}
},
returns: %{
type: "{:ok, String.t()} | {:error, {:invalid_private_key, term()}}",
description: "EIP-55 checksummed address",
example: "0x63Cc7c25e0cdb121aBb0fE477a6b9901889F99A7"
}
}
Derive checksummed Ethereum address from a private key. Raises on error.
Parameters
private_key- 32-byte binary or hex string (with or without 0x) (value)
Returns
EIP-55 checksummed address (string)
# descripex:contract
%{
params: %{
private_key: %{
description: "32-byte binary or hex string (with or without 0x)",
kind: :value
}
},
returns: %{type: :string, description: "EIP-55 checksummed address"}
}
@spec build_transaction(binary(), binary() | {:raw, binary()}, keyword()) :: {:ok, Onchain.Transaction.V2.t()} | {:error, term()}
Build an unsigned EIP-1559 transaction.
Parameters
to- Destination address (hex string or 20-byte binary) (value)calldata- Raw binary calldata (use Hex.decode!/1 on Onchain.ABI.encode_call output), {:raw, binary} for literal bytes that start with 0x, or <<>> for plain ETH transfer (value)opts- Required: :nonce, :chain_id. Optional: :gas_limit, :max_fee_per_gas, :max_priority_fee_per_gas, :value, :access_list. Gas params accept integers (wei) or {n, :gwei} tuples. (value)
Returns
Unsigned EIP-1559 transaction struct ({:ok, %Onchain.Transaction.V2{}} | {:error, term()})
# descripex:contract
%{
params: %{
opts: %{
description: "Required: :nonce, :chain_id. Optional: :gas_limit, :max_fee_per_gas, :max_priority_fee_per_gas, :value, :access_list. Gas params accept integers (wei) or {n, :gwei} tuples.",
kind: :value
},
to: %{
description: "Destination address (hex string or 20-byte binary)",
kind: :value
},
calldata: %{
description: "Raw binary calldata (use Hex.decode!/1 on Onchain.ABI.encode_call output), {:raw, binary} for literal bytes that start with 0x, or <<>> for plain ETH transfer",
kind: :value
}
},
returns: %{
type: "{:ok, %Onchain.Transaction.V2{}} | {:error, term()}",
description: "Unsigned EIP-1559 transaction struct"
}
}
@spec build_transaction!(binary(), binary() | {:raw, binary()}, keyword()) :: Onchain.Transaction.V2.t()
Build an unsigned EIP-1559 transaction. Raises on error.
Parameters
to- Destination address (hex string or 20-byte binary) (value)calldata- Raw binary calldata (use Hex.decode!/1 on Onchain.ABI.encode_call output), {:raw, binary} for literal bytes that start with 0x, or <<>> for plain ETH transfer (value)opts- Required: :nonce, :chain_id. Optional: :gas_limit, :max_fee_per_gas, :max_priority_fee_per_gas, :value, :access_list. Gas params accept integers (wei) or {n, :gwei} tuples. (value)
Returns
Unsigned EIP-1559 transaction struct (%Onchain.Transaction.V2{})
# descripex:contract
%{
params: %{
opts: %{
description: "Required: :nonce, :chain_id. Optional: :gas_limit, :max_fee_per_gas, :max_priority_fee_per_gas, :value, :access_list. Gas params accept integers (wei) or {n, :gwei} tuples.",
kind: :value
},
to: %{
description: "Destination address (hex string or 20-byte binary)",
kind: :value
},
calldata: %{
description: "Raw binary calldata (use Hex.decode!/1 on Onchain.ABI.encode_call output), {:raw, binary} for literal bytes that start with 0x, or <<>> for plain ETH transfer",
kind: :value
}
},
returns: %{
type: "%Onchain.Transaction.V2{}",
description: "Unsigned EIP-1559 transaction struct"
}
}
@spec chain_id(GenServer.server()) :: integer()
Gets the chain id for this signer.
Examples
iex> signer_proc = Onchain.Test.Signer.start_signer()
iex> Onchain.Signer.chain_id(signer_proc)
5
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec encode_transaction(Onchain.Transaction.V2.t()) :: {:ok, String.t()} | {:error, {:encode_error, :unsigned_transaction}}
Encode a signed transaction to 0x-prefixed hex for broadcast.
Parameters
signed_trx- Signed %Onchain.Transaction.V2{} struct (value)
Returns
0x-prefixed hex string ready for eth_sendRawTransaction ({:ok, String.t()} | {:error, {:encode_error, :unsigned_transaction}})
# descripex:contract
%{
params: %{
signed_trx: %{
description: "Signed %Onchain.Transaction.V2{} struct",
kind: :value
}
},
returns: %{
type: "{:ok, String.t()} | {:error, {:encode_error, :unsigned_transaction}}",
description: "0x-prefixed hex string ready for eth_sendRawTransaction",
example: "0x02f8..."
}
}
@spec encode_transaction!(Onchain.Transaction.V2.t()) :: String.t()
Encode a signed transaction to 0x-prefixed hex for broadcast. Raises on error.
Parameters
signed_trx- Signed %Onchain.Transaction.V2{} struct (value)
Returns
0x-prefixed hex string ready for broadcast (string)
# descripex:contract
%{
params: %{
signed_trx: %{
description: "Signed %Onchain.Transaction.V2{} struct",
kind: :value
}
},
returns: %{
type: :string,
description: "0x-prefixed hex string ready for broadcast"
}
}
@spec send_transaction(binary(), binary() | {:raw, binary()}, keyword()) :: {:ok, String.t()} | {:error, term()}
Build, sign, encode, and broadcast a transaction in one call.
Parameters
to- Destination address (hex string or 20-byte binary) (value)calldata- Raw binary calldata (use Hex.decode!/1 on Onchain.ABI.encode_call output), {:raw, binary} for literal bytes that start with 0x, or <<>> for plain ETH transfer (value)opts- Required: :privatekey, :nonce, :chain_id, :rpc_url. Optional: :gas_limit, :max_fee_per_gas, :max_priority_fee_per_gas, :value, :access_list. When :gas_limit is omitted it is auto-estimated via eth_estimateGas (1.25× headroom) from the signer's address; a failed estimate returns {:error, } rather than falling back to a default. (value)
Returns
Transaction hash hex string ({:ok, String.t()} | {:error, term()})
# descripex:contract
%{
params: %{
opts: %{
description: "Required: :private_key, :nonce, :chain_id, :rpc_url. Optional: :gas_limit, :max_fee_per_gas, :max_priority_fee_per_gas, :value, :access_list. When :gas_limit is omitted it is auto-estimated via eth_estimateGas (1.25× headroom) from the signer's address; a failed estimate returns {:error, _} rather than falling back to a default.",
kind: :value
},
to: %{
description: "Destination address (hex string or 20-byte binary)",
kind: :value
},
calldata: %{
description: "Raw binary calldata (use Hex.decode!/1 on Onchain.ABI.encode_call output), {:raw, binary} for literal bytes that start with 0x, or <<>> for plain ETH transfer",
kind: :value
}
},
returns: %{
type: "{:ok, String.t()} | {:error, term()}",
description: "Transaction hash hex string"
}
}
Build, sign, encode, and broadcast a transaction in one call. Raises on error.
Parameters
to- Destination address (hex string or 20-byte binary) (value)calldata- Raw binary calldata (use Hex.decode!/1 on Onchain.ABI.encode_call output), {:raw, binary} for literal bytes that start with 0x, or <<>> for plain ETH transfer (value)opts- Required: :privatekey, :nonce, :chain_id, :rpc_url. Optional: :gas_limit, :max_fee_per_gas, :max_priority_fee_per_gas, :value, :access_list. When :gas_limit is omitted it is auto-estimated via eth_estimateGas (1.25× headroom) from the signer's address; a failed estimate returns {:error, } rather than falling back to a default. (value)
Returns
Transaction hash hex string (string)
# descripex:contract
%{
params: %{
opts: %{
description: "Required: :private_key, :nonce, :chain_id, :rpc_url. Optional: :gas_limit, :max_fee_per_gas, :max_priority_fee_per_gas, :value, :access_list. When :gas_limit is omitted it is auto-estimated via eth_estimateGas (1.25× headroom) from the signer's address; a failed estimate returns {:error, _} rather than falling back to a default.",
kind: :value
},
to: %{
description: "Destination address (hex string or 20-byte binary)",
kind: :value
},
calldata: %{
description: "Raw binary calldata (use Hex.decode!/1 on Onchain.ABI.encode_call output), {:raw, binary} for literal bytes that start with 0x, or <<>> for plain ETH transfer",
kind: :value
}
},
returns: %{type: :string, description: "Transaction hash hex string"}
}
@spec sign(String.t(), GenServer.server(), Keyword.t()) :: {:ok, Onchain.Configuration.signature()} | {:error, term()}
Signs a message using this signing key.
Examples
iex> signer_proc = Onchain.Test.Signer.start_signer()
iex> {:ok, sig} = Onchain.Signer.sign("test", signer_proc)
iex> Onchain.Recover.recover_eth("test", sig)
...> |> Onchain.Hex.to_address()
"0x63Cc7c25e0cdb121aBb0fE477a6b9901889F99A7"
iex> signer_proc = Onchain.Test.Signer.start_signer()
iex> {:ok, <<_r::256, _s::256, v::binary>>} = Onchain.Signer.sign("test", signer_proc, chain_id: 0x05f5e0ff)
iex> :binary.decode_unsigned(v)
0x05f5e0ff * 2 + 35 + 1
@spec sign_direct( binary(), binary(), Onchain.Signer.Backend.t() | {module(), atom(), [any()]}, integer() | atom() | nil ) :: {:ok, Onchain.Configuration.signature()} | {:error, term()}
Directly sign a message, not using a signer process.
This is mostly used internally, but can be used safely externally as well.
The returned packed r || s || v signature is always low-s (EIP-2), regardless of
whether the backend normalized. Width follows Onchain.Configuration.signature():
65 bytes when EIP-155 v fits in one byte, longer when it does not.
Backend carriers must use :secp256k1 and receive the message's keccak digest
through sign_payload/2. Legacy MFAs receive the original message.
@spec sign_message(binary(), GenServer.server(), Keyword.t()) :: {:ok, Onchain.Configuration.signature()} | {:error, term()}
Signs a message as personal_sign / ethers signMessage / viem signMessage do.
Two differences from sign/3, both of which trip callers coming from
JavaScript libraries: the EIP-191 "\x19Ethereum Signed Message:\n" prefix is
applied before hashing, and v is 27/28 rather than the EIP-155 form.
sign/3 remains the primitive for transaction and EIP-712 digests, which
must not carry the prefix.
Examples
iex> signer_proc = Onchain.Test.Signer.start_signer()
iex> {:ok, sig} = Onchain.Signer.sign_message("hello", signer_proc)
iex> Onchain.Recover.recover_personal_sign("hello", sig) |> Onchain.Hex.to_address()
"0x63Cc7c25e0cdb121aBb0fE477a6b9901889F99A7"
iex> signer_proc = Onchain.Test.Signer.start_signer()
iex> {:ok, <<_r::256, _s::256, v>>} = Onchain.Signer.sign_message("hello", signer_proc)
iex> v in [27, 28]
true
@spec sign_transaction(Onchain.Transaction.V2.t(), binary(), pos_integer()) :: {:ok, Onchain.Transaction.V2.t()} | {:error, {:sign_error, term()} | {:invalid_private_key, term()}}
Sign a transaction with a private key.
Parameters
unsigned_trx- Unsigned %Onchain.Transaction.V2{} struct (value)private_key- 32-byte binary or hex string (with or without 0x) (value)chain_id- Chain ID integer (1 = mainnet, 11155111 = Sepolia) (value)
Returns
Signed transaction with signature fields populated ({:ok, %Onchain.Transaction.V2{}} | {:error, {:sign_error, term()}})
# descripex:contract
%{
params: %{
private_key: %{
description: "32-byte binary or hex string (with or without 0x)",
kind: :value
},
chain_id: %{
description: "Chain ID integer (1 = mainnet, 11155111 = Sepolia)",
kind: :value
},
unsigned_trx: %{
description: "Unsigned %Onchain.Transaction.V2{} struct",
kind: :value
}
},
returns: %{
type: "{:ok, %Onchain.Transaction.V2{}} | {:error, {:sign_error, term()}}",
description: "Signed transaction with signature fields populated"
}
}
@spec sign_transaction!(Onchain.Transaction.V2.t(), binary(), pos_integer()) :: Onchain.Transaction.V2.t()
Sign a transaction with a private key. Raises on error.
Parameters
unsigned_trx- Unsigned %Onchain.Transaction.V2{} struct (value)private_key- 32-byte binary or hex string (with or without 0x) (value)chain_id- Chain ID integer (1 = mainnet, 11155111 = Sepolia) (value)
Returns
Signed transaction with signature fields populated (%Onchain.Transaction.V2{})
# descripex:contract
%{
params: %{
private_key: %{
description: "32-byte binary or hex string (with or without 0x)",
kind: :value
},
chain_id: %{
description: "Chain ID integer (1 = mainnet, 11155111 = Sepolia)",
kind: :value
},
unsigned_trx: %{
description: "Unsigned %Onchain.Transaction.V2{} struct",
kind: :value
}
},
returns: %{
type: "%Onchain.Transaction.V2{}",
description: "Signed transaction with signature fields populated"
}
}
@spec sign_typed_data(Onchain.Typed.t(), GenServer.server(), Keyword.t()) :: {:ok, Onchain.Configuration.signature()} | {:error, term()}
Signs EIP-712 typed data as eth_signTypedData_v4 / ethers signTypedData /
viem signTypedData do.
Encodes the 0x1901 || domainSeparator || hashStruct payload with
Onchain.Typed.encode/1 and signs it with v in 27/28. No EIP-191
prefix is involved — that is sign_message/3. sign/3 with the encoded
payload gives the same digest with an EIP-155 v instead.
Examples
iex> signer_proc = Onchain.Test.Signer.start_signer()
iex> typed = %Onchain.Typed{
...> domain: %Onchain.Typed.Domain{name: "Complex Array", version: "1"},
...> types: %{"Array" => %Onchain.Typed.Type{fields: [{"a", {:uint, 256}}, {"b", {:uint, 256}}, {"c", :string}, {"d", :bool}]}},
...> value: %{"a" => 55, "b" => 66, "c" => "Hello", "d" => true}
...> }
iex> {:ok, sig} = Onchain.Signer.sign_typed_data(typed, signer_proc)
iex> Onchain.Recover.recover_eth(Onchain.Typed.encode(typed), sig) |> Onchain.Hex.to_address()
"0x63Cc7c25e0cdb121aBb0fE477a6b9901889F99A7"
iex> <<_r::256, _s::256, v>> = sig
iex> v in [27, 28]
true
@spec start_link( mfa: Onchain.Signer.Backend.t() | {module(), atom(), [any()]}, name: GenServer.name() | nil ) :: GenServer.on_start()
Starts a new Onchain.Signer process.