Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
105766b
fix: update VBep20 and VToken interfaces for internal cash tracking
Debugger022 Mar 16, 2026
8d7f003
test: update unit tests for internal cash tracking changes
Debugger022 Mar 16, 2026
38f7492
test: add fork tests for donation attack and internal cash upgrade
Debugger022 Mar 16, 2026
761c400
test(fork): improve internalCashUpgrade tests with descriptive error …
Debugger022 Mar 16, 2026
5afbd42
feat: add sweepToken in assembly and optimize VBep20 for contract size
Debugger022 Mar 16, 2026
04f6231
refactor: simplify sweepToken by removing redundant token parameter
Debugger022 Mar 16, 2026
0f01777
fix(VBep20): use correct selector for isAllowedToCall in sweepToken
Debugger022 Mar 16, 2026
610ad07
fix(VBep20): restore free memory pointer in sweepToken assembly
Debugger022 Mar 16, 2026
e81b0bf
test: add sweepToken and syncCash unit tests and vXAUM to fork markets
Debugger022 Mar 16, 2026
cf48a2a
test(fork): reorganize donationAttack tests with listed markets
Debugger022 Mar 16, 2026
05db3e5
chore(test): clean up formatting and remove unused imports
Debugger022 Mar 16, 2026
2f578e9
refactor(VBep20): merge syncCash and sweepToken into sweepTokenAndSync
Debugger022 Mar 16, 2026
7dcd781
test: consolidate and improve donation attack and sweep tests
Debugger022 Mar 16, 2026
94373fe
docs(VBep20): update natspec to reflect internalCash tracking
Debugger022 Mar 17, 2026
c88b6e4
chore(deploy): deploy VBep20Delegate to BSC mainnet
Debugger022 Mar 17, 2026
0a905ab
feat: updating deployment files
Debugger022 Mar 17, 2026
5c311b0
chore: remove duplicate import and unused typechain imports
Debugger022 Mar 19, 2026
373e97c
test(fork): improve storage checks and move upgrade guard to before()
Debugger022 Mar 19, 2026
e1b9231
docs(audits): add VBep20 donation patch audit reports
Debugger022 Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 60 additions & 3 deletions contracts/Tokens/VTokens/VBep20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ pragma solidity 0.8.25;

import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

import { ComptrollerInterface } from "../../Comptroller/ComptrollerInterface.sol";
import { InterestRateModelV8 } from "../../InterestRateModels/InterestRateModelV8.sol";
import { VBep20Interface, VTokenInterface } from "./VTokenInterfaces.sol";
Expand Down Expand Up @@ -223,8 +222,10 @@ contract VBep20 is VToken, VBep20Interface {
uint256 balanceBefore = token.balanceOf(address(this));
token.safeTransferFrom(from, address(this), amount);
uint256 balanceAfter = token.balanceOf(address(this));
uint256 actualAmount = balanceAfter - balanceBefore;
internalCash += actualAmount;
// Return the amount that was *actually* transferred
return balanceAfter - balanceBefore;
return actualAmount;
}

/**
Expand All @@ -233,6 +234,7 @@ contract VBep20 is VToken, VBep20Interface {
* @param amount Amount of underlying to transfer
*/
function doTransferOut(address payable to, uint256 amount) internal virtual override {
internalCash -= amount;
IERC20 token = IERC20(underlying);
token.safeTransfer(to, amount);
}
Expand All @@ -243,6 +245,61 @@ contract VBep20 is VToken, VBep20Interface {
* @return The quantity of underlying tokens owned by this contract
*/
function getCashPrior() internal view override returns (uint) {
return IERC20(underlying).balanceOf(address(this));
return internalCash;
}

/**
* @notice Admin function to sync internalCash with actual token balance
* @dev Used for one-time migration after upgrading existing deployed markets
*/
function syncCash() external {
require(msg.sender == admin, "only admin");
uint256 oldInternalCash = internalCash;
internalCash = IERC20(underlying).balanceOf(address(this));
emit CashSynced(oldInternalCash, internalCash);
}

/**
* @notice Recover excess underlying tokens sent directly to the contract (e.g. donation attack)
* @dev Only recovers the difference between actual balance and internalCash. ACM controlled.
* @param token The address of the token to sweep (must be the underlying)
*/
function sweepToken(address token) external {
uint256 excess;

assembly {
// ACM check: accessControlManager.isAllowedToCall(msg.sender, "sweepToken(address)")
let ptr := mload(0x40)
mstore(ptr, 0x7e13143600000000000000000000000000000000000000000000000000000000)
mstore(add(ptr, 0x04), caller())
mstore(add(ptr, 0x24), 0x40)
mstore(add(ptr, 0x44), 21)
mstore(add(ptr, 0x64), "sweepToken(address)\x00\x00")
let _acm := sload(accessControlManager.slot)
if iszero(staticcall(gas(), _acm, ptr, 0x84, ptr, 0x20)) { revert(0, 0) }
if iszero(mload(ptr)) { revert(0, 0) }

// require(token == underlying)
if iszero(eq(token, sload(underlying.slot))) { revert(0, 0) }

// excess = balanceOf(this) - internalCash
mstore(0x00, 0x70a0823100000000000000000000000000000000000000000000000000000000)
mstore(0x04, address())
if iszero(staticcall(gas(), token, 0x00, 0x24, 0x00, 0x20)) { revert(0, 0) }
excess := sub(mload(0x00), sload(internalCash.slot))
if iszero(excess) { revert(0, 0) }

// transfer(msg.sender, excess)
mstore(0x00, 0xa9059cbb00000000000000000000000000000000000000000000000000000000)
mstore(0x04, caller())
mstore(0x24, excess)
if iszero(call(gas(), token, 0, 0x00, 0x44, 0x00, 0x20)) { revert(0, 0) }
if returndatasize() {
if iszero(mload(0x00)) { revert(0, 0) }
}
}

emit TokenSwept(token, msg.sender, excess);
}

}
18 changes: 17 additions & 1 deletion contracts/Tokens/VTokens/VTokenInterfaces.sol
Original file line number Diff line number Diff line change
Expand Up @@ -172,12 +172,18 @@ contract VTokenStorage is VTokenStorageBase {
*/
uint256 public flashLoanAmount;

/**
* @notice Tracked internal cash balance, immune to direct token transfers (donation attacks)
* @dev Updated only via doTransferIn/doTransferOut. Must be initialized via syncCash() after upgrade.
*/
uint256 public internalCash;

/**
* @dev This empty reserved space is put in place to allow future versions to add new
* variables without shifting down storage in the inheritance chain.
* See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps
*/
uint256[46] private __gap;
uint256[45] private __gap;
}

abstract contract VTokenInterface is VTokenStorage {
Expand Down Expand Up @@ -320,6 +326,16 @@ abstract contract VTokenInterface is VTokenStorage {
uint256 protocolFee
);

/**
* @notice Event emitted when internalCash is synced with actual token balance
*/
event CashSynced(uint256 oldInternalCash, uint256 newInternalCash);

/**
* @notice Event emitted when excess tokens are swept by admin
*/
event TokenSwept(address indexed token, address indexed recipient, uint256 amount);

/**
* @notice Event emitted when flashLoan fee mantissa is updated
*/
Expand Down
4 changes: 4 additions & 0 deletions contracts/test/EvilXToken.sol
Original file line number Diff line number Diff line change
Expand Up @@ -206,4 +206,8 @@ contract EvilXToken is VBep20Delegate {
function harnessCallBorrowAllowed(uint amount) public returns (uint) {
return comptroller.borrowAllowed(address(this), msg.sender, amount);
}

function harnessSetInternalCash(uint256 _internalCash) public {
internalCash = _internalCash;
}
}
8 changes: 8 additions & 0 deletions contracts/test/VBep20Harness.sol
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@ contract VBep20Harness is VBep20Immutable {
function harnessCallBorrowAllowed(uint amount) public returns (uint) {
return comptroller.borrowAllowed(address(this), msg.sender, amount);
}

function harnessSetInternalCash(uint256 _internalCash) public {
internalCash = _internalCash;
}
}

contract VBep20Scenario is VBep20Immutable {
Expand Down Expand Up @@ -415,6 +419,10 @@ contract VBep20DelegateHarness is VBep20Delegate {
function harnessCallBorrowAllowed(uint amount) public returns (uint) {
return comptroller.borrowAllowed(address(this), msg.sender, amount);
}

function harnessSetInternalCash(uint256 _internalCash) public {
internalCash = _internalCash;
}
}

contract VBep20DelegateScenario is VBep20Delegate {
Expand Down
12 changes: 9 additions & 3 deletions contracts/test/VBep20MockDelegate.sol
Original file line number Diff line number Diff line change
Expand Up @@ -187,8 +187,7 @@ contract VBep20MockDelegate is VToken, VBep20Interface {
* @return The quantity of underlying tokens owned by this contract
*/
function getCashPrior() internal view override returns (uint) {
IERC20 token = IERC20(underlying);
return token.balanceOf(address(this));
return internalCash;
}

/**
Expand All @@ -206,7 +205,9 @@ contract VBep20MockDelegate is VToken, VBep20Interface {
token.safeTransferFrom(from, address(this), amount);
// Calculate the amount that was *actually* transferred
uint balanceAfter = IERC20(underlying).balanceOf(address(this));
return balanceAfter - balanceBefore;
uint actualAmount = balanceAfter - balanceBefore;
internalCash += actualAmount;
return actualAmount;
}

/**
Expand All @@ -219,7 +220,12 @@ contract VBep20MockDelegate is VToken, VBep20Interface {
* See here: https://medium.com/coinmonks/missing-return-value-bug-at-least-130-tokens-affected-d67bf08521ca
*/
function doTransferOut(address payable to, uint amount) internal override {
internalCash -= amount;
IERC20 token = IERC20(underlying);
token.safeTransfer(to, amount);
}

function harnessSetInternalCash(uint256 _internalCash) public {
internalCash = _internalCash;
}
}
14 changes: 14 additions & 0 deletions tests/hardhat/Comptroller/Diamond/flashLoan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,8 @@ describe("FlashLoan", async () => {

await underlyingA.harnessSetBalance(vTokenA.address, parseUnits("50", 18));
await underlyingB.harnessSetBalance(vTokenB.address, parseUnits("50", 18));
await vTokenA.harnessSetInternalCash(parseUnits("50", 18));
await vTokenB.harnessSetInternalCash(parseUnits("50", 18));

// Execute the flashLoan from the mockReceiverContract
await expect(
Expand Down Expand Up @@ -349,6 +351,8 @@ describe("FlashLoan", async () => {

await underlyingA.harnessSetBalance(vTokenA.address, parseUnits("50", 18));
await underlyingB.harnessSetBalance(vTokenB.address, parseUnits("50", 18));
await vTokenA.harnessSetInternalCash(parseUnits("50", 18));
await vTokenB.harnessSetInternalCash(parseUnits("50", 18));

await expect(
badReceiver
Expand Down Expand Up @@ -415,6 +419,8 @@ describe("FlashLoan", async () => {

await underlyingA.harnessSetBalance(vTokenA.address, parseUnits("60", 18));
await underlyingB.harnessSetBalance(vTokenB.address, parseUnits("60", 18));
await vTokenA.harnessSetInternalCash(parseUnits("60", 18));
await vTokenB.harnessSetInternalCash(parseUnits("60", 18));

await expect(
mockReceiverContract
Expand Down Expand Up @@ -446,6 +452,8 @@ describe("FlashLoan", async () => {

await underlyingA.harnessSetBalance(vTokenA.address, parseUnits("50", 18));
await underlyingB.harnessSetBalance(vTokenB.address, parseUnits("50", 18));
await vTokenA.harnessSetInternalCash(parseUnits("50", 18));
await vTokenB.harnessSetInternalCash(parseUnits("50", 18));

await comptroller.connect(alice).updateDelegate(mockReceiverContract.address, true);

Expand Down Expand Up @@ -489,6 +497,8 @@ describe("FlashLoan", async () => {

await underlyingA.harnessSetBalance(vTokenA.address, parseUnits("60", 18));
await underlyingB.harnessSetBalance(vTokenB.address, parseUnits("60", 18));
await vTokenA.harnessSetInternalCash(parseUnits("60", 18));
await vTokenB.harnessSetInternalCash(parseUnits("60", 18));

await underlyingA.harnessSetBalance(alice.address, parseUnits("1000", 18));
await underlyingB.harnessSetBalance(alice.address, parseUnits("1000", 18));
Expand Down Expand Up @@ -574,6 +584,8 @@ describe("FlashLoan", async () => {

await underlyingA.harnessSetBalance(vTokenA.address, parseUnits("60", 18));
await underlyingB.harnessSetBalance(vTokenB.address, parseUnits("60", 18));
await vTokenA.harnessSetInternalCash(parseUnits("60", 18));
await vTokenB.harnessSetInternalCash(parseUnits("60", 18));

// Calculate expected protocol fees
const expectedProtocolFeeA = flashLoanAmount1
Expand Down Expand Up @@ -708,6 +720,8 @@ describe("FlashLoan", async () => {

await underlyingA.harnessSetBalance(vTokenA.address, parseUnits("60", 18));
await underlyingB.harnessSetBalance(vTokenB.address, parseUnits("60", 18));
await vTokenA.harnessSetInternalCash(parseUnits("60", 18));
await vTokenB.harnessSetInternalCash(parseUnits("60", 18));

// Calculate expected fees
const expectedFeeA = flashLoanAmount1.mul(totalFeeMantissaTokenA).div(parseUnits("1", 18));
Expand Down
1 change: 1 addition & 0 deletions tests/hardhat/EvilXToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ describe("Evil Token test", async () => {
await vToken1.setReduceReservesBlockDelta(10000);
await vToken1.connect(user).mint(convertToUnit(1, 4));
await underlying3.harnessSetBalance(vToken3.address, convertToUnit(1, 8));
await vToken3.harnessSetInternalCash(convertToUnit(1, 8));

const protocolShareReserve = await smock.fake<IProtocolShareReserve>("IProtocolShareReserve");
protocolShareReserve.updateAssetsState.returns(true);
Expand Down
Loading
Loading