Skip to content

Feat/hub spoke pools - #559

Open
Debugger022 wants to merge 27 commits into
developfrom
feat/hub-spoke-pools
Open

Feat/hub spoke pools#559
Debugger022 wants to merge 27 commits into
developfrom
feat/hub-spoke-pools

Conversation

@Debugger022

@Debugger022 Debugger022 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

What

Adds SpokeComptroller, a fork of Comptroller for hub-funded spoke pools, behind its own beacon. Two deploy scripts: the comptroller, and a second PoolRegistry instance this pool registers in instead of the isolated-pools one. No existing pool is affected.

PoolLens is the only shared contract touched: four appended fields for the spoke-only state, with the superseded revision archived as PoolLensR3.

Nothing is wired or listed by this PR. The oracle, addPool, the ACM grants and the markets all belong to the listing VIP.

Decisions

  • Forked instead of extending Comptroller. Comptroller is shared by every pool in this repo, on this chain and others. Spoke-only policy does not belong in it, and a shared-beacon upgrade would put that policy on all live pools at once. The fork has its own beacon, so blast radius is zero.
  • The interface is inherited, not copied. SpokeComptrollerInterface is ComptrollerInterface, so both comptrollers share one definition of the function surface and it cannot drift. Only the events and errors are declared in the spoke interface. Separate interface for SpokeComptroller also works
  • Prime integration removed. Not used by this pool, and it freed ~1,067 bytes of the size budget.
  • The seven *Verify hooks kept as empty no-ops. VToken calls all seven. Removing them would force a separate VToken implementation for this pool, so keeping them lets the pool reuse the shared VToken beacon.
  • Revert strings replaced with custom errors. Smaller bytecode, and consistent with the newer contracts in the repo.
  • Bounded pricing applies only to the collateral-factor paths. Borrow and redeem value collateral at the low end of the asset's recent price window and debt at the high end, so a deviating price can only reduce borrowing capacity. The liquidation-threshold paths stay on spot, because they decide liquidation routing and bounding them would distort eligibility.
  • Optimizer runs: 30 for this contract instead of moving code into an external library. With bounded pricing added, the contract no longer fits under EIP-170 at the default 200 runs. Measured: runs: 30 costs +1,901 gas on a borrow (under 1.5%), while a linked library costs at least 2,600 gas for the cold DELEGATECALL alone, so it was both more expensive and a larger architectural change. The library stays the fallback if headroom ever runs out, since it frees kilobytes rather than bytes.
  • Storage layout unchanged in shape. The reserved gap was shrunk by the number of new slots, so the fork occupies the same slots as ComptrollerStorage.
  • A PoolRegistry instance of its own, not a row in the isolated-pools registry. That registry is the directory every consumer iterates to answer which pools exist, so a pool whose supply, borrow and liquidation sides are all restricted to known accounts would need a special case in each of them. A second instance also keeps the two products independently upgradeable and permissioned. The chain's existing DefaultProxyAdmin is reused, so there is no second admin to track.
  • The pool is deliberately absent from poolConfig. The standard scripts iterate that list and would deploy this pool on the shared beacon under the same artifact name; this script would then skip it as already deployed, leaving a pool that looks correct but runs the wrong implementation.

New behaviour

  • Per-market supply allowlist, disabled by default
  • Pool-wide liquidation allowlist, disabled by default
  • Per-market liquidation incentive, used to route an unhealthy account between liquidateAccount and healAccount
  • Deviation-bounded pricing on the borrow capacity paths, with a setter for the oracle address
  • enterMarketBehalf(address,address), so a router supplying through mintBehalf enters the supplier rather than itself. ACM-gated, one market per call to stay under EIP-170. enterMarkets is untouched, so self entry stays permissionless
  • liquidateAccount refreshes interest and prices before its routing snapshot, as healAccount already did, so the two do not decide on different views of the same position
  • PoolLens reports the spoke-only state: bounded oracle and liquidation allowlist per pool, liquidation incentive and supply allowlist per market. Probed by staticcall, so a comptroller without them reports absence instead of reverting the read, and appended rather than reordered so an old decoder still works

Known Gaps — Deferred

None of the items below are permissionless. Every one sits behind onlyOwner or the
AccessControlManager, so they are governance and configuration guardrails rather than direct
vulnerabilities. They are recorded here so the listing VIP and any future change to this contract
start from a known baseline.

1. DBO and Comptroller oracles are not cross-checked

DeviationBoundedOracle.RESILIENT_ORACLE is immutable, while SpokeComptroller.oracle is settable
through setPriceOracle. Nothing verifies the two refer to the same ResilientOracle.

If they ever diverge, borrowing power is priced through the bounded oracle while liquidation routing
and seize math are priced through the comptroller's own oracle, with no revert and no event to make
it visible.

Action: the listing VIP verifies the pairing at deployment. A future change could enforce it in
setPriceOracle and setDeviationBoundedOracle, at a cost in contract size (see item 7).

2. No maximum limit on liquidation incentives

Both setLiquidationIncentive and setMarketLiquidationIncentive enforce only a floor. Neither
rejects a value that is too high.

This is a risk-parameter decision and is intentionally left to governance, matching how the shared
Comptroller treats it. One consequence is specific to this fork and worth being aware of when
setting the value: the per-market incentive divides into maxClearableDebt, so an unusually high
incentive on one collateral market shrinks that figure for every borrower holding it, which shifts
those accounts from liquidateAccount toward healAccount and therefore toward bad debt.

Action: none in code. Set per-market incentives deliberately.

3. Unset DBO oracle causes an unnamed revert

Borrow and redeem fail closed while deviationBoundedOracle is unset, because
_updateProtectionStates calls into the zero address. The revert carries no named error, so the
failure is correct but opaque.

The window in which this is reachable is between proxy initialization and the listing VIP, and the
VIP sets the oracle before the pool serves any action.

Action: none. Failing closed is the intended behaviour; only the error message is unhelpful, and
only in a window nobody transacts in.

4. An empty liquidation allowlist can freeze liquidations

Enabling isLiquidationAllowlistEnabled with no approved liquidators blocks every seizure in the
pool. This also covers healAccount, so the keeper relied on to record bad debt has to be
allowlisted too, not just the liquidators.

Action: the listing VIP adds every liquidator and bad-debt keeper before enabling the allowlist,
in the same transaction.

5. PoolData.liquidationIncentive still reports the pool-wide value

liquidationIncentiveMantissa() resolves against msg.sender, so a lens reading it receives the
pool-wide value, and that is what PoolData.liquidationIncentive continues to mean. Changing the
meaning of an existing field would break current consumers.

Action: none. The per-market value is now reported as
VTokenMetadata.liquidationIncentiveMantissa, so a consumer reading a spoke pool takes that field.
The on-chain seize amount was never affected: liquidateCalculateSeizeTokens and VToken._seize
both resolve the collateral market's own incentive.

6. maxLoopsLimit needs re-validation

Borrow, redeem and transfer now walk an account's markets twice per call, once for
_updatePrices and once for _updateProtectionStates, on top of the reward-distributor loops. The
deployed limit of 100 was chosen before the second walk existed, and neither oracle loop is bounded
by _ensureMaxLoops.

Action: measure the worst-case borrow against a realistic market count and adjust the deployed
value if the gas ceiling, rather than the configured limit, is what actually binds.

7. Contract size

SpokeComptroller compiles to 24,551 bytes against the 24,576-byte EIP-170 limit, leaving 25
bytes
, at the runs: 30 override this contract already needs in order to fit. Any further change
has to be measured before it is written.

Moving the liquidity snapshot into an external library would free kilobytes rather than bytes, but it
has been left out for now to keep SpokeComptroller aligned with the IL Comptroller it was forked
from.

One related note: all four compiler blocks disable the Yul optimizer under CI. Compiled that way
the contract measures 25,657 bytes, over the limit, while the production build is under it. Any size
check that runs in CI is therefore measuring a binary that is never deployed.

Listing vip important notes

  1. In the listing VIP, PoolRegistry.addMarket must set vTokenReceiver to the Spoke YieldGroup (not the Timelock), so the initial supply is minted in the YieldGroup's name and the Hub stays the market's only supplier.
  2. Six new ACM role strings, verbatim (enterMarketBehalf, setMarketLiquidationIncentive, setSupplyAllowlistEnabled, setAllowedSupplier, setLiquidationAllowlistEnabled, setAllowedLiquidator), plus the inherited setActionsPaused(address[],uint256[],bool) trap where the registered string deliberately disagrees with the real ABI (uint8[]). Deriving that one from the ABI gives you a grant that never matches.
  3. Pool-wide incentive ≥ 1.05e18, now enforced, so this one fails loudly.
  4. DBO: three checks, two of which fail silently. A market with no setTokenConfig runs with zero protection and no revert; the caching-flag pairing; the RESILIENT_ORACLE pairing.
  5. Supply allowlist after addMarket, with the YieldGroup granted in the same VIP.
  6. Liquidation allowlist: liquidators and the bad-debt keeper, since it gates healAccount too.
  7. Per-market incentives: the floor, the 0-is-a-sentinel rule, and the ordering trap where lowering the pool-wide value after a market's seize share was raised is never re-checked.
  8. SpokePoolRegistry: acceptOwnership, since the deploy script only nominates, plus ACM grants in both directions. Existing wildcard grants name the isolated-pools registry's address, so none carry over and addPool reverts without new ones. The exact list is in the deploy script's closing comment.
  9. ProtocolShareReserve holds a single poolRegistry address. Pointing it at SpokePoolRegistry stops the existing isolated pools from taking income; not pointing it there stops this pool. The multi-registry change ships from another repo and has to be live first. Pinned in tests/hardhat/Fork/HubSpoke/psrRegistryConflict.ts.
  10. enterMarketBehalf: grant the role only to a router that passes its own caller as account. Anything else can enter markets for anyone.

Spoke pools need supply and liquidation allowlists plus a per-market
liquidation incentive. Mainline Comptroller backs the shared beacon on
this and other chains and has no virtual functions, so the spoke gets
its own implementation rather than a flag.

This commit is the copy alone, so later commits show the feature diff by
itself. The fork is byte-identical to its source apart from the solc
metadata trailer, and carries the same storage layout.
- The spoke pool will not incentivise borrowing through Prime, so the
  integration is dead weight in a contract with 741 bytes of headroom
  against EIP-170. Removing it frees 1,097, leaving 1,838 for the
  allowlists and the per-market liquidation incentive.
- The seven *Verify functions stay as no-ops instead of being deleted:
  ComptrollerInterface declares all of them, so omitting one leaves the
  contract abstract, and the vToken calls each against a comptroller
  with no fallback, so a missing function would revert the operation it
  belongs to. Selectors are unchanged.
- Their parameters are unnamed so the build stays free of unused
  parameter warnings, which also means the @PARAM tags had to go, since
  solc validates those against the signature.
- They are grouped in one section with the rationale stated once, rather
  than left between the pre-hooks where seven identical empty bodies
  read as noise.
- The freed storage slot returns to __gap, so the contract occupies the
  same number of slots as the one it was forked from.
- Supply allowlist checks the account credited with the minted
  vTokens, not the payer. mintBehalf lets a third party fund a mint
  attributed to someone else, and metering the recipient is what
  bounds a market's supply.
- Liquidation allowlist is pool wide rather than per market, because
  healAccount seizes across every market the borrower is in and so
  cannot attribute a seizure to a single one of them.
- Each collateral market can carry its own liquidation incentive.
  The benchmark that routes an account between liquidateAccount and
  healAccount therefore becomes the sum of collateral value over
  incentive across markets, instead of one pool wide divisor.
- liquidateAccount keeps the strict complement of healAccount's
  condition, so the two remain an exact partition and no account is
  accepted by both.
- Every new setter carries its own ACM role string, so a governance
  proposal can grant one without granting the others.
- Storage grows by five slots and the reserved gap shrinks to match,
  keeping the same slot footprint as the contract this was forked
  from.
- Values collateral at the low end of an asset's recent price window and
  debt at the high end while deviation protection is active, so a
  deviating print can only shrink borrowing capacity, never inflate it
- Applies only where the collateral factor weights the position; the
  liquidation-threshold paths stay on spot because they route
  liquidations, and bounding them would distort eligibility
- Replaces the snapshot's weight function pointer with an enum, because a
  function pointer cannot be compared and the price read has to know
  which weighting it is serving
- Persists the price window on borrow and redeem so protection latches
  and its cooldown starts, instead of evaporating once the price returns
- Optimizes this contract for size rather than gas: it no longer fits
  under EIP-170 at the default 200 optimizer runs, and the measured cost
  is under 1.5% on a borrow
- Deploys a dedicated implementation and beacon for the spoke pool
- Keeps the pool out of poolConfig, or the standard scripts would deploy
  it first on the shared beacon and this script would skip it
- Verifies constructor and initialize values before ownership transfer
- Leaves oracle, pool listing and market config to the listing VIP
- Use the collateral market's liquidation incentive consistently in
  liquidateCalculateSeizeTokens and VToken._seize, so the protocol seize
  share and liquidator discount are calculated from the same value.
- Make liquidationIncentiveMantissa() market-specific by resolving the
  incentive for msg.sender.
- Apply the same market-specific incentive when validating
  VToken.setProtocolSeizeShare.
- Add a floor of 1e18 + protocolSeizeShareMantissa in
  setMarketLiquidationIncentive to prevent configurations where the
  liquidator receives less collateral than the debt repaid.
- Keep the pool-wide incentive internal as the default for markets
  without a custom incentive, initialized by PoolRegistry during
  registration.
- Use effectiveLiquidationIncentive when reading the pool-wide/default
  value outside the pool market context.
- Check that the market is listed before applying the new incentive bound.

This ensures each market consistently uses its configured liquidation
incentive and prevents protocol and liquidator seizure calculations from
using conflicting incentive values.
- Cover allowlists, deviation bounded pricing, per market liquidation
  incentives and liquidation flows against real vTokens
- Add hook coverage for caller guards, borrow caps, close factor, forced
  liquidation and the full action pause matrix
- Cover market membership, rewards distributor wiring and max loop limits
- Pin the storage layout against ComptrollerStorage, including the
  manually calculated trailing gap size
- Assert ABI, events and role string parity with the shared implementation
  used by the lens and VIP calldata
- Add UpgradedSpokeComptroller under contracts/test as the successor used
  by beacon upgrade tests
- Skip the EIP 170 size assertion while the Yul optimizer is disabled,
  since the build is around 1.1 KB larger and is never deployed

These tests focus on spoke specific changes and prevent regressions from the shared implementation.
- Split CollateralExceedsThreshold into CollateralCoversDebt and
  DebtExceedsClearableAmount, so healAccount and liquidateAccount stop
  reporting one selector for opposite conditions
- Declare the fork's getters in SpokeComptrollerViewInterface, so
  integrators can read them without importing the implementation
- Resolve the account's markets once per hook and pass the list to both
  the price and the protection state update
- Accumulate maxClearableDebt only under the liquidation threshold
  weighting, the only one whose callers read it
- Deploy the implementation without skipIfAlreadyDeployed and verify it
  as soon as it lands, so a source change is not silently skipped
- Cover listing, allowlists, bounded pricing, bad debt, low decimal
  markets and the supply flow against live bscmainnet state
- Add the spoke adapter and its interfaces under contracts/test, so the
  suite runs the real integration path rather than a stand in
- Record BSC's shanghai and cancun activation blocks in the fork config.
  Without them a recent fork runs the London EVM and every contract
  compiled for a later target reverts on PUSH0 or TSTORE
@Debugger022
Debugger022 marked this pull request as ready for review August 20, 2026 10:55

@claude claude 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.

Claude Code Review

Claude Code Review is paused for this repository. To reconnect it, an admin of this repository's GitHub organization (or the account owner, for personal repositories) who can also manage your Claude organization's Code Review settings needs to re-link GitHub in Code Review settings. This is a one-time step.

Tip: disable this comment in your organization's Code Review settings.

@claude claude 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.

Claude Code Review

Claude Code Review is paused for this repository. To reconnect it, an admin of this repository's GitHub organization (or the account owner, for personal repositories) who can also manage your Claude organization's Code Review settings needs to re-link GitHub in Code Review settings. This is a one-time step.

Tip: disable this comment in your organization's Code Review settings.

A market listed with no incentive of its own falls back to the pool-wide
value and carries the default 5% protocol seize share, so a floor of 1e18
leaves its liquidator with less collateral than the debt it repaid.
Upstream stops at 1e18. The spoke now refuses anything below 1e18 plus
that default share.

- Add MIN_POOL_LIQUIDATION_INCENTIVE_MANTISSA and gate setLiquidationIncentive on it
- Document the derivation and the case the constant does not cover
- Rewrite the parity test on this bound as a deliberate divergence
- Move the incentive floor tests to the new value
- Park the pool-wide incentive above the market floor in the seize split test
@Debugger022 Debugger022 self-assigned this Aug 25, 2026
- The package declares ISC and publishes contracts/ wholesale, so
  these four BUSL-1.1 headers shipped inside a permissively
  licensed package
- BSD-3-Clause is what every other Solidity file in the repo
  carries; the BUSL headers arrived with the files rather than
  from a decision
- An isolated-pools VToken always returns NO_ERROR and reverts on
  failure, and no spoke pool lists a fee-on-transfer underlying,
  so the three VToken*Failed branches, the under-delivery check
  and the sub-one-vToken deposit guard had no coverage at all
- Fakes the market and nothing else: the YieldGroup is minted from
  the implementation deployed on this chain and reached through
  depositResource / withdrawResource, so the adapter arrives by
  the same delegatecall the Hub's own deposit path takes
- Extends the same treatment to the view branches no live market
  enters, including a zero total supply against a held balance,
  reserves above the whole backing, and a supply rate that
  overflows the uint64 return

@claude claude 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.

Claude Code Review

Claude Code Review is paused for this repository. To reconnect it, an admin of this repository's GitHub organization (or the account owner, for personal repositories) who can also manage your Claude organization's Code Review settings needs to re-link GitHub in Code Review settings. This is a one-time step.

Tip: disable this comment in your organization's Code Review settings.

Comment thread contracts/Spoke/SpokeComptroller.sol
Comment thread contracts/Spoke/SpokeComptroller.sol
Comment thread contracts/Spoke/SpokeComptroller.sol Outdated
Comment thread contracts/Spoke/SpokeComptroller.sol Outdated
Comment thread contracts/Spoke/SpokeComptroller.sol Outdated
Comment thread contracts/Spoke/SpokeComptroller.sol Outdated
Comment thread contracts/Spoke/SpokeComptroller.sol Outdated
@fred-venus

Copy link
Copy Markdown
Contributor

i dont see anything uncommon, but do have 2 points raising from ai, i checked make sense and seems inherited from existing codebase

  1. Pre-accrual liquidation snapshot

liquidateAccount determines eligibility before accruing the relevant markets, while execution uses the updated state and skips the liquidity check. In theory it could happen that the ahead checking is eligible to liquidate but back to healthy after.

  1. Fallback liquidation incentive

Lowering the pool-wide liquidation incentive can violate LI >= 1 + protocolSeizeShare for markets using the fallback value, the new fallback mechanism should enforce this invariant when updating the pool-wide LI

@fred-venus

fred-venus commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Continuing the topic in group, we will need to deploy a new spoke pool registry and will need to update the addr in protocol share reserve as well. However, without any change to current PSR impl this will revert the existing isolated pool when sending out the revenue because of this check

CleanShot 2026-09-02 at 13 55 05@2x

So we might need to update the logic of PSR a bit to unblock the existing isolated pool, tho its sunset at product level but lets at least make sure there is no unexpected revert onchain

@Debugger022

Debugger022 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

i dont see anything uncommon, but do have 2 points raising from ai, i checked make sense and seems inherited from existing codebase

  1. Pre-accrual liquidation snapshot

liquidateAccount determines eligibility before accruing the relevant markets, while execution uses the updated state and skips the liquidity check. In theory it could happen that the ahead checking is eligible to liquidate but back to healthy after.

  1. Fallback liquidation incentive

Lowering the pool-wide liquidation incentive can violate LI >= 1 + protocolSeizeShare for markets using the fallback value, the new fallback mechanism should enforce this invariant when updating the pool-wide LI

@fred-venus both points are correct and yes both are inherited from existing codebase

1. Pre-accrual liquidation snapshot

healAccount accrues interest and refreshes prices before it takes its snapshot. liquidateAccount takes the same kind of snapshot on stored balances and the last recorded price, and since the orders then run with skipLiquidityCheck, nothing re-tests eligibility on live state afterwards.

Fix: the accrue + updatePrice loop is pulled out of healAccount into a shared _refreshMarkets, and liquidateAccount now calls it before its snapshot. No contract size issue, Size actually drops, since the duplicated loop and a second getAssetsIn walk are gone.
fixed: 395e6a6

2. Fallback liquidation incentive

The gap is real, but I would keep it out of the contract:

  • It is already documented in the NatSpec on setLiquidationIncentive: a market whose seize share is raised above the default needs an incentive of its own.
  • The other side of the invariant is enforced. VToken.setProtocolSeizeShare checks 1e18 + newShare against the calling market's effective incentive, so raising a share cannot break it. Only lowering the pool-wide value afterwards can, and only for a market that has no incentive of its own.
  • Markets are expected to be configured with their own liquidation incentive, so the fallback is not what they read in practice.
  • For any market that does fall back, this is covered at the VIP level: a VIP lowering the pool-wide incentive checks it against 1e18 + protocolSeizeShareMantissa() of every fallback-using market.
  • Enforcing it on chain means looping allMarkets in the setter, which would cause contract size issue

Worth noting the consequence there is liveness, not loss: if the invariant is violated the liquidator receives less than it repaid, so liquidations of that market stop rather than anything being drained.

The batch liquidation entry point took its routing snapshot on stored
balances and the last recorded price, then ran every order with the
liquidity check skipped, so nothing re-tested eligibility on live state.
healAccount already refreshed first, so the two paths decided on
different views of the same position.

- Pull the accrue plus updatePrice loop out of healAccount into a
  shared _refreshMarkets helper.
- Call it from liquidateAccount before the snapshot.
- Resolve the borrower's markets once and reuse the list for the
  trailing zero-borrow check.
- Cover the ordering with a test that accrues past maxClearableDebt.
The registry is the directory every consumer iterates to answer which
pools exist. A hub-funded pool whose supply, borrow and liquidation
sides are each restricted to known accounts does not belong in the
isolated-pools list, so it gets a registry of its own.

- Add a deploy script for a second PoolRegistry instance, behind the
  chain's existing proxy admin, and construct the spoke implementation
  with it
- Renumber the comptroller script and make it depend on the registry,
  so tag-selected runs pull that one in first
- Grant the registry the six comptroller setters addPool and addMarket
  drive as the caller. The wildcard grants covering the isolated-pools
  registry name its address, so none of them carry over
- Pin the ProtocolShareReserve trade in a fork suite. It holds one
  registry address, so pointing it here stops the existing isolated
  pools from taking income, and not pointing it here stops this pool
- Widen maxStalePeriod on the main and pivot oracles in the fork
  fixture. The pinned block left about 77 seconds of budget, so which
  tests passed depended on how many transactions ran ahead of them
@Debugger022

Copy link
Copy Markdown
Collaborator Author

Continuing the topic in group, we will need to deploy a new spoke pool registry and will need to update the addr in protocol share reserve as well. However, without any change to current PSR impl this will revert the existing isolated pool when sending out the revenue because of this check

CleanShot 2026-09-02 at 13 55 05@2x So we might need to update the logic of PSR a bit to unblock the existing isolated pool, tho its sunset at product level but lets at least make sure there is no unexpected revert onchain

@fred-venus

Copy link
Copy Markdown
Contributor

One more thing

CleanShot 2026-09-02 at 19 32 32@2x

We probably want to poolLen as well to reflect DBO and per asset liquidation incentive

@Debugger022

Debugger022 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author

One more thing

CleanShot 2026-09-02 at 19 32 32@2x We probably want to poolLen as well to reflect DBO and per asset liquidation incentive

also this
image

Fixed: b261a7b

- A spoke pool's bounded oracle, liquidation allowlist and per-market
  liquidation incentive had no place in the lens output, so every
  consumer read one as if it were an ordinary isolated pool
- Probe for them with staticcall, so a comptroller that has no answer
  reports absence instead of reverting the whole read, and encode with
  abi.encodeCall so a signature change fails the build
- Append the fields rather than reordering, so a decoder built against
  the old shape still reads the fields it knows
- Archive the superseded revision as PoolLensR3 next to R1 and R2, and
  note in the deploy script that the record has to be archived before a
  redeploy overwrites it
fred-venus
fred-venus previously approved these changes Sep 4, 2026
enterMarkets reads msg.sender, so a router that supplies through
mintBehalf enters itself instead of the supplier. That forces a first
time supplier into two transactions.

- Add enterMarketBehalf(address,address), restricted by AccessControlManager
- Take a single market rather than an array, which keeps the contract
  under EIP-170 with 25 bytes to spare. The router loops instead.
- Leave enterMarkets untouched, so self entry stays permissionless
- Cover the new function in the membership, parity and role string tests
Comment thread contracts/Lens/PoolLens.sol Outdated
liquidationIncentive: comptrollerViewInstance.liquidationIncentiveMantissa(),
minLiquidatableCollateral: comptrollerViewInstance.minLiquidatableCollateral()
minLiquidatableCollateral: comptrollerViewInstance.minLiquidatableCollateral(),
deviationBoundedOracle: _probeAddress(

@fred-venus fred-venus Sep 8, 2026

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.

is this to decide whether its isolated pools or spoke pools ?

i am thinking why dont we just redeploy a new PoolLens with clean implementation, right now we already have new poolRegistry, its natural to think we should have a poolLens as well.

So basically 2 sets of contracts in parallel so they dont confuse ppl

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.

also for VTokenBeacon lets deploy a new one, so that in the future if ever we do the change its not gonna impact existing isolated pools

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The lens isn't bound to a registry, it takes poolRegistryAddress as a call argument, and it's a plain non-proxy contract we version by redeploying (R1/R2 archived, this PR archives R3), so the separation already exists at the address level. But fair point on the confusion, I'll add a separate SpokePoolLens and leave PoolLens for isolated. That also drops the staticcall probing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, will deploy a separate VToken beacon for the spoke pool.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

- Spoke pools are listed in a registry of their own, so reading them
  gets its own lens rather than more fields on the shared one
- Keeps the deployed PoolLens untouched: the spoke fields added to it
  are reverted, along with the revision that archived them, so the
  networks that carry no spoke pool need no redeploy
- Carries the whole PoolLens surface too, under the same names and
  struct shapes wherever the shape is unchanged, so a consumer reads a
  spoke pool from one address
- Declares markets and isForcedLiquidationEnabled on the spoke view
  interface, so the liquidation threshold is not dropped as trailing
  return data
- Checks every mirrored read against PoolLens for the same pool, on a
  fixture that carries real bad debt and reward state
- upgradeTo moves every proxy behind a beacon in one call, so sharing
  VTokenBeacon would tie a VToken change for the spoke pool to every
  isolated market on the chain, and the other way round
- The implementation is the same VToken with the same immutables, so
  the markets behave identically until an upgrade separates them
- Point the fork fixture's markets at the new beacon, which is where
  the separation is decided: the beacon is inert until they use it
- Extract the deploy scripts' verify helper so both spoke scripts
  share it
- 009-deploy-vtokens.ts cannot be reused: it iterates the shared pool
  config and points every market it builds at the shared VToken beacon,
  so the spoke markets need a script and a config of their own
- keep that config out of globalConfig, otherwise 008 and 009 stand the
  pool up behind the shared beacons and claim its deployment names first
- mirror the risk parameters of the isolated stablecoin markets on the
  same network, since the spoke pool restricts who may supply, borrow
  and liquidate rather than taking more risk per market
- name each market VToken_<symbol> with the pool id as the symbol
  suffix, so a spoke market reads apart from an isolated one the way
  Comptroller_HubSpoke already does
- read the comptroller, underlying and rate model back off the chain
  before the run reports success, since a market built against the
  wrong one can only be redeployed
- add USDC to the bsctestnet token list, which carried no entry for it
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown

Code Coverage

Package Line Rate Branch Rate Health
contracts 83% 73%
contracts.Gateway 98% 68%
contracts.Gateway.Interfaces 100% 100%
contracts.Lens 97% 64%
contracts.Lens.legacy 0% 0%
contracts.Pool 100% 92%
contracts.Rewards 96% 70%
contracts.Shortfall 100% 85%
contracts.Spoke 95% 86%
contracts.legacy.RiskFund 0% 0%
contracts.lib 100% 89%
Summary 77% (2207 / 2855) 67% (710 / 1052)

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.

4 participants