Skip to Content
Furnace

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(...), or enterWithToken(...). 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 via MarketRouter.sellLockToFurnace(tokenId, minClaimOut, deadline) for liquid CLAIM. Quote views (quoteEnterWith*, quoteSellLockToFurnace*, getFurnaceState) live on the FurnaceQuoter contract — resolve via Furnace.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 transferFrom into Furnace credits exactly amountIn
  • fee-on-transfer, rebasing, and other balance-mutating ERC20s therefore revert before any swap executes
  • FurnaceQuoter.quoteEnterWithToken(...) still quotes on the requested amountIn, 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 canonical ShareholderRoyalties root wired into Furnace
  • lockEthReward(user, ethAmount, targetTokenId, durationSeconds, createAutoMax, minVeOut)
    • only ShareholderRoyalties can call this
    • msg.value must equal ethAmount exactly; the Furnace does not source ethAmount from 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:

  1. Validate lock: not AutoMax, not listed, not expired, owned by user
  2. Compute incrementalWeightBps and principalEff
  3. Call _applyBonusAmm(user, lockAmount, principalEff, d) for userBonus
  4. Extend lock: ve.extendLockToFor(user, tokenId, newEnd)
  5. Add bonus: ve.addToLockFor(user, tokenId, userBonus)
  6. Enforce minBonusOut guard (reverts MinVeOutNotMet)
  7. 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:

  1. Validate lock: must be AutoMax, not listed, not expired
  2. Check lastAutoMaxBonusClaim[tokenId] — if 0, initialize and return 0
  3. If elapsed < 1 day, return 0 (no revert)
  4. Compute incremental weight and principalEff
  5. Update lastAutoMaxBonusClaim[tokenId] = now
  6. Call _applyBonusAmm(lockOwner, lockAmount, principalEff, MAX_LOCK_DURATION)
  7. Add bonus to lock: ve.addToLockFor(lockOwner, tokenId, userBonus)
  8. 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] == 0 or now - 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 by MAX_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:

  • sellLockToFurnaceFromMarket expects 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.mineMarket pointer match is not sufficient.
  • Furnace records the observed safeTransferFrom(...) sender during custody transfer and rejects any seller argument 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-supplied deadline (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 the MarketRouter owner may settle.
    • After the grace window, settlement is permissionless when the live sell quote meets minClaimOut and the listing has not expired.
    • Strict-mode MarketRouter does not use ERC-721 approvals for settlement. APPROVAL_REVOKED is a reserved analytics code and is not emitted.

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 timestamp (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.
  • 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)

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 + amountIn and 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 deadline block.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) derives minVeOut and calls Furnace.enterWithClaimFor(...) Destination rules (contract):
  • targetTokenId == 0: create a new lock (recipient is msg.sender or explicit user for enterWithClaimFor). createAutoMax only 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 createAutoMax for 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 highest tokenId. 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 highest remainingSectargetSec. Tie-break: highest amountWei, then highest tokenId. If none compatible, create new if room; otherwise block with reason.
  • No eligible locks: create new (requires MIN_LOCK_AMOUNT CLAIM + 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: durationSeconds is clamped to the lock’s current remaining duration. Entry does not extend; use Furnace.extendWithBonus for that.
  • Entry into an existing non-AutoMax lock reverts with InvalidDuration if the lock has less than 7 days (MIN_LOCK_DURATION) remaining. This prevents near-expiry bonus extraction.
  • AutoMax: durationSeconds MUST equal MAX_LOCK_DURATION exactly; 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 veOut reflects only the newly locked amount at the lock’s remaining duration
  • Furnace reverts if the guarded veOut is below minVeOut

Implementation sharp edge:

  • even pure CLAIM paths (enterWithClaim, enterWithClaimFor, and the corresponding FurnaceQuoter quote) still fail closed unless EntryTokenRegistry is 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). If creditReserve reverts, 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):

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

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_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)
  • 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 to lpRewardsVault in 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 * ratePerSec to lpRewardsVault and then best-effort calls notifyRewards(owed)
    • if notifyRewards reverts, the Furnace emits LpRewardsNotifyFailed(vault, owed, revertData) and does not revert the upstream tx
    • in shipped v1.0.0, that revertData field is emitted as empty bytes for hardening; Furnace intentionally does not copy arbitrary LP-vault revert data
  • 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. Use quoteSellLockToFurnaceFromInfo(lockAmount, lockEnd, autoMax) for listed-lock settlement quoting (see MarketRouter listing settlement flow).

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 mineMarket bundle)

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 || 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 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)

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(...) (emits FurnaceQuoterSet). Callers resolve the address via Furnace.furnaceQuoter() and call it directly. If furnaceQuoter is unset on the Furnace, consumers (MarketRouter settlement, ShareholderRoyalties auto-compound) that resolve via furnace.furnaceQuoter() will get address(0) and revert.
  • quoteEnterWith* and quoteSellLockToFurnace* revert while lockingPaused == true, matching execution behavior.
  • getFurnaceState() stays readable while locking is paused.
  • Sharp edge: even no-swap CLAIM paths (enterWithClaim, enterWithClaimFor, quoteEnterWithClaim) require live EntryTokenRegistry + 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 routeTokenId in 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: veOutQuote covers only the newly locked amount at the lock’s remaining duration.

Important (swap safety model):

  • minVeOut is mandatory: Furnace reverts with MinVeOutRequired if minVeOut == 0.
  • On swap paths (ETH/token entry):
    • DexAdapter swaps use amountOutMin = 0 at the router level.
    • Slippage protection is enforced atomically downstream via the minVeOut check.
    • Swap deadline is protocol-set: block.timestamp + SWAP_DEADLINE_SECONDS (currently 300s).

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) is onlyOwner
  • address(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, and ve() == Furnace’s VeClaimNFT (prevents miswiring)
  • 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 lastLpOverflowDripUpdate so 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) != _furnace may only be called via delegatecall from the Furnace.

Key helper functions (called by Furnace)

FunctionTypePurpose
resolveEntryDurationAndWeight(ve, user, targetTokenId, durationSeconds, createAutoMax)viewResolve effective lock duration and duration-weight for a Furnace entry (new or existing lock)
resolveExistingLockDestination(ve, user, targetTokenId, amountLocked, durationSeconds)viewValidate and resolve destination lock for add-to-lock entries
validateNewLock(amountLocked, durationSeconds, createAutoMax)pureValidate new lock params and compute veOut
clampAndDurationWeightBps(durationSeconds)pureClamp duration to [MIN, MAX] and return piecewise duration-weight curve value
normalizeSellExecutionQuote(quoter, lockAmount, lockEnd, autoMax, minClaimOut, ...)viewValidate and normalize a sell-to-Furnace execution quote (slippage, invariant, LP reward cap)
computeBonusAmmRates(...) / computeBonusAmmPayout(...)pureAMM-style bonus math: LP rate, gross spot bps, virtual depth, payout split
splitBonusAmm(grossBonus, lpRateBps)pureSplit gross bonus into user + LP portions
previewSellImpactVolume(currentVolume, lastUpdate, nowTs)pureDecay-weighted sell impact volume preview
computeAccruedSellImpactVolume(currentVol, lastUpdate, addAmount)viewCompute accrued sell impact after a sellback
previewOverflowDrip(core, deploymentTime, reserve)viewPreview LP overflow drip parameters (alpha, gate, cap, per-day)
computeStreamSchedule(amount, currentFinish, currentRate, carry, nowTs)pureRecompute LP stream rate/finish/carry after funding

Delegatecall-only functions (run in Furnace context)

FunctionPurpose
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

FunctionPurpose
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