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
| State | Split | Merge | Settle | Redeem |
|---|---|---|---|---|
| Active (before maturity) | open, unless the accountant is holding a change | open | — | — |
| Matured (after maturity, before settle) | closed | open | anyone, once the accountant is synced | — |
| Settled | closed | open, at the settled factor | done | PT → 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
| Action | Fee | Taken in |
|---|---|---|
| Split | 0.10 % | the stock token, once, on the way in |
| Merge | 0 | — |
| Redeem PT | 0 | — |
| Redeem YT | 5 % of the dividends | the 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.
| Series | Maturity | Stock | Vault | PT | YT | Accountant | Pools PT / YT | Feed |
|---|---|---|---|---|---|---|---|---|
| SGOV | Mar 2027 | not deployed | not deployed | not deployed | not deployed | not deployed | not deployed / not deployed | not deployed |
| JEPI | Mar 2027 | not deployed | not deployed | not deployed | not deployed | not deployed | not deployed / not deployed | not deployed |
| O | Mar 2027 | not deployed | not deployed | not deployed | not deployed | not deployed | not deployed / not deployed | not deployed |
| SCHD | Mar 2027 | not deployed | not deployed | not deployed | not deployed | not deployed | not deployed / not deployed | not deployed |
| SPY | Mar 2027 | not deployed | not deployed | not deployed | not deployed | not deployed | not deployed / not deployed | not deployed |
| AAPL | Mar 2027 | not deployed | not deployed | not deployed | not deployed | not deployed | not deployed / not deployed | not deployed |
| NVDA | Mar 2027 | not deployed | not deployed | not deployed | not deployed | not deployed | not deployed / not deployed | not deployed |
| MSFT | Mar 2027 | not deployed | not deployed | not deployed | not deployed | not deployed | not deployed / not deployed | not deployed |
| AMZN | Mar 2027 | not deployed | not deployed | not deployed | not deployed | not deployed | not deployed / not deployed | not deployed |
| GOOGL | Mar 2027 | not deployed | not deployed | not deployed | not deployed | not deployed | not deployed / not deployed | not deployed |
| META | Mar 2027 | not deployed | not deployed | not deployed | not deployed | not deployed | not deployed / not deployed | not deployed |
| TSLA | Mar 2027 | not deployed | not deployed | not deployed | not deployed | not deployed | not deployed / not deployed | not 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
| Endpoint | Returns |
|---|---|
| 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/health | Liveness 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/sample | Takes 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.