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:
sellLockToFurnaceFromMarketexpects 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
minClaimOutand the listing is not expired (expiresAtTime)
Semantics + UI labels (source of truth):
Sell side:
minClaimOutis 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
minClaimOutfrom slippage and passes a shortdeadline(default 60s) enforced onchain
- UI shows Estimated payout now (live quote) + Minimum payout (
- 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)
- listing intent is
Buy side:
- Buy now (Market buy / Enter now):
- user picks
tokenIn+amountInand 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 shortdeadline(TTL)
- user picks
- 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)derivesminVeOutand callsFurnace.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)wherelpScaleBps = 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):
| Duration | wBps | w% |
|---|---|---|
| 7 days | 100 | 1% |
| 14 days | 175 | 1.75% |
| 21 days | 300 | 3% |
| 30 days | 500 | 5% |
| 90 days | 1,500 | 15% |
| 180 days | 4,000 | 40% |
| 270 days | 6,500 | 65% |
| 365 days | 10,000 | 100% |
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_effSplit gross bonus into user + LP:
denom = BPS_DENOM + lpRateBps
userBonus = floor( grossBonus * BPS_DENOM / denom )
lpBonus = grossBonus - userBonus # remainderLocking 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 * ratePerSectolpRewardsVaultand then callsnotifyRewards(owed)notifyRewardsis best-effort: if it reverts, the Furnace emitsLpRewardsNotifyFailed(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):claimOutis paid to the seller.lpRewardis funded into the LP rewards stream.reserveAddis credited tofurnaceReserve.
Daily LP sellback cap (volume-driven LP funding):
- Applies only when
lpRewardsVault != 0x0. - The sellback
lpRewardis clamped by a per-day cap:inflowPerDay = MineCore.getFurnaceEmissionRateAt(now) * 1 dayscapInflow = 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
ShareholderRoyaltiesfor 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