Moro SDK · ResearchReport #1

Where the SDK's RPC calls really go

@moromoro/moro-sdk 1.1.0 measured 2026-09-13 HyperEVM · BNB Chain branch feat/optimize
TL;DR

3 ใน 4 ของ call บน HyperEVM และ 1 ใน 3 บน BNB ยิงไปหา pool ที่ไม่มีอยู่จริงหรือไม่มีสภาพคล่อง — SDK สร้าง model ให้ทุกคู่ token × ทุก route โดยไม่เคยเช็คว่า pool ถูก deploy และไม่จำผลไว้ข้ามการ quote

Three quarters of the multicall inner calls on HyperEVM (one third on BNB) are spent on pools that were never created or have zero liquidity. The SDK instantiates a model for every token pair on every route without checking pool existence, and rebuilds everything from scratch on each quote. Fixing that takes HyperEVM from 26 requests per quote to 2 with byte-identical prices. Cutting correspondent tokens or routes blindly, the tempting fix, measurably worsens prices.

26
requests / quote · HyperEVM
1 HYPE → USDT0
74%
calls wasted · HyperEVM
896 of 1,216
6
requests / quote · BNB
1 BNB → USDT
36%
calls wasted · BNB
737 of 2,053
≈4
quotes / min possible
free HyperEVM RPC, 100 req/min
2
requests / quote achievable
≈50 quotes / min, same prices

1 · The problem, stated correctly

Every getQuote() does exactly one round of network I/O: it registers models, collects each model's state calls, packs them into Multicall2.tryAggregate batches, fires all batches at once, then computes routes purely in memory. So RPC cost is decided by two things only: how many models get registered and how they are batched. Split on/off, trade size, and gas price change nothing on the wire (verified).

pairs    = generateAllTokenPairs(src, dest)     HyperEVM 11 pairs (4 corr tokens) · BNB 28 pairs (8 corr tokens)
models   = Σ routes × pairs the route "supports"  every V2/V3 route claims EVERY pair → 289 / 914 models
calls    = Σ models × state calls                 V2: 1 · V3: 4 · Hybra V4: 5 · Solidly: 4 · Curve: 3 + n
requests = ceil(calls / maxCallsPerFetch) + 1     (+1 = a separate eth_call for dest.decimals())
Root cause. UniswapV2Model.getTradablePairs() returns the pair unconditionally and the V3 models compute the pool address off-chain, so nothing ever checks that the pool was deployed. A new DexManager is created per quote, so the same non-existent pools are queried again on every quote, forever.

2 · Calls per quote: useful vs wasted

Multicall inner calls in one quote. "Wasted" = pool contract does not exist (returns 0x) or responded with zero reserves / liquidity.
useful (live pool)wasted (no pool / empty)
Table view
paircallslivewastedwaste %

Every pair on HyperEVM lands at 72–77 % waste; every BNB pair among the correspondent tokens resolves to the same 28-pair matrix and the same 36 %.

3 · Which model families waste the most

livewasted
Table view
familymodelscallsokdead (0 liq.)empty (no contract)

On HyperEVM, 350 of the 572 tick-querier calls (the heaviest call type, ~500 B each) go to pools that do not exist; tick data is 91 % of the 327 KB payload. Five routes never have a usable pool for any pair tested and cost 231 calls (19 %) per quote: hybra-v4-100, laminar-v3-10000, ultrasolid-v3-10000, prjx-v3-10000, hybra-v3-7500.

4 · Batch size is mis-tuned, in opposite directions per chain

HyperEVM · requests per quote by maxCallsPerFetch

HyperEVM · fetch time (ms)

50 (today) → 200 cuts requests 3.6× at the same latency. A single 1,300-call batch reverts on the public node. Sequential batches at 50 take 7.7 s, which is why the SDK fires everything in parallel today.

BNB · fetch time (ms) by batch size

bsc-dataseed serves many small batches faster than a few big ones (100 → 1.06 s, 500 → 1.6–1.9 s, 1,000 → 4.5 s). The batch size must be a per-provider profile, not a per-chain constant.

5 · The tempting wrong fix: fewer correspondent tokens

Cutting intermediate tokens does slash RPC cost, but it removes exactly the hops that large trades need. Measured with the real getQuote():

configurationrequests1 unit100 units1,000 units
HyperEVM · 4 corr tokens (today)2679.397 USDT07,938.0779,309.14
HyperEVM · 2 corr tokens (WHYPE, USDT0)1079.388 (−0.01 %)7,930.21 (−0.10 %)78,508.06 (−1.0 %)
BNB · 8 corr tokens (today)6726.32 USDT72,604.76722,941.59
BNB · 4 corr tokens3726.3472,604.04721,588.18 (−0.19 %)
HyperEVM · drop the 5 dead routes21identicalidenticalidentical
HyperEVM · batch 2008identicalidenticalidentical
So the lever is not "fewer tokens" or "fewer routes". It is "stop paying for pools that do not exist" — a pool registry — plus batch tuning. Both leave every price unchanged.

6 · Smaller leaks

7 · Recommendations, ranked by value per effort

#changeHyperEVM reqBNB reqprice riskeffort
R1Pool registry: per-chain JSON of (route, token0, token1) → pool | null generated offline by the profiler and shipped with the SDK, plus an in-memory negative cache learned at runtime26 → 86 → 3noneM
R2Batch profile per provider: HyperEVM 200, BNB 100–250, adaptive halving on revert / 4298 → 3–noneS
R3Fold decimals() into the multicall; cache decimals in the registry−1−1noneS
R4Static-data cache with TTL (Solidly decimals/fee, Velodrome fee, Curve A/fee)–−226 callsnoneS
R5Retire the 5 dead HyperEVM routes; add a route-health job that flags routes with 0 live pools−231 calls−84 callsnone (measured)S
R6Adaptive tick window from trade size (halves payload for typical trades)bytes −50 %bytes −50 %small · verify vs QuoterV2M
R7Reusable DexManager + block-scoped state cache (re-fetch only when the head advances)repeat → 1–2repeat → 1–2noneM
R8Concurrency limiter + retry/back-off in multicallBatchesfewer 429s–noneS
R9Lightweight build: per-chain entry points, drop unused deps, delete *.optimized.ts80 KB gz → ~25–30–noneM
R10Fix the getReserves() contract; document volumns semantics (last share is the remainder)––noneS

Projected end state (R1–R5, R8)

pairtodayafterAlchemy CU / quotequotes / month, Alchemy free tier
HyperEVM 1 HYPE → USDT026 req · 1,216 calls · 327 KB2 req · ~320 calls · ~215 KB676 → 5244 k → 577 k
BNB 1 BNB → USDT6 req · 2,053 calls · 489 KB2–3 req · ~1,300 calls · ~390 KB156 → 52–78192 k → 385–577 k

Do not: cut correspondent tokens (§5), disable split (no RPC saving), or replace Multicall with per-pool calls. Suggested order: R5 + R9(deps) + R10 as 1.1.1 → R1 + R2 + R3 as 1.2.0 → R4/R6/R8 → R7 + per-chain builds as 1.3.0.

8 · RPC nodes, free and paid

After optimisation a quote needs 2–3 eth_call batches of ≤ 200–500 calls. Choose on batch tolerance, requests/second and HyperEVM support; archive/trace/WebSocket do not matter here.

HyperEVM (999)free tierpaid fromnote
rpc.hyperliquid.xyz/evm (official)~100 req/min per IP, no signup–≈4 quotes/min today → ≈50 after R1–R3; accepts 500-call batches, rejects ~1,300
Alchemy30 M CU/month permanent, ~300 rps$5 PAYGbest free tier; CU model rewards fewer, larger batches
dRPCpublic/aggregated, 40–250 rps~$6 / 1 M reqgood for testing, latency varies
Chainstack3 M req/month, 25 rps$49/moalso HyperCore
QuickNode1-month trial$49/molowest latency in third-party comparisons
HypeRPC (Imperator)10 k req/mo, 5 rps$99/moHyperliquid-only
BNB Chain (56)free tierpaid fromnote
bsc-dataseed*.binance.org (official)10 k req / 5 min, no eth_getLogs–prefers 100–250-call batches; fine for the optimised SDK
NodeReal MegaNodefree tierpaid tiersBNB Chain's partner, lowest p95 on BSC in public benchmarks — production pick
Ankr500 M CU/day publicpaidgenerous, slower
Alchemy300 M CU/month$5+same CU maths as above
PublicNode / 1RPC / dRPC publicfree–fallbacks for the UI

9 · Method and reproduction

benchmark/profile.sh --chain hyperevm --src HYPE --dest USDT0 --live-only
benchmark/profile.sh --chain bsc --batch 100 --corr 4 --json out.json
npx ts-node -T -O '{"module":"commonjs"}' benchmark/quote-quality.ts --chain hyperevm --drop hybra-v3-7500

Full write-up with every table: INVESTIGATE.md on branch feat/optimize at korrio/moro-sdk-optimized. Production SDK on npm is unchanged: @moromoro/moro-sdk.

10 · How every next round will be judged (A/B protocol)

Each optimisation round is measured by benchmark/ab-test.ts: the published npm SDK (A) and the candidate build (B) run the identical workload (3 pairs × 1 / 100 / 1,000 units per chain) with every eth_call pinned to the same block, interleaved with alternating order, N repetitions, medians reported. The gate is on price: B fails if its amountOut is worse than A's by more than 5 bps on any case. Cost improvements are reported, never assumed.

metricdefinitionpass condition for a round
Δ price (bps)(B − A) / A × 10,000 at the same block≥ −5 on every case (0 expected for R1–R5)
requestsJSON-RPC requests per quote↓ (the headline number for rate limits and billing)
inner calls · empty/failedcalls inside multicall batches · those returning 0x or revertingwaste → 0
bytes · median msresponse size · wall time over N runsnot worse; latency judged on a private node only

Protocol, metric definitions, per-round template, and pros/cons of R1–R10: benchmark/AB-TESTING.md in the optimize repo. Results of each round are committed under benchmark/results/ and summarised as Report #2, #3, … on this site.

← All reports Report #1 · RPC cost investigation · Moro SDK research