The offer is the name: ENSv2 records and a key split the chain enforces
Deep DiveAI AgentsENSSmart ContractsSecurity

The offer is the name: ENSv2 records and a key split the chain enforces

How Turnstile publishes a seller's price, payment rails and endpoint as ENSIP-25/26 resolver records, lets a hot key change only the price and the MCP endpoint using ENS's Enhanced Access Control, and uses fork tests to prove that key can't redirect the payout. The split limits what each key can do.

Ignacio Pastor Sanchez
October 9, 2026
14 min read

TL;DR

We built Turnstile, a marketplace where AI agents sell answers and other agents pay for them. One seller, liquidity.turnstile.eth, lives on Sepolia. We wanted the things a buyer needs before paying (price, accepted rails, where to connect, where money lands) to live in one place nobody could quietly edit behind our backs. That place ended up being the ENS name itself.

  • Our own registry, plugged into ENS. In ENSv2 every name can have a registry for the names under it. turnstile.eth points to ours, so any ENS client resolves liquidity.turnstile.eth as usual. The registry, which records who owns each name, is an unmodified copy of ENS's own UserRegistry. The registrar, which sells the names, is ours: ENS ships no registrar for subnames, so we wrote one.
  • The resolver record is the offer. Standard keys first: ENSIP-26 agent-context and agent-endpoint[mcp], ENSIP-25 agent-registration[...]. Then a few turnstile: keys for price, ceiling and rails. The payout is the name's plain addr record.
  • Two keys, scoped per record. With ENSv2 Enhanced Access Control on ENS's PermissionedResolver, the hot key can write and modify exactly two records: the price and the MCP endpoint. Its setAddr call to redirect the payout reverts with EACUnauthorizedAccountRoles.
  • Where the keys live is a separate choice. The resolver grants roles to addresses, so the operator role can belong to a hardware wallet, a Safe multisig or a policy-controlled wallet. The last section compares them.

Part 5 of 9 in the Turnstile series: building a paid lane for onchain data agents.

197 agents, no standard place for a price

Turnstile's discovery side indexes ERC-8004 agent registrations across Base, mainnet and Sepolia (check 197 ERC-8004 agents, and where their prices live for details). When we measured a sample of 197 registrations, two published a price any buyer's agent could read: ours, and one agent that quotes a price in its x402 response. Another 76, all Virtuals agents, list prices in the jobOfferings field defined by ACP, Virtuals' Agent Commerce Protocol. Those prices name no currency, so a buyer can't compare them with a dollar price. ERC-8004's registration file has no price field, so there is no standard place to put the number.

A price is not enough on its own. A buyer's agent is about to send money. It needs to know:

  1. What the service is, and where to connect.
  2. What it costs and which payment rails the seller accepts.
  3. Where the money goes.
  4. That none of those were changed by someone who should not be able to change them.

Point 4 is where a web page or a JSON file falls short. If the offer lives on our server, anyone who compromises our server changes the offer. If it lives in resolver records, the contract decides who may write each record, and anyone can read those permissions onchain. Someone who steals the key we use day to day can change the price and the endpoint, but not where the money goes.

Step 1: our own registry, plugged into ENS

ENSv2 changes the shape of names. Instead of one big registry, every name can have its own registry, and registries point at their parent. liquidity.turnstile.eth hangs off this chain on Sepolia:

flowchart TD
  subgraph ENS["Deployed by ENS"]
    R["RootRegistry<br/>0x11b5…f50c"] -->|"eth"| E["ETHRegistry<br/>0x67b7…4b43"]
  end
  subgraph T["Deployed by us"]
    TR["TurnstileRegistry<br/>unmodified UserRegistry<br/>0x1096…2d24"]
    REG["TurnstileRegistrar<br/>sells and renews names"]
    N["liquidity.turnstile.eth"]
  end
  E -->|"turnstile"| TR
  TR -->|"liquidity"| N
  REG -.->|"mints into"| TR

We did not write a registry. TurnstileRegistry is a small library that deploys an instance of ENS's own UserRegistry through ENS's VerifiableFactory. The reason is verification: VerifiableFactory.verifyContract(proxy) returns the implementation behind a proxy, so a buyer's agent can confirm the registry (and later the resolver) is unmodified ENS code and not a look-alike that behaves honestly until it doesn't.

The registrar is the contract that sells names. It sets the price, takes the payment and mints the name in the registry. ENS ships none for subnames: the ENSv2 docs mention a SimpleSubnameRegistrar to model one on, but ensdomains/contracts-v2 doesn't contain it, and its src/registrar/ only holds the registrars for .eth names. So we wrote TurnstileRegistrar from scratch, against ENS's IPermissionedRegistry interface. It holds exactly two roles, both granted across the whole registry:

/// Mint and renew, and nothing else. In particular **not** `ROLE_UNREGISTER`:
/// the contract that takes buyers' money must not be able to delete the names
/// it sold, and not `ROLE_UPGRADE` or `ROLE_SET_PARENT`, which would let it
/// widen its own authority.
uint256 internal constant REGISTRAR_ROOT_ROLES =
    RegistryRolesLib.ROLE_REGISTRAR | RegistryRolesLib.ROLE_RENEW;

contracts/src/TurnstileRegistry.sol (comment trimmed)

The registrar can sell and renew names, but it can't delete them or give itself more roles. The hot key in Step 3 follows the same rule: the key that acts every day can't widen its own permissions.

Plugging it into ENS takes two calls. The registry declares its parent with setParent, and the owner of turnstile.eth on ENS's .eth registry points the label at the registry with setSubregistry.

(IRegistry currentParent, string memory currentLabel) = registry.getParent();
if (address(currentParent) != address(ethRegistry) || /* label differs */) {
    registry.setParent(IRegistry(address(ethRegistry)), cfg.parentLabel);
}

if (address(ethRegistry.getSubregistry(cfg.parentLabel)) == address(registry)) {
    console2.log("subregistry        already linked");
    return;
}
if (parentOwner != cfg.deployer) {
    console2.log("subregistry        SKIPPED - we do not own the parent name");
    return;
}
ethRegistry.setSubregistry(parentTokenId, IRegistry(address(registry)));

contracts/script/Deploy.s.sol (_linkIntoHierarchy, trimmed)

Turnstile holds no ROLE_REGISTRAR on ENS's .eth registry, so the parent name had to come through the paid ETHRegistrar flow. Once linked, the address file records linkedIntoHierarchy: true. Every ENSv2 registry stores its parent and the label it sits under, and getParent() returns both. Ask our registry and it answers with ENS's .eth registry and turnstile. Ask the .eth registry and it answers with the root registry and eth. The root has no parent, so the walk stops there, and the labels collected on the way up spell turnstile.eth.

That walk is also how names avoid being hard-coded. A namehash constant goes stale silently the day the registry is re-parented: it still resolves, to a name nobody owns. TurnstileName.sol derives the full name, its DNS encoding and its namehash by walking getParent() to the root at run time. The only input is the label, liquidity.

RootRegistry  0x11b5…f50c
  └── "eth" ──> ETHRegistry  0x67b7…4b43        (ENS's)
        └── "turnstile" ──> TurnstileRegistry   (ours, 0x1096…2d24)
              └── "liquidity" ──> liquidity.turnstile.eth

Step 2: the resolver record is the offer

The seller name points at an ENS PermissionedResolver, also deployed through VerifiableFactory. The operator key writes the offer in two transactions. The first is the resolver's multicall, which runs a list of calls in one go: here, seven setText(node, key, value) calls, one for each text record below.

The second is setAddr(node, 60, payout), which sets the payout address. An ENS name can store a different address for each chain, and each chain has a number (SLIP-44). The 60 says the address is for Ethereum.

Key Standard Live value Writable by
agent-context ENSIP-26 service description operator key
agent-endpoint[mcp] ENSIP-26 https://turnstile.moveseventyeight.com/liquidity.turnstile.eth/sse hot key
agent-registration[0x0001000003aa36a7148004a8…bd9e][10127] ENSIP-25 1 operator key
turnstile:price ours 0.07 hot key
turnstile:price-ceiling ours 0.50 operator key
turnstile:rails ours x402,usdc-arc operator key
turnstile:operator-proof ours operator-key-role-scoped operator key
addr(60) ENSIP-1 the payout address operator key

Standard keys come first on purpose. ENSIP-26 defines agent-context as a description of the agent and how to interact with it, and agent-endpoint[<protocol>] as a URL for a given protocol such as MCP or A2A. An agent that has never heard of Turnstile can read those two and know what this is and where to connect.

ENSIP-25 handles the link between the name and an ERC-8004 agent registration. The key is agent-registration[<registry>][<agentId>], where <registry> is the ERC-7930 interoperable address of the registry. A non-empty value, conventionally 1, is the name owner attesting to that agent. Decoded, ours reads: version 1, chain type eip155, a 3-byte chain reference 0xaa36a7 (11155111, Sepolia), then the 20-byte IdentityRegistry address, agent 10127. The key is long and easy to get wrong. If our encoder made a mistake, the record would still save, and our own reader, using the same encoder, would still find it. Every other ENSIP-25 app would look under the correct key and find nothing. So we test the encoder against the example key in the ENSIP itself.

The link runs both ways. The name carries the ENSIP-25 record, and on the ERC-8004 side tokenURI(10127) returns liquidity.turnstile.eth. Turnstile's seller page marks the link as verified only when both directions agree.

The turnstile: keys cover what no standard defines yet. turnstile:price is what one answer costs, in US dollars. turnstile:price-ceiling is the most the seller may charge for any answer. Only the operator key can change it, so the hot key can reprice freely below it but never above. turnstile:rails lists the payment rails the seller settles on. x402 covers the rails that take standard x402 payments, Hedera (x402 on Hedera with Blocky402) and Base (One payment interface, four chains). usdc-arc is Circle's rail on Arc (gasless nanopayments on Arc).

The payout is the name's addr record and not a turnstile:payout text key. It is the one field every ENS client already reads as "send funds here".

Step 3: scoping a hot key to two records

A seller can make two kinds of changes.

  • Frequent: reprice, move the endpoint when the host changes.
  • Rare and dangerous: change the payout address, raise the price ceiling, rotate the day-to-day key.

If one key does both, a leaked key that reprices can also redirect every payment. So there are two keys. The operator key holds the root roles, which cover every record on every name in the resolver. The hot key runs the frequent changes and nothing else.

ENSv2's Enhanced Access Control does this without us writing an access-control contract. Every permission is a role held on a resource, which is an ID for the thing the role covers. Resource 0 is the root and covers everything in the contract. Each role has an admin role, and only holders of the admin role can grant it. The Permissioned Resolver makes a resource ID for each record by hashing the name together with the record's key, so a role on turnstile:price for liquidity.turnstile.eth covers that one record and nothing else. The functions that grant these are authorizeTextRoles, authorizeDataRoles and authorizeAddrRoles.

A role can be granted at four levels, depending on whether it targets one name or all of them, and one record or all of them.

Any record One record
Any name everything in the resolver (the root) that record on every name
One name every record on that name that record on that name

Granted on liquidity.turnstile.eth alone, ROLE_SET_TEXT would let the holder write any of its text keys, the ceiling included. Granted on liquidity.turnstile.eth plus turnstile:price, it covers the price and nothing else. The "one record" column exists only for text records (per key), data records (per key) and addresses (per chain). The resolver's other writes, such as the content hash, can only be granted per name or at the root. That's enough for us: the price and the endpoint are text records, and the payout is an address.

The grant comes from one list:

/// The exact text keys the hot key is authorized to write.
/// Single source of truth. The deploy script grants from this list and
/// the tests assert against it.
function hotKeyTextKeys() internal pure returns (string[] memory keys) {
    keys = new string[](2);
    keys[0] = agentEndpointKey(PROTOCOL_MCP);
    keys[1] = KEY_PRICE;
}

function authorizeHotKey(
    PermissionedResolver resolver,
    bytes memory dnsName,
    address hotKey,
    bool grant
) internal {
    string[] memory keys = hotKeyTextKeys();
    for (uint256 i; i < keys.length; ++i) {
        resolver.authorizeTextRoles(dnsName, keys[i], hotKey, grant);
    }
}

contracts/src/TurnstileOffer.sol (comments trimmed)

That is the whole grant. The hot key holds ROLE_SET_TEXT on two part resources, no ROLE_SET_ADDR anywhere, and no ROLE_SET_TEXT_ADMIN, so it cannot grant itself more permissions. Rotation is the same function called twice by the operator key: grant = false for the old hot key, grant = true for the new one. We ran a full rotation on Sepolia and rotated back, and the old key went inert immediately.

One trap: PermissionedResolver switches off the usual grantRoles and revokeRoles (they always revert), so every grant goes through one of its four authorize functions (authorizeNameRoles, authorizeTextRoles, authorizeDataRoles, authorizeAddrRoles). The tempting shortcut, grantRootRoles(ROLE_SET_TEXT, hot), still works, and it would have given the hot key permissions over every text record on every name.

The resolver doesn't enforce the price ceiling itself. It controls who may write each record, but it never compares the two values, so nothing onchain stops the hot key from writing a price of 5 under a ceiling of 0.50. The check happens when someone reads the records. Our seller rejects a price above the ceiling and keeps charging the last good one, and a buyer's agent can read both records and compare them the same way.

Step 4: testing the live deployment

We test the role split at two levels. Local tests compile ENS's real PermissionedResolver and check the design: which roles the deploy script grants, and what each key can call. The roles on Sepolia, though, are state on a deployed contract, and any transaction from the operator key can change them. So a second set of tests runs against the live contracts.

These are fork tests. Foundry's createSelectFork copies Sepolia's current state into a local test chain, so each test calls the deployed resolver as it is right now, and nothing it does reaches the real network. The addresses, the label and the agent id all come from addresses.turnstile.sepolia.json, the file the deploy scripts write. Two tests carry the main point: the hot key can't change the payout, and it can still change the price.

/// The invariant, against the live deployment. The hot key cannot
/// redirect payment on the contract that is actually deployed.
function testFork_hotKeyCannotChangeThePayoutOnChain() public live {
    uint256 nameResource = PermissionedResolverLib.resource(node, bytes32(0));

    vm.prank(hot);
    vm.expectRevert(
        abi.encodeWithSelector(
            IEnhancedAccessControl.EACUnauthorizedAccountRoles.selector,
            nameResource,
            PermissionedResolverLib.ROLE_SET_ADDR,
            hot
        )
    );
    resolver.setAddr(node, makeAddr("attacker"));

    assertEq(resolver.addr(node), payable(payout), "payout is untouched");
}

/// And it can still do its job.
function testFork_hotKeyCanStillReprice() public live {
    vm.prank(hot);
    resolver.setText(node, TurnstileOffer.KEY_PRICE, "0.11");
    assertEq(resolver.text(node, TurnstileOffer.KEY_PRICE), "0.11");
}

contracts/test/fork/SepoliaOffer.t.sol

The first test expects one specific error. EACUnauthorizedAccountRoles names the resource that was checked, the missing role (1, ROLE_SET_ADDR) and the account (the hot key). The resource is the name-level one, (node, 0): the hot key has no permission on the payout record, so the check falls back to the whole name and fails there. A third test checks that the hot key holds ROLE_SET_TEXT on exactly the two records and nowhere wider.

Another file, SepoliaEnsV2.t.sol, watches ENS itself. ENS has archived and redeployed its Sepolia contracts once already, so this test checks that the root registry still points eth at the .eth registry we built on.

Anyone can run the same check without Foundry. Calling setAddr from the hot key's address against Sepolia gives the same error:

$ cast call $R "setAddr(bytes32,address)" $N 0x…dEaD \
    --from 0x16244874881c60881CEA827fe856Ea91d3186367
Error: execution reverted: EACUnauthorizedAccountRoles(
  87140393317148622081856579921496229094570723339484417348903196786160147430199, 1,
  0x16244874881c60881CEA827fe856Ea91d3186367)

Terminal output against the live resolver

On the live chain, the operator key set the payout when we published the offer (tx 0x922e…2fe4). When the endpoint had to move off a domain we didn't own, the hot key changed it on its own (tx 0x6736…8c66), and the payout and price stayed the same.

A leaked hot key can change the price until the operator key rotates it, and our seller won't charge more than the ceiling. It can't redirect a payment.

Step 5: from the resolver to the seller page

The seller page shows the offer straight from the chain. It's a Next.js route marked dynamic = 'force-dynamic', so the server renders it on every request instead of once at deploy time. When the hot key changes the price, the next visitor sees the new one. Each render reads the records from the resolver, one text(node, key) call per record plus the payout address, and the page shows the block number it read at.

The reading code is shared. readSellerOffer in graph/sink/ens.ts is the same function the discovery index and the buyer's MCP server use, so the market, the buyer and the seller page all see the same offer. The page wraps it in readOffer:

/** Read one seller's records off chain. Never falls back to cached values. */
export async function readOffer(ensName: string): Promise<LiveOfferResult> {
  const seller = findSeller(ensName);
  // ...
  const url = rpcUrl();
  if (!url) {
    return {
      ok: false,
      ensName: seller.ensName,
      reason:
        'SEPOLIA_RPC_URL is not set on this deployment, so the resolver cannot be read. The records are not shown rather than served from cache ...',
    };
  }
  const client = makePublicClient(url);
  offer = await readSellerOffer(client, { ensName: seller.ensName, resolver: seller.resolver, /* ... */ });
  const backlink = await readAgentBacklink(client, seller.identityRegistry as Address, seller.agentId);
  const linked =
    offer.agentRegistrationConfirmed &&
    (backlink.tokenUri ?? '').toLowerCase() === seller.ensName.toLowerCase();

web/lib/ens.ts (trimmed, including the end of the error string)

Before the page shows the price as something a buyer can act on, it runs two checks. The first is the two-way ENSIP-25 link from Step 2: the name points to agent 10127, and tokenURI(10127) points back to the name. The second asks VerifiableFactory.verifyContract which code runs behind the resolver and expects ENS's unmodified PermissionedResolver. A name can claim any agent id, and any contract can call itself a resolver, so the page checks each claim from the other side and shows the result as a pass or fail card.

The last link is the endpoint. agent-endpoint[mcp] points at the seller's MCP server, which answers over HTTP/SSE, so a client that follows the record reaches initialize and tools/list and finds the four tools.

What the key split covers

Turnstile uses three tiers of keys. On the buyer side, the warm tier is a Privy org wallet (spending mandates with key quorums) and the hot tier is an agent that signs offchain and never submits a transaction. On the seller side, the operator key holds every role that matters, and the hot key holds two records.

Only the operator key can change where the money goes. The resolver checks this on every write: when the hot key tries, the call reverts on Sepolia. This holds whatever device or service stores each key.

Because the roles belong to an address, the operator can be any account that has one. The simplest is a hardware wallet: the key never leaves the device, and every role change needs a button press. A Safe multisig goes further. The operator becomes a contract that acts only when, say, two of three owners sign, so one stolen key moves nothing. A third option is a policy wallet, such as a Privy wallet controlled by a key quorum, which signs only requests that match rules you set. Part 7 uses that setup for buyers.

The split protects against a leaked hot key. Protecting the operator key is the custody choice above, and the chain doesn't see which one you made. The turnstile:operator-proof record states the property the chain does enforce: operator-key-role-scoped.

Sources


Next in the series: Three listings per human: capping an open agent registry with World ID Selfie Check, part 6 of 9.