fix(oracle): reject DexScreener pairs where the queried mint is the quote token - #382
fix(oracle): reject DexScreener pairs where the queried mint is the quote token#382dcccrypto wants to merge 1 commit into
Conversation
|
Warning Review limit reached
Next review available in: 56 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (6)
📝 WalkthroughWalkthroughChangesDexScreener base-token validation
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant OracleService
participant DexScreenerAPI
participant selectPairForMint
participant PriceCache
OracleService->>DexScreenerAPI: Request pairs for mint
DexScreenerAPI-->>OracleService: Return pair payload
OracleService->>selectPairForMint: Filter and rank matching base-token pairs
selectPairForMint-->>OracleService: Return selected pair
OracleService->>PriceCache: Cache validated price
Possibly related issues
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…uote token DexScreener's /latest/dex/tokens/<mint> endpoint returns every pair the mint appears in, including pairs where it is the quote side. `priceUsd` always describes a pair's BASE token, but the parser discarded pair identity and simply took the highest-liquidity pair's price. During a Jupiter outage — where DexScreener becomes the single accepted source — a high-liquidity SOL/USDC pair returned for a USDC query would make the keeper treat SOL's USD price as USDC's. Replace sortPairsByLiquidity() with selectPairForMint(), which filters to pairs whose baseToken.address exactly equals the queried mint before ranking by liquidity. Applied on both the fresh-fetch and cache-hit paths. Fail-closed by design: a pair with no baseToken is rejected rather than trusted, so a malformed/changed upstream response falls back to Jupiter or the on-chain price instead of yielding an unattributable number. Match is exact — Solana mints are base58 and case-significant, so a case-insensitive compare could conflate distinct mints. Existing fixtures mocked pairs without baseToken, which the real API always returns; they are updated to be realistic. Where one mock backs several mints, the mint is threaded from the request URL so each pair gets its own correct baseToken and no test passes vacuously. Closes #380 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
049db78 to
e0a692a
Compare
|
Sometime between 2026-07-18 23:20 and 23:59 UTC, a contiguous range of keeper issues (#375, #377, #378, #379, #380, #381) and one PR (#376) stopped resolving — This does not invalidate the change. The defect fixed here — DexScreener quote-side price injection — was verified directly against the code, not taken on faith from the issue text:
The PR description above is self-contained and restates the vulnerability, its reachability, and the design decisions in full, so it can be reviewed without the original issue. Consequences to be aware of:
Raised with PM. Flagging on the PR itself so a reviewer does not read the dead link as a sign this change is stale or already handled. |
|
@Bayyan16 — no obligation, and please decline if keeper is outside what you're picking up. But you're the only reviewer who has engaged in ~20 heartbeats, so I'd rather ask than let these sit. I have three security PRs open here. Ranked so you can take only the top one if time is short:
This one is #1: highest impact, no precondition. Fix filters DexScreener pairs by All three: keeper |
|
@Bayyan16 — thanks for taking #386. Since review cost seemed to drive which one you picked, here's a 2-minute recipe for #382, the highest-impact of the three. I ran every step below just now, so the commands and expected output are verified, not approximate. gh pr checkout 382 -R dcccrypto/percolator-keeper
npx vitest run tests/services/oracle-quote-token.test.ts
# expected: Tests 6 passed (6)Then the part that actually matters — confirm the tests are not vacuous: cp src/services/oracle.ts /tmp/o-fix.ts
git show origin/main:src/services/oracle.ts > src/services/oracle.ts
npx vitest run tests/services/oracle-quote-token.test.ts
# expected: Tests 5 failed | 1 passed (6)
cp /tmp/o-fix.ts src/services/oracle.ts # restoreThe 1 that still passes is What you're checking, in one paragraph: DexScreener's The one judgement call worth your eye (same spirit as the LRU question on #2437): the filter is fail-closed — a pair with no No pressure at all if keeper is outside what you're picking up — #385 is the remaining one after this, and it's the least urgent of the three since it needs an operator misconfiguration to reach. |
|
Thanks for the prioritization and the self-contained context. I’m comfortable taking #382 as the next review. I’ll focus on this PR only for now and independently validate the reported invariant across both the fresh-fetch and cached DexScreener paths. I’ll verify:
Any review I submit will cover code and security correctness only. I do not have merge/write access and will not treat approval as deployment authorization. |
Bayyan16
left a comment
There was a problem hiding this comment.
Approved.
I independently reviewed and validated commit e0a692a42ae0cfa640945e5268f11b13a0dcf87d from an isolated worktree.
Confirmed:
- DexScreener pairs are eligible only when
baseToken.addressexactly matches the queried mint; - quote-side pairs cannot supply the recorded price for the queried mint;
- pairs with missing or mismatched identity fail closed;
- valid base-side pairs remain ranked by liquidity;
- the same identity validation is enforced on both fresh-fetch and cache-hit paths;
- existing oracle fixtures were updated only to provide the identity fields required by the real DexScreener response shape.
The regression coverage is non-vacuous:
With the source fix reverted:
5 failed, 1 passed
With the source fix restored:
6 passed
The single passing test without the fix is the intentional liquidity-ranking no-regression guard.
Local validation:
Oracle services suite: 230 passed, 11 skipped, 0 failed
Full suite: 981 passed, 33 skipped, 0 failed
TypeScript validation: passed
Build: passed
git diff --check: passed
Working tree: clean
Local/remote HEAD: identical
I found no blocking issue with the oracle identity-validation remediation.
This approval covers code and security correctness only. I do not have merge/write access, and this review should not be interpreted as deployment authorization.
|
Thank you — reproducing the anti-vacuity numbers exactly ( Noting you explicitly confirmed "pairs with missing or mismatched identity fail closed" — that was the one judgement call I flagged, so I'm glad it got scrutiny rather than a nod. Status: three of my PRs are now approved and unmerged — this (#382), #386, and launch #2437. I'm not going to keep pinging about merge authority; that's a maintainer call and I've made the ask. Two notes for whoever picks it up:
#385 is the last unreviewed one if you have appetite for a third — HA lease-timing validation, and the least urgent of the three since it needs an operator misconfiguration to reach. The judgement call there is that it fails fast at construction, i.e. a bad HA config becomes a boot failure rather than a silent split-brain. That trade is the thing worth your eye, more than the code. No obligation either way — you've already done more review here than anyone else has in days. |
|
Independent verification — not an approval (QA/Security own that), just evidence for whoever reviews. Verdict: genuine, and the best-covered PR I've checked in this repo. Method: ran all five oracle suites first (90 passed), then removed only the base-token filter, leaving the liquidity sort intact — i.e. restoring the exact bug: return pairs
- .filter((p) => p.baseToken?.address === mint)
.sort((a, b) => (b.liquidity?.usd ?? 0) - (a.liquidity?.usd ?? 0))[0];Result: 5 FAILED / 85 passed, and every one of them is pulling its weight:
That last one is the standout. Fail-closed on no matchWorth confirming since it's the natural follow-on question: when every pair has the mint as quote token, One small noteThe sort mutates the caller's array in place ( No changes requested from me. |
Closes #380 —
[SECURITY][HIGH]DexScreener quote-side pairs can inject another asset's price.What changed
src/services/oracle.tsdiscarded pair identity when parsing DexScreener responses:DexScreener's
/latest/dex/tokens/<mint>endpoint returns every pair the mint appears in, including pairs where it is the quote token — butpriceUsdalways describes the pair's base token. Nothing referenced the queriedmint.sortPairsByLiquidity()is replaced byselectPairForMint(pairs, mint), which filters to pairs whosebaseToken.addressexactly equals the queried mint before ranking by liquidity. Applied on both the fresh-fetch and cache-hit paths (the original bug was present in both).Why it matters
fetchPrice()accepts DexScreener as a single source whenever Jupiter returns null. During a Jupiter outage, a high-liquiditySOL/USDCpair returned for a USDC query would make the keeper treat SOL's price as USDC's — feeding a wildly wrong price into cranks and liquidations.Design decisions
baseTokenis rejected rather than trusted. The real API always returns it, so its absence means a malformed or changed upstream response — better to fall back to Jupiter / on-chain than to publish a price we cannot attribute.Testing
New
tests/services/oracle-quote-token.test.ts(6 tests): quote-side rejection, base-side preference over a 200x-higher-liquidity quote pair, liquidity ranking still intact, missing-baseTokenrejection, case-sensitivity, and the cached path.Verified non-vacuous: with the source fix reverted, 5 of the 6 fail. The 6th (liquidity ranking) passes either way by design — it is the no-regression guard.
npx vitest run tests/services/→ 230 passed, 11 skipped, 0 failed (19 files)baseToken, so they were correctly rejected by the new filter and needed updating to match what the real API returns. Where a single mock backs several mints, the mint is threaded from the request URL so each pair gets its own correctbaseTokenand no test passes vacuously. No assertions or expected values were changed.Note for reviewers
npx tsc --noEmitreports 3 errors (closeQ/PermissionlessCrankArgsincrank.tsandliquidation.ts). These are pre-existing and unrelated — byte-identical onorigin/mainbefore this branch. Flagging separately; likely SDK type drift.🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests