EVM binding primitives for the auth-capture scheme: commerce-payments
deployments, PaymentInfo hashing, authorizer typed data, and
AuthCaptureEscrow calldata.
Everything here is pure: the module hashes, encodes, signs, and decodes
but never talks to a node. X402.Verify.AuthCaptureEVM layers the RPC
checks on top. These primitives do not submit transactions or provide
durable lifecycle orchestration.
PaymentInfo
The escrow's struct keeps its canonical Solidity names on the wire so its EIP-712 typehash matches the contract byte for byte. Maps use string keys:
%{
"operator" => "0x...", # extra.captureAuthorizer
"payer" => "0x...", # the payload's `from`
"receiver" => "0x...", # requirements.payTo
"token" => "0x...", # requirements.asset
"maxAmount" => "1000000", # requirements.amount
"preApprovalExpiry" => 1_740_675_754,
"authorizationExpiry" => 1_740_758_554,
"refundExpiry" => 1_741_276_954,
"minFeeBps" => 100,
"maxFeeBps" => 100,
"feeReceiver" => "0x...", # extra.feeRecipient
"salt" => "0x..." # 32-byte hex, zero-padded
}Integer fields accept integers or decimal strings; salt is always the
0x-prefixed 32-byte hex spelling the spec pins.
Payment identity
Two hashes derive from the struct and are not interchangeable:
signature_nonce/3—keccak256(abi.encode(chainId, escrow, keccak256(abi.encode(TYPEHASH, info with payer = 0)))), the nonce inside the client's token authorization.payment_info_hash/3—AuthCaptureEscrow.getHash(info), the escrow's canonical identifier keyed over the real payer.
Requires the optional ex_keccak dependency (and ex_secp256k1 for
signing and recovery).
Summary
Types
A resolved commerce-payments deployment.
Encoding failures shared by the hashing and calldata helpers.
An authorizer-signed operation.
A PaymentInfo struct in wire spelling (string keys).
The decoded paymentState(bytes32) tuple.
calldata
Encodes authorize(PaymentInfo, uint256 amount, address tokenCollector, bytes collectorData).
Encodes capture(PaymentInfo, uint256 amount, <fee>, address feeReceiver)
(fee argument per charge_calldata/7).
Encodes charge(PaymentInfo, uint256 amount, address tokenCollector, bytes collectorData, <fee>, address feeReceiver).
Classifies a node revert onto the spec's typed simulation reverts.
Encodes the signature bytes for the selected token collector.
Returns the custom-error selector table (selector => {name, reason}).
Decodes the paymentState(bytes32) return data.
Returns the 0x topic of an escrow event.
Encodes paymentState(bytes32 paymentInfoHash).
Encodes reclaim(PaymentInfo) — the payer's own call after the capture
deadline, never relayed by a facilitator.
Encodes refund(PaymentInfo, uint256 amount, address tokenCollector, bytes collectorData).
Returns the four-byte selector of an escrow function.
Encodes void(PaymentInfo).
client
Returns standard EIP-712 JSON for the client's token authorization.
Computes the EIP-712 digest of a witness-less Permit2 PermitTransferFrom.
Computes the EIP-712 digest of an EIP-3009 ReceiveWithAuthorization.
deployments
Whether salt binding is on: extra.receiverAuthorizer or extra.policy
is a non-zero address.
Returns a canonical commerce-payments deployment by version.
Whether the value is a well-formed, non-zero EVM address.
Resolves the deployment a requirements entry (or its extra) selects.
The zero address, which the scheme reads for every absent operator field.
fees
Checks the submitted fee and fee receiver against the client-signed bounds, per the spec's fee system.
The escrow's fee arithmetic: amount * bps / 10000 with integer division.
payment_info
Computes the bound salt:
keccak256(abi.encode(SALT_BINDING_TYPEHASH, receiverAuthorizer, policy, saltNonce)).
Spells a uint256 (integer or decimal string) as the zero-padded 32-byte
hex the scheme pins for salt and saltNonce.
ABI-encodes a PaymentInfo struct into its twelve 32-byte words.
Checks stored PaymentInfo against its original requirements.
Parses a non-negative uint256 integer or decimal string.
Builds the PaymentInfo struct for a payment from its requirements.
Reconstructs the PaymentInfo a client payment payload commits to.
Computes AuthCaptureEscrow.getHash(paymentInfo) — the escrow's canonical
payment identifier, as 0x-prefixed hex.
The PaymentInfo EIP-712 type string, whose keccak256 is the escrow's
PAYMENT_INFO_TYPEHASH.
Spells a 32-byte hex value as the decimal uint256 string Permit2 nonces
use on the wire.
Returns fresh random 32 bytes as zero-padded 0x hex — a salt when
unbound, a saltNonce when bound.
The salt-binding type string (SALT_BINDING_TYPEHASH preimage).
Computes the payment's signatureNonce: the payer-agnostic identity that
becomes the EIP-3009 nonce (as 0x hex) and the Permit2 nonce (as the
decimal uint256, see permit2_nonce/1).
Types
@type deployment() :: %{ version: :v1_1 | :v1_0, escrow: String.t(), eip3009_collector: String.t(), permit2_collector: String.t(), refund_collector: String.t() }
A resolved commerce-payments deployment.
@type encode_error() :: :missing_dependency | :invalid_address | :invalid_amount | :invalid_bytes32 | :invalid_word | {:invalid_payment_info, String.t()} | {:missing_field, String.t()}
Encoding failures shared by the hashing and calldata helpers.
@type operation() :: :charge | :capture | :void | :refund
An authorizer-signed operation.
A PaymentInfo struct in wire spelling (string keys).
@type payment_state() :: %{ collected?: boolean(), capturable_amount: non_neg_integer(), refundable_amount: non_neg_integer() }
The decoded paymentState(bytes32) tuple.
calldata
@spec authorize_calldata(map(), term(), String.t(), binary()) :: {:ok, binary()} | {:error, encode_error()}
Encodes authorize(PaymentInfo, uint256 amount, address tokenCollector, bytes collectorData).
@spec capture_calldata(:v1_1 | :v1_0, map(), term(), term(), String.t()) :: {:ok, binary()} | {:error, encode_error()}
Encodes capture(PaymentInfo, uint256 amount, <fee>, address feeReceiver)
(fee argument per charge_calldata/7).
@spec charge_calldata( :v1_1 | :v1_0, map(), term(), String.t(), binary(), term(), String.t() ) :: {:ok, binary()} | {:error, encode_error()}
Encodes charge(PaymentInfo, uint256 amount, address tokenCollector, bytes collectorData, <fee>, address feeReceiver).
The fee argument is uint256 feeAmount on v1.1 and uint16 feeBps on
v1.0 — the value passes through untouched, so pass the field the
deployment takes.
Classifies a node revert onto the spec's typed simulation reverts.
Reads the custom-error selector from the error data when present and
otherwise looks for the error name in the message. Returns nil for an
unmapped revert.
Examples
iex> X402.AuthCapture.EVM.classify_revert(%{code: 3, message: "execution reverted", data: "0xad7c145a"})
:payment_already_collected
iex> X402.AuthCapture.EVM.classify_revert(%{code: 3, message: "reverted: AfterRefundExpiry(1, 2)", data: nil})
:refund_deadline_expired
iex> X402.AuthCapture.EVM.classify_revert(%{code: 3, message: "boom", data: nil})
nil
Encodes the signature bytes for the selected token collector.
EIP-3009 takes the original bytes; Permit2 takes abi.encode(bytes).
An ERC-6492 wrapper remains intact inside those bytes.
Examples
iex> X402.AuthCapture.EVM.collector_data(:eip3009, <<1, 2>>)
<<1, 2>>
iex> byte_size(X402.AuthCapture.EVM.collector_data(:permit2, <<1, 2>>))
96
Returns the custom-error selector table (selector => {name, reason}).
@spec decode_payment_state(term()) :: {:ok, payment_state()} | {:error, :invalid_payment_state}
Decodes the paymentState(bytes32) return data.
Examples
iex> X402.AuthCapture.EVM.decode_payment_state(
...> "0x" <> String.duplicate("00", 31) <> "01" <>
...> String.duplicate("00", 30) <> "2710" <> String.duplicate("00", 32)
...> )
{:ok, %{collected?: true, capturable_amount: 10000, refundable_amount: 0}}
iex> X402.AuthCapture.EVM.decode_payment_state("0x")
{:error, :invalid_payment_state}
Returns the 0x topic of an escrow event.
Examples
iex> X402.AuthCapture.EVM.event_topic(:voided)
"0xcadce8c3acb008e3e1c64ca7f60d22a3c87069183182b7dbb9e4d8cfb3a15842"
Encodes paymentState(bytes32 paymentInfoHash).
@spec reclaim_calldata(map()) :: {:ok, binary()} | {:error, encode_error()}
Encodes reclaim(PaymentInfo) — the payer's own call after the capture
deadline, never relayed by a facilitator.
@spec refund_calldata(map(), term(), String.t(), binary()) :: {:ok, binary()} | {:error, encode_error()}
Encodes refund(PaymentInfo, uint256 amount, address tokenCollector, bytes collectorData).
Facilitator-relayed refunds use the deployment's operator refund
collector with empty collectorData.
@spec selector(atom()) :: <<_::32>>
Returns the four-byte selector of an escrow function.
Examples
iex> X402.AuthCapture.EVM.selector(:void)
<<0xFA, 0x1C, 0xAD, 0x17>>
iex> X402.AuthCapture.EVM.selector(:payment_state)
<<0x34, 0xB7, 0x78, 0xED>>
@spec void_calldata(map()) :: {:ok, binary()} | {:error, encode_error()}
Encodes void(PaymentInfo).
client
Returns standard EIP-712 JSON for the client's token authorization.
Includes types, primaryType, a camel-case domain, and only the
signed message fields. Permit2 has no domain version or signed from.
@spec permit_transfer_digest(map(), map()) :: {:ok, <<_::256>>} | {:error, encode_error()}
Computes the EIP-712 digest of a witness-less Permit2 PermitTransferFrom.
domain is the canonical Permit2 domain (see X402.Permit2.domain/1);
the authorization carries permitted.token, permitted.amount,
spender, nonce (decimal uint256), and deadline.
@spec receive_authorization_digest(map(), map()) :: {:ok, <<_::256>>} | {:error, encode_error()}
Computes the EIP-712 digest of an EIP-3009 ReceiveWithAuthorization.
Same field layout as TransferWithAuthorization, different type name —
the token collector calls receiveWithAuthorization. domain is the
token's EIP-712 domain (see X402.EIP712.domain/1).
deployments
Whether salt binding is on: extra.receiverAuthorizer or extra.policy
is a non-zero address.
Examples
iex> X402.AuthCapture.EVM.bound?(%{"extra" => %{}})
false
iex> X402.AuthCapture.EVM.bound?(%{
...> "extra" => %{"receiverAuthorizer" => "0x2222222222222222222222222222222222222222"}
...> })
true
@spec deployment(:v1_1 | :v1_0) :: deployment()
Returns a canonical commerce-payments deployment by version.
Examples
iex> X402.AuthCapture.EVM.deployment(:v1_1).escrow
"0xf96815976523E00e65Be8f34cA5e64b4f41EB19c"
iex> X402.AuthCapture.EVM.deployment(:v1_0).eip3009_collector
"0x0E3dF9510de65469C4518D7843919c0b8C7A7757"
Whether the value is a well-formed, non-zero EVM address.
Examples
iex> X402.AuthCapture.EVM.nonzero_address?("0x2222222222222222222222222222222222222222")
true
iex> X402.AuthCapture.EVM.nonzero_address?("0x0000000000000000000000000000000000000000")
false
iex> X402.AuthCapture.EVM.nonzero_address?(nil)
false
@spec resolve_deployment(map()) :: {:ok, deployment()} | {:error, :invalid_escrow}
Resolves the deployment a requirements entry (or its extra) selects.
An absent extra.authCaptureEscrow is the v1.1 escrow; either canonical
escrow address (case-insensitive) selects its deployment; any other value
is {:error, :invalid_escrow}.
Examples
iex> {:ok, deployment} = X402.AuthCapture.EVM.resolve_deployment(%{"extra" => %{}})
iex> deployment.version
:v1_1
iex> {:ok, deployment} =
...> X402.AuthCapture.EVM.resolve_deployment(%{
...> "extra" => %{"authCaptureEscrow" => "0xbdea0d1bcc5966192b070fdf62ab4ef5b4420cff"}
...> })
iex> deployment.version
:v1_0
iex> X402.AuthCapture.EVM.resolve_deployment(%{
...> "extra" => %{"authCaptureEscrow" => "0x1111111111111111111111111111111111111111"}
...> })
{:error, :invalid_escrow}
@spec zero_address() :: String.t()
The zero address, which the scheme reads for every absent operator field.
Examples
iex> X402.AuthCapture.EVM.zero_address()
"0x0000000000000000000000000000000000000000"
fees
@spec check_fee( :v1_1 | :v1_0, non_neg_integer(), non_neg_integer(), String.t() | nil, non_neg_integer(), non_neg_integer(), String.t() ) :: :ok | {:error, :fee_bps_out_of_range | :fee_receiver | :zero_fee_receiver}
Checks the submitted fee and fee receiver against the client-signed bounds, per the spec's fee system.
v1.1 requires amount * minFeeBps / 10000 <= feeAmount <= amount * maxFeeBps / 10000;
v1.0 requires minFeeBps <= feeBps <= maxFeeBps. A non-zero
PaymentInfo.feeReceiver must equal the submitted one; a zero one admits
any non-zero address, and a zero submitted receiver with a non-zero fee
reverts onchain.
Examples
iex> X402.AuthCapture.EVM.check_fee(:v1_1, 750_000, 7500, "0x2222222222222222222222222222222222222222", 100, 100, "0x2222222222222222222222222222222222222222")
:ok
iex> X402.AuthCapture.EVM.check_fee(:v1_1, 750_000, 7501, "0x2222222222222222222222222222222222222222", 100, 100, "0x2222222222222222222222222222222222222222")
{:error, :fee_bps_out_of_range}
iex> X402.AuthCapture.EVM.check_fee(:v1_0, 750_000, 50, "0x2222222222222222222222222222222222222222", 100, 100, "0x2222222222222222222222222222222222222222")
{:error, :fee_bps_out_of_range}
iex> X402.AuthCapture.EVM.check_fee(:v1_1, 750_000, 7500, "0x3333333333333333333333333333333333333333", 100, 100, "0x2222222222222222222222222222222222222222")
{:error, :fee_receiver}
@spec fee_amount(non_neg_integer(), non_neg_integer()) :: non_neg_integer()
The escrow's fee arithmetic: amount * bps / 10000 with integer division.
Examples
iex> X402.AuthCapture.EVM.fee_amount(750_000, 100)
7500
iex> X402.AuthCapture.EVM.fee_amount(999, 100)
9
payment_info
@spec bound_salt(String.t() | nil, String.t() | nil, String.t()) :: {:ok, String.t()} | {:error, encode_error()}
Computes the bound salt:
keccak256(abi.encode(SALT_BINDING_TYPEHASH, receiverAuthorizer, policy, saltNonce)).
Absent addresses are the zero address; salt_nonce is 32-byte hex.
Spells a uint256 (integer or decimal string) as the zero-padded 32-byte
hex the scheme pins for salt and saltNonce.
Examples
iex> X402.AuthCapture.EVM.bytes32_hex(255)
{:ok, "0x00000000000000000000000000000000000000000000000000000000000000ff"}
iex> X402.AuthCapture.EVM.bytes32_hex("0x" <> String.duplicate("ab", 32))
{:ok, "0x" <> String.duplicate("ab", 32)}
iex> X402.AuthCapture.EVM.bytes32_hex("nope")
{:error, :invalid_bytes32}
ABI-encodes a PaymentInfo struct into its twelve 32-byte words.
Validates each field against its Solidity width (uint120, uint48,
uint16) and returns {:error, {:invalid_payment_info, field}} for the
first field that does not fit.
Examples
iex> info = %{
...> "operator" => "0x1563915e194d8cfba1943570603f7606a3115508",
...> "payer" => "0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a",
...> "receiver" => "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
...> "token" => "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
...> "maxAmount" => "10000",
...> "preApprovalExpiry" => 1,
...> "authorizationExpiry" => 2,
...> "refundExpiry" => 3,
...> "minFeeBps" => 0,
...> "maxFeeBps" => 100,
...> "feeReceiver" => "0x0000000000000000000000000000000000000000",
...> "salt" => "0x" <> String.duplicate("00", 31) <> "01"
...> }
iex> {:ok, encoded} = X402.AuthCapture.EVM.encode_payment_info(info)
iex> byte_size(encoded)
384
iex> X402.AuthCapture.EVM.encode_payment_info(%{"operator" => "0x1"})
{:error, {:invalid_payment_info, "operator"}}
Checks stored PaymentInfo against its original requirements.
Reconstructs and compares the canonical ABI encoding, including every Solidity width, and checks expiry ordering. The payer, preapproval expiry and salt must come from retained client state; this does not verify their signature, salt commitment, or onchain existence.
Examples
iex> X402.AuthCapture.EVM.match_payment_info(%{}, %{})
{:error, {:missing_field, "captureAuthorizer"}}
@spec parse_uint256(term()) :: {:ok, non_neg_integer()} | {:error, :invalid_amount}
Parses a non-negative uint256 integer or decimal string.
Rejects signs, whitespace and strings longer than 78 digits before integer conversion. Hex input is reserved for the bytes32 helpers.
Examples
iex> X402.AuthCapture.EVM.parse_uint256("1000")
{:ok, 1000}
iex> X402.AuthCapture.EVM.parse_uint256("+1")
{:error, :invalid_amount}
@spec payment_info(map(), String.t(), non_neg_integer(), String.t()) :: {:ok, payment_info()} | {:error, {:missing_field, String.t()}}
Builds the PaymentInfo struct for a payment from its requirements.
payer is the client address, pre_approval_expiry the authorization's
validBefore / deadline, and salt the wire payload.salt. Every
other field comes from requirements and its extra, as the spec's
PaymentInfo appendix sets out.
Examples
iex> requirements = %{
...> "amount" => "10000",
...> "asset" => "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
...> "payTo" => "0x209693Bc6afc0C5328bA36FaF03C514EF312287C",
...> "extra" => %{
...> "captureAuthorizer" => "0x1563915e194d8cfba1943570603f7606a3115508",
...> "feeRecipient" => "0x0000000000000000000000000000000000000000",
...> "captureDeadline" => 1_800_000_000,
...> "refundDeadline" => 1_800_100_000,
...> "minFeeBps" => 0,
...> "maxFeeBps" => 0
...> }
...> }
iex> {:ok, info} =
...> X402.AuthCapture.EVM.payment_info(
...> requirements,
...> "0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a",
...> 1_799_000_000,
...> "0x" <> String.duplicate("ab", 32)
...> )
iex> {info["operator"], info["maxAmount"], info["authorizationExpiry"]}
{"0x1563915e194d8cfba1943570603f7606a3115508", "10000", 1800000000}
iex> X402.AuthCapture.EVM.payment_info(%{"extra" => %{}}, "0x19e7e376e7c213b7e7e7e46cc70a5dd086daff2a", 1, "0x00")
{:error, {:missing_field, "captureAuthorizer"}}
@spec payment_info_from_payload(map(), map()) :: {:ok, payment_info()} | {:error, :payload_format | {:missing_field, String.t()}}
Reconstructs the PaymentInfo a client payment payload commits to.
Accepts the full v2 envelope or the inner payload map. The payer and
preApprovalExpiry come from the EIP-3009 authorization (from,
validBefore) or the permit2Authorization (from, deadline), and
the salt from payload.salt.
@spec payment_info_hash(non_neg_integer(), String.t(), map()) :: {:ok, String.t()} | {:error, encode_error()}
Computes AuthCaptureEscrow.getHash(paymentInfo) — the escrow's canonical
payment identifier, as 0x-prefixed hex.
keccak256(abi.encode(chainId, escrow, keccak256(abi.encode(PAYMENT_INFO_TYPEHASH, info)))).
@spec payment_info_type() :: String.t()
The PaymentInfo EIP-712 type string, whose keccak256 is the escrow's
PAYMENT_INFO_TYPEHASH.
Spells a 32-byte hex value as the decimal uint256 string Permit2 nonces
use on the wire.
Examples
iex> X402.AuthCapture.EVM.permit2_nonce("0x" <> String.duplicate("00", 31) <> "ff")
{:ok, "255"}
iex> X402.AuthCapture.EVM.permit2_nonce("0xzz")
{:error, :invalid_bytes32}
@spec random_bytes32() :: String.t()
Returns fresh random 32 bytes as zero-padded 0x hex — a salt when
unbound, a saltNonce when bound.
Examples
iex> X402.AuthCapture.EVM.random_bytes32() =~ ~r/^0x[0-9a-f]{64}$/
true
@spec salt_binding_type() :: String.t()
The salt-binding type string (SALT_BINDING_TYPEHASH preimage).
@spec signature_nonce(non_neg_integer(), String.t(), map()) :: {:ok, String.t()} | {:error, encode_error()}
Computes the payment's signatureNonce: the payer-agnostic identity that
becomes the EIP-3009 nonce (as 0x hex) and the Permit2 nonce (as the
decimal uint256, see permit2_nonce/1).
The struct is hashed with payer zeroed and every other field holding
its onchain value.