Three layers of calldata validation, and the one that's load-bearing

2026-05-12 · Engineering / Security calldata defense-in-depth

Introduced: pre-existing during the v0.11.66 Velora integration.   Detected + fixed: 2026-04-30 (v0.11.71, security retro 3-adversary review).   Disclosed: 2026-05-12 (this post).

Server v0.11.71 (shipped 2026-04-30) added three layers of calldata validation on every /v1/quote response, plus an unconditional deny-list of seven token-approval and Permit2 selectors. The headline: layer 3 binds layer 2 to the swap implementation contract. AllowanceHolder.exec — the only selector the outer allowlist permits for 0x — is intentionally designed as a generic entry point that dispatches to a separate implementation contract. Whoever sets the inner target argument controls what gets called. The load-bearing job for an integrator is validating that target equals the canonical swap implementation, not just allow-listing the outer selector.

This post walks through what the three layers do, where each one's protection starts and stops, and the security-review framing that brought the inner-target validation into the explicit defense pattern. If you're integrating any swap router into an agent runtime, the dispatcher pattern is worth understanding regardless of which service you use — it's standard architecture in modern aggregator designs.

Scope bound: no customer report, external incident, or forensic evidence has tied funds loss to the pre-v0.11.71 calldata-validation surface. 0x AllowanceHolder behaved as documented in production; the defense-in-depth shortfall was theoretical — it would have mattered if a hostile upstream had returned crafted calldata, which did not happen. v0.11.71 closed the theoretical path, and disclosure is being routed through this post and the public CHANGELOG so the change is on the record.

The pre-v0.11.71 state: one layer, partial cover

Before v0.11.71, the only layer of calldata validation on /v1/quote was an outer router-address whitelist. For each upstream aggregator, the response's router field had to be a known address — for 0x on Base, the AllowanceHolder contract 0x0000000000001fF3684f28c67538d4D072C22734; for Velora, the AugustusSwapper v6.2 contract 0x6A000F20005980200259B80c5102003040001068.

That layer catches one specific attack: an upstream returns a router field pointing at an attacker-controlled contract, hoping the agent signs and submits without checking. The whitelist refuses to forward those routes. Useful, but narrow — it does nothing about what the calldata does inside the whitelisted router.

Layer 2: outer selector allowlist

The first 4 bytes of an Ethereum calldata blob are the function selector. v0.11.71 added a per-source allowlist of permitted selectors:

An aggregator response that uses any other selector is rejected at the router-service layer; /v1/quote returns HTTP 502 rather than forwarding suspect calldata to the agent. This layer catches a broader class of malformation: an upstream that returns the correct router address but encodes a function call that wouldn't perform a swap.

The dispatcher pattern

Here's the part that's worth the most attention. The 0x allowlist contains one selector, AllowanceHolder.exec. Its function signature (selector 0x2213bc0b — verifiable by computing keccak256("exec(address,address,uint256,address,bytes)")[:4]) is:

function exec(
    address operator,
    address token,
    uint256 amount,
    address target,
    bytes calldata data
) external payable returns (bytes memory result);

Inside exec, the contract uses the (operator, token, amount) arguments to authorize a token pull from the caller, then calls target.call(data). Whatever address is passed as target gets invoked with whatever bytes are passed as data. This is by design: AllowanceHolder.exec is not a function that performs a swap. It's the stable outer entry point of a deliberate two-contract architecture — AllowanceHolder dispatches to a separate implementation contract (0x Settler) that the protocol can rotate independently.

The dispatcher pattern is intentional in modern aggregator designs — it lets the entry point stay stable while implementation contracts iterate. The integrator's job is to validate the inner target. Without that, the outer selector allowlist is doing this: "allow any calldata as long as it's wrapped in a call to AllowanceHolder.exec." A hostile upstream could wrap an approve(attacker, MAX_UINT) or a transfer inside an exec envelope, and outer-only validation would forward it. The outer router-address whitelist would also forward it, because the router itself is AllowanceHolder — the substitution lives in the target argument inside the payload, not in the outer envelope.

Layer 2 on its own is necessary but not sufficient against the dispatcher pattern — which is why v0.11.71 adds layer 3 directly underneath it.

Layer 3: inner-target decode + Settler validation

v0.11.71's third layer closes this. For any 0x response, the router service decodes the outer exec calldata, extracts the target argument from byte offset 96-127 of the encoded arguments, and validates it equals the canonical 0x Settler address on Base:

0x7747F8D2a76BD6345Cc29622a946A929647F2359

Settler is the contract 0x actually performs swaps in. AllowanceHolder is the entry point that dispatches to Settler. Validating the inner target equals Settler means: the response is allowed to be a call to AllowanceHolder dispatching to Settler; it is not allowed to be a call to AllowanceHolder dispatching to anything else. Layer 3 binds layer 2 to the swap-implementation contract: AllowanceHolder must dispatch to Settler, and only to Settler.

For Velora the inner-target decode isn't needed: AugustusSwapper's swap selectors are the swap implementation, not a dispatcher. Layer 2's selector check is the full validation for that source.

Settler upgrade path. The Settler address pinned in production today is 0x7747F8D2a76BD6345Cc29622a946A929647F2359 on Base. If 0x publishes a new Settler deployment, PaladinFi pins the new address in source and notes the rotation in the CHANGELOG under a versioned entry. The CHANGELOG is the canonical record of Settler-pin rotations; agents that need rotation resilience should treat a hardcoded Settler address as a soft pin and follow the CHANGELOG for updates.

Hard deny-list (unconditional)

v0.11.71 also added a deny-list of seven selectors that are blocked regardless of any other allowlist state — including in warn-only mode if selector enforcement were ever flipped back to it. These are the token-approval and Permit2 functions that an attacker would most want to slip through an agent's wallet:

A swap response containing one of these as the outer selector would mean "use the user's wallet to approve something or transfer something" instead of "execute a swap." Even if the rest of the validation logic were misconfigured or relaxed for debugging, these never pass.

The audit framing

v0.11.71 was the first version where security review used explicit audit framing alongside the existing code-review pass:

Treat as audit, not code review. Name funds-loss vectors explicitly.

A code reviewer verifies that the proposed change does what its description says. An audit reviewer asks: where in this code path is money that could be taken, and what could take it? Both questions get asked now — on every patch touching the swap surface.

In the v0.11.x patch window between Velora's integration (v0.11.66) and the dispatcher-target fix (v0.11.71), the selector allowlist looked correct against the code-review question and the production behavior of 0x AllowanceHolder. The dispatcher pattern only stood out as an explicit funds-loss path to validate when the reviewer started from "an attacker controls the upstream response — where can they route funds?" rather than "does this code do what the diff says?"

The lesson generalizes beyond PaladinFi. If you're reviewing any code path that signs or forwards user calldata, the framing you ask the reviewer to adopt is part of the review. "Does this code do what it claims?" and "where could an attacker drain funds through this code?" are different questions with different answers.

What this commits PaladinFi to

The selector_enforcement block in /health surfaces the current mode (enforce in production), per-source allowlist_sizes (0x: 1, Velora: 11), deny_list_size (7), a degraded flag, and rejected_total counters per source. If the mode ever flipped to warn-only, it would be observable in /health output within seconds. The deny-list is permanent and unconditional.

Adding a third aggregator in the future means defining its allowlist with the same discipline: enumerate the swap selectors; if any of them is a dispatcher, decode the inner argument and validate it equals the canonical implementation contract. The dispatcher pattern is standard in modern aggregators — routing optimizations frequently push the actual swap to a separate contract called by an outer entry point. Each new source gets its own layer-3 equivalent.

Verify any claim above

The live selector_enforcement block on /health is the load-bearing surface for everything in this post:

curl -s https://swap.paladinfi.com/health | jq .selector_enforcement
# expected:
#   {
#     "mode": "enforce",
#     "degraded": false,
#     "allowlist_sizes": { "0x": 1, "velora": 11 },
#     "deny_list_size": 7,
#     "rejected_total": { "0x": 0, "velora": 0 }
#   }

Other verification surfaces:

Server version at /health reads 0.11.74 today (the dispatcher-validation surface has been in production continuously since v0.11.71 deployed 2026-04-30; v0.11.73 added the trust-block fail-closed contract and v0.11.74 added MCP tool annotations, neither of which changed the calldata defense).

The /health endpoint, the GitHub org, and the swap product page are the surfaces that should agree with this post. If any of them drift, the post is wrong, not them.