↗LoxleyLX-402

Quickstart

A paid endpoint and a client that pays it, in about five minutes.

You need Node 20+ and a wallet holding the token you want to charge in. You do not need to run a facilitator — a public one is live.

1. Gate a route

npm install @loxley/server
import { loxley } from '@loxley/server'

app.get('/v1/report/:symbol', loxley({
  price: 10_000n,                    // 0.01 USDC, in base units
  payTo: '0xYourTreasury…',
  asset: USDC,
  assetName: 'USD Coin', assetVersion: '2',
  network: 'eip155:46630',
  settlement: '0x54A49180CFb0f1573f8d6227eA36aDE583Cf0743',
  facilitator: 'https://loxley-facilitator-production.up.railway.app',
}), (req, res) => {
  res.json({ paidBy: req.loxley!.payer })
})

2. Pay for it

npm install @loxley/client
import { createLoxleyFetch } from '@loxley/client'

const pay = createLoxleyFetch({
  account,
  chain: robinhoodChainTestnet,
  policy: { maxAmountPerRequest: 50_000n },   // required — see below
})

const res = await pay('https://api.example.com/v1/report/AAPL')

That is the whole integration. The handler is unchanged; by the time it runs, the payment has settled on-chain and req.loxley.receipt.txHash is the proof.

The spend policy is mandatory. A client that pays whatever a server asks is a wallet-draining bug, so there is no permissive default. Set a per-request cap, and a session cap if the client is long-lived.

Concepts

PayerOwns the funds and signs the intent. Usually an agent; can be a person with a browser wallet.
MerchantThe HTTP server gating a resource. Never holds a key beyond its payout address.
FacilitatorVerifies signatures and broadcasts settlement, paying gas. Untrusted — see the security model.
IntentThe signed authorization to move funds for one specific resource.
ReceiptProof settlement happened: transaction hash, block, fee.

The challenge

A merchant answers with 402 and a JSON body describing how to pay.

{
  "lx402": "1",
  "resource": "GET https://api.example.com/v1/report/AAPL",
  "resourceHash": "0x54398e3e…",
  "expiresAt": 1785111596,
  "accepts": [{
    "scheme": "exact",              // or "upto" for metered
    "network": "eip155:46630",      // CAIP-2
    "settlement": "0x54A49180…",
    "asset": "0xB3633542…",
    "assetSymbol": "USDC", "assetDecimals": 6,
    "assetName": "USD Coin", "assetVersion": "2",
    "amount": "10000",
    "payTo": "0xb3CF79a5…",
    "salt": "0x50617cd8…",
    "validAfter": 0,
    "validUntil": 1785111596,
    "sources": ["authorization", "permit2", "balance", "allowance"],
    "facilitator": "https://…"
  }]
}
  • salt is 32 random bytes, fresh per challenge. It is what makes each intent single-use.
  • validUntil should be short — 30 to 120 seconds. It is the window in which a signature is live.
  • assetName and assetVersion are required when offering authorization: that signature is made against the token's own EIP-712 domain, and without them a client would need its own RPC node purely to read them.

The payment

The client retries the identical request with a base64url header.

X-Loxley-Payment: <base64url(JSON)>
{
  "lx402": "1",
  "scheme": "exact",
  "network": "eip155:46630",
  "source": "authorization",
  "settlement": "0x54A49180…",
  "intent": {
    "payer": "0x61D5e0d9…",
    "payTo": "0xb3CF79a5…",
    "asset": "0xB3633542…",
    "amount": "10000",
    "salt": "0x50617cd8…",
    "validAfter": 0,
    "validUntil": 1785111596,
    "resourceHash": "0x54398e3e…",
    "source": 2
  },
  "signature": "0x…"
}

Every field of intent must equal the corresponding field of the chosen requirement. A merchant rejects any payload that deviates — especially payTo, asset, amount and resourceHash.

The response carries X-Loxley-Receipt, also base64url JSON.

Settlement sources

Four ways the money can move. The payer chooses; the merchant only advertises what its facilitator can settle.

authorization — gasless

For EIP-3009 tokens such as USDC. The payer signs only the token's TransferWithAuthorization message — but its nonce is set to the LX-402 intent digest, so that single token-domain signature transitively commits to the merchant, the amount and the resource. Tamper with any of them and the derived nonce changes, so the token itself rejects the signature.

The payer needs no approval and no native token at all.

permit2 — any ERC-20

The payer approves the canonical Permit2 contract once, ever; every payment after that is a signature. LX-402 folds a LoxleyWitness(payTo, resourceHash, salt) into the Permit2 message, so a Permit2 payment keeps resource binding — a bare permit commits the amount but says nothing about who is paid or what is bought.

balance — prefunded

Deposit into the settlement contract, then each payment is an internal debit. The cheapest path per payment, and the right choice for high-frequency agent billing. A withdrawal cooldown means a payer cannot pull funds out from under a payment already in flight.

allowance — universal fallback

A standard ERC-20 approval to the settlement contract, then a signature per payment. Works with any token, needs an approval per spender.

Metered billing

Under scheme: "upto" the quoted amount is a ceiling. The merchant settles for what the work actually cost.

import { loxleyMetered } from '@loxley/server'

app.post('/v1/complete', loxleyMetered({
  maxPrice: 20_000n,        // the ceiling the payer authorises
  payTo, asset, network, settlement, facilitator,
}), async (req, res) => {
  const { tokens } = await runModel(req.body)
  req.loxleyMeter!.charge(BigInt(tokens) * PRICE_PER_TOKEN)
  res.json({ tokens })
})

Settlement is necessarily deferred — the cost is unknown until the work is done. The payment is verified against the ceiling before the handler runs, so a payer who cannot cover the worst case never gets served, and settled once the response is sent.

  • The ceiling is enforced on-chain. Charging above it reverts with ChargedExceedsAuthorized.
  • The protocol fee is taken on what is charged, never on the ceiling.
  • Metered intents use distinct source values, so an exact signature can never be settled for a lesser figure — a payer quoted a fixed price always gets it.
  • Reporting no usage charges nothing. A metering bug should cost the payer zero, not their maximum.
  • authorization cannot be metered: EIP-3009 moves a fixed value with no partial semantics. Meterable sources are permit2, balance and allowance.
A client's spend policy applies to the ceiling, not to any expected figure. The ceiling is what is being authorised.

Resource binding

Every intent commits to a hash of the resource it pays for:

canonical    = "<METHOD> <scheme>://<host><path>"   // no query, no fragment
resourceHash = keccak256(utf8(canonical))

A signature captured for a cheap endpoint therefore cannot be presented at an expensive one. The merchant recomputes the hash from the incoming request and compares before serving; the contract emits it, giving every payment an auditable link to what was bought.

Query strings are excluded so that cache-busting or pagination parameters cannot invalidate a quoted price. Merchants pricing per-query express that with separate challenges, which already carry distinct salts.

Behind a proxy or TLS terminator, set origin on the middleware. Otherwise the hash the merchant computes will not match what the client signed.

Gating a route

Express

import { loxley } from '@loxley/server'
app.get('/v1/report/:symbol', loxley({ price: 10_000n, … }), handler)

Hono, Next, Bun, Deno, Workers

These all speak the Web Fetch API, so one guard covers them and none is a dependency.

import { loxleyHono, withLoxley } from '@loxley/server'

// Hono
app.get('/v1/report', loxleyHono({ price: 10_000n, … }), (c) =>
  c.json({ paidBy: c.get('loxley').payer }))

// Next App Router
export const GET = withLoxley({ price: 10_000n, … }, async (req, { payer }) =>
  Response.json({ paidBy: payer }))

Settlement timing

  • settle-before-serve (default) withholds the response until the transaction is included. Costs one block of latency; eliminates served-but-unpaid.
  • after-response serves first and settles behind it. Lower latency, but only sound for the balance source — protected by the withdrawal cooldown — or where the loss is bounded and acceptable.

Paying for one

Three clients, each written against the specification.

TypeScript

const pay = createLoxleyFetch({
  account, chain,
  policy: {
    maxAmountPerRequest: 50_000n,
    maxTotalPerSession: 1_000_000n,
    allowedAssets: [USDC],
    allowedNetworks: ['eip155:46630'],
  },
  preferSources: ['permit2'],       // optional
})

Python

from loxley import LoxleyClient, SpendPolicy

client = LoxleyClient(KEY, SpendPolicy(max_amount_per_request=50_000))
response = client.get("https://api.example.com/v1/report/AAPL")
print(client.last_receipt["txHash"])

Go

client, _ := loxley.New(key, &loxley.SpendPolicy{MaxAmountPerRequest: 50_000})
resp, err := client.Get("https://api.example.com/v1/report/AAPL")

All three are pure HTTP — none dials a node. Everything needed to sign arrives in the challenge.

Browser paywall

When a request's Accept includes text/html, the middleware serves a payment page instead of a JSON challenge. Agents never send that header, so the two audiences separate cleanly. Disable with paywall: false.

The page is self-contained — no bundler, no external script, no crypto library. That works because permit2 is signed with eth_signTypedData_v4, so the wallet does the EIP-712 hashing. The authorization source cannot be used in a browser: deriving its nonce needs a client-side keccak.

Discovery

A facilitator can index paid endpoints, so agents can find things to pay for instead of being told about them out of band.

# register — the facilitator verifies before listing
curl -X POST $FACILITATOR/discovery/register \
  -H 'content-type: application/json' \
  -d '{"resource":"https://api.example.com/v1/report"}'

# search
curl "$FACILITATOR/discovery/resources?maxAmount=10000&network=eip155:46630"

Registration is self-verifying. The facilitator fetches the URL itself, requires a real 402 whose resourceHash matches the URL it came from, and indexes the terms the endpoint actually quoted. Nothing in a listing comes from the registration request except the URL — you can nominate an endpoint, but you cannot lie about its price. Endpoints returning 200 are rejected.

Listings are advisory. A client must still validate the challenge it receives when it actually requests the resource; prices can change between indexing and payment.

Public facilitators

Point a merchant at one of these and you host nothing.

FacilitatorNetworksSchemesFeeStatus
loxley-facilitator-production.up.railway.app Robinhood Testnet (46630) exact, upto 0.30% health · supported

One entry, and it is testnet — this list is honest rather than impressive. A facilitator holds no user funds and cannot redirect a payment, so running one is low-risk and anyone can: see deploying. Ask a facilitator what it supports rather than assuming:

curl https://<facilitator>/supported
{ "networks": ["eip155:46630"], "schemes": ["exact","upto"],
  "sources": ["authorization","permit2","balance","allowance"], "feeBps": 30 }
A facilitator is untrusted infrastructure. Choosing one affects liveness and fees, never custody — it can refuse to settle or waste its own gas, but it can never redirect your funds or change an amount.

Facilitator API

EndpointPurpose
GET /healthPer-chain RPC reachability and gas balance. Returns 503 when a wallet is too low to settle.
GET /supportedNetworks, schemes, sources, settlement addresses, fee.
POST /verifyOff-chain checks plus a simulated call. No gas, no state change.
POST /settleBroadcasts and waits for inclusion. Idempotent per intent hash.
GET /discovery/resourcesSearch the index.
POST /discovery/registerAsk the facilitator to verify and index a resource.

Deploying your own facilitator

One process serves every configured chain. It holds no user funds — only gas.

LOXLEY_CHAINS=4663,8453,42161
LOXLEY_SETTLEMENT_ADDRESS=0x54A49180CFb0f1573f8d6227eA36aDE583Cf0743
LOXLEY_RPC_URL_8453=https://…          # public RPCs are rate-limited
FACILITATOR_PRIVATE_KEY=…              # hot wallet, gas only
DATABASE_URL=…                         # required before scaling past one replica
  • Fund the gas wallet on every chain you configure. A facilitator with no gas on Arbitrum fails every Arbitrum payment while happily settling on Base.
  • One replica is the default and the only safe setting without Postgres: the claim that stops two replicas broadcasting the same intent lives in the database.
  • Broadcasts are serialised per chain to avoid nonce collisions, so sustained throughput is about one settlement per round-trip per chain. Past that, run a pool of gas wallets.

Security model

The facilitator is untrusted

It pays gas and can refuse to settle. It can never redirect funds, change an amount, or settle twice — payTo and amount live inside the payer's signature, and replay is rejected on-chain by intent digest. Its only real powers are censorship and burning its own gas.

Replay

Intent hashes are marked used on-chain at settlement. The authorization path additionally inherits the token's own nonce registry. A facilitator-side cache is a latency optimisation, never the defence.

Funds cannot be trapped

withdraw() is deliberately not gated by the pause switch. An operator can stop new settlements but can never freeze user deposits.

Front-running

Settlement transactions are public before inclusion. Copying one is pointless: funds move only to the payTo inside the signed intent, so a front-runner merely pays gas to complete someone else's payment.

Fees

A contract-level feeBps, hard-capped at 5% in bytecode, deducted from the charged amount. The payer is never charged more than they signed for.

Status & limits

Unaudited. Testnet only. The contract is written conservatively and covered by 38 tests including fuzz invariants, but that is not adversarial review by someone who did not write it. Do not put real money through this yet.

Verified against a public chain

All four sources, metered billing, the Permit2 witness (tested against real Permit2 bytecode etched from chain), deterministic addressing across six chains, three client languages, discovery, and the browser paywall — each confirmed by reading chain state rather than trusting an HTTP status.

Known limits

  • Throughput is roughly one settlement per round-trip per chain.
  • Rate limiting is per-process, so N replicas allow roughly N times the configured limit.
  • after-response settlement is fire-and-forget. If the merchant process dies between serving and settling, that payment is lost — the intent stays valid until validUntil, but nothing re-submits it.
  • Discovery listings are advisory and can go stale.
  • The paywall cannot use authorization, so a browser payer needs a one-time Permit2 approval.

Resolved, not implemented

ERC-7710 delegation — an account-layer concern, not a protocol one. A real MetaMask DelegationManager v1.3.0 is deployed on Robinhood Chain testnet, so it was never blocked on a dependency. The open question — where per-payment binding lives under a standing authorization — resolves cleanly: the delegate redeems its own delegation, then pays with any existing source. The payer is the agent, so every intent is signed and resource-bound exactly as today. Delegation bounds the total; the intent binds the payment.

A settlement-contract entry point is deliberately absent. A delegation may name address(0xa11) to mean "any delegate", and a permission context is not secret — so a contract that redeemed on a caller's behalf and credited msg.sender would let the first observer of an open delegation credit themselves with someone else's funds. Caveat enforcers cannot prevent it: they constrain the on-chain execution and cannot see Loxley's credit accounting.

Solana — specified, not built. CAIP-2 solana: identifiers, a canonical byte encoding replacing EIP-712 that keeps resourceHash so resource binding survives, a settlement program using marker PDAs for replay, and a clear answer on sources: balance and allowance carry over, authorization and permit2 cannot. Gaslessness arrives more simply, since Solana separates the fee payer natively. Realistically a separate project with its own audit.