Skip to main content
Why a Safe cannot take a flash loan without a module, and what we do instead. Safe is the most widely held smart wallet in DeFi, so “can a Safe just use this directly?” is the first question anyone asks. The answer splits in two, and the two halves get opposite answers. Everything below was measured against a real Safe v1.4.1 on Base, not reasoned about. The tests are in contracts/test/SafeDelegateFlash.t.sol.

The half that works

A Safe can run an arbitrary batch of calls with nothing installed on it. The owners sign one ordinary execTransaction with operation = 1, which is a delegatecall, and the batch executes in the Safe’s own context: address(this) is the Safe, the storage is the Safe’s, and msg.sender at every target is the Safe. This is not a trick. It is exactly what Safe’s own MultiSend is, and it is why “batch these calls” needs no module, no extension and no setup.

The half that does not

A flash loan cannot be taken this way. Not “is awkward to”: cannot. The lender calls back. Every flash loan is two steps: the lender sends the money, then calls a function on the recipient to let it do the work before repaying. Balancer calls receiveFlashLoan. Morpho calls onMorphoFlashLoan. Aave calls executeOperation. That callback is a fresh external call to the Safe for a function the Safe does not have. Solidity does not care that a delegatecall frame is still open further down the stack; the call arrives from outside and is dispatched by the Safe’s fallback, which forwards it to the Safe’s fallback handler. And a fallback handler cannot help, for a reason that is easy to miss: Safe reaches its handler with call, not delegatecall. The handler therefore runs in its own context. Even a custom handler that implemented receiveFlashLoan perfectly would be a different contract holding no tokens and having no authority over the Safe.
Nominating a different flash recipient does not rescue this. Balancer, Morpho and Aave all let you name the recipient, so a helper contract can receive the callback, but that helper then has no authority over the Safe, which is the thing you needed.
Nor can an approval-based helper substitute. Given an allowance, a helper can supply and repay on a Safe’s behalf, because those take an onBehalfOf. It can never withdraw (Aave’s withdraw acts on msg.sender’s own position) and it cannot borrow without separate credit delegation. Withdrawing collateral is half of every unwind, so this route stops before it starts.

Which leaves three options

So: if the position must live inside the Safe itself, a module is the only way. That is a real cost. A Safe module can call execTransaction-equivalent functions on the Safe for as long as it is installed, its power is not scoped per operation, and it is one more contract in the trust boundary of a wallet whose whole appeal is that it has a small one.

What we do instead

The Safe owns a Kernel account. Nothing is installed on the Safe. The position lives in the Kernel account, and the Safe is its root owner. This works because of one substitution in the root validator. The stock validator recovers a signer from the signature and compares it to the owner, and a Safe’s address can never be the answer to a recovery, because a Safe has no private key. ERC1271RootValidator asks the owner instead: it hands the signature to the owner contract and accepts whatever that contract says. A contract owner then answers under its own rules, which for a Safe means its threshold. Two further properties of that checker matter as much as the delegation:
  • It does not revert. ECDSA.recover reverts on anything that is not 64 or 65 bytes, and a Safe’s multi-owner blob is 65 bytes per owner. A revert inside validateUserOp surfaces as a bundler rejection rather than a clean signature failure.
  • It falls back to ECDSA when the owner has no code, so an EOA owner keeps working. The validator is a superset of the stock one, which is what lets an account be re-owned by a Safe later without changing which validator is root.
The validator is also an IHook, which keeps the owner’s direct-call exit alive: a Safe can drive the account with no bundler, no paymaster and no ETH in the account, because being the caller is the proof.

What this gets you that a module does not

The Safe delegates bounded authority rather than total authority. Every adapter is installed per operation with its own spending caps and its own venue list, and an operation the owner did not authorise is refused by the adapter rather than by convention. A Safe module has no such granularity, see the kernel for what A, B and R may each do.

What it costs

Adopting this validator changes the address of every account not yet derived. The validator id is the first 21 bytes of the account’s init data, and the Kernel factory hashes that into the CREATE2 salt. There is no migration for an address already published.
Two more, stated plainly:
  • Signing is two domains deep. Kernel wraps the hash in its own EIP-712 domain, and the Safe wraps that in SafeMessage. Anything signing for the account must reproduce both.
  • The validator is unaudited, and deliberately minimal. It has no ERC-7739 nested-signature defence, so a Safe owning two Kernel accounts answers the same digest for both; no owner rotation; and no guard against an owner contract that later upgrades into something else.
It is deployed immutably, by plain CREATE2 behind no proxy: unlike every other genesis contract here. Whoever can replace a root validator’s code can authorise any operation on every account naming it, which is a strictly larger power than the proxy admin holds anywhere else. Making that answer “nobody” is worth the cost that a bug is fixed by deploying a new validator.