MarketRouter
MarketRouter is the lock management layer — listings (limit sell), sell now (instant exit), and bonus target escrows (patient entry). The Furnace is the only counterparty; MarketRouter orchestrates settlement. In the CLAIM stream, this is how locked positions exit or enter at market-determined prices.
TL;DR: Sell now via
sellLockToFurnace(tokenId, minClaimOut, deadline). List vialistLock(tokenId, minClaimOut, expiresAtTime). Create buy intents viacreateBonusTargetEscrowWithTarget(...). Settlement goes through the Furnace exclusively. Keeper grace period: 30 min after creation, then permissionless.
What it is
MarketRouter is the strict-mode trading surface for veCLAIM locks:
- The Furnace is the only counterparty.
- Locks are not traded user-to-user.
- All trading amounts are denominated in CLAIM.
MarketRouter is also the only allowed external transfer operator for veCLAIM:
VeClaimNFTonly allows transfers from user → Furnace custody whenmsg.sender == mineMarket(MarketRouter).- Those transfers must go only into the Furnace (
to == furnace). - VeClaimNFT also fail-closes on bundle drift before accepting the transfer (see Wiring safety model).
Wiring hardening
Runtime settlement does not trust a raw Furnace pointer.
- The constructor rejects non-contract / split-brain roots:
claim,ve, androyaltiesmust be live contracts,ve.claimToken()must equalclaim, androyalties.ve()must equalve. - Settlement and auto-Furnace execution resolve the live Furnace from
ve.furnace(). - Those paths fail closed on bundle drift (see Wiring safety model).
- Runtime wiring checks are enforced on every settlement and auto-Furnace execution path.
UI mapping
The frontend exposes MarketRouter inside the Furnace page (/furnace) in the Market card tabs:
- Sell now — market sell to the Furnace
- List Lock — listings (limit sell)
- Make Offer — buy intents (limit buy / bonus target escrows)
Note: Lock Now (bonus entry) uses the Furnace directly; it does not touch MarketRouter.
Redirect URLs:
/furnace/marketand/furnace/offersredirect into/furnace?tab=offer(the embedded Market view).
Primary surfaces
Limit sell (listing)
listLock(tokenId, minClaimOut, expiresAtTime)delistLock(tokenId)cancelExpiredListing(tokenId)(permissionless)emergencyDelist(tokenId)(seller-only afterEMERGENCY_DELIST_MIN_AGE)sellListedLockToFurnace(tokenId, deadline)(settlement)
Market sell (sell now)
sellLockToFurnace(tokenId, minClaimOut, deadline)
Limit buy (Buy intent)
createBonusTargetEscrowWithTarget(targetBonusBps, budgetClaim, durationSeconds, createAutoMax, escrowTtlSeconds, destinationLockId, slippageBps)executeAutoFurnace(offerId, deadline)cancelBonusTargetEscrow(offerId)cancelExpiredBonusTargetEscrow(offerId)(permissionless)extendBonusTargetEscrowExpiry(offerId, newExpiresAt)(buyer-only)
Read-only helpers (UI/indexers)
getListing(tokenId)/getUserListings(user)getBonusTargetEscrow(offerId)/getUserBonusTargetEscrows(user)bonusTargetConfigs(offerId)(public mapping getter: targetBonusBps + slippageBps + configured)getBonusTargetEscrowExpiryBounds(offerId)→(createdAt, expiresAt, maxExpiresAt)totalEscrowedClaim()→ total CLAIM currently held in active bonus target escrows (sum of allfundsRemaining)
Settlement keeper grace period
Both listing settlement and buy-intent execution use a keeper-priority window:
- During the first
SETTLEMENT_KEEPER_GRACE_SECONDSafter creation (default 1800s / 30m), only:- allowlisted settlement keepers (
setSettlementKeeper(address,bool)), or - the contract owner can call:
sellListedLockToFurnace(tokenId, deadline)executeAutoFurnace(offerId, deadline)
- allowlisted settlement keepers (
After the grace window, both are permissionless. In production, treat the owner key as a break-glass executor for this window, not as a normal keeper.
MarketRouter intentionally keeps a live owner path. renounceOwnership() is unsupported because router replacement / rewire, settlement-keeper changes, and guardian recovery remain operational responsibilities after launch.
Auth is checked on msg.sender. Allowlisting a permissionless contract as a settlement keeper lets its callers inherit grace-window rights. See MaintenanceHub — Settlement keeper opt-in for the ALLOW_MAINTENANCE_HUB_SETTLEMENT_KEEPER flag.
Note: strict-mode MarketRouter does not use ERC-721 approvals for settlement. LOCK_DELIST_REASON_APPROVAL_REVOKED is a reserved analytics code and is not emitted.
Listing lifecycle
Create
listLock(tokenId, minClaimOut, expiresAtTime) stores:
- seller
minClaimOut(net payout floor, in CLAIM)listedAtTime(timestamp)expiresAtTime(timestamp)activeflag
It also sets the veNFT listed flag: VeClaimNFT.setListed(tokenId, true).
Constraints:
- Caller must own the lock (v1.0.0: no separate ERC-721 approval required — MarketRouter has inherent transfer privileges on VeClaimNFT)
- Lock must not already be listed/frozen
expiresAtTimemust be:- in the future, and
<=the lock’s effective end returned byVeClaimNFT.getLockInfo(tokenId)- if AutoMax is ON, the effective end is rolling (
block.timestamp + MAX_LOCK_DURATION)
- if AutoMax is ON, the effective end is rolling (
- Listing state changes are rate-limited by a 1-block cooldown (cannot list and delist in the same block)
Settle into Furnace
sellListedLockToFurnace(tokenId, deadline) does:
- Enforce deadline: reverts with
DeadlineExpiredifblock.timestamp > deadline. - Enforce listing is active and not expired.
- Enforce keeper grace period (see above).
- Load live lock info:
ve.getLockInfo(tokenId)→(lockAmount, lockEnd, autoMax, listed)
- Quote the sellback (via FurnaceQuoter, resolved from
furnace.furnaceQuoter()):furnaceQuoter.quoteSellLockToFurnaceFromInfo(lockAmount, lockEnd, autoMax)- Reason: user-scoped sell quotes revert while a lock is listed.
- Require
quoteClaimOut >= listing.minClaimOut(wherequoteClaimOutis the first return value from the quote call). - Clear the listing, clear the ve listed flag, and emit
LockDelisted(tokenId, seller, SOLD_TO_FURNACE). - Checkpoint royalties before transfer.
- Transfer the veNFT to Furnace custody (strict transfer rule).
- Call
furnace.sellLockToFurnaceFromMarket(seller, tokenId, quoteClaimOut). - Emit
ListingSettled(tokenId, seller, claimOut, penalty).
Meaning of penalty in ListingSettled:
penalty = lockAmount - claimOut(clamped at 0)- It is the total haircut implied by the quote. Furnace may book that retained cut partly into
reserveAddand partly intolpRewardfunding for the LP stream. - It is not a platform fee.
Expiry / cancellation
- If expired:
cancelExpiredListing(tokenId)can be called by anyone. - Seller can always
delistLock(tokenId). - Canonical-router replacement rescue: after
VeClaimNFT.setMineMarket(newMarketRouter)rewires the bundle,delistLock(tokenId)on the new router can clear a seller-owned stale velistedflag even though the new router did not inherit the old router’s local listing mapping. - If the listing is stuck (e.g., UI failure) and older than
EMERGENCY_DELIST_MIN_AGE: seller canemergencyDelist(tokenId).
Sell now flow
sellLockToFurnace(tokenId, minClaimOut, deadline):
- Optionally auto-delists if currently listed
- Checkpoints royalties, transfers veNFT to Furnace custody
- Calls
furnace.sellLockToFurnaceFromMarket(seller, tokenId, minClaimOut) - Emits
MarketSellToFurnace(tokenId, seller, minClaimOut, deadline, claimOut)
minClaimOut + deadline are the user’s slippage / anti-stale guards.
Buy intents (bonus target escrows)
What is stored
createBonusTargetEscrowWithTarget(...):
- Transfers
budgetClaimfrom buyer into MarketRouter escrow - Computes and stores
discountBpsfromtargetBonusBpsdiscountBps = floor(targetBonusBps * 10_000 / (10_000 + targetBonusBps))
- Validates TTL (
escrowTtlSeconds) and storesexpiresAt = now + ttl- if
escrowTtlSeconds == 0, usesDEFAULT_BONUS_TARGET_ESCROW_TTL_SECONDS - onchain constraint is
ttl > 0 && ttl <= MAX_BONUS_TARGET_ESCROW_TTL_SECONDS
- if
- Validates
slippageBps- onchain constraint is
slippageBps < 10_000;slippageBps >= 10_000revertsSlippageTooHigh
- onchain constraint is
- If
destinationLockId != 0, validates that lock immediately at creation time- it must already be buyer-owned, not listed, not expired, match the offer’s AutoMax mode, and not shorten the effective lock end relative to
block.timestamp + effectiveDuration - otherwise the create call reverts; later execution still re-validates and falls back to
resolvedLockId = 0if the destination has become ineligible since creation
- it must already be buyer-owned, not listed, not expired, match the offer’s AutoMax mode, and not shorten the effective lock end relative to
- Stores an offer with:
- buyer
- fundsRemaining (initially
budgetClaim) durationSeconds(effective duration)- if
createAutoMax == true, effective duration is forced toMAX_LOCK_DURATION
- if
createAutoMax- destinationLockId
0= create a new lock at execution time- nonzero = validated immediately at creation against the current destination-lock eligibility checks (same owner, not expired, not listed, matching AutoMax flag, and no lock-shortening)
- expiresAt
- configured target:
targetBonusBps,slippageBps, andconfigured = true
- Creation-time constraints that matter in practice:
slippageBpsmust be< 10_000(BPS_DENOM) or creation revertsSlippageTooHigh- a saved nonzero
destinationLockIdis still re-checked at execution time; if that lock later becomes ineligible, execution falls back toresolvedLockId = 0and creates a new lock instead of reverting for stale destination metadata
Global offer parameters (anti-spam / safety)
MarketRouter enforces two owner-managed global parameters on new buy intents:
minBonusTargetEscrowBudget(CLAIM): minimumbudgetClaimrequired to create an offer.- Default:
DEFAULT_MIN_BONUS_TARGET_ESCROW_BUDGET(v1.0.0: 10,000 CLAIM). - Increase-only (cannot be decreased).
- Default:
maxBonusTargetEscrowDiscountBps(BPS): extra cap on the offer’s implied discount (discountBps, derived fromtargetBonusBps).- Default:
DEFAULT_MAX_BONUS_TARGET_ESCROW_DISCOUNT_BPS(v1.0.0: 8,000). - Can be set to
10,000to effectively disable the extra cap.
- Default:
Admin surface (owner):
setBonusTargetEscrowParams(minBudgetClaim, maxDiscountBps)- emits
BonusTargetEscrowParamsChanged(oldMinBudget, newMinBudget, oldMaxDiscountBps, newMaxDiscountBps)
Execution trigger
executeAutoFurnace(offerId, deadline):
- Enforce deadline: reverts with
DeadlineExpiredifblock.timestamp > deadline. - Enforce offer is active and not expired.
- Enforce keeper grace period (see above).
- Snapshot the escrow/config and close the offer immediately (CEI one-shot). Any later revert restores the escrow because the whole transaction reverts.
- Resolve the destination lock id (use
destinationLockIdif it is still eligible; otherwise setresolvedLockId = 0, which means create a new lock). This is a second live eligibility check at execution time; a destination lock that was valid when the offer was created can still fall back to a new lock if it later becomes listed, expired, AutoMax-mismatched, or longer-dated than the target end. - Resolve the live Furnace from
ve.furnace()and fail closed on bundle drift (see Wiring safety model). - Derive
executionDurationSecondsfrom the resolved destination semantics:- new lock: use the escrow’s stored
durationSeconds - existing AutoMax lock: use
MAX_LOCK_DURATION - existing non-AutoMax lock: use the live remaining duration (
lockEnd - block.timestamp) because entry does not extend duration
- new lock: use the escrow’s stored
- Quote the live entry (via FurnaceQuoter, resolved from
furnace.furnaceQuoter()):furnaceQuoter.quoteEnterWithClaim(buyer, claimIn, resolvedLockId, executionDurationSeconds, createAutoMax)
- Compute
effectiveBonusBps = floor(bonusClaim * 10_000 / principalClaim)from the quote. - Require
effectiveBonusBps >= targetBonusBps. - Compute
minVeOut = floor(veOut * (10_000 - slippageBps) / 10_000)from the live quote.veOuthere covers only the newly locked amount at the lock’s remaining duration; entry into an existing lock does not change its duration.- If
veOut > 0but floor-rounding would produceminVeOut == 0,executeAutoFurnaceclamps it to1before calling Furnace.
- Call
furnace.enterWithClaimFor(buyer, claimIn, resolvedLockId, executionDurationSeconds, createAutoMax, minVeOut).
Events:
BonusTargetEscrowCreatedBonusTargetEscrowConfiguredBonusTargetEscrowExecuted(canonical generic execution receipt)BonusTargetEscrowAutoFurnaceExecuted(back-compat companion receipt)
Cancellation / expiry:
- Buyer can
cancelBonusTargetEscrow(offerId)at any time. - If the router is no longer canonical for the live market bundle, anyone can trigger
cancelBonusTargetEscrow(offerId)as a refund-only unwind rescue; CLAIM still returns to the buyer. - Anyone can
cancelExpiredBonusTargetEscrow(offerId)at or after expiry.
Extend expiry
extendBonusTargetEscrowExpiry(offerId, newExpiresAt):
- Buyer-only.
newExpiresAtis an absolute expiry timestamp (unix seconds).- Must be:
- greater than the current
expiresAt, and <= offer.createdAt + MAX_BONUS_TARGET_ESCROW_TTL_SECONDS
- greater than the current
UI helper:
getBonusTargetEscrowExpiryBounds(offerId)returnsmaxExpiresAt(computed from the offer’s creation timestamp).
Trading pause behavior
When tradingPaused == true:
Disabled (reverts TradingPaused):
listLocksellListedLockToFurnacesellLockToFurnacecreateBonusTargetEscrowWithTargetexecuteAutoFurnaceextendBonusTargetEscrowExpiry
Allowed (exits and housekeeping):
delistLockcancelExpiredListingemergencyDelistcancelBonusTargetEscrowcancelExpiredBonusTargetEscrow
Batch and pagination helpers
Batch expiry cleanup
cancelExpiredListingBatch(uint256[] calldata tokenIds)— batch-cancel expired listings. Best-effort: ineligible items are silently skipped.cancelExpiredBonusTargetEscrowBatch(uint256[] calldata offerIds)— batch-cancel expired bonus target escrows. Best-effort: ineligible items are silently skipped.
Stale listing cleanup
cleanupStaleListing(uint256 tokenId)— permissionless: clear a zombie listing where MarketRouter local state is active but the ve-level listed flag was already cleared (e.g. after a router rollback). EmitsLockDelisted.
Paginated read helpers
getUserListingsPaginated(address user, uint256 offset, uint256 limit) → (uint256[] ids, bool hasMore)— paginate a user’s active listing token IDs.getUserBonusTargetEscrowsPaginated(address user, uint256 offset, uint256 limit) → (uint256[] ids, bool hasMore)— paginate a user’s active bonus target escrow IDs.totalEscrowedClaimPaginated(uint256 startId, uint256 endId) → uint256 total— paginated version oftotalEscrowedClaimfor on-chain safety.startIdis inclusive (min 1),endIdis exclusive (capped atnextOfferId).
The non-paginated getUserListings, getUserBonusTargetEscrows, and totalEscrowedClaim are retained for off-chain convenience but are gas-unbounded.
See also
- Furnace — lock bonus engine and Sell now settlement
- Locks (veCLAIM) — lock lifecycle and ve mechanics
- ClaimAllHelper — bundled collect + lock
- Events and Indexing — Market event reference
- Tutorial: Index Market listings
- User manual: Market