Cartouche.Signer (Cartouche v0.9.1)

Copy Markdown View Source

Cartouche.Signer is a GenServer which can sign messages. The runtime carrier is a {backend_module, config} pair implementing Cartouche.Signer.Backend (for instance Cartouche.Signer.Curvy with a local key, or Cartouche.Signer.CloudKMS with GCP Cloud KMS coordinates). A legacy {module, function, args} MFA is also accepted so existing call sites (Cartouche.Signer.sign_direct/4, and start_link/1 handed a 3-tuple) keep working. In either case, start the GenServer and call Cartouche.Signer.sign(MySigner, "message") to get a packed r || s || v Ethereum signature (Cartouche.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 Cartouche.Recover.normalize_low_s/1 before the recovery-bit search and EIP-155 packing. The MFA carrier is kept because sign_direct/4 is the production signing route in the downstream onchain repo; migrating that call site is a separate task. It 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.

API Functions

FunctionArityDescriptionParam Kinds
sign_direct4Sign a message directly with a signing MFA and known signer address.message: value, address: value, signer_mfa: value, chain_id_or_name: value
chain_id1Get the chain id configured for a signer process.name: value
address1Get the Ethereum address controlled by a signer process.name: value
sign_typed_data3Sign EIP-712 typed data the way eth_signTypedData_v4 / ethers signTypedData does.typed: value, name: value, opts: value
sign_message3Sign a message the way a wallet's personal_sign / ethers signMessage does.message: value, name: value, opts: value
sign3Sign a message with a running signer process.message: value, name: value, opts: value
start_link1Start a signer process backed by the provided signer backend carrier.signer_options: value
child_spec1Build the supervisor child specification for a signer process.init_arg: value

Summary

Functions

Gets the address for this signer.

Gets the chain id for this signer.

Returns a specification to start this module under a supervisor.

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.

Signs EIP-712 typed data as eth_signTypedData_v4 / ethers signTypedData / viem signTypedData do.

Starts a new Cartouche.Signer process.

Functions

address(name \\ Default)

@spec address(GenServer.server()) :: Cartouche.address()

Gets the address for this signer.

Examples

iex> signer_proc = Cartouche.Test.Signer.start_signer()
iex> Cartouche.Signer.address(signer_proc) |> Cartouche.Hex.to_address()
"0x63Cc7c25e0cdb121aBb0fE477a6b9901889F99A7"

chain_id(name \\ Default)

@spec chain_id(GenServer.server()) :: integer()

Gets the chain id for this signer.

Examples

iex> signer_proc = Cartouche.Test.Signer.start_signer()
iex> Cartouche.Signer.chain_id(signer_proc)
5

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

sign(message, name \\ Default, opts \\ [])

@spec sign(String.t(), GenServer.server(), Keyword.t()) ::
  {:ok, Cartouche.signature()} | {:error, term()}

Signs a message using this signing key.

Examples

iex> signer_proc = Cartouche.Test.Signer.start_signer()
iex> {:ok, sig} = Cartouche.Signer.sign("test", signer_proc)
iex> Cartouche.Recover.recover_eth("test", sig)
...> |> Cartouche.Hex.to_address()
"0x63Cc7c25e0cdb121aBb0fE477a6b9901889F99A7"

iex> signer_proc = Cartouche.Test.Signer.start_signer()
iex> {:ok, <<_r::256, _s::256, v::binary>>} = Cartouche.Signer.sign("test", signer_proc, chain_id: 0x05f5e0ff)
iex> :binary.decode_unsigned(v)
0x05f5e0ff * 2 + 35 + 1

sign_direct(message, address, arg, chain_id_or_name)

@spec sign_direct(
  String.t(),
  binary(),
  {module(), atom(), [any()]},
  integer() | atom() | nil
) ::
  {:ok, Cartouche.signature()} | {:error, String.t()}

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 MFA backend normalized. Width follows Cartouche.signature(): 65 bytes when EIP-155 v fits in one byte, longer when it does not.

sign_message(message, name \\ Default, opts \\ [])

@spec sign_message(binary(), GenServer.server(), Keyword.t()) ::
  {:ok, Cartouche.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 = Cartouche.Test.Signer.start_signer()
iex> {:ok, sig} = Cartouche.Signer.sign_message("hello", signer_proc)
iex> Cartouche.Recover.recover_personal_sign("hello", sig) |> Cartouche.Hex.to_address()
"0x63Cc7c25e0cdb121aBb0fE477a6b9901889F99A7"

iex> signer_proc = Cartouche.Test.Signer.start_signer()
iex> {:ok, <<_r::256, _s::256, v>>} = Cartouche.Signer.sign_message("hello", signer_proc)
iex> v in [27, 28]
true

sign_typed_data(typed, name \\ Default, opts \\ [])

@spec sign_typed_data(Cartouche.Typed.t(), GenServer.server(), Keyword.t()) ::
  {:ok, Cartouche.signature()} | {:error, term()}

Signs EIP-712 typed data as eth_signTypedData_v4 / ethers signTypedData / viem signTypedData do.

Encodes the 0x1901 || domainSeparator || hashStruct payload with Cartouche.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 = Cartouche.Test.Signer.start_signer()
iex> typed = %Cartouche.Typed{
...>   domain: %Cartouche.Typed.Domain{name: "Complex Array", version: "1"},
...>   types: %{"Array" => %Cartouche.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} = Cartouche.Signer.sign_typed_data(typed, signer_proc)
iex> Cartouche.Recover.recover_eth(Cartouche.Typed.encode(typed), sig) |> Cartouche.Hex.to_address()
"0x63Cc7c25e0cdb121aBb0fE477a6b9901889F99A7"
iex> <<_r::256, _s::256, v>> = sig
iex> v in [27, 28]
true

start_link(list)

@spec start_link(
  mfa: Cartouche.Signer.Backend.t() | {module(), atom(), [any()]},
  name: GenServer.name() | nil
) :: GenServer.on_start()

Starts a new Cartouche.Signer process.