All articles
Shadow ArenaMay 23, 20268 min read

How Venus Inherited a 5x Interest Inflation Bug When Forking Compound to BSC

Compound V2 hardcodes blocksPerYear = 2,102,400 (15-second blocks). BSC has 3-second blocks. Forking the constant unchanged turns a 5% APR market into a 25% APR one. The Venus H-01 finding, walked through.

By Carlos (Bloqarl)

TL;DR

  • Compound V2's interest rate model assumes the chain produces a block every 15 seconds. The constant blocksPerYear = 2,102,400 is hardcoded against that assumption.
  • Venus is BSC's largest lending protocol. It is a Compound V2 fork. BSC produces a block roughly every 3 seconds.
  • When the constant was inherited unchanged, every interest accrual on Venus ran 5x faster than the team intended. A market configured to charge 5% APR was actually charging closer to 25% APR. A 20% jump rate was charging 100%+.
  • This is documented in Venus's H-01 audit finding in the Zealynx Academy Shadow Arena. The same class of bug appears in Canto v2 (H-04: per-year vs per-block confusion in the rate calculation).
  • The fix is one constant. The lesson is bigger: forking a protocol involves auditing every assumption the code makes about the chain it was written for.

Why this matters

Forking a smart contract codebase looks like a code-level activity. Open the original, understand it, copy it, deploy it. What teams underestimate is how much of the original protocol's correctness depends on chain-specific assumptions baked into constants and parameter defaults.

Block time is the canonical example. Compound V2 was designed for Ethereum, where blocks land every ~12 to 15 seconds. The protocol's interest math is denominated per block. A "1% per year" rate becomes a per-block rate of 1% / 2_102_400, which when applied per block, compounds to roughly 1% over a year of Ethereum blocks.

When someone forks Compound V2 to BSC (or any other chain with a different block time), the per-block math is now wrong. Same code, same math, completely different real-world rate. The team thinks they are charging 5%; the borrowers are paying 25%. The market dynamics distort. Borrowers leave. Suppliers earn more than expected. The team eventually realizes, often only after a researcher reports it.

Venus's audit finding is the public case study, but the bug class extends to every chain-time-dependent constant in any forked protocol.

The bug, with concrete numbers

Compound V2's JumpRateModel exposes its rate model parameters in per-year units, but stores them in per-block units. The conversion uses blocksPerYear, which is hardcoded:

uint public constant blocksPerYear = 2102400;

constructor(
    uint baseRatePerYear,
    uint multiplierPerYear,
    uint jumpMultiplierPerYear,
    uint kink_
) public {
    baseRatePerBlock        = baseRatePerYear        / blocksPerYear;
    multiplierPerBlock      = multiplierPerYear      / blocksPerYear;
    jumpMultiplierPerBlock  = jumpMultiplierPerYear  / blocksPerYear;
    kink = kink_;
}

The team passes in per-year rates. The contract converts them to per-block rates by dividing by 2_102_400 (which is 365 * 24 * 60 * 60 / 15, the number of 15-second blocks in a year).

Now consider what happens when this contract is deployed to BSC, where blocks land every ~3 seconds. There are roughly 365 * 24 * 60 * 60 / 3 = 10_512_000 blocks per year on BSC. But the contract still uses 2_102_400 in the divisor.

The per-block rates the contract stores are 5x larger than they should be (because the divisor is 5x smaller than it should be).

Effect on a hypothetical market parameterized for 5% APR:

intended baseRatePerYear     = 0.05    (5%)
intended baseRatePerBlock    = 0.05 / 10_512_000 ≈ 4.76e-9

contract baseRatePerBlock    = 0.05 / 2_102_400  ≈ 2.38e-8

ratio: contract / intended ≈ 5x

Every block on BSC accrues 5x the interest the team intended. Compounded over a year:

intended APR  = (1 + 4.76e-9)^10_512_000 - 1 ≈ 5.13%
actual   APR = (1 + 2.38e-8)^10_512_000 - 1 ≈ 28.6%

A market the team thought was charging 5% APR is actually charging closer to 25%-30% APR. The exact ratio depends on the rate model parameters, the kink, and the utilization, but the order of magnitude is right.

For a jump-rate model with a 200% jump multiplier above the 80% utilization kink, the distortion is more dramatic. Borrowers crossing the kink see effective rates of several thousand percent annualized.

The fix is trivial. The detection is hard.

The fix is one line: replace 2_102_400 with the chain-appropriate value, or pass blocksPerYear in as a constructor parameter. Three to five tokens of code, depending on language preference.

What is hard is detecting the bug. The contract compiles. The unit tests pass (because the unit tests are written against the same blocksPerYear assumption). The integration tests pass. The deployment goes smoothly. The first borrower opens a position and pays the wrong rate, but they have no reference for what the rate "should be"; they just see the number on the front end.

The bug shows up in production through three indirect signals:

  1. Borrowers leave faster than supplied capital. With a real APR of 25%+ on a market intended for 5%, borrowers refinance away or repay early. Utilization drops. Suppliers earn less than projected.
  2. Researchers run their own per-block math. Anyone who reads the contract code carefully and does the per-block-to-per-year conversion notices the discrepancy. Once one person notices, the audit finding becomes public and the team scrambles to fix.
  3. Cross-chain comparison. A market configured the same way on two chains produces different observed rates. Someone with a portfolio across Ethereum-Compound and BSC-Venus notices the gap.

In Venus's case, the bug shipped to production and was eventually identified. The team patched the rate model. Borrowers who had been overcharged received some form of remediation. The audit finding is now public and is a standard reference for "what to check when forking Compound to a non-Ethereum chain".

Where else this class of bug shows up

blocksPerYear is the most famous, but it is one example of a broader class: constants in a forked codebase that encode chain-specific assumptions.

Per-block emission rates. Some protocols hardcode reward emissions in tokens-per-block. The intended emission curve assumes 15-second blocks. Fork to a 1-second chain (Solana-EVM, Optimism in some periods) and the schedule completes in 1/15 of the intended time.

Maximum block delays for oracle staleness. A staleness check that allows up to 100 blocks of cached data is allowing 25 minutes on Ethereum and 5 minutes on BSC. The oracle staleness window is now chain-dependent, with different exploit characteristics on each chain.

Liquidation grace periods. Some protocols allow a "grace period" before liquidation, expressed in blocks. A 1000-block grace is 4 hours on Ethereum and 50 minutes on BSC.

Voting periods in governance forks. A 17280-block voting period is 3 days on Ethereum, 14 hours on BSC. Governance manipulation surfaces are different on each chain. A snapshot-based vote that was secure on Ethereum is exploitable on BSC because flash-loan voting can complete inside the shorter window.

Reward distribution checkpoints. Rate-limited claims that allow one claim per 5760 blocks are once-per-day on Ethereum and once-per-five-hours on BSC. Compounding and liquidity-mining strategies built against the Ethereum cadence are over-rewarded on BSC.

The Canto v2 H-04 finding in Shadow Arena is a closely related example: a per-year vs per-block confusion in the rate calculation. Different cause (a unit-conversion mistake rather than a chain-time mismatch), same effect (rates running at the wrong cadence).

How to audit for it

If you are reviewing a Compound V2 fork (or any protocol fork ported across chains), include this checklist:

  1. List every block-denominated constant. grep for "blocks", "BlockNumber", "PerBlock", "perYear". Catalog every occurrence.
  2. For each constant, verify it matches the deployment chain. A 15-second-Ethereum constant in a BSC deployment is a finding. A 12-second-Ethereum-PoS constant in a Polygon deployment is a finding. Etc.
  3. Confirm the chain's actual block time, not the spec. BSC's "3-second" target has drifted up to 4-5 seconds in some periods. Use observed averages over the last six months.
  4. Trace every per-time parameter. APR, emission, staleness, liquidation grace, vote duration. For each, verify the chain-time-to-block conversion is right.
  5. Run a worst-case scenario test. Build a fork test that simulates one year of borrower activity at the deployment chain's actual block cadence and verify the realized APR matches the configured APR. This catches blocksPerYear-class bugs immediately.

This checklist is the first thing the Zealynx team runs when reviewing a Compound V2 fork. It catches the class before it ships.

Related questions

Why is blocksPerYear a constant in canonical Compound? Because Compound was Ethereum-only when it was written, and 15 seconds was a reasonable Ethereum block time at the time. The constant has not been updated since the merge changed Ethereum's block target to 12 seconds; even canonical Compound is now slightly off, though the difference is much smaller (12/15 = 0.8x, not 3/15 = 0.2x as on BSC).

Did Venus pay back overcharged borrowers? Per the post-incident communications, borrowers received remediation through the protocol's reserve pool and a token-based rebate. The exact mechanics are documented in Venus's governance forum.

Are there forks that got this right? Yes. Forks that explicitly parameterized blocksPerYear at deployment, or used a block.timestamp-based rate model rather than block-denominated, sidestep the bug entirely. The Aave v2/v3 model uses timestamp-based rates and does not have this surface.

What about chains with variable block times? Some L2s (Optimism, Arbitrum) have block times that vary depending on transaction load. A constant blocksPerYear is approximately right but not exact. For most lending applications, the approximation is fine; the failure mode here is small drift over time, not the 5x error of fork-without-update.

Where to study this further

The Venus target in Shadow Arena (/shadow-arena/venus) ships with the original H-01 finding. You can read the affected functions, run the reproducible scenario, and submit a finding of your own. The room scores you against the actual audit results.

If you have not built a Compound V2 interest rate model yourself, the Zealynx Academy Compound V2 module covers it in section 3 (JumpRateModel). When you implement the per-year-to-per-block conversion yourself, the temptation to hardcode a chain-specific constant is real, and the test suite forces you to handle it correctly. By the time you finish that section, "is this constant chain-correct?" is the first question you ask of any fork.

Tagged

Shadow ArenaVenusBSCCompound V2Smart Contract Security