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.

  • Contracts DividendSeries · ClaimToken · AttestedDividendOracle · ManagedEligibilityPolicy
  • Live series 3 (AAPL ×2, NVDA)
  • Claim decimals 18
  • Revision September 2026

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.

Not a dividend

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.

assetThe 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.
startSeries open. Must satisfy start ≤ now at deployment. Before it, split and merge revert with SeriesNotStarted.
splitCloseLast moment new claims can be minted. Requires start < splitClose ≤ maturity. From this timestamp split() reverts with SplitClosed; merge stays open.
maturityEnd 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).
maxDividendFactorCeiling 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 · oracleEventIdThe 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.
oracleSignerSetIdThe 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.
eligibilityPolicyThe IEligibilityPolicy consulted on split and on every claim transfer. Live series pin an owner-managed, closed-by-default ManagedEligibilityPolicy (§2.7).
pt · ytTwo 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.

SeriesAssetWindowContractsApp access
aapl-sep-2026 AAPL
0xaF3D…93f9
start 2026-08-29 21:25:16
splitClose 2026-09-01 21:25:16
maturity 2026-09-05 21:25:16
Series 0x2f9d…ADA0
Oracle 0xA0Ac…3139
Policy 0x3699…0263
PT 0x3D6d…3366 · YT 0x41C1…16da
status & exits only
aapl-2026-09-10 AAPL
0xaF3D…93f9
start 2026-09-03 11:28:03
splitClose 2026-09-06 11:28:03
maturity 2026-09-10 11:28:03
Series 0x5a46…24e1
Oracle 0xa447…2F6f
Policy 0x14F9…5e07
PT 0x67c3…08bc · YT 0x7f73…6e2B
issuance & exits
nvda-2026-09-10 NVDA
0xd060…9EEC
start 2026-09-03 11:28:03
splitClose 2026-09-06 11:28:03
maturity 2026-09-10 11:28:03
Series 0x8B6D…82De
Oracle 0x1a9e…3402
Policy 0xA3B5…2C1a
PT 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.

DividendSeries phase machine Open start ≤ t < splitClose split() · merge() eligibility on split solvency checked Split closed t ≥ splitClose merge() only split() → SplitClosed supply is fixed Matured t ≥ maturity, !finalized merge() until finalized finalize() by anyone needs oracle result Finalized terminal redeemPrincipal() redeemYield() split · merge revert clock t reaches splitClose clock t reaches maturity transaction finalize() succeeds oracle final · factor in range
Fig 2.1Phase transitions. The first two are pure clock transitions; the last is a permissionless transaction that only succeeds once the oracle has finalized and the factor is within [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.

Live series timeline, August to September 2026 (UTC) Live series · UTC · 2026 aapl-sep-2026 AAPL · 7-day window aapl-2026-09-10 AAPL · 7-day window nvda-2026-09-10 NVDA · 7-day window Aug 29 Aug 31 Sep 02 Sep 04 Sep 06 Sep 08 Sep 10 Sep 12 Split open · 3 days Split closed · merge only · 4 days Earliest oracle challenge delay · 2 days Row 1 · start 08-29 21:25 · splitClose 09-01 21:25 · maturity 09-05 21:25 Rows 2–3 · start 09-03 11:28 · splitClose 09-06 11:28 · maturity 09-10 11:28
Fig 2.2The three live series. Each is an independent stack; the two September-10 series share dates because they were deployed in one broadcast. The challenge-delay segment assumes an attestation submitted at maturity; a later submission shifts the earliest finalization by the same amount.

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.sender and receiver to be eligible under the policy; otherwise IneligibleParticipant.
  • Pulls exactly assets raw units, increments accountedAssets, mints assets PT and assets YT to receiver.

merge(amount, receiver)

  • Requires now ≥ start, finalized == false, full collateralisation. No maturity check and no eligibility check: exit is unconditional.
  • Burns amount PT and amount YT from the caller, decrements accountedAssets, pushes exactly amount raw units to receiver.

finalize()

  • Requires now ≥ maturity, not already finalized, settlement policy unchanged, and full collateralisation.
  • Reads oracle.getSettlement(asset, start, maturity). Reverts OracleNotFinalized unless the oracle has finalized; InvalidDividendFactor unless 1e18 ≤ factor ≤ maxDividendFactor; InvalidEvidence if the evidence hash is zero.
  • Allocates: principalPool = Math.mulDiv(pt.totalSupply(), 1e18, factor, Ceil); yieldPool = accountedAssets − principalPool. Records dividendFactor, evidenceHash, finalizedAssets and sets finalized = true. Emits SeriesFinalized.

redeemPrincipal(amount, receiver) · redeemYield(amount, receiver)

  • Requires finalized == true and asset.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 when yieldPool == 0): the claim is burned and no transfer is made.
Flow of Stock Token collateral through a series Before finalization · split and merge are inverse, 1 : 1 : 1 After finalize() · two pools, pro-rata redemption Eligible wallet n raw Stock Token units approve(series, n) DividendSeries holds n raw units accountedAssets += n PT-DIV · n units ClaimToken · 18 dec YT-DIV · n units ClaimToken · 18 dec split(n) → ← merge(n) mint / burn finalize() f = oracle factor (WAD) 1e18 ≤ f ≤ 1.05e18 P = ceil(S · 1e18 / f) Y = A − P S = PT supply · A = assets principalPool = P = A when f = 1 · else < A yieldPool = Y ≥ 0 · zero when f = 1 PT holder P · amount / supply YT holder Y · amount / supply redeemPrincipal redeemYield
Fig 2.3Top: a split pulls n raw units and mints n PT and n YT; merge is the exact inverse until finalization. Bottom: 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.

Pool allocation per unit of collateral by finalized factor Allocation of 1.0 unit · principal pool vs yield pool f = 1.000e18 PT 100.00% · YT 0.00% f = 1.010e18 PT 99.01% · YT 0.99% f = 1.025e18 PT 97.56% · YT 2.44% f = 1.050e18 PT 95.24% · YT 4.76% 0 1.0 unit principalPool yieldPool
Fig 2.4Per-unit allocation for factors within the live ceiling of 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 slotCreated 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 setVersioned, 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 dataDomain 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 checkPer 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 checkssubmitAttestation 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 protectionEach 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.
CorrectionsThe 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 delaychallengeDelay 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.
Attestation and finalization sequence Signer set (3 keys) off-chain · threshold 2 Any account submits · finalizes AttestedDividendOracle event slot · signer set 1 DividendSeries pinned eventId 1 · after periodEnd ≥2 EIP-712 signatures 2 · validAfter ≤ t ≤ deadline submitAttestation(...) Validate slot · period · asset factor ≤ max · window nonce · digest · sigs Challenge delay finalizeAfter = t + 2d 2b · optional correction · restarts delay 3 · t ≥ finalizeAfter finalize(eventId) finalize() anyone · t ≥ maturity allocates P and Y getSettlement() → (ok, factor, hash)
Fig 2.5Attestation lifecycle. Signatures are produced off-chain; any account may submit them, submit a correction during the challenge delay, or call either 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.

Gatedsplit() (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 gatedmerge(), redeemPrincipal(), redeemYield(), and any burn. A holder the owner later marks ineligible keeps the unconditional right to exit to the underlying.
Owner powerssetEligibility(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 cannotFreeze or seize claims, block a merge or redemption, change any series parameter, touch collateral, or affect the oracle.
Live ownerThe 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, yieldPool is 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 ceiling f = 1.05e18 a 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 is merge(), 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-exits does not make an ineligible wallet able to split.
Verify

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.