Skip to Content
Furnace

Furnace

The Furnace converts entry value into veCLAIM. It pays a reserve-backed bonus and (optionally) routes part of that bonus to LP staker rewards.

Entry methods

All entry methods converge into the same internal flow:

  • swap to CLAIM if needed
  • compute bonus (gross, then split)
  • lock principal + net user bonus into veCLAIM
  • route the LP portion (if enabled)

External entry points (direct user):

  • enterWithEth(targetTokenId, durationSeconds, createAutoMax, minVeOut)
  • enterWithClaim(claimAmount, targetTokenId, durationSeconds, createAutoMax, minVeOut)
  • enterWithToken(tokenIn, amountIn, targetTokenId, durationSeconds, createAutoMax, minVeOut)

Allowlisted entry (protocol-controlled callers):

  • enterWithClaimFor(user, claimAmount, targetTokenId, durationSeconds, createAutoMax, minVeOut)
  • lockEthReward(user, ethAmount, targetTokenId, durationSeconds, createAutoMax, minVeOut)
    • only ShareholderRoyalties can call this

Delegated entry (bot pays, user receives lock; session-gated via DelegationHub):

  • enterWithEthFor(user, targetTokenId, durationSeconds, createAutoMax, minVeOut)
  • enterWithClaimFromCallerFor(user, claimAmount, targetTokenId, durationSeconds, createAutoMax, minVeOut)
  • enterWithTokenFromCallerFor(user, tokenIn, amountIn, targetTokenId, durationSeconds, createAutoMax, minVeOut)

MarketRouter helpers (strict mode: Furnace-only)

The Furnace exposes helper entry points that are restricted to MarketRouter (mineMarket) for settlement:

  • sellLockToFurnaceFromMarket(address seller, uint256 tokenId, uint256 minClaimOut) -> claimOutNet

Operational expectations:

  • sellLockToFurnaceFromMarket expects MarketRouter to:
    • checkpoint the seller in ShareholderRoyalties,
    • clear any listing / ve listed flag,
    • transfer the veNFT to the Furnace,
    • then call this function to burn and pay out.

User-facing entry points in MarketRouter (strict mode):

  • sellLockToFurnace(tokenId, minClaimOut, deadline)Market sell (Sell now): immediate sellback with slippage-derived minimum payout + short onchain-enforced deadline (default 60s)
  • Limit sell (Market listing) settlement — permissionless settlement when the live sell quote meets minClaimOut and the listing is not expired (expiresAtTime)

Semantics + UI labels (source of truth):

Sell side:

  • minClaimOut is the user’s Minimum payout (net CLAIM the seller is willing to receive).
  • For Sell now (Market sell):
    • UI shows Estimated payout now (live quote) + Minimum payout (minClaimOut)
    • frontend derives minClaimOut from slippage and passes a short deadline (default 60s) enforced onchain
  • For Market listings (Limit sell):
    • listing intent is listLock(tokenId, minClaimOut, expiresAtTime)
    • expiry is enforced onchain (must be ≤ lock end)
    • UI default expiry: 30d
    • expired listings can be cleared permissionlessly via cancelExpiredListing(tokenId)

Buy side:

  • Buy now (Market buy / Enter now):
    • user picks tokenIn + amountIn and the lock config
    • UI shows Estimated ve out now (live quote) + Minimum ve out (minVeOut, slippage-derived)
    • if tokenIn != CLAIM (swap path), execution also uses a short deadline (TTL)
  • Limit buy (Buy intent):
    • implemented via bonus target escrows in MarketRouter (see MarketRouter docs)
    • escrow stores budgetClaim, expiresAt, targetBonusBps, slippageBps, and lock config
    • executeAutoFurnace(offerId) derives minVeOut and calls Furnace.enterWithClaimFor(...) Destination rules:
  • targetTokenId == 0: create a new lock (recipient is msg.sender or explicit user for enterWithClaimFor)
  • targetTokenId != 0: add to an existing lock (must be owned by the recipient)

Slippage guard:

  • minVeOut is a minimum ve delta expected from the lock update
  • Furnace reverts if veOut < minVeOut

Reserve accounting

State:

  • furnaceReserve R: CLAIM available to pay bonuses
  • bonusVirtualDepth V: AMM depth used to quote and pay bonuses
  • lastBonusUpdate: timestamp used for V recovery

Invariant:

  • furnaceReserve is always bounded by the Furnace’s onchain CLAIM holdings, excluding any remaining LP stream liability:
    • furnaceReserve <= claim.balanceOf(furnace) - getLpStreamRemaining()
    • if the invariant would be violated (e.g. MineCore over-credits reserve), the Furnace clamps reserve down and emits ReserveClamped

Reserve inflow:

  • MineCore mints the Furnace emission stream to the Furnace
  • MineCore calls Furnace.creditReserve(amount)

Reserve outflow:

  • gross bonus payouts (user bonus + LP top-up)
  • optional LP overflow drip (protocol -> LP rewards stream)

Bonus model (single source of truth)

Bonus economics are pinned in:

  • docs/spec/env-config-and-constants-v1.0.0.md (section 3.4)
  • src/lib/Constants.sol

Key constants (v1.0.0)

Basis points:

  • BPS_DENOM = 10,000

User bonus cap and gross cap:

  • MAX_USER_BONUS_BPS = 10,000 (100% net user cap)
  • MAX_GROSS_BONUS_BPS = 12,500 (125% hard clamp, user + LP). With current LP top-up max (15%), max gross is 11,500 (115%).

LP top-up rate (a fraction of the user bonus):

  • LP_TOPUP_RATE_MIN_BPS = 750 (7.5% of user bonus)
  • LP_TOPUP_RATE_MAX_BPS = 1,500 (15% of user bonus)
  • LP_TOPUP_GAMMA = 2 (convex curve; higher makes lpRate grow more slowly at low bonuses)

Note:

  • Effective LP top-up rate is scaled down by the reserve factor (down-only) to protect the reserve.
    • lpRateBps = floor(lpRateBpsBase * lpScaleBps / 10_000) where lpScaleBps = min(10_000, reserveFactorBps(...)).

Anchors:

  • LOCK_PCT_TARGET_BPS = 550 (5.5% total lock; half-max point for base cap)
  • LOCK_TARGET = 120,000,000 CLAIM (legacy constant; current base cap uses lock%)
  • RESERVE_TARGET_FINAL = 25,000,000 CLAIM
  • RESERVE_FACTOR_MAX_BPS = 20,000 (2.0x)

Time controls:

  • SWING_TIME = 90 days (reserve control ramps in)
  • BONUS_DECAY_WINDOW = 3 hours (bonus cool-off recovery)

Step 1: user spot bonus cap (lock-% + reserve control)

Inputs:

  • totalSupply = CLAIM.totalSupply()
  • lockedSupply = ve.totalLockedClaim()
  • lockedPctBps = clamp(floor(BPS_DENOM * lockedSupply / totalSupply), 0, BPS_DENOM)
  • reserve R = furnaceReserve
  • elapsed = timeSinceLaunch

Base cap (lock-% anchored):

baseUserBps = floor( MAX_USER_BONUS_BPS * LOCK_PCT_TARGET_BPS / (LOCK_PCT_TARGET_BPS + lockedPctBps) )

Reserve fullness (clamped):

reserveFullnessBps = clamp( floor( BPS_DENOM * R / RESERVE_TARGET_FINAL ), 0, RESERVE_FACTOR_MAX_BPS )

Ramp-in (0 at launch, 100% at SWING_TIME):

swingAlphaBps = clamp( floor( BPS_DENOM * elapsed / SWING_TIME ), 0, BPS_DENOM )

Reserve factor (signed-floor semantics, implemented piecewise):

if reserveFullnessBps >= BPS_DENOM: reserveFactorBps = BPS_DENOM + floor( swingAlphaBps * (reserveFullnessBps - BPS_DENOM) / BPS_DENOM ) else: reserveFactorBps = BPS_DENOM - ceil( swingAlphaBps * (BPS_DENOM - reserveFullnessBps) / BPS_DENOM )

Lock-% dependent max boost cap (prevents 100% headlines in low lock adoption regimes):

if lockedPctBps <= LOCK_PCT_MIN_FOR_BOOST_CAP_BPS: maxReserveFactorBps = RESERVE_FACTOR_MAX_BPS_LOWLOCK else if lockedPctBps >= LOCK_PCT_FULL_BOOST_CAP_BPS: maxReserveFactorBps = RESERVE_FACTOR_MAX_BPS else: maxReserveFactorBps = RESERVE_FACTOR_MAX_BPS_LOWLOCK + floor((RESERVE_FACTOR_MAX_BPS - RESERVE_FACTOR_MAX_BPS_LOWLOCK) * (lockedPctBps - LOCK_PCT_MIN_FOR_BOOST_CAP_BPS) / (LOCK_PCT_FULL_BOOST_CAP_BPS - LOCK_PCT_MIN_FOR_BOOST_CAP_BPS)) reserveFactorBps = min(reserveFactorBps, maxReserveFactorBps)

Final user spot cap:

userSpotBps = min( MAX_USER_BONUS_BPS, floor( baseUserBps * reserveFactorBps / BPS_DENOM ) )

Intuition:

  • more lockedPctBps => lower userSpotBps
  • low reserve => damp userSpotBps (after ramp-in)
  • high reserve => boost userSpotBps (after ramp-in)

Step 2: LP top-up rate (additive)

LP top-up is a split of the gross bonus, not a subtraction from the user.

LP top-up is enabled only when lpRewardsVault is set.

if lpRewardsVault == 0x0: lpRateBps = 0 else: span = (LP_TOPUP_RATE_MAX_BPS - LP_TOPUP_RATE_MIN_BPS) γ = LP_TOPUP_GAMMA # v1.0.0 pinned to 2 lpRateBps = LP_TOPUP_RATE_MIN_BPS + floor( span * (userSpotBps^γ) / (MAX_USER_BONUS_BPS^γ) ) lpTopUpSpotBps = floor( userSpotBps * lpRateBps / BPS_DENOM ) grossSpotBps = min( MAX_GROSS_BONUS_BPS, userSpotBps + lpTopUpSpotBps )

Step 3: duration weight wBps

The Furnace scales bonus by a non-linear duration weight curve.

Breakpoints (v1.0.0):

DurationwBpsw%
7 days1001%
14 days1751.75%
21 days3003%
30 days5005%
90 days1,50015%
180 days4,00040%
270 days6,50065%
365 days10,000100%

Computation:

  • clamp durationSeconds into [MIN_LOCK_DURATION, MAX_LOCK_DURATION]
  • find the surrounding breakpoints
  • linear interpolate between them

Effective principal:

P_eff = floor( P * wBps / BPS_DENOM )

If P_eff == 0:

  • gross bonus is 0
  • AMM state does not change

Step 4: virtual depth (bonus cool-off)

The bonus is priced using a constant-product-like AMM that uses:

  • reserve R
  • virtual depth V

Target virtual depth (cap enforcement):

if grossSpotBps == 0 or R == 0: vTarget = 0 else: vTarget = ceil( R * BPS_DENOM / grossSpotBps )

Recovery (cool-off):

  • V decays linearly back toward vTarget over BONUS_DECAY_WINDOW (3 hours)
  • large entries increase V and reduce near-term quotes

Step 5: gross bonus payout and split

Gross bonus (drawn from reserve):

grossBonus = floor( R * P_eff / (V + P_eff) ) R = R - grossBonus V = V + P_eff

Split gross bonus into user + LP:

denom = BPS_DENOM + lpRateBps userBonus = floor( grossBonus * BPS_DENOM / denom ) lpBonus = grossBonus - userBonus # remainder

Locking amount:

  • Furnace locks (principal + userBonus) into veCLAIM.

LP routing:

  • if lpRewardsVault == 0x0 then lpRateBps == 0 and lpBonus == 0
  • if lpRewardsVault != 0x0 then lpBonus is funded into the Furnace LP rewards stream (then streamed to LpStakingVault7D over LP_STREAM_WINDOW; transfers happen on later accrual/tick calls)

LP overflow drip (protocol -> LP rewards stream)

This is separate from the per-entry LP top-up split.

Enabled only when lpRewardsVault != 0x0.

Constants (v1.0.0):

  • LP_OVERFLOW_DRIP_START = 18 months
  • LP_OVERFLOW_DRIP_RAMP = 180 days
  • LP_OVERFLOW_DRIP_INFLOW_SHARE_CAP_BPS = 1,000 (10%)
  • LP_OVERFLOW_DRIP_FIXED_CAP_PER_DAY = 30,000 CLAIM/day
  • LP_OVERFLOW_DRIP_GATE_K = 2,000,000 CLAIM

Daily drip (conceptual):

  • reserveTarget(t) ramps from 0 to RESERVE_TARGET_FINAL over LP_OVERFLOW_DRIP_RAMP after LP_OVERFLOW_DRIP_START
  • excess(t) = max(0, R - reserveTarget(t))
  • gate(t) = excess(t) / (excess(t) + K)
  • drip(t) is capped by:
    • capFixedPerDay
    • capInflowPerDay = inflowPerDay * INFLOW_SHARE_CAP

Implementation notes:

  • The drip accrues on state-changing calls (entry, creditReserve) and via tick().
  • View helpers:
    • getLpOverflowDripPerDay(nowTs)
    • getLpOverflowDripState(nowTs)
    • getLpStreamRemaining()
    • getLpStreamState()
    • tick() (accrues the LP stream and optionally funds the overflow drip into the stream)

LP rewards stream (smoothing)

All Furnace-funded LP rewards are folded into a rolling stream with window LP_STREAM_WINDOW = 14 days:

  • per-entry LP top-up split (lpBonus / lpRewardClaim)
  • LP overflow drip
  • sellback LP share

This is a smoothing layer:

  • funding adds to the stream schedule
  • accrual transfers owed = dt * ratePerSec to lpRewardsVault and then calls notifyRewards(owed)
    • notifyRewards is best-effort: if it reverts, the Furnace emits LpRewardsNotifyFailed(vault, owed, revertData) and does not revert the upstream tx
    • the CLAIM transfer still succeeds, so the vault can reconcile via balance-delta when a later notify succeeds

Sell lock to Furnace (instant liquidity)

Furnace can buy a veCLAIM NFT lock back for liquid CLAIM.

Endpoints:

  • quoteSellLockToFurnace(user, tokenId) -> (lockAmount, claimOut, spreadBps, lpReward, reserveAdd)
  • sellLockToFurnace(tokenId, minClaimOut) -> claimOut

Mechanics (high level):

  • The lock is burned and its underlying locked CLAIM becomes available to the Furnace.
  • A dynamic spread is applied (convex vs userSpotBonusBps, adjusted by remaining lock time):
    • claimOut is paid to the seller.
    • lpReward is funded into the LP rewards stream.
    • reserveAdd is credited to furnaceReserve.

Daily LP sellback cap (volume-driven LP funding):

  • Applies only when lpRewardsVault != 0x0.
  • The sellback lpReward is clamped by a per-day cap:
    • inflowPerDay = MineCore.getFurnaceEmissionRateAt(now) * 1 days
    • capInflow = floor(inflowPerDay * LP_SALE_REWARD_CAP_INFLOW_SHARE_BPS / 10_000)
    • capPerDay = (inflowPerDay == 0) ? LP_SALE_REWARD_CAP_FIXED_CAP_PER_DAY : min(capInflow, LP_SALE_REWARD_CAP_FIXED_CAP_PER_DAY)
    • remaining = max(0, capPerDay - lpSaleFundedToday)
    • lpReward = min(lpRewardRaw, remaining)
  • Any cut not sent to LP (due to the cap) flows into reserveAdd.
  • View helpers:
    • getLpSaleRewardCapPerDay()
    • getLpSaleRewardFundedToday()
    • getLpSaleRewardCapRemaining()

Accounting rule:

  • MUST checkpoint ShareholderRoyalties for the seller before burning/transferring the NFT so the seller keeps the ETH earned up to the sell.

Quote views (for setting minVeOut)

Furnace provides view helpers that mirror execution math:

  • quoteEnterWithEth(user, ethIn, targetTokenId, durationSeconds, createAutoMax)
  • quoteEnterWithClaim(user, claimIn, targetTokenId, durationSeconds, createAutoMax)
  • quoteEnterWithToken(user, tokenIn, amountIn, targetTokenId, durationSeconds, createAutoMax)

Each returns:

  • principalClaim (post-swap principal)
  • bonusClaim (net user bonus)
  • veOut (ve delta)
  • routeTokenId (0 if a new lock would be created)

Client pattern:

  • veOutQuote = quote…veOut
  • minVeOut = floor( veOutQuote * (10,000 - slippageBps) / 10,000 )

Also available:

  • getFurnaceState() (reserve, lockedSupply, userSpotBonusBps, lpTopupRateBps, marginal quote rates, virtualDepth)

LP rewards toggle (“when enabled”)

Furnace has a single config switch:

  • lpRewardsVault

Effects:

  • enables LP top-up split (lpRateBps > 0)
  • enables LP overflow drip accrual

Configuration:

  • setLpRewardsVault(address) is onlyOwner and only callable before freezeConfig
  • non-zero vaults must:
    • be a contract (code.length > 0)
    • report furnace() == address(this) (prevents miswiring)
  • emits LpRewardsVaultSet(oldVault, newVault) on change
  • after freezeConfig, this cannot be changed