Skip to content

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

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

feat(graphql): Blend positions, pools, and earn discovery#661
aditya1702 wants to merge 32 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 and pools

Final stacked Blend v2 PR (on #660). Adds the GraphQL read surface for Blend lending: account positions, a pool catalog, and the Blend state-change types.

What this adds

  • Account.blendPositions — the account's lending/collateral/borrowing per pool and reserve, backstop deposits with queued withdrawals, claimable BLND emissions, lifetime claimed totals, interest earned/paid to date, and any open Dutch auctions on the account.
  • Query.blendPools / blendPool(address) — pool catalog: per-pool status, admin, reward-zone flag, supplied/borrowed/backstop USD, weighted APYs, and every reserve's rates, sizes, and prices.
  • Eight concrete state-change types under the schema-wide (category = object, reason = action) convention — BlendSupplyChange, BlendCollateralChange, BlendDebtChange (BORROW/REPAY/FLASH_LOAN/BAD_DEBT/BURN, with per-reason amount denomination documented on the type), BlendAuctionChange (FILL, with auctionType, fillPercent, counterparty, and first-class lot/bid amount lists), BlendEmissionsClaimChange, BlendBackstopEmissionsClaimChange, BlendBackstopChange, and BlendBackstopQueueChange. Every non-null field is always meaningful for its type; unmatched (category, reason) rows surface as errors, not null nodes.
  • All rate and valuation math lives in internal/services/blend/rates.go as pure functions, verified against blend-contracts-v2 @ ba22b487 and blend-sdk-js @ af62659 (details below).

Conventions

Convention Value
USD / APY fields Float, null when the value is uncomputable (missing or stale price)
Token amounts String raw integers at the token's native decimals
Price staleness prices older than 24h count as missing — same window the pool contract enforces
BlendPool.status BlendPoolStatus enum; null until ingested
Fixed-point scales backstopRate, cFactor, lFactor are 7-decimal fixed point
APR→APY borrow compounds daily (365), supply weekly (52) — the SDK's convention
netApy (position) (Σ supplied·supplyApy − Σ borrowed·borrowApy) / Σ supplied, 0 when nothing supplied — the SDK/UI convention
Complexity every Blend list level carries its own bound: catalog ×50, per-account lists ×10, reserves and auction bid/lot ×30, q4w ×20

Math audit

Every formula was diffed against the contract source (authority) and the JS SDK (cross-check), adversarially — including edge states. Where the SDK and contract disagree, the contract wins.

Area Result
Interest curve, supply capture, APR→APY, utilization clamp matches the contract; exact-rational arithmetic differs from on-chain fixed-point by <1e-5 APR, always in the contract's favor
Rate projection to "now" matches Reserve::load, including the zero-utilization short-circuit and the unclamped bad-debt bRate accrual
Emission claimables stream indexes are projected last_time → now exactly like the distributors (reserve: over token supply; backstop: over unqueued shares); contract test vectors pinned bit-exact
Emission claim divisor 10^reserve_decimals · 1e7 per the contract — the SDK's uniform 1e14 is only right for 7-decimal tokens
Backstop valuation shares→LP→USD matches; queued withdrawals stay in the headline value (still slashable first-loss capital) but earn no emissions
Comet LP pricing NAV per share from actual pool weights — algebraically equal to the SDK's usdc×5/totalShares for the 80/20 pool
Debt-side rounding underlying debt truncates instead of the contract's ceil: at most 1 stroop low, display-only

Notes for reviewers

  • Backstop LP / BLND prices are read only from the pinned BLEND_BACKSTOP_LP_CONTRACT_ID oracle group — a permissionless pool can't spoof the protocol-wide prices with a self-priced row.
  • claimedBlnd / backstopClaimedLp read current-state accumulator tables, not history sums, so retention can't undercount lifetime totals.
  • Full-selection complexity worst cases: blendPools = 26,150, blendPositions = 7,572. Admitting full selections needs a deployment-side GRAPHQL_COMPLEXITY_LIMIT raise (recommendation: 30,000). Existing non-Blend entries are untouched, so the freighter full-detail budget is unchanged.
  • docs.blend.capital's "rate modifier has 9 decimals" is stale v1 wording; the deployed v2 contract uses 7 decimals (SCALAR_7), which is what this implements.

Deploy

  • api/serve needs BLEND_BACKSTOP_LP_CONTRACT_ID in its env (ingest already has it).
  • GRAPHQL_COMPLEXITY_LIMIT raise as above.
  • Run order unchanged: migrate up → restart ingest → protocol-setup --protocol-id BLENDprotocol-migrate history/current-state --protocol-id BLEND --start-ledger <v2 deploy ledger>.

@aditya1702
aditya1702 force-pushed the blend/pr5-graphql branch from 63bc4a8 to 16c5afb 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
Comment thread internal/serve/graphql/resolvers/blend_positions.go
Comment thread internal/serve/graphql/schema/blend.graphqls Outdated
Comment thread internal/serve/graphql/schema/blend.graphqls
@JiahuiWho

Copy link
Copy Markdown
Contributor

With the null-propagation and weighting rules, can we add totalUsdValue + netApy to BlendAccountPositions, computed in the resolver from the per-pool values?

…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.
Read claimedBlnd/backstopClaimedLp from the PoolClaimed/BackstopClaimed
current-state accumulators (GetByAccount) instead of aggregating LENDING/CLAIM
history rows. Remove StateChangeModel.GetLendingClaimTotals and LendingClaimTotal.
The CLAIM history rows are still written and feed the LendingChange activity feed.
…variant

The blend account-positions, pool-catalog, and earn-options resolvers each issued
their mutually-independent reads serially. Group them into errgroup tiers keyed by
their shared dependency (account -> pool -> oracle/asset), so each tier's reads run
concurrently while the barriers between tiers preserve data dependencies. Correctness
is unchanged; this cuts read round-trip latency (the account path drops from ~12
serial round-trips to 3 concurrent waves).

Also pin the Comet two-row invariant in findBackstopPrices: the snapshot writer stores
exactly the self-priced LP row and its BLND sibling per oracle, so document that and
log loudly if a group ever has more than one sibling (which would make BLND-row
selection ambiguous).
…laim math

Claimable BLND was computed against the stored emission index, which is
only as fresh as the stream's last on-chain touch — idle streams
under-reported until someone else interacted. ProjectEmissionIndex
replicates the contract distributors' update_emission_data step
(blend-contracts-v2 @ ba22b487, pool and backstop distributor.rs; mirrored
by blend-sdk-js Emissions.accrue): index + floor(min(now,expiration)-
last_time)*eps*scalar/supply with the contract's exact guards. Pool
streams project over the side's raw b/dToken supply at 10^decimals;
the backstop projects over unqueued shares (shares - q4w) at 1e7.
Contract-test vectors (test_update_emission_data_past_exp/_rounds_down)
pinned bit-exact in rates_test.go.
emissionsAPRFor call sites passed the stored BRate/DRate while every
sibling token/USD figure on the same types is valued at the projected
PB/PD, so emissions APRs were overstated after idle accrual. All
surviving call sites (position reserve, pool catalog) now pass PB/PD.
…d-sdk-js

BlendPoolPosition.netApy divided the weighted APY spread by the net
position value (supplied - borrowed), which diverges from blend-sdk-js's
PositionsEstimate — the convention the Blend UI displays — and explodes
as a leveraged position's net value approaches 0. Now
(Σ suppliedUsd·supplyApy − Σ borrowedUsd·borrowApy) / Σ suppliedUsd, with
the SDK's supply-less rule: exactly 0 (debt is forgiven as bad debt).
…eams

Blend runs two independent emission streams per reserve; the single
emissionsApr picked the bToken side whenever supply/collateral existed,
silently dropping the borrow-stream APR for both-sides positions.
BlendReservePosition now mirrors BlendReserve with emissionsSupplyApr +
emissionsBorrowApr, each the stream's pool-wide APR at projected rates.
Earn discovery is a downstream composition concern: blendPools already
carries every field the asset-first view derived (status gating,
reserve enabled, supplyApy, emissionsSupplyApr, suppliedUsd ordering,
token metadata), so consumers assemble it themselves. Drops the query,
its BlendEarnOption/BlendEarnPoolOption types, resolver, complexity
entry, and the now-unused ReserveModel.GetAll reader.
…te accrual

Adversarial audit vs blend-contracts-v2 @ ba22b487 found two edge-state
deviations in ProjectRates:

- util==0: the contract bumps last_time and skips the rate update when
  nobody borrows; the projection grew dRate by irMod*rBase. Masked today
  (every pD consumer multiplies a zero dToken amount) but a landmine.
- bad debt (liabilities >= supply): the contract grows bRate by
  accrued*(1-bstop)/totalSupply — the UNCLAMPED liabilities/supply
  ratio — while the reduction reused the clamped utilization, so
  suppliers' bRate growth was understated (~0.5% over 30 idle days).

ProjectRates now takes bSupply/dSupply, derives the raw ratio itself,
and mirrors the contract's on-load guards (zero bSupply / zero
liabilities). New vectors: util==0, zero bSupply, bad-debt raw ratio.
…ry/Account

BlendPool.status becomes a BlendPoolStatus enum (ADMIN_ACTIVE..SETUP,
matching the BlendAuctionType precedent); null until ingested and for
any unrecognized on-chain value. The blend entry points move off
'extend type' blocks into the base Query (queries.graphqls) and Account
(account.graphqls) definitions so each type reads complete in one place;
blend.graphqls keeps all Blend object/enum types.
Nested blend lists were priced once under a single outer multiplier, so
a full blendPools selection cost ~29 units per pool instead of pools x
reserves. Every list level now carries its own bound: catalog x50,
per-account pools/backstop/activeAuctions x10 (documented assumption),
reserves and auction bid/lot x30 (MAX_RESERVES), q4w x20 (MAX_Q4W_SIZE);
Account.blendPositions drops back to default pricing so cardinality is
not double-charged. Full-selection worst cases are now blendPools=26150
and blendPositions=7572 - above the current 6000 deployment limit by
design; admitting full selections needs a deployment-side limit raise.
Existing non-blend entries (freighter budget) untouched.
…ct guard

The distributor advances the stream index before any user write and
traps on a negative delta via require_nonnegative; off-chain a negative
delta can only mean an ingestion gap between the stream row and the
user row. ClaimableEmissions now returns the accrued balance alone in
that state instead of a negative claimable.
GetBackstopLPPrices inferred the protocol-wide BLND/LP price group from
a self-priced-row join, but a self-priced row does not uniquely identify
the configured Comet: pools are permissionless, so any pool whose oracle
is also one of its reserve assets produces the same shape, and a stale
formerly-configured Comet's rows linger until they age out.
BLEND_BACKSTOP_LP_CONTRACT_ID - the same pin the snapshot writer
targets - now flows into serve (cmd/serve.go -> Configs -> resolver) and
scopes the query to exactly that oracle; findBackstopPrices reduces to
picking the self-priced LP row and its BLND sibling within the pinned
group. An unset pin returns no rows, so backstop USD fields resolve to
null. Deploy note: the api deployment needs BLEND_BACKSTOP_LP_CONTRACT_ID
in its env (ingest already has it).
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.

4 participants