Technical documentation · v1

Split

One token. Two convictions. A pair token is a single ERC-20 that is long one Robinhood Stock Token and short another, backed one-for-one by USDG in a two-sided pool and marked continuously to Chainlink total-return feeds.

Overview

A pair is defined by two Chainlink feeds, feedA (numerator) and feedB (denominator). The pool tracks the ratio R = P_A / P_B. It issues two side tokens:

SideExample symbolPayoff
LONGNVDA/AMDgains when R rises: NVIDIA outperforms AMD
SHORTAMD/NVDAgains when R falls: AMD outperforms NVIDIA

Both sides deposit the same collateral, USDG. There is no borrowing, no external counterparty, no liquidation and no expiry. The pool is a closed system: gains on one side are exactly the losses on the other, minus nothing. Fees are taken only at the door (mint) and at the exit (redeem).

The two feeds are total-return feeds. Chainlink's Robinhood feeds multiply the underlying share price by the token's ERC-8056 uiMultiplier(), which absorbs dividends and splits. A dividend on AMD therefore shows up as a small decline in NVDA/AMD with no extra handling.

Lifecycle

End to end, in the order things happen.

Holder

  1. Connect. Any EIP-1193 wallet or WalletConnect, on chain 4663. The dashboard adds the network to the wallet if missing.
  2. Quote. Read navPerShare(side) and effectiveLeverage(). Both are preview-marked, i.e. they already include the pending move since the last mark.
  3. Mint. USDG.approve(pool, amount), then pool.mint(long, amount, minShares, to). The pool marks, takes 20 bps to the multisig, and issues shares at the post-mark NAV.
  4. Hold / transfer / trade. Side tokens are ERC-20 + permit. NAV moves only when the pool marks; the token itself never rebases.
  5. Redeem. pool.redeem(long, shares, minOut, to). Marks, burns shares, pays USDG minus 20 bps. Available at all times except while paused; available forever after settlement.

Protocol

  1. Feeds. Chainlink updates each stock feed on deviation/heartbeat during the 24/5 session. Off hours the feed holds; updatedAt stops advancing; the 4-day staleness window covers a weekend.
  2. Keeper. Every 60 s: read both pools, treasury and events; write data/state.json for the site; if |R_now / R_last − 1| ≥ 5 bps and at least 10 minutes since the last mark, send poke(). Off hours it only fixes drift ≥ 50 bps. Poking an empty pool is skipped.
  3. Mark. Value moves loser → winner per the transfer rule; the skew fee (if enabled) moves heavy → light; lastRatio, lastMark update; Marked is emitted.
  4. Fees. Minted/Redeemed events carry the fee; the keeper sums them into the "fees to treasury" figure. Fees are transferred to the multisig in the same transaction.
  5. Treasury. The multisig applies the 50/50 policy by hand: mint the vault half into NVDA/AMD (the multisig's long-token balance is the vault, valued at NAV on the dashboard); buy $SPLIT with the other half and transfer it to 0x…dEaD. The keeper indexes Transfer(*, 0x…dEaD) and lists each burn.
  6. Listing / admin. New pairs and parameter changes are Safe transactions to the factory or pool; every pool reads governance from factory.owner().
Nothing in the holder path depends on the keeper. If it stops, mint and redeem still mark themselves; only the website's cached figures and the between-trade pokes pause.

Architecture

Three contracts, no proxies, no upgradeability.

ContractRole
PairFactoryOwned by the multisig. Lists pairs via createPair, stores default parameters. Every pool reads its governance from factory.owner(), so one ownership transfer moves every pool.
PairPoolOne per pair. Holds USDG, owns the two side tokens, implements marking, mint, redeem, the oracle guards and the admin switches.
SideTokenStandard ERC-20 + ERC-2612 permit, 18 decimals. mint/burn callable only by its pool. Freely transferable and AMM-listable.
PairFactory.createPair(NewPair{feedA, feedB, stockA, stockB, leverage, names…})
   └─ new PairPool(InitParams)          // reads factory defaults: collateral, fees, staleness, feeSink
        ├─ new SideToken(longName, longSymbol, pool)
        ├─ new SideToken(shortName, shortSymbol, pool)
        └─ lastRatio = _readRatio()     // reverts if the oracle is unusable at listing time

Pricing model

State

VariableMeaningUnits
longCollateralUSDG attributed to the long sidecollateral units (1e6)
shortCollateralUSDG attributed to the short sidecollateral units
lastRatioR at the last markWAD (1e18)
leverageL, immutable per pool; 1e18 = 1xWAD
lastMarktimestamp of the last markseconds

The invariant longCollateral + shortCollateral == USDG.balanceOf(pool) holds at all times (fees are transferred out in the same transaction they are charged). It is fuzz-tested.

Ratio

R = P_A · 1e18 / P_B     (both prices normalised to 18 decimals from the feed's decimals())

Feed answers are read through latestRoundData() and normalised, so pairs can mix 8-decimal and 18-decimal feeds.

Value transfer per mark

Let r = R_now / R_last − 1. On every mark the pool moves collateral between the sides:

r > 0 :  c = min(r · L, 1)    t = shortCollateral · c    short −= t, long += t
r < 0 :  c = min(|r| · L, 1)   t = longCollateral · c     long −= t, short += t

The losing side pays a fraction c of its own balance. Consequences:

Worked example

Book:    long 1,000 USDG   short 1,000 USDG      R_last = 0.4820  (NVDA 230.24 / AMD 477.70)
NVDA prints 241.75 (+5%), AMD unchanged  →  R_now = 0.5061,  r = +5.0%
t = 1,000 · 0.05 = 50 USDG   from short to long
Book:    long 1,050          short 950
NAV_long  = 1.050 (+5%)      NAV_short = 0.950 (−5%)

Path dependence

Like every product that rebalances to a constant exposure, the pool is path dependent. Up 10% then down 9.09% (R back to start) leaves the long side above and the short side below their starting NAV, because the second step is applied to a larger long balance. Over many marks this is the usual volatility drag of leveraged/inverse products; at L = 1 it is small, but it is not zero.

Marking

A mark is a pure function of the two feed answers and the elapsed time. It runs at the start of every mint, redeem and poke:

function _mark() internal {
    if (settled) return;
    uint256 ratio = _readRatio();                         // reverts on any oracle problem
    (lc, sc, toLong) = _applyMove(longCollateral, shortCollateral, ratio);
    (lc, sc)         = _applySkewFee(lc, sc);
    longCollateral = lc; shortCollateral = sc;
    lastRatio = ratio;  lastMark = block.timestamp;
    emit Marked(ratio, lc, sc, toLong);
}

Why there is no daily rebalance

A scheduled rebalance publishes the pending transfer hours in advance: anyone can mint into the winning side just before it and redeem just after, extracting from existing holders. Marking on every interaction makes the mint price equal to the post-move NAV, so there is nothing to front-run. The test test_mintAfterMoveGetsFairPrice asserts this.

Robinhood's stock feeds update 24/5 during market sessions and hold their last value over weekends and holidays, so in practice NAV moves during trading hours and is frozen otherwise. poke() is permissionless; a keeper calls it every few minutes during market hours so the displayed NAV is fresh even when nobody is trading.

All views (navPerShare, effectiveLeverage) run a preview mark, so they show post-mark values without a transaction.

Skew & effective leverage

Because the winning side is paid out of the losing side's balance, the effective exposure each side gets on a favourable move depends on the book:

effLev_long  = shortCollateral · L / longCollateral
effLev_short = longCollateral · L / shortCollateral

With 10,000 USDG long and 1,000 short, a +10% move in R gives longs +1% and costs shorts −10%. The dashboard displays both numbers before you mint. The lighter side of a skewed book is the better trade, which is the natural incentive that pulls books back toward balance.

Skew fee

Optionally, the heavier side pays the lighter side a continuous fee, applied at each mark:

imbalance = |heavy − light| / (heavy + light)
fee = heavy · skewFeeRatePerDay · (Δt / 1 day) · imbalance

skewFeeRatePerDay is a WAD fraction (0.01e18 = 1% per day at 100% imbalance). It is 0 on the launch pools; the multisig can switch it on if books stay lopsided. The fee stays inside the pool: it is a transfer between sides, not protocol revenue.

Mint & redeem

Mint

function mint(bool long, uint256 amountIn, uint256 minShares, address to) returns (uint256 shares)
  1. Requires not paused, not settled, amountIn > 0. Marks.
  2. Pulls amountIn USDG. fee = amountIn · mintFeeBps / 10000 is sent to feeSink; net = amountIn − fee.
  3. Shares: supply == 0 ? net · 1e18 / 1e6 : net · supply / sideCollateral. Genesis price is exactly 1 USDG per share.
  4. If supply > 0 and sideCollateral == 0 the side has been wiped; mint reverts with SideWiped (the multisig settles and relists).
  5. Reverts with Slippage if shares < minShares, with CapExceeded if maxCollateralPerSide is set and exceeded.

Redeem

function redeem(bool long, uint256 shares, uint256 minOut, address to) returns (uint256 amountOut)
  1. Requires not paused. Works when settled. Marks (no-op when settled).
  2. gross = shares · sideCollateral / supply; fee = gross · redeemFeeBps / 10000 to feeSink; amountOut = gross − fee to to.
  3. Burns shares from msg.sender. Reverts with Slippage if amountOut < minOut.

Round trip with no price move: 1,000 in → 998 net → 996.004 out. Rounding is always in the pool's favour by at most 1 wei of collateral.

Oracle safety

_readRatio() refuses to produce a number unless every check passes. A failing check reverts the whole transaction, so no mint or redeem can settle at a bad price.

CheckErrorDetail
Sequencer upSequencerDown / SequencerGraceIf a Chainlink L2 sequencer uptime feed is configured, status must be 0 and up for longer than sequencerGracePeriod (1 h). None is published for Robinhood Chain yet; the multisig can add one with setSequencerFeed.
Corporate actionOraclePausedForCorporateActionReads the advisory oraclePaused() flag on each stock token (via staticcall; a missing function is treated as not paused). Robinhood pauses feeds during splits and large multiplier updates.
FreshOracleStaleblock.timestamp − updatedAt ≤ maxStaleness per feed. Set to 4 days so a weekend does not lock the pool. Chainlink's stock feeds have no heartbeat outside sessions.
SaneBadPriceanswer > 0 and updatedAt > 0.

Settlement

If an oracle dies for good, or a pair is delisted, the multisig calls settle(). It tries one final mark (ignoring failure), then freezes: lastRatio never changes again, mint reverts with IsSettled, and redeem keeps working forever at the frozen NAV with no oracle dependency. Settlement also clears any pause, so it can never be used to trap funds.

Admin surface

Governance is factory.owner(): the Safe at the address below, single owner, threshold 1 at launch. It can:

FunctionEffectBound
factory.createPairlist a new pairone pool per (feedA, feedB, leverage)
factory.setDefaultsdefaults for future pools
pool.setParamsmint fee, redeem fee, skew fee, stalenessfees ≤ 200 bps each (hard-coded)
pool.setFeeSinkwhere fees go
pool.setCapmax collateral per side (launch guard rail)0 = no cap
pool.setSequencerFeedadd / change the uptime feed
pool.setPausedblock mint and redeemcannot survive settle()
pool.settlefreeze NAV, open redemptions foreverirreversible
There is no function that moves user collateral. No withdraw, no sweep, no rescue, no upgrade. The only USDG that ever leaves a pool is a redemption to the redeemer or a fee to feeSink at the moment it is charged.

Risks

Fees & treasury

FeeRatePaid inGoes to
Mint20 bpsUSDGmultisig
Redeem20 bpsUSDGmultisig
Skew0 (configurable)USDGthe lighter side of the same pool

The multisig applies a fixed policy to everything it receives, executed manually and published on the dashboard:

50% · Conviction Vault

Minted into NVDA/AMD: the protocol's treasury is long NVIDIA and short AMD in its own product. The position is the multisig's balance of the NVDA/AMD side token, visible on chain and valued at NAV on the dashboard.

50% · $SPLIT buyback & burn

USDG is used to market-buy $SPLIT which is then transferred to 0x…dEaD. Each burn is a normal ERC-20 transfer, so the dashboard lists every one by transaction hash and tracks cumulative supply removed.

$SPLIT

$SPLIT is launched on a launchpad and is not part of the protocol contracts. It carries no admin rights and no claim on pool collateral. Its only mechanical link to the protocol is the buyback: half of every fee the pools generate is spent buying it and removing it from circulation. Supply only goes down.

Contract address (Robinhood Chain):

Once the token is live, the dashboard reads burns directly from Transfer(*, 0x…dEaD) events.

Integration

Read NAV and effective leverage

const pool = new ethers.Contract(POOL, ABI.pool, provider);
const [navLong, navShort] = await Promise.all([pool.navPerShare(true), pool.navPerShare(false)]); // USDG (1e6) per 1e18 shares
const [levLong, levShort] = await pool.effectiveLeverage();                                        // WAD
const ok = await pool.oracleOk();                                                                    // false → mint/redeem would revert

Mint long with slippage protection

const amountIn = ethers.parseUnits("1000", 6);
await usdg.approve(POOL, amountIn);
const fee   = amountIn * await pool.mintFeeBps() / 10_000n;
const nav   = await pool.navPerShare(true);
const quote = (amountIn - fee) * 10n**18n / nav;
await pool.mint(true, amountIn, quote * 995n / 1000n, me);   // accept up to 0.5% worse

Redeem short

const shares = await shortToken.balanceOf(me);
const gross  = shares * await pool.navPerShare(false) / 10n**18n;
const minOut = (gross - gross * await pool.redeemFeeBps() / 10_000n) * 995n / 1000n;
await pool.redeem(false, shares, minOut, me);

Events

event Marked(uint256 ratio, uint256 longCollateral, uint256 shortCollateral, int256 transferToLong)
event Minted(address indexed to, bool indexed long, uint256 amountIn, uint256 fee, uint256 shares)
event Redeemed(address indexed to, bool indexed long, uint256 shares, uint256 fee, uint256 amountOut)
event Settled(uint256 ratio)
event PairCreated(address indexed pool, address indexed feedA, address indexed feedB, uint256 leverage, address longToken, address shortToken, string longSymbol, string shortSymbol)   // on the factory

Composing

Side tokens are ordinary 18-decimal ERC-20s with permit. They can be pooled on Uniswap against USDG, used as collateral on lending markets that accept custom oracles (price = navPerShare), or wrapped into indices. A lending market should treat oracleOk() == false as "price unavailable", the same way it would treat a paused Chainlink feed.

ABI reference (PairPool)

FunctionReturnsNotes
mint(bool long, uint256 amountIn, uint256 minShares, address to)sharesmarks first; pulls USDG
redeem(bool long, uint256 shares, uint256 minOut, address to)amountOutmarks first; burns from caller
poke()permissionless mark
navPerShare(bool long) viewuint256collateral units per 1e18 shares, after preview mark
effectiveLeverage() view(uint256 longLev, uint256 shortLev)WAD
currentRatio() viewuint256WAD; reverts if oracle unusable
oracleOk() viewboolnon-reverting wrapper
longCollateral() / shortCollateral() viewuint256stored (pre-mark) values
lastRatio() / lastMark() viewuint256
leverage() / mintFeeBps() / redeemFeeBps() / skewFeeRatePerDay() / maxCollateralPerSide()uint256
longToken() / shortToken() / collateral() / feedA() / feedB() / stockA() / stockB() / factory()address
paused() / settled() viewbool
governance() viewaddress= factory.owner()

Addresses · Robinhood Chain mainnet (4663)

ContractAddress
PairFactory0x08bde683876eb8837e0580b708bce38d3fd0f6fa
Multisig (owner, fee recipient)0xc0e0B610746201D6a9f6A689CeBF056246D110e0
NVDA/AMD pool0x856C233D4711369eea29a97D325a8b8b8b7AC88a
  NVDA/AMD (long)0xb76Db138874362A2B4E2b371b120C61b145b838c
  AMD/NVDA (short)0x7cE1a53F4A73bbFBc6ea4b512Cf6Aebc136286a7
NVDA/SPY pool0x4B642f9dc59a848c7D20b261877491D1Ec7Ac699
  NVDA/SPY (long)0x3D254F4531127e8715Fc26CA395760497b5cF99a
  SPY/NVDA (short)0xc707bfA6e92CcF9F345D988f5DF7CE4F8555Cc4A
USDG0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168
Chainlink RHNVDA / USD0x379EC4f7C378F34a1B47E4F3cbeBCbAC3E8E9F15
Chainlink RHAMD / USD0x943A29E7ae51A4798823ca9eEd2ed533B2A22C72
Chainlink RHSPY / USD0x319724394D3A0e3669269846abE664Cd621f9f6A
$SPLIT
X
Powered byChainlinkRobinhood ChainUSDG

Split composes over Robinhood Stock Tokens issued by Robinhood Assets (Jersey) Ltd. Side tokens are synthetic claims on a USDG pool and confer no rights against any issuer. Not available to US persons; restricted in the UK, Canada and Switzerland. Not investment advice.