Documentation

How Halve works, from the contracts up.

Overview

Halve splits a tokenized stock or ETF on Robinhood Chain (chain id 4663) into two tokens with a fixed maturity date:

  • PT, the principal token (pTICKER): the share with its dividends removed. At maturity one PT redeems for one share of the stock token. Before maturity it trades at a discount, which is the fixed yield.
  • YT, the yield token (yTICKER): every dividend the share pays between now and maturity, and nothing else. At maturity it redeems for the accrued dividends, less the yield fee.

One share in gives one PT and one YT out. Holding both at any time and merging them gives the share back, for free, in every state of the series. Nothing is claimed along the way: dividends compound inside the stock token itself and are paid once, at maturity.

Lifecycle of a series

StateSplitMergeSettleRedeem
Active (before maturity)open, unless the accountant is holding a changeopen
Matured (after maturity, before settle)closedopenanyone, once the accountant is synced
Settledclosedopen, at the settled factordonePT → share, YT → dividends − 5%

Merge is never gated: not by pool liquidity, not by a held corporate action, not by maturity. That is the guarantee that makes PT + YT always worth at least one share.

Maths

The stock tokens follow ERC-8056: a balance is shares × uiMultiplier, and the issuer moves the multiplier for dividends (small growth) and splits (integer ratios). The accountant keeps two indices out of those moves: dividendIndex (D) and splitFactor (S), both 1e18 = 1.0.

factor()      = (D / d0) × (S / s0)            stock per base unit right now
split(amount) : base = (amount − 0.10 %) / factor()   →  base PT + base YT
merge(base)   : stock = base × factor()               free, any state
settle()      : dm = D, sm = S                        after maturity, once isSynced()
redeemPT(base): stock = base × (sm / s0)              the share, dividends removed
redeemYT(base): stock = base × (sm / s0) × (dm / d0 − 1) − 5 %   the dividends

The interface derives its headline numbers from on-chain reads only:

ptPrice   = PT/stock pool price (Uniswap v3 slot0, orientation by token0)
fixedApy  = (1 / ptPrice) ^ (1 / yearsToMaturity) − 1
leverage  = 1 / ytPrice
accrued   = dividendIndex / d0 − 1
usd       = Chainlink stock/USD feed (already includes the multiplier — never multiplied again)

Fees

ActionFeeTaken in
Split0.10 %the stock token, once, on the way in
Merge0
Redeem PT0
Redeem YT5 % of the dividendsthe stock token, at redemption

Fees go to the treasury address of each vault. The vault owner can change the cap and the treasury address and nothing else.

Corporate actions: the accountant

One MultiplierAccountant per stock token watches the multiplier and classifies every change by rule. sync() is permissionless and idempotent, so a keeper can be late but never wrong.

  • Dividend: growth of 0 < r ≤ 3 %. Applied immediately: D ×= r.
  • Split: a clean small-integer ratio (p/q with q ≤ 20, p ≤ 100) at least 20 % away from 1. Applied immediately: S ×= r.
  • Everything else (special dividends, odd ratios, stacked actions): held. isSynced() turns false, splitting and settlement pause, merging continues at the stale factor. After a public two-day timelock the guardian resolves it as a split or a special dividend; a plain dividend tag is rejected because the band would have caught it.

Every applied change is a checkpoint (ts, kind, ratio, indexAfter); dividendIndexAt(ts) binary-searches them, so any contract can settle against the index at a past timestamp.

Contracts

Source: contracts/ in the repository (Foundry, MIT). Deployed addresses come straight from src/contracts/series.json, the same file the app reads; a series is live only when every address is filled in.

SeriesMaturityStockVaultPTYTAccountantPools PT / YTFeed
SGOVMar 2027not deployednot deployednot deployednot deployednot deployednot deployed / not deployednot deployed
JEPIMar 2027not deployednot deployednot deployednot deployednot deployednot deployed / not deployednot deployed
OMar 2027not deployednot deployednot deployednot deployednot deployednot deployed / not deployednot deployed
SCHDMar 2027not deployednot deployednot deployednot deployednot deployednot deployed / not deployednot deployed
SPYMar 2027not deployednot deployednot deployednot deployednot deployednot deployed / not deployednot deployed
AAPLMar 2027not deployednot deployednot deployednot deployednot deployednot deployed / not deployednot deployed
NVDAMar 2027not deployednot deployednot deployednot deployednot deployednot deployed / not deployednot deployed
MSFTMar 2027not deployednot deployednot deployednot deployednot deployednot deployed / not deployednot deployed
AMZNMar 2027not deployednot deployednot deployednot deployednot deployednot deployed / not deployednot deployed
GOOGLMar 2027not deployednot deployednot deployednot deployednot deployednot deployed / not deployednot deployed
METAMar 2027not deployednot deployednot deployednot deployednot deployednot deployed / not deployednot deployed
TSLAMar 2027not deployednot deployednot deployednot deployednot deployednot deployed / not deployednot deployed

No series is deployed to mainnet yet; the interface runs on mock data until the addresses above are filled in. Contracts are announced on X first. Shared infrastructure on chain 4663: Multicall3 0xcA11…CA11, block explorer robinhoodchain.blockscout.com.

Integrate

The accountant is the free part. Wire it into any contract that holds a stock token:

interface IMultiplierAccountant {
    function isSynced() external view returns (bool);          // pause on false
    function dividendIndex() external view returns (uint256);  // 1e18 = 1.0
    function splitFactor() external view returns (uint256);    // 1e18 = 1.0
    function dividendIndexAt(uint256 ts) external view returns (uint256);
    function pending() external view returns (bool exists, uint64 ts, uint256 oldMultiplier, uint256 newMultiplier);
    function checkpointCount() external view returns (uint256);
    function checkpointAt(uint256 i) external view returns (uint64 ts, uint8 kind, uint256 ratio, uint256 indexAfter);
}

interface IStripVault {
    function split(uint256 amount) external returns (uint256 base);   // approve the stock token first
    function merge(uint256 base) external returns (uint256 amount);
    function settle() external;
    function redeemPT(uint256 base) external returns (uint256 amount);
    function redeemYT(uint256 base) external returns (uint256 amount);
    function factor() external view returns (uint256);
    function state() external view returns (uint8);                   // 0 active · 1 matured · 2 settled
}

Full ABIs are generated into src/contracts/abis/ from the compiled artifacts (pnpm abis).

HTTP API

EndpointReturns
GET /api/yt-history/:seriesId{ samples: [{ t, yt, tvl }] } — YT price in stock and TVL in USD, sampled every 10 minutes over the last 30 days. Empty for series that are not deployed.
GET /api/healthLiveness for monitors: mock flag, RPC reachability and latency, latest block, history backend, series counts. 503 when the RPC is unreachable in live mode.
GET /api/cron/sampleTakes one sample per live series (Bearer CRON_SECRET).

Everything else is read directly from chain by the browser, through the RPC in NEXT_PUBLIC_RPC_URL, polled every 12 seconds.

Security

  • Audit: none yet. The contracts are covered by unit and fuzz tests (forge test) and an end-to-end run against a local chain, which is not the same thing. Treat every series as unaudited until an audit report is linked here.
  • Privileges: the vault owner can set the cap and the treasury. The accountant guardian can resolve a held change after the two-day timelock, into a split or a special dividend only. No pause, no upgrade, no fund movement, no admin mint.
  • Disclosure: report vulnerabilities privately via DM on X before publishing. Please do not exploit on mainnet.

Run it yourself

git clone <repo> && cd halve && pnpm install
pnpm dev                   # mock mode, http://localhost:3000
cd contracts && forge test # 26 contract tests
pnpm test:e2e:live         # anvil (chain 4663) → deploy → MOCK=false build → Playwright split/merge/dividend

The interface never needs this site to exist: with the addresses above and any wallet you can call merge or redeemPT on the explorer directly. See the Risk Disclosure and Terms of Use.