Skip to content

feat(blend): BLEND validator, entry/event decoders, and processor - #659

Open
aditya1702 wants to merge 12 commits into
blend/pr2-schema-modelsfrom
blend/pr3-validator-processor
Open

feat(blend): BLEND validator, entry/event decoders, and processor#659
aditya1702 wants to merge 12 commits into
blend/pr2-schema-modelsfrom
blend/pr3-validator-processor

Conversation

@aditya1702

@aditya1702 aditya1702 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

BLEND validator, entry/event decoders, and processor

Third of 5 stacked PRs adding Blend Capital v2 lending support (stacked on #658). Adds the ingestion layer between the ledger stream and #658's writer models: a validator (classifies pools/backstop by WASM interface), entry decoders (ContractData changes → current-state snapshots), event decoders (contract events → Blend state-change history rows + additive folds), and the processor that stages both and persists in dependency order. Every shape was verified against the deployed v2 WASMs (mainnet and testnet are byte-identical) and blend-capital/blend-contracts-v2 @ ba22b487.

Also: the SEP-41 validator's WASM-spec matcher is extracted verbatim into shared internal/services/wasmspec (mechanical; SEP-41 unchanged), and everything registers via init() + blank imports in protocol-setup / protocol-migrate / ingest. mocks.go (deferred from PR2's deadcode gate) lands here with its consumer, the processor tests.

Validator

Classification is purely interface-based (all required functions must match), exercised by real-WASM fixture tests:

Pool (6 fns) Backstop (7 fns)
get_config, get_reserve, get_positions, submit, flash_loan, bad_debt deposit, queue_withdrawal, dequeue_withdrawal, withdraw, claim, draw, donate

Pool matches are enriched best-effort via RPC get_config (a failed call never fails validation). Pool names have no on-chain getter — the processor fills them from instance storage.

Canonical-backstop pin. Backstop-derived tables key rows by pool/user with no backstop contract id, so an impostor deployed from the real backstop WASM could overwrite genuine rows. Backstop-shaped entries (UserBalance/PoolBalance/BEmisData/UEmisData/RZ) and events (deposit/queue_withdrawal/dequeue_withdrawal/withdraw/backstop claim) are therefore folded only from the canonical backstop per network (addresses from docs.blend.capital + blend-utils, WASM hashes verified on-chain; skips get one debug log per contract per window). Junk pools stay tolerated — they key rows under their own contract ids.

Network Canonical backstop BLND SAC
Pubnet CAQQR5SWBXKIGZKPBZDH3KM5GQ5GUTPKB7JAFCINLZBC5WXPJKRG3IM7 CD25MNVTZDL4Y3XBCPCJXGXATV5WUHHOWMYFF4YBEGU5FCPGMYTVG5JY
Testnet CBDVWXT433PRVTUNM56C3JREF3HIZHRBA64NB2C3B2UNCKIS65ZYCLZA CB22KRA3YZVCNCQI64JQ5WE7UY2VAV7WFLK6A2JN3HEX56T2EDAFO7QF

Entry decoders

Twelve ContractData kinds, routed by disjoint key shape; anything else decodes to KindIgnored (never an error). Removal and Option::None (ScvVoid) are explicit nil-payload cases.

Kind Key Durability Feeds
PoolInstance instance storage (must contain Config) instance blend_pools — config + Admin/Name (best-effort)
Positions Positions(user) persistent blend_positions — supplied / collateral / borrowed
ResConfig / ResData ResConfig(asset) / ResData(asset) persistent blend_reserves — curve config / rates, supplies, backstop credit
EmisData / UserEmis EmisData(u32) / UserEmis(user, reserve_id) persistent blend_reserve_emissions / blend_emissions (token_id >= 0)
BackstopUserBalance / BackstopPoolBalance UserBalance(pool, user) / PoolBalance(pool) persistent blend_backstop_positions / blend_backstop_pools
BackstopBEmisData / BackstopUEmisData BEmisData(pool) / UEmisData(pool, user), Option<…> persistent blend_backstop_pools emission state / blend_emissions (token_id = -1)
Auction Auction(auct_type, user) temporary blend_auctions
RewardZone bare symbol "RZ" persistent blend_pools.in_reward_zone
  • Auction encodings verified against a live mainnet auction; the AuctionKey ScMap sorts auct_type before user (symbol-byte order, reverse of Rust field order). An empty RZ vec is a valid live value. An instance entry without Config is the backstop's own → ignored.
  • Consciously ignored bookkeeping keys (no per-entity, user-facing state): pool ResList/PoolEmis/PropAdmin/ResInit; backstop LastDist/DropList/BackfillEmis/Backfill/RzEmis/PoolUSDC.

Event decoders

Each row carries a (category, reason) pair under the schema-wide convention — category names the on-chain object (BLEND_SUPPLY, BLEND_DEBT, …), reason the action — plus key_value.poolId (where the event names a pool) and token_id = reserve asset, BLND, or NULL. Unrecognized symbols decode to nothing; a malformed recognized event errors → logged at Warn, counted in wallet_ingestion_protocol_decode_failures_total{protocol_id, kind} (probable contract drift), and skipped, never aborting the ledger. Decode validation rejects auct_type above int32 range and fill_percent outside 1..100 (the contract enforces ≤ 100 on-chain).

Event Row (category, reason) Fold
supply / supply_collateral (BLEND_SUPPLY, CREDIT) / (BLEND_COLLATERAL, CREDIT) net_supplied +=
withdraw_collateral (BLEND_COLLATERAL, DEBIT) net_supplied -=
borrow / repay (BLEND_DEBT, BORROW) / (BLEND_DEBT, REPAY) net_borrowed += / -=
flash_loan (BLEND_DEBT, FLASH_LOAN) net_borrowed += (a same-tx repay nets it out)
bad_debt (BLEND_DEBT, BAD_DEBT) user's net_borrowed reset to 0
defaulted_debt (BLEND_DEBT, BURN) — (attributed to the pool address)
fill_auction (BLEND_AUCTION, FILL) — user + filler rows per auction type, below
withdraw (ambiguous) (BLEND_SUPPLY, DEBIT) or (BLEND_BACKSTOP, DEBIT) net_supplied -= (pool side only)
claim (ambiguous) (BLEND_EMISSIONS, CLAIM) or (BLEND_BACKSTOP_EMISSIONS, CLAIM) lifetime claimed totals
deposit / queue_withdrawal / dequeue_withdrawal (BLEND_BACKSTOP, CREDIT) / (BLEND_BACKSTOP_QUEUE, ADD) / (BLEND_BACKSTOP_QUEUE, REMOVE)
  • withdraw: pool and backstop shapes are identical; disambiguated by topic[1] — a backstop withdraw's is a tracked Blend pool, a pool withdraw's is a reserve asset (never Blend-tracked).
  • claim: pool = tuple data, backstop = bare i128; the category alone now encodes the source. A backstop claim pays Comet LP (BLND auto-swapped + re-deposited), so its row is token_id NULL + units:"backstop_lp" with no pool address.
  • fill_auction folds mirror on-chain Positions moves: type 0 (UserLiquidation) lot+bid both sides; type 1 (BadDebt) bid both sides; type 2 (Interest) none.

Processor

RequiresContractData() = true. StateChangeOrdinalBase() returns the reserved Blend state_change_id namespace base, and PersistHistory assigns deterministic ordinals in emission order via types.AssignStateChangeOrdinals — Blend rows can never collide with the main indexer's or SEP-41's IDs for the same operation. Event groups are folded in on-chain (TxIdx, OpIdx) order (not map order): the cost-basis folds accumulate across groups into shared staged state and bad_debt's reset makes them order-sensitive, so a borrow and a bad_debt for one key in different groups of the same ledger must fold in chain order — pinned by a probabilistic regression test. Events stage history rows + additive folds; entries stage last-write-wins snapshots; a Persist without a following Reset() refuses further folds (no double-count). Persist order: pools → reserves (fold SQL resolves asset → reserve_index via blend_reserves; a window that decoded a reserve's config without its data goes through a config-only partial update — BatchUpdateConfig — so a set_reserve invariant violation degrades to a stale data half, never zeroed live rates) → positions (delete removed → zero absent → upsert snapshots → net-deltas → auction adjustments) → backstop/emission/claimed/auction sets (independently keyed) → reward zone last (flips in_reward_zone on rows the pools upsert just created). Pool claims fold per emitting pool; backstop claims fold account-wide. Auctions stage LWW per (pool, user, type) — created-then-filled in one window nets to a delete. Batch equivalence (one- vs two-window folds, removal+recreate, same-ledger cross-group ordering) is covered by processor tests.

Notes for reviewers

  • Auction FILL rows: token_id/amount NULL (multi-asset); per-asset detail in key_value.lot/bid (exposed as first-class lists in feat(graphql): Blend positions, pools, and earn discovery #661); non-reserve assets no-op via the reserves join.
  • defaulted_debt: backstop-side basis not folded (not user-facing).
  • Scalars pinned: b_rate/d_rate 1e12, ir_mod 1e7, backstop emissions 1e14; the pool reserve-emission scalar is per-reserve (10^decimals) — PR5 math must join ReserveConfig.decimals.
  • Backstop claim auto-restake: a claim also emits one genuine deposit event per pool, so one claim op yields 1 backstop-emissions CLAIM + N backstop CREDIT rows (Σ deposits == claim amount); no double-count — covered by a dedicated processor test.
  • Unknown networks degrade to NULL token_id on pool-claim rows rather than a misattributed token.
  • Known limitation — windowed-migration auction basis: a multi-ledger migration window values its accumulated auction lot/bid deltas at end-of-window rates; the divergence vs per-ledger ingestion is ≤ borrow APR × window/year (~1e-5 at 50% APR for the default 100-ledger window), affects only interestEarned/interestPaid, and is one-time (live ingestion runs window=1, exact).
  • Known limitation — evicted auctions: change reads are tx-meta-only, so a temporary Auction entry evicted by TTL leaves a stale blend_auctions row. Accepted: auction economics make an unfilled expiry near-impossible.
  • Operational caveat — backstop swap: if the emitter ever swaps the canonical backstop, the pin must be updated and backstop-derived state migrated.

🤖 Generated with Claude Code

@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: 7fa1c21f24

ℹ️ 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/services/blend/events.go

@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: 8036e49c83

ℹ️ 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/services/blend/processor.go
Comment thread internal/services/blend/processor.go
Comment thread internal/services/blend/processor.go
@aditya1702
aditya1702 force-pushed the blend/pr3-validator-processor branch 2 times, most recently from 9330867 to edaf72c Compare July 9, 2026 20:12
@aditya1702
aditya1702 force-pushed the blend/pr3-validator-processor branch from edaf72c to e00ce3f Compare July 9, 2026 20:34
@aditya1702
aditya1702 force-pushed the blend/pr3-validator-processor branch from e00ce3f to 23307c6 Compare July 9, 2026 21:37
@aditya1702
aditya1702 force-pushed the blend/pr3-validator-processor branch 4 times, most recently from 8b68326 to d6d740f Compare July 16, 2026 20:07
@aditya1702
aditya1702 requested a review from Copilot July 21, 2026 19:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds Blend Capital v2 ledger ingestion, including contract classification, state/event decoding, persistence, and registration.

Changes:

  • Introduces shared WASM signature matching and Blend pool/backstop validation.
  • Adds Blend entry/event decoders and a staged processor with canonical-backstop protection.
  • Adds comprehensive fixtures, mocks, tests, and command/ingestion registration.

Reviewed changes

Copilot reviewed 22 out of 24 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
internal/services/wasmspec/wasmspec.go Shared WASM interface matcher.
internal/services/wasmspec/wasmspec_test.go Matcher tests.
internal/services/sep41/validator.go Uses shared matcher.
internal/services/sep41/validator_test.go Removes relocated tests.
internal/services/blend/validator.go Blend classification and enrichment.
internal/services/blend/validator_test.go Validator and WASM tests.
internal/services/blend/scval.go Soroban value helpers.
internal/services/blend/scval_test.go Value-decoder tests.
internal/services/blend/entries.go Contract-data decoders.
internal/services/blend/entries_test.go Entry-decoder tests.
internal/services/blend/events.go Event and fold decoders.
internal/services/blend/events_test.go Event-decoder tests.
internal/services/blend/processor.go Staging and persistence pipeline.
internal/services/blend/processor_test.go Processor behavior tests.
internal/services/blend/register.go Registers Blend services.
internal/services/blend/register_test.go Registration tests.
internal/services/blend/testdata/blend_pool_v2.wasm Real pool fixture.
internal/services/blend/testdata/blend_backstop_v2.wasm Real backstop fixture.
internal/data/blend/mocks.go Blend model mocks.
internal/data/blend/pools.go Documents monotonic ledger updates.
internal/data/blend/pools_test.go Tests ledger non-regression.
internal/ingest/ingest.go Enables Blend ingestion.
cmd/protocol_setup.go Enables Blend setup.
cmd/protocol_migrate.go Enables Blend migration.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/services/blend/processor.go Outdated
Comment thread internal/services/blend/processor.go
Comment thread internal/services/blend/validator.go Outdated
Comment thread internal/services/blend/processor.go Outdated
Comment thread internal/services/blend/processor.go Outdated
Comment thread internal/services/blend/entries.go
Comment thread internal/services/blend/events.go Outdated
Comment thread internal/services/blend/validator.go Outdated
@aditya1702
aditya1702 force-pushed the blend/pr3-validator-processor branch from 73c2e72 to 841c130 Compare July 30, 2026 16:28
…claim token

Verified against blend-contracts-v2 @ ba22b487:

fill_auction (pool/src/auctions/*.rs): fill_bad_debt_auction moves the bid
dTokens from the backstop's Positions to the FILLER's — the filler assumes
the debt — while the lot (backstop LP tokens) is drawn straight to the
filler's wallet and never touches pool Positions. fill_interest_auction
settles entirely outside pool Positions (bid donated to the backstop, lot
paid from the reserves' backstop_credit, captured by the ResData entry
snapshot). The decoder previously folded the user side of every asset for
all auction types and mirrored to the filler only for type 0: a type-1
filler's net_borrowed missed the assumed debt, and type-2 fills fabricated
lot adjustments against the backstop-address row (in the wrong units —
underlying, valued as bTokens). Folds now mirror the on-chain Positions
moves exactly: type 0 both sides, type 1 bid-only both sides, type 2 none.

backstop claim (backstop/src/contract.rs -> emissions/claim.rs): the event's
amount is execute_claim's return — the Comet LP tokens minted and
auto-deposited (per-pool deposit events are emitted alongside) — never raw
BLND. The row's token_id is now NULL with units backstop_lp in key_value,
matching every other backstop-LP-denominated row, instead of mislabeling
LP amounts as BLND.
Decode a ClaimFold from each pool/backstop claim event and accumulate it into
the staged pool/backstop claimed-total maps during current-state indexing,
persisting via PoolClaimed/BackstopClaimed.BatchApplyDeltas. History mode is
unchanged — it still records the CLAIM feed rows but folds no totals.
… swap caveats

Audit of the decoders/processor against blend-contracts-v2 @ ba22b487
found no behavioral gaps; this records the three residual findings:

- test: a backstop claim op also emits one genuine deposit event per
  claimed pool (auto-restake) — 1 CLAIM + N BACKSTOP_DEPOSIT rows, a
  single account-wide claimed-total fold, no double count
- godoc: TTL-evicted temporary Auction entries are invisible to
  ingestion (tx-meta-only reads), so an unfilled expired auction leaves
  a stale blend_auctions row
- godoc: an emitter backstop swap requires updating the canonical
  backstop pin and migrating backstop-derived state
@aditya1702
aditya1702 force-pushed the blend/pr3-validator-processor branch from 841c130 to 1339841 Compare July 30, 2026 21:39
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.

3 participants