
Batas
An agent runs your 1inch Aqua position under limits it cannot widen and a name you can revoke.
Demo
About
1inch Aqua is a shared liquidity layer where the maker never deposits anything. Tokens stay in their own wallet, Aqua keeps a ledger of allowances, and it pulls directly from that wallet at settlement. That removes custody risk and concentrates a different one, because Aqua.pull() checks nothing beyond msg.sender. Whichever app the maker ships to may take their tokens. The entire security model is one sentence: you trust the app you ship to. Hand that app to an autonomous agent and the question gets sharp — what stops it, and where is the stop enforced? Most agent tooling answers "in the application layer", which means nowhere, because anyone can call the contract directly and skip your checks. Batas answers with a mandate: a size cap, a floor price, and an expiry. The mandate IS the Aqua strategy. Aqua.ship() hashes the strategy bytes you hand it, and Batas passes the encoded mandate as those bytes, so the mandate hash and the strategy hash are the same value. The terms are not metadata attached to the position — they are its identity. An attacker can compile a program with the limits removed, but that is a different strategy hash, and the maker never shipped a token to it. The limits are checked in the two places that gate the money. BatasApp validates them before AQUA.pull(), because after pull() the tokens have already left. And PolicyEnvelope, a new SwapVM instruction at third-party opcode slot 0x21, wraps the rest of the program: it delegates to runLoop() and inspects the settled registers when that returns. Placed first it is the outermost frame, so a fee appended behind the curve cannot push the amounts back out of bounds after the guard has passed, and in exactOut mode — where the input is only final once the curve has run — placement stops being something a program author can silently get wrong. It costs 975 gas. What that is worth, measured rather than asserted: the same attacker against the same reserves, twice. Under a mandate, two trades at an average rate of 1.662 and 83% of the position still there. With no mandate, sixty-four trades at 0.270 — a seventh of where the pool opened — and it is gutted. Every one of those was an ordinary constant-product swap that no application-layer check was there to stop. A second instruction at slot 0x22 makes the kill switch binding. The agent's authority is an ENSv2 subname: expiring, with the expiry compiled into the program's own deadline; revocable, because the grantor keeps ROLE_UNREGISTER; soulbound, because ROLE_CAN_TRANSFER_ADMIN is withheld. MandateName reads that registry during settlement, so revoking the name reverts the swap for every caller rather than only stopping an agent polite enough to ask. agent/killswitch.mjs --prove demonstrates it live: quote the position, revoke, quote again and get MandateNameNotHeld from a caller that has never heard of ENS, re-grant, and the position comes back at the same price. The agent reads the live position, derives its floor from measured price movement rather than a constant, compiles the SwapVM program itself, checks its bytes against the deployed router's hash before shipping, and publishes the grant to Hedera Consensus Service. Run with --watch it keeps running the position: it renews inside the expiry window, docks the mandate it replaces, and stops acting the moment the name is revoked without needing to be restarted by the person who just stopped it. A paid x402 endpoint on Hedera sells the one answer a counterparty cannot assemble alone: the decoded terms, the consensus timestamp on which those exact bytes became public, and the ERC-8004 identity behind the position with a check that it is held by the address that granted the mandate. Three of the four questions are free, deliberately — an agent should be able to refuse a position for nothing. agent/counterparty.mjs is that second agent: it finds the service through the host's own x402 discovery manifest, asks the free questions, walks away for free from a position it does not like, and pays a tenth of a cent only when a real doubt remains. Then it trades, and writes what it saw into ERC-8004's reputation registry — sixty basis points above the floor the mandate advertised. The registry refuses feedback from the agent's own owner, which is what makes that number worth reading. Everything is live on Sepolia and Hedera testnet, and the repository is held to it: a README suite walks the document and asks the chain about every address, transaction, topic and timestamp it points at, and a deployment check compares the bytecode on Sepolia against this repository's build byte for byte.
How it's made
Solidity 0.8.30 with Foundry, via_ir, pinned to forge 1.8.0. Contracts: a Mandate struct and library, BatasApp (an Aqua application), PolicyEnvelope and MandateName (two new SwapVM instructions), and BatasRouter, a redeployment of SwapVM carrying both. Nothing in node_modules/@1inch/** is edited; the 1inch track permits redeploying a modified SwapVM. Off chain it is Node 24 and viem, an Express service on Vercel Functions, and an MCP server over stdio. Two things about extending SwapVM that were learned the hard way. Extend AquaOpcodes, not Opcodes: the full set carries 24 instructions an Aqua strategy never reaches for, including every balance instruction, because in Aqua mode balances come from Aqua rather than from bytecode — carrying them puts the router at 28,618 bytes against EIP-170's 24,576 and it cannot be deployed at all. And not OpcodesDebug either, which overrides _runOpcode without re-declaring it virtual, so it is terminal: you can have custom opcodes or debug opcodes, not both. The hacky part worth mentioning is the guard's own argument handling. InstructionArgs.at is a raw calldataload and 1inch documents plainly that the library does no bounds validation. For a fee or a curve that is a fair trade, because a misparse produces a visibly wrong price. For an instruction whose job is to refuse it produces a guard that passes, which is silent by construction: a PolicyEnvelope declaring sixteen argument bytes instead of thirty-two reads its floor out of the next instruction's bytes, and one that is last in the program reads past order.data entirely. Both instructions check their length. That finding is written up in UPSTREAM.md as a documentation suggestion for swap-vm. A fuzz of 2000 runs compares the two enforcement surfaces on all five terms — it used to hold expiry and feeBps fixed, which excused the two most able to disagree, and unfixing them found a real one-second disagreement at the expiry boundary: SwapVM's Deadline is block.timestamp <= deadline and BatasApp was <, so for one second a mandate authorised a trade through the VM and refused it through the app. Hedera: x402 through the Blocky402 facilitator, priced in HBAR first because an HTS token must be associated with an account before it can be received. A caller already associated with the project's HTS token, Batas Inspection Credit, can pay in it instead, and that settlement assesses the token's fixed custom fee on the ledger; a 1% fractional fee was tried first and assessed nothing, because Hedera does not charge a fractional fee on a credit to the fee collector. Note that the official Hedera x402 proof of concept points its testnet config at x402.org and reaches for Blocky402 only on mainnet; Blocky402 does serve hedera:testnet, at api.testnet.blocky402.com, whose /supported sits at the root rather than under /v1. Copy the PoC as-is and you settle through the wrong facilitator with everything appearing to work. Grants and revocations are both published to a Hedera Consensus Service topic, readable by anyone with no account, because a ledger carrying only grants is the optimistic half of the story. ENSv2 on Sepolia, through a UserRegistry proxy deployed by ENS's own VerifiableFactory. The registry's token id is the labelhash with its low 32 bits cleared — those hold a version counter it bumps on re-registration — so a plain keccak256(label) asks about a token that does not exist, and the zero address that comes back reads as revoked rather than as a wrong question. The off-chain reader calls findTokenId instead; the on-chain instruction hands keccak256(label) to getState, which strips the version bits itself — and getState is where the previous router was wrong: its interface listed the registry's State struct in documentation order rather than declaration order (status, expiry, latestOwner, tokenId, resource), so every settlement against a named mandate reverted while every test passed, because the mock matched the interface. A fork test now asks the deployed registry, and the router was redeployed. Revocation also sets a name's expiry to the moment it happened rather than zeroing it, so a name the owner pulled and one that ran out are indistinguishable by timestamp; ownership is checked before expiry, because burning clears the owner and lapsing does not. ERC-8004 identity and reputation, both on Sepolia. Two addresses circulate for these registries and the ones in most write-ups hold code on mainnet only, reading as empty on Sepolia — which looks exactly like a correct address for an unregistered agent. The reputation registry needed the same care: the address that circulates begins 0x8004B663056e9e57 and the live one begins 0x8004B663056A597D. What settles it is that getIdentityRegistry() on it answers with the identity registry this project already uses. The registration is a data: URI rather than a hosted link, because the point of an identity registry is that the answer survives. The service is described in formats other software already reads, all generated from one route table: /openapi.json (OpenAPI 3.1, with the free/paid split as an x-cost per operation), /.well-known/agent-card.json (an A2A Agent Card), and POST /mcp (the same four tools over Streamable HTTP). With BATAS_ATTEST_KEY set, the paid answer carries an EIP-712 attestation over its own canonical JSON. GET /v1/position/health joins the live position to its mandate — headroom to the floor, every settlement since the ship, and alert codes — and the page shows it as a "This morning" panel. Two implementation notes on reading chains that cost real time. Aqua's Shipped event indexes nothing at all — maker, app, strategy hash and strategy bytes all sit in the data — so a node cannot filter it and every consumer sifts client-side. And ethereum-sepolia-rpc.publicnode.com fronts a pool whose backends do not all hold the same receipts: asked eight times for a transaction demonstrably on the canonical chain, it answered seven, which turned the README suite red with a false claim about the chain. Measured five calls each before switching to rpc.sepolia.ethpandaops.io, which was 5/5 up at 532ms against publicnode's 4/5 at 1680ms.