Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tiders

Tiders x402 Server

Tiders x402 Server is a payment-enabled database API server that combines DuckDB with the x402 payment protocol to create a pay-per-query data marketplace.

Data providers can expose DuckDB tables through a REST API where each query requires a cryptocurrency micropayment. Pricing is calculated per row returned, with support for tiered pricing based on result size.

Key Features

  • Pay-per-query data access – Charge users fixed or per row of data returned using cryptocurrency micropayments.
  • x402 protocol integration – Standard HTTP 402 Payment Required flow with automatic payment negotiation.
  • DuckDB backend – Fast, in-process analytical database with no separate database server required.
  • Apache Arrow responses – Efficient columnar data transfer using Arrow IPC format instead of JSON.
  • Tiered pricing – Multiple price tiers based on the number of rows requested (e.g., bulk discounts).
  • Multi-language support – Rust server with Python bindings (via PyO3).
  • Familiar SQL for Requests – A safe SQL subset that prevents expensive operations like JOINs, GROUP BY, and subqueries.

How It Works

  1. A client sends a SQL query to the server.
  2. The server parses and validates the query, then estimates the payment options.
  3. If payment is required, the server responds with HTTP 402 and payment options.
  4. The client signs a payment using their crypto wallet and resends the request with an X-Payment header.
  5. The server verifies and settles the payment through a facilitator, then returns the query results as Arrow IPC.

Project Structure

tiders-x402-server/
  server/          # Rust server (Axum-based REST API)
  python/          # Python bindings via PyO3 + maturin
  examples         # Python and Rust server examples
  client-scripts/  # Python and TypeScript clients scripts using x402-fetch
  docs/            # MDbook documentation page
  Cargo.toml       # Workspace configuration

Technology Stack

ComponentTechnology
Web frameworkAxum
DatabaseDuckDB, ClickHouse, PostgreSQL
Async runtimeTokio
Payment protocolx402 (via x402-rs)
Data serializationApache Arrow IPC
SQL parsingsqlparser
Blockchain primitivesAlloy
ObservabilityOpenTelemetry + tracing
Python FFIPyO3

Installation

Python

Using uv (recommended):

uv pip install tiders-x402-server
import tiders_x402_server

The published Python package includes all database backends (DuckDB, PostgreSQL, ClickHouse).

Running the example:

cd examples/python
uv run duckdb_server.py

You need a virtual environment active. Use uv venv && source .venv/bin/activate

Rust

Each database backend is a separate Cargo feature. You must enable at least one:

FeatureDatabase
duckdbDuckDB
postgresqlPostgreSQL
clickhouseClickHouse

No database is included by default — this keeps compile times and binary size down when you only need one backend.

Add tiders-x402 to your Cargo.toml with the features you need:

[dependencies]
tiders-x402 = { version = "0.1.0", features = ["duckdb"] }

Or combine multiple backends:

[dependencies]
tiders-x402 = { version = "0.1.0", features = ["duckdb", "clickhouse", "postgresql"] }

Running the example:

cd examples/rust
cargo run

The Rust example (examples/rust/Cargo.toml) enables all three backends by default. Edit its features list if you only need one.

Development Setup

If you’re modifying tiders-x402-server repo locally, you probably want to build it against your local version.

Python

Build the Python binding using maturin:

cd python
maturin develop --uv   # builds the Rust extension and installs it into the active venv

Rust

Build the example with the local server:

cargo build --config 'patch.crates-io.tiders-x402="../../server"'

Persistent local development

For persistent local development, you can put this in examples/rust/Cargo.toml:

[patch.crates-io]
tiders-x402 = { path = "../../server" }

This avoids passing --config on every build command.

Server Overview

System Components

Tiders-x402-Server Components

The server sits between clients and a database. Clients submit SQL queries over HTTP. If a table requires payment, the server process the request, calculate the cost, coordinates with an external x402 facilitator to verify and settle the payment before returning data as Arrow IPC.

Module Structure

The server is organized into the following modules:

ModulePurpose
root_handlerGET / — returns server metadata and available tables
query_handlerPOST /query — main handler for query execution and payment flow
sqp_parserParses and validates SQL, rejecting unsafe operations
sql_[database]Converts analyzed queries to Database-compatible SQL
databaseImplement the database trait to execute queries, get schemas and serializes results to Arrow IPC
pricePricing model: PricingModel (per-row or fixed), PriceTag, and TablePaymentOffers data structures
payment_configDetermines pricing for a query and generates x402 V2 payment requirements
payment_processingTranslates between V2 types and the facilitator’s wire format
facilitator_clientHTTP client for the remote x402 facilitator

Request Lifecycle

  1. Axum receives the HTTP request and routes it to the appropriate handler.
  2. sqp_parser parses and validates the SQL (rejects unsafe operations).
  3. duckdb_reader converts the analyzed query to a DuckDB-compatible SQL string.
  4. payment_config determines whether the table is free or paid, and calculates pricing based on the estimated row count.
  5. If payment is required, payment_processing and facilitator_client handle verification and settlement with the remote facilitator.
  6. database executes the query and serializes results to Arrow IPC.

Payment Flow

The server implements a two-step HTTP payment flow based on the x402 protocol (V2). The flow differs slightly depending on whether the table uses per-row or fixed pricing.

Pricing Flow

Tiders-x402-server P Flow

Step 1: Estimation

When a client sends a query without a Payment-Signature header:

  1. The server parses and validates the SQL.
  2. For per-row tables: it wraps the query in SELECT COUNT(*) FROM (...) to estimate the row count. It then calculates applicable pricing tiers based on the estimated row count. For fixed-price tables: this step is skipped (the price doesn’t depend on row count).
  3. It returns HTTP 402 Payment Required with:
    • A JSON body containing the error message, resource info, and all applicable payment options.
    • A Payment-Required header with the same information, base64-encoded.
{
  "x402Version": 2,
  "error": "No crypto payment found. Implement x402 protocol...",
  "resource": {
    "url": "http://server:4021/query",
    "description": "Uniswap v2 swaps - 2 rows",
    "mime_type": "application/vnd.apache.arrow.stream"
  },
  "accepts": [
    {
      "scheme": "exact",
      "network": "base-sepolia",
      "amount": "4000",
      "pay_to": "0xE7a820f9E05e4a456A7567B79e433cc64A058Ae7",
      "max_timeout_seconds": 300,
      "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
      "extra": { "name": "USDC", "version": "2" }
    }
  ]
}

Each entry in accepts represents a valid payment option. If multiple pricing tiers apply (e.g., different tokens or bulk tiers), multiple options are returned.

Step 2: Execution and Settlement

When the client resubmits with a Payment-Signature header (base64-encoded payment payload). THe process differs for each pricing model:

Per-Row Tables

  1. The server decodes and deserializes the payment payload into a V2 PaymentPayload.
  2. It executes the actual query to get the real row count.
  3. It matches the payload’s accepted field against the generated payment requirements.
  4. It sends a verify request to the facilitator to confirm the payment is valid and funded.
  5. If verified, it sends a settle request to execute the on-chain transfer.
  6. It returns the query results as Arrow IPC with HTTP 200.

Fixed-Price Tables

  1. The server decodes and deserializes the payment payload into a V2 PaymentPayload.
  2. It matches the payload’s accepted field against the generated payment requirements.
  3. It sends a verify request to the facilitator to confirm the payment is valid and funded.
  4. Only after verification succeeds, it executes the actual query.
  5. It sends a settle request to execute the on-chain transfer.
  6. It returns the query results as Arrow IPC with HTTP 200.

Error Cases

ScenarioResponse
Table not found400 Bad Request
Invalid SQL400 Bad Request
Malformed payment header400 Bad Request
No matching payment offer500 Internal Server Error
Payment verification fails402 Payment Required (with reason and updated options)
Payment settlement fails402 Payment Required (with reason and updated options)
Facilitator unreachable500 Internal Server Error

Running the Server

The tiders-x402-server is best used as a library — you construct and start the server from your own code. This page walks through the minimal setup: connecting to a facilitator, defining pricing, configuring a table, and starting the server.

Both Rust and Python examples are shown side by side. They follow the same steps and produce identical servers. For the full range of configuration options see the Server Components section.

1. Connect to a Facilitator

The facilitator handles blockchain-side payment operations (verification and settlement). Point it at a public facilitator or a running facilitator instance.

Rust:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use tiders_x402_server::facilitator_client::FacilitatorClient;

let facilitator = Arc::new(
    FacilitatorClient::try_from("https://facilitator.x402.rs")
        .expect("Failed to create facilitator client")
);
}

Python:

import tiders_x402_server

facilitator = tiders_x402_server.FacilitatorClient("https://facilitator.x402.rs")

2. Create a Database

Create a database backend.

The examples in the repo ship with a sample CSV file (uniswap_v3_pool_swap.csv) containing Uniswap V3 swap data. For DuckDB, you can load it directly. For PostgreSQL and ClickHouse, seed scripts are provided.

DuckDB

Rust:

#![allow(unused)]
fn main() {
// Load sample data from CSV into an in-memory DuckDB database.
let conn = duckdb::Connection::open_in_memory().unwrap();
let db = tiders_x402_server::database_duckdb::DuckDbDatabase::new(conn);
}

Python:

import duckdb
db_path = "data/duckdb.db"
conn = duckdb.connect(db_path)
db = tiders_x402_server.DuckDbDatabase(db_path)

3. Define Pricing and Configure Tables

Each paid table needs a price tag that describes how much to charge and in which token. The schema is optional but recommended — it is shown to clients in the root endpoint.

Rust:

#![allow(unused)]
fn main() {
use std::str::FromStr;
use x402_chain_eip155::chain::ChecksummedAddress;
use x402_types::networks::USDC;
use tiders_x402_server::price::{PriceTag, PricingModel, TablePaymentOffers, TokenAmount};
use tiders_x402_server::Database;

let usdc = USDC::base_sepolia();

let price_tag = PriceTag {
    pay_to: ChecksummedAddress::from_str("0x[your_address]").unwrap(),
    pricing: PricingModel::PerRow {
        amount_per_item: TokenAmount(usdc.parse("0.002").unwrap().amount),
        min_total_amount: None,
        min_items: None,
        max_items: None,
    },
    token: usdc.clone(),
    description: None,
    is_default: true,
};

let schema = db.get_table_schema("uniswap_v3_pool_swap")
    .await
    .expect("Failed to get table schema");

let offers_table = TablePaymentOffers::new(
    "uniswap_v3_pool_swap".to_string(),
    vec![price_tag],
    Some(schema),
)
.with_description("Uniswap V3 swaps".to_string());
}

Python:

usdc = tiders_x402_server.USDC("base_sepolia")

price_tag = tiders_x402_server.PriceTag(
    pay_to="0x[your_address]",
    amount_per_item="$0.002",
    token=usdc,
    is_default=True,
)

schema = db.get_table_schema("uniswap_v3_pool_swap")

offers_table = tiders_x402_server.TablePaymentOffers("uniswap_v3_pool_swap", [price_tag], schema)

You can add multiple price tags to a table for tiered pricing. See Configuration for details.

4. Build the Payment Configuration

The global payment configuration holds the facilitator client and all table offers.

Rust:

#![allow(unused)]
fn main() {
use tiders_x402_server::payment_config::GlobalPaymentConfig;

let mut global_payment_config = GlobalPaymentConfig::default(facilitator);
global_payment_config.add_offers_table(offers_table);
}

Python:


global_payment_config = tiders_x402_server.GlobalPaymentConfig(
    facilitator,
)
global_payment_config.add_offers_table(offers_table)

5. Create State and Start the Server

Wrap the database and payment configuration into the application state, then start the server.

Rust:

#![allow(unused)]
fn main() {
use Url
use tiders_x402_server::{AppState, start_server};

let server_base_url = Url::parse("http://0.0.0.0:4021").expect("Failed to parse server base URL");
let state = Arc::new(AppState {
    db: Arc::new(db),
    payment_config: Arc::new(global_payment_config),
    server_base_url
});

start_server(state).await;
}

Python:

state = tiders_x402_server.AppState(
    db,
    payment_config=global_payment_config,
)

tiders_x402_server.start_server_py(state)

The server blocks until it receives a shutdown signal (Ctrl+C or SIGTERM).

Verifying the Server

Once running, check the root endpoint:

curl http://localhost:4021/

This returns the available tables, their schemas, and the SQL parser rules.

Environment Variables

The server loads .env files via dotenvy. OpenTelemetry tracing is opt-in — set these variables to export traces:

OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
OTEL_SERVICE_NAME=tiders-x402-server

Configuration Reference

The server is configured programmatically – there are no config files. You set up payment rules, database connections, and server parameters in code. All configuration objects provide both Rust functions and Python bindings.

AppState

The shared application state accessible by every request handler. Holds the database connection, payment configuration, and server URL.

FieldTypeDescription
dbArc<dyn Database>Database backend (DuckDB, Postgres, ClickHouse)
payment_configArc<GlobalPaymentConfig>Global payment configuration
server_base_urlUrlServer’s public URL, used for binding and resource URLs in payment requirements

Construction

#![allow(unused)]
fn main() {
// Rust
let state = AppState {
    db: Arc::new(db),
    payment_config: Arc::new(config),
    server_base_url: Url::parse("http://0.0.0.0:4021").unwrap(),
};
}
# Python
state = AppState(database, payment_config, "http://0.0.0.0:4021")

Getters

GetterRustPythonReturns
Server base URLstate.server_base_url (pub field)state.server_base_urlUrl / str

Setters

SetterRustPython
Set server base URLstate.server_base_url = Url::parse("...").unwrap()state.set_server_base_url("http://0.0.0.0:4021")

FacilitatorClient

HTTP client for communicating with a remote x402 facilitator. Wraps a base URL and derives /verify, /settle, and /supported endpoints automatically.

Construction

#![allow(unused)]
fn main() {
// Rust
let facilitator = FacilitatorClient::try_from("https://facilitator.x402.rs")
    .expect("Failed to create facilitator client");
}
# Python
facilitator = FacilitatorClient("https://facilitator.x402.rs")

Getters

GetterRustPythonReturns
Base URLfacilitator.base_url()facilitator.base_url&Url / str
Verify URLfacilitator.verify_url()facilitator.verify_url&Url / str
Settle URLfacilitator.settle_url()facilitator.settle_url&Url / str
Headersfacilitator.headers()&HeaderMap
Timeoutfacilitator.timeout()facilitator.timeout_ms&Option<Duration> / Optional[int] (ms)

Setters

SetterRustPython
Set custom headersfacilitator.with_headers(header_map)facilitator.set_headers({"Authorization": "Bearer ..."})
Set timeoutfacilitator.with_timeout(Duration::from_millis(5000))facilitator.set_timeout(5000)

GlobalPaymentConfig

Central payment configuration shared across all request handlers. Holds the facilitator client, response format, timeout, and per-table pricing rules.

FieldTypeDefaultDescription
facilitatorArc<FacilitatorClient>Client for the x402 facilitator service
mime_typeStringapplication/vnd.apache.arrow.streamResponse MIME type
max_timeout_secondsu64300How long a payment offer remains valid
default_descriptionStringQuery execution paymentFallback description for tables without their own
offers_tablesHashMap<String, TablePaymentOffers>emptyPer-table payment configuration

Construction

All fields except facilitator are optional and fall back to sensible defaults.

#![allow(unused)]
fn main() {
// Rust - with defaults
let config = GlobalPaymentConfig::default(Arc::new(facilitator));

// Rust - with custom values
let config = GlobalPaymentConfig::new(
    Arc::new(facilitator),
    Some("text/csv".to_string()),       // mime_type
    Some(600),                           // max_timeout_seconds
    Some("Custom description".to_string()), // default_description
    None,                                // offers_tables (defaults to empty)
);
}
# Python - with defaults
config = GlobalPaymentConfig(facilitator)

# Python - with custom values
config = GlobalPaymentConfig(
    facilitator,
    mime_type="text/csv",
    max_timeout_seconds=600,
    default_description="Custom description",
)

Getters

GetterRustPythonReturns
MIME typeconfig.mime_type (pub field)config.mime_typeString / str
Max timeoutconfig.max_timeout_seconds (pub field)config.max_timeout_secondsu64 / int
Default descriptionconfig.default_description (pub field)config.default_descriptionString / str
Get table offersconfig.get_offers_table("table")Option<&TablePaymentOffers>
Table requires paymentconfig.table_requires_payment("table")config.table_requires_payment("table")Option<bool>

Setters

SetterRustPython
Set facilitatorconfig.set_facilitator(arc_client)config.set_facilitator(facilitator)
Set MIME typeconfig.set_mime_type("text/csv".to_string())config.set_mime_type("text/csv")
Set max timeoutconfig.set_max_timeout_seconds(600)config.set_max_timeout_seconds(600)
Set default descriptionconfig.set_default_description("...".to_string())config.set_default_description("...")
Add table offersconfig.add_offers_table(offer)config.add_offers_table(offer)

PriceTag

A single pricing tier for a table. Defines who gets paid, how much, and in which token. A table can have multiple price tags for tiered pricing. The pricing model (per-row or fixed) is specified via the pricing field.

FieldTypeDescription
pay_toChecksummedAddressRecipient wallet address
pricingPricingModelPer-row or fixed pricing (see below)
tokenEip155TokenDeploymentERC-20 token (chain, contract address, transfer method)
descriptionOption<String>Human-readable label for this tier
is_defaultboolWhether this is the default pricing tier

PricingModel

VariantFieldsDescription
PerRowamount_per_item, min_items, max_items, min_total_amountPrice scales with row count
FixedamountFlat fee regardless of row count

Construction (Per-Row)

#![allow(unused)]
fn main() {
// Rust
let price_tag = PriceTag {
    pay_to: ChecksummedAddress::from_str("0x...").unwrap(),
    pricing: PricingModel::PerRow {
        amount_per_item: TokenAmount(usdc.parse("0.002").unwrap().amount),
        min_total_amount: None,
        min_items: None,
        max_items: None,
    },
    token: usdc.clone(),
    description: None,
    is_default: true,
};
}
# Python — per-row pricing (constructor)
# amount_per_item accepts a string ("0.002") or int (2000) for smallest token units
price_tag = PriceTag(
    pay_to="0x...",
    amount_per_item="0.002",
    token=usdc,
    is_default=True,
)

Construction (Fixed)

#![allow(unused)]
fn main() {
// Rust
let price_tag = PriceTag {
    pay_to: ChecksummedAddress::from_str("0x...").unwrap(),
    pricing: PricingModel::Fixed {
        amount: TokenAmount(usdc.parse("1.00").unwrap().amount),
    },
    token: usdc.clone(),
    description: Some("Fixed price query".to_string()),
    is_default: true,
};
}
# Python — fixed pricing (static method)
price_tag = PriceTag.fixed(
    pay_to="0x...",
    fixed_amount="1.00",
    token=usdc,
    description="Fixed price query",
    is_default=True,
)

PriceTag is immutable after creation – create a new one to change values.


TablePaymentOffers

Groups the payment configuration for a single table: its pricing tiers, whether payment is required, and metadata shown to clients.

FieldTypeDescription
table_nameStringThe table this configuration applies to
price_tagsVec<PriceTag>Available pricing tiers
requires_paymentboolWhether queries require payment (derived from price tags)
descriptionOption<String>Description shown in root endpoint and 402 responses
schemaOption<Schema>Arrow schema for client discovery

Construction

#![allow(unused)]
fn main() {
// Rust - paid table
let offer = TablePaymentOffers::new("my_table".to_string(), vec![price_tag], Some(schema))
    .with_description("My dataset".to_string());

// Rust - free table
let free = TablePaymentOffers::new_free_table("public_table".to_string(), Some(schema));
}
# Python - paid table (description can be set at creation)
offer = TablePaymentOffers("my_table", [price_tag], schema=schema, description="My dataset")

# Python - free table
free = TablePaymentOffers.new_free_table("public_table", schema=schema, description="Public data")

Getters

GetterRustPythonReturns
Table nameoffer.table_name (pub field)offer.table_nameString / str
Requires paymentoffer.requires_payment (pub field)offer.requires_paymentbool
Descriptionoffer.description (pub field)offer.descriptionOption<String> / Optional[str]
Price tag countoffer.price_tags.len() (pub field)offer.price_tag_countusize / int
Price tag descriptionsiterate offer.price_tagsoffer.price_tag_descriptions– / List[Optional[str]]

Setters / Mutators

MethodRustPythonDescription
Set description.with_description(desc).with_description(desc)Set or replace the description
Add price tag.add_payment_offer(tag).add_payment_offer(tag)Add a pricing tier, sets requires_payment = true
Remove price tag.remove_price_tag(index).remove_price_tag(index)Remove by index, returns bool, updates requires_payment
Make free.make_free().make_free()Remove all price tags and set requires_payment = false

Tiered Pricing Example

Charge less per row for larger queries (per-row pricing):

#![allow(unused)]
fn main() {
// Default: $0.002/row for any query
let default_tier = PriceTag {...}

// Bulk: $0.001/row for queries returning 100+ rows
let bulk_tier = PriceTag {...}

let offer = TablePaymentOffers::new("my_table".to_string(), vec![default_tier], Some(schema))
    .add_payment_offer(bulk_tier);
}

The client receives both options in the 402 response and can choose the cheaper one.


Supported Networks

Currently supported USDC deployments:

NetworkRustPython
Base Sepolia (testnet)USDC::base_sepolia()USDC("base_sepolia")
BaseUSDC::base()USDC("base")
Avalanche Fuji (testnet)USDC::avalanche_fuji()USDC("avalanche_fuji")
AvalancheUSDC::avalanche()USDC("avalanche")
PolygonUSDC::polygon()USDC("polygon")
Polygon Amoy (testnet)USDC::polygon_amoy()USDC("polygon_amoy")

API Endpoints

The server exposes two endpoints:

GET /

Returns server information as plain text.

Response

Welcome to the Tiders-x402 API!

Usage:
- Send a POST request to /query with a JSON body: { "query": "SELECT ... FROM ..." }
- You must implement the x402 payment protocol to access paid tables.
- See x402 protocol docs: https://x402.gitbook.io/x402

Supported tables:
- Table: swaps_df
  Schema:
    - block_number: Int64
    - tx_hash: Utf8
    ...
  Description: Uniswap v2 swaps
  Payment required: true

SQL parser rules:
- Only SELECT statements are supported.
- Only one statement per request.
- Only one table in the FROM clause.
- No GROUP BY, HAVING, JOIN, or subqueries.
- Only simple field names in SELECT, no expressions.
- WHERE, ORDER BY, and LIMIT are supported with restrictions.

POST /query

Executes a SQL query against the database.

Request

curl -X POST http://localhost:4021/query \
  -H "Content-Type: application/json" \
  -d '{"query": "SELECT * FROM my_table LIMIT 10"}'

Request Body

{
  "query": "SELECT * FROM my_table WHERE col1 = 'value' LIMIT 10"
}

Response: 200 OK (Success)

Binary Arrow IPC stream.

Content-Type: application/vnd.apache.arrow.stream

Parse with any Arrow library (PyArrow, apache-arrow in JS, arrow crate in Rust).

Response: 402 Payment Required

Returned when the table requires payment and no valid X-Payment header is present.

{
  "x402Version": 1,
  "error": "No crypto payment found. Implement x402 protocol...",
  "accepts": [
    {
      "scheme": "exact",
      "network": "base-sepolia",
      "max_amount_required": "4000",
      "resource": "http://localhost:4021/query",
      "description": "Uniswap v2 swaps - 2 rows",
      "mime_type": "application/vnd.apache.arrow.stream",
      "pay_to": "0xE7a820f9E05e4a456A7567B79e433cc64A058Ae7",
      "max_timeout_seconds": 300,
      "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
      "extra": {
        "name": "USDC",
        "version": "2"
      }
    }
  ]
}

Response: 400 Bad Request

Content-Type: text/plain

Invalid query: Simplified SQL does not support the use of 'GroupByExpr'

Response: 500 Internal Server Error

Content-Type: text/plain

Failed to execute query: ...

Headers

HeaderDirectionDescription
Content-Type: application/jsonRequestRequired for POST body
X-PaymentRequestBase64-encoded payment payload (Phase 2)
Content-Type: application/vnd.apache.arrow.streamResponseArrow IPC data on success
Content-Type: application/jsonResponsePayment requirements on 402

Payment Protocol

The server implements the x402 payment protocol, which standardizes micropayments over HTTP using the 402 Payment Required status code.

Protocol Overview

x402 extends HTTP with a payment negotiation layer:

  1. Server returns 402 with payment options in the response body.
  2. Client signs a payment and attaches it as the X-Payment header.
  3. Server verifies the payment via a facilitator and delivers the content.

This is analogous to HTTP authentication (401 / Authorization header) but for payments.

X-Payment Header

The X-Payment header contains a base64-encoded JSON PaymentPayload:

{
  "x402Version": 1,
  "scheme": "exact",
  "network": "base-sepolia",
  "payload": {
    "signature": "0x...",
    "authorization": {
      "from": "0x<sender>",
      "to": "0x<recipient>",
      "value": "4000",
      "validAfter": "0",
      "validBefore": "...",
      "nonce": "0x..."
    }
  }
}

x402 Payment Schemes

Currently the only supported scheme is "exact", which requires the client to pay the exact amount specified in the 402 response. The amount is either calculated from the row count (per-row pricing) or a flat fee (fixed pricing). From the client’s perspective, the protocol is identical — only the server-side calculation differs.

Verification Flow

Server                            Facilitator
  |                                    |
  |  POST /verify                      |
  |  { payment_payload,                |
  |    payment_requirements }          |
  |----------------------------------->|
  |                                    | Validates signature
  |                                    | Checks on-chain balance
  |                                    | Verifies authorization
  |  VerifyResponse::Valid             |
  |  or VerifyResponse::Invalid        |
  |<-----------------------------------|

If valid, the server proceeds to settle:

Server                            Facilitator
  |                                    |
  |  POST /settle                      |
  |  { verify_response,                |
  |    verify_request }                |
  |----------------------------------->|
  |                                    | Executes on-chain transfer
  |  SettleResponse                    |
  |<-----------------------------------|

Supported Tokens

Payment is made in ERC-20 tokens. Currently supported:

  • USDC on Base, Base Sepolia, Avalanche, Avalanche Fuji, Polygon, Polygon Amoy

The token’s EIP-712 domain info (name, version) is included in the extra field of payment requirements, enabling clients to construct the correct typed data for signing.

NetworkRustPython
Base Sepolia (testnet)USDC::base_sepolia()USDC("base_sepolia")
BaseUSDC::base()USDC("base")
Avalanche Fuji (testnet)USDC::avalanche_fuji()USDC("avalanche_fuji")
AvalancheUSDC::avalanche()USDC("avalanche")
PolygonUSDC::polygon()USDC("polygon")
Polygon Amoy (testnet)USDC::polygon_amoy()USDC("polygon_amoy")

See the Configuration Reference for full pricing and payment configuration details.

Client Libraries

The x402 protocol has client libraries that handle the payment flow automatically:

  • TypeScript/JavaScript: x402-fetch – wraps fetch() to automatically handle 402 responses
  • Python: See the Python example for server-side usage

Response Formats

Arrow IPC (Success)

Successful queries return data in Apache Arrow IPC streaming format.

Content-Type: application/vnd.apache.arrow.stream

Arrow IPC is a binary columnar format that is significantly more efficient than JSON for structured data. It preserves type information and supports zero-copy reads.

Reading in TypeScript

import * as arrow from 'apache-arrow';

const response = await fetch("http://localhost:4021/query", { ... });
const arrayBuffer = await response.arrayBuffer();
const table = arrow.tableFromIPC(arrayBuffer);

for (const row of table) {
  console.log(row.toJSON());
}

Reading in Python

import pyarrow as pa

# From bytes
reader = pa.ipc.open_stream(response_bytes)
table = reader.read_all()
print(table.to_pandas())

Reading in Rust

#![allow(unused)]
fn main() {
use arrow::ipc::reader::StreamReader;
use std::io::Cursor;

let reader = StreamReader::try_new(Cursor::new(bytes), None)?;
let batches: Vec<RecordBatch> = reader.collect::<Result<_, _>>()?;
}

Payment Required (402)

When payment is needed, the response is JSON following the x402 specification:

{
  "x402Version": 1,
  "error": "No crypto payment found...",
  "accepts": [
    {
      "scheme": "exact",
      "network": "base-sepolia",
      "max_amount_required": "4000",
      "resource": "http://localhost:4021/query",
      "description": "Uniswap v2 swaps - 2 rows",
      "mime_type": "application/vnd.apache.arrow.stream",
      "pay_to": "0xE7a820f9E05e4a456A7567B79e433cc64A058Ae7",
      "max_timeout_seconds": 300,
      "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
      "extra": { "name": "USDC", "version": "2" }
    }
  ]
}

Fields

FieldDescription
schemePayment scheme ("exact")
networkBlockchain network name
max_amount_requiredTotal price in the token’s smallest unit (e.g., USDC has 6 decimals, so "4000" = $0.004)
resourceURL of the resource being paid for
descriptionHuman-readable description with row count
mime_typeContent type of the successful response
pay_toRecipient wallet address
max_timeout_secondsHow long the payment offer is valid
assetERC-20 token contract address
extraToken EIP-712 domain info for signing

Error Responses

Errors are returned as plain text:

Content-Type: text/plain
  • 400: Invalid SQL, unsupported table, or malformed payment header
  • 500: Database errors, facilitator communication failures, or serialization errors

Server Library

The server library (server/src/lib.rs) is the entry point of the server. It sets up and runs the Axum HTTP server for the Tiders x402 payment-gated data service. It wires together routing, shared state, tracing, and graceful shutdown.

AppState

AppState is the shared context with data of the whole server and that every request handler has access to. It holds the resources that handlers need to do their work:

#![allow(unused)]
fn main() {
pub struct AppState {
    pub db: Arc<dyn Database>,
    pub payment_config: Arc<GlobalPaymentConfig>,
    pub server_base_url: Url,
}
}
  • db — The database backend (DuckDB, Postgres, ClickHouse, etc.) behind a trait object.
  • payment_config — The global payment configuration, including which tables require payment, pricing rules, and facilitator settings. See Payment Configuration.
  • server_base_url — The server’s public URL, used for binding and for building resource URLs in payment requirements.

Configuration

server_base_url can be changed after creation:

#![allow(unused)]
fn main() {
// Rust
state.server_base_url = Url::parse("http://0.0.0.0:8080").unwrap();
}
# Python
state.set_server_base_url("http://0.0.0.0:8080")
print(state.server_base_url)  # getter

Router

The server exposes two routes (API entry points):

MethodPathHandlerDescription
GET/root_handlerReturns server metadata and available data offers
POST/queryquery_handlerAccepts SQL queries with x402 payment support

Telemetry

The server supports exporting traces to an external observability backend (e.g., Jaeger, Grafana Tempo) via the OpenTelemetry protocol (opentelemetry_otlp). This is opt-in: set the OTEL_EXPORTER_OTLP_ENDPOINT environment variable to enable it, and optionally OTEL_SERVICE_NAME to customize the service name (defaults to "tiders-x402"). When not configured, logs are written to the console only.

Middleware

Every request passes through a tracing middleware layer (tower_http::TraceLayer) before reaching the handler. Its role is observability: it automatically logs each request’s method, path, response status, and latency so operators can monitor the server without adding logging code to every handler.

Graceful Shutdown

When the server receives a stop signal (Ctrl+C or a container orchestrator’s termination signal), it stops accepting new connections but lets any in-flight requests finish before exiting. This prevents clients from seeing abrupt connection drops during deployments or restarts.

Module Map

lib.rs declares the following public modules:

ModulePurpose
root_handlerGET / handler
query_handlerPOST /query handler
pricePer-row pricing logic
payment_configGlobal payment configuration
payment_processingPayment verify/settle orchestration
facilitator_clientx402 facilitator HTTP client
sqp_parserSQL parsing and validation
databaseDuckDB execution and Arrow IPC serialization
duckdb_readerDuckDB query construction

Root Handler

The root handler (server/src/root_handler.rs) has the functions to serve the GET / (Root) endpoint. It returns a plain-text response describing the server and its available data.

Response Contents

The response includes:

  1. Usage instructions — how to send queries and a link to the x402 protocol docs.
  2. Supported tables — for each table in the payment configuration:
    • Table name
    • Schema (field names and data types), if available
    • Description, if provided
    • Whether payment is required
  3. SQL parser rules — the restrictions enforced by the SQL parser:
    • Only SELECT statements
    • One statement per request
    • One table in the FROM clause
    • No GROUP BY, HAVING, JOIN, or subqueries
    • Only simple field names in SELECT (no expressions)
    • WHERE, ORDER BY, and LIMIT supported with restrictions

Purpose

This endpoint is designed for both human users and AI agents to discover what data is available and how to query it before making paid requests.

Query Handler

The query handler (server/src/query_handler.rs) is the Axum handler for the POST /query API endpoint. It is the core of the server logic: it receives SQL queries from clients, validates them, checks whether payment is required, and orchestrates the x402 V2 payment flow when needed.

The handler accepts a JSON body:

{ "query": "SELECT * FROM my_table LIMIT 10" }

For paid tables, a successful request typically involves two steps. First, the client submits a query without payment to discover the price. The server responds with a 402 containing the payment conditions — most importantly, the cost. Then the client resubmits the same query with a Payment-Signature header attached.

Processing Flow

Every request goes through the same initial validation:

  1. Parse and validate the SQL query using sqp_parser::analyze_query.
  2. Convert the parsed query into executable DuckDB SQL via duckdb_reader::create_duckdb_query.
  3. Check table existence — return status 400 if the table is not in the configuration.
  4. Check payment requirement — if the table is free, execute immediately and return the data (Arrow IPC format).

For paid tables, the flow branches depending on whether the client included a payment and the table’s pricing model:

  1. Estimation (no Payment-Signature header):

    • For per-row tables: estimate the row count using a COUNT(*) wrapper query.
    • For fixed-price tables: skip the estimation (the price doesn’t depend on row count).
    • Return status 402 with x402 V2 payment requirements in both the Payment-Required header (base64-encoded) and the JSON response body.
  2. Execution and Settlement (with Payment-Signature header):

    The server uses two different flows depending on the pricing model:

    Per-row flow (process_payment):

    • Decode and deserialize the payment header into a V2 PaymentPayload.
    • Execute the query to get the actual results and compute the actual number of rows (to verify the cost).
    • Match the payload against the generated payment requirements.
    • Verify the payment with the facilitator.
    • If verification fails, return 402 with updated payment options.
    • Settle the payment with the facilitator.
    • Return the query results as Arrow IPC data.

    Fixed-price flow (process_fixed_price_payment):

    • Decode and deserialize the payment header into a V2 PaymentPayload.
    • Match the payload against the generated payment requirements.
    • Verify the payment with the facilitator BEFORE executing the query. This prevents bogus payment headers from triggering expensive queries.
    • Execute the query.
    • Settle the payment with the facilitator.
    • Return the query results as Arrow IPC data.

Responses

The query handler can return four types of responses, each signaling a different outcome to the client:

OutcomeStatusContent-TypeDescription
Success200application/vnd.apache.arrow.streamThe query executed successfully. The body contains the result data in Arrow IPC format.
Bad Request400text/plainThe client sent something invalid — a malformed query, an unsupported table, or a bad payment header. The body explains what went wrong.
Payment Required402application/jsonThe query is valid but requires payment. The body contains the x402 V2 payment requirements (price, accepted networks, etc.). The same information is also available base64-encoded in the Payment-Required header.
Internal Error500text/plainSomething unexpected failed on the server side (database error, serialization failure, facilitator unreachable).

Helper Functions

The handler delegates to several private helpers to keep the main function readable:

  • run_query_to_ipc — executes a query and serializes the results to Arrow IPC bytes.
  • estimate_row_count — wraps a query in COUNT(*) to estimate the number of rows (skipped for fixed-price tables).
  • execute_db_query — acquires the database lock and runs a query, returning Arrow record batches.
  • decode_payment_payload — base64-decodes and deserializes the Payment-Signature header into a V2 PaymentPayload.
  • process_payment — orchestrates the per-row verify/settle cycle (execute first, then verify).
  • process_fixed_price_payment — orchestrates the fixed-price verify/settle cycle (verify first, then execute).

Server Overload Vector

For per-row pricing, the server runs the full query before verifying the payment. So an attacker can repeatedly submit expensive queries with bogus Payment-Signature headers and the server will execute every one of them.

Fixed-price tables are not affected — the process_fixed_price_payment flow verifies payment with the facilitator before executing the query. Bogus payment headers are rejected without touching the database.

For per-row tables, the attack surface has two layers:

  1. Estimation abuse (Step 5) — COUNT(*) queries are cheap but still hit the database. High volume could saturate the mutex.
  2. Execution abuse (Step 6) — full queries run before payment is verified. Needs more computation, can be arbitrarily expensive.

Possible mitigations for per-row tables:

Verify before executing — move payment verification before execute_db_query. Use an estimated row count (like in Step 5, recomputed cheaply) instead of actual rows for matching. This eliminates the expensive work for invalid payments. The tradeoff: the actual row count might differ from the estimate. (Minor improvement)

Proof-of-intent deposit — require a payment signature at the estimation step, not just at execution. A successful request would then involve two payments signatures: one for the estimate, one for the data. The server would need additional logic to decide when to charge the estimation fee (e.g., always, or only after repeated requests). This shifts the cost of abuse to the attacker but adds protocol complexity.

Query cost cap — reject queries above a certain estimated cost (row count, complexity). This bounds the damage per request.

Rate limiting — add a middleware layer (by IP, by wallet address from the payment header, etc.) to cap requests per time window. Cheap to implement, but doesn’t prevent slow, sustained abuse.

SQL Parser

The SQL parser (server/src/sqp_parser.rs) implements a restricted SQL dialect called “Simplified SQL” that prevents expensive or dangerous operations. It uses the sqlparser crate with the ANSI dialect.

Supported SQL Features

SELECT Clause

  • Wildcard: SELECT *
  • Named columns: SELECT col1, col2
  • Aliases: SELECT col1 AS alias
  • No expressions, functions, or computed columns

FROM Clause

  • Single table only: FROM my_table
  • No table aliases
  • No JOINs

WHERE Clause

  • Comparison operators: =, !=, <, >, <=, >=
  • Boolean: IS TRUE, IS FALSE, IS NULL, IS NOT NULL
  • Range: BETWEEN ... AND ..., NOT BETWEEN
  • Set membership: IN (...), NOT IN (...)
  • Pattern matching: LIKE, ILIKE, SIMILAR TO
  • Logical operators: AND, OR, NOT
  • Nested expressions with parentheses
  • Type casting: CAST(... AS ...), TRY_CAST, ::
  • String functions: SUBSTRING, TRIM, OVERLAY, POSITION
  • Math functions: CEIL, FLOOR
  • Time functions: EXTRACT, AT TIME ZONE
  • Literals: strings, numbers, booleans, NULL, arrays, intervals

ORDER BY

  • Column references
  • ASC / DESC
  • NULLS FIRST / NULLS LAST

LIMIT / OFFSET

  • LIMIT n
  • OFFSET n

Explicitly Unsupported

The parser rejects queries containing:

FeatureReason
JOINsPrevents cross-table operations
GROUP BY / HAVINGNo aggregation
SubqueriesNo nested queries
CTEs (WITH)No common table expressions
UNION / INTERSECT / EXCEPTNo set operations
Window functionsNo analytical functions
Aggregate functions (COUNT, SUM, etc.)No aggregation in SELECT
DISTINCTNo deduplication
Qualified wildcards (alias.*)No table-qualified wildcards
LIMIT BYMySQL-specific syntax
ORDER BY ALLNot supported

Analyzed Query Structure

The parser produces an AnalyzedQuery struct:

#![allow(unused)]
fn main() {
pub struct AnalyzedQuery {
    pub body: AnalyzedSelect,          // SELECT/FROM/WHERE
    pub limit_clause: Option<AnalyzedLimitClause>,
    pub order_by: Option<Vec<AnalyzedOrderByExpr>>,
}

pub struct AnalyzedSelect {
    pub projection: Vec<AnalyzedSelectItem>,  // Column list
    pub wildcard: bool,                        // Was * used?
    pub from: String,                          // Table name
    pub selection: Option<Expr>,               // WHERE clause AST
}
}

The WHERE clause retains the sqlparser Expr AST nodes, which are validated to contain only supported expression types.

Row Count Estimation

The parser provides create_estimate_rows_query which wraps any valid query in a SELECT COUNT(*):

#![allow(unused)]
fn main() {
pub fn create_estimate_rows_query(duckdb_sql: &str) -> String {
    format!("SELECT COUNT(*) as num_rows FROM ({})", duckdb_sql)
}
}

This is used in Phase 1 to estimate pricing without executing the full query.

Database

The database layer is split into a shared trait and per-backend implementations. This lets the rest of the server (query handler, root handler) work with any supported database without knowing which one is running.

Trait

Define in database.rs.

The Database trait (server/src/database.rs) defines the async interface that every backend must implement:

  • execute_query — runs a SQL query and returns results as Arrow RecordBatches.
  • execute_row_count_query — runs a COUNT(*) query and returns the count as a single number. Used during the estimation step to determine the price before payment.
  • get_table_schema — returns the Arrow schema of a table. Used by the root handler to advertise available tables.
  • create_sql_query — converts an AnalyzedQuery AST (from the SQL parser) into a backend-specific SQL string. Each backend delegates to its own SQL generator (see SQL Generators).

This file also contains serialize_batches_to_arrow_ipc, a backend-agnostic helper that converts Arrow record batches into the Arrow IPC streaming format — the binary format sent back to clients in successful responses.

DuckDB

The DuckDB backend (server/src/database_duckdb.rs) wraps a duckdb::Connection behind Arc<Mutex<…>>. Since the DuckDB crate is synchronous, all operations use tokio::task::spawn_blocking to avoid blocking the async runtime.

Construction:

  • DuckDbDatabase::new — from a pre-configured Connection.
  • DuckDbDatabase::from_path — opens a database file at the given path.

Schema introspection uses SELECT * FROM table LIMIT 0 to get the Arrow schema directly from DuckDB’s native Arrow support.

PostgreSQL

The PostgreSQL backend (server/src/database_postgresql.rs) uses deadpool-postgres for async connection pooling and tokio-postgres for query execution. Since Postgres does not return Arrow natively, this backend converts tokio-postgres rows into Arrow RecordBatches column-by-column.

Construction:

  • PostgresqlDatabase::from_connection_string — parses a connection string, builds a pool (default 16 connections), and verifies connectivity.
  • PostgresqlDatabase::from_params — accepts individual parameters (host, port, user, password, dbname) with full control over pool settings (timeouts, recycling method, max size).
  • PostgresqlDatabase::from_pool — from a user-managed deadpool_postgres::Pool.

The file includes a pg_type_to_arrow mapping that covers booleans, integers, floats, strings, dates, timestamps, decimals (with precision/scale from the type modifier), UUIDs, intervals, arrays, and JSON. Custom FromSql wrappers (PgDate, PgTimestamp, PgNumeric, PgUuid, PgTime, PgInterval) handle binary decoding of types that tokio-postgres does not convert directly.

ClickHouse

The ClickHouse backend (server/src/database_clickhouse.rs) uses the clickhouse crate, which is natively async. Queries request results in FORMAT ArrowStream, so the response arrives as Arrow IPC bytes that are decoded directly into RecordBatches — no intermediate JSON step.

Construction:

  • ClickHouseDatabase::from_url — from a ClickHouse HTTP endpoint URL (e.g. http://localhost:8123).
  • ClickHouseDatabase::from_params — accepts URL plus optional user, password, database, access token, compression mode (none or lz4), custom settings, and HTTP headers.
  • ClickHouseDatabase::from_client — from a user-managed clickhouse::Client.

Schema introspection uses DESCRIBE TABLE and maps ClickHouse types to Arrow via ch_type_to_arrow, handling Nullable(…) and LowCardinality(…) wrappers, decimal variants, enums, and date/time types.

Adding a New Backend

To support a new database, create a database_<backend>.rs file that implements the four methods on the Database trait: executing queries, counting rows, retrieving table schemas, and generating backend-specific SQL. You will also need a corresponding SQL generator — see SQL Generators for that side.

Key design decisions to consider:

  • Async strategy — if the database driver is synchronous, wrap calls in spawn_blocking (as DuckDB does). Natively async drivers (ClickHouse, PostgreSQL) can be used directly.
  • Arrow conversion — some drivers return Arrow natively (DuckDB) or can be asked to (ClickHouse with FORMAT ArrowStream). Others require manual row-to-Arrow conversion (PostgreSQL).
  • Schema introspection — choose between SELECT * FROM table LIMIT 0 or a DESCRIBE TABLE equivalent, depending on what the backend supports.

Why This Structure

Separating the trait from the implementations keeps each backend self-contained. Adding a new database means writing a new file that implements Database — the query handler, root handler, and the rest of the server do not need to change.

SQL Generators

The SQL generator layer converts an AnalyzedQuery (from the SQL Parser) into a database-specific SQL string. It is split into a shared module that handles standard SQL and per-backend modules that handle dialect differences.

Shared

The shared module (server/src/sql_shared.rs) contains logic that is identical across all backends:

  • create_query — assembles the final SQL string from the AnalyzedQuery AST: SELECT, FROM, WHERE, ORDER BY, LIMIT, and OFFSET clauses. It accepts a display_expr callback so each backend can plug in its own expression renderer.
  • display_common_expr — renders standard SQL expressions that work the same everywhere: identifiers, literals, boolean predicates (IS TRUE, IS NULL, etc.), IN, BETWEEN, binary operators, LIKE/ILIKE/SIMILAR TO, CAST, ::, math functions (CEIL, FLOOR), string functions (POSITION, SUBSTRING, TRIM, OVERLAY), nested/tuple/array/interval expressions. Returns None for dialect-specific expressions (EXTRACT, AT TIME ZONE, TypedString, TRY_CAST, SafeCast), letting each backend handle those.
  • format_value — formats SQL value literals (strings, numbers, booleans, NULL, placeholders) with proper escaping.

DuckDB

The DuckDB generator (server/src/sql_duckdb.rs) calls display_common_expr first and only handles what falls through:

  • EXTRACTdate_part('field', expr) (DuckDB’s preferred syntax).
  • AT TIME ZONE → standard expr AT TIME ZONE 'tz'.
  • TypedString'value'::type (double-colon cast).
  • TRY_CAST → supported natively.
  • SafeCast → rejected (not available in DuckDB).

This file also contains the test suite that exercises all expression types through the parse → analyze → generate pipeline.

PostgreSQL

The PostgreSQL generator (server/src/sql_postgresql.rs) follows the same pattern — shared handler first, then Postgres-specific overrides:

  • EXTRACT → standard EXTRACT(field FROM expr).
  • AT TIME ZONE → standard expr AT TIME ZONE 'tz'.
  • TypedString'value'::type (same as DuckDB).
  • TRY_CAST and SafeCast → both rejected (not available in PostgreSQL).

ClickHouse

The ClickHouse generator (server/src/sql_clickhouse.rs) has the most overrides because ClickHouse’s SQL dialect diverges further from standard SQL. Some overrides are checked before calling the shared handler to intercept expressions that would otherwise be handled differently:

  • SIMILAR TO → rejected (not supported).
  • POSITION → rewritten to position(haystack, needle) (ClickHouse uses reversed argument order).
  • OVERLAY → synthesized as concat(substring(…), replacement, substring(…)) since ClickHouse lacks native OVERLAY.
  • :: (double-colon cast) → rewritten as CAST(expr AS type).
  • EXTRACT → standard EXTRACT(field FROM expr).
  • AT TIME ZONEtoTimezone(expr, 'tz').
  • TypedStringCAST('value' AS type).
  • TRY_CAST and SafeCast → both rejected.

Adding a New Backend

To support a new database dialect, create a new sql_<backend>.rs file that:

  1. Calls create_query with a backend-specific display_expr callback.
  2. In that callback, tries display_common_expr first.
  3. Handles any dialect-specific expressions that returned None from the shared handler, or overrides expressions before calling it when the backend needs different behavior.

Price

The price module (server/src/price.rs) defines the pricing model for paid tables. It contains the data structures that describe how much a query costs and the builder methods used to configure tables at startup.

PricingModel

A PricingModel determines how the total price for a query is calculated. There are two variants:

  • PerRow — price scales linearly with the number of rows returned.
  • Fixed — a flat fee regardless of how many rows are returned.
#![allow(unused)]
fn main() {
pub enum PricingModel {
    PerRow {
        amount_per_item: TokenAmount,
        min_items: Option<usize>,
        max_items: Option<usize>,
        min_total_amount: Option<TokenAmount>,
    },
    Fixed {
        amount: TokenAmount,
    },
}
}

PerRow Fields

  • amount_per_item — the price per row (in the token’s smallest unit).
  • min_items / max_items — optional range that determines when this tier applies. A price tag only matches if the row count falls within this range.
  • min_total_amount — optional minimum charge, enforced even if the per-row calculation is lower.

Fixed Fields

  • amount — the flat fee charged for any query against this table.

PriceTag

A PriceTag represents a single pricing tier for a table. It combines a pricing model with payment details (who gets paid and in which token).

#![allow(unused)]
fn main() {
pub struct PriceTag {
    pub pay_to: ChecksummedAddress,
    pub pricing: PricingModel,
    pub token: Eip155TokenDeployment,
    pub description: Option<String>,
    pub is_default: bool,
}
}

Each price tag specifies:

  • pay_to — the recipient wallet address.
  • pricing — the pricing model (PerRow or Fixed) and its parameters.
  • token — the ERC-20 token used for payment (chain, contract address, transfer method).
  • description — optional human-readable label for this tier.
  • is_default — whether this is the default pricing tier for the table.

A table can have multiple price tags (e.g., different tokens, different tiers for small vs. large queries). The payment_config module selects which ones apply for a given row count.

Construction (Per-Row)

#![allow(unused)]
fn main() {
// Rust
let price_tag = PriceTag {
    pay_to: ChecksummedAddress::from_str("0x...").unwrap(),
    pricing: PricingModel::PerRow {
        amount_per_item: TokenAmount(usdc.parse("0.002").unwrap().amount),
        min_total_amount: None,
        min_items: None,
        max_items: None,
    },
    token: usdc.clone(),
    description: None,
    is_default: true,
};
}
# Python — per-row pricing (constructor)
price_tag = PriceTag(
    pay_to="0x...",
    amount_per_item="0.002",
    token=usdc,
    is_default=True,
)

Construction (Fixed)

#![allow(unused)]
fn main() {
// Rust
let price_tag = PriceTag {
    pay_to: ChecksummedAddress::from_str("0x...").unwrap(),
    pricing: PricingModel::Fixed {
        amount: TokenAmount(usdc.parse("1.00").unwrap().amount),
    },
    token: usdc.clone(),
    description: Some("Fixed price query".to_string()),
    is_default: true,
};
}
# Python — fixed pricing (static method)
price_tag = PriceTag.fixed(
    pay_to="0x...",
    fixed_amount="1.00",
    token=usdc,
    description="Fixed price query",
    is_default=True,
)

PriceTag is immutable after creation – create a new one to change values.

Price Calculation

For per-row pricing:

total = amount_per_item * row_count

If min_total_amount is set:

charge = max(total, min_total_amount)

For fixed pricing:

charge = amount   (row count is ignored)

TablePaymentOffers

TablePaymentOffers groups everything needed to describe a table’s payment setup:

#![allow(unused)]
fn main() {
pub struct TablePaymentOffers {
    /// Table name
    pub table_name: String,
    /// Available payment options for this table
    pub price_tags: Vec<PriceTag>,
    /// Whether this table requires payment
    pub requires_payment: bool,
    /// Custom description for this table's payment requirements
    pub description: Option<String>,
    /// Table schema: Option<Schema>
    pub schema: Option<Schema>,
}
}
  • table_name — the table this configuration applies to.
  • price_tags — the list of pricing tiers.
  • requires_payment — whether the table is paid or free (derived from whether price tags exist).
  • description — optional description shown to clients in the root endpoint and 402 responses.
  • schema — optional Arrow schema, displayed in the root endpoint to help clients discover available columns.

Construction and Configuration

Tables are created with constructors and modified with builder/mutator methods.

MethodRustPythonDescription
Create paid tableTablePaymentOffers::new(name, tags, schema)TablePaymentOffers(name, tags, schema=s, description=d)Creates a table with pricing tiers
Create free tableTablePaymentOffers::new_free_table(name, schema)TablePaymentOffers.new_free_table(name, schema=s, description=d)Creates a table with no payment
Set description.with_description(desc).with_description(desc)Set or replace the description
Add price tag.add_payment_offer(tag).add_payment_offer(tag)Add a pricing tier
Remove price tag.remove_price_tag(index).remove_price_tag(index)Remove by index, returns bool
Make free.make_free().make_free()Remove all price tags

Helpers

  • is_all_fixed_price() — returns true if all price tags use PricingModel::Fixed. Used by the query handler to skip the COUNT(*) estimation query.

Getters

GetterRustPythonReturns
Table nameoffer.table_name (pub field)offer.table_namestr
Requires paymentoffer.requires_payment (pub field)offer.requires_paymentbool
Descriptionoffer.description (pub field)offer.descriptionOptional[str]
Price tag countoffer.price_tags.len() (pub field)offer.price_tag_countint
Price tag descriptionsiterate offer.price_tagsoffer.price_tag_descriptionsList[Optional[str]]

Payment Configuration

The payment configuration module (server/src/payment_config.rs) is the central place where pricing rules are defined and x402 V2 payment requirements are generated. It determines how much each query costs and what payment options the server offers to clients.

GlobalPaymentConfig

GlobalPaymentConfig holds everything the server needs to price queries and communicate payment options:

#![allow(unused)]
fn main() {
pub struct GlobalPaymentConfig {
    pub facilitator: Arc<FacilitatorClient>,
    pub mime_type: String,               // default: "application/vnd.apache.arrow.stream"
    pub max_timeout_seconds: u64,        // default: 300
    pub default_description: String,     // default: "Query execution payment"
    pub offers_tables: HashMap<String, TablePaymentOffers>,
}
}
  • facilitator — The client used to verify and settle payments with the x402 facilitator.
  • mime_type — The response format advertised to clients (defaults to "application/vnd.apache.arrow.stream").
  • max_timeout_seconds — How long a payment remains valid before expiring (defaults to 300 seconds).
  • default_description — Fallback description when a table doesn’t have its own (defaults to "Query execution payment").
  • offers_tables — A map of table names to their payment offers (pricing tiers, schemas, descriptions).

Construction

All fields except facilitator are optional and fall back to sensible defaults.

#![allow(unused)]
fn main() {
// Rust - with defaults
let config = GlobalPaymentConfig::default(Arc::new(facilitator));

// Rust - with custom values
let config = GlobalPaymentConfig::new(
    Arc::new(facilitator),
    Some("text/csv".to_string()),
    Some(600),
    Some("Custom description".to_string()),
    None,
);
}
# Python - with defaults
config = GlobalPaymentConfig(facilitator)

# Python - with custom values
config = GlobalPaymentConfig(
    facilitator,
    mime_type="text/csv",
    max_timeout_seconds=600,
    default_description="Custom description",
)

Getters

GetterRustPythonReturns
MIME typeconfig.mime_type (pub field)config.mime_typeString / str
Max timeoutconfig.max_timeout_seconds (pub field)config.max_timeout_secondsu64 / int
Default descriptionconfig.default_description (pub field)config.default_descriptionString / str
Get table offersconfig.get_offers_table("table")Option<&TablePaymentOffers>
Table requires paymentconfig.table_requires_payment("table")config.table_requires_payment("table")Option<bool>

Setters

SetterRustPython
Set facilitatorconfig.set_facilitator(arc_client)config.set_facilitator(facilitator)
Set MIME typeconfig.set_mime_type("text/csv".to_string())config.set_mime_type("text/csv")
Set max timeoutconfig.set_max_timeout_seconds(600)config.set_max_timeout_seconds(600)
Set default descriptionconfig.set_default_description("...".to_string())config.set_default_description("...")
Add table offersconfig.add_offers_table(offer)config.add_offers_table(offer)

See the Configuration Reference for the full API.

What It Does

The module answers four questions for the query handler:

  1. Does this table require payment?table_requires_payment returns whether a table is free, paid, or unknown.

  2. What are the payment options for this query?get_all_payment_requirements takes a table name and estimated row count, then returns all applicable payment requirements. Each price tag is checked against its min_items/max_items range to determine if it applies.

  3. Does the client’s payment match what we expect?find_matching_payment_requirements compares the PaymentRequirements the client echoed back (in PaymentPayload.accepted) against the server-generated requirements using direct equality.

  4. What should the 402 response look like?create_payment_required_response assembles the full PaymentRequired response body including the error message, resource info, and all applicable payment options.

Price Calculation

The price calculation depends on the pricing model:

Per-row pricing:

total = amount_per_item * item_count
charge = max(total, min_total_amount)   // if min_total_amount is set

Fixed pricing:

charge = amount

Payment Requirements

Each applicable price tag produces a x402 PaymentRequirements entry sent to the client in the 402 response. The key fields are:

FieldDescription
schemePayment scheme style. Always "exact" — the client must pay the exact amount
networkThe blockchain network (e.g., "base-sepolia")
amountTotal price in the token’s smallest unit
pay_toThe recipient wallet address
max_timeout_secondsHow long the payment offer is valid
assetThe token contract address
extraToken metadata (e.g., name, version)

Payment Processing

The payment processing module (server/src/payment_processing.rs) handles the communication with the x402 facilitator for payment verification and settlement. It sits between the query handler and the facilitator client, translating between the server’s V2 types and the facilitator’s wire format.

Role

The query handler delegates to this module once it has a payment payload and a matching payment requirement. The module provides two functions that map directly to the two steps of the payment lifecycle:

  1. verify_payment — builds a V2 verify request, converts it to the facilitator’s wire format, and sends it. Returns both the wire-format request (needed later for settlement) and the typed V2 response so the query handler can inspect the result.

  2. settle_payment — takes a successful verification response and the original wire-format request, and asks the facilitator to execute the on-chain transfer. Returns an error if the facilitator reports a settlement failure.

How It Fits Together

query_handler
  │
  ├── verify_payment(facilitator, payload, requirements)
  │       └── facilitator_client.verify(request)
  │
  └── settle_payment(verify_response, facilitator, verify_request)
          └── facilitator_client.settle(request)

The module intentionally keeps no state — it converts types and forwards calls. The payment configuration logic (which requirements to use, pricing) lives in payment_config, and the HTTP transport lives in facilitator_client.

Facilitator Client

The facilitator client (server/src/facilitator_client.rs) is responsible for communicating with a remote x402 facilitator service. It handles the HTTP details so the rest of the server can verify and settle payments through simple function calls.

What is a Facilitator?

An x402 facilitator is a third-party service that handles the blockchain side of payments. The server never interacts with the blockchain directly — instead, it delegates to the facilitator for three operations:

  • Verify — confirms that a payment payload is valid, properly signed, and funded.
  • Settle — executes the on-chain payment transfer.
  • Supported — reports which payment schemes and networks the facilitator can handle.

The default public facilitator is at https://facilitator.x402.rs.

FacilitatorClient

#![allow(unused)]
fn main() {
pub struct FacilitatorClient {
    base_url: Url,
    verify_url: Url,      // base_url + "./verify"
    settle_url: Url,       // base_url + "./settle"
    supported_url: Url,    // base_url + "./supported"
    client: Client,        // reqwest HTTP client (shared connection pool)
    headers: HeaderMap,    // optional custom headers
    timeout: Option<Duration>,
}
}

FacilitatorClient wraps an HTTP client pointed at a facilitator’s base URL. On construction, it derives the /verify, /settle, and /supported endpoint URLs automatically.

The client can be safely reused across concurrent requests.

Construction

The client can be created from a URL string or a parsed Url:

#![allow(unused)]
fn main() {
let facilitator = FacilitatorClient::try_from("https://facilitator.x402.rs")
    .expect("Failed to create facilitator client");
}

Configuration

The client supports optional customization after creation:

#![allow(unused)]
fn main() {
// Rust
let facilitator = facilitator.with_headers(header_map);
let facilitator = facilitator.with_timeout(Duration::from_millis(5000));

// Read back
println!("{}", facilitator.base_url());
println!("{}", facilitator.verify_url());
println!("{}", facilitator.settle_url());
println!("{:?}", facilitator.timeout());
}
# Python
facilitator.set_headers({"Authorization": "Bearer token123"})
facilitator.set_timeout(5000)  # milliseconds

# Getters (properties)
print(facilitator.base_url)
print(facilitator.verify_url)
print(facilitator.settle_url)
print(facilitator.timeout_ms)  # returns int or None

See the Configuration Reference for the full API.

Facilitator Trait

The client implements the x402_types::facilitator::Facilitator trait, which defines the verify, settle, and supported methods. This allows it to be used interchangeably with other facilitator implementations (e.g., a local one for testing).

Error Handling

Errors are captured with context about where the failure occurred:

ErrorMeaning
UrlParseThe facilitator URL or an endpoint path could not be parsed
HttpA network or transport error occurred (connection refused, DNS failure, timeout)
JsonDeserializationThe facilitator returned a response that could not be parsed as JSON
HttpStatusThe facilitator returned a non-200 status code
ResponseBodyReadThe response body could not be read as text

Telemetry

All facilitator requests are wrapped in OpenTelemetry tracing spans. Each span records the outcome (otel.status_code as "OK" or "ERROR") and, on failure, the error details. This makes facilitator latency and errors visible in the server’s observability pipeline.