Raxol.Payments.Mandate (Raxol Payments v0.2.0)

Copy Markdown View Source

Xochi delegation envelope: an EIP-712-signed authorization from a Xochi Member to a specific agent wallet, scoped to particular endpoints with a budget.

Per the Xochi design (locked 2026-04-27 in xochi/docs/planning/agent-auth.md), a Mandate is a per-request envelope the agent presents on every protected Xochi call via X-Xochi-Delegation. Xochi's worker verifies the signature and decrements budget counters in KV keyed by H(envelope). The agent never authenticates; it just holds and presents the envelope.

This module's job is to issue, hold, and present envelopes from the raxol side. It does not enforce budgets locally -- that's Xochi's responsibility.

Wire format

X-Xochi-Delegation: base64url(JSON({
  message: <MandateMessage>,
  signature: "0x" <> <130 hex chars>
}))

Schema

Matches xochi/packages/shared/src/eip712.ts:182-263 exactly -- the type hash and field ordering must agree, or signatures won't verify on the Xochi side.

Mandate(
  address human_wallet,
  address agent_wallet,
  string[] scopes,
  uint256 max_amount_usd,
  uint256 max_calls,
  uint256 expires_at,
  bytes32 nonce
)

EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)
  name              = "Xochi Mandate"
  version           = "1"
  chainId           = 1
  verifyingContract = 0x0000000000000000000000000000000000000000

Chain ID is pinned to mainnet by convention. The envelope is metadata, not a transaction; the pin keeps the type hash stable across deployments.

Summary

Functions

List the scopes Xochi recognizes.

Build an unsigned Mandate, validating fields against the Xochi Zod constraints in xochi/packages/shared/src/schemas.ts:197-211.

Compute H(envelope) = keccak256(canonical_envelope_json). Matches the key Xochi uses in KV for per-envelope budget counters.

Return true when the Mandate's scopes include the given scope.

Compute the EIP-712 digest of a Mandate message via Raxol.Payments.EIP712.hash/3. The string[] scopes field is encoded per EIP-712 array rules (keccak256(concat(keccak256(s_i)))) by the shared encoder.

Return true when the Mandate has passed its expires_at.

Decode a base64url envelope back into a Mandate struct. Does not verify the signature; call verify/1 separately if needed.

Sign a Mandate with the given wallet module.

Encode a signed Mandate as a base64url envelope suitable for the X-Xochi-Delegation header.

Return the EIP-712 typed-data tuple {domain, types, message} for a Mandate. Matches viem's buildMandateEip712Data output.

Verify a signed Mandate.

Types

hex_address()

@type hex_address() :: String.t()

hex_bytes32()

@type hex_bytes32() :: String.t()

hex_signature()

@type hex_signature() :: String.t()

scope()

@type scope() :: String.t()

t()

@type t() :: %Raxol.Payments.Mandate{
  agent_wallet: hex_address(),
  created_at: integer(),
  envelope_hash: <<_::256>> | nil,
  expires_at: pos_integer(),
  human_wallet: hex_address(),
  max_amount_usd: non_neg_integer(),
  max_calls: pos_integer(),
  nonce: hex_bytes32(),
  scopes: [scope()],
  signature: hex_signature() | nil
}

Functions

allowed_scopes()

@spec allowed_scopes() :: [scope()]

List the scopes Xochi recognizes.

build(attrs)

@spec build(map() | keyword()) :: {:ok, t()} | {:error, term()}

Build an unsigned Mandate, validating fields against the Xochi Zod constraints in xochi/packages/shared/src/schemas.ts:197-211.

Generates a random 32-byte nonce when omitted.

compute_envelope_hash(m)

@spec compute_envelope_hash(t()) :: <<_::256>>

Compute H(envelope) = keccak256(canonical_envelope_json). Matches the key Xochi uses in KV for per-envelope budget counters.

covers_scope?(mandate, scope)

@spec covers_scope?(t(), scope()) :: boolean()

Return true when the Mandate's scopes include the given scope.

digest(m)

@spec digest(t()) :: {:ok, <<_::256>>} | {:error, term()}

Compute the EIP-712 digest of a Mandate message via Raxol.Payments.EIP712.hash/3. The string[] scopes field is encoded per EIP-712 array rules (keccak256(concat(keccak256(s_i)))) by the shared encoder.

expired?(m, now \\ nil)

@spec expired?(t(), integer() | nil) :: boolean()

Return true when the Mandate has passed its expires_at.

from_envelope(b64)

@spec from_envelope(String.t()) :: {:ok, t()} | {:error, term()}

Decode a base64url envelope back into a Mandate struct. Does not verify the signature; call verify/1 separately if needed.

sign(m, wallet_mod)

@spec sign(t(), module()) :: {:ok, t()} | {:error, term()}

Sign a Mandate with the given wallet module.

The wallet must implement Raxol.Payments.Wallet. Sets :signature and :envelope_hash on the returned struct. The :signature is the 0x-prefixed 65-byte hex form expected by Xochi's Zod schema; :envelope_hash is keccak256(envelope_json), the same key Xochi uses in KV for budget counters.

to_envelope(m)

@spec to_envelope(t()) :: {:ok, String.t()} | {:error, :unsigned}

Encode a signed Mandate as a base64url envelope suitable for the X-Xochi-Delegation header.

typed_data(m)

@spec typed_data(t()) :: {map(), map(), map()}

Return the EIP-712 typed-data tuple {domain, types, message} for a Mandate. Matches viem's buildMandateEip712Data output.

Intended for inspection or external signing; sign/2 does not call this -- it computes the digest directly because the string[] field needs array-aware encoding that Raxol.Payments.EIP712 does not provide.

verify(m)

@spec verify(t()) ::
  :ok | {:error, :unsigned | :invalid_signature | :unauthorized_signer}

Verify a signed Mandate.

Recovers the signer's address from the signature and compares to :human_wallet. Returns :ok on match or {:error, reason}. A mutated field changes the digest, which changes the recovered signer -- so tamper detection falls out of this check.