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:
| Side | Example symbol | Payoff |
|---|---|---|
| LONG | NVDA/AMD | gains when R rises: NVIDIA outperforms AMD |
| SHORT | AMD/NVDA | gains 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).
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
- Connect. Any EIP-1193 wallet or WalletConnect, on chain 4663. The dashboard adds the network to the wallet if missing.
- Quote. Read
navPerShare(side)andeffectiveLeverage(). Both are preview-marked, i.e. they already include the pending move since the last mark. - Mint.
USDG.approve(pool, amount), thenpool.mint(long, amount, minShares, to). The pool marks, takes 20 bps to the multisig, and issues shares at the post-mark NAV. - Hold / transfer / trade. Side tokens are ERC-20 + permit. NAV moves only when the pool marks; the token itself never rebases.
- 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
- Feeds. Chainlink updates each stock feed on deviation/heartbeat during the 24/5 session. Off hours the feed holds;
updatedAtstops advancing; the 4-day staleness window covers a weekend. - Keeper. Every 60 s: read both pools, treasury and events; write
data/state.jsonfor the site; if|R_now / R_last − 1| ≥ 5 bpsand at least 10 minutes since the last mark, sendpoke(). Off hours it only fixes drift ≥ 50 bps. Poking an empty pool is skipped. - Mark. Value moves loser → winner per the transfer rule; the skew fee (if enabled) moves heavy → light;
lastRatio,lastMarkupdate;Markedis emitted. - Fees.
Minted/Redeemedevents carry the fee; the keeper sums them into the "fees to treasury" figure. Fees are transferred to the multisig in the same transaction. - 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 to0x…dEaD. The keeper indexesTransfer(*, 0x…dEaD)and lists each burn. - Listing / admin. New pairs and parameter changes are Safe transactions to the factory or pool; every pool reads governance from
factory.owner().
Architecture
Three contracts, no proxies, no upgradeability.
| Contract | Role |
|---|---|
| PairFactory | Owned 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. |
| PairPool | One per pair. Holds USDG, owns the two side tokens, implements marking, mint, redeem, the oracle guards and the admin switches. |
| SideToken | Standard 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
| Variable | Meaning | Units |
|---|---|---|
| longCollateral | USDG attributed to the long side | collateral units (1e6) |
| shortCollateral | USDG attributed to the short side | collateral units |
| lastRatio | R at the last mark | WAD (1e18) |
| leverage | L, immutable per pool; 1e18 = 1x | WAD |
| lastMark | timestamp of the last mark | seconds |
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
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 = longCollateral · c long −= t, short += t
The losing side pays a fraction c of its own balance. Consequences:
- The losing side's return on an adverse move is exactly
−r·Lof its own NAV. - The winning side's gain is
r·Lof the other side's balance. With a balanced book that is+r·Lof its own NAV; with a skewed book it is less (see skew). - No side can go below zero, so the pool is always fully collateralised. There is nothing to liquidate.
cis capped at 1: a single mark can at most wipe the losing side. With L = 1 that needs R to double, or fall to zero, between two consecutive marks.
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_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:
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)
- Requires not paused, not settled,
amountIn > 0. Marks. - Pulls
amountInUSDG.fee = amountIn · mintFeeBps / 10000is sent tofeeSink;net = amountIn − fee. - Shares:
supply == 0 ? net · 1e18 / 1e6 : net · supply / sideCollateral. Genesis price is exactly 1 USDG per share. - If
supply > 0andsideCollateral == 0the side has been wiped; mint reverts withSideWiped(the multisig settles and relists). - Reverts with
Slippageifshares < minShares, withCapExceededifmaxCollateralPerSideis set and exceeded.
Redeem
function redeem(bool long, uint256 shares, uint256 minOut, address to) returns (uint256 amountOut)
- Requires not paused. Works when settled. Marks (no-op when settled).
gross = shares · sideCollateral / supply;fee = gross · redeemFeeBps / 10000tofeeSink;amountOut = gross − feetoto.- Burns
sharesfrommsg.sender. Reverts withSlippageifamountOut < 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.
| Check | Error | Detail |
|---|---|---|
| Sequencer up | SequencerDown / SequencerGrace | If 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 action | OraclePausedForCorporateAction | Reads 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. |
| Fresh | OracleStale | block.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. |
| Sane | BadPrice | answer > 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:
| Function | Effect | Bound |
|---|---|---|
| factory.createPair | list a new pair | one pool per (feedA, feedB, leverage) |
| factory.setDefaults | defaults for future pools | — |
| pool.setParams | mint fee, redeem fee, skew fee, staleness | fees ≤ 200 bps each (hard-coded) |
| pool.setFeeSink | where fees go | — |
| pool.setCap | max collateral per side (launch guard rail) | 0 = no cap |
| pool.setSequencerFeed | add / change the uptime feed | — |
| pool.setPaused | block mint and redeem | cannot survive settle() |
| pool.settle | freeze NAV, open redemptions forever | irreversible |
feeSink at the moment it is charged.Risks
- Oracle latency. Chainlink feeds update on deviation and heartbeat; between updates the on-chain price can lag the market. A fast actor could mint at a stale NAV and redeem after the update. The 40 bps round-trip fee is the defence and should stay above the feed's deviation threshold. If it proves insufficient, the multisig can raise fees or add a minimum hold.
- Skew. Your upside on a favourable move is capped by the other side's balance. Always read
effectiveLeverage()before minting into the heavy side. - Path dependence. Holding through a round trip in R does not return exactly to start.
- Side wipe. A move of more than 1/L between two marks wipes the losing side entirely. At 1x that is a doubling or a collapse to zero between marks, which with continuous marking during sessions is remote but not impossible (e.g. a feed resuming after a multi-day pause).
- Stale-price freeze. If both feeds stop updating for longer than
maxStaleness, mint and redeem are blocked until they resume or the multisig settles. - Feed correctness. Split trusts Chainlink and Robinhood's multiplier. A wrong print is a wrong mark.
- Admin. The multisig can pause. It cannot take funds, but it can delay your exit until it settles or unpauses.
- Unaudited. The pool contract is ~380 lines with 28 tests including a mainnet fork rehearsal, and no external audit yet. Use size you can afford to lose.
- Regulatory. Robinhood Stock Tokens are not offered to US persons and are restricted in the UK, Canada and Switzerland. Split inherits those restrictions.
Fees & treasury
| Fee | Rate | Paid in | Goes to |
|---|---|---|---|
| Mint | 20 bps | USDG | multisig |
| Redeem | 20 bps | USDG | multisig |
| Skew | 0 (configurable) | USDG | the lighter side of the same pool |
The multisig applies a fixed policy to everything it receives, executed manually and published on the dashboard:
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.
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)
| Function | Returns | Notes |
|---|---|---|
| mint(bool long, uint256 amountIn, uint256 minShares, address to) | shares | marks first; pulls USDG |
| redeem(bool long, uint256 shares, uint256 minOut, address to) | amountOut | marks first; burns from caller |
| poke() | — | permissionless mark |
| navPerShare(bool long) view | uint256 | collateral units per 1e18 shares, after preview mark |
| effectiveLeverage() view | (uint256 longLev, uint256 shortLev) | WAD |
| currentRatio() view | uint256 | WAD; reverts if oracle unusable |
| oracleOk() view | bool | non-reverting wrapper |
| longCollateral() / shortCollateral() view | uint256 | stored (pre-mark) values |
| lastRatio() / lastMark() view | uint256 | |
| leverage() / mintFeeBps() / redeemFeeBps() / skewFeeRatePerDay() / maxCollateralPerSide() | uint256 | |
| longToken() / shortToken() / collateral() / feedA() / feedB() / stockA() / stockB() / factory() | address | |
| paused() / settled() view | bool | |
| governance() view | address | = factory.owner() |
Addresses · Robinhood Chain mainnet (4663)
| Contract | Address |
|---|---|
| PairFactory | 0x08bde683876eb8837e0580b708bce38d3fd0f6fa |
| Multisig (owner, fee recipient) | 0xc0e0B610746201D6a9f6A689CeBF056246D110e0 |
| NVDA/AMD pool | 0x856C233D4711369eea29a97D325a8b8b8b7AC88a |
| NVDA/AMD (long) | 0xb76Db138874362A2B4E2b371b120C61b145b838c |
| AMD/NVDA (short) | 0x7cE1a53F4A73bbFBc6ea4b512Cf6Aebc136286a7 |
| NVDA/SPY pool | 0x4B642f9dc59a848c7D20b261877491D1Ec7Ac699 |
| NVDA/SPY (long) | 0x3D254F4531127e8715Fc26CA395760497b5cF99a |
| SPY/NVDA (short) | 0xc707bfA6e92CcF9F345D988f5DF7CE4F8555Cc4A |
| USDG | 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 |
| Chainlink RHNVDA / USD | 0x379EC4f7C378F34a1B47E4F3cbeBCbAC3E8E9F15 |
| Chainlink RHAMD / USD | 0x943A29E7ae51A4798823ca9eEd2ed533B2A22C72 |
| Chainlink RHSPY / USD | 0x319724394D3A0e3669269846abE664Cd621f9f6A |
| $SPLIT |
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.