Skip to content

feat(graphql): Blend positions, pools, and earn discovery#661

Open
aditya1702 wants to merge 18 commits into
blend/pr4-price-snapshotfrom
blend/pr5-graphql
Open

feat(graphql): Blend positions, pools, and earn discovery#661
aditya1702 wants to merge 18 commits into
blend/pr4-price-snapshotfrom
blend/pr5-graphql

Conversation

@aditya1702

@aditya1702 aditya1702 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Blend GraphQL: positions, pools, earn discovery

Fifth and final stacked PR adding Blend Capital v2 lending support (stacked on #660).

  • Rate/valuation math (rates.go, pure functions, big.Rat/big.Int): three-slope reactive curve, supply capture, APR→APY, rate projection, underlying conversion, emissions APR, claimable emissions, earned interest, backstop LP conversion, USD scaling. Every scalar/convention verified against blend-contracts-v2 @ ba22b487 and blend-sdk-js @ af62659, with live-mainnet vectors (simulated get_reserve/get_config/get_reserve_emissions) and contract-test bit-exact fixtures.
  • Readers — per-account/per-pool scoped reads, GetBackstopLPPrices (self-priced-row discrimination), GetLendingClaimTotals (per (poolId, source); backstop rows group under NULL pool), GetTokenMetadataByContractIDs.
  • LENDING taxonomyLENDING category + 15 reasons in the schema; LendingChange implements BaseStateChange with tokenId/amount/poolId (from key_value).
  • Account.blendPositions — single-pass batch assembly, no dataloaders: token amounts at rates projected to now, USD values, APYs, per-reserve claimable emissions + interest earned/paid (cost-basis columns parsed as decimals) with the asset's oracle priceUsd for client-side USD conversion of token-denominated fields, backstop shares → LP tokens → USD with queued-for-withdrawal shares valued in the totals and per BlendQ4W entry (queued shares stay interest-earning, slashable first-loss capital; only emissions accrue on active shares alone).
  • Query.blendPools / blendPool — pool catalog with supplied/borrowed/backstop USD and supplied-USD-weighted interestApy/netApy; missing prices propagate null, never error.
  • Query.blendEarnOptions — asset-first grouping; disabled reserves and supply-rejecting pools (status ≥ 4 — Frozen/Setup — or status not yet ingested) excluded; each pool option carries supplyApy + emissionsSupplyApr so a wallet can show the emissions-inclusive earn rate; pools per asset ordered by supplied USD desc (nil last, address tie-break).
  • ComplexityblendPools/blendEarnOptions ×50, Account.blendPositions ×10; derived worst cases: blendPools ≈ 1450, blendPositions ≈ 370 — both ≪ the 6000 prod limit. No existing complexity entry touched; a regression test guards AccountTransactionEdge.operations/stateChanges (freighter full-detail budget).

Deviations register (reviewer sign-off):

# Item
8 APR→APY is asymmetric in blend-sdk-js: borrow compounds daily (365), supply weekly (52) — ToAPY(apr, periods) + two constants (plan assumed uniform weekly)
8b Emission eps/index are uniformly 1e14; the per-reserve part is the CLAIM divisor (10^decimals · 1e7) — ClaimableEmissions takes an explicit scalar
new Schema deltas vs plan, forced by on-chain event shapes: position-level emissionsEarnedBlnd = claimable (uncollected) BLND; lifetime claimed emissions surface as BlendPoolPosition.claimedBlnd (pool-source, BLND) and BlendAccountPositions.backstopClaimedLp (aggregate, Comet LP tokens — the backstop auto-swaps claims into its LP token, and backstop claim events carry no pool addresses)
new getDBColumns gained a poolId → key_value field mapping — without it the JSONB column was never selected and LendingChange.poolId always resolved null (caught by the integration test)
new blendEarnOptions.tokenDecimals sources from contract_tokens metadata (reserve-config decimals only as fallback) — reserve-order-dependent value caught as a flake in the full-suite run
2/3 net_* cost-basis columns may be scale-16 decimal strings (auction valuation ÷1e12) — resolvers parse via ParseDecimalToBigInt (truncating sub-token dust)

Manual run procedure + smoke queries per the plan: migrate up → restart ingest → protocol-setup --protocol-id BLENDprotocol-migrate history/current-state --protocol-id BLEND --start-ledger <v2 deploy ledger>.

🤖 Generated with Claude Code


Post-review fixes (verified against blend-capital/blend-contracts-v2 @ ba22b487, blend-sdk-js, and docs.blend.capital):

Fix Commit
backstopClaimedBlndbackstopClaimedLp: backstop claims pay Comet LP tokens (7 decimals), not BLND — the backstop auto-swaps and re-deposits the claim d9c7779f
claimableStream pays the contract's historical-accrual branch: a holder with a balance but no UserEmissionData row gets balance·index/scalar, not 0 4fa7b2ef
Utilization clamped at 100% matching reserve.rs — unclamped, a bad-debt reserve displays unbounded APY b4a31ee0
Oracle prices older than 24h are treated as missing, so USD/APY fields go null exactly like a never-priced asset. Matches the pool contract's own rejection window (pool.rs::load_price); shares the MaxPriceAge constant with #660's snapshot-side guard 33c45f59
blendEarnOptions excludes supply-rejecting pools: status ≥ 4 (Admin Frozen / Frozen / Setup all refuse deposits on-chain) or status not yet ingested. On-Ice pools (2–3) stay — the contract accepts deposits there 24cf1d80
Backstop lpTokens/usdValue include queued-for-withdrawal shares — per Blend's docs, queued shares keep earning pool interest and remain slashable first-loss capital until withdrawn; only emissions stop. Each BlendQ4W entry gains its own lpTokens/usdValue; shares stays active-only 6cb742f7
findBackstopPrices picks deterministically (lowest oracle key) and error-logs if a second self-priced Comet group ever appears, instead of flapping with Go map-iteration order 52b6b030
Schema doc comments: 7-decimal fixed-point scales (backstopRate, cFactor, lFactor), the status 0–6 encoding and its supply/borrow permissions, and the 24h price-staleness convention 1f6ca7ab

Note for reviewers cross-checking docs.blend.capital: its "rate modifier has 9 decimals" claim is stale v1 wording — the deployed v2 contract uses SCALAR_7 (interest.rs), which is what the math here implements.

@aditya1702 aditya1702 force-pushed the blend/pr5-graphql branch from 63bc4a8 to 16c5afb Compare July 9, 2026 00:30
@aditya1702 aditya1702 force-pushed the blend/pr4-price-snapshot branch from d86bb20 to a1197eb Compare July 9, 2026 00:30

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 16c5afb605

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/serve/graphql/resolvers/blend_positions.go
Comment thread internal/serve/graphql/resolvers/blend_earn.go Outdated
…data

Classification inserts contract_tokens rows with decimals 0 before RPC
enrichment; blendEarnOptions reported 0 for such assets. Metadata decimals
now apply only once the row is genuinely enriched (name set or nonzero
decimals); the reserve config's on-chain copy is the fallback.
reserve.rs's utilization is explicitly capped ("This is capped at 100% to
ensure interest calculations are fair") with branch order: zero liabilities
first, then liabilities >= supply clamps to exactly 1. Unclamped, a bad-debt
reserve (liabilities exceeding supply — a reachable state the contract has a
dedicated test for) pushed BorrowAPR's >95% slope past its [0,1] domain and
displayed an unbounded borrow/supply rate. Zero supply with outstanding debt
now also reads 100% instead of 0.
…stream

distributor.rs's update_user_emissions has a third branch: a user holding
tokens with NO UserEmissionData entry is owed balance*index/scalar the first
time their emissions are touched ("user had tokens before emissions began").
claimableStream returned 0 whenever the account had no blend_emissions row,
understating claimable BLND for every holder of a reserve (or backstop pool)
whose emission stream was configured after they supplied. With no user row
but a configured stream and a positive balance, the full config index now
accrues from a zero starting point — mirroring the contract exactly.
The backstop claim event's amount is execute_claim's return — the Comet LP
tokens minted and auto-deposited — never raw BLND (the decoder now records
those rows with a NULL token_id and units backstop_lp). The account-wide
lifetime total sourced from those rows is therefore LP-denominated:
backstopClaimedBlnd is renamed to backstopClaimedLp and documented as Comet
LP tokens at 7 decimals. Pre-release schema, no deprecation shim.
The pool contract refuses prices older than 24h (pool.rs::load_price),
so a staler blend_oracle_prices row cannot honestly value anything — but
the resolvers applied stored prices unconditionally, pricing every
USD/APY field off a dead oracle's last snapshot forever. Filter rows
past blendrates.MaxPriceAge (exported from the snapshot service so both
sides share one constant) when building the price lookups, making a
stale price null the same fields a missing one does.
The pool contract refuses supply for status >= 4
(pool.rs::require_action_allowed): 4 Admin Frozen, 5 Frozen, 6 Setup.
blendEarnOptions filtered only on reserve.Enabled, so a frozen or
still-in-setup pool's enabled reserves were advertised as earn
destinations the chain would reject. Gate reserves on the pool's status
(< 4, per docs.blend.capital's status lifecycle); a NULL status — config
entry not ingested yet — can't be confirmed eligible and is excluded
too. On-Ice pools (2-3) stay visible: they accept deposits.
Queued-for-withdrawal shares keep earning pool interest and remain
slashable first-loss capital until withdrawn (docs.blend.capital
backstop deposit-management) — only emissions stop accruing on them. But
BlendBackstopPosition.lpTokens/usdValue were computed from the active
share balance alone, so a user's backstop value dropped by the queued
amount the moment they queued, understating their at-risk capital for
the whole 17-day queue. Value active+queued in the totals and give each
BlendQ4W entry its own lpTokens/usdValue through the same shares→LP→USD
chain; shares stays active-only and emissions still accrue on active
shares only, both now documented in the schema.
…uity

findBackstopPrices returned the first complete (self-priced LP, BLND
sibling) group Go's map iteration happened to visit, so a second
self-priced Comet group in blend_oracle_prices — a misconfigured
Comet/oracle address, since Blend v2 has exactly one backstop token at a
time — would make backstop valuations flap between requests. Pick the
lowest oracle key instead and log the ambiguity as an error rather than
failing the whole query over a config mistake.
…eness

blend.graphqls handed clients three 7-decimal fixed-point fields
(backstopRate, cFactor, lFactor) and the raw 0-6 status int with no
scale or encoding documented — docs.blend.capital's vocabulary now backs
each description. Also states the 24h price-staleness convention at the
top and replaces the earn-option description's stale task references
with the actual supply-eligibility contract.
…ion price

The wallet's earn list shows the emissions-inclusive yield per token
(supply APY + BLND emissions — the number Blend's UI marks with an
asterisk), but BlendEarnPoolOption only carried the interest-only
supplyApy. Add emissionsSupplyApr, computed by the same emissionsAPRFor
path the pool catalog uses, with the earn resolver now batch-loading
reserve emissions and the Comet BLND price.

The per-token earnings view also needs interestEarned (token units) in
USD; BlendReservePosition carried emissionsEarnedUsd but no way to value
the interest side. Expose priceUsd — the reserve asset's oracle price —
so any token-denominated field converts client-side.
@aditya1702 aditya1702 force-pushed the blend/pr4-price-snapshot branch from 9d078d4 to 23486c9 Compare July 10, 2026 11:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant