Raxol.Payments.Protocols.Xochi (Raxol Payments v0.2.0)

Copy Markdown View Source

Xochi private execution protocol.

Xochi is the default agent-facing protocol for cross-chain transfers. It routes intents through the Xochi dark pool where Riddler (and other solvers) compete to fill them. This is the cash-positive path with tier-based fees.

Unlike x402/MPP, Xochi is not a 402-triggered protocol. It uses an explicit quote -> sign -> execute -> poll flow.

Usage

config = %{base_url: "https://api.xochi.fi", auth: {:member, "..."}}
wallet = MyWallet

{:ok, quote} = Xochi.quote(config, %QuoteRequest{...})
{:ok, exec} = Xochi.execute(config, quote, wallet)
{:ok, status} = Xochi.poll_status(config, exec.intent_id)

Fee Tiers

The fee is layered: a solver spread plus gas floor (never discounted), a venue fee, and a routing fee. Trust discounts carve the venue and routing layers only, so the solver floor is identical at every tier. Headline totals by tier and asset:

TierScoreStableVolatile
Standard0-240.22%0.40%
Trusted25-490.19%0.35%
Verified50-740.15%0.29%
Premium75-990.12%0.25%
Institutional100+0.10%0.22%

A quote will carry an optional fee_breakdown with the per-layer split (solver, venue, routing) once the worker emits it; QuoteResponse does not parse it yet.

Origin-pull solver allowlist

The origin pull authorizes the solver to collect funds from the agent wallet. The pull recipient/spender is the solver's collection address; by default it is not pinned (solver addresses rotate and there is no client-facing manifest). An operator who knows their solver set can pin it:

config :raxol_payments, :pull_solver_allowlist, ["0xsolver...", "0xsolver2..."]

When set, a pull whose to (ERC-3009) or spender (Permit2) is not in the list is rejected before any signature. When unset (the default), the address is not bound. See GitHub #333.

Summary

Types

A buyer's pre-signed Xochi intent bundle, as handed to the storefront relay.

Functions

Raise when the origin-pull solver pin is fail-open for the given allowlist and requirement flag, otherwise return :ok. Call at boot in a fund-moving deployment so a missing solver pin halts startup instead of silently signing ERC-3009 pulls to an unverified recipient. See GitHub #333.

Fetch a deposit-route quote and verify its deposit_attestation before returning the deposit instructions -- the authenticated form of a Tron-origin quote.

Sign and execute an intent from a quote.

Like execute/3, but binds the served pull_authorization to the caller's intended transfer (request) before signing it.

Relay a buyer's pre-signed intent to Xochi WITHOUT re-signing.

Request a cross-chain intent quote from Xochi.

True when the origin-pull solver pin would let an unverified recipient through: an empty allowlist with the pin not required. In that state validate_pull accepts any ERC-3009 to (Permit2 stays fail-closed regardless). The boot-time assert_origin_pull_pinned!/2 uses this to refuse a fail-open prod start.

Poll intent status until terminal (completed/failed/expired) or timeout.

Like poll_status/3 but also returns the elapsed milliseconds to the terminal status, so the caller can report whether settlement landed within budget.

Sign a quoted intent into a relayable bundle WITHOUT executing it.

Like sign_intent/2, but binds the served pull_authorization to the caller's intended transfer (request) before signing it -- see execute/4 for why this matters. Pass nil only when there is no pull authorization to validate; a pull presented with a nil request fails closed.

Full transfer flow: quote -> sign -> execute -> poll.

Validate the served origin-pull authorization against the intended transfer WITHOUT signing or executing -- the read-only counterpart of the check execute/4 runs before it signs.

Verify a deposit-route quote's attestation against the pinned signer and return the deposit instructions, or a fail-closed error. See deposit_route_quote/3 for signer resolution.

Types

signed_intent()

@type signed_intent() :: %{optional(atom() | String.t()) => term()}

A buyer's pre-signed Xochi intent bundle, as handed to the storefront relay.

Keys may be atoms (internal callers) or strings (decoded from an ACP requirement). Required: intent_id, quote_id, signature, nonce. Optional: pull_signature (nil for non-pulling methods), aztec_proof (shielded claims).

Functions

assert_origin_pull_pinned!(allowlist, require_pin?)

@spec assert_origin_pull_pinned!([String.t()] | nil, boolean()) :: :ok

Raise when the origin-pull solver pin is fail-open for the given allowlist and requirement flag, otherwise return :ok. Call at boot in a fund-moving deployment so a missing solver pin halts startup instead of silently signing ERC-3009 pulls to an unverified recipient. See GitHub #333.

deposit_route_quote(config, request, opts \\ [])

@spec deposit_route_quote(
  Raxol.Payments.Xochi.Client.config(),
  Raxol.Payments.Xochi.Schemas.DepositRouteRequest.t(),
  keyword()
) :: {:ok, map()} | {:error, term()}

Fetch a deposit-route quote and verify its deposit_attestation before returning the deposit instructions -- the authenticated form of a Tron-origin quote.

A non-EVM origin has no gasless pull, so the quote returns a bare deposit_address the payer must fund directly. A MITM or compromised endpoint could swap that address, so raxol verifies the attestation recovers to the pinned signer BEFORE surfacing the address, failing closed when no signer is pinned or the attestation does not verify. raxol never sends the funds; the returned instructions are for the caller's own Tron wallet to fund, then poll with poll_status/3.

Signer resolution, in precedence order: opts[:deposit_attestation_signer] (an operator's out-of-band pin), else config :raxol_payments, :xochi_deposit_attestation_signer, else the live capability matrix's deposit_attestation_signer.

Options

  • :deposit_attestation_signer -- pin the expected signer explicitly.
  • :capabilities -- a pre-fetched Capabilities.t() (skips the network).

execute(config, quote_resp, wallet)

@spec execute(
  Raxol.Payments.Xochi.Client.config(),
  Raxol.Payments.Xochi.Schemas.QuoteResponse.t(),
  module()
) :: {:ok, Raxol.Payments.Xochi.Schemas.ExecuteResponse.t()} | {:error, term()}

Sign and execute an intent from a quote.

Signs the EIP-712 typed data from the quote response using the wallet, then submits the signed intent for execution.

execute(config, quote_resp, wallet, request)

@spec execute(
  Raxol.Payments.Xochi.Client.config(),
  Raxol.Payments.Xochi.Schemas.QuoteResponse.t(),
  module(),
  Raxol.Payments.Xochi.Schemas.QuoteRequest.t() | nil
) :: {:ok, Raxol.Payments.Xochi.Schemas.ExecuteResponse.t()} | {:error, term()}

Like execute/3, but binds the served pull_authorization to the caller's intended transfer (request) before signing it.

The agent signs an ERC-3009/Permit2 authorization the solver serves; a hostile or compromised quote endpoint could otherwise serve one that pulls the wallet's full balance to an attacker. With the request, the origin pull is checked against the intended signer, token, chain, and amount before any signature is released. Pass nil only when there is no pull authorization to validate; a pull authorization presented with a nil request fails closed.

execute_signed(config, signed_intent)

@spec execute_signed(Raxol.Payments.Xochi.Client.config(), signed_intent()) ::
  {:ok, Raxol.Payments.Xochi.Schemas.ExecuteResponse.t()} | {:error, term()}

Relay a buyer's pre-signed intent to Xochi WITHOUT re-signing.

The storefront (pure-relay) primitive. The buyer quoted and signed the EIP-712 intent (and any origin-pull authorization) against Xochi itself, then handed raxol the opaque bundle {intent_id, quote_id, signature, nonce, pull_signature}. raxol posts it verbatim; Riddler verifies the signature against its own server-persisted quote, so neither raxol nor the buyer can forge the amount or route.

Unlike execute/3,4, this takes no wallet and releases no signature -- raxol is never on the fund-signing path. Fails closed with {:error, {:invalid_signed_intent, field}} on a missing or malformed field, before any network call.

get_quote(config, request)

@spec get_quote(
  Raxol.Payments.Xochi.Client.config(),
  Raxol.Payments.Xochi.Schemas.QuoteRequest.t()
) ::
  {:ok, Raxol.Payments.Xochi.Schemas.QuoteResponse.t()} | {:error, term()}

Request a cross-chain intent quote from Xochi.

origin_pull_fail_open?(allowlist, require_pin?)

@spec origin_pull_fail_open?([String.t()] | nil, boolean()) :: boolean()

True when the origin-pull solver pin would let an unverified recipient through: an empty allowlist with the pin not required. In that state validate_pull accepts any ERC-3009 to (Permit2 stays fail-closed regardless). The boot-time assert_origin_pull_pinned!/2 uses this to refuse a fail-open prod start.

poll_status(config, intent_id, opts \\ [])

@spec poll_status(Raxol.Payments.Xochi.Client.config(), String.t(), keyword()) ::
  {:ok, Raxol.Payments.Xochi.Schemas.IntentStatus.t()} | {:error, term()}

Poll intent status until terminal (completed/failed/expired) or timeout.

Fast-polls inside the settlement budget window, then backs off. See Raxol.Payments.Poll for the timing options (:budget_ms, :fast_interval_ms, :slow_interval_ms, :timeout_ms).

poll_status_timed(config, intent_id, opts \\ [])

@spec poll_status_timed(Raxol.Payments.Xochi.Client.config(), String.t(), keyword()) ::
  {:ok, Raxol.Payments.Xochi.Schemas.IntentStatus.t(), non_neg_integer()}
  | {:error, term()}

Like poll_status/3 but also returns the elapsed milliseconds to the terminal status, so the caller can report whether settlement landed within budget.

quote_and_sign(config, request, wallet)

@spec quote_and_sign(
  Raxol.Payments.Xochi.Client.config(),
  Raxol.Payments.Xochi.Schemas.QuoteRequest.t(),
  module()
) :: {:ok, signed_intent()} | {:error, term()}

Buyer-side one-shot: get_quote/2 then sign_intent/3.

Fetches a quote for request and signs it into a relayable bundle. The buyer hands the bundle to a storefront (e.g. as an ACP requirement's signed_intent) which relays it via execute_signed/2; the storefront never re-signs.

sign_intent(quote_resp, wallet)

@spec sign_intent(Raxol.Payments.Xochi.Schemas.QuoteResponse.t(), module()) ::
  {:ok, signed_intent()} | {:error, term()}

Sign a quoted intent into a relayable bundle WITHOUT executing it.

The buyer-side counterpart to execute_signed/2: validates the quote, signs the EIP-712 intent with wallet, and returns the opaque bundle %{intent_id, quote_id, signature, nonce} (plus pull_signature when the quote carried an origin-pull authorization) to hand to a storefront/relay or to execute_signed/2 directly. Does not talk to the worker.

sign_intent(quote_resp, wallet, request)

@spec sign_intent(
  Raxol.Payments.Xochi.Schemas.QuoteResponse.t(),
  module(),
  Raxol.Payments.Xochi.Schemas.QuoteRequest.t() | nil
) :: {:ok, signed_intent()} | {:error, term()}

Like sign_intent/2, but binds the served pull_authorization to the caller's intended transfer (request) before signing it -- see execute/4 for why this matters. Pass nil only when there is no pull authorization to validate; a pull presented with a nil request fails closed.

transfer(config, request, wallet, opts \\ [])

@spec transfer(
  Raxol.Payments.Xochi.Client.config(),
  Raxol.Payments.Xochi.Schemas.QuoteRequest.t(),
  module(),
  keyword()
) :: {:ok, Raxol.Payments.Xochi.Schemas.IntentStatus.t()} | {:error, term()}

Full transfer flow: quote -> sign -> execute -> poll.

Convenience function that runs the complete Xochi intent lifecycle. Returns the final terminal status.

validate_pull(quote_resp, request, wallet)

@spec validate_pull(
  Raxol.Payments.Xochi.Schemas.QuoteResponse.t(),
  Raxol.Payments.Xochi.Schemas.QuoteRequest.t(),
  module()
) :: :ok | {:error, term()}

Validate the served origin-pull authorization against the intended transfer WITHOUT signing or executing -- the read-only counterpart of the check execute/4 runs before it signs.

Returns :ok when the quote's pull_authorization binds to request (signer, token, chain, value, envelope type, expiry) and its to/spender satisfies the configured solver pin, or {:error, {:authorization_mismatch, field}} otherwise. A quote with no pull authorization is :ok -- there is nothing to pull. Lets a preflight reject a forged or rotated-solver quote across every corridor before any funded run, with no funds moved.

verify_deposit_route(config, request, quote, opts \\ [])

@spec verify_deposit_route(
  Raxol.Payments.Xochi.Client.config(),
  Raxol.Payments.Xochi.Schemas.DepositRouteRequest.t(),
  Raxol.Payments.Xochi.Schemas.QuoteResponse.t(),
  keyword()
) :: {:ok, map()} | {:error, term()}

Verify a deposit-route quote's attestation against the pinned signer and return the deposit instructions, or a fail-closed error. See deposit_route_quote/3 for signer resolution.