02 · Settlement claims Live on Robinhood Chain
One collateral pool,
two settlement claims.
A DividendSeries locks a Stock Token for a declared period and mints two ERC-20 claims against it: a principal claim (PT) and a residual yield claim (YT). At maturity a threshold-signed oracle factor decides how the locked pool is divided between them. Nothing is minted out of thin air, no cash is distributed, and every step is a permissionless call on an immutable contract.
2.1 Purpose
Robinhood Stock Tokens are ERC-8056 tokens: balances are raw units that never rebase, and dividends and corporate actions are expressed through an issuer-controlled display multiplier, uiMultiplier(). A holder therefore has no onchain way to separate "the share" from "what the share earned this period".
A settlement series does that separation for one asset and one fixed window. Depositing n raw units mints n PT and n YT. Until the series is finalized the two can always be merged back into exactly n units. After finalization the locked pool is split into a principal pool and a yield pool by an oracle-attested dividend factor, and each claim redeems pro rata from its own pool.
YT is a residual claim on a factor-derived share of the locked Stock Token pool. It does not receive or stream company dividends, and if the finalized factor is exactly 1e18 it redeems for zero. PT is not a fixed-return instrument: its pool is ceil(supply · 1e18 / factor) units, which is less than one unit per PT whenever the factor is above 1e18.
2.2 Series parameters
Every parameter is an immutable set in the constructor. There is no owner, no setter, no pause and no upgrade path on DividendSeries or on either ClaimToken. The constructor also reads the oracle's settlement policy for (asset, start, maturity) and pins the returned event id, signer-set id and factor ceiling; if any of those later differ, split and finalize revert with SettlementPolicyChanged.
| asset | The Stock Token. Must have code and decimals() == 18, otherwise the constructor reverts with InvalidAssetDecimals. The series accounts in raw token units only; uiMultiplier() is read by the interface for display and is never used in series arithmetic. |
|---|---|
| start | Series open. Must satisfy start ≤ now at deployment. Before it, split and merge revert with SeriesNotStarted. |
| splitClose | Last moment new claims can be minted. Requires start < splitClose ≤ maturity. From this timestamp split() reverts with SplitClosed; merge stays open. |
| maturity | End of the dividend period and the earliest time finalize() may be called. It is also the oracle's periodEnd: the event slot is keyed on exactly (asset, start, maturity). |
| maxDividendFactor | Ceiling for the finalized factor, in WAD. Must be in [1e18, 2e18] (MAX_ALLOWED_DIVIDEND_FACTOR = 2e18) and no higher than the oracle slot's own ceiling. All three live series use 1.05e18. |
| oracle · oracleEventId | The AttestedDividendOracle for this series and the event id it resolved at construction. One oracle, one event slot and one signer set are deployed per series; nothing is shared between series. |
| oracleSignerSetId | The signer-set version the event slot was created under (1 for every live series: 3 signers, threshold 2). Later signer sets created on the oracle never apply to an existing slot. |
| eligibilityPolicy | The IEligibilityPolicy consulted on split and on every claim transfer. Live series pin an owner-managed, closed-by-default ManagedEligibilityPolicy (§2.7). |
| pt · yt | Two ClaimToken contracts created by the series constructor: "Dividend Series Principal" / PT-DIV and "Dividend Series Yield" / YT-DIV, 18 decimals. Only the series can mint or burn them. |
Live series
All three stacks carry identical constructor parameters, taken from the broadcast receipts: splitClose = start + 3 days, maturity = start + 7 days, maxDividendFactor = 1.05e18, oracle challengeDelay = 172800 s (2 days), one signer set of 3 with threshold 2. Timestamps are UTC.
| Series | Asset | Window | Contracts | App access |
|---|---|---|---|---|
| aapl-sep-2026 | AAPL0xaF3D…93f9 |
start 2026-08-29 21:25:16 splitClose 2026-09-01 21:25:16 maturity 2026-09-05 21:25:16 |
Series 0x2f9d…ADA0Oracle 0xA0Ac…3139Policy 0x3699…0263PT 0x3D6d…3366 · YT 0x41C1…16da |
status & exits only |
| aapl-2026-09-10 | AAPL0xaF3D…93f9 |
start 2026-09-03 11:28:03 splitClose 2026-09-06 11:28:03 maturity 2026-09-10 11:28:03 |
Series 0x5a46…24e1Oracle 0xa447…2F6fPolicy 0x14F9…5e07PT 0x67c3…08bc · YT 0x7f73…6e2B |
issuance & exits |
| nvda-2026-09-10 | NVDA0xd060…9EEC |
start 2026-09-03 11:28:03 splitClose 2026-09-06 11:28:03 maturity 2026-09-10 11:28:03 |
Series 0x8B6D…82DeOracle 0x1a9e…3402Policy 0xA3B5…2C1aPT 0x1650…c8e2 · YT 0x2875…a769 |
issuance & exits |
"App access" is an interface setting, not a contract state. For an issuance-and-exits series the interface prepares the Stock Token approval and split() while the onchain window is open; for a status-and-exits-only series it shows status and phase-appropriate exits and prepares no split. Eligibility is enforced by the contract regardless of the interface mode. Full addresses and event ids are listed in Deployed contracts.
2.3 Lifecycle
A series moves through four phases driven by the block timestamp and a single irreversible state bit, finalized. Phases are read from the contract (start, splitClose, maturity, finalized); no phase is stored, so there is nothing an operator can advance or roll back.
[1e18, maxDividendFactor]. Merge stays available through Matured because _requireMergeOpen checks finalized, not maturity.The timeline below plots the three live series. The grey segment after maturity is the oracle's challenge delay: an attestation can only be submitted once the period has ended, and finalize() on the oracle is blocked until challengeDelay (2 days, immutable) has elapsed after submission. Redemption therefore cannot begin earlier than maturity + 2 days, and later if the attestation lands later.
2.4 Split, merge, finalize, redeem
Four external functions change state. All are nonReentrant; every token movement is delta-measured on both sides and reverts with TransferMismatch if the Stock Token debits or credits anything other than the exact amount (fee-on-transfer or blocked-transfer behaviour is refused rather than absorbed).
split(assets, receiver)
- Requires
start ≤ now < splitClose,finalized == false, the pinned settlement policy unchanged, and the series fully collateralised (pt.totalSupply() == yt.totalSupply() == accountedAssets ≤ asset.balanceOf(series)). - Requires both
msg.senderandreceiverto be eligible under the policy; otherwiseIneligibleParticipant. - Pulls exactly
assetsraw units, incrementsaccountedAssets, mintsassetsPT andassetsYT toreceiver.
merge(amount, receiver)
- Requires
now ≥ start,finalized == false, full collateralisation. No maturity check and no eligibility check: exit is unconditional. - Burns
amountPT andamountYT from the caller, decrementsaccountedAssets, pushes exactlyamountraw units toreceiver.
finalize()
- Requires
now ≥ maturity, not already finalized, settlement policy unchanged, and full collateralisation. - Reads
oracle.getSettlement(asset, start, maturity). RevertsOracleNotFinalizedunless the oracle has finalized;InvalidDividendFactorunless1e18 ≤ factor ≤ maxDividendFactor;InvalidEvidenceif the evidence hash is zero. - Allocates:
principalPool = Math.mulDiv(pt.totalSupply(), 1e18, factor, Ceil);yieldPool = accountedAssets − principalPool. RecordsdividendFactor,evidenceHash,finalizedAssetsand setsfinalized = true. EmitsSeriesFinalized.
redeemPrincipal(amount, receiver) · redeemYield(amount, receiver)
- Requires
finalized == trueandasset.balanceOf(series) ≥ principalPool + yieldPool. No eligibility check. payout = amount == supply ? pool : Math.mulDiv(pool, amount, supply)— pro rata, rounded down; the final redeemer of a claim sweeps the pool's remainder.- Burns the claim, reduces that pool and
accountedAssets, pushes the payout. A zero payout is allowed (YT whenyieldPool == 0): the claim is burned and no transfer is made.
finalize() divides the tracked pool by the oracle factor once; each claim then redeems only from its own pool. Lime edges carry Stock Token units.Because the fully-collateralised check forces pt.totalSupply() == accountedAssets at finalization, the principal pool is simply ceil(A / f) of the tracked assets. The chart below shows that division for one unit of collateral at representative factors inside the live ceiling.
1.05e18. YT's share is 1 − 1/f: at most 4.76% of collateral for these series, and exactly zero at f = 1e18. Actual per-claim payouts additionally round down except for the final redeemer.2.5 Oracle attestation
AttestedDividendOracle holds no price logic. Its only input is a threshold-signed EIP-712 DividendAttestation; its only outputs are getSettlementPolicy() and getSettlement(), which the series reads. The contract is chain-bound: the constructor reverts unless block.chainid == 4663, and every state-changing call and both views check the deployment chain.
| Event slot | Created once by the administrator with createEventSlot(eventId, asset, periodStart, periodEnd, maxDividendFactorWad). A slot is immutable; a given (asset, periodStart, periodEnd) may map to only one event id (PeriodSlotAlreadyExists), and a slot pins latestSignerSetId at creation. The event id itself is derived by the deploy script from the full policy: domain STOCKYIELD_ROBINHOOD_EVENT_POLICY_V1, chain id, deployer, asset, policy, oracle, start, splitClose, maturity, max factor, challenge delay, signer list and threshold. |
|---|---|
| Signer set | Versioned, append-only. Version 1 is created in the constructor; createSignerSet() (administrator only) adds a new version but cannot modify or retire an existing one, and cannot change which version an existing slot uses. Live sets: 3 signers, threshold 2. Signers must be strictly ascending by address and non-zero. |
| Typed data | Domain AttestedDividendOracle / version 1 / chain 4663 / oracle address. Struct DividendAttestation(bytes32 eventId, address asset, uint64 periodStart, uint64 periodEnd, uint256 dividendFactorWad, bytes32 evidenceHash, bytes32 supersedes, uint64 validAfter, uint64 deadline, uint64 signerSetId, uint256 nonce, uint32 revision). hashAttestation() returns the digest signers must approve. |
| Signature check | Per signer, in ascending order and without duplicates: an address with no code is verified by ECDSA.tryRecoverCalldata; an address with code by SignatureChecker.isValidSignatureNowCalldata (ERC-1271). At least threshold valid signatures from the slot's signer set are required. The deploy script additionally refuses signers that have code at deployment, including EIP-7702 delegation designators. |
| Binding checks | submitAttestation rejects an attestation whose asset or period differs from the slot, whose factor is outside [1e18, slot.maxDividendFactorWad], whose evidence hash is zero, or whose signer-set id differs from the slot's. It also requires now ≥ periodEnd, validAfter ≥ periodEnd, validAfter ≤ now ≤ deadline, deadline − validAfter ≤ 30 days, and now + challengeDelay ≤ deadline. |
| Replay protection | Each digest is single-use (AttestationAlreadyUsed) and each (signerSetId, nonce) is single-use (NonceAlreadyUsed). The chain id is part of the EIP-712 domain, so a signature cannot be replayed on another chain. |
| Corrections | The first attestation must carry supersedes = 0 and revision = 1. A correction must name the current digest in supersedes and carry revision + 1; it restarts the challenge delay. Corrections are only possible before finalize(eventId). |
| Challenge delay | challengeDelay is immutable, in (0, 30 days]; every live oracle uses 172800 seconds (2 days). finalize(eventId) is permissionless, requires now ≥ finalizeAfter and now ≤ deadline, and is terminal: afterwards submitAttestation reverts with EventAlreadyFinalized. |
finalize. Nothing in this sequence is restricted to the administrator, who cannot submit a factor alone or shorten the delay.2.6 Eligibility policy
Two implementations of IEligibilityPolicy exist in the codebase. OpenEligibilityPolicy is ownerless and returns true for every address. Every live series instead pins a ManagedEligibilityPolicy, which is closed by default: an address is ineligible until the owner sets it otherwise.
| Gated | split() (caller and receiver), ClaimToken.approve() with a non-zero value (the approver), and every claim transfer (sender and receiver, via _update). Escrow contracts and routers that only hold an allowance are not gated. |
|---|---|
| Never gated | merge(), redeemPrincipal(), redeemYield(), and any burn. A holder the owner later marks ineligible keeps the unconditional right to exit to the underlying. |
| Owner powers | setEligibility(address, bool), setEligibilityBatch(address[], bool[]), and transferOwnership under Ownable2Step (the new owner must accept). renounceOwnership is overridden to revert, so the policy can never become permanently closed by accident. |
| Owner cannot | Freeze or seize claims, block a merge or redemption, change any series parameter, touch collateral, or affect the oracle. |
| Live owner | The deploying account for each stack. Read owner() on each policy address in §2.2; pendingOwner() is zero unless a transfer is mid-handshake. |
2.7 Risks
- YT can redeem for zero. If the finalized factor is
1e18,yieldPoolis zero and every YT redeems for nothing. The factor is revisable until the oracle finalizes. - PT is not one-for-one.
principalPool = ceil(A / f); at the live ceilingf = 1.05e18a PT redeems for about 95.24% of a raw unit. PT and YT together always equal the tracked collateral, but neither alone is guaranteed to. - Oracle dependence. Settlement reflects whatever two of three signers attest within the validity window. An incorrect attestation that survives the 2-day challenge delay is final. If no attestation is ever finalized before its
deadline,finalize()on the series reverts forever and the only exit ismerge(), which requires equal PT and YT. - Issuer controls. The Stock Token is an issuer-controlled instrument that can be paused, blocked, burned or upgraded. A refused transfer reverts the affected split, merge or redemption; a balance change at the series address makes the solvency check fail and halts every function that depends on it.
- No guaranteed venue. The protocol deploys no market for PT or YT and validates no external one. The interface exposes only split, merge, finalize and redeem for a series; any secondary liquidity is outside the protocol and may not exist.
- Eligibility. Entry is at the discretion of the policy owner. An interface set to
issuance-and-exitsdoes not make an ineligible wallet able to split.
Every number on this page is a public getter. On any series address: start(), splitClose(), maturity(), maxDividendFactor(), oracleEventId(), finalized(), principalPool(), yieldPool(). On its oracle: challengeDelay(), signerSets(1), eventSlots(eventId), eventStates(eventId). See Security model for the trust boundaries these getters sit inside.