Skip to Content
Market (MarketRouter)

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 via listLock(tokenId, minClaimOut, expiresAtTime). Create buy intents via createBonusTargetEscrowWithTarget(...). 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:

  • VeClaimNFT only allows transfers from user → Furnace custody when msg.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, and royalties must be live contracts, ve.claimToken() must equal claim, and royalties.ve() must equal ve.
  • 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/market and /furnace/offers redirect 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 after EMERGENCY_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 all fundsRemaining)

Settlement keeper grace period

Both listing settlement and buy-intent execution use a keeper-priority window:

  • During the first SETTLEMENT_KEEPER_GRACE_SECONDS after creation (default 1800s / 30m), only:
    • allowlisted settlement keepers (setSettlementKeeper(address,bool)), or
    • the contract owner can call:
    • sellListedLockToFurnace(tokenId, deadline)
    • executeAutoFurnace(offerId, deadline)

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)
  • active flag

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
  • expiresAtTime must be:
    • in the future, and
    • <= the lock’s effective end returned by VeClaimNFT.getLockInfo(tokenId)
      • if AutoMax is ON, the effective end is rolling (block.timestamp + MAX_LOCK_DURATION)
  • 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:

  1. Enforce deadline: reverts with DeadlineExpired if block.timestamp > deadline.
  2. Enforce listing is active and not expired.
  3. Enforce keeper grace period (see above).
  4. Load live lock info:
    • ve.getLockInfo(tokenId)(lockAmount, lockEnd, autoMax, listed)
  5. 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.
  6. Require quoteClaimOut >= listing.minClaimOut (where quoteClaimOut is the first return value from the quote call).
  7. Clear the listing, clear the ve listed flag, and emit LockDelisted(tokenId, seller, SOLD_TO_FURNACE).
  8. Checkpoint royalties before transfer.
  9. Transfer the veNFT to Furnace custody (strict transfer rule).
  10. Call furnace.sellLockToFurnaceFromMarket(seller, tokenId, quoteClaimOut).
  11. 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 reserveAdd and partly into lpReward funding 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 ve listed flag 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 can emergencyDelist(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 budgetClaim from buyer into MarketRouter escrow
  • Computes and stores discountBps from targetBonusBps
    • discountBps = floor(targetBonusBps * 10_000 / (10_000 + targetBonusBps))
  • Validates TTL (escrowTtlSeconds) and stores expiresAt = now + ttl
    • if escrowTtlSeconds == 0, uses DEFAULT_BONUS_TARGET_ESCROW_TTL_SECONDS
    • onchain constraint is ttl > 0 && ttl <= MAX_BONUS_TARGET_ESCROW_TTL_SECONDS
  • Validates slippageBps
    • onchain constraint is slippageBps < 10_000; slippageBps >= 10_000 reverts SlippageTooHigh
  • 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 = 0 if the destination has become ineligible since creation
  • Stores an offer with:
    • buyer
    • fundsRemaining (initially budgetClaim)
    • durationSeconds (effective duration)
      • if createAutoMax == true, effective duration is forced to MAX_LOCK_DURATION
    • 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, and configured = true
  • Creation-time constraints that matter in practice:
    • slippageBps must be < 10_000 (BPS_DENOM) or creation reverts SlippageTooHigh
    • a saved nonzero destinationLockId is still re-checked at execution time; if that lock later becomes ineligible, execution falls back to resolvedLockId = 0 and 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): minimum budgetClaim required to create an offer.
    • Default: DEFAULT_MIN_BONUS_TARGET_ESCROW_BUDGET (v1.0.0: 10,000 CLAIM).
    • Increase-only (cannot be decreased).
  • maxBonusTargetEscrowDiscountBps (BPS): extra cap on the offer’s implied discount (discountBps, derived from targetBonusBps).
    • Default: DEFAULT_MAX_BONUS_TARGET_ESCROW_DISCOUNT_BPS (v1.0.0: 8,000).
    • Can be set to 10,000 to effectively disable the extra cap.

Admin surface (owner):

  • setBonusTargetEscrowParams(minBudgetClaim, maxDiscountBps)
  • emits BonusTargetEscrowParamsChanged(oldMinBudget, newMinBudget, oldMaxDiscountBps, newMaxDiscountBps)

Execution trigger

executeAutoFurnace(offerId, deadline):

  1. Enforce deadline: reverts with DeadlineExpired if block.timestamp > deadline.
  2. Enforce offer is active and not expired.
  3. Enforce keeper grace period (see above).
  4. Snapshot the escrow/config and close the offer immediately (CEI one-shot). Any later revert restores the escrow because the whole transaction reverts.
  5. Resolve the destination lock id (use destinationLockId if it is still eligible; otherwise set resolvedLockId = 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.
  6. Resolve the live Furnace from ve.furnace() and fail closed on bundle drift (see Wiring safety model).
  7. Derive executionDurationSeconds from 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
  8. Quote the live entry (via FurnaceQuoter, resolved from furnace.furnaceQuoter()):
    • furnaceQuoter.quoteEnterWithClaim(buyer, claimIn, resolvedLockId, executionDurationSeconds, createAutoMax)
  9. Compute effectiveBonusBps = floor(bonusClaim * 10_000 / principalClaim) from the quote.
  10. Require effectiveBonusBps >= targetBonusBps.
  11. Compute minVeOut = floor(veOut * (10_000 - slippageBps) / 10_000) from the live quote.
    • veOut here covers only the newly locked amount at the lock’s remaining duration; entry into an existing lock does not change its duration.
    • If veOut > 0 but floor-rounding would produce minVeOut == 0, executeAutoFurnace clamps it to 1 before calling Furnace.
  12. Call furnace.enterWithClaimFor(buyer, claimIn, resolvedLockId, executionDurationSeconds, createAutoMax, minVeOut).

Events:

  • BonusTargetEscrowCreated
  • BonusTargetEscrowConfigured
  • BonusTargetEscrowExecuted (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.
  • newExpiresAt is an absolute expiry timestamp (unix seconds).
  • Must be:
    • greater than the current expiresAt, and
    • <= offer.createdAt + MAX_BONUS_TARGET_ESCROW_TTL_SECONDS

UI helper:

  • getBonusTargetEscrowExpiryBounds(offerId) returns maxExpiresAt (computed from the offer’s creation timestamp).

Trading pause behavior

When tradingPaused == true:

Disabled (reverts TradingPaused):

  • listLock
  • sellListedLockToFurnace
  • sellLockToFurnace
  • createBonusTargetEscrowWithTarget
  • executeAutoFurnace
  • extendBonusTargetEscrowExpiry

Allowed (exits and housekeeping):

  • delistLock
  • cancelExpiredListing
  • emergencyDelist
  • cancelBonusTargetEscrow
  • cancelExpiredBonusTargetEscrow

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). Emits LockDelisted.

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 of totalEscrowedClaim for on-chain safety. startId is inclusive (min 1), endId is exclusive (capped at nextOfferId).

The non-paginated getUserListings, getUserBonusTargetEscrows, and totalEscrowedClaim are retained for off-chain convenience but are gas-unbounded.

See also