Skip to content

[VPD-1982] Add CollateralGateway for hub deposits - #74

Open
Debugger022 wants to merge 18 commits into
developfrom
feat/VPD-1982
Open

Debugger022 wants to merge 18 commits into
developfrom
feat/VPD-1982

Conversation

@Debugger022

@Debugger022 Debugger022 commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • Adds CollateralGateway, which turns an underlying balance into collateral in one call on Core (through a Liquidity Hub and its vh market) and on Spoke Pools, and withdraws a vh position back to the wallet. No proxy, no admin, no funds held between calls. The Core Comptroller is fixed at construction.
  • Ported from HubRouter in venus-liquidity-hub. The port itself changed only license, imports, types and casts. The review changes since then are listed below and do change behaviour.

Related PRs

  • [VPD-1982]: support PrimeV2 markets with underlyings above 18 decimals venus-protocol#710 adds MarketFacet.enterMarketForAccount(address,address), the ACM gated entry point every Core supply in this gateway calls, and lets PrimeV2 list the 24 decimal vh markets. The gateway's Core paths revert on chain until that facet is cut into the Comptroller (governance step 4 below). The fork tests stand in for that upgrade by etching tests/foundry/fork/fixtures/MarketFacet.deployed.hex, which is built from #710, so the fixture must be rebuilt whenever #710 changes.

Changes

Contract (contracts/CollateralGateway/)

  • CollateralGateway.sol, ICollateralGateway.sol, and a minimal local IHub (asset, deposit, redeem). License is BSD-3-Clause.
  • Port changes against the hub source: IVBep20 becomes IVToken, comptroller() returns IComptroller rather than an address, and executeOperation and the flash loan market array take IVToken[].

Shared interfaces (additions only)

  • IComptroller: enterMarketForAccount(account, vToken), getHypotheticalAccountLiquidity.
  • IVToken: flashLoanFeeMantissa.
  • IILComptroller: Spoke enterMarketBehalf(vToken, account). The arguments are the reverse of Core IComptroller.enterMarketBehalf(onBehalf, vToken), so it is not added to IComptroller.

Review changes on top of the port

Change Why
Balance guard on supplyFromCollateral a caller supplied hub could spend an approval against a balance the gateway was already holding
InsufficientReceipts check an over-redeem panicked inside the market with a bare "math error"
The hub asset is read once per entry point and threaded through a caller supplied hub could return a different token on a later read than the one validated against vToken.underlying()
Core Comptroller is immutable, and a supply requires the vh market to be listed on it the comptroller was read off the caller supplied vh market, so nothing tied a call to a real Core deployment
The source market is accrued before the shortfall check redeemBehalf accrues before its own liquidity check, so asking beforehand read a stale borrow balance and could send a borrowing caller down the direct path, which then reverts
The migration context moved to transient storage the flash loan path wrote ten cold storage slots per call. The borrowing journey drops from 1,891,510 to 1,688,054 gas

The last change needs transient, so CollateralGateway.sol, its two local interfaces and the tests moved to pragma solidity 0.8.28, with matching via_ir entries in foundry.toml.

Tests (Foundry)

  • Unit, 40 tests in tests/foundry/unit/: Adversarial 9, Spoke 13, Supply 5, Withdraw 13.
  • Fork, 18 tests, and Journey, 5 tests, in tests/foundry/fork/, pinned to BSC block 121_990_000 and reading ARCHIVE_NODE_bscmainnet. Setup etches the MarketFacet fixture (built from [VPD-1982]: support PrimeV2 markets with underlyings above 18 decimals venus-protocol#710) and cuts in enterMarketForAccount as the NormalTimelock. The flash loan whitelist and the supply cap are set through setWhiteListFlashLoanAccount and setMarketSupplyCaps as the NormalTimelock.
  • foundry.toml: read access to the fixtures directory, and via_ir for the three 0.8.28 gateway files.

Docs and deploy

  • contracts/CollateralGateway/README.md.
  • deploy/028-deploy-collateral-gateway.ts: takes the Core Comptroller from the Unitroller deployment, tag collateral-gateway, verified on live networks, skipped on the in-process hardhat network where no Core deployment exists.

Test plan

  • forge test --match-path "tests/foundry/unit/CollateralGateway_*": 40 passed.
  • forge test --match-path "tests/foundry/fork/*" with an archive RPC: 23 passed. With no RPC, all 23 skip.
  • yarn lint and yarn test pass.
  • yarn hardhat deploy completes on the hardhat network, where 028 skips.
  • CI passes. Fork tests are excluded in CI because it has no archive RPC; the fork results above are from a local run.

Known gaps

  1. The fee branches in _flashAmount never run on the fork: treasuryPercent and vUSDT flashLoanFeeMantissa are both 0 at the pinned block. Only a mock with a 1% fee covers them.
  2. No fuzz or invariant tests. Candidates: _flashAmount sizing, the _freeFromMarket rounding cap, the wallet and market split in withdrawPosition, and "holds no funds after any call".
  3. Fork coverage lacks partial withdraw, the minAssets revert, a non 18 decimal asset, and the Spoke paths (no Spoke pool is deployed).
  4. The fork tests cut the facet with a single Add. The real VIP cut (Replace for 31 selectors plus Add for 1) is untested.
  5. Journey results at the pinned block: the gateway saves signatures but costs more gas. A first wallet supply takes 2 transactions and 1,254,044 gas, against 5 transactions and 650,842 gas by hand. A withdraw only from the wallet never beats the manual path: 2 transactions against 1 the first time, and 1 against 1 after. The one case the manual path cannot reach at all is a migration while borrowing, which needs the flash loan.
  6. The Spoke paths do not check that a market is registered in PoolRegistry. The gateway pulls from msg.sender and zeroes the approval on the next line, so a fake market can only take the caller's own tokens inside the caller's own transaction. Adding the check needs an immutable registry address and cannot be fork tested until a Spoke pool exists.

Governance dependencies (one VIP, all executed by the NormalTimelock 0x939bD8d64c0A9583A7Dcea9933f7b21697ab6396)

# Action Target
1 Deploy new PrimeV2 implementation off chain
2 ProxyAdmin.upgrade(prime, newImpl) ProxyAdmin
3 Deploy new MarketFacet off chain
4 diamondCut: Replace for the existing MarketFacet selectors on 0x21f8E1471b153f49BE1d645A008E4a57434eEd23, Add for 0x2e30a93c. The exact selector list comes from #710 Core Comptroller 0xfD36E2c2a6789Db23113685031d7F16329158384
5 giveCallPermission(comptroller, "enterMarketForAccount(address,address)", gateway) ACM 0x4788629ABc6cFCA10F9f969efdEAa1cF70c23555
6 setWhiteListFlashLoanAccount(gateway, true) Core Comptroller

Steps 5 and 6 need the deployed gateway address, so the gateway is deployed and audited before the VIP is proposed. Spoke enterMarketBehalf(address,address) grants are not part of this VIP. The Prime addMarket for vh markets follows the decimals fix separately.

@Debugger022 Debugger022 self-assigned this Sep 15, 2026
@Debugger022
Debugger022 changed the base branch from develop to chore/foundry-setup September 15, 2026 08:31
@Debugger022
Debugger022 marked this pull request as ready for review September 15, 2026 08:37
@greptile-apps

greptile-apps Bot commented Sep 15, 2026

Copy link
Copy Markdown

Greptile Summary

The PR introduces a stateless CollateralGateway for depositing wallet assets or existing Core collateral into Liquidity Hub markets, withdrawing combined wallet/Core Hub positions, and supplying to Spoke markets.

  • Adds Core direct and flash-loan migration paths with delegated redemption and market entry.
  • Adds Spoke supply-and-entry batching and the required shared interface methods.
  • Adds Foundry unit, fork, and journey coverage plus deployment and operational documentation.
  • One blocking trust-boundary issue allows attacker-selected migration contracts to spend stranded gateway balances; withdrawal rounding can also consume more of a position than requested.

Confidence Score: 3/5

The PR is not safe to merge until flash migrations prevent caller-selected spenders from accessing preexisting gateway balances.

A permissionless migration can approve and invoke an attacker-controlled Hub against gateway-held underlying, allowing stranded funds to be drained through the active callback; withdrawal rounding also spends one complete market receipt for requests smaller than a receipt unit.

Files Needing Attention: contracts/CollateralGateway/CollateralGateway.sol

Security Review

A permissionless flash migration can approve and invoke an attacker-controlled Hub against gateway-held underlying tokens. The selected Hub can use that allowance to drain stranded balances while returning fabricated values that let the migration complete.

Important Files Changed

Filename Overview
contracts/CollateralGateway/CollateralGateway.sol Implements all gateway flows; caller-controlled flash-migration spenders can access stranded balances, and market-share rounding can over-redeem withdrawals.
contracts/CollateralGateway/ICollateralGateway.sol Defines the public API, events, errors, prerequisites, and expected withdrawal semantics.
contracts/CollateralGateway/IHub.sol Adds the minimal ERC-4626-compatible Hub interface required by the gateway.
contracts/Interfaces/IComptroller.sol Adds Core market-entry-for-account and hypothetical-liquidity declarations.
contracts/Interfaces/IILComptroller.sol Adds the isolated-pool market-entry interface with the Spoke-specific argument order.
contracts/Interfaces/IVToken.sol Exposes the flash-loan fee mantissa used to size migration loans.
deploy/028-deploy-collateral-gateway.ts Adds a constructor-free deployment and live-network verification task.
tests/foundry/unit/CollateralGateway_Adversarial.t.sol Covers callback argument validation and balance-delta protection but does not cover a malicious approved spender draining preexisting funds.
tests/foundry/unit/CollateralGateway_Withdraw.t.sol Covers wallet/Core withdrawals and explicitly demonstrates whole-receipt over-redemption for sub-receipt requests.
tests/foundry/fork/Fork_CollateralGateway.t.sol Exercises Core flows against pinned BNB Chain state with an etched pending MarketFacet implementation.

Reviews (1): Last reviewed commit: "feat(deploy): add CollateralGateway depl..." | Re-trigger Greptile

Comment thread contracts/CollateralGateway/CollateralGateway.sol
Comment thread contracts/CollateralGateway/CollateralGateway.sol
@Debugger022
Debugger022 changed the base branch from chore/foundry-setup to develop September 15, 2026 09:32
Comment thread contracts/CollateralGateway/CollateralGateway.sol Outdated
Asking for more receipts than the caller holds made the liquidity check
report a shortfall, so the call took the flash loan branch and failed
later inside the market with a bare "math error". Also scopes the header
claim about measured amounts to the redeem and mint paths.
The hub is caller supplied, so every extra read of asset() can return a
different token than the one validated against the vToken. The callback
path read it again after deposit, which a hub can flip. Migration now
carries the validated address and every leg takes it as an argument.
Comment thread contracts/CollateralGateway/CollateralGateway.sol
Comment thread contracts/CollateralGateway/CollateralGateway.sol
Comment thread contracts/CollateralGateway/CollateralGateway.sol Outdated
Comment thread contracts/CollateralGateway/CollateralGateway.sol
Comment thread contracts/CollateralGateway/CollateralGateway.sol Outdated
Comment thread contracts/CollateralGateway/CollateralGateway.sol Outdated
The comptroller was read off the caller-supplied vh market, so nothing
tied a call to a real Core deployment and a fake market could name its
own. It is an immutable now, and a supply reverts unless that Comptroller
lists the market, which pins the hub and the asset behind it.
redeemBehalf accrues the market before it runs its own liquidity check,
so asking beforehand read a stale borrow balance. A caller borrowing from
the source market could be told there was no shortfall, take the direct
path and have the redeem reject it, when the flash loan path would have
worked.
The flash loan path wrote ten cold storage slots to carry context into
the callback and refunded part of it on delete, with refunds capped at a
fifth of the transaction. Transient storage drops the borrowing journey
from 1,891,510 to 1,688,054 gas. The deploy script also skips the bare
hardhat network, where the Core Comptroller it now takes has no
deployment.
The spoke comptroller renamed enterMarketBehalf to
enterMarketForAccount and reordered its arguments to (account, vToken)
to match Core. The interface note warning that the arguments were
reversed is dropped, since the two signatures now agree.
Comment thread contracts/CollateralGateway/CollateralGateway.sol Outdated
Comment thread contracts/CollateralGateway/CollateralGateway.sol
Comment thread contracts/CollateralGateway/CollateralGateway.sol
Comment thread contracts/CollateralGateway/CollateralGateway.sol
Comment thread contracts/CollateralGateway/CollateralGateway.sol
Comment thread contracts/CollateralGateway/CollateralGateway.sol
Comment thread contracts/CollateralGateway/CollateralGateway.sol Outdated
Comment thread contracts/CollateralGateway/CollateralGateway.sol
A vh market wraps exactly one hub, so passing both meant carrying a fact
the market already states and a check for the two disagreeing. Now that
the Comptroller is pinned, the market vouches for it: every entry point
requires the market to be listed and reads the hub from it, and the
source market of a migration is checked the same way.
Migrating a whole position meant reading the receipt balance first and
passing it back, so a caller who wanted all of it had to do it in two
steps. type(uint256).max now resolves to the caller's balance when the
call runs.
No leg of the gateway leaves a balance behind, but tokens sent here by
mistake had no way out, because nothing reads the contract's own balance.
The owner can now move them, which is the same shape SwapRouter and the
leverage manager use.
Every Core market in a call is checked against the pinned Comptroller,
but the spoke paths took whatever address the caller passed, so a fake
market could take a caller's own tokens through a contract they trusted.
The registry pins the market to its pool and asset, and the pool is asked
separately whether it is still listed, because the registry keeps its
entry after an unlist.
fred-venus
fred-venus previously approved these changes Sep 21, 2026
if (walletShares != 0) IERC20(hub).safeTransferFrom(msg.sender, address(this), walletShares);

uint256 freedShares;
if (walletShares < shares) freedShares = _freeFromMarket(hub, vhMarket, shares - walletShares);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This passes the uncapped remainder through. The wallet leg above is capped at the balance, but if someone calls with type(uint256).max the remainder here is still ~1.16e77, and _freeFromMarket multiplies by EXP_SCALE before it caps vTokens at the caller's balance, so it overflows and panics before reaching the cap.

Simplest fix is capping the remainder before the multiply so it degrades to "everything you hold". Or add the sentinel properly, for symmetry.

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.

Comment on lines +227 to +228
// Change is measured against this migration's own redeem, never against the balance. Every
// address here is caller-supplied, so a balance reading would hand a caller anything else

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Every address here is caller-supplied, so a balance reading would hand a caller anything else the gateway happens to hold

with isListed check now , the comment should be updated

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.

_enterCoreMarket(vhMarket);

uint256 vTokens;
if (_wouldCauseShortfall(vToken, vTokenAmount)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If the caller never entered vToken, this reports their pre-existing shortfall rather than anything about this redeem the hypothetical only walks entered markets, so a balance that was never collateral contributes nothing before or after. Core knows that and skips the liquidity check outright for non-members in redeemAllowedInternal, so the redeem goes straight through even for an underwater account.

Net effect is we route those callers into the flash path and charge them the fee for nothing. A checkMembership short-circuit before this call fixes it, and swaps a lens call that loops every entered market with an oracle read each for a single mapping read.

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.

The receipt count was computed before capping at the caller's balance,
so shares above about 1e59, including type(uint256).max, overflowed. A
request worth more than the receipts now redeems all of them. Also
restates comments that still described caller-supplied hubs and
Core-only markets.
Core skips the redeem liquidity check for a market the caller never
entered, but the gateway still asked the lens, which reported the
caller's existing shortfall. An account already under water was sent
through the flash loan, paying its fee or reverting when the gateway
is not allow-listed.
@github-actions

Copy link
Copy Markdown

Code Coverage

Package Line Rate Branch Rate Health
contracts 100% 100% ✔
contracts.CollateralGateway 0% 0% ❌
contracts.DeviationSentinel 100% 100% ✔
contracts.DeviationSentinel.Oracles 96% 93% ✔
contracts.EmergencyBrake 0% 0% ❌
contracts.Executor 0% 0% ❌
contracts.Interfaces 100% 100% ✔
contracts.LeverageManager 96% 81% ✔
contracts.Libraries 29% 33% ❌
contracts.PositionSwapper 0% 0% ❌
contracts.RelativePositionManager 91% 64% ✔
contracts.SwapHelper 100% 100% ✔
contracts.SwapRouter 79% 55% ➖
contracts.pendle-pt-fixed-rate-vault 0% 0% ❌
contracts.pendle-pt-fixed-rate-vault.interfaces 100% 100% ✔
contracts.pendle-pt-fixed-rate-vault.test 0% 0% ❌
Summary 57% (978 / 1722) 44% (490 / 1118) ➖

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