fix: keep on-chain pegged prices; never use CoinGecko for pegged coins - #175
Conversation
ALKIMI/UP are pegged at 0 via oracle::set_fixed_price and mirrored into coin_info.pyth_price by the ws cron. The truthiness gate on pythPrice treats the pegged 0 (a JS number at runtime) as missing and substitutes the CoinGecko market quote, which inflates totalSuppliedUsd, safeBorrowLimit, and the weighted liquidation threshold. resolveCoinPrice (src/utils/price.ts) now carries a PEGGED_COIN_TYPES allowlist: pegged coins take pythPrice only, zero included, and never fall back to CoinGecko. Non-pegged coins keep the old rule. All five fallback sites use the helper.
Same thin caller the other repos use; delegates to AlphaFiTech/alphalend-workflows claude-review.yml@main.
This reverts commit 7d66e4c.
Review follow-up: a Market.getMarketData fixture test proves the wiring (pegged zero survives, non-pegged zero still falls back), a positive-peg assertion covers the accept side of the >= 0 bound, and the PEGGED_COIN_TYPES doc records that pegged prices carry no freshness signal in this SDK.
resolveCoinPrice took the metadata and the coin type as two arguments, so a caller could pass a mismatched pair. The coin type is already on the metadata object. Read it from there. The return type stays Decimal | null. A raw pythPrice return would spread the CoinMetadata type error to every call site. The field is declared string | null, but the GraphQL schema says Float and the wire carries a JSON number. TypeScript would then permit string operations that throw at runtime. Verified against the live prod API: of the 52 coins it returns, only ALKIMI and UP change, and the other 50 match main exactly.
ReviewThe core fix is correct. Verified against prod One blocking issue. A zero price produces
|
The resolver makes 0 a legal price, and three paths assumed it never was. flashRepay divides the withdraw value by the withdraw market's price: at 0 the raw amount is Infinity and the supply cap turns that into 'withdraw the entire collateral balance', so refuse to build the transaction instead. The reward-APR paths divide by liquidity value scaled by the market price: at 0 the APRs go NaN and poison every portfolio aggregate they touch, so skip reward APRs with a warn. The old truthiness guards on these paths were dead — a Decimal is always truthy — and are deleted. An unpriced reward coin stays in the list at $0 / 0% APR to match the Rust SDK, whose numbers ws serves. Export resolveCoinPrice and PEGGED_COIN_TYPES so consumers can adopt the rule instead of copying the list. Tests fail when any guard is reverted (verified one by one). README documents that MarketData.price can now be exactly 0.
Re-review of 0cc2c41The finding is fixed, and the commit found a third divisor I had missed. Verified on this head: 71/71 tests across 8 suites, The original failure mode is gone. Re-ran the fixture that previously produced The guards are genuinely pinned. Reverted each one individually and re-ran the suite:
flashRepay was the more serious of the three. Also confirmed dropping Nothing blocking left. Two notes:
Full sweep of |
|
Merge/deploy notes: independent of the Rust-side ordering — this SDK is consumed by the FEs via |
jangid
left a comment
There was a problem hiding this comment.
operate.md: clean — CI green (test / lint / build), head 0cc2c41. Not a contract PR. Zero review threads open.
Re-derived against the current diff rather than the replies. src/utils/price.ts is the single resolution point: toFinite normalises the runtime-number/declared-string mismatch, the peg branch admits >= 0 and never reaches CoinGecko, the non-pegged branch keeps > 0 with the fallback, and the peg check reads coinMetadata.coinType (not the caller's map key), which is what keeps the long-form SUI alias out of it. Both getPrice implementations (market.ts, position.ts) now route through it. The three divisor guards are present and use !gt(0) rather than lte(0) — correct, since decimal.js cmp returns NaN for a NaN operand, so lte would let a NaN through into the division.
Nothing new to raise; every finding on this PR is closed on the record.
One 🟢 note carried, not a blocker and already stated on the PR: position.ts still has one lte(0) price guard, which is safe today only because resolveCoinPrice filters non-finite values to null upstream. Worth flipping to !gt(0) so the file reads to one rule.
Approvals: 2/2 — merge is a maintainer call. Consumed by the FEs via file: dep, so it lands on their next build; no publish gate.
Problem
ALKIMI and UP are pegged at 0 on-chain (
oracle::set_fixed_price, 2026-08-04). The wsupdate_pricescron mirrors the peg intocoin_info.pyth_priceevery ~10 s. The SDK gates its price fallback onif (pythPrice), but the GraphQL Float arrives as a JS number at runtime — the declaredstring | nulltype is wrong — so a pegged0is falsy and silently falls through tocoingeckoPrice.The portfolio math then counts phantom collateral.
totalSuppliedUsd,safeBorrowLimit, and the weighted liquidation threshold (ALKIMI threshold 69, UP 60) all inflate, sogetUserPortfolioreports a position as safer than the chain enforces.getAllMarketsDataprices the wound-down markets at the CoinGecko quote instead of 0.Fix
New
resolveCoinPriceinsrc/utils/price.tswith aPEGGED_COIN_TYPESallowlist (ALKIMI, UP):pythPriceonly, zero included. It never uses CoinGecko. A missing mirror value givesnull, not a market-quote substitute.All five fallback sites now use it:
Market.getPrice,Position.getPrice, the rewards USD helper, and both swap-quote USD estimates. The swap guards still skip a 0 price instead of dividing by it.Keep
PEGGED_COIN_TYPESin sync with alphalend-sdk-rustsrc/blockchain/coin_registry.rsand the liquidator'sprice_feed_config.rs.Verification
__tests__/pegged-price.test.ts: pegged zero survives; a pegged coin with no mirror value returnsnull, never CoinGecko; non-pegged behavior unchanged; number and string shapes both handled.npm run buildclean.Companion Rust fix: AlphaFiTech/alphalend-sdk-rust#222 (same allowlist, same rule).