
Turnkey Earn lets an app offer its users yield from Morpho and Aave vaults, through a fee wrapper Turnkey deploys in front of each vault.
This post is a look at how we think about security when we build a product like this. One principle drove most of Earn's design: the service that builds a transaction should never be the one that decides where the funds go. The sections below follow where that principle led, and why we made each decision along the way.
In Quotes Are Contracts, a blog from Mohammad Cheikh, Turnkey’s Senior Product Engineer, we showed how Swaps hold up when Turnkey's own Coordinator is untrusted: the quote a user sees is frozen inside an enclave, and execution has to match it.
Earn has a different shape. There is no provider quote to freeze. The external object is a smart contract: a Morpho or Aave lending vault the developer chose, and a fee wrapper Turnkey deploys in front of it.
Throughout this article, we'll follow one deposit: A wallet app has listed a Morpho USDC vault on Base, and one of its users deposits 1,000 USDC.
The risk: A deposit that lands somewhere the user never approved
What is destination substitution?
Destination substitution means a valid signature on a deposit that sends funds somewhere the user never approved. An untrusted Coordinator could get one by pointing the deposit at a contract the user never chose, changing who receives the shares, or registering a vault that was never verified.
In Earn, the state worth protecting is the Organization plus its catalog of deployed wrappers.
Each catalog entry records the underlying vault, the chain, the wrapper address, the fee splitter, the underlying asset, and the fee split agreed at deploy time.
The Earn wrapper catalog is the attack surface. If the Coordinator could add an entry to it, or build a deposit transaction that disagrees with it, a user's approval would no longer pin down which wrapper Signer deposits into, or for whom. That gap is what destination substitution exploits.
Where a Coordinator-built deposit goes wrong
A first version of Earn could work like this. When the app's developer onboards the vault, the Coordinator deploys a wrapper and stores its address in a database.
When the user deposits, the Coordinator looks up the address and builds two calls: approve the wrapper to spend USDC, then deposit. The Coordinator asks the Policy Engine whether the EarnDeposit is allowed, then asks Signer to sign what it built.
With an honest Coordinator, nothing is wrong with this.
An untrusted Coordinator would have two ways in. The untrusted Coordinator could keep the shape of the transaction and change the contents: approve an attacker's contract, or make the attacker the receiver.
Or it could poison the catalog with a row whose "wrapper" is a contract that takes deposits and never returns them. Either way the deposit looks well formed, so the Policy Engine allows it and Signer signs it.
Both the forged catalog row (1) and the attacker as receiver (2) work for the same reason: the Coordinator writes the catalog and builds the transaction, so neither enclave has an independent record to check them against.
This is the design choice at the heart of Earn. Instead of asking the enclaves to judge whatever the Coordinator proposes, we split the problem into two questions and answer each at a different time:
- Is this vault real, and is this wrapper ours? Decided once, at deploy time, by trusted infrastructure, with the answer written into notarized Organization state, and checked against the live wrapper before every deposit.
- What exactly is this transaction? Decided on every deposit, withdrawal, and fee claim, by rebuilding it inside the Policy Engine from that state and the user's signed intent, and refusing to rule unless the Coordinator's version matches byte for byte.
Once per vault: Deploying the wrapper
The app's developer onboards the Morpho vault with EarnDeployWrapper.
The EarnDeployWrapper intent names the vault address, the chain, the developer's fee in basis points, and the Turnkey wallet that should receive that fee. Nothing a user can later deposit into exists without going through EarnDeployWrapper. Each vault and fee configuration gets its own wrapper, so changing a fee means deploying a new one.
Checking the Organization and fee wallet
When the developer's EarnDeployWrapper intent arrives, the Policy Engine checks it first. It validates the Organization against its trusted snapshot, confirms that the fee wallet is an account the Organization owns, and evaluates policy for the deploy before it issues a ruling.
Verifying the vault inside TLS Fetcher
Next, the Coordinator asks TLS Fetcher to verify the vault. TLS Fetcher is the only enclave that talks to the chain. It does so twice: here, to verify the vault at deploy, and again before every deposit, to read the live wrapper.
The RPC host, the Morpho vault factory, and the Aave pool addresses are pinned per chain inside the enclave.
The Coordinator supplies an encrypted RPC credential and DNS resolvers, and neither can change the answer: the enclave only accepts a TLS session with the pinned host.
For Morpho, TLS Fetcher asks the pinned factory whether it created the vault, then reads the vault's underlying asset. For Aave, TLS Fetcher reads the asset's reserve data from the pinned pool and checks that the reserve is active, not frozen, and not paused.
If the checks pass, TLS Fetcher signs a short-lived token with its quorum key. TLS Fetcher's vault token names the vault, the chain, the provider, and the underlying asset, and it expires after ten minutes.
What Signer checks before it deploys the wrapper
Signer receives the ruling, the vault token, the funded deployer account, and gas parameters.
Before signing a single byte, Signer checks:
- The ruling is signed by the Policy Engine, fresh, and an Allow for this deploy intent.
- The vault token is signed by TLS Fetcher's pinned key, unexpired, and names the same vault and chain as the intent.
- The chain in the intent matches the chain id the transaction will be signed for.
- The Turnkey fee the Coordinator proposes is under a cap compiled into the enclave, and so is the combined fee. The Turnkey fee wallet comes from the enclave's own configuration, not from the request.
- The deployer account is a Turnkey-operated wallet in a pinned Turnkey Organization. Signer re-derives its address from that Organization's seed before using it.
Then Signer builds the deployment. The deployer contracts, wrapper implementations, and creation bytecode are pinned inside the enclave.
The wrapper is created with CREATE2, which fixes a contract's address in advance from a few inputs, including a value called the salt. Signer builds that salt from the Organization, vault, chain, fee split, and a secret derived from the enclave's quorum key, so the Coordinator cannot predict the address and deploy its own contract there first.
A Morpho wrapper also permanently gives up the gates that could lock users out. At creation the Morpho wrapper abdicates the gates on receiving shares, sending shares, and receiving withdrawn assets, so they stay open forever.
The Morpho wrapper also abdicates adding or removing adapters, so it is locked to the vault it was created for. No later operator, Turnkey included, can lock a user out of withdrawing or transferring shares, or point the wrapper at a different vault.
Signer returns two things: the signed deployment transactions and a signed wrapper record. The wrapper record carries every fact the deposit path will later depend on, with the underlying asset taken from TLS Fetcher's token rather than from the Coordinator.
Recording the wrapper in notarized Organization state
The Coordinator hands the deployment to the broadcaster and, without waiting for it to confirm, asks the Notarizer to record the wrapper. The Notarizer verifies Signer's signature over the record and checks that the Organization, vault, and chain match the deploy intent. Then the Notarizer writes the entry into the Organization's notarized state.
The Notarizer's write is the only way an entry can get into that state. The generic feature-update path refuses to create or delete the Earn catalog. A Coordinator that inserts a row directly produces an Organization snapshot no enclave will accept.
| Recorded field | What it guarantees |
|---|---|
| Signer signature | Proves the record came from a verified deploy, not the Coordinator |
| Organization id | Ties the wrapper to one Organization's state |
| Vault and chain | Match the Fetcher-verified vault and the deploy intent |
| Wrapper and splitter addresses | The only contracts a deposit may ever approve or call |
| Underlying asset | The only token a deposit may approve, read from the chain inside the enclave |
| Provider | Selects the correct deposit, withdraw, and fee-claim calldata |
| Fee split and payout wallets | Fixed at deploy; changing a fee means deploying a new wrapper |
| Deposits-disabled flag | An incident brake that only the parent Organization can flip |
Every transaction: Rebuilding each deposit, withdrawal, and fee claim inside the Policy Engine
The first question is settled at deploy. The second one changes with every transaction, so it is answered every time. Deposits, withdrawals, and fee claims all take the same path.
The user deposits their 1,000 USDC. The user stamps an EarnDeposit intent naming the wrapper, their wallet, the amount, the chain, and whether gas is sponsored.
The user never signs raw transaction data, and they never name the asset or the receiver. The asset and the receiver follow from the wrapper record and the signing wallet.
The Coordinator builds the approve-and-deposit batch, exactly as the first version did, and sends it through the parser, which returns a signed digest of what it parsed.
Then the Coordinator requests a ruling. Inside the Policy Engine:
- The parser's signature is verified.
- The wrapper named in the intent is looked up in the notarized Organization state. The wrapper must exist, be on the chain the intent names, and have deposits enabled.
- The enclave derives the transaction itself: approve the recorded wrapper for the stamped amount on the recorded asset, then deposit that amount with the signing wallet as receiver. For self-paid deposits, a fixed gas limit is part of the derivation.
- The digest of that derived transaction must equal the parser's digest of the Coordinator's version. Any difference in approve target, receiver, amount, chain, or gas limit, and the ruling is refused.
- The signing wallet must belong to the Organization, which Signer re-checks when it loads the key, and policies are evaluated against the Earn intent.
The Coordinator's transaction is only a proposal.
The ruling the Policy Engine signs does not carry the Coordinator's transaction. The Policy Engine's ruling carries the derived one, together with the Organization digest, the constructed unsigned payload, the EIP-7702 authorization when one is needed, and the gas station address that may execute the batch.
Signer signs only what the ruling carries, including EIP-7702 batches
Signer verifies the ruling's signature, checks the Organization digest and id against the Organization bytes it was given, checks freshness, and requires an Allow. Signer then signs only the payload the ruling committed to, with the key the ruling names as from. Signer has no input through which the Coordinator could slip in a transaction of its own.
Deposits are always two calls, so a self-paid deposit runs as a batch through Turnkey's gas station using an EIP-7702 delegation.
The Coordinator builds the outer transaction, and Signer verifies it before signing.
Signer requires an accepted gas station as the target, calldata that wraps exactly the approved batch and the user's signature over it.
For gas-sponsored deposits, a Turnkey paymaster sends the transaction.
The paymaster is only a relayer, so its signature falls under its own Organization's policies rather than the Earn checks. What moves the funds is the user's own signature over the approved calls, with a one-time nonce and an expiry, which the user's account verifies onchain before executing anything.
A misbehaving relayer could waste its own gas or hold the transaction until it expires, but any change to the destination, amount, or recipient invalidates the user's signature.
The Coordinator supplies the outer envelope, but the only thing inside it that moves funds is the batch the ruling already committed to.
Withdrawals follow the same path. The derived withdrawal calldata fixes both owner and receiver to the signing wallet.
The wrapper record is written before the deployment confirms, so the Policy Engine also checks the live wrapper.
Before every deposit, TLS Fetcher reads the contract at the recorded address and signs a short-lived token over what it found. The Policy Engine checks that token against the record: the performance fee on both providers; the fee recipient, a zero management fee, and the underlying vault on Morpho; the owner and the locked starter deposit on Aave.
This matters because someone could race our deploy transaction once it is in the mempool. On Morpho the address binds our deployer key, so no one else can occupy it. On Aave it does not, so this read is what ties the record to the contract that actually landed.
Fees come out of yield, inside the same boundary
Earn charges a developer-chosen fee and a Turnkey fee, both on yield only, and the user never signs a fee transaction.
Collecting fees is itself an Earn activity. The Policy Engine derives the claim calldata from the recorded provider and fee wallets, so a fee claim can only pay the wallet agreed at deploy time. A Coordinator cannot redirect accrued fees any more than it can redirect a deposit.
What the Coordinator still chooses, and why each choice is safe
Removing every decision from the Coordinator was never the goal. The test we apply is whether a choice could move funds. A few things are left to the Coordinator on purpose, and each one is safe to leave there for a specific reason.
- Gas prices. For deploys, Signer caps the deployer's gas price. For a self-paid deposit the gas limit is fixed by the enclave, but the tip and max fee are chosen outside. The worst case is an overpaid or stuck transaction, not a redirected one.
- The Turnkey fee rate at deploy. The Coordinator proposes it, Signer caps it. An untrusted Coordinator could at most lower our revenue, not raise the user's cost past the cap.
Turnkey Earn: Verified once, rebuilt on every transaction
Earn splits the work of trusting a deposit across time. Once per vault, TLS Fetcher confirms the vault is real, Signer deploys the wrapper from code fixed inside the enclave, and the Notarizer records it in Organization state.
On every deposit, withdrawal, and fee claim, the Policy Engine rebuilds the transaction from that record and the user's intent, and Signer signs nothing else. That is how the 1,000 USDC in our example reached the Morpho vault the app listed, with the user as the only owner of the shares, even though the Coordinator carried every message.
Every decision in Earn came from the same question: what could an untrusted Coordinator change that would move funds, and how do we take that away?
That is why vault facts are verified once and stored where only trusted code can write, why every transaction is rebuilt inside the Policy Engine instead of trusted as handed over, and why the only choices left to the Coordinator can cost time, gas, or our own revenue, never redirect funds.
For a customer, Turnkey Earn means they can offer yield inside their product, with fees taken only from that yield. Every deposit reaches the vault the app listed, and the user who made it is the only owner of the shares it buys. Neither guarantee depends on trusting Turnkey's Coordinator.
Additional resources
- Yesterday never dies: Securing stateless systems against downgrade attacks
- Quotes are contracts: How Turnkey Swaps remain honest in an untrusted world
- Why we built Swaps and Earn
- Turnkey Whitepaper
- Public Earn activities:
EarnDeployWrapper,EarnDeposit,EarnWithdraw,ClaimEarnFees

.png)
.png)