The corporate-action layer for Stock Tokens.
Robinhood Stock Tokens don't pay cash dividends onchain — they raise an ERC-8056 multiplier that changes how many underlying shares each token represents, while raw balances stay put. Standard tooling ignores this, which is why public data on these assets has been off by 10x to 100x.
exdate indexes what actually happens: every multiplier update, every corporate action, the dividend that is owed but not yet reflected, the net yield after the fees and withholding nobody documents, and the health of every Chainlink feed.
Status: M1 to M5 shipped. The indexer, the API, the reconciliation table and the status page run against Robinhood Chain mainnet today: 194 tokens polled, 35 Chainlink feeds, 12 distinct multiplier changes, 43 issuer corporate actions, 49 reconciliation rows, and 43 signed webhook deliveries verified end to end. A second issuer — Coinbase B20 on Base — is now verified address by address on chain but not wired (
docs/second-issuer-base.md). What is left is listed under Known gaps. Readdocs/phase-0-verification.mdfor every verified fact.
| Pending dividend | Between ex-date and multiplier application, a token is worth more than the oracle says. Undervalued collateral, predictable DEX/NAV premium. |
| Observed haircut | Reconciling declared dividends against observed multiplier steps measures the real cost of the structure. Published nowhere else. |
| Feed health | Feeds are 24/5 and freeze off-hours, while the chain does not. exdate samples the off-hours share hourly instead of repeating the brief's ~46% — the answer is published only once every session has been sampled. Lending protocols need to know before they liquidate. |
None of that is hypothetical. On 2026-09-02, mid-session, the SPY feed was 18 hours stale and the QQQ feed 4 hours stale. Ten tokens have already moved their multiplier — steps ranging from 0.64 bps to 214.86 bps — and the onchain warning before a change takes effect is nine minutes. Only 18% of Stock Tokens have a Chainlink feed at all. Reconciling the issuer's own dividend rates against the onchain steps gives a ~34–36 % haircut on AAPL and SGOV, four events that don't reconcile at all, and seven dividends marked completed that have not reached the chain after up to four weeks. Every input is sourced; see the report.
pnpm install
pnpm dev # indexer + API http://localhost:42069
pnpm dev:status # status page http://localhost:3000
pnpm dev:web # public site http://localhost:3001
pnpm test # unit tests: core, api, sdk, and the indexer's webhook outbox
pnpm typecheckThe poller writes its first rows within about a minute. Until then the page says so; it never shows a zero it has not observed.
The public site is live at https://exdate-bactas-projects.vercel.app and deploys to Vercel
from vercel.json at the repository root: one project builds the workspace
(pnpm --filter @exdate/web build) and serves the static export from apps/web/out.
framework is deliberately null rather than nextjs — the site is output: 'export', so there
is no server to detect and "run this command, serve this folder" behaves identically locally and
on Vercel.
Deployment is automatic: the project is connected to this repository with the default branch
as its production branch, so every push builds and goes live. That is not a convenience — the
collectors below commit on their own schedules, and a site that only updated by hand would start
showing figures older than the files it claims to read. Deploying by hand is still possible from
an agent container with scripts/deploy-web.sh, which uploads the tree from a copy without
.git: Vercel blocks a CLI deployment whose commit author is not a member of the team, and
the commits here are the agent's. A git-connected deployment is attributed to the installation
instead and is not blocked, which is why the fallback deploy workflow was removed.
Seven GitHub Actions run in .github/workflows, and six of them are collectors: they exist because
the things exdate measures are not readable after the fact. The issuer keeps about a month of
corporate actions and quotes only the present; the chain keeps no archive state; a session's
transfer rate is a rate, not a total. Every day one of these does not run is a day that cannot be
recovered later.
| Workflow | When | What it collects |
|---|---|---|
ci |
push | typecheck, tests, status-page build, and proof the generated registry still matches its data |
archive-corporate-actions |
daily | merges the issuer's one-month window into data/corporate-actions.archive.json, and rebuilds the token list; commits only when something changed |
capture-effective-prices |
every 5 min | watches for a UIMultiplierUpdated announcement and returns at effectiveAt to sample the issuer's quote — the price a haircut is computed from; also sends the alerts below |
measure-session-share |
hourly | one sampled window into data/session-share.observed.json; it takes a week to cover the clock, which is why it is a schedule and not a script anyone runs once |
measure-dex-feed-gap |
hourly | the distance between the traded price and the Chainlink answer per token, and the pairing corroboration built on it |
measure-primary-flows |
daily | mint minus burn per token, in contiguous windows, so a delayed run loses nothing |
measure-capture-cadence |
daily | how often GitHub really ran the capture job, from its own run log — the number that decides whether a step is caught |
Two checks are run by hand rather than on a schedule, because their inputs move only when the
chain does: probe-rpc-endpoints.mjs measures what each public endpoint can do, and
verify-multiplier-history.mjs reads every step back in the chain's own state.
Each commits to the default branch, which deploys the site — so the published pages and the committed record move together.
The most perishable thing exdate measures is the announcement lead: a multiplier change is
published on chain about nine minutes before it takes effect. scripts/notify.mjs sends that
notice, and the one at the moment it takes effect - which no chain event marks, so a log watcher
cannot produce it. It runs inside the capture workflow every five minutes and needs no host at
all: set EXDATE_ALERT_WEBHOOK_URL (a Discord or Slack incoming webhook) or
EXDATE_TELEGRAM_BOT_TOKEN with EXDATE_TELEGRAM_CHAT_ID as repository secrets. With none set it
does nothing and says so. An announcement seen more than an hour late is refused rather than sent,
because delivering it would report a lead rather than give one; delivery is recorded in
data/effective-prices.observed.json, which makes it the evidence that the lead was given.
The indexer's signed webhook outbox is the other half of this and still needs a host - see below.
Robinhood's own endpoint is the default: it takes a 2,000,000-block eth_getLogs, answers
browsers, and keeps no archive — eth_call a few thousand blocks back answers
metadata is not found. That last fact was recorded here as a property of the chain for a month.
It is a property of that endpoint.
node scripts/probe-rpc-endpoints.mjs reads the public chain registries and measures every
endpoint they list, committing the result to data/rpc-endpoints.observed.json: six of eight
answer, three serve state at any height, and two reach the oldest multiplier step. Archive is
tested as state that differs from latest, never as a block number, since Multicall3's
getBlockNumber() answers on any node. Where two endpoints answer the same depth their answers
are compared, and a depth with one witness is reported as one witness.
Their capabilities do not overlap, so the project uses more than one. Set RHC_RPC_URL_ARCHIVE to
give the indexer and scripts/verify-multiplier-history.mjs a node that keeps history; wide log
scans stay on Robinhood's. These are third parties with no service commitment: sound for history,
which can be re-read at any time, and never for a capture that cannot.
The capture job above is scheduled every five minutes, and GitHub's cron is best-effort: measured
on its first morning, it fired every 7 to 25 minutes, against a nine-minute announcement lead. A
run now waits nine minutes for a step it has seen announced, which is the most a schedule can do,
and data/capture-cadence.observed.json says what that buys. The answer is a process that is
simply always there: scripts/watch-effective-prices.mjs, the same logic from the same module
(scripts/lib/effective-prices.mjs, tested in packages/core), scanning every thirty seconds,
sampling at the instant, committing what it caught and a heartbeat every six hours.
It needs a machine, a clone on a branch it may push to, and a deploy key with write access.
deploy/install.sh, run as root on a fresh Debian or Ubuntu box, does all of it: it runs twice,
the first pass generating a deploy key and printing the public half to add to the repository, the
second cloning, installing deploy/exdate-watcher.service and starting it. The private key is
generated on the machine and never travels. There is also a watcher service in
docker-compose.yml, next to the API.
node scripts/check-watcher.mjs says whether a machine can do the job before it matters: node, the
checkout, push access proved with a dry run, the chain, a real issuer quote, and the clock measured
against the issuer's own, since the watcher wakes on that clock and drift means missed windows. Alert sinks are the ones under Alerts. Then set the
repository variable EXDATE_CAPTURE_MODE=watchdog: the GitHub job stops capturing on its own,
checks the watcher's heartbeat instead, captures in its place only while that heartbeat is stale,
and says so through the sinks on each transition. The two share one file and each owns its own
field in it, so neither erases what the other recorded. EXDATE_WATCH_PUSH=false runs the watcher
without committing, for a trial.
A public instance runs at https://api.exdate.me, with the status page at
https://status.exdate.me. It is one small machine, not a service with an availability
commitment — say so before anyone builds on it.
deploy/install-api.sh puts the same thing on a fresh Debian or Ubuntu box: Postgres, the indexer
serving /v1 on 42069, the status page rendered server-side, and Caddy terminating TLS for both
names (Dockerfile, deploy/status.Dockerfile, deploy/Caddyfile, docker-compose.yml under
--profile public). It refuses before Caddy can ask Let's Encrypt for a name that does not resolve
to the machine, because repeated ACME failures are rate-limited per domain, and it reads back which
containers came up rather than assuming. Caddy forwards the client address in X-Forwarded-For, so
anonymous quotas count per visitor and not per proxy. Everything else has a default; .env.example
lists what can change.
Keys and quotas are three variables: EXDATE_API_KEYS as key:label:requestsPerMinute, comma
separated (empty means open at the anonymous rate); EXDATE_ANON_RPM for callers without a key
(60); EXDATE_KEY_RPM for a key that states no quota (600). An unknown key is a 401, never a
silent downgrade. GET /v1/me tells a caller its tier and what is left, uncounted. The counting is
in memory, per process: this API serves a few hundred rows from one process, and a shared store
would be machinery for a problem it does not have.
If pnpm dev stops with Schema "public" was previously used by a different Ponder app, the local
PGlite database belongs to an earlier build: delete packages/indexer/.ponder/ and start again.
@exdate/core and @exdate/sdk are set up for npm: the workspace consumes their TypeScript source,
and publishConfig swaps main, types and exports to the dist/ that pnpm build emits at
publish time, so nothing in the monorepo changes shape. pnpm --filter @exdate/core publish then
pnpm --filter @exdate/sdk publish (the SDK depends on core; pnpm rewrites the workspace: range
to the published version). Neither is published today, and the SDK README says so.
exdate is an independent measurement. It is not affiliated with, endorsed by, or officially
connected with Robinhood Markets, Inc. or any of its affiliates. The reading of Robinhood's terms
against what this repository does is in docs/terms-review.md.
The on-chain record is public. The issuer's registry and corporate-action feed come from
api.robinhood.com/rhj, which needs no key but sits under Robinhood's developer-documentation
terms (RHDA, LLC): a personal, revocable licence that forbids distributing "Robinhood Materials" to
third parties or building a competing product. Whether an archived JSON row is such material is a
question for counsel before any of it is sold or licensed; this repository keeps the feed's rows,
with their source named on every one, and the site no longer serves the issuer's files as
downloadable datasets. exdate's own observations are under CC BY 4.0 with the issuer's fields
carved out (DATA-LICENSE.md); the code is MIT (LICENSE).
The same Terms bind every Service to "testing, experimentation, evaluation, and development"
purposes and say the public RPC is "not intended for production-grade" use, while the chain
itself is expressly not a Service. So production reads go to a measured third-party endpoint
first and touch Robinhood's only as a fallback — scripts/phase0/rpc.mjs and the indexer's
failoverHttp both carry that order, and RHC_RPC_URLS overrides it. A paid provider from
Robinhood's own list adds the one thing the free endpoints lack, a service commitment.
node scripts/phase0/check-chain.mjs # chainId 4663, cadence, head
node scripts/phase0/check-tokens.mjs AAPL SGOV # ERC-20 + ERC-8056 views
node scripts/phase0/check-feeds.mjs # every Chainlink feed + its age
node scripts/phase0/check-corporate-actions.mjs # issuer dividends vs onchain steps
node scripts/phase0/feed-price-at.mjs <feed> <iso> # Chainlink price at an instant, no archive
node scripts/phase0/snapshot-registry.mjs # refresh + diff the issuer registry
node scripts/phase0/probe-oracle-link.mjs # is there an on-chain token <-> feed link? (no)
node scripts/phase0/verify-base-b20.mjs # Coinbase B20 on Base, every address on chain
node scripts/phase0/verify-feed-map.mjs # corroborate the feed map by behaviour
node scripts/phase0/check-svr-proxies.mjs # primary vs SVR proxy, all 35 feeds
node scripts/phase0/measure-transfers.mjs # transfer volume and provable trades
node scripts/archive-corporate-actions.mjs # merge today's window into the archive
node scripts/measure-session-share.mjs # one off-hours sample; the Action runs it hourly
node scripts/backfill-multiplier-events.mjs # full-chain event scan, 26 requests
node scripts/build-reconciliations.mjs # declared vs observed, priced at effectiveAt
node scripts/generate-registry.mjs # snapshots -> typed moduleLive checks against mainnet, opt-in so the unit suite never needs the network:
EXDATE_INTEGRATION=1 pnpm --filter @exdate/core testCommitted artifacts, all first-party or read from the chain:
| File | What |
|---|---|
data/robinhood-assets.snapshot.json |
194 Stock Tokens from the issuer's own registry |
data/robinhood-corporate-actions.snapshot.json |
43 dividends from the issuer's own feed (12 done, 31 upcoming) |
data/chainlink-feeds.snapshot.json |
57 Chainlink feeds on Robinhood Chain |
data/token-feed-map.json |
token → feed pairing, every row verified: false, 23 of 35 corroborated by behaviour, with corroboratedBy naming which |
data/feed-map-verification.json |
what that pairing was actually tested against |
data/svr-proxy-check.json |
the primary and SVR proxies of all 35 feeds, compared by address |
data/transfer-volume.observed.json |
what indexing transfers would cost, measured |
data/session-share.observed.json |
how much activity happens outside the US market session — sampled hourly, share refused until every session is covered |
data/base-coinbase-feeds.snapshot.json |
the 13 Coinbase B20 feeds on Base — second-issuer reconnaissance |
data/base-b20-verification.json |
Coinbase's oracle registry, 13 B20 tokens and 13 feeds on Base, each read back by address |
data/multiplier-events.observed.json |
every UIMultiplierUpdated log on chain — 13 logs, 12 distinct changes, 10 tokens |
data/reconciliations.observed.json |
every declared action against the step it produced, priced at effectiveAt |
data/corporate-actions.archive.json |
every action the issuer has published while exdate was watching — its own endpoint keeps about a month |
GET /v1/health build identity
GET /v1/chains supported chains
GET /v1/:chain/tokens every token: multiplier, scheduled update, feed state
GET /v1/:chain/tokens/:addr one token plus its full event history
GET /v1/:chain/events every multiplier event, newest first
GET /v1/:chain/reconciliations declared vs observed, per action ?token= ?status=
GET /v1/:chain/tokens/:addr/yield the distribution ledger: per-payment gross, received,
haircut; growth split dividend / unexplained; no rate
GET /v1/:chain/tokens/:addr/pending what is owed and has not arrived: the change already
announced on chain, and every declared dividend the
multiplier has not reflected
GET /v1/status every feed: live, stale, paused, and how many have none
GET /v1/calendar issuer corporate actions + pending on-chain updates
GET /v1/webhooks the event catalogue, the signing scheme, the retries
GET /v1/:chain/webhooks/events the outbox: what was noticed, and what each delivery did
Full reference, with a real captured response for every route: docs/api.md.
:chain accepts robinhood or 4663. Every bigint is a decimal string; anything unobserved is
null, never 0.
/yield is a ledger, not a rate. It carries one row per observed multiplier step and per declared
action, calls a step yield only when it is paired with an issuer cash dividend, and lists every
figure it refuses to compute (annualizedYield, trailingTwelveMonthYield, forwardYield) with a
reason code. Nothing in it is per annum.
/pending keeps apart states that are usually conflated: scheduled (a log is on chain, the
change is about nine minutes away), upcoming (declared, the process date has not arrived, so
nothing is owed yet), awaiting (declared, past the process date and still inside the observed
next-business-day window) and declared_complete_not_on_chain (the issuer's own feed says COMPLETED while the
multiplier has not moved — seven tokens on 2026-09-02, BND for four weeks). It projects the step a
full payment would produce at the latest round, marked notAMeasurement, and refuses to predict
when the step will land or how much of it will survive. The status page renders it under
What is owed and has not arrived, including grossPerToken — the one figure there that needs no
oracle at all, since the declared rate is per underlying share and one raw token carries
uiMultiplier of them.
Seven events: multiplier.scheduled, multiplier.applied, feed.stale, feed.resumed,
pause.changed, dividend.pending, dividend.reconciled. GET /v1/webhooks serves the
catalogue, and each entry states what exdate observed to send it — multiplier.applied is a
poller observation, because nothing is emitted on chain when a change takes effect.
Endpoints are configured out of band, because they carry the signing secret:
EXDATE_WEBHOOK_ENDPOINTS=[{"id":"curator","url":"https://…","secret":"…","events":["dividend.reconciled"]}]
Every delivery is signed HMAC-SHA256 over `${t}.${rawBody}` and carries
exdate-signature: t=<unix seconds>,v1=<hex digest>
exdate-event: dividend.reconciled
exdate-event-id: dividend.reconciled:4663:<action>:<processDate>
Verify the raw bytes, before parsing the JSON:
import { verifySignature } from '@exdate/core'
const raw = await request.text()
const result = await verifySignature({
secret: process.env.WEBHOOK_SECRET!,
header: request.headers.get('exdate-signature'),
body: raw,
nowSeconds: Math.floor(Date.now() / 1000),
})
if (!result.valid) return new Response(result.reason, { status: 400 })The timestamp is inside the signed material and is checked against a 300 s window, so a captured
delivery cannot be replayed. Event ids are deterministic: a redelivery, or the same occurrence seen
by both the live indexer and the poller, carries the id you already have — key your bookkeeping on
it. Failures retry seven times over about twelve hours and are then marked failed and kept, so a
consumer that was down can see what it missed at /v1/:chain/webhooks/events.
Two things worth knowing before pointing a production consumer at it. A fresh database emits the
current backlog once (43 events on 2026-09-02 — 37 declared dividends and 6 reconciliations);
those dividend.pending payloads carry backlog: true, so a consumer starting up can act only on
backlog: false. And delivery is drained at the start of each poll cycle, so it lags an event by
up to one interval (~60 s by default) against a ~9-minute announcement lead.
import { createClient } from '@exdate/sdk'
const exdate = createClient({ baseUrl: 'https://api.exdate.me' })
const ledger = await exdate.yield('0x92FD66527192E3e61d4DDd13322Aa222DE86F9B5')
ledger.totals?.dividendGrowthBps // 20.22 — explained by a paired issuer dividend
ledger.totals?.unexplainedGrowthBps // 30.73 — steps with no issuer row behind themTyped against the API, with the two shapes it cannot afford to get wrong (YieldLedger,
PendingView) derived from the functions that produce them. The webhook verifier is the same
function the sender signs with, and installing the SDK does not drag in the server. Full usage:
packages/sdk/README.md.
packages/core chains, ABIs, WAD maths, staleness, NFT log filtering, reconciliation,
the throttled RPC transport, the generated registry. No I/O, unit-tested.
packages/indexer Ponder: indexes UIMultiplierUpdated, polls the ERC-8056 views, the
Chainlink feeds and the issuer's corporate actions, serves the API.
packages/api Hono routes over a Repository interface — no SQL, deployable alone.
apps/status Next.js App Router status page. Reads the API and nothing else.
apps/web The public site: a token finder, one static page per token (what it
represents, what was declared, what arrived, what is owed, and how each figure
was measured), a calendar of declared dividends not yet on chain, and /wallet/,
where a visitor's browser reads what an address holds straight from the chain
(no server, no signature). Every published number is read at build time from
the committed observations in data/, with its date; the balance is the one
live figure, dated by its block. What past dividends delivered to the address
is rebuilt in the browser from its own transfers, against the twelve effective
blocks resolved once by scripts/resolve-effective-blocks.mjs.
packages/sdk @exdate/sdk — typed client + webhook verifier. Depends on core only.
scripts verification and backfill scripts
data committed snapshots of first-party registries, observed events, and the
feed-map verification
docs Phase 0 report, API reference
Ponder's sync loop manages 25 blocks per 9–16 s on the public RPC — about 300 days for the
51.7 M blocks since mainnet — because the endpoint rejects roughly half of all eth_getLogs calls
at any pacing and Ponder sizes each round from the previous round's duration. One wide query per
2 000 000 blocks does the same scan in 26 requests, two minutes.
So history comes from scripts/backfill-multiplier-events.mjs and Ponder starts at the head and
owns everything live. Every row records which scanner found it (onchain:scan or
onchain:indexer); both are real logs with real transaction hashes. Point RHC_RPC_URL_ARCHIVE at
a dedicated provider and set RHC_START_BLOCK=900000 to hand the whole history back to Ponder.
cp .env.example .env # optional: every default points at the public RPCEdit the root .env only. pnpm dev, pnpm dev:status and pnpm start copy it to
packages/indexer/.env.local and apps/status/.env.local, which are the files Ponder and Next
actually read.
No API key is needed anywhere. The registry, the prices and the corporate actions all come from the
issuer's own unauthenticated endpoints, and the scripts under scripts/phase0/ are dependency-free
— plain Node 22, fetch, hand-rolled ABI encoding — so they run against a bare checkout.
See CLAUDE.md for verified onchain facts, known traps, and the decision log.
exdate never displays a number it cannot trace to an onchain event. Where no event has been
observed yet, the API returns an explicit confidence field and the UI says so. Fees and
withholding on onchain distributions are undocumented by the issuer; everything exdate reports
about them is observed, never official.
The same rule applies to this repository. Anything derived rather than read — currently the
token → feed mapping — is committed with verified: false and named as a heuristic.
That mapping has since been tested rather than left as an assumption
(docs/phase-0-verification.md §14). No first-party, address-level
link exists — the token contract answers with no address at all, and the probe that establishes
that is committed. What does exist: all 35 aggregators name their ticker in their own on-chain
description(), the issuer's registry carries 194 distinct tickers for 194 assets, and SGOV's
2026-07-08 multiplier step was seen moving its assigned feed by +9.5778 bps against an expected
+9.5752 — on a feed whose ordinary movement is 0.0094 bps, and uniquely closest among all 35 feeds
measured at that instant. That is the causal test, and SGOV is the only row that passes it: a
0.5 % deviation threshold puts ~50 bps between consecutive rounds, so a dividend-sized step is
invisible on every other token.
A second, weaker test runs hourly and now carries 23 of the 35 rows: a token's on-chain traded
price should sit closer to its own feed than to any of the other 34, and repeatedly does. It
identifies the underlying rather than testing the mechanism — two unrelated assets can trade at one
price — so the two are never merged into one word. corroboratedBy names which evidence a row
carries, everywhere the fact travels: the map, the generated registry, reconcile(), the API, the
SDK and both sites. The 12 rows that pass neither are mostly the volatile names, where the traded
price is genuinely far from the feed and the test has nothing to recognise.
The confidence ladder keeps high reserved for a first-party statement that does not yet exist;
both kinds of corroboration reach medium, and the row says which one it stands on.
One exists on the other chain, which is the useful contrast. Coinbase's oracle registry on Base
answers a per-token read for its thirteen B20 tokens and reverts for WETH or for an address
holding no code — so the registry names its token set by address, not by ticker. That is a link of
the kind Robinhood Chain has nowhere. It still does not pair a feed with a token: that join is
AAPLc ↔ Coinbase AAPL, a ticker heuristic like the other one.
docs/second-issuer-base.md has the whole reconnaissance, and every
address in it was read back on chain.