Skip to content

Bell in--:--:--

Architecture and contracts

Build · 13 / 19Preview · not deployed

Architecture and contracts

The seven planned contracts with draft interfaces. Nothing is deployed.

Regent is planned as seven contracts around one Uniswap v4 hook. The hook sits in the swap path and stays small. Everything that can live outside the swap path does: the auction, the escrow, the bond, the duty checks and the staking vault. The calendar is its own contract because every other contract needs to agree on what session it is.

Uniswap v4 PoolManagerdynamic-fee poolSeatHookbeforeSwap: fee + bell window / afterRemoveLiquidity: anti-JITSessionClockET, DST, holidaysChainlinkequity feed + market statusSeatAuctionEnglish auction, USDGRentStreamescrow + donate()DutyMonitorband, poke(), slashBondRegistry$RGNT bonds by tierStakingVaultstakers' USDG + governanceprotocol cutgoverns tierssessions
Planned architecture. Nothing in this diagram is deployed.
ContractOne-line responsibility
SeatHookApplies the seat rights inside the pool: fee override, bell window, anti-JIT
SessionClockSays which session it is, from the NYSE calendar
SeatAuctionSells the seat for each pool and session
RentStreamHolds the winning bid and streams it to in-range LPs
BondRegistryHolds the $RGNT bonds that make a bidder eligible
DutyMonitorChecks the oracle band during DAY and slashes breaches
StakingVaultPays stakers their share of the protocol cut and hosts governance

The mechanism itself is described in How Regent works. Every number is in Parameters. The code blocks below use named constants with no values on purpose.

SeatHook#

Responsibility. SeatHook is the only contract the Uniswap v4 PoolManager calls. In beforeSwap it does two things: it enforces the bell window, and it overrides the swap fee. The regent's registered executor gets the 0% LP fee. Everyone else pays the fee the regent has set, inside the capped bounds for the session. If the seat is unsold, the hook returns the default fee, LPs receive it as in any v4 pool, and there is no bell window. In afterRemoveLiquidity the hook uses a return delta to apply the anti-JIT rule: liquidity removed less than 30 min after being added forfeits its accrued rent back to the pool.

The pool must be a dynamic-fee pool, otherwise a hook cannot override the fee. Hooks are fixed at pool creation in Uniswap v4. A Regent pool is therefore always a new pool. See Edge cases.

Key state. The current session id per pool, the regent and executor for that session, the fee the regent has set, the end of the current bell window, and the time each position last added liquidity. In the reference logic, topping up a position restarts its anti-JIT clock.

// DRAFT, NOT DEPLOYED. Interface sketch, subject to change.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {IHooks} from "v4-core/src/interfaces/IHooks.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId} from "v4-core/src/types/PoolId.sol";

/// Hook permissions used: beforeInitialize, afterAddLiquidity, afterRemoveLiquidity,
/// afterRemoveLiquidityReturnDelta, beforeSwap.
///
/// IHooks already declares the callbacks this design relies on:
///   beforeSwap(address sender, PoolKey key, SwapParams params, bytes hookData)
///       returns (bytes4 selector, BeforeSwapDelta delta, uint24 lpFeeOverride)
///   afterRemoveLiquidity(address sender, PoolKey key, ModifyLiquidityParams params,
///       BalanceDelta delta, BalanceDelta feesAccrued, bytes hookData)
///       returns (bytes4 selector, BalanceDelta hookDelta)
interface ISeatHook is IHooks {
    error NotDynamicFeePool();
    error BellWindowRegentOnly(PoolId poolId, uint64 endsAt);
    error NotRegent(address caller);
    error FeeOutOfBounds(uint24 fee, uint24 minFee, uint24 maxFee);

    event SessionRolled(PoolId indexed poolId, bytes32 indexed sessionId, address regent, address executor);
    event SeatFeeSet(PoolId indexed poolId, bytes32 indexed sessionId, uint24 fee);
    event BellWindowOpened(PoolId indexed poolId, uint64 startsAt, uint64 endsAt);
    event RentForfeited(PoolId indexed poolId, bytes32 indexed positionKey, uint256 amount);

    /// Regent only. Reverts outside the bounds for the current session type (see Parameters).
    function setSeatFee(PoolKey calldata key, uint24 fee) external;

    /// Regent only. Swap fees paid by everyone else during the regent's session.
    function collectFees(PoolKey calldata key, bytes32 sessionId, address to)
        external
        returns (uint256 amount0, uint256 amount1);

    /// Permissionless. Opens a new BELL_WINDOW when the market status reports a halt resumption.
    function noteResumption(PoolKey calldata key) external;

    function currentFee(PoolId poolId) external view returns (uint24);
    function feeBounds(PoolId poolId) external view returns (uint24 minFee, uint24 maxFee);
    function bellWindowEndsAt(PoolId poolId) external view returns (uint64);
    function positionAddedAt(PoolId poolId, bytes32 positionKey) external view returns (uint64);
}

Talks to. SessionClock for the session, SeatAuction for the regent and executor, RentStream to bring the stream up to date before a swap or a liquidity change, and the PoolManager for everything else. How the fees owed to the regent are routed inside v4, as an LP fee override or as a hook-level fee taken through the swap delta, is an open implementation question. The sketch only exposes the claim. How a single-stock halt and its resumption are detected on-chain is also open; the draft assumes the Chainlink market status reports them.

SessionClock#

Responsibility. SessionClock maps a block timestamp to one of the five sessions. Session boundaries are wall-clock times in New York, so the contract converts ET to UTC and has to get daylight saving time right on both transition dates. It carries the holiday calendar and the 13:00 early closes. Any closure longer than one night is a WEEKEND-type session. On an early close, DAY ends at 13:00 and POST follows for its usual four hours. See Sessions and the calendar.

A calendar can be wrong. SessionClock therefore cross-checks its own answer against the Chainlink market status for the pool's feed. If the two disagree, duties do not apply.

Key state. The holiday table and the early-close table, keyed by ET day and set by governance. The daylight saving transitions. The Chainlink feed per pool.

// DRAFT, NOT DEPLOYED. Interface sketch, subject to change.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {PoolId} from "v4-core/src/types/PoolId.sol";

interface ISessionClock {
    enum SessionType { OPEN, DAY, POST, NIGHT, WEEKEND }

    struct Session {
        SessionType sessionType;
        bytes32 id;        // keccak256(ET day the session starts on, sessionType)
        uint64 start;      // unix seconds, UTC
        uint64 end;        // unix seconds, UTC
        bool earlyClose;
    }

    error CalendarNotLoaded(uint16 year);
    error NotGovernance();

    event HolidaySet(uint32 indexed etDay, bool closed);
    event EarlyCloseSet(uint32 indexed etDay, bool earlyClose);

    function currentSession() external view returns (Session memory);
    function sessionAt(uint64 timestamp) external view returns (Session memory);
    function nextSession(Session calldata session) external view returns (Session memory);

    /// Calendar view and Chainlink market status, side by side. Duties need both to say open.
    function marketOpen(PoolId poolId) external view returns (bool byCalendar, bool byFeed);

    /// Governance only (StakingVault).
    function setHoliday(uint32 etDay, bool closed) external;
    function setEarlyClose(uint32 etDay, bool earlyClose) external;
}

Talks to. Read by SeatHook, SeatAuction, RentStream and DutyMonitor. Written only by governance. The TypeScript twin in this repository is tested against DST transitions, holidays and early closes.

SeatAuction#

Responsibility. One open ascending auction in USDG per pool and per session. The opening bid must meet the reserve, which is 50% of the median of the last 10 winning bids for that session type. Each raise must beat the standing bid by at least +5%. A bid in the last 60 s pushes the close back by 60 s. Nothing extends past the hard close, 2 min before the session starts. "No bond, no bid": a bidder without a locked bond is rejected. Full rules are in The seat auction.

The draft pulls USDG when a bid is placed and refunds the outbid bidder in the same call. The winning amount is therefore already in escrow when the auction closes. There is no credit and nothing to default on. At settlement the protocol cut of 10% is split and the remaining 90% goes to RentStream.

Key state. Per auction: open time, current close, hard close, reserve, standing bid and bidder, the bidder's executor, the number of extensions, the result. Per pool and session type: the recent winning bids that feed the reserve.

// DRAFT, NOT DEPLOYED. Interface sketch, subject to change.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId} from "v4-core/src/types/PoolId.sol";

interface ISeatAuction {
    // Constants, values in Parameters:
    // MIN_RAISE_BPS, ANTI_SNIPE_WINDOW, ANTI_SNIPE_EXTENSION, HARD_CLOSE_BEFORE_SESSION,
    // RESERVE_WINDOW, RESERVE_FRACTION_BPS, PROTOCOL_CUT_BPS

    error NotOpenYet(uint64 opensAt);
    error AuctionClosed();
    error NoBond(address bidder);
    error BelowMinimum(uint256 amount, uint256 minimum);
    error AlreadyTopBidder();

    event BidPlaced(
        PoolId indexed poolId,
        bytes32 indexed sessionId,
        address indexed bidder,
        uint256 amount,
        bool extended,
        uint64 closesAt
    );
    event SeatSold(PoolId indexed poolId, bytes32 indexed sessionId, address regent, address executor, uint256 amount);
    event SeatUnsold(PoolId indexed poolId, bytes32 indexed sessionId);

    /// Pulls `amount` USDG from the caller and refunds the previous top bidder.
    function bid(PoolKey calldata key, bytes32 sessionId, uint256 amount, address executor) external;

    /// Permissionless once the close has passed. Idempotent.
    function settle(PoolKey calldata key, bytes32 sessionId) external;

    function reserve(PoolId poolId, bytes32 sessionId) external view returns (uint256);
    function minNextBid(PoolId poolId, bytes32 sessionId) external view returns (uint256);
    function closesAt(PoolId poolId, bytes32 sessionId) external view returns (uint64 current, uint64 hardClose);
    function regentOf(PoolId poolId, bytes32 sessionId) external view returns (address regent, address executor);
}

Talks to. BondRegistry to check and lock the bond. SessionClock for session start times. RentStream, which receives the LP share at settlement. StakingVault, which receives the stakers' share of the cut. The execution path for the buy and burn share is not designed yet.

RentStream#

Responsibility. RentStream holds the LP share of the winning bid and releases it linearly over the session. Accounting is per second: the amount owed to LPs at any instant is the rate multiplied by the seconds elapsed. The transfer itself happens through Uniswap v4 donate(), which pays the LPs who are in range at that moment. The stream is brought up to date whenever it is touched, by the hook before a swap or a liquidity change, or by anyone through a permissionless call. LPs know the rate before the session starts. See Rent streaming and anti-JIT.

If the sequencer is down, rent cannot stream. The unstreamed part for that period is refunded to the regent pro rata.

Key state. Per pool and session: the escrowed amount, the rate per second, the last streamed timestamp, the amount refunded. Rent forfeited under the anti-JIT rule is donated back to the pool.

// DRAFT, NOT DEPLOYED. Interface sketch, subject to change.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId} from "v4-core/src/types/PoolId.sol";

interface IRentStream {
    error OnlySeatAuction();
    error OnlySeatHook();
    error NothingToStream();

    event StreamFunded(PoolId indexed poolId, bytes32 indexed sessionId, uint256 amount, uint256 ratePerSecond);
    event RentDonated(PoolId indexed poolId, bytes32 indexed sessionId, uint256 amount, uint64 upTo);
    event DowntimeRefunded(PoolId indexed poolId, bytes32 indexed sessionId, address regent, uint256 amount);

    /// SeatAuction only, at settlement.
    function fund(PoolKey calldata key, bytes32 sessionId, uint256 amount, uint64 start, uint64 end) external;

    /// Permissionless. Donates everything accrued since the last call to in-range LPs.
    function stream(PoolKey calldata key) external returns (uint256 donated);

    /// SeatHook only. Re-donates rent forfeited by a position removed before ANTI_JIT_MIN_AGE.
    function forfeit(PoolKey calldata key, uint256 amount) external;

    /// Permissionless. Refunds rent that could not stream during sequencer downtime.
    function refundDowntime(PoolKey calldata key, bytes32 sessionId) external returns (uint256 refunded);

    function ratePerSecond(PoolId poolId, bytes32 sessionId) external view returns (uint256);
    function streamed(PoolId poolId, bytes32 sessionId) external view returns (uint256);
    function pending(PoolId poolId) external view returns (uint256);
}

Talks to. Funded by SeatAuction. Touched by SeatHook. Calls donate() on the PoolManager. How downtime is measured on-chain is open; a sequencer uptime feed is the natural candidate.

BondRegistry#

Responsibility. BondRegistry holds the $RGNT bonds. The required bond depends on the pool tier, and tiers are set by governance. A bidder locks the bond before bidding. In the draft, the bond of the winning bidder stays locked for the session and is the only thing DutyMonitor can slash, and losing bidders can unlock once they no longer hold a top bid. The token is not live and has no contract address. See The $RGNT token.

Key state. Tier per pool, required bond per tier, bond posted and bond remaining per bidder and pool, lock expiry.

// DRAFT, NOT DEPLOYED. Interface sketch, subject to change.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {PoolId} from "v4-core/src/types/PoolId.sol";

interface IBondRegistry {
    error BondLocked(uint64 until);
    error InsufficientBond(uint256 posted, uint256 required);
    error OnlyDutyMonitor();
    error NotGovernance();

    event BondLockedFor(PoolId indexed poolId, address indexed bidder, uint256 amount);
    event BondUnlocked(PoolId indexed poolId, address indexed bidder, uint256 amount);
    event BondSlashed(PoolId indexed poolId, address indexed regent, uint256 amount);
    event TierSet(uint8 indexed tier, uint256 requiredBond);

    function lock(PoolId poolId, uint256 amount) external;
    function unlock(PoolId poolId) external;

    /// DutyMonitor only. Capped by what remains of the bond. Returns the amount actually slashed.
    function slash(PoolId poolId, address regent, uint256 amount) external returns (uint256 slashed);

    function isBonded(PoolId poolId, address bidder) external view returns (bool);
    function requiredBond(PoolId poolId) external view returns (uint8 tier, uint256 amount);
    function bondOf(PoolId poolId, address bidder) external view returns (uint256 posted, uint256 remaining);

    /// Governance only (StakingVault).
    function setTier(uint8 tier, uint256 requiredBond) external;
}

Talks to. SeatAuction reads isBonded on every bid and extends the lock of the winner. DutyMonitor calls slash. Governance sets tiers.

DutyMonitor#

Responsibility. DutyMonitor enforces the seat duties. They apply during DAY only, and only when the Chainlink tokenized-equity feed is fresh, the market status is open and there is no halt. In that state the pool price must stay within a band around the oracle price. The half-width of the band is the larger of the current fee and 0.30%. More than 60 s continuously outside the band is a breach. Every second after the grace period is a breach-second, and the bond is slashed per breach-second.

Nothing runs by itself on a blockchain, so the check is a permissionless poke(). Anyone can call it. It converts pending breach-seconds into a slash and splits it: 50% to LPs, 10% to the caller, 40% burned. With no pending breach-seconds there is no slash and no reward. See Seat duties and slashing.

Key state. Per pool: the timestamp the price left the band, pending breach-seconds, total breach-seconds for the session, the last observation.

// DRAFT, NOT DEPLOYED. Interface sketch, subject to change.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId} from "v4-core/src/types/PoolId.sol";

interface IDutyMonitor {
    // Constants, values in Parameters:
    // BAND_FLOOR_BPS, GRACE_PERIOD, SLASH_TO_LPS_BPS, SLASH_TO_POKER_BPS, SLASH_BURN_BPS

    error DutiesInactive();

    event BandExited(PoolId indexed poolId, uint64 at, int256 deviationBps);
    event BandReentered(PoolId indexed poolId, uint64 at);
    event Slashed(
        PoolId indexed poolId,
        address indexed regent,
        address indexed poker,
        uint256 breachSeconds,
        uint256 toLps,
        uint256 toPoker,
        uint256 burned
    );

    /// Permissionless. Observes the pool price against the feed, accrues breach-seconds,
    /// and slashes whatever is pending.
    function poke(PoolKey calldata key) external returns (uint256 slashed);

    function dutiesActive(PoolId poolId) external view returns (bool);
    function band(PoolId poolId) external view returns (uint256 oraclePrice, uint256 lower, uint256 upper);
    function outsideSince(PoolId poolId) external view returns (uint64);
    function pendingBreachSeconds(PoolId poolId) external view returns (uint256);
}

Talks to. Reads the pool price from the PoolManager, the fee from SeatHook, the session and market status from SessionClock, and the price from the Chainlink feed. Calls slash on BondRegistry. The LP share of a slash is in $RGNT, and donate() only accepts the pool's two currencies, so how that share reaches in-range LPs is an open design question.

StakingVault#

Responsibility. The protocol takes 10% of every winning bid. Of that cut, 50% goes to buy and burn, 30% goes to stakers in USDG and 20% goes to the treasury. StakingVault receives the stakers' share and distributes it pro rata to staked $RGNT. It is also where governance lives. Stakers govern four things: the fee bounds, the bell window length, the bond tiers and the holiday calendar. What stakers receive depends on auctions and can be zero.

Key state. Staked balance per account, a reward-per-token accumulator in USDG, and the governed parameters or pointers to the contracts that hold them.

// DRAFT, NOT DEPLOYED. Interface sketch, subject to change.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

interface IStakingVault {
    error OnlySeatAuction();
    error NothingToClaim();

    event Staked(address indexed account, uint256 amount);
    event Unstaked(address indexed account, uint256 amount);
    event RewardsNotified(uint256 usdgAmount);
    event Claimed(address indexed account, uint256 usdgAmount);

    function stake(uint256 amount) external;
    function unstake(uint256 amount) external;
    function claim() external returns (uint256 usdgAmount);

    /// SeatAuction only. The stakers' share of the protocol cut, in USDG.
    function notifyRewards(uint256 usdgAmount) external;

    function earned(address account) external view returns (uint256 usdgAmount);
    function votingPower(address account) external view returns (uint256);
}

Talks to. Funded by SeatAuction at every settlement. Through governance it writes to SessionClock (calendar), BondRegistry (tiers) and SeatHook (fee bounds, bell window length). The voting process, quorum and any timelock are not designed yet.

How a swap flows through the hook#

  1. A router, or the regent's executor, calls swap on the PoolManager. The PoolManager calls beforeSwap on SeatHook with the caller as sender.
  2. The hook asks SessionClock for the current session. If the session has changed since the last swap, the hook rolls over: it reads the regent and executor for the new session from SeatAuction.
  3. The hook touches RentStream, so that rent accrued up to this second is donated to the LPs who are in range before the swap moves the price.
  4. Bell window check. If the seat is sold and the current time is inside the first 20 seconds of OPEN, or of a halt resumption, only the regent's executor may swap. Any other sender reverts.
  5. Fee selection. The regent's executor gets the 0% LP fee. Any other sender gets the fee set by the regent, which was checked against the session bounds when it was set. If the seat is unsold, the default fee applies.
  6. The hook returns the fee as an override. The PoolManager executes the swap against the pool's liquidity as usual. SeatHook never takes custody of LP liquidity.
  7. DutyMonitor is not in the swap path. It observes the resulting price the next time someone calls poke().

Liquidity changes follow a shorter path. On add, the hook records the time. On remove, afterRemoveLiquidity compares the age of the position with the anti-JIT minimum and, if it is too young, returns a delta that keeps the accrued rent in the pool.

Trust assumptions#

Chainlink feed and market status. Duties, halts and the calendar cross-check all depend on the Chainlink tokenized-equity feed and its market status. A stale feed or a closed status switches duties off; it does not slash anyone. A feed that is fresh but wrong during DAY could slash an honest regent or excuse a negligent one. Regent does not remove this dependency. It limits it to the one session where a reference price exists.

Sequencer. Robinhood Chain orders transactions first come, first served with blocks of ~100 ms. Regent assumes the sequencer includes transactions in the order it receives them and does not censor bids, swaps or poke() calls. Downtime is handled by the pro rata refund. Censorship is not handled.

Governance. $RGNT stakers can change the fee bounds, the bell window length, the bond tiers and the holiday calendar. A captured governance could set these badly. Whether contracts are upgradeable, and behind what delay, is not decided. See Risks and disclaimers.

The regent. No trust is placed in the regent. Its rights are bounded by the hook, its payment is taken upfront, and a regent who does nothing has only wasted its bid.

The web preview's data layer#

The site reads everything through one data-source interface. Today the only implementation in use is SimulatedSource: a deterministic simulator built on the TypeScript reference logic in lib/regent, which mirrors the planned contracts module by module (auction, rent, duty, session clock). Market data in the preview is simulated. No transaction is ever sent.

An OnchainSource stub implements the same interface. It switches on only when the environment variable NEXT_PUBLIC_SEAT_AUCTION_ADDRESS is set. That variable is empty, because there is no SeatAuction contract to point it at. When contracts exist on a testnet, the same screens will read them without a rewrite. The sequence is on the Roadmap.