Skip to content
StockSync
Docs · Architecture

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

text
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:

  • core never 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.
  • ui never 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

text
Robinhood Stock Token API ─┐
                           ├─> StockSync Core ──> CLI · MCP server · Web · SDK
Robinhood Chain RPC ───────┘    validate → normalize → verify → attach freshness

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

ts
import { createStockSync, resolveConfig } from '@stocksync/core';

const stocksync = createStockSync({ config: resolveConfig(process.env) });
const { data: price, freshness, warnings } = await stocksync.getPrice('NVDA');
MethodReturnsReads from
listAssets, searchAssets, getAsset, getAssetByAddressRegistry tokens; getAsset returns data: null when nothing matches/assets
getPrices, getPriceRaw underlying quotes; PriceView adds the multiplier and token-equivalent/prices, /prices/{SYMBOL}, /assets
getCorporateActionsProcessed corporate actions, optionally for one registered symbol/corporate-actions, /assets
getMultiplierStateRegistry multiplier, onchain ERC-8056 state, and whether they agree/assets, Robinhood Chain RPC
getTradingCapabilitiesSession tradability plus the halt flag (degrades to null without a quote)/assets, /prices/{SYMBOL}
verifyCanonicalAddresscanonical, symbol-mismatch, or not-canonical with a reason/assets
getOraclePriceChainlink reading with heartbeat, pause flag, and underlying conversionChainlink feed directory, RPC
getChainInfoNetwork configuration; configured RPC URLs are never exposedStatic registry

Request pipeline

  1. Validate caller input (symbol, EIP-55 checksum, chain) before any network request.
  2. Fetch with a per-attempt timeout. Retry only transient failures (network errors, timeouts, HTTP 5xx, and 429 when Retry-After fits the limit) with jittered exponential backoff, three attempts in total. Caller cancellation is never retried.
  3. Reject non-JSON and malformed JSON as UPSTREAM_SCHEMA_MISMATCH.
  4. 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.
  5. Cache the normalized result and return Sourced<T>.

Errors

CodeMeaningRetryable
INVALID_INPUTMalformed symbol, address, query, or optionNo
UNSUPPORTED_CHAINChain is neither mainnet (4663) nor testnet (46630)No
UNSUPPORTED_CONTRACTAddress has no code, or lacks the required interfaceNo
NOT_FOUNDA required record does not exist (unknown symbol, no deployment)No
CONFIG_INVALIDEnvironment configuration failed validationNo
UPSTREAM_TIMEOUTAn upstream did not respond in timeYes
UPSTREAM_UNAVAILABLENetwork failure or HTTP 5xxYes
UPSTREAM_RATE_LIMITEDHTTP 429Yes
UPSTREAM_HTTP_ERRORAny other unexpected HTTP statusNo
UPSTREAM_SCHEMA_MISMATCHThe upstream responded in an unexpected shapeNo
RPC_ERRORRobinhood Chain RPC failureYes

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

DataReused forPresented as stale afterStale fallback window
Assets1 min10 min1 h
Quotes15 s1 min5 min
Corporate actions15 min2 h6 h
Onchain state10 s1 minnone
Feed directory1 h24 h7 days
Oracle reading10 sthe feed's heartbeatnone

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, with withinHeartbeat and the advisory oraclePaused() 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).

TopicFact
NetworksMainnet chain ID 4663, testnet 46630, ETH gas, Arbitrum L2. Public RPCs are rate-limited; production should use a provider.
Stock Token APIGET https://api.robinhood.com/rhj/{assets,prices,corporate-actions}, 60 req/s. /prices cache 15 s, /corporate-actions cache 1 h.
Canonical statusA contract is canonical only if the registry lists it. Matching ticker or name is not proof.
Quotes/prices bid and ask are raw underlying-equity values, not multiplier-adjusted.
Onchain feedsChainlink Stock Token feeds report the price of one token and already include the multiplier. Staleness checks remain the primary guard; oraclePaused() is advisory.
MultiplierERC-8056 uiMultiplier() is 18-decimal fixed point. newUIMultiplier() and effectiveAt() expose scheduled changes. Raw balances never change.
Corporate action idThe affected asset's uid, not a unique action id (verified: 43 of 43 live actions).

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/nvda is 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's currentMultiplier (checked for NVDA and CRWD). Tokens report ERC-165 support for 0xa60bf13d, 0x4bd27648, and 0xd890fd71. When nothing is pending, newUIMultiplier() equals uiMultiplier() and effectiveAt() 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 /assets returns whole and fractional tradability per session (market, extended, overnight) with TRADING_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

text
/                     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 menu

How 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.ts runs the actual runCli from @stocksync/cli with captured output, and src/server/mcp.ts keeps one in-memory MCP connection to createStockSyncMcpServer from @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/*.md is lexed with marked and 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 import server-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/playground allows 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.ts wraps the generated worker. Cacheable GET requests (see src/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 pages no-store, which the Cache API refuses, so the stored copy carries its own Cache-Control and 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:util parseArgs, 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_MAINNET as a Worker secret; OpenNext copies secrets into process.env before the web app creates its Core client.
  • Building on Windows. OpenNext recreates pnpm's symlinked node_modules in its output. scripts/cloudflare-build.mjs preloads scripts/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 native sharp dependency live in tools/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

DecisionReason
TypeScript 6.0.x, not 7.xtypescript-eslint supports <6.1.
ESLint 9, not 10eslint-plugin-react, -import, and -jsx-a11y (via eslint-config-next) do not support ESLint 10 yet.
Versions older than 24 hoursRespect pnpm 11's minimumReleaseAge supply-chain protection instead of adding exclusions.
Vendored fontsReproducible builds with no dependency on a font CDN.
MCP TypeScript SDK v2@modelcontextprotocol/server with stdio transport; SSE is not used.

Testing

  • Live (opt-in): pnpm --filter @stocksync/core test:live runs read-only requests against the real Stock Token API, Robinhood Chain RPC, and Chainlink directory. Set STOCKSYNC_LIVE_DOH=1 on 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 degraded project): 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:screenshots captures every route at 1440, 1280, 1024, 768, 430, and 390px into .qa/. Set E2E_LIVE=1 to capture against the real upstreams instead of the mock.