Skip to content

Repository files navigation

exdate

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. Read docs/phase-0-verification.md for every verified fact.

Why it matters

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.

Run it

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 typecheck

The 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.

Alerts

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.

Reading the chain, and its history

Robinhood's own endpoint is the default: it takes a 2,000,000-block eth_getLogs, answers browsers, and keeps no archiveeth_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 watcher

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.

Hosting the API

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.

Publishing the packages

@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.

Terms that bind the data

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.

Verification scripts

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 module

Live checks against mainnet, opt-in so the unit suite never needs the network:

EXDATE_INTEGRATION=1 pnpm --filter @exdate/core test

Committed 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

API

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.

Webhooks

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.

SDK

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 them

Typed 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.

Repo layout

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

Why the history is scanned rather than indexed

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.

Development

cp .env.example .env      # optional: every default points at the public RPC

Edit 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.

Honesty policy

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 AAPLcCoinbase 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.

Releases

Packages

Contributors

Languages