Reference
StockSync architecture
StockSync is one read-only data layer for Robinhood Chain Stock Tokens with several interfaces. This document records the structure, the verified upstream facts it is built on, and the decisions that are not obvious from the code.
Packages
apps/web Next.js site: explorer, verifier, playground, docs → core, ui, cli, mcp
packages/cli stocksync command-line interface → core
packages/mcp read-only MCP server for AI agents → core
packages/core domain model, upstream schemas, providers, shared logic → (nothing internal)
packages/ui design tokens and accessible React primitives → (nothing internal)
packages/config shared TypeScript configuration (dev only)Rules, enforced by pnpm check:boundaries and ESLint:
corenever depends on a surface, React, Next.js, or Node-only I/O modules. I/O is injected through provider interfaces so the same code runs in the CLI, the MCP server, and Next.js.uinever depends on domain packages or Next.js.- Every
@stocksync/*import must be declared and allowed; relative imports never cross packages. - The internal graph is acyclic by construction.
Internal packages export TypeScript source. Next.js transpiles core and ui; the CLI and MCP
server will be bundled for publishing. Runtime packages use explicit .ts import specifiers
(rewriteRelativeImportExtensions) so they also run under Node's type stripping.
Data flow
Robinhood Stock Token API ─┐
├─> StockSync Core ──> CLI · MCP server · Web · SDK
Robinhood Chain RPC ───────┘ validate → normalize → verify → attach freshnessEvery result is a Sourced<T>: the data, its DataFreshness (source, fetch time, upstream
timestamp, maximum age, cache status), and non-fatal DataWarnings. Failures are a single
StockSyncError type with a stable code and a retryable flag.
Core engine
createStockSync() returns the single read-only API that the CLI, MCP server, and web app share.
Request pipeline
- Validate caller input (symbol, EIP-55 checksum, chain) before any network request.
- Fetch with a per-attempt timeout. Retry only transient failures (network errors, timeouts, HTTP
5xx, and 429 when
Retry-Afterfits the limit) with jittered exponential backoff, three attempts in total. Caller cancellation is never retried. - Reject non-JSON and malformed JSON as
UPSTREAM_SCHEMA_MISMATCH. - Validate the envelope, then each record. Invalid records are skipped with a warning; if every record fails the response is rejected rather than returned empty.
- Cache the normalized result and return
Sourced<T>.
Errors
Messages name the endpoint path, never the full URL, and RPC errors carry no cause, because
provider URLs frequently embed API keys.
Caching and freshness
Concurrent requests for the same data share one upstream call. When an upstream fails transiently,
an expired response may be served, but only with cache: 'stale-fallback', a
SERVED_STALE_AFTER_UPSTREAM_ERROR warning, and a stale freshness assessment. Age is measured from
the upstream's own timestamp whenever it provides one.
Price semantics
StockSync never returns an unlabeled "price".
UnderlyingQuote: bid and ask exactly as published by/prices,multiplierAdjusted: false.PriceView.tokenEquivalent: the documented conversion, bid and ask × current multiplier, in exact decimal arithmetic with the formula named in the result.OraclePriceView.reading: the Chainlink feed price of one token,multiplierAdjusted: true, withwithinHeartbeatand the advisoryoraclePaused()flag.OraclePriceView.underlyingEquivalent: the documented feed ÷uiMultiplier()conversion, rounded half-even to the feed's decimals.
Onchain reads
Every value for one request is read at a single block. Mainnet uses Multicall3 (canonical bytecode
verified at 0xcA11…CA11); chains without a verified Multicall3 fall back to parallel calls at the
same block number. Addresses without code, or without the required interface, fail with
UNSUPPORTED_CONTRACT. oraclePaused() is optional and reported as null when absent.
Verified upstream facts
Checked on 2026-09-15 against the official docs and live responses (fixtures in
packages/core/test/fixtures/robinhood).
Additional facts verified during Phase 2 (fixtures in packages/core/test/fixtures/{robinhood,chain,chainlink}):
/prices/{SYMBOL}returns the same{ quotes: [...] }envelope with one quote. Symbols are case-sensitive:/prices/nvdais a 404 with a gRPC-style body (code: 5). Unknown query parameters return 400 as plain text, and no rate-limit or cache headers are exposed.- Onchain
uiMultiplier()equals the registry'scurrentMultiplier(checked for NVDA and CRWD). Tokens report ERC-165 support for0xa60bf13d,0x4bd27648, and0xd890fd71. When nothing is pending,newUIMultiplier()equalsuiMultiplier()andeffectiveAt()keeps the last effective time. - Chainlink's Robinhood mainnet directory lists 33 primary tokenized-equity USD feeds (8 decimals, 24 h heartbeat, 0.5% deviation threshold) and no L2 sequencer uptime feed. The NVDA feed (211.93) sat within its deviation threshold of the API bid × multiplier (212.23), consistent with feeds including the multiplier.
Where docs and live responses disagree
tradingCapabilities: the reference docs describe three underlier flags (fractionalTradability,allDayTradability,extendedHoursFractionalTradability); live/assetsreturns whole and fractional tradability per session (market,extended,overnight) withTRADING_STATUS_*values. Core models both shapes explicitly instead of inventing a mapping between them.- Live payloads carry undocumented fields (
tokenDecimals,isin,networkName,dailyHigh,dailyLow,mintBurn*). Schemas tolerate additive fields and never depend on undocumented ones for correctness.
Unknown enum values are surfaced as unrecognized with a warning, never silently mapped.
Information architecture
/ overview with live CLI and MCP output, architecture, proof, setup
/explore every registry token: search, filters, keyboard navigation
/asset/[symbol] quote, Chainlink feed, multiplier, trading, corporate actions, metadata
/verify canonical verification by address, optional ticker and chain
/playground run the real CLI, MCP server or Core SDK against live data
/agents agent workflows: inspect a token, check an integration, tool list
/docs/[slug] rendered from this repository's docs/ directory
/status live upstream checks, shared for 30 s, no uptime history
/system design system reference, not indexed
/api/playground POST, validated playground requests
/api/search-index GET, tickers and names for the command menuHow the web app gets its data
- Every data page renders per request on the server and calls one process-wide Core client
(
src/server/stocksync.ts), so pages, route handlers and the embedded surfaces share Core's cache and freshness rules. Core never runs in the browser. - The site shows real surface output instead of imitating it.
src/server/cli.tsruns the actualrunClifrom@stocksync/cliwith captured output, andsrc/server/mcp.tskeeps one in-memory MCP connection tocreateStockSyncMcpServerfrom@stocksync/mcp. The homepage terminal, the playground and the agent workflows all render those results. - Slow sections (quotes, Chainlink reads, onchain multipliers, corporate actions) stream behind Suspense boundaries with skeletons sized like the final content. A failed section shows its own error; the rest of the page still renders.
- Freshness is aged in the browser from the server's render time, so a tab left open shows data turning stale instead of presenting it as current.
- Docs pages are static:
docs/*.mdis lexed withmarkedand rendered with StockSync components and server-side Shiki highlighting. Raw HTML in Markdown is not rendered.
Security and privacy
- Read-only everywhere. No surface holds keys, signs, transfers, approves or trades. The MCP
server registers read-only tools only, each annotated
readOnlyHint: true. - Secrets stay on the server.
STOCKSYNC_*variables are read only by server code (modules importserver-only); the only public variable is the site URL. Configuration errors name the variable and never echo its value, and error messages name endpoint paths, never URLs, because RPC URLs often embed API keys. - Input is validated before any work. Tickers and EIP-55 addresses are parsed by Core; the playground API accepts a strict zod schema with bounded string lengths; search parameters are truncated before use.
- Abuse is bounded.
/api/playgroundallows 60 requests a minute per client in each server process, status checks are shared for 30 seconds, and Core's cache means repeated requests for the same data reuse one upstream response. Multi-instance deployments should add a limit at the edge. - No untrusted HTML. The only raw HTML rendered is Shiki output for first-party code. Docs
Markdown is rendered token by token and raw HTML in it is dropped; links other than
http(s)and sibling docs are not rendered as links. - Headers. Every response sets a Content-Security-Policy that allows only same-origin resources,
frame-ancestors 'none', HSTS,X-Content-Type-Options, a strict referrer policy, and a permissions policy that disables camera, microphone, geolocation and payment.
Deployment
The web app runs on Node (next start) or on Cloudflare Workers through @opennextjs/cloudflare
(pnpm --filter @stocksync/web cf:deploy). The Workers deployment differs from Node in four ways.
- Edge page cache.
apps/web/worker.tswraps the generated worker. Cacheable GET requests (seesrc/lib/edge-cache.ts) are served from the Cloudflare Cache API, and a copy older than its freshness window is still served while one background render replaces it. Next.js marks dynamic pagesno-store, which the Cache API refuses, so the stored copy carries its ownCache-Controland visitors' browsers never keep a copy. Server Component payloads are cached only when Next.js keyed them with_rsc. Measured on 2026-09-15, a warm render costs 30 to 500 ms of CPU and the Workers Free plan stops a request at 10 ms, so the cache is what keeps pages available there. - No OpenNext cache interception. With
enableCacheInterception, OpenNext answered Next.js segment prefetches (Next-Router-Segment-Prefetch: /_tree) for the prerendered docs pages with the full-page payload. The client rejected it and requested it again, about twice a second per open tab, for every link to a docs page. Requests now go through the Next.js server, and the edge page cache keeps them cheap. Cache keys include the deployment version (version_metadata), so a new deployment never serves another deployment's pages. - Argument parsing. Workers does not implement
node:utilparseArgs, so the CLI parses arguments itself (packages/cli/src/parse-args.ts, tested against Node's implementation). - RPC egress. The public Robinhood Chain RPC rate-limits Cloudflare's shared egress addresses.
Deployments set
STOCKSYNC_RPC_URL_MAINNETas a Worker secret; OpenNext copies secrets intoprocess.envbefore the web app creates its Core client. - Building on Windows. OpenNext recreates pnpm's symlinked
node_modulesin its output.scripts/cloudflare-build.mjspreloadsscripts/windows-symlink-shim.mjs, which retargets pnpm's absolute junctions into the output and uses junctions where symlinks would need elevation. Packages installed at the repository root are reachable by the Workers bundler, which is why the brand tooling and its nativesharpdependency live intools/brand.
Design system
- Palette derived from the approved mark: near-black grounds, graphite hairlines, warm
off-white text, and one emerald signal (
#00dc6c) reserved for interaction, verification, and live state. Default Tailwind palettes are reset so every color comes from tokens. Contrast is enforced by tests. - Type: Schibsted Grotesk (editorial grotesk with tabular figures) for language; Martian Mono, set at 87.5% width, for addresses, fixed-point values, and code. Both are self-hosted under the SIL OFL.
- Geometry: mostly sharp; radii 2/4/6/10px by role. Depth comes from surface tone and 1px lines; shadows only on floating layers.
- Motion: durations 80–640ms and three easings, mirrored between CSS and JavaScript and checked
by tests. Motion explains a state change or confirms an action; nothing loops for decoration, and
everything honors
prefers-reduced-motion. - Signature: the homepage "sync seam" shows the real CLI and MCP output of the same request, generated per page load, joined through a StockSync Core node by a line that follows the ribbon of the mark. The seam anchors to the same value located in both outputs, and disappears rather than pointing at the wrong thing when either surface returns an error.
- Live data language: every data block carries a freshness line (source, age, fresh or stale) that keeps ageing in the browser. The signal green marks verified and live state only.
Toolchain decisions
Testing
- Live (opt-in):
pnpm --filter @stocksync/core test:liveruns read-only requests against the real Stock Token API, Robinhood Chain RPC, and Chainlink directory. SetSTOCKSYNC_LIVE_DOH=1on networks whose DNS blocks robinhood.com. - Unit (Vitest): schemas against live and documented payloads, fixed-point math, address and symbol parsing, configuration, error taxonomy, token contrast and motion parity, component behavior, hero data integrity.
- End-to-end (Playwright): desktop and mobile behavior against a production build, including
keyboard paths, reduced motion, clipboard, 404 status, and console cleanliness. Tests run against
tests/support/mock-upstream.mjs, which serves the captured Core fixtures and fails every RPC call, so results are deterministic and the degraded paths (stale quotes, unreadable contracts, failing status checks) are exercised on every run. The same suite crawls every internal link on the main pages, holds the homepage to a layout shift below 0.1 on a throttled mobile connection, and runs axe against WCAG 2.2 AA on every route and the open command menu. - Full outage (Playwright
degradedproject): a second app instance whose upstreams all return HTTP 503. Every page must still render, name the failure, keep docs and navigation working, and never show a verdict, a price, or an uptime figure it could not fetch. - Visual QA:
pnpm qa:screenshotscaptures every route at 1440, 1280, 1024, 768, 430, and 390px into.qa/. SetE2E_LIVE=1to capture against the real upstreams instead of the mock.