EIP-3009 TransferWithAuthorization building and EIP-712 signing.
Implements the client half of the x402 exact scheme on EVM networks with
the default eip3009 asset transfer method: building an authorization from
v2 payment requirements, computing its EIP-712 digest, and signing it
through the X402.Signer behaviour to produce the scheme payload map
%{"signature" => "0x...", "authorization" => %{...}}carried inside a v2 PaymentPayload (see X402.Client.build_payment/3 for
the full envelope).
The EIP-712 domain is derived from the payment requirements as specified by
the exact-EVM scheme: name/version from extra, the chain id from the
CAIP-2 network, and the verifying contract from asset.
Cryptographic operations require the optional ex_secp256k1 and
ex_keccak dependencies and return {:error, :missing_dependency} when
they are unavailable; the library itself compiles without them.
Summary
Payment Settlement
Builds the transferWithAuthorization calldata for an authorization and
its raw signature bytes.
Types
A TransferWithAuthorization authorization in wire shape: string keys
"from", "to", "value", "validAfter", "validBefore", "nonce".
An EIP-712 domain map — see X402.EIP712.domain/0.
The scheme payload map for a signed EIP-3009 payment.
Functions
Builds a TransferWithAuthorization authorization map in wire shape.
Extracts the chain id from an eip155:<chainId> CAIP-2 network identifier.
Derives the lowercase EVM address for a 32-byte secp256k1 private key.
Derives the EIP-712 domain from v2 payment requirements.
Computes the EIP-712 digest of a TransferWithAuthorization.
ABI-encodes a 0x-prefixed EVM address into a 32-byte word.
Decodes a 0x-prefixed hex string into a 32-byte binary.
ABI-encodes a non-negative integer (or decimal string) into a 32-byte word.
Converts a 65-byte uncompressed (or 64-byte) secp256k1 public key to a
lowercase 0x-prefixed EVM address.
Returns a fresh random 32-byte nonce as a 0x-prefixed hex string.
Recovers the signer address from an EIP-712 digest and a 65-byte signature.
Signs the exact/eip3009 scheme payload for the given requirements.
Signs an authorization's EIP-712 digest with a signer.
Payment Settlement
@spec transfer_calldata(map(), binary(), :eoa | :erc1271 | :erc6492_counterfactual) :: {:ok, binary()} | {:error, encode_error() | :invalid_signature}
Builds the transferWithAuthorization calldata for an authorization and
its raw signature bytes.
The overload is selected by the VERIFIED signature type, never by byte
length: only :eoa signatures take the (v, r, s) variant (selector
0xe3ee160e, with v normalized to 27/28 as EIP-3009 contracts
expect); contract signatures (:erc1271, :erc6492_counterfactual) —
which can also be exactly 65 bytes — always take the dynamic-bytes
variant (selector 0xcf092995), whose token-side SignatureChecker
routes by account code. Shared by X402.Verify.EVM's eth_call simulation and
X402.Facilitator.Engine's settlement transaction — the calldata a
facilitator signs is always built here, from the verified authorization
fields, and nowhere else.
Examples
iex> authorization = %{
...> "from" => "0x1111111111111111111111111111111111111111",
...> "to" => "0x2222222222222222222222222222222222222222",
...> "value" => "10000",
...> "validAfter" => "0",
...> "validBefore" => "99999999999",
...> "nonce" => "0x" <> String.duplicate("ab", 32)
...> }
iex> {:ok, calldata} = X402.EIP3009.transfer_calldata(authorization, <<1::520>>, :eoa)
iex> {binary_part(calldata, 0, 4), byte_size(calldata)}
{<<0xE3, 0xEE, 0x16, 0x0E>>, 4 + 9 * 32}
iex> {:ok, contract} = X402.EIP3009.transfer_calldata(authorization, <<1::520>>, :erc1271)
iex> binary_part(contract, 0, 4)
<<0xCF, 0x09, 0x29, 0x95>>
iex> X402.EIP3009.transfer_calldata(%{}, <<1::520>>, :eoa)
{:error, {:missing_field, "from"}}
Types
A TransferWithAuthorization authorization in wire shape: string keys
"from", "to", "value", "validAfter", "validBefore", "nonce".
@type domain() :: X402.EIP712.domain()
An EIP-712 domain map — see X402.EIP712.domain/0.
@type domain_error() :: X402.EIP712.domain_error() | {:unsupported_transfer_method, term()}
@type encode_error() :: X402.EIP712.encode_error()
@type payload() :: %{optional(String.t()) => String.t() | authorization()}
The scheme payload map for a signed EIP-3009 payment.
Functions
@spec build_authorization(map(), String.t(), keyword()) :: {:ok, authorization()} | {:error, :invalid_requirements}
Builds a TransferWithAuthorization authorization map in wire shape.
value and to come from the requirements' amount and payTo;
validAfter is now - valid_after_buffer, validBefore is
now + maxTimeoutSeconds, and nonce is a fresh random 32-byte value.
Field values are validated when the digest is computed, not here.
Options
:valid_after_buffer(non_neg_integer/0) - Seconds subtracted from the current time for the authorization'svalidAfter, tolerating clock skew between payer, facilitator, and chain. The default value is60.
@spec chain_id_from_caip2(term()) :: {:ok, non_neg_integer()} | {:error, :unsupported_network}
Extracts the chain id from an eip155:<chainId> CAIP-2 network identifier.
See X402.EIP712.chain_id_from_caip2/1.
Examples
iex> X402.EIP3009.chain_id_from_caip2("eip155:84532")
{:ok, 84532}
iex> X402.EIP3009.chain_id_from_caip2("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp")
{:error, :unsupported_network}
@spec derive_address(binary()) :: {:ok, String.t()} | {:error, :missing_dependency | :invalid_private_key}
Derives the lowercase EVM address for a 32-byte secp256k1 private key.
@spec domain(map()) :: {:ok, domain()} | {:error, domain_error()}
Derives the EIP-712 domain from v2 payment requirements.
Per the exact-EVM scheme specification, extra.name and extra.version
are required, the chain id comes from the CAIP-2 network, and the
verifying contract is the asset address. Requirements selecting a
non-default extra.assetTransferMethod are rejected.
Examples
iex> X402.EIP3009.domain(%{
...> "network" => "eip155:84532",
...> "asset" => "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
...> "extra" => %{"name" => "USDC", "version" => "2"}
...> })
{:ok,
%{
name: "USDC",
version: "2",
chain_id: 84532,
verifying_contract: "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
}}
iex> X402.EIP3009.domain(%{"network" => "eip155:84532", "asset" => "0xasset", "extra" => %{}})
{:error, {:missing_extra, "name"}}
@spec eip712_digest(map(), map()) :: {:ok, <<_::256>>} | {:error, encode_error()}
Computes the EIP-712 digest of a TransferWithAuthorization.
Returns keccak256(0x19 0x01 || domainSeparator || structHash) as a
32-byte binary. domain and authorization accept both the internal
snake-case atom keys and the wire-style string keys.
@spec encode_address(term()) :: {:ok, <<_::256>>} | {:error, :invalid_address}
ABI-encodes a 0x-prefixed EVM address into a 32-byte word.
See X402.EIP712.encode_address/1.
Examples
iex> {:ok, word} = X402.EIP3009.encode_address("0x1111111111111111111111111111111111111111")
iex> byte_size(word)
32
iex> X402.EIP3009.encode_address("0x123")
{:error, :invalid_address}
@spec encode_bytes32(term()) :: {:ok, <<_::256>>} | {:error, :invalid_bytes32}
Decodes a 0x-prefixed hex string into a 32-byte binary.
See X402.EIP712.encode_bytes32/1.
Examples
iex> {:ok, bytes} = X402.EIP3009.encode_bytes32("0x" <> String.duplicate("ab", 32))
iex> byte_size(bytes)
32
iex> X402.EIP3009.encode_bytes32("0xdead")
{:error, :invalid_bytes32}
@spec encode_uint256(term()) :: {:ok, <<_::256>>} | {:error, :invalid_amount}
ABI-encodes a non-negative integer (or decimal string) into a 32-byte word.
See X402.EIP712.encode_uint256/1.
Examples
iex> X402.EIP3009.encode_uint256(1)
{:ok, <<1::unsigned-big-integer-size(256)>>}
iex> X402.EIP3009.encode_uint256("not a number")
{:error, :invalid_amount}
@spec public_key_to_address(binary()) :: {:ok, String.t()} | {:error, :missing_dependency | :invalid_public_key}
Converts a 65-byte uncompressed (or 64-byte) secp256k1 public key to a
lowercase 0x-prefixed EVM address.
@spec random_nonce() :: String.t()
Returns a fresh random 32-byte nonce as a 0x-prefixed hex string.
Examples
iex> nonce = X402.EIP3009.random_nonce()
iex> String.match?(nonce, ~r/^0x[0-9a-f]{64}$/)
true
@spec recover_signer(binary(), binary()) :: {:ok, String.t()} | {:error, :missing_dependency | :invalid_signature | term()}
Recovers the signer address from an EIP-712 digest and a 65-byte signature.
Accepts the 0x-prefixed hex signature produced by sign_authorization/3
or the raw 65-byte binary. Useful for verifying a signed payment locally.
@spec sign(map(), X402.Signer.t(), keyword()) :: {:ok, payload()} | {:error, domain_error() | encode_error() | term()}
Signs the exact/eip3009 scheme payload for the given requirements.
Derives the EIP-712 domain from the requirements, builds a fresh
authorization from the signer's address (from), payTo (to), and
amount (value) with a random 32-byte nonce, computes the EIP-712
digest, and signs it through signer.
Options
:valid_after_buffer(non_neg_integer/0) - Seconds subtracted from the current time for the authorization'svalidAfter, tolerating clock skew between payer, facilitator, and chain. The default value is60.
Examples
{:ok, signer} = X402.Signer.LocalKey.new(private_key)
{:ok, %{"signature" => _, "authorization" => _}} =
X402.EIP3009.sign(
%{
"scheme" => "exact",
"network" => "eip155:84532",
"amount" => "10000",
"asset" => "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
"payTo" => "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
"maxTimeoutSeconds" => 60,
"extra" => %{"name" => "USDC", "version" => "2"}
},
signer
)
@spec sign_authorization(X402.Signer.t(), map(), map()) :: {:ok, String.t()} | {:error, encode_error() | term()}
Signs an authorization's EIP-712 digest with a signer.
Returns the 0x-prefixed 65-byte r || s || v signature.