Documentation

How Meet works

Meet reads your on-chain trading history from Robinhood Chain, derives fee-aware performance metrics, assigns a precision tier, and matches you with traders whose behaviour genuinely resembles yours. This page documents exactly how each of those steps works — no hand-waving.

Overview

Most "trader social" products match people on self-reported bios. Meet matches on verifiable on-chain behaviour. You connect a wallet, prove ownership with a signature (no transaction, no gas, no approvals), and the system reconstructs your entire memecoin trading history from Robinhood Chain.

From that history it computes realized PnL, ROI, win rate, drawdown, trade tempo, and average holding time — all denominated in ETH and net of fees. Those six features become a vector. Your vector is compared against every other synced wallet using weighted cosine similarity, and you get introduced to the traders closest to you in that space.

What problem this solves

  • Signal over noise. A trader doing 5-second flips on freshly-launched tokens has nothing useful to exchange with someone swing-holding for days. Tempo and hold-time are first-class matching features.
  • No self-reporting. Every number comes from the chain. You cannot inflate your ROI in a profile field, because there is no profile field.
  • Risk-aligned peers. Drawdown tolerance is weighted heavily. Someone who survives -60% drawdowns and someone who cuts at -10% are not peers, even if their ROI happens to match.

Architecture

Two processes. The Next.js app serves the UI and API routes; a separate BullMQ worker handles wallet syncing. They communicate through Redis — both as a job queue and as a pub/sub bus for realtime notifications.

┌──────────────┐        ┌─────────────┐        ┌──────────────┐
│   Browser    │◄──────►│  Next.js    │◄──────►│  PostgreSQL  │
│  (wagmi/viem)│  HTTP  │  App + API  │ Prisma │   (Neon)     │
└──────┬───────┘   +WS  └──────┬──────┘        └──────────────┘
       │                       │
       │  Socket.IO            │ enqueue / pub-sub
       │                       ▼
       │                ┌─────────────┐
       └───────────────►│    Redis    │
                        │  (Upstash)  │
                        └──────┬──────┘
                               │ BullMQ job
                               ▼
                        ┌─────────────┐        ┌──────────────┐
                        │   Worker    │◄──────►│   Alchemy    │
                        │ (sync/calc) │  RPC   │ Robinhood RPC│
                        └─────────────┘        └──────────────┘

The split matters: syncing a wallet with 600+ trades takes ~20 seconds and hundreds of RPC calls. Doing that inside a request handler would block the response. Instead login enqueues a job, the worker processes it, and the browser polls /api/sync/status until the job leaves the queue.

Data ingestion

Robinhood Chain is an Arbitrum Orbit L2 (chain ID 4663, ~100ms blocks, ETH as gas). That block time is the central engineering constraint: a single day is roughly 864,000 blocks.

Why not eth_getLogs

The obvious approach — scan Uniswap Swapevents — is infeasible. Alchemy's free tier caps eth_getLogs at a 10-block range. At 100ms blocks that is one second of chain time per request; covering a single day would need ~86,000 requests. Not viable.

What we use instead

alchemy_getAssetTransfers — an indexed endpoint with no block-range limit. One paginated call chain returns the wallet's entire transfer history from block 0:

{
  "method": "alchemy_getAssetTransfers",
  "params": [{
    "fromBlock":  "0x0",
    "toBlock":    "latest",
    "fromAddress": "0x…",          // and a second pass on toAddress
    "category":   ["external", "internal", "erc20"],
    "withMetadata": true,
    "excludeZeroValue": true,
    "maxCount":   "0x3e8",         // 1000/page, up to 15 pages
    "order":      "desc"
  }]
}

Reconstructing swaps

Transfers are grouped by transaction hash. A swap is identified by its token leg:

  • Token arrives at the wallet in a tx → BUY
  • Token leaves the wallet in a tx → SELL

Plain sends, airdrops, and bridging naturally fail this test (they have no counter-leg, or no ETH pricing anchor) and are discarded. Everything is deduplicated by hash.

Trade pricing

Knowing you bought 2,000,000 $DATABEAR is useless without knowing what it cost. This is the hardest part of the pipeline, because freshly-launched memecoins do not exist in any price API — their price only exists on-chain, inside the pool.

Buys: native ETH from tx.value

On a buy, the router is called with native ETH attached. That amount sits in tx.value — a single eth_getTransactionByHash gives the exact ETH spent. Where a router emits an explicit WETH transfer instead, that is used directly (it is exact and cheaper).

Sells: WETH transfer logs from the receipt

Sells are harder. Alchemy only indexes internal transfers on Ethereum and Polygon mainnet — not on Orbit chains. So the ETH you receive from a sell is invisible to the Transfers API.

The fix is to read the transaction receipt and scan its logs for Transfer events emitted by the WETH contract, taking the largest. Note this deliberately does not require to == wallet: on a sell the router receives WETH from the pool and unwraps it to native ETH before forwarding, so the log's recipient is the router. The largest WETH movement in a single-hop sell is the swap amount.

Transfer(address,address,uint256)
topic0 = 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef

WETH (Robinhood Chain) = 0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73

If a pool traded native ETH directly and emitted no WETH log, the fallback is a balance delta: read eth_getBalance at block-1 and block, then add back gasUsed × gasPrice to isolate trade proceeds from gas cost.

Why this matters:an earlier version priced sells by applying each token's average buy price to the amount sold. That silently assumes the price never moved — which forces realized PnL toward zero and makes every trader look flat. Reading real proceeds from the chain is the difference between plausible-looking numbers and true ones.

Metrics engine

All metrics are denominated in ETH, not USD. This is deliberate: Robinhood Chain is an ETH-gas L2 and memecoin liquidity is paired against WETH, so ETH is the chain's natural unit of account. It also avoids depending on price feeds that have no coverage of day-old tokens.

Realized PnL — FIFO lot matching

Positions are closed first-in-first-out, per token. Each sell is matched against the oldest open buy lots; the cost basis consumed is subtracted from proceeds to produce realized PnL. A trade counts as a win if its realized PnL is positive after fees.

for each SELL of token T, qty Q:
    remaining ← Q
    basis     ← 0
    while remaining > 0 and lots[T] not empty:
        lot   ← lots[T][0]              // oldest
        take  ← min(remaining, lot.qty)
        basis += lot.cost × (take / lot.qty)
        consume take from lot
        remaining -= take
    realizedPnL += (proceeds - fees) - basis

ROI (30d)

ROI = realizedPnL / capitalDeployed, where capitalDeployed is the sum of every buy in the window — not the net position. Using net position as the denominator is a classic bug: a trader who closes everything ends up dividing by ≈0, and their ROI explodes to a meaningless number.

Other features

  • Win rate — closed trades with positive realized PnL ÷ total closed trades.
  • Max drawdown — largest peak-to-trough decline of the cumulative realized-PnL curve.
  • Trade frequency — trades per day across the active window.
  • Avg holding time— mean hours between a lot's buy and its matching sell.

Risk score

A single composite in [0, 1], built from normalized components:

riskScore = 0.35 × roiScore
          + 0.35 × winRateScore
          + 0.20 × drawdownScore     // inverted: less drawdown ⇒ higher
          + 0.10 × frequencyScore

A wallet with no trade history scores ≈ 0.20 — which correctly lands it in Bronze until it has an actual track record.

Tier assignment

Tiers are thresholds on riskScore. Notably they are not based on account size or volume — a whale who loses consistently ranks below a small account that compounds carefully. What is measured is the quality and consistency of trading.

TierMin risk score
Bronze0.00
Silver0.30
Gold0.55
Platinum0.75
Diamond0.90

Tiers are recomputed on every sync, so they track your current form rather than a one-time snapshot.

Peer matching

Each wallet becomes a six-dimensional feature vector. Features are normalized to [0, 1] against sane bounds (e.g. ROI clamped to ±100%, frequency to 25 trades/day, hold time to 72h), then weighted.

Feature weights

FeatureWeightRationale
ROI (30d)0.25Primary performance signal
Win rate0.20Consistency, independent of size
Max drawdown0.20Risk tolerance — heavily weighted
Trade frequency0.15Tempo: scalper vs swing
Avg hold time0.10Complements tempo
Risk score0.10Composite tie-breaker

Weighted cosine similarity

Weights are applied as √w to each component before the dot product, so that the resulting cosine is equivalent to a properly weighted inner product:

aᵢ' = √wᵢ · normalize(aᵢ)
bᵢ' = √wᵢ · normalize(bᵢ)

              Σ aᵢ' · bᵢ'
similarity = ─────────────────
             ‖a'‖ · ‖b'‖

Tier gating

Cosine similarity alone would happily pair a Diamond with a Bronze if their vectors pointed the same way. To prevent that, candidates are gated: same-tier matches are always allowed, and an adjacent tier is permitted only if similarity ≥ 0.85. Anything further apart is rejected outright.

Results are cached in Redis for 15 minutes and invalidated globally whenever any wallet finishes a sync — because a newly synced peer might now belong in anyone's results.

Realtime layer

Socket.IO handles two room types: tier rooms (tier:gold) for group chat, and DM rooms (dm:<idA>:<idB>, ids sorted so the name is order-independent) for 1:1 conversations. All messages are persisted to Postgres, so conversations survive refreshes, restarts, and reconnects.

Cross-process fanout

The worker runs in a separate process with no Socket.IO server of its own. When it finishes a sync it publishes to a Redis channel; the Next.js process subscribes and rebroadcasts a peers:changed event to every connected client, which then silently refetches its peer list. No polling, instant propagation — a 30-second safety-net poll exists purely as a backstop.

Security model

Authentication — SIWE (EIP-4361)

Login is a signature, never a transaction. Meet never requests token approvals, never initiates transfers, and cannot move funds. The flow:

1. POST /api/auth/nonce   → server mints a random nonce (Redis, 5min TTL)
2. Wallet signs an EIP-4361 message containing that nonce
3. POST /api/auth/verify  → server verifies:
     • signature is valid for the claimed address  (secp256k1)
     • nonce matches the one it issued             (replay protection)
     • domain matches the request host             (anti-phishing)
     • chainId == 4663                             (chain binding)
4. Nonce is consumed; httpOnly JWT session cookie is issued

The domain check is what stops cross-site replay: without it, a phishing clone could prompt you to sign a SIWE message scoped to its own domain, then replay that cryptographically-valid signature against this API and mint a session as you.

Other controls

  • Socket authorization. WebSocket handshakes are authenticated from the session cookie. Joining a DM room requires being a participant of that room — you cannot subscribe to a conversation between two other people by guessing its name.
  • CSRF. Double-submit token with timingSafeEqual comparison (a plain === leaks bytes via response timing), plus an Origin check as a second layer.
  • Rate limiting. Redis-backed, so limits are global rather than per-process. Auth endpoints are additionally limited by IP — a per-wallet limit is meaningless when the attacker can mint unlimited fresh addresses.
  • Sessions. httpOnly, Secure, SameSite=Lax JWTs, backed by DB session rows with a token version for revocation.

Stack & limits

Frontend

Next.js (App Router), TypeScript, Tailwind, shadcn/ui, Framer Motion, React Three Fiber

Web3

viem + wagmi — any EIP-1193 / EIP-6963 wallet (MetaMask, Rabby, Trust, Phantom in EVM mode)

Chain

Robinhood Chain — Arbitrum Orbit L2, chain ID 4663, ~100ms blocks, ETH gas

Data

Alchemy Transfers API + JSON-RPC (receipts, balances, transactions)

Persistence

PostgreSQL via Prisma; Redis for queues, cache, pub/sub, rate limits

Jobs

BullMQ worker — wallet sync, pricing, metrics, tiering

Realtime

Socket.IO with Redis pub/sub fanout across processes

Auth

SIWE (EIP-4361) + JWT session cookies

Known limits

  • Gas is not deducted from PnL. On an L2 it is negligible relative to memecoin trade sizes, but it is a known omission rather than an oversight.
  • Multi-hop routes are approximated. A tx with several token legs is reduced to its first pricing leg and first token leg. Almost all memecoin trades are single-hop, so the impact is small.
  • Sync depth is bounded. Up to 15,000 transfers per wallet and a 90-second on-chain pricing budget. Beyond that, remaining sells fall back to average-buy-price approximation.
  • Metrics window is 30 days. Robinhood Chain itself is younger than that, so today it effectively covers full history.