[TEMP CHECK] Base Pool Upgrade - prevents permanent fund locks

Hello,

I ran into a problem today and would like to propose a small change to address it:

[TEMP CHECK] Base Pool: allow repay(MaxUint256) for third-party onBehalfOf (prevents permanent fund locks)

Author

NestorKurtz

Summary

On Aave V3 Base, Pool.repay(asset, type(uint256).max, rateMode, onBehalfOf) reverts when the caller is a third-party contract and onBehalfOf != msg.sender. This can permanently lock funds in non-upgradeable helper contracts that use MaxUint256 as “repay as much as possible” to safely handle interest drift.This post proposes a minimal backward-compatible Pool patch so that the MaxUint256 sentinel clamps to payer balance / user debt instead of reverting in the third-party onBehalfOf path.

Motivation

Aave has previously shipped Pool patches to restore compatibility for integrators with immutable infrastructure (e.g. v3.2 legacy periphery patch proposalId=182). This is a similar class of issue: an integration pattern that is safe and widely used elsewhere (MaxUint256 as “repay all”) is not executable on Base for third-party repayments, and the failure mode is catastrophic for immutable helpers.

Real incident impact (reproducible)

  • Helper contract (non-upgradeable): 0x7e3a8d6154adec875D40583fC527c18cBf4330c6

  • Locked funds: 1052.246268 USDC

  • Pool (Base): 0xA238Dd80C259a72e81d7e4664a9801593F98d1c5

  • USDC: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913

  • Observed revert selector: 0xcd3779c3

  • Reverted txs:

  • 0xb46c6a9f36c797d6fe10d3cd87103c3fd825e2974608bf971a6e818e4ea82d13 (block 42269963)

  • 0x3cc4dedd31b2ee4e2458f65f6a9ac2ee66d1805f60718aafaea82b9e443b8b55 (block 42270155)

Static-call proof:

  • repay(MaxUint256, onBehalfOf=borrower) from helper => reverts (0xcd3779c3)

  • repay(exactAmount, onBehalfOf=borrower) from helper => succeeds

Specification (proposed behavior change)

Current behavior (Base)

When onBehalfOf != msg.sender, repay(amount=type(uint256).max) reverts.

Proposed behavior

When amount == type(uint256).max in a third-party repay call, treat it as a sentinel value meaning:

amountToRepay = min(
userOutstandingDebt(onBehalfOf, interestRateMode),
IERC20(asset).balanceOf(msg.sender)
)

Then proceed with repayment using amountToRepay.If amountToRepay == 0, return 0 (no revert).This preserves the intent of “repay all you can” and prevents bricking immutable helpers.

Why this is safe / desirable

  • It does not let a third-party repay “too much” (it is explicitly bounded by payer balance and user debt).

  • It matches the mental model integrators already use on many chains/protocols: MaxUint256 as “repay max possible.”

  • It avoids catastrophic irrecoverable-funds outcomes for immutable contracts.

  • It aligns with Aave’s history of making minimal Pool upgrades to improve backwards compatibility (e.g. proposalId=182).

Security considerations

  • Needs review for any edge cases where balanceOf(msg.sender) differs from transferable amount (fee-on-transfer tokens, etc.). For Base USDC this is standard ERC20.

  • Must ensure stable/variable debt modes remain correct; sentinel should apply per debt mode.

  • Should maintain existing revert conditions for invalid rate modes / inactive reserves.

  • Should include full regression tests around repay flows:

  • self repay vs onBehalfOf repay

  • partial repay vs full repay

  • zero debt case (must not revert)

  • repay when payer has 0 balance (must not revert)

Implementation / upgrade mechanism

Aave governance can upgrade Pool logic on Base via PoolAddressesProvider.setPoolImpl(newPoolImpl) (governance-owned). Official docs: Pool Addresses Provider | Aave Protocol Documentation

References / evidence

Public sanitized incident pack:

Full evidence (private, shareable with Aave contributors on request).

Ask

  1. Is the current revert behavior expected by design on Base?

  2. If yes, would Aave consider this sentinel-clamp behavior as a compatibility improvement?

  3. If feasible, what’s the path/timeline for a Pool patch on Base?

Regarding the above proposed change:

it would be a small and bounded one:

Implementation sketch (minimal Pool patch)

The change can be implemented as a small branch inside the existing Pool.repay() flow (or the internal repay logic), only affecting the

amount == type(uint256).max sentinel when onBehalfOf != msg.sender.

High-level pseudocode:

*function repay(asset, amount, rateMode, onBehalfOf) returns (uint256 repaid) {
// existing validation: reserve active, rateMode valid, debt exists, etc.

if (amount == type(uint256).max) {
uint256 payerBal = IERC20(asset).balanceOf(msg.sender);

uint256 userDebt = (rateMode == 2)
? IERC20(variableDebtToken).balanceOf(onBehalfOf)
: IERC20(stableDebtToken).balanceOf(onBehalfOf);

uint256 amountToRepay = payerBal < userDebt ? payerBal : userDebt;

if (amountToRepay == 0) {
return 0; // no revert, no state change
}

amount = amountToRepay;
}

// continue with existing repay logic (burn debt tokens, update indexes, etc.)
return _executeRepay(asset, amount, rateMode, onBehalfOf);
}*

Why this is low-risk / low-effort:

  • It does not change any state transitions besides choosing a bounded amount.

  • It preserves existing behavior for all normal calls (amount != MaxUint256).

  • It prevents the catastrophic “immutable helper bricked forever” scenario without letting third parties overpay (explicit clamp to min(debt, payerBalance)).

Edge cases to confirm in tests:

  • borrower debt = 0 → returns 0

  • payer balance = 0 → returns 0

  • payer balance < debt → partial repay succeeds

  • existing revert paths for invalid rateMode remain unchanged

How governance upgrade would look (Base)

As with other v3 patches, this would be a standard Pool implementation upgrade on Base:

  1. Deploy Pool implementation with the above change.

  2. 2.Governance payload calls (on Base market provider):

  • PoolAddressesProvider.setPoolImpl(newPoolImpl)

Docs reference:

PoolAddressesProvider can update Pool implementation via setPoolImpl().

This is exactly the same upgrade mechanism used in past Pool patches (e.g. v3.2 legacy periphery patch).

Hello, if you think sth is a bug the appropriate way is to report it on immunify.

That being said, the pools accross networks are identical and do max clamping like they always did (clamping type(uint256).max when onBehalfOf == user => reverting if not).
I think the rationale for not clamping onBehalfOf is that it would be an attack vector (frontrun the repayment with a borrow).

2 Likes

Hello!

Thanks for the comment and clarification — that helps. Just to reflect back, what I understand:

  • The Pool behavior is consistent across networks.

  • type(uint256).max is intentionally treated as a special “repay all” sentinel only when onBehalfOf == msg.sender (self-repay).

  • When its onBehalfOf != msg.sender , the Pool does not “max-clamp” and instead reverts, because clamping there could enable some frontrun scenarios (the case could be, that someone borrows right before the repay and a third party ends up repaying more than they intended).

Totally fair, I acknowledge that. Surely this is a legitimate safety rationale and I’m not trying to frame it as a bug, if this is by design. Therefor I hadn’t considered Immunefy.

Regarding integration safety my concern is practical:

immutable helper contracts that used the common pattern “max means repay as much as possible”, well they can end up permanently locking funds if they ever call repay(max, …, onBehalfOf != msg.sender).

That’s exactly what happened in my incident: the helper holds funds, but its only exit path reverts at step 1 due to this rule.

What I am cconsidering now are possible safe alternatives:

like, if the current behavior must stay, could we consider an alternative interface that preserves the security property but avoids permanent fund locks? For example:

  • a dedicated repayOnBehalfUpTo(amountCap, …) (explicit cap, no sentinel)

  • or a “repay all debt” function that requires additional constraints / explicit user intent (so it’s not an accidental sponsorship vector)

  • or at minimum, clear documentation guidance for integrators: “never use max for third-party onBehalfOf repay; always pass an explicit amount”

I am not assuming this to be unintended, I suppose this is a protocol design choice with an important integration footgun that deserves a mitigation path or explicit documentation.