# A signature says who, not what

> A signed transaction feels final. But a signature proves who approved something valid — not that the exact thing executing is the thing they approved. Here is what DeFiLoops puts in that gap: a content receipt, and a simulation that judges what a transaction actually does.

- Source: https://defiloops.com/blog/a-signature-says-who-not-what
- Published: 2026-09-15
- Category: Security
- Tags: security, agentic-wallet, signatures, simulation, execution
- Author: DeFiLoops

---
A signed transaction feels like the end of the story. Someone with authority approved it,
the signature verifies, the chain will honour it. Done.

But there is a gap between *approved* and *executed*, and it is wider than it looks. Between
the moment an agent signs and the moment a block includes the transaction, a route gets
chosen, calldata gets built, and minutes pass. A signature is a statement about **who**
approved — it says almost nothing about **what** finally runs. Closing that gap is most of
the security work, and none of it is the signature.

## What the agent actually signs

Our agent never signs a raw transaction. It signs a **typed intent** — an amount, a venue
named from a list, a range — and the adapter builds the call from those parameters. That is
the whole reason a compromised agent cannot aim a call at an attacker: it was never handed a
target to aim ([we wrote about that here](/blog/the-executor-that-trusted-itself)).

But typed or not, a signature covers only the fields it hashes. And an EIP-712 message hashes
a *subset*.

<Callout type="danger" title="A signature is a statement about a class, not an instance">
  On a single collateralize intent, **twenty-one of twenty-six optional fields never reach the
  chain.** The signature recovers to the agent perfectly — it simply never covered them. So a
  valid signature proves the agent approved *something in a large family of valid intents*. It
  does not prove which one. Validity is a property of the family; identity is not.
</Callout>

This is the crack. Anything that can change one of those uncovered fields between signing and
executing changes *what happens* while leaving *every signature verifying*. Re-checking that
the intent is still valid does not help — the changed intent is also valid. Both checks agree,
about different intents.

## Two approvals, minutes apart

An adapter plan is authorised twice, deliberately, because the calldata does not exist at the
first approval.

<Steps>
  <Step title="Sign the group, and mint a receipt">
    The signer validates every step, signs each one's EIP-712 message, and mints a **validation
    receipt** — a statement, signed by the same key, that says: *I validated intents with
    exactly these content hashes, under exactly these limits, for this account on this chain.*
    The content hash covers **every** field, not the signed subset.
  </Step>
  <Step title="Build the route the plan could not know yet">
    Only now does routing pick the swap's pools and assemble the real calldata. This is the
    entire reason there is a second approval — the bytes did not exist a moment ago.
  </Step>
  <Step title="Authorise the exact bytes, and check the receipt">
    The second hop recomputes the content hash of what it is holding and checks it against the
    receipt. Same intent, bit for bit — including every field the chain will never see. If a
    single uncovered field changed, the hashes disagree and it refuses.
  </Step>
</Steps>

The receipt does one precise thing: it replaces *"is this valid?"* — which the first hop
already answered and the second cannot re-answer usefully — with *"is this the same?"*, which
is the question that actually matters. Everything the second hop can only learn at the second
hop, like whether the route filled well, it still learns there. Freshness is never trusted from
the receipt; a receipt minted at 12:00 says nothing about 12:20, so timing is re-checked
regardless.

<Callout type="warn" title="Why the receipt is signed, not stamped">
  It is signed by the agent's own key, domain-separated by a fixed tag so a receipt digest can
  never collide with a money-moving EIP-712 message. Not an HMAC — that would mean a shared
  secret to distribute, rotate and eventually leak, and inside a secure enclave, a secret that
  has to get *in*. The key is already there. The cost is one extra signature per group, not
  per step.
</Callout>

## The check that looks at what a transaction *does*

Everything above proves the transaction is the one that was approved. It does not prove the
transaction is *safe* — a correctly-signed, correctly-matched intent can still route through a
pool that fills terribly, or leave an allowance behind. So before anything is broadcast, we
run it.

The simulator executes the transaction against a real, recent block **without broadcasting** —
`debug_traceCall` — and reports every call, log and state change. Then one rule judges the
result. It is the only rule in the system that looks at what a transaction *does* rather than
what was asked for.

<Spec title="What the simulation refuses before broadcast" rows={[
  ['A net drain the plan did not declare', 'The account may only lose what the plan says it loses. Anything beyond that, refused.'],
  ['Any leftover allowance', 'Every (owner, spender) allowance must end at zero. A standing allowance is the most valuable thing an attacker can leave behind — it outlives the transaction, and nothing else looks for it.'],
  ['An output below the floor', 'What the plan buys must arrive at or above the floor the plan already signed. A badly filled route is refused here — cheaply, before gas — instead of reverting on chain.'],
  ['An NFT that no step moved', 'No position or token moves unless a step in the plan moves it. A Uniswap position is an NFT, and its id is a number that has been mistaken for an amount before.'],
]} />

The trick that makes this possible is that the simulation returns the *real* numbers, so it
needs no price oracle — it compares against the floor the plan already carries. And a reverting
call is not an error to route around; it is the finding. If any call in the bundle reverts,
none of the transaction's effects persist, and refusing before broadcast turns a wasted-gas
on-chain revert into a cheap, legible *no*.

<Callout type="warn" title="What the simulation is not">
  It is not a replacement for the chain's own checks. A simulation is a prediction at one
  block; the transaction lands at a later one, so `minOut` is still signed and the adapter still
  enforces it. This is a cheaper, earlier, more legible refusal — not a new source of truth. And
  it is not a judgement about price: a route that fills poorly but above the floor passes.
  Bounding *that* is a question about how wide a floor may be, not about simulation.
</Callout>

## Everything in the gap fails closed

Line these up and the shape is the same at every step: the safe answer is *no*, and it is the
default.

<StatRow>
  <Stat value="who" label="a signature proves" />
  <Stat value="what" label="the receipt + simulation prove" accent />
  <Stat value="no" label="the answer when any of it disagrees" />
</StatRow>

An unbound guard refuses. An absent receipt refuses. A content hash that does not match
refuses. A simulation that does not match the plan refuses — before a single byte is broadcast.
None of these is the signature. The signature was never the hard part; it only ever proved that
*someone* approved *something*. The pipeline exists to prove it is *this* something, and that
*this* something does what the plan said.

## How to evaluate any of this

You do not need to trust this page. The question that separates a real execution pipeline from
a signature and a prayer is short: **between the moment it is approved and the moment it lands,
what is checked?**

If the answer is "the signature verifies," then every field a signature does not cover is
open, and whatever builds the calldata is trusted completely. If the answer names a *content
receipt* and a *simulation of effects*, then the two things a signature cannot do — prove
sameness, and judge what actually happens — are being done by something that can.