feat(db): Blend v2 schema — entity-category enums, blend_* tables, data models - #658
Conversation
d0f6a07 to
a32b0dc
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a32b0dcef0
ℹ️ 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".
4a818a6 to
c088f01
Compare
4080676 to
fb4858f
Compare
916748d to
8ea8219
Compare
8ea8219 to
0965d80
Compare
|
Not a blocker — the magnitude is below an error bound this function already documents and accepts. Raising it because the current rounding order is load-bearing but implicit, and no test pins it.
With the existing test fixture ( Worth noting this is purely a composition issue, not a mistake in either change that produced it: f73ff53 added the Trigger is narrow: 2+ fills against the same Suggested test — no new fixture needed, and neither existing case can catch this ( t.Run("aggregates before flooring, so folded fills round once not per-fill", func(t *testing.T) {
// Documents the current rounding order. Flooring doesn't distribute over
// addition: at b_rate 1.1, two fills of 5 give trunc(10 * 1.1) = 11 here,
// where per-fill flooring would give trunc(5.5) * 2 = 10. Same 1-stroop
// skew on the negative (liquidated user) side. If the conversion ever moves
// inside the GROUP BY, these expectations become 10 / 20 and -10 / -20.
fillerAddr := keypair.MustRandom().Address()
userAddr := keypair.MustRandom().Address()
insertPosition(t, ctx, pool, poolAddr, fillerAddr, 4, "0", "0", "0", "0", "0")
insertPosition(t, ctx, pool, poolAddr, userAddr, 4, "0", "0", "0", "0", "0")
runInTx(t, ctx, pool, func(tx pgx.Tx) {
require.NoError(t, m.ApplyAuctionAdjustments(ctx, tx, []blend.PositionAuctionAdjustment{
{Pool: poolAddr, User: fillerAddr, Asset: assetAddr, LotBTokensDelta: "5", BidDTokensDelta: "10", LedgerNumber: 91},
{Pool: poolAddr, User: fillerAddr, Asset: assetAddr, LotBTokensDelta: "5", BidDTokensDelta: "10", LedgerNumber: 92},
{Pool: poolAddr, User: userAddr, Asset: assetAddr, LotBTokensDelta: "-5", BidDTokensDelta: "-10", LedgerNumber: 91},
{Pool: poolAddr, User: userAddr, Asset: assetAddr, LotBTokensDelta: "-5", BidDTokensDelta: "-10", LedgerNumber: 92},
}))
})
filler, ok := getPosition(t, ctx, pool, poolAddr, fillerAddr, 4)
require.True(t, ok)
assert.Equal(t, "11", filler.NetSupplied, "trunc((5+5) * 1.1) = 11, not trunc(5.5) * 2 = 10")
assert.Equal(t, "21", filler.NetBorrowed, "trunc((10+10) * 1.05) = 21, not trunc(10.5) * 2 = 20")
user, ok := getPosition(t, ctx, pool, poolAddr, userAddr, 4)
require.True(t, ok)
assert.Equal(t, "-11", user.NetSupplied, "same skew, more negative")
assert.Equal(t, "-21", user.NetBorrowed)
})Suggested doc note on // Conversion happens after the GROUP BY, so a folded batch is floored once on
// the summed delta rather than per fill. Flooring doesn't distribute over
// addition, so n fills against one position overstate magnitude by at most
// n-1 stroops (at b_rate 1.1, two fills of 5 give trunc(11.0) = 11, not
// trunc(5.5) * 2 = 10). That is strictly smaller than the end-of-window rate
// approximation quantified on ApplyAuctionAdjustments below, and affects the
// same display-only cost-basis fields, so the cheaper single trunc is kept
// deliberately. Moving trunc inside the subquery would restore per-fill
// flooring if that ever matters.Happy to be told this is over-documenting a stroop. |
|
Line 33 says the rollback "Requires no BLEND_* rows to exist (delete them first in dev)", but lines 35-41 and 44-49 re-add the narrowed CHECKs as
Why the precondition matters more than constraint hygiene. The reason the Down wants those rows gone is that a rollback should restore a state the pre-Blend code can serve — and the pre-Blend resolver hits the Applies to the reason constraint as well ( Suggested fix — a guard, not dropping -- +migrate Down
-- Rollback requires no BLEND_* rows: the re-narrowed CHECKs below are NOT VALID
-- (cheap, no hypertable scan), so they cannot reject pre-existing rows. Fail
-- loudly here rather than leaving rows the restored constraint forbids.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM state_changes
WHERE state_change_category IN (
'BLEND_SUPPLY', 'BLEND_COLLATERAL', 'BLEND_DEBT', 'BLEND_AUCTION',
'BLEND_EMISSIONS', 'BLEND_BACKSTOP_EMISSIONS', 'BLEND_BACKSTOP',
'BLEND_BACKSTOP_QUEUE')
) THEN
RAISE EXCEPTION 'rollback requires no BLEND_* state_changes rows; delete them first';
END IF;
END $$;This should be cheap in the expected case: it's Separately, on the Up direction. Lines 10-20 and 22-29 permanently downgrade constraints that were VALID — they were declared inline at table creation ( Also minor: none of the four |
0965d80 to
8f1dab7
Compare
|
Re: ApplyAuctionAdjustments rounding order (#658 (comment)) — both added in 707a6ac: the discriminating subtest essentially as suggested (11/21 and −11/−21 pinned, with the note that moving trunc inside the GROUP BY flips the expectations to 10/20) and the rounding-order paragraph on |
|
Re: the |
8f1dab7 to
2c85d31
Compare
2c85d31 to
31a4a50
Compare
31a4a50 to
f71c79e
Compare
…sons Blend state changes follow the core convention: category names the on-chain object (BLEND_SUPPLY, BLEND_COLLATERAL, BLEND_DEBT, BLEND_AUCTION, BLEND_EMISSIONS, BLEND_BACKSTOP_EMISSIONS, BLEND_BACKSTOP, BLEND_BACKSTOP_QUEUE) and reason names the action. Amount-bearing categories reuse the generic CREDIT/DEBIT/ADD/REMOVE/ BURN verbs; only BORROW, REPAY, FLASH_LOAN, BAD_DEBT, FILL, and CLAIM are added.
…keys Postgres UPDATE ... FROM applies only one matching source row per target row, so duplicate (pool, user, asset) keys in a batch silently dropped deltas. Net deltas reject duplicates (ZeroBorrowed makes merging order-dependent; the processor pre-aggregates); auction adjustments are purely additive and are summed server-side before applying.
…odels Add blend_pool_claimed (pool, user) and blend_backstop_claimed (user) tables plus PoolClaimedModel/BackstopClaimedModel with additive BatchApplyDeltas. These hold lifetime claimed BLND / Comet LP totals, folded from claim events during current-state indexing — the only pass that sees every claim since Blend's first ledger. Mirrors the net_supplied/net_borrowed cost-basis accumulator.
…account_id Both tables are read by GetByAccount (the per-user positions path) filtering on user_account_id, which is the second PK column and so cannot use the primary key. Add single-column B-tree indexes mirroring idx_blend_positions_user, keeping the index defined alongside its table in the same migration.
ApplyAuctionAdjustments converted protocol tokens to underlying with exact numeric division, leaving a fractional tail (e.g. "1100.0000000000000000") in net_supplied/net_borrowed while every other write to those columns stores floored integer text. The contract uses fixed_mul_floor (floor of the positive magnitude). Wrap the conversion in trunc(): for the signed lot/bid deltas, trunc toward zero reproduces floor-of-magnitude-with-sign (trunc(-366.3) = -366, not floor's -367). Adds a subtest covering a fractional product on both the positive (filler) and negative (liquidated user) sides.
… writers BatchApplyNetDeltas and ApplyAuctionAdjustments mutate existing rows only; a delta for a not-yet-inserted position row silently no-ops. Document that callers must upsert the Positions snapshot (and reserves) first, as PersistCurrentState does.
The full-snapshot upsert/zero writers overwrote last_modified_ledger outright while the additive writers (net deltas, claimed, reserve data, reward zone) already use GREATEST. Switch the snapshot writers to GREATEST(<table>.last_modified_ledger, EXCLUDED/u.ledger) too, so the column never moves backward and every writer treats it uniformly. Behavior is unchanged under the strictly ledger-ordered persist path.
StartBlock and LastModifiedLedger were int32 while every other blend row struct uses uint32 for ledger-valued fields, casting to int32 only at the write boundary. Align Auction with that convention.
…ments Flooring doesn't distribute over addition, so n fills folded into one (pool, user, asset) row overstate magnitude by at most n-1 stroops versus per-fill flooring. The existing duplicate-key test uses amounts exact at the fixture rates, so it cannot tell the two orders apart; this one can (two fills of 5 at b_rate 1.1: 11 aggregated vs 10 per-fill). The doc on applyAuctionAdjustmentsSQL states why the single trunc on the summed delta is kept: the skew is strictly below the end-of-window rate approximation already accepted on the same display-only fields.
The Down re-adds the narrowed CHECKs as NOT VALID, which is precisely the modifier that skips checking existing rows — so the stated precondition (no BLEND_* rows) was a comment, not a guarantee, and a rollback over Blend data left rows the restored constraints forbid: unwritable, and fatal to whole stateChanges pages in the pre-Blend resolver. A DO block now probes both the category and reason lists (bloom sparse indexes serve both) and raises before touching any constraint. All four DROP CONSTRAINTs gain IF EXISTS so both directions are re-runnable. The Up keeps NOT VALID: validating would scan every columnstore chunk under an AccessExclusiveLock.
f71c79e to
ef82fb9
Compare
Blend v2 schema + data models
Second of 5 stacked PRs adding Blend Capital v2 lending support (stacked on #657). This PR adds the database schema and the Go writer layer that Blend ingestion populates. The GraphQL readers that expose this data land in #661.
Migrations
state_changesCHECK constraints gain the Blend v2 values under the schema-wide convention that category names the on-chain object and reason names the action: eight categories (BLEND_SUPPLY,BLEND_COLLATERAL,BLEND_DEBT,BLEND_AUCTION,BLEND_EMISSIONS,BLEND_BACKSTOP_EMISSIONS,BLEND_BACKSTOP,BLEND_BACKSTOP_QUEUE). Amount-bearing categories reuse the genericCREDIT/DEBIT/ADD/REMOVE/BURNverbs; onlyBORROW,REPAY,FLASH_LOAN,BAD_DEBT,FILL, andCLAIMare added. The constraints restate the tightened base lists exactly and are recreatedNOT VALIDso no hypertable rewrite is needed. The rollback enforces its no-BLEND-rows precondition with a fail-loud guard (aDOblock probing both the category and reason lists — served by the columnstore's bloom sparse indexes — sinceNOT VALIDre-narrowed CHECKs cannot reject pre-existing rows), and all constraint drops areIF EXISTSso both directions are re-runnable.11 new current-state tables — plain mutable tables, not hypertables, because they hold UPSERT-heavy snapshots of live on-chain state (same rationale as
sep41_balances). Storage parameters follow the current conventions:fillfactor = 80only where essentially every row turns over per active ledger (blend_reserves,blend_reserve_emissions,blend_oracle_prices, each with an in-file justification),fillfactor = 90elsewhere, and noautovacuum_vacuum_cost_limit(cost_delay = 0 already exempts these tables from cross-worker balancing):blend_poolsblend_positionsblend_reservesblend_backstop_positionsblend_backstop_poolsblend_reserve_emissionsblend_emissionsblend_oracle_pricesblend_pool_claimed/blend_backstop_claimedblend_auctionsEvery user-keyed table is indexed on
user_account_idfor the per-account "my positions" read path.blend_reservesadditionally enforcesUNIQUE (pool_contract_id, asset_contract_id)— the fold SQL resolvesreserve_indexby joining on that pair, andUPDATE ... FROMwould silently apply an arbitrary source row if it ever matched twice; the backing unique index also serves those joins. Also registers theBLENDprotocol.Go data layer (
internal/data/blend, writer surface only)PositionModel— snapshot upserts (last-write-wins), zeroing of exited reserves (rows kept so lifetime-earned survives a full exit), additive net-delta cost-basis folds (asset → reserve resolved viablend_reserves), bad-debt reset, and auction adjustments that convert protocol tokens to underlying at current rates (floored to match the contract; conversion happens once on the batch-summed delta, so folded fills round once, not per fill — pinned by test, skew ≤ n−1 stroops, below the documented rate-staleness bound).PoolModel(partial upsert — a NULL field never clobbers known config; plus an absolute reward-zone set),ReserveModel(full-row upserts plus a ResData-only partial update, both rejecting duplicate (pool, asset) keys per batch —UPDATE ... FROMwould silently pick one arbitrary source row),BackstopPositionModel/BackstopPoolModel,Emission/ReserveEmissionmodels,PoolClaimed/BackstopClaimed(additive lifetime totals), andAuctionModel.data.Models.Blend; adds the 8 Blend category + 6 reason Go constants (the locked cross-PR contract).Notes for reviewers
blend_emissionswith reserve emissions, distinguished bytoken_id = -1(backstop) vs>= 0(reserve token); backstop rows carry the pool assource_contract_id.BACKFILL_STATUS/BACKFILL_EMISSIONS) is not captured — during that transitional state accrued backstop BLND isn't yet claimable, so a user's claimable-BLND figure can be overstated in that window. Deliberately out of scope.🤖 Generated with Claude Code