Rainforest Cheng
Back to portfolio

Case study

Hashgreen DEX

Frontend Engineer · Hashgreen Labs · 2022 – Present

A Next.js order-book exchange for trading Chia CAT tokens, with per-market routes and a live order book.

Hashgreen DEX trading interface with the price chart, order book and trade history
1 / 3

Trading — chart, order book & trade history

500+ CATs, one searchable list

A tabbed, fuzzy-searchable market list that scrolls 500-plus assets while keeping only the on-screen rows mounted.

I led the frontend and owned this list. I refused to trade rendering speed against searchability, so I layered three independent mechanisms — windowing, deferred fetch, fuzzy scoring — each solving one axis. The Fuse threshold is 0.1 on purpose: tickers are short and collision-prone.

TableVirtuoso mounts only the rows in view; MarketStore uses onBecomeObserved/onBecomeUnobserved so a market defers fetching until something observes it; Fuse with threshold: 0.1 scores over currency code and name.

HGNHGN / USDC
HGN2HGN2 / USDC
HGN3HGN3 / USDC
HGNXHGNX / USDC
HGNPROHGNPRO / USDC
HGNLPHGNLP / USDC
HGNMINIHGNMINI / USDC
HGNMAXHGNMAX / USDC

Mirrors src/components/Markets/MarketList.tsx · src/stores/MarketStore.ts · src/utils/fuse.ts

Reading the book without acting on a ghost

A live bid/ask book where clicking a level opens a summary panel, and a level that moves off the book closes it instantly.

I owned an invariant: a trader must never act on a price level that has already moved. A tooltip anchored to a row that gets replaced is a first-class event that closes the popover — not a rendering afterthought.

A reconciliation useEffect keys every level by key(market, price, amount, total) and clears the selection the moment that key stops matching anything in the current book — the same invariant the production UI's side-aware react-popper tooltip relies on to avoid anchoring to a row that no longer exists.

HGN-USDC
priceamounttotal
1.2431mid

Mirrors src/components/Trade/OrderBook.tsx · src/components/Trade/OfferTooltip.tsx

Fetch once, then stream

Stats and a trade tape that load once over REST, then go live over one subscription — with a dot that mirrors the socket.

I built streaming as one reusable contract. Every live surface hydrates the same way — one REST fetch, then one hook — and every subscription tears down on unmount and re-subscribes cleanly when the channel changes.

A useAbly({ channelName, events, callback }) hook owns the whole subscription lifecycle; ablyIndicator reads Ably's connection state to drive the dot.

idle
Live
last price
1.2431
24h high
1.3129
24h low
1.1904
  • 14:02:11buy 1.243142.50
  • 14:02:13sell 1.242818.20
  • 14:02:16buy 1.243365.70
useAbly lifecycle
  • idle — flip Live to subscribe; switch market to see the channel swap.

Mirrors src/hooks/useAbly.ts · src/components/Navbar/ablyIndicator.tsx

One state machine, three wallets

Connect Goby, Hoogii, or Chia over WalletConnect — all resolving to one shortened address and one connected state.

I built this around one source of truth — WalletStageEnum — instead of a scatter of booleans. WalletConnect pairing is its own explicit stage, because pairing can be rejected or time out, and those had to be first-class states, not error handling bolted on afterward.

A small stage machine — Unspecified → Initial → Pairing → Connected — sits over per-wallet Wallet classes; the connected account exposes shortenAddress and iconUrl.

Stage is Unspecified until the user opts in.

stage machine
  1. Unspecified
  2. Initial
  3. Pairing
  4. Connected

Mirrors src/stores/WalletStore.ts · src/utils/wallets/*.ts

Patch one row, or refetch the page?

A live order history — paginated and filterable — that decides per event whether to refetch the page or patch one row.

I owned the sync strategy here. The real question is whether a live update still belongs on the page you're looking at. Refetch only when an event changes page membership; otherwise patch the row in place and leave everything else untouched.

A page-keyed cache is fed by an Ably channel scoped to auth.id. Each event runs needRefetch() = checkMarket && checkStatus && checkDateRange; when it's false, checkUserOrderCached() swaps just that row in place.

ordermarketpriceamountstatusage
ORD-101HGN-USDCbuy 1.2401120Active2m
ORD-102HGN-USDCsell 1.248960Active4h
ORD-103XCH-USDCbuy 18.25Filled9m
ORD-104SBX-USDCbuy 0.0414000Active5d
ORD-105HGN-USDCsell 1.2630Cancelled14m
page 1 / 2
Fills a random Active order, then decides the branch against your filters.

Mirrors src/stores/OrderStore.ts · src/hooks/useUserOrders.ts