Three layers of calldata validation, and the one that's load-bearing
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:
- 0x: exactly one entry —
0x2213bc0b(the selector forAllowanceHolder.exec; full signature in the next section). - Velora: 11 entries — the swap selectors of AugustusSwapper v6.2 on Base (
swapExactAmountIn,swapExactAmountOut, multi-path variants, and the wrap/unwrap helpers).
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.
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:
- ERC-20
approve - ERC-20
transferFrom(same selector as Permit2 AllowanceTransfer'stransferFrom) - ERC-20
transfer - Permit2
permitTransferFrom(single) - Permit2
permitTransferFrom(batch) - Permit2
permit(single) - Permit2
permit(batch)
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:
- CHANGELOG.md — public-API-affecting changes including the v0.11.71 entry.
- github.com/paladinfi/paladin-swap-mcp + the openapi.yaml spec — open client.
- 0x Settler reference deploy on Base:
0x7747F8D2a76BD6345Cc29622a946A929647F2359. AllowanceHolder:0x0000000000001fF3684f28c67538d4D072C22734. Velora AugustusSwapper v6.2:0x6A000F20005980200259B80c5102003040001068. All independently verifiable on Basescan. - Selector verification:
keccak256("exec(address,address,uint256,address,bytes)")[:4] = 0x2213bc0b. Any language with a Keccak-256 primitive can confirm.
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.