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.

Crypto Training2026-04-2334 min read

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#

flowchart LR ETH[100 ETH] -->|stake| SV[StakingVault] SV -->|mint| ST[92 stETH] ST -->|wrap| WST[wstETH] WST -->|supply| AAVE[AAVE Pool] AAVE -->|borrow| USDC[USDC] SV -. locks .- LOCK1[Lido minimalReserve] AAVE -. locks .- LOCK2[AAVE LTV buffer]

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:

flowchart LR VP[Pledged stVault\n100 ETH] -->|reserves capacity| AAVE[AAVE StVaultSpoke] AAVE -->|borrow| USDC[USDC] HF{HF lt 1.0} AAVE --> HF HF -->|yes| LIQ[Liquidator calls liquidationCall] LIQ -->|mints stETH, wraps, pays| LIQUIDATOR[Liquidator wallet] HF -->|no| HEAL[Position continues]

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.

graph TB subgraph PerBorrower[Per-borrower - one set per position] PG[PledgeGuard] DB[Dashboard - Lido stock] SV[StakingVault - Lido stock] end subgraph Singletons[Protocol singletons - deployed once] LA[LiquidationAdapter] PR[PledgeRegistry] CO[CapacityOracle] SVF[StVaultFactory] SVS[StVaultSpoke - fork of AAVE Spoke] end subgraph Lido[Lido V3 - untouched] VH[VaultHub] LO[LazyOracle] STETH[stETH / wstETH] end subgraph AAVE[AAVE v4 - untouched] HUB[Hub] end PG -->|owns roles on| DB DB --> SV PG --> PR LA -->|MINT_ROLE on| DB LA --> PR LA --> STETH SVS --> CO SVS --> LA SVS --> PR SVS --> HUB CO --> DB CO --> LO CO --> PR SVF --> DB SVF --> PG SVF --> PR DB --> VH VH --> LO
ContractRoleDeployed
PledgeGuardPer-borrower wrapper. Holds broad Dashboard admin roles; filters operations to prevent pledge erosion.Once per borrower
LiquidationAdapterSingleton. Sole holder of MINT_ROLE on every pledged Dashboard. Mints only via Spoke-initiated liquidation.Once
PledgeRegistrySingleton. Source of truth for pledge state: who pledged what to which Spoke, what's reserved, what's been consumed.Once
CapacityOracleSingleton. Composite price feed: Lido accounting authority × Chainlink market rate × freshness checks.Once
StVaultFactorySingleton. Atomic deployment: creates the Lido vault, deploys the PledgeGuard, rewires the role graph.Once
StVaultSpokeSingleton. 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#

RoleHolderWhy
DEFAULT_ADMIN_ROLEPledgeGuardSole administrator — the only thing that can grant or revoke roles.
WITHDRAW_ROLEPledgeGuardWithdrawal must be pledge-aware.
VOLUNTARY_DISCONNECT_ROLEPledgeGuardDisconnection must be blocked while pledged.
VAULT_CONFIGURATION_ROLEPledgeGuardTier changes must be blocked while pledged.
REQUEST_VALIDATOR_EXIT_ROLEPledgeGuardExiting validators is a pledge-eroding action.
TRIGGER_VALIDATOR_WITHDRAWAL_ROLEPledgeGuardSame.
FUND_ROLEBorrowerAdding ETH is strictly safe (additive to collateral).
BURN_ROLEBorrowerBurning reduces liability — always safe.
REBALANCE_ROLEBorrowerRebalancing improves health.
MINT_ROLELiquidationAdapter (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#

stateDiagram-v2 [*] --> BorrowerAdmin : Factory init\n(Factory = DEFAULT_ADMIN at creation) BorrowerAdmin --> GuardAdmin : Factory grants DEFAULT_ADMIN to PledgeGuard\nFactory revokes DEFAULT_ADMIN from self GuardAdmin --> GuardMintLocked : PledgeGuard.lockMintRole\n(MINT_ROLE granted to LiquidationAdapter) GuardMintLocked --> GuardAdmin : PledgeGuard.unlockMintRole\n(debt==0, MINT_ROLE revoked) GuardAdmin --> BorrowerAdmin : PledgeGuard.returnFullControl\n(DEFAULT_ADMIN back to borrower) BorrowerAdmin --> [*] : Vault operates as standalone Lido stVault

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:

SOLIDITY
// 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#

graph TB subgraph Dashboard[Dashboard role holders] GUARD[PledgeGuard\nDEFAULT_ADMIN\nWITHDRAW\nVOLUNTARY_DISCONNECT\nVAULT_CONFIGURATION\nREQUEST_VALIDATOR_EXIT\nTRIGGER_VALIDATOR_WITHDRAWAL] BORROWER[Borrower EOA\nFUND\nBURN\nREBALANCE\nPAUSE_BEACON_CHAIN_DEPOSITS\nRESUME_BEACON_CHAIN_DEPOSITS] ADAPTER[LiquidationAdapter\nMINT_ROLE\nonly while pledged] end

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#

SOLIDITY
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#

stateDiagram-v2 [*] --> Unpledged : Factory setup complete Unpledged --> Pledged : lockMintRole(reservedShares)\n(MINT_ROLE granted,\nPledgeRegistry updated) Pledged --> Pledged : addReservedCapacity\n(grow the pledge) Pledged --> Pledged : AAVE operations\n(borrow / repay / liquidate) Pledged --> Unpledged : unlockMintRole\n(requires borrowCount==0) Unpledged --> FullControl : returnFullControl\n(DEFAULT_ADMIN back to borrower) FullControl --> [*] : Guard is now a no-op; borrower runs vault standalone

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.

SOLIDITY
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:

sequenceDiagram autonumber actor Attacker as Malicious Borrower participant PG as PledgeGuard participant DB as Dashboard participant PR as PledgeRegistry participant STETH as stETH Note over Attacker: Vault has 100 ETH total, 80 ETH reserved on AAVE, borrowed 60 USDC Attacker->>PG: withdraw(self, 99 ether) PG->>DB: totalValue() DB-->>PG: 100 ETH PG->>DB: locked() DB-->>PG: 1 ETH (connect deposit only) PG->>PR: availableCapacityShares(dashboard) PR-->>PG: 80 ETH worth of shares PG->>STETH: getPooledEthByShares(80 ether) STETH-->>PG: 80 ETH PG->>PG: effectiveLocked = max(1, 80) = 80 PG->>PG: require(100 >= 80 + 99) FAILS PG-->>Attacker: revert ExceedsUnencumbered

4.4 Operations that must be blocked while pledged#

OperationBehavior 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#

SOLIDITY
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.

SOLIDITY
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:

  1. Status check — the pledge is active.
  2. Caller check — the caller is exactly the Spoke recorded for this pledge.
  3. Borrower check — the Spoke claims the liquidation is for the same borrower we pledged for.
  4. Capacity check — the requested shares fit within unconsumed capacity.

And three state effects, in the right order:

  1. Registry capacity is decremented first (effects before interactions).
  2. Dashboard mints stETH to this adapter.
  3. 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#

sequenceDiagram autonumber actor LiqBot as Liquidator participant SVS as StVaultSpoke participant LA as LiquidationAdapter participant PR as PledgeRegistry participant DB as Dashboard participant VH as VaultHub participant STETH as stETH participant WSTETH as wstETH participant Hub as AAVE Hub Note over LiqBot: Off-chain: monitors HF, detects HF lt 1.0 for borrower X LiqBot->>SVS: liquidationCall(STVAULT, USDC, X, 5000 USDC) SVS->>SVS: recompute HF with CapacityOracle SVS->>SVS: compute actualDebt, stETHToMint, wstETHToLiquidator SVS->>LiqBot: transferFrom(liquidator, Hub, 5000 USDC) SVS->>Hub: reduceDebtViaLiquidation(X, 5000) SVS->>LA: mintForLiquidation(dashboard, X, stETH, wstETH, liquidator) LA->>PR: pledges(dashboard) - check status, spoke, borrower, capacity PR-->>LA: Pledge record LA->>PR: consumeCapacity(dashboard, shares) LA->>DB: mintShares(self, shares) DB->>VH: mintShares(vault, adapter, shares) VH->>STETH: increment adapter balance LA->>WSTETH: wrap(balanceOf(self)) WSTETH-->>LA: wstETHReceived LA->>WSTETH: transfer(liquidator, wstETHToLiquidator) LA->>LiqBot: LiquidationMint event

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#

SOLIDITY
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#

SOLIDITY
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#

stateDiagram-v2 [*] --> Unregistered : Dashboard created but no guard registered yet Unregistered --> RegisteredUnpledged : Factory.registerPledgeGuard(guard, dashboard) RegisteredUnpledged --> Pledged : guard.markPledged(dashboard, borrower, spoke, reservedShares) Pledged --> Pledged : guard.addReservedCapacity(dashboard, more) Pledged --> Pledged : adapter.consumeCapacity(dashboard, shares)\n(partial liquidation) Pledged --> RegisteredUnpledged : guard.markUnpledged(dashboard)\n(reserved = 0, consumed = 0) RegisteredUnpledged --> Pledged : guard.markPledged again\n(re-pledge after repay)

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:

CODE
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#

flowchart LR subgraph Lido[Lido accounting authority] L1[Dashboard.remainingMintingCapacityShares 0] L2[LazyOracle.latestReportTimestamp] L3[LazyOracle.vaultQuarantine] end subgraph Registry[AAVE-side pledge] R1[Registry.availableCapacityShares] end subgraph Chainlink[Chainlink market data] C1[ETH/USD feed] C2[wstETH/ETH feed] end L1 --> MIN[min] R1 --> MIN L2 --> FRESH[freshness check] L3 --> QUAR[quarantine check] MIN --> STETHAMT[enforceableStETH] STETHAMT --> PATH_A[Path A: accounting\nstETH x ETH/USD] STETHAMT --> PATH_B[Path B: market\nwstETH x wstETH/ETH x ETH/USD] C1 --> PATH_A C1 --> PATH_B C2 --> PATH_B PATH_A --> FINALMIN[min] PATH_B --> FINALMIN FINALMIN --> OUT[collateral value USD_1e8]

Three independent bounds, and the oracle takes the most conservative.

7.2 The actual computation#

SOLIDITY
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:

  1. 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.

  2. Implicit min in availableCapacityShares (which is reserved - consumed). After a partial liquidation, consumed shares are already spent — they can't be minted twice.

  3. 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#

SOLIDITY
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:

flowchart TD Spoke_sol[AAVE v4 Spoke sol stock] subgraph Ours[StVaultSpoke - fork] FA[storage: userDashboard mapping] FB[storage: STVAULT_RESERVE_ID] FC[new: registerPledge / deregisterPledge] FD[edit: _processUserAccountData] FE[edit: liquidationCall] end Spoke_sol --> FA Spoke_sol --> FB Spoke_sol --> FC Spoke_sol --> FD Spoke_sol --> FE

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?

sequenceDiagram autonumber actor LiqBot as Liquidator participant SVS as StVaultSpoke participant CO as CapacityOracle participant LA as LiquidationAdapter participant PR as PledgeRegistry participant Hub as AAVE Hub Note over LiqBot: Wants to cover 20k USDC debt, vault undercollateralized after slashing LiqBot->>SVS: liquidationCall(STVAULT, USDC, user, 20000, false) SVS->>CO: getCollateralValueUSD(dashboard) CO-->>SVS: 10000 USD (capacity reduced by loss) SVS->>SVS: actualDebt = 10000 USD worth\nstETHToMint = max available\nremaining = 10000 USD SVS->>LA: mintForLiquidation(dashboard, user, max, max, liquidator) LA->>PR: consumeCapacity(dashboard, maxShares) PR->>PR: availableCapacity = 0 SVS->>SVS: _handleDeficitIfAny(user) SVS->>Hub: reportDeficit(USDC, remaining 10000) SVS-->>LiqBot: PartialLiquidation event\nDeficitReported event

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#

sequenceDiagram autonumber actor Borrower participant SVF as StVaultFactory participant LVF as Lido VaultFactory participant DB as Dashboard (new) participant SV as StakingVault (new) participant PG as PledgeGuard (new) participant PR as PledgeRegistry participant LA as LiquidationAdapter participant SVS as StVaultSpoke Borrower->>SVF: createBorrowerVault(borrower, op, feeBP, expiry){value: 1 ETH} SVF->>LVF: createVaultWithDashboard(factory, op, ...) LVF-->>SVF: (stakingVault, dashboard) SVF->>PG: new PledgeGuard(dashboard, LA, PR, SPOKE, borrower) SVF->>DB: grantRole(DEFAULT_ADMIN, PG) SVF->>DB: grantRole(broad pledge-eroding roles, PG) SVF->>DB: grantRole(safe roles, borrower) SVF->>DB: revokeRole(DEFAULT_ADMIN, self) SVF->>PR: registerPledgeGuard(PG, dashboard) SVF-->>Borrower: (dashboard, stakingVault, pledgeGuard) Note over Borrower: Fund with 100 ETH Borrower->>PG: fund(100 ether){value: 100 ETH} PG->>DB: fund(){value: 100 ETH} DB->>SV: fund() Note over Borrower: Pledge 80 stETH shares to AAVE Borrower->>PG: lockMintRole(80 ether) PG->>DB: grantRole(MINT_ROLE, LA) PG->>PR: markPledged(DB, borrower, SPOKE, 80 ether) PG-->>Borrower: MintRoleLocked event Note over Borrower: Register vault as AAVE collateral Borrower->>SVS: registerPledge(dashboard) SVS->>SVS: userDashboard[borrower] = dashboard SVS->>SVS: setUsingAsCollateral(STVAULT_RESERVE_ID, true) Note over Borrower: Now borrow 50k USDC Borrower->>SVS: borrow(USDC, 50000, borrower) SVS->>CO: getCollateralValueUSD(dashboard) CO-->>SVS: ~80 ETH worth USD SVS->>SVS: HF check passes SVS-->>Borrower: 50000 USDC

9.2 Repay and unpledge#

sequenceDiagram autonumber actor Borrower participant SVS as StVaultSpoke participant LA as LiquidationAdapter participant PG as PledgeGuard participant DB as Dashboard participant PR as PledgeRegistry Borrower->>SVS: repay(USDC, fullDebt, borrower) SVS->>SVS: debt cleared, borrowCount == 0 Borrower->>SVS: deregisterPledge() SVS->>LA: isBorrowerDebtFree(spoke, borrower)? LA->>SVS: getUserAccountData(borrower).borrowCount == 0 SVS->>SVS: userDashboard[borrower] = 0\nsetUsingAsCollateral(false) SVS-->>Borrower: PledgeDeregistered Borrower->>PG: unlockMintRole() PG->>LA: isBorrowerDebtFree(spoke, owner())? LA-->>PG: true PG->>DB: revokeRole(MINT_ROLE, LA) PG->>PR: markUnpledged(dashboard) PG-->>Borrower: MintRoleUnlocked opt Return full control Borrower->>PG: returnFullControl() PG->>DB: grantRole(DEFAULT_ADMIN, borrower) PG->>DB: revokeRole(DEFAULT_ADMIN, self) end

10. Threat Model#

10.1 Attack tree#

graph TD Root[Attacker wants to extract value] Root --> A1[Evade liquidation while keeping debt] Root --> A2[Drain vault collateral before liquidation] Root --> A3[Oracle manipulation] Root --> A4[Reentrancy or cross-contract abuse] Root --> A5[LST depeg economic attack] Root --> A6[Governance or config attack] A1 --> A1a[Revoke MINT_ROLE directly] A1 --> A1b[Block liquidation via config] A1 --> A1c[Front-run a pending liquidation] A2 --> A2a[Withdraw unlocked ETH] A2 --> A2b[Transfer Dashboard ownership] A2 --> A2c[Disconnect vault from Lido] A2 --> A2d[Change tier to reduce capacity] A2 --> A2e[Force-exit validators to drain balance] A3 --> A3a[Manipulate Chainlink feed] A3 --> A3b[Stale Lido report during move] A3 --> A3c[Exploit stETH depeg window] A3 --> A3d[Per-vault vs global staleness mismatch] A4 --> A4a[Reenter mintForLiquidation] A4 --> A4b[ERC-20 callback reentrancy] A4 --> A4c[Cross-contract reentrancy] A5 --> A5a[Mass liquidation cascade] A5 --> A5b[Flash-loan price manipulation] A6 --> A6a[AAVE governance hostile param change] A6 --> A6b[Lido DAO breaks interface]

10.2 Mitigation map#

AttackPrimary mitigationBackup
A1a Revoke MINT_ROLEBorrower doesn't hold DEFAULT_ADMIN_ROLE; PledgeGuard does. Guard only revokes MINT_ROLE when borrowCount == 0 via unlockMintRole.
A1b Config changeVAULT_CONFIGURATION_ROLE and VOLUNTARY_DISCONNECT_ROLE held by Guard; blocked while pledged.
A1c Front-run liquidationLiquidation 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 ETHPledgeGuard.withdraw enforces totalValue >= max(lidoLocked, reservedETH) + amount.
A2b Transfer Dashboard ownershipPledgeGuard 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 vaultVOLUNTARY_DISCONNECT_ROLE held by Guard; Guard refuses while pledged.
A2d Change tierVAULT_CONFIGURATION_ROLE held by Guard; Guard has no pass-through function.
A2e Force-exit validatorsREQUEST_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 manipulationCAPO-style price cap limits rate jumps. Freshness check (1h for ETH/USD) bounds window.AAVE PriceOracleSentinel grace period.
A3b Stale Lido reportMAX_VAULT_REPORT_STALENESS = 24h, stricter than Lido's 48h. Oracle reverts, liquidations pause.Manual operator response via Sentinel.
A3c stETH depegOracle uses min(accounting, market). Depeg reduces reported collateral value immediately.LTV set conservatively (70%).
A3d Per-vault stalenessCurrently 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 mintForLiquidationonlySpoke modifier + ReentrancyGuard.Checks-effects-interactions (capacity consumed before mint).
A4b ERC-20 callbacksstETH and wstETH are standard ERC-20 without transfer hooks.
A4c Cross-contract reentrancyCapacity is decremented before the external mint and transfer. Even if the Dashboard had a callback, the Registry state is consistent.
A5a Mass liquidationConservative LTV (70%), staggered borrower HF curves, diversified liquidator cohort.
A5b Flash loanOracle 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 changeParams 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 changePer-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:

  1. Direct Dashboard.withdraw(self, 80e18). Fails — borrower doesn't have WITHDRAW_ROLE. Only the PledgeGuard does. Test confirms: test_borrowerCannotCallDashboardWithdrawDirectly.
  2. PledgeGuard.withdraw(self, 99e18). Fails — revert ExceedsUnencumbered. Test: test_cannotDrainWhilePledged.
  3. PledgeGuard.transferOwnership(attackerTwo), then attackerTwo.unlockMintRole(). Fails — transferOwnership revert PledgedVaultsCannotTransferOwnership while pledged.
  4. PledgeGuard.requestValidatorExit(pubkey). Fails — revert PledgedVaultsCannotExitValidators while pledged. Even if it succeeded, exiting returns ETH to the vault's liquid balance — not a drain.
  5. PledgeGuard.voluntaryDisconnect(). Fails — revert PledgedVaultsCannotDisconnect while pledged.
  6. Dashboard.transferOwnership(...). Fails — Dashboard doesn't have a transferOwnership (it uses AccessControl, not Ownable). grantRole(DEFAULT_ADMIN_ROLE, attackerTwo) would require the borrower to be an admin, which they aren't.
  7. Call StakingVault.withdraw directly. 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 StVaultSpoke may 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 CapacityOracle calls 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#

ContractSizeMutable state?Admin / upgrade path
PledgeGuard~200 LOCNoneOwnable2Step; owner is the borrower
LiquidationAdapter~120 LOCNoneNone
PledgeRegistry~185 LOCPledge mapNone
CapacityOracle~150 LOCNoneNone
StVaultFactory~150 LOCNoneNone
StVaultSpoke~200 LOC diff on ~900 LOC forkStandard Spoke storageAAVE 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 virtual before 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 == 0 on-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.