Crypto Training
stVaults Liquidation Manager: Borrowing Against Lido V3 Without Pre-Minting stETH
A deep technical walkthrough of a liquidation manager that lets institutions pledge a Lido V3 stVault to AAVE v4 as collateral — where stETH is minted only during liquidation, not before. Six immutable contracts, a composite capacity oracle, a drain-proof role graph, and the attack surface of each.
If you want to borrow stablecoins today against an Ethereum staking position, you pay for collateral twice.
You stake ETH — locking it for validators. You mint stETH against that staking balance. You wrap to wstETH. You deposit wstETH on AAVE. You borrow USDC. The wstETH sitting in AAVE is a mirror of your vault's value — its balance is redundant with the stETH liability you already hold on Lido. You're overcollateralized on both sides.
A better design: let AAVE treat the vault's remaining minting capacity as the collateral. Mint stETH only if the position actually liquidates. No pre-minted tokens. No double lock.
This post walks through a reference implementation that does exactly that — six immutable contracts between Lido V3's stVault system and AAVE v4's Spoke architecture. We'll cover the component layout, the role graph, the fund flows, the oracle, the invariants, the attack surface, and where the design still has sharp edges.
1. The Problem#
1.1 Status quo double-collateralization#
Two independent solvency systems each apply a haircut. Lido's minimalReserve keeps the vault overcollateralized on the protocol side. AAVE's LTV keeps the position healthy on the lender side. The user pays for both.
1.2 Design goal#
A vault that is simultaneously the staking position and the AAVE collateral, with one shared overcollateralization buffer. Lido's remaining minting capacity is the authoritative measure of "how much stETH this vault could support" — feed that number to AAVE as collateral value.
Minting only happens at liquidation time:
Before liquidation: zero stETH exists from this position. AAVE reads a view function that says "this vault could mint N stETH shares if asked." After liquidation: exactly enough stETH was minted to cover the liquidator's payout, and the vault's capacity drops by that amount.
1.3 Hard constraints#
- No modifications to Lido V3 stock contracts. Dashboard, StakingVault, VaultHub are unchanged.
- AAVE v4 is deployed. We can't re-architect its Spoke.
- Non-custodial. The borrower keeps every power that is strictly safe (funding, rebalancing, burning liability). Only pledge-eroding powers are held elsewhere.
- Permissionless liquidation. Anyone can call
liquidationCall(). No trusted liquidator. - No admin in the hot path. No multisig, no timelock, no pause — the liquidation path has no off-switch that a human could flip.
2. Six Contracts#
The integration is six immutable contracts. Five are deployed once (singletons). One is deployed per borrower.
| Contract | Role | Deployed |
|---|---|---|
PledgeGuard | Per-borrower wrapper. Holds broad Dashboard admin roles; filters operations to prevent pledge erosion. | Once per borrower |
LiquidationAdapter | Singleton. Sole holder of MINT_ROLE on every pledged Dashboard. Mints only via Spoke-initiated liquidation. | Once |
PledgeRegistry | Singleton. Source of truth for pledge state: who pledged what to which Spoke, what's reserved, what's been consumed. | Once |
CapacityOracle | Singleton. Composite price feed: Lido accounting authority × Chainlink market rate × freshness checks. | Once |
StVaultFactory | Singleton. Atomic deployment: creates the Lido vault, deploys the PledgeGuard, rewires the role graph. | Once |
StVaultSpoke | Singleton. Fork of AAVE v4 Spoke.sol with a custom collateral-accounting branch and a custom liquidation path. | Once |
The StVaultSpoke must be a fork of AAVE's Spoke.sol, not a subclass. Neither liquidationCall nor _processUserAccountData is marked virtual on the deployed v4 contract — inheritance doesn't give us override points. We copy the contract, add two branches, and audit the whole thing.
3. The Role Graph#
The trick that makes the whole design possible is the Dashboard role graph. Lido V3's Dashboard uses OpenZeppelin AccessControl with named roles. The factory rewires who holds what.
3.1 Who holds what#
| Role | Holder | Why |
|---|---|---|
DEFAULT_ADMIN_ROLE | PledgeGuard | Sole administrator — the only thing that can grant or revoke roles. |
WITHDRAW_ROLE | PledgeGuard | Withdrawal must be pledge-aware. |
VOLUNTARY_DISCONNECT_ROLE | PledgeGuard | Disconnection must be blocked while pledged. |
VAULT_CONFIGURATION_ROLE | PledgeGuard | Tier changes must be blocked while pledged. |
REQUEST_VALIDATOR_EXIT_ROLE | PledgeGuard | Exiting validators is a pledge-eroding action. |
TRIGGER_VALIDATOR_WITHDRAWAL_ROLE | PledgeGuard | Same. |
FUND_ROLE | Borrower | Adding ETH is strictly safe (additive to collateral). |
BURN_ROLE | Borrower | Burning reduces liability — always safe. |
REBALANCE_ROLE | Borrower | Rebalancing improves health. |
MINT_ROLE | LiquidationAdapter (only when pledged) | The key decision. Adapter is the sole minter. |
The borrower never holds DEFAULT_ADMIN_ROLE while pledged. The PledgeGuard does. The PledgeGuard is immutable bytecode with no call(target, data) escape hatch. It cannot grant roles arbitrarily, cannot change MINT_ROLE's admin, cannot disconnect the vault. Its surface is small enough to eyeball.
3.2 State machine for Dashboard admin#
The transition from GuardAdmin to BorrowerAdmin is one-way per PledgeGuard — but the borrower can always go back the other way by deploying a new PledgeGuard through the factory.
3.3 Factory atomic setup#
The factory has to achieve this configuration in a single transaction so the vault never exists in a half-configured state where some operations are dangerous. Here is the actual sequence, straight from the code:
// Step 1: Lido factory creates vault + dashboard with THIS factory as DEFAULT_ADMIN.
(stakingVault, dashboard) = LIDO_FACTORY.createVaultWithDashboard{value: msg.value}(
address(this), // _defaultAdmin = this factory
nodeOperator,
nodeOperator, // _nodeOperatorManager
nodeOperatorFeeBP,
confirmExpiry,
emptyRoles
);
// Step 2: Deploy PledgeGuard bound to this Dashboard and owned by borrower.
pledgeGuard = address(new PledgeGuard(dashboard, ADAPTER, REGISTRY, SPOKE, borrower));
// Step 3: Configure Dashboard role graph.
bytes32 adminRole = d.DEFAULT_ADMIN_ROLE();
d.grantRole(adminRole, pledgeGuard);
d.grantRole(d.WITHDRAW_ROLE(), pledgeGuard);
d.grantRole(d.VOLUNTARY_DISCONNECT_ROLE(), pledgeGuard);
d.grantRole(d.VAULT_CONFIGURATION_ROLE(), pledgeGuard);
d.grantRole(d.REQUEST_VALIDATOR_EXIT_ROLE(), pledgeGuard);
d.grantRole(d.TRIGGER_VALIDATOR_WITHDRAWAL_ROLE(), pledgeGuard);
d.grantRole(d.FUND_ROLE(), borrower);
d.grantRole(d.BURN_ROLE(), borrower);
d.grantRole(d.REBALANCE_ROLE(), borrower);
d.grantRole(d.PAUSE_BEACON_CHAIN_DEPOSITS_ROLE(), borrower);
d.grantRole(d.RESUME_BEACON_CHAIN_DEPOSITS_ROLE(), borrower);
// Step 4: Factory steps out.
d.revokeRole(adminRole, address(this));
// Step 5: Register the Guard in the registry so it can call protected functions.
REGISTRY.registerPledgeGuard(pledgeGuard, dashboard);
A reviewer should ask: what happens if this transaction reverts midway? Answer — because it's all one call, it either commits whole or reverts whole. No half-configured vault ever sits on chain.
A second question: if renounceRole is disabled on Lido's Permissions contract, how does the factory revoke DEFAULT_ADMIN_ROLE from itself? The trick is that revokeRole(DEFAULT_ADMIN_ROLE, self) is not the same as renounceRole. OpenZeppelin AccessControl allows the admin to revoke any role (including DEFAULT_ADMIN_ROLE) from any account — including itself — as long as the caller holds the admin role. Lido only blocks renounceRole. So the factory can relinquish its role without violating any protocol invariant.
3.4 Role graph after factory setup#
4. PledgeGuard — The Borrower's Filtered Interface#
PledgeGuard is where most of the security thinking lives. It's the only contract the borrower interacts with directly once the vault is set up.
4.1 Shape#
contract PledgeGuard is Ownable2Step {
IDashboard public immutable DASHBOARD;
LiquidationAdapter public immutable LIQUIDATION_ADAPTER;
PledgeRegistry public immutable REGISTRY;
address public immutable SPOKE;
IStETH public immutable STETH;
bytes32 public immutable MINT_ROLE;
bytes32 public immutable DEFAULT_ADMIN_ROLE;
// No mutable state — all state lives in PledgeRegistry.
}
Six immutable references and two cached role IDs. No storage variables. No upgrade path. No admin beyond Ownable2Step's owner, which is the borrower. The contract is 199 lines including whitespace.
Why zero mutable state matters: there is no storage to corrupt, no state to desynchronize, no variable initialization gap on clone deployments. The guard behaves as a stateless filter whose decisions derive entirely from (a) its immutable bindings and (b) fresh reads from the Registry and Dashboard.
4.2 The pledge lifecycle#
4.3 The critical function — withdraw#
withdraw is the function that stops the entire class of "drain before liquidation" attacks. The borrower wants to pull ETH out; the system has to refuse if pulling it would leave the pledge underwater.
function withdraw(address recipient, uint256 ether_) external onlyOwner {
uint256 totalValue_ = DASHBOARD.totalValue();
uint256 lidoLocked = DASHBOARD.locked();
// Convert reserved AAVE capacity (in stETH shares) to ETH equivalent.
uint256 reservedShares = REGISTRY.availableCapacityShares(address(DASHBOARD));
uint256 reservedETH = STETH.getPooledEthByShares(reservedShares);
uint256 effectiveLocked = lidoLocked > reservedETH ? lidoLocked : reservedETH;
if (totalValue_ < effectiveLocked + ether_) revert ExceedsUnencumbered();
DASHBOARD.withdraw(recipient, ether_);
emit GuardWithdrew(recipient, ether_);
}
There are two solvency buffers:
lidoLocked— the Lido-native minimum. If you dip below this, VaultHub would refuse the withdrawal anyway.reservedETH— the AAVE-side reservation. The ETH equivalent of the stETH shares we've promised AAVE can be minted.
We take the max of the two and require the vault retains at least that much after the withdrawal. Either buffer alone is a valid guard; taking the max makes the strictest bound binding.
The sequence for an attempted drain-while-pledged:
4.4 Operations that must be blocked while pledged#
| Operation | Behavior while pledged |
|---|---|
withdraw(recipient, amount) | Allowed up to the unencumbered value. |
requestValidatorExit(pubkeys) | Reverts PledgedVaultsCannotExitValidators. |
voluntaryDisconnect() | Reverts PledgedVaultsCannotDisconnect. |
transferOwnership(newOwner) | Reverts PledgedVaultsCannotTransferOwnership. |
addReservedCapacity(n) | Allowed. Adding to the pledge is strictly safe for AAVE. |
fund(amount) | Allowed. Adding collateral is strictly safe. |
rebalanceVaultWithShares(shares) | Allowed. Reduces liability. |
Why block transferOwnership while pledged? Because the PledgeGuard's owner is the party whose debt is being checked for isBorrowerDebtFree. If you could reassign ownership mid-loan to an address with no AAVE debt, unlockMintRole() would pass its debt check, MINT_ROLE would be revoked, and the liquidator's mint path would vanish. Blocking the transfer eliminates the desync.
Why block requestValidatorExit? Exiting a validator reduces the staked balance — not directly a drain, because the ETH reappears in the vault's liquid balance. But in combination with other vectors (e.g., attempting a very large withdrawal right before Lido's next report), it can contribute to a composite attack. The conservative rule is "block all vault-configuration operations while a lender has claims on you."
4.5 The unlock gate#
function unlockMintRole() external {
if (!isPledged()) revert NotPledged();
if (!LIQUIDATION_ADAPTER.isBorrowerDebtFree(SPOKE, owner())) revert DebtOutstanding();
DASHBOARD.revokeRole(MINT_ROLE, address(LIQUIDATION_ADAPTER));
REGISTRY.markUnpledged(address(DASHBOARD));
emit MintRoleUnlocked(address(DASHBOARD));
}
This function is permissionless. Anyone can call it — not just the borrower. Why? Because the consequence of unlocking when debt is zero is benign: the borrower simply has their MINT_ROLE back. The check LIQUIDATION_ADAPTER.isBorrowerDebtFree(SPOKE, owner()) is a fresh read of AAVE state, so a third party calling unlock at the exact moment debt is zero achieves the same outcome as if the borrower had called it. No trusted caller; no admin.
Keeping unlock permissionless is also a correctness property: if the borrower goes offline, the ecosystem can still clean up the pledge after they repay.
5. LiquidationAdapter — The Sole Minter#
5.1 The single mint path#
Everything about this contract is shaped by one invariant: stETH can only be minted by a real, on-chain AAVE liquidation. No admin override. No emergency mint. No back door.
function mintForLiquidation(
address dashboard,
address borrower,
uint256 stETHToMint,
uint256 wstETHToLiquidator,
address liquidator
) external nonReentrant returns (uint256 mintedShares, uint256 wstETHPaid) {
// Lookup pledge; enforce spoke and borrower identity.
PledgeRegistry.Pledge memory p = REGISTRY.pledges(dashboard);
if (p.status != PledgeRegistry.PledgeStatus.Pledged) revert NotPledged();
if (msg.sender != p.spoke) revert CallerNotSpoke();
if (borrower != p.borrower) revert BorrowerMismatch();
uint256 MINT_BUFFER = 2;
uint256 requiredShares = wstETHToLiquidator + MINT_BUFFER;
uint256 available = uint256(p.reservedShares) - uint256(p.consumedShares);
if (requiredShares > available) revert ExceedsReserved();
mintedShares = requiredShares;
// Checks-effects-interactions: account BEFORE minting.
REGISTRY.consumeCapacity(dashboard, mintedShares);
IDashboard(dashboard).mintShares(address(this), mintedShares);
// Wrap ALL stETH currently held to absorb Lido's share/ETH rounding.
uint256 stETHBalance = STETH.balanceOf(address(this));
uint256 wstETHReceived = WSTETH.wrap(stETHBalance);
if (wstETHReceived < wstETHToLiquidator) revert InsufficientWstETH();
wstETHPaid = wstETHToLiquidator;
IERC20(address(WSTETH)).safeTransfer(liquidator, wstETHPaid);
emit LiquidationMint(dashboard, borrower, liquidator, mintedShares, stETHToMint, wstETHPaid);
}
Four preconditions, all enforced by reads against the Registry:
- Status check — the pledge is active.
- Caller check — the caller is exactly the Spoke recorded for this pledge.
- Borrower check — the Spoke claims the liquidation is for the same borrower we pledged for.
- Capacity check — the requested shares fit within unconsumed capacity.
And three state effects, in the right order:
- Registry capacity is decremented first (effects before interactions).
- Dashboard mints stETH to this adapter.
- wstETH is wrapped and sent to the liquidator.
5.2 The 2-wei mint buffer#
Notice the line uint256 MINT_BUFFER = 2;. This is the kind of detail that only survives real fork tests.
The stETH ↔ shares ↔ wstETH roundtrip loses 1–2 wei per direction because Lido rounds down at every conversion. If you request the liquidator receive exactly W wstETH and mint exactly W shares, the wrap step might give you W - 1 or W - 2 wstETH. The transfer at the end would revert.
The buffer is paid by the vault (it counts against pledged capacity), not by the liquidator. It's a negligible economic cost — at most 2 wei per liquidation — in exchange for guaranteeing liquidators always receive the full quoted amount. The alternative (no buffer, letting the transfer revert) would create a DoS vector where rounding conspires to block every liquidation attempt.
5.3 Why the Spoke must be hardcoded per-pledge#
Look at msg.sender != p.spoke. The Spoke is read from the pledge record, which was set when the borrower called lockMintRole — it's SPOKE from the PledgeGuard's immutable binding, which was SPOKE from the StVaultFactory's immutable binding, which was fixed at protocol deployment.
In other words: there is exactly one Spoke that can ever mint from a given pledged vault. A malicious Spoke can't register itself somewhere and start minting. A governance upgrade that replaces the Spoke would need a new protocol deployment.
5.4 The flow diagram#
6. PledgeRegistry — The Source of Truth#
The Registry is a tiny contract. It holds a mapping from Dashboard to Pledge and lets three parties touch it:
- The factory, to register a new PledgeGuard.
- PledgeGuards, to mark their own Dashboard pledged/unpledged and add capacity.
- The LiquidationAdapter, to decrement capacity.
6.1 Storage layout#
enum PledgeStatus { Unpledged, Pledged }
struct Pledge {
address borrower; // 20
address spoke; // 20
address pledgeGuard; // 20
uint96 reservedShares; // 12
uint96 consumedShares; // 12
PledgeStatus status; // 1
uint48 pledgedAt; // 6
}
mapping(address => Pledge) private _pledges;
mapping(address => address) public guardedDashboard; // guard => dashboard
address public immutable FACTORY;
address public immutable LIQUIDATION_ADAPTER;
The packed layout fits everything except the three addresses into ~31 bytes — two storage slots per pledge. uint96 for shares gives us up to 2^96 - 1 ≈ 7.9 × 10^28 shares, comfortably above total stETH supply (around 9 × 10^24 wei of stETH today).
6.2 Access control via two modifiers#
modifier onlyGuardFor(address dashboard) {
if (guardedDashboard[msg.sender] != dashboard) revert NotGuard();
_;
}
modifier onlyAdapter() {
if (msg.sender != LIQUIDATION_ADAPTER) revert NotAdapter();
_;
}
Note the coupling between guardedDashboard[msg.sender] and the dashboard argument. A PledgeGuard can only touch pledges for the Dashboard it was registered for. It can't call markPledged(someOtherDashboard, ...) to hijack another borrower's vault.
6.3 State transitions#
6.4 The monotonicity guarantee#
consumeCapacity asserts that consumedShares + newAmount <= reservedShares before it increments. Combined with the fact that only the singleton LiquidationAdapter can call it, this gives us the invariant that drives I2 from the ADR:
for all dashboards d, times t:
Registry.consumedShares(d, t) <= Registry.reservedShares(d, t)
which translates directly to: the vault never mints more stETH than the borrower pledged. No minting overdraft is possible even if the Spoke's liquidation math is buggy — the Registry will revert.
7. CapacityOracle — Priced Right#
The oracle is the subtlest component. It translates a pledged vault into a USD value that AAVE's standard math can treat like any other collateral.
7.1 Three sources, one answer#
Three independent bounds, and the oracle takes the most conservative.
7.2 The actual computation#
function getCollateralValueUSD(address dashboard) external view returns (uint256 valueUSD_1e8) {
if (!REGISTRY.isPledged(dashboard)) revert NotPledged();
address stakingVault = address(IDashboard(dashboard).stakingVault());
_checkFreshness(stakingVault);
_checkQuarantine(stakingVault);
uint256 lidoCapShares = IDashboard(dashboard).remainingMintingCapacityShares(0);
uint256 availableShares = REGISTRY.availableCapacityShares(dashboard);
uint256 enforceableShares = lidoCapShares < availableShares ? lidoCapShares : availableShares;
if (enforceableShares == 0) return 0;
uint256 enforceableStETH = STETH.getPooledEthByShares(enforceableShares);
uint256 ethUsd = _readETHUSD_1e8();
// Path A: accounting — 1 stETH = 1 ETH per Lido protocol.
uint256 accountingUSD = (enforceableStETH * ethUsd) / 1e18;
// Path B: market — wstETH × wstETH/ETH × ETH/USD.
uint256 wstETHRate_1e18 = _readWstETHperETH_1e18();
uint256 wstETHAmount = WSTETH.getWstETHByStETH(enforceableStETH);
uint256 ethEquivalent = (wstETHAmount * wstETHRate_1e18) / 1e18;
uint256 marketUSD = (ethEquivalent * ethUsd) / 1e18;
valueUSD_1e8 = accountingUSD < marketUSD ? accountingUSD : marketUSD;
}
7.3 The conservative min in three places#
The oracle uses a min three times. Each guards a different failure mode:
-
min(lidoCapacity, reservedCapacity). Lido's own remaining capacity may be much larger than what the borrower pledged (the borrower might have only pledged 50% of their mint capacity). The effective AAVE collateral is whichever is smaller. -
Implicit min in
availableCapacityShares(which isreserved - consumed). After a partial liquidation, consumed shares are already spent — they can't be minted twice. -
min(accountingUSD, marketUSD). If stETH depegs, market value drops below accounting value (1 stETH no longer worth 1 ETH to traders). If markets go crazy the other direction, accounting value stays at 1 ETH and protects AAVE from paying a premium.
7.4 Freshness checks#
function _checkFreshness(address stakingVault) internal view {
uint256 lastReport = LAZY_ORACLE.latestReportTimestamp();
if (lastReport == 0 || block.timestamp - lastReport > MAX_VAULT_REPORT_STALENESS) {
revert StaleVaultReport();
}
}
MAX_VAULT_REPORT_STALENESS = 24 hours, stricter than Lido's own 48-hour freshness delta. If Lido's oracle goes stale for more than a day, AAVE treats the vault's collateral as zero — effectively. Actually, it's worse than zero: the oracle reverts. That means every _calculateUserAccountData for a pledged borrower will revert, which means liquidationCall also reverts. Borrowers can still repay (repay doesn't need the oracle), but new borrows are frozen and liquidations are paused.
Is pausing liquidations a good outcome on stale oracle? It's a trade-off:
- Arg for pausing: a stale oracle might reflect a stale (too-high) collateral value. Liquidating at a bad price hurts the borrower.
- Arg against pausing: during stale periods, the protocol accumulates bad debt if prices have moved adversely.
The stricter 24-hour bound reflects the bet that Lido's oracle is reliable enough that 24h is a real anomaly, not just normal operations. Combined with AAVE's PriceOracleSentinel (which can override the pause during a sequencer recovery grace period), this gives operators a well-defined knob.
Known limitation. The current implementation uses LazyOracle.latestReportTimestamp() — a global value across all vaults — rather than a per-vault timestamp. If one vault's report is delayed but others update, the global timestamp still advances, and the delayed vault's collateral would read fresh when it shouldn't. A hardened version must read the per-vault report timestamp directly.
7.5 The accounting vs market split#
Why even have two paths? Why not just use Chainlink?
Because stETH is not wstETH. Lido's accounting treats 1 stETH = 1 ETH by protocol definition. But wstETH is a share-like token whose ratio to stETH grows with rewards. Chainlink's wstETH/ETH feed reports the market's current wstETH → ETH ratio, including any depeg.
If you use only the market feed:
- During a depeg, collateral value drops fast (good for liquidators, bad for borrowers).
- During a wstETH premium, borrowers over-borrow.
If you use only the accounting path:
- It never reflects depegs. If stETH trades at 0.92 ETH in spot markets, AAVE's LTV is calculated as if 1 stETH were still 1 ETH — and the position is actually undercollateralized.
The min() of both gives you the worst-case-always answer. When markets are calm (both paths agree), you get the obvious value. When markets diverge, you take the pessimistic side.
8. StVaultSpoke — The Fork#
The Spoke is where user-facing AAVE operations live: supply, borrow, repay, liquidationCall. Our custom Spoke is a fork of AAVE v4's stock Spoke.sol because neither liquidationCall nor _processUserAccountData is virtual.
8.1 The minimum viable diff#
Changes against stock Spoke.sol:
The edit to _processUserAccountData adds a branch: if the user has a registered dashboard AND is using the stVault reserve as collateral, pull collateral value from CapacityOracle instead of the standard supply balance accounting. All other reserves (USDC, USDT, GHO, WETH) fall through to stock logic.
The edit to liquidationCall adds a branch: if the collateral reserve is the stVault reserve, compute liquidation amounts using AAVE's LiquidationLogic math (bonus formula, debt-to-target formula, dust threshold), pull the debt token from the liquidator, reduce the debt, then call LiquidationAdapter.mintForLiquidation to produce the collateral payout. For any other collateral reserve, delegate to the parent flow.
8.2 Partial liquidation and the deficit path#
What happens when a vault's capacity is exhausted mid-liquidation?
AAVE v4 has a built-in deficit path: when a reserve has bad debt, the Hub socializes the loss across depositors via a debt-token-to-deposit-token rebase. It's not pretty but it's mechanical, and it doesn't require a governance vote. Our Spoke just opts in by calling reportDeficit when capacity hits zero with remaining debt.
8.3 The self-liquidation block#
A borrower liquidating themselves would net the bonus (paying their own debt and receiving their own collateral at a discount). Stock AAVE blocks it with msg.sender != user. Our fork preserves this check.
Harder edge case: can a borrower sybil themselves? Deploy a second EOA, transfer wstETH to it, have the second EOA call liquidationCall. This works today and is fine — the second EOA genuinely paid the debt token (whose value flows to the reserve), and received the bonus. The sybil case is economically identical to a legitimate arbitrage liquidator. The block on self-liquidation exists only because letting user == msg.sender would let the borrower escape the debt cost entirely (transferFrom from self to self is a no-op in their internal accounting). Sybil doesn't.
9. End-to-End Flows#
9.1 Setup — factory call to first borrow#
9.2 Repay and unpledge#
10. Threat Model#
10.1 Attack tree#
10.2 Mitigation map#
| Attack | Primary mitigation | Backup |
|---|---|---|
| A1a Revoke MINT_ROLE | Borrower doesn't hold DEFAULT_ADMIN_ROLE; PledgeGuard does. Guard only revokes MINT_ROLE when borrowCount == 0 via unlockMintRole. | — |
| A1b Config change | VAULT_CONFIGURATION_ROLE and VOLUNTARY_DISCONNECT_ROLE held by Guard; blocked while pledged. | — |
| A1c Front-run liquidation | Liquidation is a single transaction; the user can't interject between HF drop and liquidator call. Their only pre-liquidation option is repay, which is legitimate. | — |
| A2a Withdraw unlocked ETH | PledgeGuard.withdraw enforces totalValue >= max(lidoLocked, reservedETH) + amount. | — |
| A2b Transfer Dashboard ownership | PledgeGuard is the admin on the Dashboard, so Dashboard ownership is a property of the Guard's role graph, not a transferable thing. PledgeGuard's own transferOwnership is blocked while pledged. | — |
| A2c Disconnect vault | VOLUNTARY_DISCONNECT_ROLE held by Guard; Guard refuses while pledged. | — |
| A2d Change tier | VAULT_CONFIGURATION_ROLE held by Guard; Guard has no pass-through function. | — |
| A2e Force-exit validators | REQUEST_VALIDATOR_EXIT_ROLE held by Guard; blocked while pledged. Even if the validator exits naturally via protocol-level, ETH returns to vault liquid balance — not a drain. | Lido's own minimalReserve check. |
| A3a Chainlink manipulation | CAPO-style price cap limits rate jumps. Freshness check (1h for ETH/USD) bounds window. | AAVE PriceOracleSentinel grace period. |
| A3b Stale Lido report | MAX_VAULT_REPORT_STALENESS = 24h, stricter than Lido's 48h. Oracle reverts, liquidations pause. | Manual operator response via Sentinel. |
| A3c stETH depeg | Oracle uses min(accounting, market). Depeg reduces reported collateral value immediately. | LTV set conservatively (70%). |
| A3d Per-vault staleness | Currently a gap — the oracle uses LazyOracle.latestReportTimestamp() (global), not per-vault. Per-vault freshness needs to read each vault's own timestamp from the Hub. | — |
A4a Reenter mintForLiquidation | onlySpoke modifier + ReentrancyGuard. | Checks-effects-interactions (capacity consumed before mint). |
| A4b ERC-20 callbacks | stETH and wstETH are standard ERC-20 without transfer hooks. | — |
| A4c Cross-contract reentrancy | Capacity is decremented before the external mint and transfer. Even if the Dashboard had a callback, the Registry state is consistent. | — |
| A5a Mass liquidation | Conservative LTV (70%), staggered borrower HF curves, diversified liquidator cohort. | — |
| A5b Flash loan | Oracle does not read DEX prices; Lido accounting is slow-moving; Registry-side reserved capacity can't be inflated by borrower in the same tx. | Chainlink feeds are Chainlink-signed, not DEX-derived. |
| A6a Hostile AAVE param change | Params like LTV, bonus, liquidation fee are AAVE governance; any adverse change surfaces on forum first. Borrowers can repay. | BGD Labs / risk framework review. |
| A6b Lido interface change | Per-borrower PledgeGuards are immutable; existing positions keep their bytecode. New vaults use an updated factory. | No hot-path upgrade anywhere. |
10.3 The drain attack walk-through#
Let's walk through the most dangerous class of attack — the borrower trying to drain their own collateral before liquidation.
Precondition: Borrower has 100 ETH in vault, pledged 80 stETH shares (~80 ETH) to AAVE, borrowed enough to bring HF close to 1.
Attacker goal: extract ETH such that on HF drop below 1.0, the liquidator can't recover enough.
Vectors to try:
- Direct
Dashboard.withdraw(self, 80e18). Fails — borrower doesn't haveWITHDRAW_ROLE. Only the PledgeGuard does. Test confirms:test_borrowerCannotCallDashboardWithdrawDirectly. PledgeGuard.withdraw(self, 99e18). Fails — revertExceedsUnencumbered. Test:test_cannotDrainWhilePledged.PledgeGuard.transferOwnership(attackerTwo), thenattackerTwo.unlockMintRole(). Fails —transferOwnershiprevertPledgedVaultsCannotTransferOwnershipwhile pledged.PledgeGuard.requestValidatorExit(pubkey). Fails — revertPledgedVaultsCannotExitValidatorswhile pledged. Even if it succeeded, exiting returns ETH to the vault's liquid balance — not a drain.PledgeGuard.voluntaryDisconnect(). Fails — revertPledgedVaultsCannotDisconnectwhile pledged.Dashboard.transferOwnership(...). Fails — Dashboard doesn't have atransferOwnership(it uses AccessControl, not Ownable).grantRole(DEFAULT_ADMIN_ROLE, attackerTwo)would require the borrower to be an admin, which they aren't.- Call
StakingVault.withdrawdirectly. Fails — StakingVault's owner is VaultHub, not the borrower. Test:test_borrowerCannotCallStakingVaultDirectly.
Safe operations still work:
fund(n)— adds collateral. Test:test_canFundWhilePledged.withdraw(recipient, unencumbered)— takes exactly the free amount. Test:test_canWithdrawFreeCollateralWhilePledged.
The 7 drain tests in DrainAttack.t.sol against real mainnet Lido V3 contracts are the core of the security story.
11. Sharp Edges and Open Questions#
11.1 The fork tax#
Because Spoke.liquidationCall and _processUserAccountData aren't virtual, StVaultSpoke is a copy of Spoke.sol with additions. This has costs:
- Audit scope doubles. You're auditing the forked parent too, not just your additions.
- Upstream drift. If AAVE upgrades
Spoke.sol, you have to port changes manually. - Version skew. The deployed
StVaultSpokemay be behind the latest AAVE patchset.
The alternative — persuading AAVE to mark these functions virtual on a future Spoke version — would eliminate the fork entirely. That's an AAVE governance question, not a contract question. In the meantime, minimize the diff and document every changed line.
11.2 Per-vault vs global report freshness#
As noted in section 7.4, the current oracle reads LazyOracle.latestReportTimestamp() — global. This is a compromise: cheaper, simpler, but less accurate. The real check should read the per-vault report timestamp from VaultHub.vaultRecord(stakingVault).report.timestamp.
Why this matters: imagine a scenario where most vaults report on schedule but one pledged vault's operator is slow. The global timestamp is fresh; that vault's individual report is 30 hours old. The oracle returns a confident-looking value based on stale state. If meaningful slashing happened to that vault in the last 30 hours, the reported collateral value overstates reality.
Fix: switch _checkFreshness to read per-vault. Easy to do; just needs an interface update.
11.3 Rounding buffer economics#
The 2-wei MINT_BUFFER is a soft cost on the vault. In aggregate across many liquidations, it's negligible — 2 wei × number of liquidations, where 2 wei is 2×10^{-18} stETH. Even at a thousand liquidations per vault, this is less than a billionth of a penny. But auditors should note: the buffer is part of the pledged capacity's consumed count, so over a vault's lifetime, consumedShares will be very slightly higher than the sum of liquidator payouts.
11.4 Upgrade story for the Spoke#
The StVaultSpoke is deployed once and meant to be frozen. AAVE governance can, in principle, call updatePositionManager on it (if wired up), or the Hub can change which Spoke gets listed — but the Spoke's internal storage layout, constants, and liquidation logic are immutable from a user perspective. If a bug is found in the Spoke, a new Spoke must be deployed and the AAVE governance AIP updated to route new positions through it. Existing positions on the old Spoke continue under their original terms.
This is by design: no live-contract mutation means no hot-path governance attack surface. It's also a real maintenance constraint: you can't patch.
11.5 What would break with a Lido V3 interface change#
Every component that reads Lido has an immutable pointer to a Lido contract. If Lido ships a major change to Dashboard, VaultHub, or LazyOracle that changes their interface:
- Old
PledgeGuards keep calling the same functions. If the functions still exist, behavior continues. If they're removed or renamed, old Guards revert. - Old
CapacityOraclecalls may revert on a rename. - Existing positions would need to be migrated to a new stack before the interface cutover.
Mitigation: the Lido protocol's upgrade discipline (long deprecation windows, interface stability across minor versions) is a hard dependency of this system. This is a real trust assumption, even though there's no custodial trust. The trust is "Lido won't break backwards compat mid-loan."
11.6 VaultFactory signature discrepancy#
A fun implementation detail: the RoleAssignment struct on the deployed Lido VaultFactory on mainnet has a different field order than the on-disk source code. The deployed contract uses (address account, bytes32 role); the current master source uses (bytes32 role, address account). Selectors differ — 0x5b0aa953 vs 0xca25d9f2. Our ILidoVaultFactory interface uses the deployed ordering because that's what actually lives on chain.
This is the sort of discrepancy that only shows up when you run fork tests and look at actual selectors. It's a reminder: always reconcile your interface to deployed bytecode, not to whatever is on GitHub.
12. What the Contracts Look Like at Rest#
| Contract | Size | Mutable state? | Admin / upgrade path |
|---|---|---|---|
PledgeGuard | ~200 LOC | None | Ownable2Step; owner is the borrower |
LiquidationAdapter | ~120 LOC | None | None |
PledgeRegistry | ~185 LOC | Pledge map | None |
CapacityOracle | ~150 LOC | None | None |
StVaultFactory | ~150 LOC | None | None |
StVaultSpoke | ~200 LOC diff on ~900 LOC fork | Standard Spoke storage | AAVE governance |
Only the Registry and the Spoke have mutable state on the hot path. Both are constrained: the Registry's transitions are guarded by modifiers and monotonic in the capacity fields; the Spoke is governed by AAVE's normal risk-parameter process.
No hot-path admin, no hot-path multisig, no hot-path pause. The only human in the loop is AAVE governance, and only for risk-parameter tuning — not for liquidation execution.
13. Takeaways#
- Role graph engineering is the cheapest security mechanism available in a protocol like Lido V3 that uses OpenZeppelin AccessControl. You can achieve strong invariants ("borrower cannot reduce collateral while debt is outstanding") purely by rewiring which entities hold which roles, with no new mutable state.
- Minting at liquidation time is strictly cheaper than minting upfront — you get rid of double collateralization — but it requires the lender to treat a view function (remaining mint capacity) as a price. That in turn requires a composite oracle that takes the min of independent bounds.
- "Forking the parent contract" is a real cost when inheritance doesn't give you override points. It doubles audit scope and adds upstream drift. Understand which functions in your dependencies are
virtualbefore you start designing. - The 2-wei mint buffer is the kind of detail that only shows up on real fork tests. Budget for rounding slop in every share-based integration; don't assume
getSharesByPooledEth(getPooledEthByShares(x)) == x. - Permissionless unlock is the kind of design choice that initially feels wrong and is actually right. If the condition under which unlock is safe is publicly verifiable (here:
borrowCount == 0on-chain), there's no reason to require the borrower to be the caller. - Global vs per-vault is a recurring trap in oracle design. A global timestamp is cheap to read; it's also wrong when vaults diverge. Be explicit about which you're reading and why.
- Verify against deployed bytecode, not against source repositories. Interface drift between what's in the Lido GitHub repo and what's on mainnet can break your integration in surprising places.
The underlying pattern — immutable per-user guard with a small role graph, singleton adapter for privileged actions, composite oracle with conservative min — generalizes beyond stVaults. Any lending protocol that wants to use a synthetic-asset vault as collateral faces the same questions: who can drain the vault, what's the right price, who can mint, and what happens when the oracle is stale. The specific answers change; the shape doesn't.