All of the code is on GitHub.
The problem: the final cost arrives after the call
We started with a simple marketplace idea: list AI services, let someone choose one, and charge for a single call. The part that looked straightforward was the price. Then we tried to tell a buyer what one call would cost before running it.
The input can be counted up front, but the model decides the output length while it writes. A flat fee works when calls are uniform and makes short calls subsidize long ones when they vary. Billing afterward tracks usage but asks the buyer to accept a number they never approved.
We wanted both properties: a maximum the buyer agrees to before the call and a final charge based on what the call used. Tollgate applies auth-and-capture to that transaction. A provider publishes a rate card, the buyer escrows the maximum, and the contract settles the actual charge from reported token counts.
The marketplace surface was the easy part. Making that maximum a real bound—and keeping one payment, one generation, and one pricing agreement aligned—drove the design below. The result is a local reference implementation with 3 AI services, one HTTP server, and an EVM contract. Model calls are deterministic and offline by default, with an opt-in path to the Anthropic API.
What we built

We ended up separating the transaction into 4 parts:
A public rate card. Each service has a base fee, input-token rate, output-token rate, and maximum output. The contract calculates quotes and final charges from those terms.
A buyer commitment. The buyer verifies the quote independently, commits to the complete rate card, and places the maximum charge in escrow.
A metering server. It counts the input, verifies the funded terms and buyer signature, runs one model generation, and reports input and output counts.
A settlement contract. It accepts counts rather than a price, enforces the quoted bounds, divides the charge between provider and treasury, and credits the unused escrow back to the buyer.

The complete offline walkthrough is:
git clone https://github.com/ashnkumar/tollgate && cd tollgate
pnpm install
./scripts/walkthrough.sh
It starts a local chain, deploys the contract and its 3 services, starts the server, and opens the
browser flow. No API key or wallet setup is required. USE_FAKE_MODEL=false with an
ANTHROPIC_API_KEY runs the same transaction against the real API.
Lesson 1: The seller reports usage, not a price
Our first version calculated prices in the server. The arithmetic was correct, but the seller was running the meter, calculating the bill, and collecting the payment. The buyer couldn’t distinguish a correct charge from an inflated one.
We moved the rate card and arithmetic into the contract. quote() is public, so the browser and
terminal clients calculate the same maximum without trusting the server. At settlement, the server
submits only token counts:
// packages/contracts/contracts/Tollgate.sol
function settleCall(bytes32 callId, uint32 inputTokens, uint32 outputTokens) external {
Call storage c = calls[callId];
if (c.buyer == address(0)) revert NoSuchCall();
if (c.settled) revert AlreadySettled();
if (msg.sender != c.settler) revert NotSettler();
if (outputTokens > c.maxOutputTokens) revert OutputOverCap(outputTokens, c.maxOutputTokens);
if (inputTokens > c.quotedInputTokens) revert InputOverQuote(inputTokens, c.quotedInputTokens);
The contract uses the rates stored with the funded call and rejects a cost above the escrow. A dishonest settler can’t submit an amount, report either count above its quoted limit, redirect earnings, or charge past the buyer’s maximum.
It can still lie within those bounds. Nothing on-chain can prove how many tokens a model used. The contract changes the settler’s authority from choosing the final bill to reporting 2 bounded measurements. Verifiable metering would require an oracle, trusted execution, or proof of inference.
x402’s upto scheme
already supports authorizing a maximum and settling a smaller usage-based amount; its listed use cases
include LLM token generation. Tollgate makes one narrower trade: the resource server reports usage
counts and the contract applies a published formula, rather than the resource server sending the final
amount to a facilitator.
The reusable rule isn’t specific to a chain: the party with an incentive to increase a bill should submit measurements, not the monetary conclusion.
Lesson 2: Agreeing to a total isn’t agreeing to the formula
Once the contract was calculating prices, we had the buyer compare the server’s quote with the on-chain quote. The totals matched, so we treated that as an agreement. It wasn’t.
We found the gap by constructing 2 rate cards with the same quote. The demo’s summarize service
charges 0.001 ETH plus 0.000001 ETH per input token and 0.000005 ETH per output token, with a 400-token
ceiling. A 254-token input quotes at 0.003254 ETH. Moving that entire amount into the base fee and
setting both token rates to 0 produces the same quote, but every call now consumes the full escrow
regardless of output length.
Our checks compared the service, input count, ceiling, and total. They all passed. The buyer had verified a number and funded a different formula.
We changed the funding transaction to carry the agreement itself. termsHash() hashes the service
ID, provider, settler, 3 rates, and output ceiling. The buyer reads the quote and hash together, then
sends both the hash and escrow to openCall():
// packages/contracts/contracts/Tollgate.sol
bytes32 actualTerms = _termsHash(serviceId, s);
if (actualTerms != expectedTerms) revert TermsChanged(expectedTerms, actualTerms);
If any term moves before funding lands, the transaction reverts. The buyer must take a fresh quote.
That fixed quote to funding and left a second window. Settlement still read the provider’s live
service, so an ordinary rate change could make an already-funded call impossible to settle: raised
rates could exceed escrow, a lower ceiling could reject the real output, and a new settler could lock
out the server that performed the work. We fixed that separately by copying the complete terms into
each call. termsHash() now protects quote to funding; the call snapshot protects funding to
settlement.
Both commitments cover billing, not the service implementation. The model, system prompt, and input contents remain off-chain. The buyer still trusts the server to perform the advertised work; Tollgate makes the price agreement independently checkable, not the generation itself.
Lesson 3: The API provides one ceiling and one estimate
The quote needs 2 token numbers before generation: the input size and the maximum output. We first treated both as ordinary configuration values. They provide different guarantees.
For output, we considered prompt instructions and effort settings, but neither provides a numeric
bound. The Messages API defines
max_tokens as an absolute maximum. Generation may stop sooner but can’t exceed it, so that became
the output side of the quote.
For input, we initially treated the pre-call count as the billable count. Then we read the
token-counting documentation more closely. Anthropic documents
count_tokens as an estimate,
and the estimate can include system-added tokens that aren’t billed. Treating it as the final invoice
could end up overcharging the buyer.
The clients don’t tokenize the input independently. They verify the contract’s price for the count the server returned. That makes the arithmetic public without making the measurement trustless.
Tollgate uses the pre-call count as the maximum billable input and charges the lower of the observed and quoted count:
// packages/server/src/app.ts
const billedInputTokens = Math.min(result.inputTokens, onChainCall.quotedInputTokens);
const billedOutputTokens = Math.min(result.outputTokens, onChainCall.maxOutputTokens);
Any divergence is the provider’s exposure. The provider chose the model and rate card; the buyer agreed to the quote.
Our first count and generation requests were also assembled separately. That made an ordinary refactor—a system prompt, tool, or message added to one path—a pricing change. We replaced both with one request builder:
// packages/server/src/ai.ts
function buildRequest(service: ServiceDefinition, input: string) {
return {
model: service.model,
system: service.systemPrompt,
messages: [{ role: "user" as const, content: input }],
thinking: { type: "disabled" as const },
};
}
Four opt-in live tests check the current vendor assumptions: pre-call counting, agreement between the count and the usage returned for catalog requests, output-ceiling enforcement, and usable output.
Lesson 4: One payment must start one generation
Once a call could be funded and settled correctly, we assumed one funded call would produce one model generation. It didn’t. The model call is the irreversible step: after it starts, the provider may owe the model vendor money whether settlement succeeds or not.
The first duplicate path was /run. Two requests with the same call ID could both read the pending
quote, await chain checks, and reach generation before either consumed it. The buyer would settle once
while the provider paid for multiple generations.
The current handler claims the call ID synchronously before its first await:
// packages/server/src/app.ts
if (inFlight.has(callId)) throw new HttpError(409, "This call is already being run");
inFlight.add(callId);
We wrote a concurrency test for the fix and it passed even after we removed the guard. The harness was serializing the requests before they reached the handler. The useful test needed a real listening socket and a delayed fake model so both requests could overlap.
Then we found a second duplicate path in the SDK. The
TypeScript SDK retries
selected connection, timeout, rate-limit, and server errors twice by default. One call in the
application can become 3 model requests. max_tokens limits each attempt, not the combined
spend.
Tollgate disables model retries:
// packages/server/src/ai.ts
maxRetries: 0,
timeout: MODEL_TIMEOUT_MS,
Model refusals arrive as normal responses, so the client checks stop_reason and turns a refusal into
a full-refund path. Anthropic also offers automatic refusal fallbacks but Tollgate doesn’t enable them:
a fallback is another generation on another model, while the service advertised one model and the
buyer funded one generation under its ceiling.
A failed model call attempts a full on-chain refund. Chain transactions are different: by then the model cost has already been charged, so the chain client retries settlement up to 3 times. It serializes transactions from the settler key, resets the local nonce after a failed send, and checks the call’s on-chain state before retrying an ambiguous result.
This protects the provider from duplicate generations and the buyer from a settlement that mined but was reported as failed. It doesn’t make output durable. The generated text stays in memory until settlement completes; a crash or lost response after settlement can still leave the buyer charged with no output to retrieve.
Lesson 5: The ceiling that bounds cost can truncate the answer
By this point, max_tokens was carrying the whole pricing model. We initially described it as the
buyer’s answer budget. That was only true while the model spent the budget on visible text.
On Claude Opus 5,
thinking tokens
count toward the same max_tokens budget as response text. A difficult request can consume much of
the purchased budget before producing the visible answer. The cost remains bounded while the answer
can still be cut short.
We decided to disable thinking so the rate card’s output ceiling applies to visible output. That is a
metering decision, not the vendor’s preferred quality setting. On Claude Opus 5, disabling thinking
is permitted only at high effort or below and can expose internal tags in visible text. The catalog
uses the documented output-hygiene instruction:
// packages/server/src/catalog.ts
const OUTPUT_HYGIENE = "Do not include internal or system XML tags in your response.";
Anthropic’s guidance is to leave thinking enabled and lower effort when cost matters. Effort changes expected reasoning volume but doesn’t provide a strict token count, so it can’t replace the ceiling this transaction needs.
Models that require thinking can still provide a hard cost ceiling, but not a guarantee that the ceiling corresponds to visible answer tokens. Tollgate’s catalog is limited to configurations where thinking can be disabled.
That still leaves the provider choosing the ceiling. Lower values reduce escrow and increase the chance of a response ending at the limit. Higher values reduce truncation and hold more buyer capital that will later be refunded.
We selected the 400, 1200, and 2000-token demo ceilings to make different settlement outcomes visible. They weren’t tuned against a workload. A response that reaches its ceiling is fully charged whether it ended cleanly or mid-sentence; billing alone can’t distinguish the 2 cases.
What this doesn’t solve
-
The chain verifies billing, not model execution. The server supplies the token counts, model, prompt, and input sent upstream. The buyer can verify the formula and maximum charge, but still trusts the server to perform the advertised work.
-
Quote and output state aren’t durable. Pending quotes and generated text live in one process. A crash or lost response after settlement can leave the buyer charged with no way to retrieve the output, and multiple server processes would need a shared quote store.
-
This isn’t a production deployment. The contract hasn’t been audited, the browser uses a published development key,
/quotehas no authentication or rate limit, and native-token prices have no fiat oracle. The 5-minute expiry check narrows a settle-versus-reclaim race without removing it; the default server remains loopback-only for these reasons.
Try it
git clone https://github.com/ashnkumar/tollgate && cd tollgate
pnpm install
./scripts/walkthrough.sh
Source: https://github.com/ashnkumar/tollgate