Furnace
The Furnace is the protocol’s bonus engine and the only counterparty for lock creation and settlement. Every path into veCLAIM passes through the Furnace — whether a user locks CLAIM directly, compounds royalties, or routes LP rewards. It sits at the center of the CLAIM stream.
TL;DR: Enter via
enterWithEth(...),enterWithClaim(...), orenterWithToken(...). The Furnace adds a reserve-backed bonus (up to 100% net, duration-weighted), locks principal + bonus as veCLAIM, and optionally tops up LP rewards. Sell back viaMarketRouter.sellLockToFurnace(tokenId, minClaimOut, deadline)for liquid CLAIM. Quote views (quoteEnterWith*,quoteSellLockToFurnace*,getFurnaceState) live on the FurnaceQuoter contract — resolve viaFurnace.furnaceQuoter().
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)
Token-entry safety note:
- WETH stays on the built-in 1:1 unwrap path and does not use the ERC20 exact-receipt guard
- every other token entry path fail-closes unless the initial
transferFrominto Furnace credits exactlyamountIn - fee-on-transfer, rebasing, and other balance-mutating ERC20s therefore revert before any swap executes
FurnaceQuoter.quoteEnterWithToken(...)still quotes on the requestedamountIn, so quote fidelity assumes standard ERC20 transfer semantics
Allowlisted entry (protocol-controlled callers):
- enterWithClaimFor(user, claimAmount, targetTokenId, durationSeconds, createAutoMax, minVeOut)
- allowlisted callers: MarketRouter (
mineMarket), MineCore (King auto-lock), LpStakingVault7D (lpRewardsVault) - if the caller is MarketRouter, Furnace also fail-closes unless
MarketRouter.royalties()still matches the canonicalShareholderRoyaltiesroot wired into Furnace
- allowlisted callers: MarketRouter (
- lockEthReward(user, ethAmount, targetTokenId, durationSeconds, createAutoMax, minVeOut)
- only ShareholderRoyalties can call this
msg.valuemust equalethAmountexactly; the Furnace does not sourceethAmountfrom idle ETH balance
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)
Extension bonus (non-AutoMax locks)
Extending a lock’s duration through the Furnace awards a bonus on the existing locked capital for the incremental duration commitment. No new capital is required — the bonus is funded entirely from the Furnace reserve using the same bonus AMM as entry.
Entry points:
- extendWithBonus(tokenId, durationSeconds, minBonusOut) -> bonusClaim
- extendWithBonusFor(user, tokenId, durationSeconds, minBonusOut) -> bonusClaim (delegated; requires
P_VE_EXTEND_LOCK_FOR)
Rejects AutoMax locks — AutoMax locks receive bonuses automatically via claimAutoMaxBonus (see AutoMax automatic bonus growth).
Math:
oldRemaining = lockEnd - now
(d, _) = clampAndDurationWeightBps(durationSeconds)
incrementalWeightBps = durationWeightBps(d) - durationWeightBps(oldRemaining)
principalEff = floor( lockAmount * incrementalWeightBps / BPS_DENOM )
bonus = bonusAMM(principalEff) # same CPMM as entry path (Step 5)This is path-independent with respect to the duration weight curve: extending from 30d to 365d in one step yields the same principalEff as extending 30d → 180d → 365d in two steps. The AMM curve is concave, so splitting extensions gives slightly less total bonus — identical to the entry-side behavior. With deep virtual depth, the difference is negligible.
Execution flow:
- Validate lock: not AutoMax, not listed, not expired, owned by user
- Compute
incrementalWeightBpsandprincipalEff - Call
_applyBonusAmm(user, lockAmount, principalEff, d)foruserBonus - Extend lock:
ve.extendLockToFor(user, tokenId, newEnd) - Add bonus:
ve.addToLockFor(user, tokenId, userBonus) - Enforce
minBonusOutguard (revertsMinVeOutNotMet) - Sync reserve, emit
FurnaceEnter(user, MODE_EXTEND_WITH_BONUS=4, 0, 0, bonusClaim, tokenId)
Quote view:
quoteExtendWithBonus(user, tokenId, durationSeconds)->(lockAmount, bonusClaim, newEndSec)
AutoMax automatic bonus growth
A key advantage of AutoMax locks is automatic bonus growth. Because AutoMax locks are perpetually at MAX_LOCK_DURATION, the protocol accrues extension bonuses on their behalf — no manual extensions, no gas costs for the user, no risk of forgetting. A permissionless function allows any caller (keeper/bot) to trigger accrual for any AutoMax lock:
- claimAutoMaxBonus(tokenId) -> bonusClaim
- claimAutoMaxBonusBatch(tokenIds[], maxLocks) -> totalBonus
State:
lastAutoMaxBonusClaim(tokenId)— per-lock timestamp of the last claim (public mapping)
Rules:
- Only for AutoMax locks (reverts on non-AutoMax for single; batch silently skips ineligible locks)
- 24h minimum cooldown per lock (if elapsed < 1 day, single returns 0; batch skips)
- First call initializes the timestamp and returns 0 bonus (no retroactive accrual)
- If elapsed > MAX_LOCK_DURATION, elapsed is clamped to MAX_LOCK_DURATION
- Batch: processes up to
min(tokenIds.length, maxLocks, MAX_AUTOMAX_BONUS_BATCH)locks per call (MAX_AUTOMAX_BONUS_BATCH = 200)
Math:
elapsed = now - lastAutoMaxBonusClaim[tokenId]
pseudoOldRemaining = MAX_LOCK_DURATION - elapsed
incrementalWeightBps = durationWeightBps(MAX_LOCK_DURATION) - durationWeightBps(pseudoOldRemaining)
principalEff = floor( lockAmount * incrementalWeightBps / BPS_DENOM )
bonus = bonusAMM(principalEff)AutoMax lockers receive the same bonus a non-AutoMax locker would earn by extending from (MAX - elapsed) back to MAX every elapsed seconds — but without lifting a finger. This hands-free compounding is one of the strongest incentives for choosing AutoMax.
Execution flow:
- Validate lock: must be AutoMax, not listed, not expired
- Check
lastAutoMaxBonusClaim[tokenId]— if 0, initialize and return 0 - If
elapsed < 1 day, return 0 (no revert) - Compute incremental weight and
principalEff - Update
lastAutoMaxBonusClaim[tokenId] = now - Call
_applyBonusAmm(lockOwner, lockAmount, principalEff, MAX_LOCK_DURATION) - Add bonus to lock:
ve.addToLockFor(lockOwner, tokenId, userBonus) - Emit
AutoMaxBonusClaimed(lockOwner, tokenId, bonusClaim)(dedicated event — does not appear in the activity feed)
Quote views (on FurnaceQuoter):
FurnaceQuoter.quoteAutoMaxBonus(tokenId)->(lockAmount, bonusClaim)FurnaceQuoter.quoteAutoMaxBonusBatch(tokenIds[])->(bonuses[], totalBonus)
Keeper integration:
- Scan for AutoMax locks where
lastAutoMaxBonusClaim[tokenId] == 0ornow - lastAutoMaxBonusClaim[tokenId] >= 1 day - Use
quoteAutoMaxBonusBatch(tokenIds)to preview bonus amounts; skip locks below the minimum reward threshold (KEEPER_AUTOMAX_BONUS_MIN_REWARD, default 100 CLAIM) to avoid wasting gas on small accruals - Use
claimAutoMaxBonusBatch(tokenIds, maxLocks)to process up to 200 locks per tx (ineligible locks are silently skipped, capped byMAX_AUTOMAX_BONUS_BATCH) - Single-lock fallback:
claimAutoMaxBonus(tokenId) - Gas cost naturally bounds throughput
MarketRouter helpers (strict mode: Furnace-only)
The Furnace exposes helper entry points that are restricted to the canonically wired MarketRouter bundle (mineMarket) for settlement:
sellLockToFurnaceFromMarket(address seller, uint256 tokenId, uint256 minClaimOut) -> claimOut
Operational expectations:
sellLockToFurnaceFromMarketexpects MarketRouter to:- checkpoint the seller in ShareholderRoyalties,
- clear any listing / ve listed flag,
- move the veNFT into Furnace custody with
safeTransferFrom(...), - then call this function to burn and pay out.
- Both custody admission and settlement execution fail closed on bundle drift (see Wiring safety model). A raw
Furnace.mineMarketpointer match is not sufficient. - Furnace records the observed
safeTransferFrom(...)sender during custody transfer and rejects anysellerargument that does not match that observed owner.
User-facing entry points in MarketRouter (strict mode):
sellLockToFurnace(tokenId, minClaimOut, deadline)— Market sell (Sell now): immediate sellback with slippage-derived minimum payout + caller-supplieddeadline(shipped frontend: 60s in Sell now tab, 120s in Furnace hero Sell modal).- Limit sell (Market listing) settlement:
- Keeper-priority for
SETTLEMENT_KEEPER_GRACE_SECONDS(30 min) after creation — only allowlisted keepers or theMarketRouterowner may settle. - After the grace window, settlement is permissionless when the live sell quote meets
minClaimOutand the listing has not expired. - Strict-mode
MarketRouterdoes not use ERC-721 approvals for settlement.APPROVAL_REVOKEDis a reserved analytics code and is not emitted.
- Keeper-priority for
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 shortdeadlinetimestamp (now + TTL; the current shipped frontend uses 60s in the Sell now tab and 120s in the Furnace hero Sell modal). Onchain only enforces the timestamp the caller actually passes.
- 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)
- current shipped frontend default expiry: 30d
- expired listings can be cleared permissionlessly via
cancelExpiredListing(tokenId)
- listing intent is
Buy side:
- Buy now (Enter now / Market buy):
- immediate Furnace entry (mints/tops up your veCLAIM lock; not a user-to-user purchase)
- user picks
tokenIn+amountInand the lock config - UI shows Estimated ve out now (live quote; covers only the newly locked amount at the lock’s remaining duration) + Minimum ve out (
minVeOut, slippage-derived) - if
tokenIn != CLAIM(swap path), swaps use an onchain deadlineblock.timestamp + SWAP_DEADLINE_SECONDS(currently 300s)
- 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, deadline)derivesminVeOutand callsFurnace.enterWithClaimFor(...)Destination rules (contract):
- targetTokenId == 0: create a new lock (recipient is msg.sender or explicit user for enterWithClaimFor).
createAutoMaxonly applies on this path. - targetTokenId != 0: add to an existing lock (must be owned by the recipient). Furnace preserves the destination lock’s existing AutoMax mode and ignores
createAutoMaxfor existing locks.
Frontend auto-selection (all “lock into ve” flows):
Duration: Default is AutoMax (365 days). Lowering the slider disables AutoMax; returning to 365d does not re-enable it (requires explicit toggle).
Lock destination is a clickable dropdown (not behind an Advanced panel). Shows the auto-selected lock or “New lock”.
Selection logic:
- AutoMax intent (
wantsAutoMax == true): pick the eligible AutoMax lock with the highesttokenId. If none exist, create new (createAutoMax = true) if lock-cap room remains; otherwise mark AutoMax unavailable and block submit. - Fixed-duration intent (
wantsAutoMax == false): pick the eligible fixed lock with highestremainingSec≤targetSec. Tie-break: highestamountWei, then highesttokenId. If none compatible, create new if room; otherwise block with reason. - No eligible locks: create new (requires
MIN_LOCK_AMOUNTCLAIM + spare lock-cap room). - Eligible = owned, not listed, not expired (AutoMax locks never treated as expired while
autoMax == true).
Duration rules for existing destinations:
- Non-AutoMax:
durationSecondsis clamped to the lock’s current remaining duration. Entry does not extend; useFurnace.extendWithBonusfor that. - Entry into an existing non-AutoMax lock reverts with
InvalidDurationif the lock has less than 7 days (MIN_LOCK_DURATION) remaining. This prevents near-expiry bonus extraction. - AutoMax:
durationSecondsMUST equalMAX_LOCK_DURATIONexactly; any other value reverts (InvalidDuration). Pre-fill 365d and disable the slider.
Manual override persists per-wallet until the selected lock becomes invalid, then falls back to Auto. See docs/ui/lock-destination-defaults-v1.0.0.md for the full algorithm.
Slippage guard:
- minVeOut is a minimum entry-attributable ve delta expected from the newly locked amount
- Entry into an existing lock does not change its duration, so
veOutreflects only the newly locked amount at the lock’s remaining duration - Furnace reverts if the guarded
veOutis belowminVeOut
Implementation sharp edge:
- even pure CLAIM paths (
enterWithClaim,enterWithClaimFor, and the corresponding FurnaceQuoter quote) still fail closed unlessEntryTokenRegistryis set and its router/factory/wrappedNative/CLAIM config is populated - this is an implementation precondition for config parity with the swap paths; it does not by itself require a token-specific allowlisted route or the canonical WETH→CLAIM hop unless the chosen path actually needs them
Delegatecall event emission (EIP-170 relief)
Three Furnace events — BonusPaid, LpOverflowDripPaid, and LockSoldToFurnace — are emitted from the Furnace address via delegatecall into FurnaceGuardHelper. This keeps Furnace runtime bytecode under the EIP-170 limit while preserving the Furnace address as the log emitter (delegatecall runs in the caller’s context). The events are declared in IFurnace so they appear in Furnace’s compiled ABI; off-chain tooling does not need to merge the FurnaceGuardHelper ABI to decode them.
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 the LP stream liability:
furnaceReserve <= claim.balanceOf(furnace) - lpStreamLiability- where
lpStreamLiability = (finish - lastUpdate) * rate + lpStreamCarry, which is strictly >=getLpStreamRemaining()whenever there are matured-but-unpaid tokens or non-zero carry dust. - 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)
Operational dependency:
- MineCore intentionally fails closed on Furnace reserve accrual. During takeovers it mints the Furnace emission stream and then calls
Furnace.creditReserve(amount). IfcreditReservereverts, the takeover reverts as well so the protocol never leaves untracked CLAIM sitting in the Furnace reserve bucket. - Operators should monitor repeated takeover failures /
InvariantViolation()during launch and keep a guardian pause procedure ready if Furnace reserve accounting becomes unhealthy.
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 a down-only LP scale derived from the same capped reserve factor used for
userSpotBps.lpScaleBps = min(10_000, reserveFactorBps)after the lock-% max-boost cap.lpRateBps = floor(lpRateBpsBase * lpScaleBps / 10_000).
Anchors:
- LOCK_PCT_TARGET_BPS = 700 (7.0% total lock; half-max point for base cap)
- LOCK_TARGET = 120,000,000 CLAIM (absolute-supply constant; primary base cap uses lock%)
- RESERVE_TARGET_FINAL = 20,000,000 CLAIM
- RESERVE_FACTOR_MAX_BPS = 20,000 (2.0x)
Time controls:
- SWING_TIME = 60 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:
lpRateBpsBase = 0
lpRateBps = 0
else:
span = (LP_TOPUP_RATE_MAX_BPS - LP_TOPUP_RATE_MIN_BPS)
γ = LP_TOPUP_GAMMA # v1.0.0 pinned to 2
lpRateBpsBase = LP_TOPUP_RATE_MIN_BPS
+ floor( span * (userSpotBps^γ) / (MAX_USER_BONUS_BPS^γ) )
# reserveFactorBps here is the Step 1 runtime factor after the low-lock max-boost cap
lpScaleBps = min( BPS_DENOM, reserveFactorBps )
lpRateBps = floor( lpRateBpsBase * lpScaleBps / BPS_DENOM )
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% |
Footnote: the 7-day floor applies only when the lock actually has ≥ 7 days remaining. Locks with < 7 days remaining are rejected as entry destinations.
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 / convergence:
- If V < vTarget (e.g. reserve decreased or spot cap changed): V snaps up to vTarget immediately (no gradual ramp)
- If V > vTarget (e.g. after a large entry): V decays linearly back toward vTarget over BONUS_DECAY_WINDOW (3 hours)
- If dt >= BONUS_DECAY_WINDOW since last update: V snaps to vTarget
- large entries increase V (via Step 5: V = V + P_eff) 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)
- each LP stream re-fund emits
LpStreamFunded(amountFunded, newRatePerSec, newPeriodFinish)so offchain systems can track the live schedule without replaying every reserve-affecting event
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):
- excess = max(0, R - RESERVE_TARGET_FINAL)
- gate(t) = excess / (excess + K)
- alpha(t) ramps from 0 to 1 after LP_OVERFLOW_DRIP_START over LP_OVERFLOW_DRIP_RAMP
- drip(t) = min(capFixedPerDay, capInflowPerDay) * gate(t) * alpha(t)
- capInflowPerDay = inflowPerDay * INFLOW_SHARE_CAP
Implementation notes:
- The drip accrues on state-changing calls (entry bonus paths, sellback, creditReserve) and via
tick(). - Read helpers:
getLpOverflowDripPerDay()getLpStreamRemaining()getLpStreamState()
- Upkeep entrypoint:
tick()(permissionless; accrues the LP stream and optionally funds the overflow drip into that stream; returns the CLAIM amount streamed tolpRewardsVaultin this call)
- There is no dedicated
getLpOverflowDripState()getter in the shipped v1.0.0 ABI.
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 best-effort callsnotifyRewards(owed)- if
notifyRewardsreverts, the Furnace emitsLpRewardsNotifyFailed(vault, owed, revertData)and does not revert the upstream tx - in shipped v1.0.0, that
revertDatafield is emitted as empty bytes for hardening; Furnace intentionally does not copy arbitrary LP-vault revert data
- if
- stream schedule changes are observable directly via
LpStreamFunded(amountFunded, newRatePerSec, newPeriodFinish)- the CLAIM transfer still succeeds, so the vault can reconcile via balance-delta when a later notify or fee harvest succeeds
Sell lock to Furnace (instant liquidity)
Furnace can buy a veCLAIM NFT lock back for liquid CLAIM.
Quote endpoints (on FurnaceQuoter, not Furnace — resolve address via Furnace.furnaceQuoter()):
- quoteSellLockToFurnace(user, tokenId) -> (lockAmount, claimOut, spreadBps, lpReward, reserveAdd)
- quoteSellLockToFurnaceFromInfo(lockAmount, lockEnd, autoMax) -> (claimOut, spreadBps, lpReward, reserveAdd)
- quoteSellLockToFurnaceBreakdown(user, tokenId) -> SellLockQuoteBreakdown
- quoteSellLockForExecution(lockAmount, lockEnd, autoMax) -> SellExecutionQuote (used internally by FurnaceGuardHelper for execution alignment)
- Note: user-scoped sell quotes (
quoteSellLockToFurnace,quoteSellLockToFurnaceBreakdown) revert while a lock is listed. UsequoteSellLockToFurnaceFromInfo(lockAmount, lockEnd, autoMax)for listed-lock settlement quoting (see MarketRouter listing settlement flow).
- Note: user-scoped sell quotes (
Execution endpoints (strict mode):
- MarketRouter.sellLockToFurnace(tokenId, minClaimOut, deadline) -> claimOut
- MarketRouter.sellListedLockToFurnace(tokenId, deadline) -> claimOut (listed settlement)
- Furnace.sellLockToFurnaceFromMarket(seller, tokenId, minClaimOut) -> claimOut (restricted to the canonical
mineMarketbundle)
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 || capInflow == 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)
All quote views live on the FurnaceQuoter contract, not on Furnace itself. Resolve the address via Furnace.furnaceQuoter():
- FurnaceQuoter.quoteEnterWithEth(user, ethIn, targetTokenId, durationSeconds, createAutoMax)
- FurnaceQuoter.quoteEnterWithClaim(user, claimIn, targetTokenId, durationSeconds, createAutoMax)
- FurnaceQuoter.quoteEnterWithToken(user, tokenIn, amountIn, targetTokenId, durationSeconds, createAutoMax)
- FurnaceQuoter.quoteExtendWithBonus(user, tokenId, durationSeconds) -> (lockAmount, bonusClaim, newEndSec)
- FurnaceQuoter.quoteAutoMaxBonus(tokenId) -> (lockAmount, bonusClaim)
Implementation detail (important):
- Owner sets the quoter via
setFurnaceQuoter(...)(emitsFurnaceQuoterSet). Callers resolve the address viaFurnace.furnaceQuoter()and call it directly. IffurnaceQuoteris unset on the Furnace, consumers (MarketRouter settlement, ShareholderRoyalties auto-compound) that resolve viafurnace.furnaceQuoter()will getaddress(0)and revert. quoteEnterWith*andquoteSellLockToFurnace*revert whilelockingPaused == true, matching execution behavior.getFurnaceState()stays readable while locking is paused.- Sharp edge: even no-swap CLAIM paths (
enterWithClaim,enterWithClaimFor,quoteEnterWithClaim) require liveEntryTokenRegistry+ router config. Treat broken registry wiring as fatal for CLAIM entry too, not just swap paths.
Each returns:
- principalClaim (post-swap principal)
- bonusClaim (net user bonus)
- veOut (entry-attributable ve delta for the newly locked amount at the lock’s remaining duration)
- tokenIdUsed (4th return value; named
routeTokenIdin the ABI)0= create a new lock- otherwise = route into an existing destination lock tokenId
Note: This tokenIdUsed is unrelated to EntryTokenRegistry routeTokenId (DIRECT_TO_CLAIM / VIA_WETH).
Client pattern:
- veOutQuote = quote…veOut
- minVeOut = floor( veOutQuote * (10,000 - slippageBps) / 10,000 )
- Note:
veOutQuotecovers only the newly locked amount at the lock’s remaining duration.
Important (swap safety model):
minVeOutis mandatory: Furnace reverts withMinVeOutRequiredifminVeOut == 0.- On swap paths (ETH/token entry):
- DexAdapter swaps use
amountOutMin = 0at the router level. - Slippage protection is enforced atomically downstream via the
minVeOutcheck. - Swap deadline is protocol-set:
block.timestamp + SWAP_DEADLINE_SECONDS(currently 300s).
- DexAdapter swaps use
Also available on FurnaceQuoter:
- FurnaceQuoter.getFurnaceState() → (reserve, lockedSupply, userSpotBonusBps, lpTopupRateBps, quoteUserBonusBps, quoteLpTopUpBps, virtualDepth, lastUpdate)
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)isonlyOwneraddress(0)disables LP split routing and future overflow-drip funding- non-zero vaults must:
- be a contract (
code.length > 0) - report
furnace() == address(this),claim()== Furnace’s CLAIM, andve()== Furnace’s VeClaimNFT (prevents miswiring)
- be a contract (
- changing the vault address, including switching to
address(0), fails closed while already-earned LP liability remains attributable to the current vault (LpRewardsStreamActive)- that liability includes both the parked LP stream remainder and overflow-drip rewards already accrued for the current vault period but not yet checkpointed into the stream
- any successful vault change resets
lastLpOverflowDripUpdateso a new vault period cannot inherit backlog from a prior or disabled period - emits
LpRewardsVaultSet(oldVault, newVault)on change
FurnaceGuardHelper
FurnaceGuardHelper is a companion contract deployed from the Furnace constructor (EIP-170 bytecode relief). It holds externalized guard checks, math helpers, swap execution, and delegatecall-only event emitters that run in Furnace’s storage context.
Deployment and immutables
- Constructor sets
_furnace = msg.sender(the deploying Furnace) and_self = address(this). - Functions guarded by
address(this) != _furnacemay only be called viadelegatecallfrom the Furnace.
Key helper functions (called by Furnace)
| Function | Type | Purpose |
|---|---|---|
resolveEntryDurationAndWeight(ve, user, targetTokenId, durationSeconds, createAutoMax) | view | Resolve effective lock duration and duration-weight for a Furnace entry (new or existing lock) |
resolveExistingLockDestination(ve, user, targetTokenId, amountLocked, durationSeconds) | view | Validate and resolve destination lock for add-to-lock entries |
validateNewLock(amountLocked, durationSeconds, createAutoMax) | pure | Validate new lock params and compute veOut |
clampAndDurationWeightBps(durationSeconds) | pure | Clamp duration to [MIN, MAX] and return piecewise duration-weight curve value |
normalizeSellExecutionQuote(quoter, lockAmount, lockEnd, autoMax, minClaimOut, ...) | view | Validate and normalize a sell-to-Furnace execution quote (slippage, invariant, LP reward cap) |
computeBonusAmmRates(...) / computeBonusAmmPayout(...) | pure | AMM-style bonus math: LP rate, gross spot bps, virtual depth, payout split |
splitBonusAmm(grossBonus, lpRateBps) | pure | Split gross bonus into user + LP portions |
previewSellImpactVolume(currentVolume, lastUpdate, nowTs) | pure | Decay-weighted sell impact volume preview |
computeAccruedSellImpactVolume(currentVol, lastUpdate, addAmount) | view | Compute accrued sell impact after a sellback |
previewOverflowDrip(core, deploymentTime, reserve) | view | Preview LP overflow drip parameters (alpha, gate, cap, per-day) |
computeStreamSchedule(amount, currentFinish, currentRate, carry, nowTs) | pure | Recompute LP stream rate/finish/carry after funding |
Delegatecall-only functions (run in Furnace context)
| Function | Purpose |
|---|---|
createLockDelegated(claim, ve, user, amount, duration, autoMax) | Approve + create lock via VeClaimNFT on behalf of Furnace |
addToLockDelegated(claim, ve, user, tokenId, amount) | Approve + add-to-lock via VeClaimNFT on behalf of Furnace |
emitBonusPaid(frame, reserveAfter, virtualDepthAfter) | Emit BonusPaid event (15 data words) from the Furnace address |
emitLockSoldToFurnace(...) | Emit LockSoldToFurnace event from the Furnace address |
emitLpOverflowDripPaid(...) | Emit LpOverflowDripPaid event from the Furnace address |
receiveTokenBalanceDelta(tokenIn, from, amountIn) | Balance-delta transferFrom for token entries |
Swap execution
| Function | Purpose |
|---|---|
executeSwapEthToClaim(registry, router, factory, weth, claimToken, recipient) | ETH → CLAIM via DexAdapter (deadline: block.timestamp + SWAP_DEADLINE_SECONDS) |
executeSwapTokenToClaim(registry, tokenIn, amountIn, router, factory, weth, claimToken, recipient) | ERC-20 → CLAIM via DexAdapter (unwraps WETH to ETH path if tokenIn == weth) |
buildAndValidateSwapRoutes(reg, tokenIn, router, factory, weth, claimToken) | Build and validate DEX routes from EntryTokenRegistry |
Wiring and validation checks
requireCanonicalMineMarket, requireCanonicalMineMarketEntryCaller, requireCanonicalDelegationHub, requireOnlyShareholderRoyalties, requireLpRewardsVaultCompatible, requireFurnaceQuoterCompatible, getValidatedRouterConfig, validateSellLockReceive, validateReceiveEth — cross-contract wiring integrity checks used by Furnace during entry, sellback, and configuration flows.
Sell-impact tracking
The Furnace tracks cumulative sell volume via sellImpactVolume / lastSellImpactUpdate state variables. When a Furnace entry occurs while recent sell volume is elevated, NearSlippageLimitEntry(user, minVeOut, actualVeOut, marginBps) is emitted as a transparency signal. The sell impact decays linearly over BONUS_DECAY_WINDOW. FurnaceGuardHelper provides previewSellImpactVolume and computeAccruedSellImpactVolume for off-chain previews.
See also
- Locks (veCLAIM) — lock lifecycle, ve decay, and AutoMax
- Market (MarketRouter) — listings, escrows, and Furnace settlement
- ShareholderRoyalties (Barons) — ETH royalty distribution
- Core Mechanics — takeover loop and emission schedule
- Tutorial: Integrate Furnace quotes + enter
- User manual: The Furnace