This section outlines the security policy and known limitations of the Superform v2 core protocol.
The protocol includes a few accepted trade-offs and architectural decisions that integrators and users should be aware of and handle accordingly.
There is a low-likelihood scenario where a user's signed bridging intent may still be executed on the destination chain even after it has been canceled on the source chain.
Given the user must still have sufficient balance on their smart account on the destination chain, this is deemed to be a non-issue as it suffices to better UX and intended behavior to simplify core actions.
The process of marking a root as processed is susceptible to front-running. While mitigations are in place, it may not cover all edge cases.
Superform assumes the system remains static between the time a user signs an intent and its execution. If key components (e.g., smart account configuration or target contracts) change during this period, it may result in unexpected behavior.
Users accept this trade-off when using the protocol.
Hooks are external contracts and may not always be trustworthy. Interacting with unverified or malicious hooks can compromise user funds. Superform does not guarantee the safety of custom or third-party hooks.
Superform is tested and optimized for Nexus and Safe smart accounts. Other ERC-7579-compatible smart accounts may not function as intended and should be used with caution.
Superform uses a cached cost basis when a user's smart account withdraws directly from a vault. This is intended behavior.
Superform relies on vaults exposing accurate functions for pricing, like convertToAssets() in ERC-4626. Misconfigured or non-compliant vaults may lead to issues such as failed deposits or withdrawals. For vaults with timelocks, special hooks must be used before normal actions. Proper integration is essential to avoid disruptions.
There are multiple edge cases where protocol fees may be bypassed. While this is an accepted trade-off, it should be noted when designing or integrating with the system.
The destination executor supports only one leaf per destination in the Merkle root. Signing multiple leaves for the same destination may cause race conditions. While this is not expected to result in fund loss, it can cause execution conflicts.
The protocol allows signatures with no expiration. While convenient, these signatures can pose risks in certain operational contexts and should be used carefully.
Once an intent is signed, there are several valid methods to execute it, even in cases where the associated bridge transaction has not completed successfully. This provides flexibility but requires careful handling by integrators to avoid unintended consequences.
Superform's yield sources and oracle calculations are designed with ERC-20 assets that use up to 18 decimals of precision in mind. Yield source assets with more than 18 decimals are considered non-standard and unsupported.
Relay Protocol deposits (RelaySendFundsAndExecuteOnDstHook / ApproveAndRelaySendFundsAndExecuteOnDstHook) are escrowed in Relay's depository, which has no user-side on-chain withdrawal or cancellation function. If no solver fills an order, the refund is performed by Relay's solver on the origin chain and attested by Relay's off-chain Oracle/Allocator stack. Execution safety of user funds on the destination remains anchored in Superform's destination signature and balance validation; Relay's trust stack affects liveness only. This is the same trust class as Across/deBridge relayer fill liveness.
The RelayAdapter is permissionless (Relay has no authenticatable destination caller). Its received-funds guard and escrow accounting prevent phantom failed-transfer credits and cross-user escrow sweeps. However, funds parked in the adapter between two separate solver transactions — a deviation from Relay's atomic txs[] batching (allowFailure = false) — are forwardable by any caller presenting a validly-signed message for their own account until the legitimate second leg lands. The SuperBundler must always request fund delivery and the adapter call as one atomic batch.
Identity-PPS yield-source oracles are registered with feePercent = 0. Three of them override
getAssetOutputWithFees to bypass the fee view on-chain: AaveV4ReserveOracle,
ERC20YieldSourceOracle and MorphoBlueDebtOracle.
EulerDebtOracle does NOT — it carries the invariant in NatSpec only ("Neither is guarded
on-chain here"), so it depends entirely on configuration; a fee-bypass backport is a candidate
hardening PR. In every case the ledger accounting path (BaseLedger._processOutflow) computes
fees directly from SuperLedgerConfiguration and is not guarded by the oracle. Because no hook
snapshots cost basis for these positions, any configured fee taxes principal as profit; pairing
such an oracle with FlatFeeLedger fees the full principal on every outflow. Operational
invariant: these oracle ids are registered with feePercent = 0 or not registered at all, and
never with FlatFeeLedger — this covers AaveV4ReserveOracle explicitly, whose registration is
asymmetric by leg. Its oracle id MUST be registered at feePercent = 0 if the idle MONEY_MARKET pair is ever
driven. NOTE THE KEY: since SUP-21254 the key that pair posts under is the MARKET key, not the SUPPLY reserve
key (the oracle resolves the former to the latter). The fee wiring is by yieldSourceOracleId, not by key, so
the rule is unchanged in substance — only the noun moved: AaveV4LendHook is HookType.INFLOW and AaveV4RedeemHook is
HookType.OUTFLOW, and SuperExecutorBase reverts MANAGER_NOT_SET for those hooks unless the
header's yieldSourceOracleId resolves to a configured oracle. Its DEBT keys must never be
ledger-wired at all (every LOAN hook is NONACCOUNTING). Today no Aave V4 oracleId is configured
anywhere in the deploy path, so the idle pair would revert if driven — a pre-existing gap, not a
property to rely on.
Second invariant, for every registry-keyed family: never register the supply-side and debt-side
oracle ids of the SAME registry key against the same ledger. BaseLedger accumulators are keyed
(user, yieldSource) with no oracle-id component, so the two legs of one position sum into a
single accumulator slot. Use one ledger per side, or register only the side being accounted.
This still applies to Morpho Blue, whose MorphoBlueMarketRegistry key is side-agnostic and
whose two legs therefore share a key. It no longer applies to Aave V4: AaveV4ReserveRegistry
binds a Side into the key preimage (supply keeps the legacy two-word derivation; debt is
keccak256(abi.encode(spoke, reserveId, DEBT_KEY_DOMAIN))), so the legs are distinct
yieldSource values and cannot share an accumulator slot even on one ledger. That separation is
structural, not operational — it is what lets a single AaveV4ReserveOracle serve both legs
through the sideless IYieldSourceOracle surface the SuperYieldSourceOracle aggregator calls. For ERC20YieldSourceOracle specifically, whoever whitelists a token as
a SuperVault yield source owns its due diligence: single canonical entry point (double-entry
tokens would be double-counted by off-chain pricing), no rebasing/fee-on-transfer mechanics with
the corporate-action convention pinned per token and confirmed with the issuer in writing (the
off-chain price feed must match that convention — balance rebase vs total-return multiplier vs
airdrops), the token's upgrade surface monitored (BSC B-tokens are BeaconProxies sharing a
single beacon, so one beacon upgrade swaps balanceOf/decimals semantics for the whole family —
monitor the beacon's implementation, not the token's EIP-1967 slot, which is empty),
blocklist/pause semantics understood, donations accounted for (balanceOf includes unsolicited
transfers; off-chain pricing should reconcile balance deltas against executed flows), decimals
<= 18, and getTVL (global totalSupply) never used as a pricing input.
AaveV4ReserveRegistryV2 holds TWO key namespaces (SUP-21239), kept disjoint as storage by
KEY_NAMESPACE_COLLISION on all three write paths, and crossed by the oracle in exactly ONE direction
(SUP-21255):
- NAV / accounting —
computeReserveKey(spoke, reserveId)(SUPPLY) andcomputeDebtKey(spoke, reserveId)(DEBT), stored in_reserves. Every per-leg NAV read is keyed by these. - Market —
computeMarketKey(spoke, supplyReserveId, borrowReserveId), stored in_markets. This is the headeryieldSourceof the six V2 LOAN hooks AND, since SUP-21254, of the idleAaveV4LendHook/AaveV4RedeemHookpair. It is therefore what merkle leaves, the vault whitelist and off-chain indexing name.
Who reads it, precisely — the distinction that matters:
-
For the six V2 LOAN hooks the market key is intent identity only. They are
NONACCOUNTING, soSuperExecutorBase._updateAccountingnever reads their header and a market key is never a SuperLedger key for them. -
For the idle pair the market key IS the SuperLedger key and an oracle argument: lend is INFLOW, redeem is OUTFLOW. Since SUP-21263 the body carries ONE
targetReserveIdwhich may be EITHER leg of that market — idle USDC on a MAG7 pair must settle under the same key as idle NVDAc, and USDC is that market's LOAN leg — and membership is a REGISTRY READ inside the hook (getMarketInfo(headerKey), inbuildandpreExecute). Three consequences, all deliberate:- the market allowlist is enforced EARLIER: an unregistered market reverts
MARKET_NOT_REGISTEREDin the hook, before any Spoke call, instead of inside SuperLedger; inspectispureand therefore NO LONGER authenticates the header — it is a transformation API like the three sizing views, and a template that inspects must also passbuild/preExecute;getBalanceOfOwner(marketKey)may not describe the moved asset._resolveLegmaps a market key to its COLLATERAL leg, so whentargetReserveIdis the BORROW leg the scalar read reports the collateral reserve's supplied assets instead. Portfolio valuation for this family MUST come fromgetOwnerSnapshot, which discovers every leg, de-duplicates across the requested set, and (since SUP-21259) reports residual collateral no requested market covers. Never value an idle position from the scalar market-key read or from the ledger accumulators.
One ledger key per idle position is an OPS INVARIANT, not a property — item 4 below has the two reasons and the two controls that stand in for enforcement.
- the market allowlist is enforced EARLIER: an unregistered market reverts
Operational invariants:
- A market key resolves ONE-DIRECTIONALLY, to the collateral leg only (SUP-21255). Aave V4 positions
are reserve-granular —
getUserSuppliedAssets(reserveId, owner)takes no market parameter — so one reserve participates in N markets. Collateral reserves differ per market, so resolving a market key to its COLLATERAL leg is summable and is what the sidelessIYieldSourceOraclereads return. The DEBT leg must stay unreachable from a market key: on the live Base MAG7 spoke all seven equity markets borrow the one USDC reserve, so market-keyed debt would report the same liability seven times, and nothing in the aggregator de-duplicates by underlying position.getMarketPositionreturns both legs raw and un-netted for a caller that prices them;getOwnerSnapshotis the portfolio path and de-duplicates legs across the whole requested set (SUP-21256). Independent per-market reads must never be summed into a portfolio. This is also why Morpho Blue's side-agnostic market key is NOT a template here: Morpho storesposition[marketId][user], so the market IS its accounting unit. Aave V4's is the reserve. TWO RESIDUAL RULES: (a) two markets sharing a COLLATERAL reserve resolve to the same leg, so summing their sideless reads double counts — use the snapshot; (b) a market key is still never a SuperLedger key for a LOAN hook, because those hooks are NONACCOUNTING. - Market registration gates execution for the idle pair, and NOT for the LOAN hooks. No Aave V4 hook calls the registry; they only pin that the header equals the market key of the body. But because the idle pair is INFLOW / OUTFLOW, its accounting read resolves the header through the oracle, so an unregistered market reverts the userOp — a real, if indirect, gate. The LOAN hooks have no such gate: registering a market merely records its binding for off-chain consumers, and deciding which markets a vault may touch remains an off-chain whitelist decision.
- Removal order is markets, then reserve legs — and a market with a live idle position must not be
removed at all. A market claims exactly two of its two reserves' four legs — the collateral reserve's
SUPPLY leg and the loan reserve's DEBT leg — and
marketRefsrefuses to deregister either while the market lives (MARKET_REFERENCES_RESERVE). Symmetrically, a leg with a pending deregistration cannot be claimed by a new market (RESERVE_DEREGISTRATION_PENDING), so the two timelocks never overlap. Taking a claimed leg dark would abort whole-batch reads ingetPricePerShareMultiple/getTVLMultiple(which, unlikegetTVLByOwnerOfSharesMultiple, DO isolate per entry). UNGUARDED, OPS-ENFORCED (SUP-21254, corrected in SUP-21263): deregistering a market under which an account still holds an open IDLE position bricks that account's redeem through Superform, and the only exit is calling the Spoke directly, outside the ledger. The registry cannot see user positions, so there is nomarketRefsanalogue for this direction. WHERE IT FAILS, precisely. Since SUP-21263 the first failure is in the HOOK, not the oracle:_requireTargetIsMarketLegcallsREGISTRY.getMarketInfo(marketKey)— from_buildHookExecutions,_preExecuteAND_postExecute— and an unregistered key revertsMARKET_NOT_REGISTEREDbefore any accounting runs. The oracle'sRESERVE_NOT_REGISTEREDinside_updateAccountingis the second-order failure, reachable only by a path that does not go through the idle hooks. THE DRAIN CHECK:getBalanceOfOwner+usersAccumulatorSharesIS NOT SUFFICIENT. It was the documented check through SUP-21254 and it no longer establishes what it claims, for two independent reasons:getBalanceOfOwner(marketKey, account)resolves a market key to its collateral leg only (SUP-21255, one-directional by design so the sideless reads stay summable). SUP-21263 enables idle positions on the market's borrow reserve, and such a position is invisible to this scalar — it reads zero while the borrow leg is still supplied.usersAccumulatorShares(account, marketKey)counts deposited accounting units, not current supplied assets. Aave converts supplied assets through the Hub, so an interest-bearing supply exceeds the units ever credited: redeeming every credited unit takes the accumulator to zero and leaves the accrued remainder supplied. Both therefore read zero with real user funds still in the market. Regression:test_RetirementDocumentedZeroChecksPassWithBorrowLegYieldRemaining. THE CHECK THAT DOES ESTABLISH IT. Before proposing, and again immediately before executing:
IAaveV4Spoke.getUserSuppliedAssets(reserveId, holder)is zero for both of the market's reserves — the supply leg AND the borrow leg — for every holder, read at one pinned block. A completegetOwnerSnapshotis equally acceptable once its spoke and holder coverage is established; what is not acceptable is a market-scalar read or a ledger read standing in for the Spoke's own accounting.- Keep the two ledger checks as a secondary signal. They do not prove the position is drained, but a non-zero accumulator against a drained Spoke position is itself a reconciliation defect worth catching before removal.
- Quiesce the affected roots first, so no new idle op can settle into the market during the window.
- Re-run step 1 after the 2-day timelock, immediately before
executeDeregisterMarket. The timelock is a delay, not a freeze: without quiesced roots a holder can open a position inside it, and interest accrues throughout regardless.
- ONE market per idle-lendable reserve:
marketRefs[computeReserveKey(spoke, supplyReserveId)] <= 1. NOT enforced on-chain —registerMarketwill accept(R, 0),(R, 1),(R, 2)and the idle hooks accept any of them, giving reserve R's single idle position one ledger key per such market.BaseLedgeraccumulators are keyed(user, yieldSource)with no oracle id, so lending under one and redeeming under another capsusedSharesto zero (UsedSharesCapped): a permanently stale accumulator, a NAV double count for anyone summing both keys, and a performance-fee bypass the day thefeePercent = 0invariant in item 15 stops holding. WHERE IT IS ACTUALLY ENFORCED, precisely.ConfigureAaveV4ReserveRegistryrejects a second supply claim withCOLLATERAL_LEG_CLAIMED_TWICEin three places: when registering a market (_registerOneMarket, including its already-registered branch, so a re-run cannot inherit an ambiguity) and inconfigureAll's final gate over every listed reserve.runCheckAllprints the offending reserves and then fails too, but under the broaderIDLE_IDENTITY_AMBIGUOUSmessage, since it now gates the SUP-21263 settlement-designation violations as well. That is a script-level guard: it covers this configuration process and an audit of its result, and it does NOT constrain a manager who callsregisterMarketon the registry directly. Only the SUPPLY leg is constrained — the loan reserve's DEBT leg is shared by design (all seven Base equity markets borrow the one USDC reserve, refcount 7). The by-construction fix, if this needs to be structural, is a registry flag marking a market as the idle-supply market for its collateral reserve and refusing a second one; it is not taken today because it means redeploying and re-seeding a registry that is already live and configured. SUP-21263 ADDED A SECOND, WIDER DIRECTION that the supply-leg gate above does NOT cover. Once the idle hooks accept either leg of the header market, a reserve that is the BORROW leg of N registered markets can settle an idle position under any of those N keys: N = 7 on the Base MAG7 spoke, where every equity market borrows the one USDC reserve and that refcount of 7 is REQUIRED for the LOAN hooks, so it cannot be tightened. Lending under market A and redeeming under market B SUCCEEDS —BaseLedger.calculateCostBasisViewcapsusedSharesat B's empty accumulator rather than reverting — and strands A's shares and cost basis permanently. Separately, both legs of ONE market may be idled at once (the feature requires it), so one accumulator can sum two reserves' positions in incommensurable units. Neither loses funds nor blocks an exit whilefeePercent == 0for this oracle id: the identity PPS makes cost basis equal the amount, and every realized-profit fee is multiplied by zero. The two controls that stand in for on-chain enforcement are therefore load-bearing:feePercentMUST stay 0 for the Aave V4 reserve oracle id (see item 15).- Exactly ONE registered market key per
(spoke, reserveId)is designated that reserve's idle settlement key, and the OMS allowlist must never sign an idle leaf naming any other.ConfigureAaveV4ReserveRegistry._assertIdleSettlementDesignatedenforces that a designation exists, is registered, and really names the reserve — for every reserve with more than one candidate market — inconfigureAll's final gate and inrunCheckAll, which prints the candidate count per reserve and then fails. On Base the designation for the shared USDC loan reserve iscomputeMarketKey(spoke, 0, 7). Changing it after an idle position exists would strand that position's accumulator under the old key, so it must stay stable.
- Off-chain consumers must key on
(chainId, marketKey). Like the two leg derivations, the market preimage contains no chainId, and Aave V4 spoke addresses are not guaranteed chain-unique. On-chain this is harmless — the pin is evaluated on the executing chain and the signed envelope binds chainId — but any consumer keying a whitelist or an index on the bare 20 bytes would conflate two chains' markets. getOwnerSnapshotdiscovery is SYMMETRIC, and strict about what it cannot resolve (SUP-21259). Debt is discovered by scanning every reserve of every covered spoke; supply used to arrive ONLY through the requested market bindings. That asymmetry meant removing the last market naming a supply reserve dropped its collateral from NAV while its debt kept being counted — PPS falls, or a negative-NAV guard rejects a snapshot that is merely incomplete. Collateral no requested market accounts for is now returned as its own SUPPLY-leg position, and when that leg is NOT registered the whole call revertsUNCOVERED_COLLATERAL(spoke, reserveId): never silently omitted, never resolved through an unregistered key. Dedup is unchanged — a leg a requested market already contributed is not added twice. COVERAGE BOUNDARY, so it is not over-read: this protects a snapshot WHEN IT IS REQUESTED. A consumer whose strategy lists no Aave source at all requests no Aave snapshot, so complete-removal coverage stays a manager/lifecycle guarantee. Cost: one extragetUserSuppliedAssetsstaticcall per reserve per covered spoke, taken unconditionally because a plain idle supply reads(false, false)fromgetUserReserveStatusand would otherwise be invisible.- The derivation is frozen and its leg order is significant.
keccak256(abi.encode(spoke, supplyReserveId, borrowReserveId, MARKET_KEY_DOMAIN)), lower 20 bytes. The ids are never sorted: "collateral A, borrow B" and "collateral B, borrow A" are different strategies and must stay different keys.MARKET_KEY_DOMAINcannot change once any market key has been signed into a root.