Onchain.Signer (onchain v0.16.0)

Copy Markdown View Source

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

FunctionArityDescriptionParam Kinds
send_transaction!3Build, sign, encode, and broadcast a transaction in one call. Raises on error.to: value, calldata: value, opts: value
send_transaction3Build, sign, encode, and broadcast a transaction in one call.to: value, calldata: value, opts: value
encode_transaction!1Encode a signed transaction to 0x-prefixed hex for broadcast. Raises on error.signed_trx: value
encode_transaction1Encode a signed transaction to 0x-prefixed hex for broadcast.signed_trx: value
sign_transaction!3Sign a transaction with a private key. Raises on error.unsigned_trx: value, private_key: value, chain_id: value
sign_transaction3Sign a transaction with a private key.unsigned_trx: value, private_key: value, chain_id: value
build_transaction!3Build an unsigned EIP-1559 transaction. Raises on error.to: value, calldata: value, opts: value
build_transaction3Build an unsigned EIP-1559 transaction.to: value, calldata: value, opts: value
address_from_key!1Derive checksummed Ethereum address from a private key. Raises on error.private_key: value
address_from_key1Derive checksummed Ethereum address from a private key.private_key: value
sign_direct4Sign 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_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.

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

address(name \\ Default)

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"

address_from_key(private_key)

@spec address_from_key(binary()) ::
  {:ok, String.t()} | {:error, {:invalid_private_key, term()}}

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"
  }
}

address_from_key!(private_key)

@spec address_from_key!(binary()) :: String.t()

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"}
}

build_transaction(to, calldata, opts)

@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"
  }
}

build_transaction!(to, calldata, opts)

@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"
  }
}

chain_id(name \\ Default)

@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

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

encode_transaction(signed_trx)

@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..."
  }
}

encode_transaction!(trx)

@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"
  }
}

send_transaction(to, calldata, opts)

@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"
  }
}

send_transaction!(to, calldata, opts)

@spec send_transaction!(binary(), binary() | {:raw, binary()}, keyword()) ::
  String.t()

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"}
}

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

@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

sign_direct(message, address, carrier, chain_id_or_name)

@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.

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

@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

sign_transaction(unsigned_trx, private_key, chain_id)

@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"
  }
}

sign_transaction!(unsigned_trx, private_key, chain_id)

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"
  }
}

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

@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

start_link(list)

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

Starts a new Onchain.Signer process.