Skip to content

feat: Add BabylonSpoke - #1345

Open
avniculae wants to merge 4 commits into
mainfrom
feat/babylon-spoke
Open

feat: Add BabylonSpoke#1345
avniculae wants to merge 4 commits into
mainfrom
feat/babylon-spoke

Conversation

@avniculae

@avniculae avniculae commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Adds a BabylonSpoke variant for the Babylon integration (vaultBTC collateral).

Liquidations are restricted to a configured liquidation manager and sized by a collateral removal cap instead of the target health factor. One call repays multiple debt reserves: each pair repays up to the given amount (premium first, no dust validation, no target-HF sizing), removes collateral priced by the canonical bonus formula, and emits a per-pair event, followed by a summary event with the totals.

The removal cap is tracked in asset terms and converted into removable shares before each repayment, so the total removed collateral tracks the cap as closely as rounding allows at any supply share price. When the priced removal exceeds the cap, the repayment is resized to exactly consume it. The sizing and rounding maths are proven with z3 scripts in tests/misc/z3/. Debt reserves the user no longer borrows are skipped, so front-running liquidations cannot block the call. The liquidation fee is never charged: the liquidator receives the full removed collateral, always in underlying assets.

Users can register only the managed collateral reserve as collateral, one at a time. The manager and the managed collateral reserve are set through updateBabylonLiquidationConfig, called directly by the executor rather than through the SpokeConfigurator. The config engine and the deployment engine support the new spoke, gated by the granular BABYLON_SPOKE_CONFIGURATOR_ROLE.

BabylonLiquidationLogic mirrors the canonical LiquidationLogic structure and compiles without via-ir. Canonical Spoke only gains virtual markers, keeping canonical instances bytecode-unchanged. BabylonSpokeInstance compiles at via-ir/450, the highest runs value that fits EIP-170 (121 bytes of margin).

The liquidation suites run against a dedicated fourth spoke deployed through the engine (BabylonBase), with the managed collateral non-borrowable and fee-free as in production. They mirror the canonical fuzz matrix (four shapes across nine config variants) and scenarios, and gas snapshots cover all user actions plus the liquidation paths.

@aave-code-review aave-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review covered an earlier commit. It is superseded by the review for 07b48dad.

@avniculae
avniculae force-pushed the feat/babylon-spoke branch 3 times, most recently from e1267b4 to 0cbf5a1 Compare August 28, 2026 13:52

@aave-code-review aave-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review covered an earlier commit. It is superseded by the review for c720149d.

@aave-code-review aave-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review covered an earlier commit. It is superseded by the review for 68de8b4a.

@aave-code-review aave-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review covered an earlier commit. It is superseded by the review for 66c27946.

Comment thread src/spoke/Spoke.sol
uint256 debtToCover,
bool receiveShares
) external nonReentrant {
) external virtual nonReentrant {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're marking virtual only on the functions BabylonSpoke overrides (this one and _validateSetUsingAsCollateral), which is not consistent from the Spoke perspective. My preference would be to take the opportunity and mark all relevant functions virtual — opinions?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im ok with all virtual tbh

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

public virtual

}

/// @dev Restricts users to at most one registered collateral.
function _validateSetUsingAsCollateral(

@avniculae avniculae Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This caps the count but deliberately does not check the reserve against managedCollateralReserveId: a user whose single registered collateral is another reserve is unreachable by liquidationCall, which is only safe while every other reserve keeps a zero collateral factor.

This hook does not receive the reserve id, so enforcing the id here would need a reserveId parameter added to the canonical hook.

The alternative is overriding the external setUsingAsCollateral (made public virtual for super) — which leaves the canonical spoke bytecode the same

Question for reviewers: keep the count-only check, or enforce the managed reserve id via the external override? The override is proven bytecode-free for canonical instances, so it's purely a question of whether we want the stricter invariant.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should a full reserve for loop using positionStatus.isUsingAsCollateral() be good for this?

The detailed change might be a function _validateBabylonPositionStatus() where all other reserves (!= managedCollateralReserveId) are enforced 0. It might create a gas cost overhead but should be more intuitive.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented: BabylonSpoke now overrides setUsingAsCollateral (made public virtual on the canonical Spoke, verified byte-identical) and requires reserveId == managedCollateralReserveId when enabling, with a dedicated UnsupportedCollateralReserve error. No loop needed since only the managed id can ever be registered.

The count check in _validateSetUsingAsCollateral stays on top: it still matters after a config change, when a user may hold the previously managed reserve while registering the new one.

uint256 debtReserveId = params.debtReserveIds[i];
require(params.debtToCoverAmounts[i] > 0, ISpoke.InvalidDebtToCover());
require(!reserves.get(debtReserveId).flags.paused(), ISpoke.ReservePaused());
// quadratic duplicate scan; the list is bounded by the user's borrowed reserves

@avniculae avniculae Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicates are rejected with a quadratic scan: the list is bounded by the user's borrowed reserves, so N should be small, which is why I preferred this over a temporary bitmap (which needed either inline bit manipulation or new PositionStatusMap helpers). Flagging in case anyone prefers a different mechanism here.

collateralAssetId: params.collateralAssetId,
collateralAssetUnit: MathUtils.uncheckedExp(10, params.collateralAssetDecimals),
collateralAssetPrice: IAaveOracle(params.oracle).getReservePrice(params.collateralReserveId),
liquidationBonus: LiquidationLogic.calculateLiquidationBonus({

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We compute lb only once, using the entry health factor, instead of recomputing HF after each debt reserve liquidated and recomputing lb. This should be fine, as lb will be the same or higher in this scenario, which means that we do not incentivise liquidaton splitting.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea and also bc we might be performing a liquidation when position has become healthy due to previous liqs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also bc we might be performing a liquidation when position has become healthy due to previous liqs

yes although in this case you could update the lb calculation fn to return min lb is healthy

params: liquidateDebtReservesParams
});

emit IBabylonSpoke.BabylonLiquidationCallSummary({

@avniculae avniculae Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you think about the current event structure? one summary event + one event per each debt reserve.

});

return
collateralUserPosition.suppliedShares == 0 &&

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't check activeCollateralCount bc we restrict collateral count to 1 already in the BabylonSpoke.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can replace collateralUserPosition.suppliedShares == 0 with liquidateDebtReservesParams.suppliedShares == collateralSharesRemoved

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

imo == 0 is also fine

assetId: params.collateralAssetId,
sharesToLiquidate: liquidationAmounts.collateralSharesToLiquidate,
sharesToLiquidator: liquidationAmounts.collateralSharesToLiquidate,
liquidator: params.liquidator,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this means that we execute multiple collateral transfers to the liquidator. I preferred this option over either:

  1. passing address(this) here and then send the transfer at the end
  2. deferring removing collateral towards the end and removing in one go bc then we would repay debt before removing collateral, which we know is problematic.

we have a third approach, which is same as 2, but we also defer debt repayment such that we still remove coll before repaying debt, but that would increase complexity.

worth noting that 2 might not be problematic is the collateral has a share price of 1, but I don't want to make assumptions about the share price in this implementation (although we could ofc, if we want).

thoughts?

@ngfam ngfam Aug 29, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From Babylon's point of view, if Aave does not enforce the price assumption, the adapter will have an extra layer of rounding to make sure that it receives enough collateral for a vault.

This though should not be an issue and the rounding should return an output exactly the same as the input, but seems to be addressed a lot more easily on the Spoke side.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We cannot enforce the supply share price assumption in the spoke, as the hub can let other spokes borrow the same asset in theory

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree, still believe it's safe to assume for this collateral (it's only on the hub bc we didnt want to build a custom hub for this collateral) but regardless the current option of multiple transfer seems fine to me (we're non reenetrent so there's no execution context handover and the underlying token does not have extra weird rounding on each transfer for allowance or balance)

@aave-code-review aave-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review covered an earlier commit. It is superseded by the review for 89dab3d9.

@aave-code-review aave-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review covered an earlier commit. It is superseded by the review for 20a8c400.

@aave-code-review aave-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review covered an earlier commit. It is superseded by the review for c33c8b4b.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI Tests CI time goes up from 1h40 to 2h40

@aave-code-review aave-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review covered an earlier commit. It is superseded by the review for 90f4e3f2.

@aave-code-review aave-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review covered an earlier commit. It is superseded by the review for a2072a1b.

@aave-code-review aave-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review covered an earlier commit. It is superseded by the review for 159e0292.

@aave-code-review aave-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review covered an earlier commit. It is superseded by the review for d215a492.

@aave-code-review aave-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review covered an earlier commit. It is superseded by the review for 9702e1fa.

@aave-code-review aave-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This review covered an earlier commit. It is superseded by the review for 6a1c8324.

@avniculae avniculae changed the title feat: add BabylonSpoke with a manager-gated, cap-bounded liquidationCall feat: Add BabylonSpoke Aug 29, 2026
.min(params.suppliedShares - totalCollateralSharesRemoved);
// no further repayments once the removal cap is consumed; the first repayment always runs,
// so debt can be liquidated even when the corresponding collateral amount to receive is zero
if (maxRemovableShares == 0 && i > 0) break;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wdyt about this condition?

});
}
if (deployInputs.spokeLabels.length > 0) {
if (deployInputs.spokeLabels.length > 0 || deployInputs.babylonSpokeLabels.length > 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this or needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is bc the roles for the standard spoke fns (that are inherited by Babylon spoke) should still be configured, wdyt?

/// @param collateralAmountRemoved The amount of collateral removed, expressed in asset units.
/// @param collateralSharesLiquidated The amount of collateral shares liquidated.
event BabylonLiquidationCall(
uint256 indexed debtReserveId,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

still fine i think to emit managedCollReserveId


return
collateralUserPosition.suppliedShares == 0 &&
userPositionStatus.nextBorrowing(params.reserveCount) != PositionStatusMap.NOT_FOUND;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just borrowCount > 1 is cheaper i think

)
.min(params.suppliedShares - totalCollateralSharesRemoved);
// no further repayments once the removal cap is consumed; the first repayment always runs,
// so debt can be liquidated even when the corresponding collateral amount to receive is zero

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we want to allow the first repayment again sorry?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for the same reason we allow no collateral to be liquidated while debt is being repaid. in an edge case scenario, the supply share price can be so high that one share is worth more than the entire debt. if we don't allow first repayment here, we would basically disallow 0 collateral liquidations

sharesToLiquidate: liquidationAmounts.collateralSharesToLiquidate,
sharesToLiquidator: liquidationAmounts.collateralSharesToLiquidate,
liquidator: params.liquidator,
receiveShares: false

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you remind me why pls

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we receive shares, anyone can bypass the liquidation manager (ie the adapter) and liquidate collateral up to any granularity

Comment on lines +427 to +428
// strict inequality is mandatory given rounding
if (params.debtToCover < premiumDebtRayToLiquidate.fromRayUp()) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dont we lose out on a dust premium case (premium < 1 ray anyway is fine to discard?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we lose any case here, this is btw the same code as the canonical spoke. please think through it again and make sure it's correct, will do the same

/// @notice Spoke variant for the Babylon integration: liquidations are restricted to a configured
/// liquidation manager and sized by a collateral cap instead of a target health factor. Users can
/// register at most one reserve as collateral.
abstract contract BabylonSpoke is IBabylonSpoke, Spoke {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we override _validate config methods to ensure liquidationFee is zero and no additional coll

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants