diff --git a/cmd/serve.go b/cmd/serve.go index f578a0cb5..036fa87b8 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -29,6 +29,7 @@ func (c *serveCmd) Command() *cobra.Command { utils.StellarEnvironmentOption(&stellarEnvironment), utils.ServerBaseURLOption(&cfg.ServerBaseURL), utils.GraphQLComplexityLimitOption(&cfg.GraphQLComplexityLimit), + utils.BlendBackstopLPContractIDOption(&cfg.BlendBackstopLPContractID), utils.AdminPortOption(&cfg.AdminPort), { Name: "port", diff --git a/internal/data/blend/oracle_prices.go b/internal/data/blend/oracle_prices.go index c5afa7cdb..e046c31db 100644 --- a/internal/data/blend/oracle_prices.go +++ b/internal/data/blend/oracle_prices.go @@ -189,25 +189,37 @@ func (m *OraclePriceModel) GetByOracles(ctx context.Context, oracleIDs []string) return prices, nil } -// GetBackstopLPPrices returns every blend_oracle_prices row that shares an -// oracle with a self-priced row (oracle_contract_id = asset_contract_id) — -// i.e. a Comet LP token's self-quoted price plus its sibling BLND price -// quoted under the same oracle. +// GetBackstopLPPrices returns the pinned Comet oracle's price group: its +// self-priced LP-share row (oracle_contract_id = asset_contract_id) plus the +// sibling BLND row quoted under the same oracle. // -// No de-duplication is needed: (oracle_contract_id, asset_contract_id) is the -// table's primary key, so at most one row per oracle_contract_id can satisfy -// the self-priced predicate (asset = oracle). The self-join therefore cannot -// multiply matches — each output row corresponds to exactly one (self-priced -// row, sibling row) pair, keyed by a distinct oracle_contract_id. -func (m *OraclePriceModel) GetBackstopLPPrices(ctx context.Context) ([]OraclePrice, error) { +// cometID is the configured Comet BLND:USDC pool contract +// (BLEND_BACKSTOP_LP_CONTRACT_ID) — the same pin the price snapshot writer +// targets when quoting the BLND/LP-share leg (see internal/services/blend/ +// prices.go). Scoping the query to that one protocol-wide oracle is what makes +// this safe under permissionless pools: a coincidentally self-priced group +// (any pool whose oracle is also one of its own reserve assets) is never +// mistaken for the backstop LP prices. +// +// Returns an empty, non-nil slice WITHOUT querying when cometID is empty (the +// pin is unset): the backstop LP/BLND USD fields then resolve to null +// downstream, exactly like a never-priced asset. +func (m *OraclePriceModel) GetBackstopLPPrices(ctx context.Context, cometID string) ([]OraclePrice, error) { + if cometID == "" { + return []OraclePrice{}, nil + } + cometBytes, err := addressToBytes(cometID) + if err != nil { + return nil, fmt.Errorf("converting comet contract id for backstop LP price lookup: %w", err) + } + start := time.Now() const query = ` - SELECT o2.oracle_contract_id, o2.asset_contract_id, o2.price, o2.price_decimals, o2.price_timestamp, o2.updated_at - FROM blend_oracle_prices o1 - JOIN blend_oracle_prices o2 ON o2.oracle_contract_id = o1.oracle_contract_id - WHERE o1.oracle_contract_id = o1.asset_contract_id - ORDER BY o2.oracle_contract_id, o2.asset_contract_id` - rows, err := m.DB.Query(ctx, query) + SELECT oracle_contract_id, asset_contract_id, price, price_decimals, price_timestamp, updated_at + FROM blend_oracle_prices + WHERE oracle_contract_id = $1 + ORDER BY oracle_contract_id, asset_contract_id` + rows, err := m.DB.Query(ctx, query, cometBytes) if err != nil { m.Metrics.QueryErrors.WithLabelValues("GetBackstopLPPrices", oraclePricesTable, utils.GetDBErrorType(err)).Inc() return nil, fmt.Errorf("querying blend backstop LP oracle prices: %w", err) diff --git a/internal/data/blend/oracle_prices_test.go b/internal/data/blend/oracle_prices_test.go index 8f39faa6d..338ec7619 100644 --- a/internal/data/blend/oracle_prices_test.go +++ b/internal/data/blend/oracle_prices_test.go @@ -209,10 +209,16 @@ func TestOraclePriceModel_GetBackstopLPPrices(t *testing.T) { ctx, _, m, cleanup := newOraclePricesFixture(t) defer cleanup() - cometOracle := keypair.MustRandom().Address() // Comet pool contract, self-priced as its own oracle + cometOracle := keypair.MustRandom().Address() // pinned Comet pool contract, self-priced as its own oracle blndAsset := keypair.MustRandom().Address() normalOracle := keypair.MustRandom().Address() normalAsset := keypair.MustRandom().Address() + // A SECOND, unpinned self-priced group: a permissionless pool whose oracle + // is coincidentally one of its own reserve assets. It satisfies the old + // self-priced inference but is NOT the configured Comet contract, so it must + // never be returned. + otherComet := keypair.MustRandom().Address() + otherSibling := keypair.MustRandom().Address() require.NoError(t, m.BatchUpsert(ctx, []blend.OraclePrice{ // Comet LP self-priced row: asset == oracle. @@ -221,17 +227,28 @@ func TestOraclePriceModel_GetBackstopLPPrices(t *testing.T) { {OracleContractID: types.AddressBytea(cometOracle), AssetContractID: types.AddressBytea(blndAsset), Price: "50", PriceDecimals: 7, PriceTimestamp: 1}, // An unrelated, non-self-priced oracle row must be excluded. {OracleContractID: types.AddressBytea(normalOracle), AssetContractID: types.AddressBytea(normalAsset), Price: "10", PriceDecimals: 7, PriceTimestamp: 1}, + // A second self-priced group under a DIFFERENT oracle must be excluded. + {OracleContractID: types.AddressBytea(otherComet), AssetContractID: types.AddressBytea(otherComet), Price: "999", PriceDecimals: 7, PriceTimestamp: 1}, + {OracleContractID: types.AddressBytea(otherComet), AssetContractID: types.AddressBytea(otherSibling), Price: "888", PriceDecimals: 7, PriceTimestamp: 1}, })) - got, err := m.GetBackstopLPPrices(ctx) + got, err := m.GetBackstopLPPrices(ctx, cometOracle) require.NoError(t, err) - require.Len(t, got, 2, "exactly the Comet self-price row plus its sibling BLND row") + require.Len(t, got, 2, "exactly the pinned Comet self-price row plus its sibling BLND row") gotPrices := map[types.AddressBytea]string{} for _, p := range got { - assert.Equal(t, types.AddressBytea(cometOracle), p.OracleContractID) + assert.Equal(t, types.AddressBytea(cometOracle), p.OracleContractID, "only the pinned Comet oracle's rows, never the second self-priced group") gotPrices[p.AssetContractID] = p.Price } assert.Equal(t, "100", gotPrices[types.AddressBytea(cometOracle)]) assert.Equal(t, "50", gotPrices[types.AddressBytea(blndAsset)]) + + t.Run("empty cometID returns an empty slice without querying", func(t *testing.T) { + m := &blend.OraclePriceModel{} // no DB: proves it never queries + got, err := m.GetBackstopLPPrices(context.Background(), "") + require.NoError(t, err) + require.NotNil(t, got) + require.Empty(t, got) + }) } diff --git a/internal/data/blend/readers.go b/internal/data/blend/readers.go index fafc5f5b2..7949909cc 100644 --- a/internal/data/blend/readers.go +++ b/internal/data/blend/readers.go @@ -302,7 +302,7 @@ func (m *PoolModel) GetAll(ctx context.Context) ([]Pool, error) { } // scanReserves scans every row of rows into a Reserve slice, matching -// blend_reserves' column order. Shared by GetByPools and GetAll. +// blend_reserves' column order. func scanReserves(rows pgx.Rows) ([]Reserve, error) { reserves := []Reserve{} for rows.Next() { @@ -369,32 +369,6 @@ func (m *ReserveModel) GetByPools(ctx context.Context, poolIDs []string) ([]Rese return result, nil } -// GetAll returns every blend_reserves row, ordered by (pool_contract_id, reserve_index). -func (m *ReserveModel) GetAll(ctx context.Context) ([]Reserve, error) { - start := time.Now() - query := fmt.Sprintf(` - SELECT %s - FROM blend_reserves - ORDER BY pool_contract_id, reserve_index`, reservesSelectColumns) - rows, err := m.DB.Query(ctx, query) - if err != nil { - m.Metrics.QueryErrors.WithLabelValues("GetAll", reservesTable, utils.GetDBErrorType(err)).Inc() - return nil, fmt.Errorf("querying all blend reserves: %w", err) - } - defer rows.Close() - - result, err := scanReserves(rows) - if err != nil { - m.Metrics.QueryErrors.WithLabelValues("GetAll", reservesTable, utils.GetDBErrorType(err)).Inc() - return nil, err - } - - duration := time.Since(start).Seconds() - m.Metrics.QueryDuration.WithLabelValues("GetAll", reservesTable).Observe(duration) - m.Metrics.QueriesTotal.WithLabelValues("GetAll", reservesTable).Inc() - return result, nil -} - // GetByPools returns the blend_reserve_emissions rows for the given pool // contract IDs, ordered by (pool_contract_id, reserve_token_id). Returns an // empty, non-nil slice without querying when poolIDs is empty. diff --git a/internal/data/blend/readers_test.go b/internal/data/blend/readers_test.go index 84e1b518d..27ed97dc5 100644 --- a/internal/data/blend/readers_test.go +++ b/internal/data/blend/readers_test.go @@ -248,29 +248,6 @@ func TestReserveModel_GetByPools(t *testing.T) { }) } -func TestReserveModel_GetAll(t *testing.T) { - ctx, pool, m, cleanup := newReservesFixture(t) - defer cleanup() - - poolA := keypair.MustRandom().Address() - assetA := keypair.MustRandom().Address() - assetB := keypair.MustRandom().Address() - runInTx(t, ctx, pool, func(tx pgx.Tx) { - require.NoError(t, m.BatchUpsert(ctx, tx, []blend.Reserve{ - fullReserve(poolA, assetA, 1, 1), - fullReserve(poolA, assetB, 0, 1), - })) - }) - - got, err := m.GetAll(ctx) - require.NoError(t, err) - require.Len(t, got, 2) - assertOrderedByAddrThen(t, got, - func(r blend.Reserve) types.AddressBytea { return r.PoolContractID }, - func(r blend.Reserve) int32 { return r.ReserveIndex }, - ) -} - func TestReserveEmissionModel_GetByPools(t *testing.T) { ctx, pool, m, cleanup := newReserveEmissionsFixture(t) defer cleanup() diff --git a/internal/integrationtests/blend_test.go b/internal/integrationtests/blend_test.go new file mode 100644 index 000000000..42ec40f30 --- /dev/null +++ b/internal/integrationtests/blend_test.go @@ -0,0 +1,890 @@ +package integrationtests + +// Blend v2 integration coverage: BlendMigrationTestSuite exercises the real +// protocol-setup -> protocol-migrate (current-state + history) flow against a +// deployed Blend v2 stack, mirroring DataMigrationTestSuite's real-binary +// verification strategy (see data_migration_test.go's doc comment) but for a +// protocol whose contracts are uploaded WHILE live ingestion is already +// running, so classification and blend_pools enrichment happen in real time +// rather than requiring an explicit backfill. BlendLiveIngestionTestSuite then +// drives phase-2 ops (withdrawals, backstop queue/dequeue, emissions, +// liquidation) under live ingestion and asserts the result over GraphQL, +// mirroring AccountBalancesAfterLiveIngestionTestSuite's API-surface +// verification strategy. + +import ( + "context" + "crypto/rand" + "fmt" + "math" + "math/big" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/prometheus/client_golang/prometheus" + "github.com/stellar/go-stellar-sdk/strkey" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/stellar/wallet-backend/internal/data" + blenddata "github.com/stellar/wallet-backend/internal/data/blend" + "github.com/stellar/wallet-backend/internal/db" + indexertypes "github.com/stellar/wallet-backend/internal/indexer/types" + "github.com/stellar/wallet-backend/internal/integrationtests/infrastructure" + "github.com/stellar/wallet-backend/internal/metrics" + "github.com/stellar/wallet-backend/internal/utils" + wbtypes "github.com/stellar/wallet-backend/pkg/wbclient/types" +) + +const blendProtocolID = "BLEND" + +// randomContractAddress generates a random, syntactically valid but unknown +// C-address, mirroring resolvers/setup_test.go's helper of the same name (not +// importable from this package). +func randomContractAddress(t *testing.T) string { + t.Helper() + var raw [32]byte + _, err := rand.Read(raw[:]) + require.NoError(t, err) + addr, err := strkey.Encode(strkey.VersionByteContract, raw[:]) + require.NoError(t, err) + return addr +} + +// parseBigIntStr parses a plain base-10 integer string — the raw on-chain +// amount most Blend GraphQL/DB fields carry (unlike net_supplied/net_borrowed, +// see parseBlendDecimalToBigInt). +func parseBigIntStr(t *testing.T, s string) *big.Int { + t.Helper() + n, ok := new(big.Int).SetString(s, 10) + require.Truef(t, ok, "parsing blend integer %q", s) + return n +} + +// parseBlendDecimalToBigInt parses blend_positions.net_supplied/net_borrowed, +// which — per blend_positions.go's parseBigInt doc comment — may carry a +// decimal point (unlike every other Blend integer column); the fractional +// part is truncated, mirroring internal/services/blend/rates.go's +// ParseDecimalToBigInt. +func parseBlendDecimalToBigInt(t *testing.T, s string) *big.Int { + t.Helper() + intPart := s + if idx := strings.IndexByte(s, '.'); idx >= 0 { + intPart = s[:idx] + } + n, ok := new(big.Int).SetString(intPart, 10) + require.Truef(t, ok, "parsing blend decimal %q", s) + return n +} + +// findPositionByIndex returns the blend_positions row for reserveIndex, or +// nil if absent. +func findPositionByIndex(positions []blenddata.Position, reserveIndex int32) *blenddata.Position { + for i := range positions { + if positions[i].ReserveIndex == reserveIndex { + return &positions[i] + } + } + return nil +} + +// findReserveByAsset returns the BlendReserve entry for assetID, or nil. +func findReserveByAsset(reserves []wbtypes.BlendReserve, assetID string) *wbtypes.BlendReserve { + for i := range reserves { + if reserves[i].AssetContractID == assetID { + return &reserves[i] + } + } + return nil +} + +// findReservePositionByAsset returns the BlendReservePosition entry for assetID, or nil. +func findReservePositionByAsset(reserves []wbtypes.BlendReservePosition, assetID string) *wbtypes.BlendReservePosition { + for i := range reserves { + if reserves[i].AssetContractID == assetID { + return &reserves[i] + } + } + return nil +} + +// ============================================================================ +// BlendMigrationTestSuite +// ============================================================================ + +// BlendMigrationTestSuite exercises the real protocol-setup -> protocol-migrate +// (current-state + history) deployment flow for Blend v2, against a stack +// deployed by SetupBlendStack and driven through phase-1 ops +// (SubmitBlendPhase1Ops) by main_test.go before this suite runs. +// +// Unlike SEP-41 (see DataMigrationTestSuite), the Blend pool/backstop +// contracts are uploaded WHILE live ingestion is already running (main_test.go +// calls SetupBlendStack well after the ingest container starts), so live +// ingestion's per-ledger classification (services.ingestService. +// runClassification) and the Blend validator's blend_pools enrichment both +// happen in real time — protocol-setup's classification pass and this suite's +// pre-checks should find that work already done. +// +// NOTE: like DataMigrationTestSuite, this suite deliberately has no teardown: +// the migrated state it produces is asserted again (over GraphQL, post +// phase-2 ops) by BlendLiveIngestionTestSuite, which runs later. +type BlendMigrationTestSuite struct { + suite.Suite + testEnv *infrastructure.TestEnvironment + stack *infrastructure.BlendStack +} + +func (s *BlendMigrationTestSuite) setupDB() (*pgxpool.Pool, func()) { + ctx := context.Background() + dbURL, err := s.testEnv.Containers.GetWalletDBConnectionString(ctx) + s.Require().NoError(err) + + pool, err := db.OpenDBConnectionPool(ctx, dbURL) + s.Require().NoError(err) + + return pool, func() { pool.Close() } +} + +func (s *BlendMigrationTestSuite) setupModels(pool *pgxpool.Pool) *data.Models { + dbMetrics := metrics.NewMetrics(prometheus.NewRegistry()).DB + models, err := data.NewModels(pool, dbMetrics) + s.Require().NoError(err) + + return models +} + +// addressBytes converts a strkey address to its 33-byte BYTEA representation, +// mirroring internal/data/blend's addressToBytes (unexported, so not directly +// reusable from this package). +func (s *BlendMigrationTestSuite) addressBytes(addr string) []byte { + raw, err := indexertypes.AddressBytea(addr).Value() + s.Require().NoError(err) + b, ok := raw.([]byte) + s.Require().True(ok) + return b +} + +func (s *BlendMigrationTestSuite) ingestStoreKeyExists(ctx context.Context, pool *pgxpool.Pool, key string) bool { + var exists bool + err := pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM ingest_store WHERE key = $1)`, key).Scan(&exists) + s.Require().NoError(err) + return exists +} + +func (s *BlendMigrationTestSuite) countRows(ctx context.Context, pool *pgxpool.Pool, table string) int { + var count int + err := pool.QueryRow(ctx, fmt.Sprintf(`SELECT COUNT(*) FROM %s`, table)).Scan(&count) + s.Require().NoError(err) + return count +} + +func (s *BlendMigrationTestSuite) countLendingStateChanges(ctx context.Context, pool *pgxpool.Pool) int { + var count int + err := pool.QueryRow(ctx, `SELECT COUNT(*) FROM state_changes WHERE state_change_category = 'LENDING'`).Scan(&count) + s.Require().NoError(err) + return count +} + +func (s *BlendMigrationTestSuite) countLendingReason(ctx context.Context, pool *pgxpool.Pool, address, reason string) int { + var count int + err := pool.QueryRow(ctx, + `SELECT COUNT(*) FROM state_changes WHERE account_id = $1 AND state_change_category = 'LENDING' AND state_change_reason = $2`, + s.addressBytes(address), reason).Scan(&count) + s.Require().NoError(err) + return count +} + +// assertAllLendingPoolIDs asserts every LENDING state-change row's +// key_value->>'poolId' equals poolID — the phase-1 fixture only ever produces +// pool-scoped rows (no backstop CLAIM, whose poolId is NULL), so every row at +// this point in the run must agree. +func (s *BlendMigrationTestSuite) assertAllLendingPoolIDs(ctx context.Context, pool *pgxpool.Pool, poolID string) { + var mismatches int + err := pool.QueryRow(ctx, + `SELECT COUNT(*) FROM state_changes WHERE state_change_category = 'LENDING' AND (key_value->>'poolId') IS DISTINCT FROM $1`, + poolID).Scan(&mismatches) + s.Require().NoError(err) + s.Assert().Zero(mismatches, "every phase-1 LENDING state change should carry poolId=%s", poolID) +} + +// TestProtocolSetupThenMigration mirrors DataMigrationTestSuite's real +// deployment order: pre-check -> protocol-setup -> protocol-migrate +// current-state -> protocol-migrate history -> assert. +func (s *BlendMigrationTestSuite) TestProtocolSetupThenMigration() { + ctx := context.Background() + pool, cleanup := s.setupDB() + defer cleanup() + + models := s.setupModels(pool) + stack := s.stack + + // ------------------------------------------------------------------ + // Phase 1: pre-checks. Live ingestion classified the pool/backstop + // wasms and the Blend validator enriched blend_pools in real time + // (both contracts were uploaded after the ingest container started — + // see the suite doc comment), so this state already exists even + // though protocol-setup/protocol-migrate haven't run yet. + // ------------------------------------------------------------------ + classifiedContracts, err := models.ProtocolContracts.GetByProtocolID(ctx, blendProtocolID) + s.Require().NoError(err) + s.Require().GreaterOrEqualf(len(classifiedContracts), 2, + "live ingestion should have already classified at least the Blend pool and backstop wasms") + + poolsPre, err := models.Blend.Pools.GetByIDs(ctx, []string{stack.PoolID}) + s.Require().NoError(err) + s.Require().Len(poolsPre, 1, "the Blend validator should have already enriched blend_pools for the pool") + s.Assert().Equal(stack.OracleID, string(poolsPre[0].OracleContractID)) + + s.Assert().Zero(s.countRows(ctx, pool, "blend_positions"), "blend_positions must be empty before migration runs") + s.Assert().Zero(s.countRows(ctx, pool, "blend_reserves"), "blend_reserves must be empty before migration runs") + s.Assert().Zero(s.countRows(ctx, pool, "blend_backstop_positions"), "blend_backstop_positions must be empty before migration runs") + s.Assert().Zero(s.countLendingStateChanges(ctx, pool), "no LENDING state changes should exist before migration runs") + + s.Assert().False(s.ingestStoreKeyExists(ctx, pool, utils.ProtocolCurrentStateCursorName(blendProtocolID)), + "current-state cursor should not exist before migration runs") + s.Assert().False(s.ingestStoreKeyExists(ctx, pool, utils.ProtocolHistoryCursorName(blendProtocolID)), + "history cursor should not exist before migration runs") + + // ------------------------------------------------------------------ + // Phase 2: protocol-setup marks BLEND's classification status. + // ------------------------------------------------------------------ + exitCode, logs, err := s.testEnv.Containers.RunWalletBackendCommand(ctx, + "wallet-backend-blend-protocol-setup", "protocol-setup --protocol-id "+blendProtocolID, nil) + s.Require().NoError(err) + s.Require().Zerof(exitCode, "protocol-setup should exit 0 (see `docker logs wallet-backend-blend-protocol-setup`); logs:\n%s", logs) + + // ------------------------------------------------------------------ + // Phase 3: protocol-migrate current-state (from the stack's start + // ledger) and protocol-migrate history (from oldest_ingest_ledger), + // both against the datastore backend fed by a host-side exporter — + // see data_migration_test.go's TestProtocolSetupThenCurrentStateMigration + // for the full rationale. + // ------------------------------------------------------------------ + oldestLedger, err := models.IngestStore.Get(ctx, data.OldestLedgerCursorName) + s.Require().NoError(err) + s.Require().Greater(oldestLedger, uint32(0), "oldest_ledger_cursor should be set by live ingestion") + + rpcURL, err := s.testEnv.Containers.RPCContainer.GetConnectionString(ctx) + s.Require().NoError(err) + minioEndpoint, err := s.testEnv.Containers.MinioContainer.GetConnectionString(ctx) + s.Require().NoError(err) + // The exporter must start at the oldest cursor (not stack.StartLedger): + // protocol-migrate history reads its own start from oldest_ingest_ledger. + stopExporter := infrastructure.StartLedgerExporter(s.T(), rpcURL, minioEndpoint, oldestLedger) + defer stopExporter() + + currentStateCmd := fmt.Sprintf("protocol-migrate current-state --protocol-id %s "+ + "--ledger-backend-type datastore --start-ledger %d --window-size 10", blendProtocolID, stack.StartLedger) + exitCode, logs, err = s.testEnv.Containers.RunWalletBackendCommand(ctx, + "wallet-backend-blend-protocol-migrate-current-state", currentStateCmd, s.testEnv.Containers.DatastoreEnv()) + s.Require().NoError(err) + s.Require().Zerof(exitCode, "protocol-migrate current-state should exit 0 (see `docker logs wallet-backend-blend-protocol-migrate-current-state`); logs:\n%s", logs) + + historyCmd := fmt.Sprintf("protocol-migrate history --protocol-id %s "+ + "--ledger-backend-type datastore --window-size 10", blendProtocolID) + exitCode, logs, err = s.testEnv.Containers.RunWalletBackendCommand(ctx, + "wallet-backend-blend-protocol-migrate-history", historyCmd, s.testEnv.Containers.DatastoreEnv()) + s.Require().NoError(err) + s.Require().Zerof(exitCode, "protocol-migrate history should exit 0 (see `docker logs wallet-backend-blend-protocol-migrate-history`); logs:\n%s", logs) + + // ------------------------------------------------------------------ + // Phase 4: post-assertions. + // ------------------------------------------------------------------ + protocols, err := models.Protocols.GetByIDs(ctx, []string{blendProtocolID}) + s.Require().NoError(err) + s.Require().Len(protocols, 1) + s.Assert().Equal(data.StatusSuccess, protocols[0].ClassificationStatus, "classification status should be success") + s.Assert().Equal(data.StatusSuccess, protocols[0].CurrentStateMigrationStatus, "current-state migration status should be success") + s.Assert().Equal(data.StatusSuccess, protocols[0].HistoryMigrationStatus, "history migration status should be success") + + s.Assert().True(s.ingestStoreKeyExists(ctx, pool, utils.ProtocolCurrentStateCursorName(blendProtocolID))) + s.Assert().True(s.ingestStoreKeyExists(ctx, pool, utils.ProtocolHistoryCursorName(blendProtocolID))) + currentStateCursor, err := models.IngestStore.Get(ctx, utils.ProtocolCurrentStateCursorName(blendProtocolID)) + s.Require().NoError(err) + s.Assert().GreaterOrEqual(currentStateCursor, stack.StartLedger, "current-state cursor should have advanced past its init value") + + // The phase-1 fixture performs no claims, so the claimed-total accumulators + // must exist (created by migration) and be empty — a guard that current-state + // indexing folds nothing spurious into them. + s.Assert().Zero(s.countRows(ctx, pool, "blend_pool_claimed")) + s.Assert().Zero(s.countRows(ctx, pool, "blend_backstop_claimed")) + + // blend_pools + pools, err := models.Blend.Pools.GetByIDs(ctx, []string{stack.PoolID}) + s.Require().NoError(err) + s.Require().Len(pools, 1) + poolRow := pools[0] + s.Require().NotNil(poolRow.Status) + s.Assert().Equal(int32(0), *poolRow.Status) + s.Require().NotNil(poolRow.BackstopRate) + s.Assert().Equal(int32(1_000_000), *poolRow.BackstopRate) + s.Require().NotNil(poolRow.MaxPositions) + s.Assert().Equal(int32(4), *poolRow.MaxPositions) + s.Require().NotNil(poolRow.Name) + s.Assert().Equal(stack.PoolName, *poolRow.Name) + s.Assert().Equal(stack.OracleID, string(poolRow.OracleContractID)) + + // blend_reserves: exactly 2 rows (USDC index 0, XLM index 1). + reserves, err := models.Blend.Reserves.GetByPools(ctx, []string{stack.PoolID}) + s.Require().NoError(err) + s.Require().Len(reserves, 2) + usdcReserve := findPositionInReserves(reserves, 0) + xlmReserve := findPositionInReserves(reserves, 1) + s.Require().NotNil(usdcReserve) + s.Require().NotNil(xlmReserve) + s.Assert().Equal(stack.USDCTokenID, string(usdcReserve.AssetContractID)) + s.Assert().Equal(stack.XLMTokenID, string(xlmReserve.AssetContractID)) + s.Assert().True(usdcReserve.Enabled) + s.Assert().True(xlmReserve.Enabled) + s.Assert().NotEmpty(usdcReserve.BRate) + s.Assert().NotEmpty(usdcReserve.DRate) + s.Assert().NotEmpty(xlmReserve.BRate) + s.Assert().NotEmpty(xlmReserve.DRate) + + // blend_positions: supplier. + supplierPositions, err := models.Blend.Positions.GetByAccount(ctx, stack.Supplier.Address()) + s.Require().NoError(err) + supplierUSDC := findPositionByIndex(supplierPositions, 0) + supplierXLM := findPositionByIndex(supplierPositions, 1) + s.Require().NotNil(supplierUSDC) + s.Require().NotNil(supplierXLM) + s.Assert().Greater(parseBigIntStr(s.T(), supplierUSDC.SupplyBTokens).Sign(), 0) + s.Assert().Greater(parseBigIntStr(s.T(), supplierUSDC.CollateralBTokens).Sign(), 0) + s.Assert().Greater(parseBigIntStr(s.T(), supplierXLM.SupplyBTokens).Sign(), 0) + + expectedSupplierNetSuppliedUSDC := big.NewInt(infrastructure.BlendSupplierSupplyUSDC + infrastructure.BlendSupplierSupplyCollateralUSDC) + s.Assert().Zero(parseBlendDecimalToBigInt(s.T(), supplierUSDC.NetSupplied).Cmp(expectedSupplierNetSuppliedUSDC), + "supplier's USDC net_supplied should equal SupplyUSDC + SupplyCollateralUSDC") + expectedSupplierNetSuppliedXLM := big.NewInt(infrastructure.BlendSupplierSupplyXLM) + s.Assert().Zero(parseBlendDecimalToBigInt(s.T(), supplierXLM.NetSupplied).Cmp(expectedSupplierNetSuppliedXLM), + "supplier's XLM net_supplied should equal SupplyXLM") + + // blend_positions: borrower. + borrowerPositions, err := models.Blend.Positions.GetByAccount(ctx, stack.Borrower.Address()) + s.Require().NoError(err) + borrowerUSDC := findPositionByIndex(borrowerPositions, 0) + borrowerXLM := findPositionByIndex(borrowerPositions, 1) + s.Require().NotNil(borrowerUSDC) + s.Require().NotNil(borrowerXLM) + s.Assert().Greater(parseBigIntStr(s.T(), borrowerUSDC.CollateralBTokens).Sign(), 0) + s.Assert().Greater(parseBigIntStr(s.T(), borrowerXLM.LiabilityDTokens).Sign(), 0) + + expectedBorrowerNetBorrowedXLM := big.NewInt(infrastructure.BlendBorrowerBorrowXLM - infrastructure.BlendBorrowerRepayXLM) + s.Assert().Zero(parseBlendDecimalToBigInt(s.T(), borrowerXLM.NetBorrowed).Cmp(expectedBorrowerNetBorrowedXLM), + "borrower's XLM net_borrowed should equal BorrowXLM - RepayXLM") + + // blend_backstop_positions: whale. + backstopPositions, err := models.Blend.BackstopPositions.GetByAccount(ctx, stack.Whale.Address()) + s.Require().NoError(err) + s.Require().Len(backstopPositions, 1) + whaleBackstop := backstopPositions[0] + s.Assert().Equal(stack.PoolID, string(whaleBackstop.PoolContractID)) + s.Assert().Equal("500000000000", whaleBackstop.Shares, "first-ever backstop deposit mints shares 1:1") + s.Assert().Empty(whaleBackstop.Q4W, "no queued withdrawals in phase 1") + + // blend_backstop_pools. + backstopPools, err := models.Blend.BackstopPools.GetByIDs(ctx, []string{stack.PoolID}) + s.Require().NoError(err) + s.Require().Len(backstopPools, 1) + s.Assert().Greater(parseBigIntStr(s.T(), backstopPools[0].Shares).Sign(), 0) + s.Assert().Greater(parseBigIntStr(s.T(), backstopPools[0].Tokens).Sign(), 0) + + // state_changes: LENDING counts by (account, reason). + s.Assert().Equal(2, s.countLendingReason(ctx, pool, stack.Supplier.Address(), "SUPPLY")) + s.Assert().Equal(1, s.countLendingReason(ctx, pool, stack.Supplier.Address(), "SUPPLY_COLLATERAL")) + s.Assert().Equal(1, s.countLendingReason(ctx, pool, stack.Borrower.Address(), "SUPPLY_COLLATERAL")) + s.Assert().Equal(1, s.countLendingReason(ctx, pool, stack.Borrower.Address(), "BORROW")) + s.Assert().Equal(1, s.countLendingReason(ctx, pool, stack.Borrower.Address(), "REPAY")) + s.Assert().Equal(1, s.countLendingReason(ctx, pool, stack.Whale.Address(), "BACKSTOP_DEPOSIT")) + + s.assertAllLendingPoolIDs(ctx, pool, stack.PoolID) +} + +// findPositionInReserves returns the blend_reserves row for reserveIndex, or nil. +func findPositionInReserves(reserves []blenddata.Reserve, reserveIndex int32) *blenddata.Reserve { + for i := range reserves { + if reserves[i].ReserveIndex == reserveIndex { + return &reserves[i] + } + } + return nil +} + +// ============================================================================ +// BlendLiveIngestionTestSuite +// ============================================================================ + +// BlendLiveIngestionTestSuite drives Blend v2 phase-2 ops (withdrawals, +// backstop queue/dequeue, emissions claims, liquidation) under live ingestion +// and asserts the result primarily over GraphQL (testEnv.WBClient), with a +// handful of DB-level assertions for state the GraphQL surface doesn't expose +// directly (emissions config rows, post-liquidation cost-basis positions). +// +// All steps run inside a single test method — rather than testify's +// alphabetically-ordered separate methods — since the steps are strictly +// sequential (each depends on the previous step's on-chain/DB state). +type BlendLiveIngestionTestSuite struct { + suite.Suite + testEnv *infrastructure.TestEnvironment + stack *infrastructure.BlendStack +} + +func (s *BlendLiveIngestionTestSuite) setupDB() (*pgxpool.Pool, func()) { + ctx := context.Background() + dbURL, err := s.testEnv.Containers.GetWalletDBConnectionString(ctx) + s.Require().NoError(err) + + pool, err := db.OpenDBConnectionPool(ctx, dbURL) + s.Require().NoError(err) + + return pool, func() { pool.Close() } +} + +func (s *BlendLiveIngestionTestSuite) setupModels(pool *pgxpool.Pool) *data.Models { + dbMetrics := metrics.NewMetrics(prometheus.NewRegistry()).DB + models, err := data.NewModels(pool, dbMetrics) + s.Require().NoError(err) + + return models +} + +// assertApproxRelative asserts actual is within tolerance (a fraction, e.g. +// 0.01 for 1%) of expected. +func (s *BlendLiveIngestionTestSuite) assertApproxRelative(expected, actual, tolerance float64) { + diff := math.Abs(actual-expected) / expected + s.Assert().LessOrEqualf(diff, tolerance, "expected ~%v, got %v (relative diff %v)", expected, actual, diff) +} + +// fetchLendingChanges fetches an account's LENDING state changes filtered to +// one reason, type-asserting every node to *wbtypes.LendingChange. +func (s *BlendLiveIngestionTestSuite) fetchLendingChanges(ctx context.Context, address, reason string) []*wbtypes.LendingChange { + category := "LENDING" + first := int32(50) + conn, err := s.testEnv.WBClient.GetAccountStateChanges(ctx, address, nil, nil, &category, &reason, nil, nil, &first, nil, nil, nil) + s.Require().NoError(err) + s.Require().NotNil(conn) + + out := make([]*wbtypes.LendingChange, 0, len(conn.Edges)) + for _, e := range conn.Edges { + s.Require().NotNil(e.Node, "expected a non-null state change node for reason %s", reason) + lc, ok := e.Node.(*wbtypes.LendingChange) + s.Require().Truef(ok, "expected *LendingChange node for reason %s, got %T", reason, e.Node) + out = append(out, lc) + } + return out +} + +// requireOneLendingChange asserts changes has exactly one entry and returns it. +func (s *BlendLiveIngestionTestSuite) requireOneLendingChange(changes []*wbtypes.LendingChange, reason string) *wbtypes.LendingChange { + s.Require().Lenf(changes, 1, "expected exactly one LENDING/%s state change", reason) + return changes[0] +} + +// assertTokenPoolAmount asserts lc carries a non-nil TokenID equal to +// wantToken, a non-nil PoolID equal to wantPool, and a positive Amount — the +// shape of WITHDRAW, WITHDRAW_COLLATERAL, and a pool-side CLAIM (see +// internal/services/blend/events.go's decodeWithdrawAmbiguous/ +// decodeClaimAmbiguous). +func (s *BlendLiveIngestionTestSuite) assertTokenPoolAmount(lc *wbtypes.LendingChange, wantToken, wantPool string) { + s.Require().NotNil(lc.TokenID) + s.Assert().Equal(wantToken, *lc.TokenID) + s.Require().NotNil(lc.PoolID) + s.Assert().Equal(wantPool, *lc.PoolID) + s.Require().NotNil(lc.Amount) + s.Assert().Greater(parseBigIntStr(s.T(), *lc.Amount).Sign(), 0) +} + +func (s *BlendLiveIngestionTestSuite) TestBlendLiveIngestion() { + ctx := context.Background() + stack := s.stack + + // ------------------------------------------------------------------ + // Step 1: restart ingest with Blend price-snapshot env wired in. + // ------------------------------------------------------------------ + err := s.testEnv.RestartIngestContainer(ctx, map[string]string{ + "BLEND_PRICE_INTERVAL": "5s", + "BLEND_BACKSTOP_LP_CONTRACT_ID": stack.CometID, + }) + s.Require().NoError(err) + s.Require().NoError(s.testEnv.Containers.WaitForIngestCatchup(ctx)) + + pool, cleanup := s.setupDB() + defer cleanup() + models := s.setupModels(pool) + + // ------------------------------------------------------------------ + // Step 2: oracle prices. Exactly 4 rows: (oracle,USDC), (oracle,XLM), + // (comet,comet) [LP self-priced], (comet,BLND) [sibling]. BLND is not + // a reserve, so the SEP-40 leg never snapshots it under the oracle. + // ------------------------------------------------------------------ + s.Require().Eventually(func() bool { + prices, priceErr := models.Blend.OraclePrices.GetByOracles(ctx, []string{stack.OracleID, stack.CometID}) + if priceErr != nil || len(prices) != 4 { + return false + } + now := time.Now().Unix() + for _, p := range prices { + if p.PriceDecimals != 7 { + return false + } + if p.PriceTimestamp < now-120 { + return false + } + } + return true + }, 90*time.Second, 5*time.Second, "expected 4 fresh blend_oracle_prices rows within 90s") + + // ------------------------------------------------------------------ + // Step 3: phase-2 ops, observing the whale's queued backstop position + // mid-flight (between queue and dequeue). + // ------------------------------------------------------------------ + var whaleQueuedPositions *wbtypes.BlendAccountPositions + s.testEnv.Containers.SubmitBlendPhase2Ops(ctx, s.T(), stack, func() { + s.Require().NoError(s.testEnv.Containers.WaitForIngestCatchup(ctx)) + var posErr error + whaleQueuedPositions, posErr = s.testEnv.WBClient.GetAccountBlendPositions(ctx, stack.Whale.Address()) + s.Require().NoError(posErr) + }) + s.Require().NoError(s.testEnv.Containers.WaitForIngestCatchup(ctx)) + // Let the 5s price interval re-snapshot the crashed USDC price. + time.Sleep(6 * time.Second) + + s.assertQueuedWhalePositions(whaleQueuedPositions, stack) + + // ------------------------------------------------------------------ + // Step 4: blendPools. + // ------------------------------------------------------------------ + poolsList, err := s.testEnv.WBClient.GetBlendPools(ctx) + s.Require().NoError(err) + s.Require().Len(poolsList, 1, "exactly one Blend pool should exist") + catalogPool := poolsList[0] + s.assertPoolCatalog(catalogPool, stack) + + // ------------------------------------------------------------------ + // Step 5: blendPool(known) / blendPool(unknown). + // ------------------------------------------------------------------ + onePool, err := s.testEnv.WBClient.GetBlendPool(ctx, stack.PoolID) + s.Require().NoError(err) + s.Require().NotNil(onePool) + s.Assert().Equal(catalogPool.Address, onePool.Address) + s.Assert().Equal(catalogPool.Name, onePool.Name) + + unknownPool, err := s.testEnv.WBClient.GetBlendPool(ctx, randomContractAddress(s.T())) + s.Require().NoError(err) + s.Assert().Nil(unknownPool, "an unknown but valid pool address should resolve to nil, not an error") + + // ------------------------------------------------------------------ + // Step 6: supplier positions. + // ------------------------------------------------------------------ + supplierPositions, err := s.testEnv.WBClient.GetAccountBlendPositions(ctx, stack.Supplier.Address()) + s.Require().NoError(err) + s.assertSupplierPositions(supplierPositions, stack) + + // ------------------------------------------------------------------ + // Step 7: borrower positions. + // ------------------------------------------------------------------ + borrowerPositions, err := s.testEnv.WBClient.GetAccountBlendPositions(ctx, stack.Borrower.Address()) + s.Require().NoError(err) + s.assertBorrowerPositions(borrowerPositions, stack) + + // ------------------------------------------------------------------ + // Step 8: whale backstop (post-dequeue final state). + // ------------------------------------------------------------------ + whalePositions, err := s.testEnv.WBClient.GetAccountBlendPositions(ctx, stack.Whale.Address()) + s.Require().NoError(err) + s.assertWhaleFinalPositions(whalePositions, stack) + + // ------------------------------------------------------------------ + // Step 9: state changes. + // ------------------------------------------------------------------ + s.assertStateChanges(ctx, stack) + + // ------------------------------------------------------------------ + // Step 10: emissions DB rows. + // ------------------------------------------------------------------ + s.assertEmissionsRows(ctx, models, stack) + + // ------------------------------------------------------------------ + // Step 11: post-liquidation positions (DB-level). + // ------------------------------------------------------------------ + s.assertPostLiquidationPositions(ctx, models, stack) +} + +// assertQueuedWhalePositions asserts the whale's backstop position while its +// withdrawal is queued (between BackstopQueueWithdrawal and +// BackstopDequeueWithdrawal). +// +// bp.Shares reflects only the ACTIVE (non-queued) share balance: entries.go's +// decodeBackstopUserBalance decodes it as a straight passthrough of the +// contract's on-chain UserBalance.shares field, and blend_positions.go's +// buildBackstopPosition doc comment states this explicitly ("bp.Shares holds +// only the ACTIVE share balance — queued-for-withdrawal shares live in +// bp.Q4W"). Queuing a withdrawal therefore moves shares out of the active +// balance into Q4W, so the active balance during the queued window is +// 500,000,000,000 (initial) - 50,000,000,000 (queued) = 450,000,000,000. +func (s *BlendLiveIngestionTestSuite) assertQueuedWhalePositions(positions *wbtypes.BlendAccountPositions, stack *infrastructure.BlendStack) { + s.Require().NotNil(positions) + s.Require().Len(positions.Backstop, 1) + bp := positions.Backstop[0] + s.Assert().Equal(stack.PoolID, bp.PoolAddress) + s.Assert().Equal("450000000000", bp.Shares, + "active shares should be reduced by the queued amount while the withdrawal is pending") + + s.Require().Len(bp.Q4W, 1) + q := bp.Q4W[0] + s.Assert().Equal("50000000000", q.Amount) + now := time.Now().Unix() + s.Assert().GreaterOrEqual(q.Expiration, now+16*86400) + s.Assert().LessOrEqual(q.Expiration, now+18*86400) + s.Assert().Greater(parseBigIntStr(s.T(), q.LpTokens).Sign(), 0) + s.Assert().NotNil(q.UsdValue) +} + +// assertPoolCatalog asserts blendPools/blendPool's pool-wide catalog view. +func (s *BlendLiveIngestionTestSuite) assertPoolCatalog(p wbtypes.BlendPool, stack *infrastructure.BlendStack) { + s.Assert().Equal(stack.PoolID, p.Address) + s.Require().NotNil(p.Name) + s.Assert().Equal(stack.PoolName, *p.Name) + s.Require().NotNil(p.Status) + s.Assert().Equal(wbtypes.BlendPoolStatusAdminActive, *p.Status) + s.Require().NotNil(p.BackstopRate) + s.Assert().Equal(int32(1_000_000), *p.BackstopRate) + s.Require().NotNil(p.MaxPositions) + s.Assert().Equal(int32(4), *p.MaxPositions) + s.Require().NotNil(p.OracleContractID) + s.Assert().Equal(stack.OracleID, *p.OracleContractID) + s.Assert().NotNil(p.SuppliedUsd) + s.Assert().NotNil(p.BorrowedUsd) + s.Assert().NotNil(p.BackstopUsd) + s.Assert().NotNil(p.NetApy) + s.Require().Len(p.Reserves, 2) + + usdc := findReserveByAsset(p.Reserves, stack.USDCTokenID) + xlm := findReserveByAsset(p.Reserves, stack.XLMTokenID) + s.Require().NotNil(usdc) + s.Require().NotNil(xlm) + + s.Assert().True(usdc.Enabled) + s.Assert().True(xlm.Enabled) + + s.Require().NotNil(usdc.CFactor) + s.Assert().Equal(int32(9_500_000), *usdc.CFactor) + s.Require().NotNil(usdc.LFactor) + s.Assert().Equal(int32(9_500_000), *usdc.LFactor) + s.Require().NotNil(xlm.CFactor) + s.Assert().Equal(int32(9_000_000), *xlm.CFactor) + s.Require().NotNil(xlm.LFactor) + s.Assert().Equal(int32(9_000_000), *xlm.LFactor) + + s.Assert().NotNil(usdc.SupplyApy) + s.Assert().NotNil(usdc.BorrowApy) + s.Assert().NotNil(xlm.SupplyApy) + s.Assert().NotNil(xlm.BorrowApy) + + s.Require().NotNil(usdc.PriceUsd) + // USDC's oracle price was crashed to $0.005 in the liquidation fixture. + s.assertApproxRelative(0.005, *usdc.PriceUsd, 0.01) + s.Require().NotNil(xlm.PriceUsd) + s.assertApproxRelative(0.10, *xlm.PriceUsd, 0.01) + + s.Assert().Greater(parseBigIntStr(s.T(), usdc.SuppliedTokens).Sign(), 0) + s.Assert().Greater(parseBigIntStr(s.T(), xlm.SuppliedTokens).Sign(), 0) + s.Assert().Greater(parseBigIntStr(s.T(), xlm.BorrowedTokens).Sign(), 0) + + s.Require().NotNil(xlm.Utilization) + s.Assert().Greater(*xlm.Utilization, 0.0) + + s.Assert().NotNil(usdc.EmissionsSupplyApr, "USDC reserve has bToken emissions configured") +} + +// assertSupplierPositions asserts the supplier's positions after phase-2 +// withdrawals and an emissions claim. +func (s *BlendLiveIngestionTestSuite) assertSupplierPositions(positions *wbtypes.BlendAccountPositions, stack *infrastructure.BlendStack) { + s.Require().NotNil(positions) + s.Require().Len(positions.Pools, 1) + pp := positions.Pools[0] + s.Assert().Equal(stack.PoolID, pp.PoolAddress) + s.Require().NotNil(pp.PoolName) + s.Assert().Equal(stack.PoolName, *pp.PoolName) + s.Assert().NotNil(pp.UsdValue) + s.Assert().NotNil(pp.SuppliedUsd) + s.Assert().NotNil(pp.NetApy) + s.Assert().Greater(parseBigIntStr(s.T(), pp.ClaimedBlnd).Sign(), 0, "supplier claimed pool emissions in phase 2") + + usdcPos := findReservePositionByAsset(pp.Reserves, stack.USDCTokenID) + xlmPos := findReservePositionByAsset(pp.Reserves, stack.XLMTokenID) + s.Require().NotNil(usdcPos) + s.Require().NotNil(xlmPos) + + s.Assert().Greater(parseBigIntStr(s.T(), usdcPos.SuppliedTokens).Sign(), 0) + s.Assert().Greater(parseBigIntStr(s.T(), usdcPos.CollateralTokens).Sign(), 0) + s.Assert().NotNil(usdcPos.SuppliedUsd) + s.Assert().NotNil(usdcPos.PriceUsd) + s.Assert().GreaterOrEqual(parseBigIntStr(s.T(), usdcPos.InterestEarned).Sign(), 0) + s.Assert().GreaterOrEqual(parseBigIntStr(s.T(), usdcPos.EmissionsEarnedBlnd).Sign(), 0) + + s.Assert().Greater(parseBigIntStr(s.T(), xlmPos.SuppliedTokens).Sign(), 0) +} + +// assertBorrowerPositions asserts the borrower's positions after the +// liquidation auction partially absorbed its debt and collateral. +func (s *BlendLiveIngestionTestSuite) assertBorrowerPositions(positions *wbtypes.BlendAccountPositions, stack *infrastructure.BlendStack) { + s.Require().NotNil(positions) + s.Require().Len(positions.Pools, 1) + pp := positions.Pools[0] + + xlmPos := findReservePositionByAsset(pp.Reserves, stack.XLMTokenID) + s.Require().NotNil(xlmPos) + s.Assert().Greater(parseBigIntStr(s.T(), xlmPos.BorrowedTokens).Sign(), 0, "debt remains after a 50% auction fill") + s.Assert().NotNil(xlmPos.BorrowApy) + s.Assert().GreaterOrEqual(parseBigIntStr(s.T(), xlmPos.InterestPaid).Sign(), 0) + + usdcPos := findReservePositionByAsset(pp.Reserves, stack.USDCTokenID) + s.Require().NotNil(usdcPos) + collateralUSDC := parseBigIntStr(s.T(), usdcPos.CollateralTokens) + s.Assert().Greater(collateralUSDC.Sign(), 0) + s.Assert().Less(collateralUSDC.Cmp(big.NewInt(infrastructure.BlendBorrowerSupplyCollateralUSDC)), 0, + "the liquidation auction should have taken some of the borrower's USDC collateral lot") +} + +// assertWhaleFinalPositions asserts the whale's backstop position after the +// queued withdrawal was dequeued (restored) and backstop emissions claimed. +func (s *BlendLiveIngestionTestSuite) assertWhaleFinalPositions(positions *wbtypes.BlendAccountPositions, stack *infrastructure.BlendStack) { + s.Require().NotNil(positions) + s.Require().Len(positions.Backstop, 1) + bp := positions.Backstop[0] + s.Assert().Equal(stack.PoolID, bp.PoolAddress) + // The dequeue restores the original 50,000e7-share deposit, and the phase-2 backstop claim + // re-stakes the claimed LP into the position (backstop.claim deposits rather than pays out), + // minting a small number of additional shares on top. + shares := parseBigIntStr(s.T(), bp.Shares) + s.Assert().Greater(shares.Cmp(big.NewInt(500_000_000_000)), 0, + "dequeue should have restored the deposit and the backstop claim re-staked LP on top") + s.Assert().Greater(parseBigIntStr(s.T(), bp.LpTokens).Sign(), 0) + s.Assert().NotNil(bp.UsdValue) + s.Assert().Empty(bp.Q4W, "the queued withdrawal was dequeued") + s.Assert().GreaterOrEqual(parseBigIntStr(s.T(), bp.EmissionsEarnedBlnd).Sign(), 0) + + s.Assert().Greater(parseBigIntStr(s.T(), positions.BackstopClaimedLp).Sign(), 0, "whale claimed backstop emissions in phase 2") +} + +// assertStateChanges asserts the phase-2 LENDING state changes over GraphQL. +// Token/Amount/PoolID expectations mirror internal/services/blend/events.go's +// EventRow construction exactly (see decodeWithdrawAmbiguous, +// decodeClaimAmbiguous, decodeBackstopDeposit/WithdrawQueue/WithdrawCancel, +// and the liquidation rows in decodeFillAuction), not a blanket "always BLND" +// assumption: WITHDRAW/WITHDRAW_COLLATERAL carry the underlying reserve asset +// (USDC here) as their token, only a pool-side CLAIM carries BLND, backstop +// queue/cancel/claim rows carry no token at all, and LIQUIDATION rows carry +// neither a token nor an amount. +func (s *BlendLiveIngestionTestSuite) assertStateChanges(ctx context.Context, stack *infrastructure.BlendStack) { + supplierWithdraw := s.requireOneLendingChange(s.fetchLendingChanges(ctx, stack.Supplier.Address(), "WITHDRAW"), "WITHDRAW") + s.assertTokenPoolAmount(supplierWithdraw, stack.USDCTokenID, stack.PoolID) + + supplierWithdrawCollateral := s.requireOneLendingChange(s.fetchLendingChanges(ctx, stack.Supplier.Address(), "WITHDRAW_COLLATERAL"), "WITHDRAW_COLLATERAL") + s.assertTokenPoolAmount(supplierWithdrawCollateral, stack.USDCTokenID, stack.PoolID) + + supplierClaim := s.requireOneLendingChange(s.fetchLendingChanges(ctx, stack.Supplier.Address(), "CLAIM"), "CLAIM") + s.assertTokenPoolAmount(supplierClaim, stack.BLNDTokenID, stack.PoolID) + + whaleQueue := s.requireOneLendingChange(s.fetchLendingChanges(ctx, stack.Whale.Address(), "BACKSTOP_WITHDRAW_QUEUE"), "BACKSTOP_WITHDRAW_QUEUE") + s.Assert().Nil(whaleQueue.TokenID, "backstop share rows carry no token_id") + s.Require().NotNil(whaleQueue.PoolID) + s.Assert().Equal(stack.PoolID, *whaleQueue.PoolID) + s.Require().NotNil(whaleQueue.Amount) + s.Assert().Greater(parseBigIntStr(s.T(), *whaleQueue.Amount).Sign(), 0) + + whaleCancel := s.requireOneLendingChange(s.fetchLendingChanges(ctx, stack.Whale.Address(), "BACKSTOP_WITHDRAW_CANCEL"), "BACKSTOP_WITHDRAW_CANCEL") + s.Assert().Nil(whaleCancel.TokenID) + s.Require().NotNil(whaleCancel.PoolID) + s.Assert().Equal(stack.PoolID, *whaleCancel.PoolID) + s.Require().NotNil(whaleCancel.Amount) + s.Assert().Greater(parseBigIntStr(s.T(), *whaleCancel.Amount).Sign(), 0) + + whaleClaim := s.requireOneLendingChange(s.fetchLendingChanges(ctx, stack.Whale.Address(), "CLAIM"), "CLAIM") + s.Assert().Nil(whaleClaim.TokenID, "a backstop claim pays out Comet LP tokens, tracked with no token_id") + s.Assert().Nil(whaleClaim.PoolID, "a backstop claim aggregates across every pool, carrying no pool address") + s.Require().NotNil(whaleClaim.Amount) + s.Assert().Greater(parseBigIntStr(s.T(), *whaleClaim.Amount).Sign(), 0) + + borrowerLiquidation := s.requireOneLendingChange(s.fetchLendingChanges(ctx, stack.Borrower.Address(), "LIQUIDATION"), "LIQUIDATION") + s.Assert().Nil(borrowerLiquidation.TokenID) + s.Assert().Nil(borrowerLiquidation.Amount) + s.Require().NotNil(borrowerLiquidation.PoolID) + s.Assert().Equal(stack.PoolID, *borrowerLiquidation.PoolID) + + fillerLiquidation := s.requireOneLendingChange(s.fetchLendingChanges(ctx, stack.Filler.Address(), "LIQUIDATION"), "LIQUIDATION") + s.Assert().Nil(fillerLiquidation.TokenID) + s.Assert().Nil(fillerLiquidation.Amount) + s.Require().NotNil(fillerLiquidation.PoolID) + s.Assert().Equal(stack.PoolID, *fillerLiquidation.PoolID) +} + +// assertEmissionsRows asserts the raw blend_emissions/blend_reserve_emissions +// rows the GraphQL surface derives its emissions figures from. +func (s *BlendLiveIngestionTestSuite) assertEmissionsRows(ctx context.Context, models *data.Models, stack *infrastructure.BlendStack) { + supplierEmissions, err := models.Blend.Emissions.GetByAccount(ctx, stack.Supplier.Address()) + s.Require().NoError(err) + hasSupplierBTokenStream := false + for _, e := range supplierEmissions { + if e.TokenID == 1 { + hasSupplierBTokenStream = true + } + } + s.Assert().True(hasSupplierBTokenStream, "expected a token_id=1 (USDC bToken) emission row for the supplier") + + whaleEmissions, err := models.Blend.Emissions.GetByAccount(ctx, stack.Whale.Address()) + s.Require().NoError(err) + hasWhaleBackstopStream := false + for _, e := range whaleEmissions { + if e.TokenID == blenddata.BackstopEmissionTokenID { + hasWhaleBackstopStream = true + } + } + s.Assert().True(hasWhaleBackstopStream, "expected a token_id=-1 (backstop) emission row for the whale") + + reserveEmissions, err := models.Blend.ReserveEmissions.GetByPools(ctx, []string{stack.PoolID}) + s.Require().NoError(err) + tokenIDs := map[int32]bool{} + for _, re := range reserveEmissions { + tokenIDs[re.ReserveTokenID] = true + } + s.Assert().True(tokenIDs[1], "expected reserve emission config for token_id=1 (USDC bToken)") + s.Assert().True(tokenIDs[2], "expected reserve emission config for token_id=2 (XLM dToken)") + + // Lifetime claimed totals: the accumulator rows that back the resolver's + // claimedBlnd (per pool) and backstopClaimedLp (account-wide). The supplier + // claimed pool-reserve emissions and the whale claimed backstop emissions in + // phase 2, so both must have folded a positive total. + supplierClaimed, err := models.Blend.PoolClaimed.GetByAccount(ctx, stack.Supplier.Address()) + s.Require().NoError(err) + s.Require().Len(supplierClaimed, 1, "supplier has one pool-source claimed row") + s.Assert().Equal(stack.PoolID, string(supplierClaimed[0].PoolContractID)) + s.Assert().Greater(parseBigIntStr(s.T(), supplierClaimed[0].ClaimedBlnd).Sign(), 0, + "supplier's pool claim folded into blend_pool_claimed") + + whaleClaimed, err := models.Blend.BackstopClaimed.GetByAccount(ctx, stack.Whale.Address()) + s.Require().NoError(err) + s.Require().NotNil(whaleClaimed, "whale has an account-wide backstop claimed row") + s.Assert().Greater(parseBigIntStr(s.T(), whaleClaimed.ClaimedLp).Sign(), 0, + "whale's backstop claim folded into blend_backstop_claimed") +} + +// assertPostLiquidationPositions asserts the borrower's collateral shrank and +// the filler picked up a USDC collateral position, at the DB level. +func (s *BlendLiveIngestionTestSuite) assertPostLiquidationPositions(ctx context.Context, models *data.Models, stack *infrastructure.BlendStack) { + borrowerPositions, err := models.Blend.Positions.GetByAccount(ctx, stack.Borrower.Address()) + s.Require().NoError(err) + borrowerUSDC := findPositionByIndex(borrowerPositions, 0) + s.Require().NotNil(borrowerUSDC) + s.Assert().Less(parseBigIntStr(s.T(), borrowerUSDC.CollateralBTokens).Cmp(big.NewInt(infrastructure.BlendBorrowerSupplyCollateralUSDC)), 0, + "the liquidation auction should have removed some of the borrower's USDC collateral lot") + + fillerPositions, err := models.Blend.Positions.GetByAccount(ctx, stack.Filler.Address()) + s.Require().NoError(err) + fillerUSDC := findPositionByIndex(fillerPositions, 0) + s.Require().NotNil(fillerUSDC) + s.Assert().Greater(parseBigIntStr(s.T(), fillerUSDC.CollateralBTokens).Sign(), 0) +} diff --git a/internal/integrationtests/infrastructure/blend_contracts.go b/internal/integrationtests/infrastructure/blend_contracts.go new file mode 100644 index 000000000..c7cfad3c7 --- /dev/null +++ b/internal/integrationtests/infrastructure/blend_contracts.go @@ -0,0 +1,491 @@ +// Package infrastructure provides Soroban transaction helpers for integration tests. +// +// This file adds thin, typed wrappers over executeSorobanOperationAs for each Blend v2 contract +// call the integration tests need to drive. Each wrapper builds an InvokeHostFunction from a +// contract address, function name, and ScVal args (using the builders in blend_operations.go), +// executes it, and returns either the transaction hash or a decoded return value. +// +// All wrappers take an explicit contract-address string rather than a stack struct — assembling +// wrappers into a full deployed-stack abstraction is deferred to a later task. +package infrastructure + +import ( + "context" + "encoding/base64" + "fmt" + "math/big" + "testing" + + "github.com/stellar/go-stellar-sdk/keypair" + "github.com/stellar/go-stellar-sdk/txnbuild" + "github.com/stellar/go-stellar-sdk/xdr" + "github.com/stretchr/testify/require" +) + +// scAddressVec builds a Vec
ScVal from the given addresses, in order. +func scAddressVec(t *testing.T, addrs []string) xdr.ScVal { + t.Helper() + + vals := make([]xdr.ScVal, 0, len(addrs)) + for _, a := range addrs { + vals = append(vals, scAddr(t, a)) + } + return scVec(vals...) +} + +// scU32Vec builds a Vec ScVal from the given values, in order. +func scU32Vec(vals []uint32) xdr.ScVal { + scVals := make([]xdr.ScVal, 0, len(vals)) + for _, v := range vals { + scVals = append(scVals, scU32(v)) + } + return scVec(scVals...) +} + +// scI128Vec builds a Vec ScVal from the given values, in order. +func scI128Vec(t *testing.T, vals []*big.Int) xdr.ScVal { + t.Helper() + + scVals := make([]xdr.ScVal, 0, len(vals)) + for _, v := range vals { + scVals = append(scVals, scI128(t, v)) + } + return scVec(scVals...) +} + +// scVoid builds an ScvVoid ScVal, used to encode Option::None. +func scVoid() xdr.ScVal { + return xdr.ScVal{Type: xdr.ScValTypeScvVoid} +} + +// buildInvokeOp builds an InvokeHostFunction operation invoking functionName on the contract at +// contractAddress with args, sourced from sourceAddress. +func buildInvokeOp(t *testing.T, contractAddress, functionName string, args []xdr.ScVal, sourceAddress string) *txnbuild.InvokeHostFunction { + t.Helper() + + contractScAddress, err := parseAddressToScAddress(contractAddress) + require.NoError(t, err, "parsing contract address %s", contractAddress) + + return &txnbuild.InvokeHostFunction{ + HostFunction: xdr.HostFunction{ + Type: xdr.HostFunctionTypeHostFunctionTypeInvokeContract, + InvokeContract: &xdr.InvokeContractArgs{ + ContractAddress: contractScAddress, + FunctionName: xdr.ScSymbol(functionName), + Args: xdr.ScVec(args), + }, + }, + SourceAccount: sourceAddress, + } +} + +// invokeResultAddress fetches the confirmed transaction identified by hash and decodes its Soroban +// return value as an Address, returning the address in G-/C- string form. Used by contract +// factories (comet new_c_pool, pool factory deploy) whose invoke return value is the address of +// the contract they just deployed. +// +// Handles both TransactionMetaV3 (SorobanTransactionMeta.ReturnValue, a plain ScVal) and +// TransactionMetaV4 (SorobanTransactionMetaV2.ReturnValue, a *ScVal) — mirroring +// ExtractClaimableBalanceIDsFromMeta's meta-version handling, extended to also cover V3 since the +// return value (unlike claimable balance creation, which that helper looks for) is populated by +// both meta versions. +func (s *SharedContainers) invokeResultAddress(ctx context.Context, t *testing.T, hash string) (string, error) { + t.Helper() + + rpcURL, err := s.RPCContainer.GetConnectionString(ctx) + if err != nil { + return "", fmt.Errorf("getting RPC connection string: %w", err) + } + + txResult, err := getTransactionFromRPC(s.httpClient, rpcURL, hash) + if err != nil { + return "", fmt.Errorf("fetching transaction %s: %w", hash, err) + } + + metaBytes, err := base64.StdEncoding.DecodeString(txResult.ResultMetaXDR) + if err != nil { + return "", fmt.Errorf("decoding result meta XDR: %w", err) + } + + var txMeta xdr.TransactionMeta + if err := xdr.SafeUnmarshal(metaBytes, &txMeta); err != nil { + return "", fmt.Errorf("unmarshalling transaction meta: %w", err) + } + + var returnValue xdr.ScVal + switch txMeta.V { + case 3: + v3 := txMeta.MustV3() + if v3.SorobanMeta == nil { + return "", fmt.Errorf("transaction %s meta v3 has no soroban meta", hash) + } + returnValue = v3.SorobanMeta.ReturnValue + case 4: + v4 := txMeta.MustV4() + if v4.SorobanMeta == nil || v4.SorobanMeta.ReturnValue == nil { + return "", fmt.Errorf("transaction %s meta v4 has no soroban return value", hash) + } + returnValue = *v4.SorobanMeta.ReturnValue + default: + return "", fmt.Errorf("transaction %s has unsupported meta version %d", hash, txMeta.V) + } + + if returnValue.Type != xdr.ScValTypeScvAddress || returnValue.Address == nil { + return "", fmt.Errorf("transaction %s return value is %v, not an address", hash, returnValue.Type) + } + + return scAddressToString(*returnValue.Address) +} + +// --------------------------------------------------------------------------------------------- +// Pool (blend_pool_v2) +// --------------------------------------------------------------------------------------------- + +// PoolSubmit invokes submit(from, spender, to, requests) on the pool, with from=spender=to=user. +func (s *SharedContainers) PoolSubmit(ctx context.Context, t *testing.T, poolID string, user *keypair.Full, requests []BlendRequest) string { + t.Helper() + + userAddr := scAddr(t, user.Address()) + args := []xdr.ScVal{userAddr, userAddr, userAddr, scRequestVec(t, requests)} + op := buildInvokeOp(t, poolID, "submit", args, user.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, user, nil, DefaultConfirmationRetries) + require.NoError(t, err, "submitting pool requests") + return hash +} + +// PoolClaim invokes claim(from, reserve_token_ids, to) on the pool, with from=to=user. +func (s *SharedContainers) PoolClaim(ctx context.Context, t *testing.T, poolID string, user *keypair.Full, reserveTokenIDs []uint32) string { + t.Helper() + + userAddr := scAddr(t, user.Address()) + args := []xdr.ScVal{userAddr, scU32Vec(reserveTokenIDs), userAddr} + op := buildInvokeOp(t, poolID, "claim", args, user.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, user, nil, DefaultConfirmationRetries) + require.NoError(t, err, "claiming pool emissions") + return hash +} + +// PoolNewAuction invokes new_auction(auction_type, user, bid, lot, percent) on the pool, signed by +// caller. The auctioned user is passed as a plain address argument, not a signer. +func (s *SharedContainers) PoolNewAuction(ctx context.Context, t *testing.T, poolID string, caller *keypair.Full, auctionType uint32, user string, bid, lot []string, percent uint32) string { + t.Helper() + + args := []xdr.ScVal{scU32(auctionType), scAddr(t, user), scAddressVec(t, bid), scAddressVec(t, lot), scU32(percent)} + op := buildInvokeOp(t, poolID, "new_auction", args, caller.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, caller, nil, DefaultConfirmationRetries) + require.NoError(t, err, "creating pool auction") + return hash +} + +// PoolSetStatus invokes set_status(pool_status) on the pool, signed by admin. +func (s *SharedContainers) PoolSetStatus(ctx context.Context, t *testing.T, poolID string, admin *keypair.Full, status uint32) string { + t.Helper() + + args := []xdr.ScVal{scU32(status)} + op := buildInvokeOp(t, poolID, "set_status", args, admin.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, admin, nil, DefaultConfirmationRetries) + require.NoError(t, err, "setting pool status") + return hash +} + +// PoolQueueSetReserve invokes queue_set_reserve(asset, metadata) on the pool, signed by admin. +func (s *SharedContainers) PoolQueueSetReserve(ctx context.Context, t *testing.T, poolID string, admin *keypair.Full, asset string, cfg BlendReserveConfig) string { + t.Helper() + + args := []xdr.ScVal{scAddr(t, asset), scReserveConfig(t, cfg)} + op := buildInvokeOp(t, poolID, "queue_set_reserve", args, admin.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, admin, nil, DefaultConfirmationRetries) + require.NoError(t, err, "queueing pool reserve config") + return hash +} + +// PoolSetReserve invokes set_reserve(asset) -> u32 on the pool, signed by admin, executing a +// previously queued reserve config change. +func (s *SharedContainers) PoolSetReserve(ctx context.Context, t *testing.T, poolID string, admin *keypair.Full, asset string) string { + t.Helper() + + args := []xdr.ScVal{scAddr(t, asset)} + op := buildInvokeOp(t, poolID, "set_reserve", args, admin.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, admin, nil, DefaultConfirmationRetries) + require.NoError(t, err, "setting pool reserve") + return hash +} + +// PoolSetEmissionsConfig invokes set_emissions_config(res_emission_metadata) on the pool, signed by +// admin. +func (s *SharedContainers) PoolSetEmissionsConfig(ctx context.Context, t *testing.T, poolID string, admin *keypair.Full, metas []BlendEmissionMetadata) string { + t.Helper() + + args := []xdr.ScVal{scEmissionMetadataVec(t, metas)} + op := buildInvokeOp(t, poolID, "set_emissions_config", args, admin.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, admin, nil, DefaultConfirmationRetries) + require.NoError(t, err, "setting pool emissions config") + return hash +} + +// PoolGulpEmissions invokes gulp_emissions() -> i128 on the pool, signed by caller. This is a +// permissionless call that pulls newly distributed emissions into the pool's local accounting. +func (s *SharedContainers) PoolGulpEmissions(ctx context.Context, t *testing.T, poolID string, caller *keypair.Full) string { + t.Helper() + + op := buildInvokeOp(t, poolID, "gulp_emissions", nil, caller.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, caller, nil, DefaultConfirmationRetries) + require.NoError(t, err, "gulping pool emissions") + return hash +} + +// --------------------------------------------------------------------------------------------- +// Backstop (blend_backstop_v2) +// --------------------------------------------------------------------------------------------- + +// BackstopDeposit invokes deposit(from, pool_address, amount) -> i128 on the backstop, signed by +// user. +func (s *SharedContainers) BackstopDeposit(ctx context.Context, t *testing.T, backstopID string, user *keypair.Full, poolID string, amount *big.Int) string { + t.Helper() + + args := []xdr.ScVal{scAddr(t, user.Address()), scAddr(t, poolID), scI128(t, amount)} + op := buildInvokeOp(t, backstopID, "deposit", args, user.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, user, nil, DefaultConfirmationRetries) + require.NoError(t, err, "depositing into backstop") + return hash +} + +// BackstopQueueWithdrawal invokes queue_withdrawal(from, pool_address, amount) -> Q4W on the +// backstop, signed by user. +func (s *SharedContainers) BackstopQueueWithdrawal(ctx context.Context, t *testing.T, backstopID string, user *keypair.Full, poolID string, amount *big.Int) string { + t.Helper() + + args := []xdr.ScVal{scAddr(t, user.Address()), scAddr(t, poolID), scI128(t, amount)} + op := buildInvokeOp(t, backstopID, "queue_withdrawal", args, user.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, user, nil, DefaultConfirmationRetries) + require.NoError(t, err, "queueing backstop withdrawal") + return hash +} + +// BackstopDequeueWithdrawal invokes dequeue_withdrawal(from, pool_address, amount) on the +// backstop, signed by user. +func (s *SharedContainers) BackstopDequeueWithdrawal(ctx context.Context, t *testing.T, backstopID string, user *keypair.Full, poolID string, amount *big.Int) string { + t.Helper() + + args := []xdr.ScVal{scAddr(t, user.Address()), scAddr(t, poolID), scI128(t, amount)} + op := buildInvokeOp(t, backstopID, "dequeue_withdrawal", args, user.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, user, nil, DefaultConfirmationRetries) + require.NoError(t, err, "dequeueing backstop withdrawal") + return hash +} + +// BackstopClaim invokes claim(from, pool_addresses, min_lp_tokens_out) -> i128 on the backstop, +// signed by user. +func (s *SharedContainers) BackstopClaim(ctx context.Context, t *testing.T, backstopID string, user *keypair.Full, poolIDs []string, minLPTokensOut *big.Int) string { + t.Helper() + + args := []xdr.ScVal{scAddr(t, user.Address()), scAddressVec(t, poolIDs), scI128(t, minLPTokensOut)} + op := buildInvokeOp(t, backstopID, "claim", args, user.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, user, nil, DefaultConfirmationRetries) + require.NoError(t, err, "claiming backstop emissions") + return hash +} + +// BackstopDistribute invokes distribute() -> i128 on the backstop, signed by caller. This is a +// permissionless call that distributes queued BLND emissions out to backstop pools. +func (s *SharedContainers) BackstopDistribute(ctx context.Context, t *testing.T, backstopID string, caller *keypair.Full) string { + t.Helper() + + op := buildInvokeOp(t, backstopID, "distribute", nil, caller.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, caller, nil, DefaultConfirmationRetries) + require.NoError(t, err, "distributing backstop emissions") + return hash +} + +// BackstopAddReward invokes add_reward(to_add, to_remove) on the backstop, signed by caller, with +// to_remove always encoded as Option::None (ScvVoid). +func (s *SharedContainers) BackstopAddReward(ctx context.Context, t *testing.T, backstopID string, caller *keypair.Full, toAdd string) string { + t.Helper() + + args := []xdr.ScVal{scAddr(t, toAdd), scVoid()} + op := buildInvokeOp(t, backstopID, "add_reward", args, caller.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, caller, nil, DefaultConfirmationRetries) + require.NoError(t, err, "adding backstop reward") + return hash +} + +// --------------------------------------------------------------------------------------------- +// Emitter +// --------------------------------------------------------------------------------------------- + +// EmitterDistribute invokes distribute() -> i128 on the emitter, signed by caller. This is a +// permissionless call that mints and distributes BLND emissions since the last call. +func (s *SharedContainers) EmitterDistribute(ctx context.Context, t *testing.T, emitterID string, caller *keypair.Full) string { + t.Helper() + + op := buildInvokeOp(t, emitterID, "distribute", nil, caller.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, caller, nil, DefaultConfirmationRetries) + require.NoError(t, err, "distributing emitter emissions") + return hash +} + +// EmitterInitialize invokes initialize(blnd_token, backstop, backstop_token) on the emitter, +// signed by caller. +func (s *SharedContainers) EmitterInitialize(ctx context.Context, t *testing.T, emitterID string, caller *keypair.Full, blndToken, backstop, backstopToken string) string { + t.Helper() + + args := []xdr.ScVal{scAddr(t, blndToken), scAddr(t, backstop), scAddr(t, backstopToken)} + op := buildInvokeOp(t, emitterID, "initialize", args, caller.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, caller, nil, DefaultConfirmationRetries) + require.NoError(t, err, "initializing emitter") + return hash +} + +// --------------------------------------------------------------------------------------------- +// Mock oracle (SEP-40) +// --------------------------------------------------------------------------------------------- + +// OracleSetData invokes set_data(admin, base, assets, decimals, resolution) on the mock oracle, +// signed by admin. base is hardcoded to the SEP-40 Asset::Other("USD") variant. +func (s *SharedContainers) OracleSetData(ctx context.Context, t *testing.T, oracleID string, admin *keypair.Full, assetAddrs []string, decimals, resolution uint32) string { + t.Helper() + + args := []xdr.ScVal{ + scAddr(t, admin.Address()), + scSep40OtherAsset("USD"), + scSep40StellarAssetVec(t, assetAddrs), + scU32(decimals), + scU32(resolution), + } + op := buildInvokeOp(t, oracleID, "set_data", args, admin.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, admin, nil, DefaultConfirmationRetries) + require.NoError(t, err, "setting oracle data") + return hash +} + +// OracleSetPriceStable invokes set_price_stable(prices) on the mock oracle, signed by admin. +func (s *SharedContainers) OracleSetPriceStable(ctx context.Context, t *testing.T, oracleID string, admin *keypair.Full, prices []*big.Int) string { + t.Helper() + + args := []xdr.ScVal{scI128Vec(t, prices)} + op := buildInvokeOp(t, oracleID, "set_price_stable", args, admin.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, admin, nil, DefaultConfirmationRetries) + require.NoError(t, err, "setting oracle stable prices") + return hash +} + +// --------------------------------------------------------------------------------------------- +// Comet factory / Comet +// --------------------------------------------------------------------------------------------- + +// CometFactoryInit invokes init(pool_wasm_hash) on the Comet factory, signed by caller. +func (s *SharedContainers) CometFactoryInit(ctx context.Context, t *testing.T, factoryID string, caller *keypair.Full, wasmHash xdr.Hash) string { + t.Helper() + + args := []xdr.ScVal{scBytes32(wasmHash)} + op := buildInvokeOp(t, factoryID, "init", args, caller.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, caller, nil, DefaultConfirmationRetries) + require.NoError(t, err, "initializing comet factory") + return hash +} + +// CometFactoryNewPool invokes new_c_pool(salt, controller, tokens, weights, balances, swap_fee) -> +// Address on the Comet factory, signed by controller, and returns the newly deployed Comet pool's +// address decoded from the invoke's return value. +func (s *SharedContainers) CometFactoryNewPool(ctx context.Context, t *testing.T, factoryID string, controller *keypair.Full, salt [32]byte, tokens []string, weights, balances []*big.Int, swapFee *big.Int) string { + t.Helper() + + args := []xdr.ScVal{ + scBytes32(salt), + scAddr(t, controller.Address()), + scAddressVec(t, tokens), + scI128Vec(t, weights), + scI128Vec(t, balances), + scI128(t, swapFee), + } + op := buildInvokeOp(t, factoryID, "new_c_pool", args, controller.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, controller, nil, DefaultConfirmationRetries) + require.NoError(t, err, "deploying comet pool") + + poolAddr, err := s.invokeResultAddress(ctx, t, hash) + require.NoError(t, err, "decoding comet pool address") + return poolAddr +} + +// CometJoinPool invokes join_pool(pool_amount_out, max_amounts_in, user) on the Comet pool, signed +// by user. +func (s *SharedContainers) CometJoinPool(ctx context.Context, t *testing.T, cometID string, user *keypair.Full, poolAmountOut *big.Int, maxAmountsIn []*big.Int) string { + t.Helper() + + args := []xdr.ScVal{scI128(t, poolAmountOut), scI128Vec(t, maxAmountsIn), scAddr(t, user.Address())} + op := buildInvokeOp(t, cometID, "join_pool", args, user.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, user, nil, DefaultConfirmationRetries) + require.NoError(t, err, "joining comet pool") + return hash +} + +// --------------------------------------------------------------------------------------------- +// Pool factory +// --------------------------------------------------------------------------------------------- + +// PoolFactoryDeploy invokes deploy(admin, name, salt, oracle, backstop_take_rate, max_positions, +// min_collateral) -> Address on the pool factory, signed by admin, and returns the newly deployed +// pool's address decoded from the invoke's return value. +func (s *SharedContainers) PoolFactoryDeploy(ctx context.Context, t *testing.T, factoryID string, admin *keypair.Full, name string, salt [32]byte, oracle string, backstopTakeRate, maxPositions uint32, minCollateral *big.Int) string { + t.Helper() + + args := []xdr.ScVal{ + scAddr(t, admin.Address()), + scString(t, name), + scBytes32(salt), + scAddr(t, oracle), + scU32(backstopTakeRate), + scU32(maxPositions), + scI128(t, minCollateral), + } + op := buildInvokeOp(t, factoryID, "deploy", args, admin.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, admin, nil, DefaultConfirmationRetries) + require.NoError(t, err, "deploying pool") + + poolAddr, err := s.invokeResultAddress(ctx, t, hash) + require.NoError(t, err, "decoding deployed pool address") + return poolAddr +} + +// --------------------------------------------------------------------------------------------- +// Stellar Asset Contract (SAC) admin +// --------------------------------------------------------------------------------------------- + +// SACSetAdmin invokes set_admin(new_admin) on a Stellar Asset Contract, signed by currentAdmin. +// +// Note: since currentAdmin is often the shared master account acting as an arbitrary +// executeSorobanOperationAs source (rather than through executeSorobanOperation), the master +// account's locally tracked sequence counter drifts from the ledger's view. Call +// SyncMasterSequence before resuming master-account operations through executeSorobanOperation. +func (s *SharedContainers) SACSetAdmin(ctx context.Context, t *testing.T, sacID string, currentAdmin *keypair.Full, newAdmin string) string { + t.Helper() + + args := []xdr.ScVal{scAddr(t, newAdmin)} + op := buildInvokeOp(t, sacID, "set_admin", args, currentAdmin.Address()) + + hash, err := s.executeSorobanOperationAs(ctx, t, op, currentAdmin, nil, DefaultConfirmationRetries) + require.NoError(t, err, "setting SAC admin") + return hash +} diff --git a/internal/integrationtests/infrastructure/blend_fixtures.go b/internal/integrationtests/infrastructure/blend_fixtures.go new file mode 100644 index 000000000..df12b1e8d --- /dev/null +++ b/internal/integrationtests/infrastructure/blend_fixtures.go @@ -0,0 +1,158 @@ +// Package infrastructure provides Soroban transaction helpers for integration tests. +// +// This file drives a deployed BlendStack (blend_setup.go) through the pool/backstop/emitter +// operations exercised by the protocol-migrate and live-ingestion integration test suites. +package infrastructure + +import ( + "context" + "math/big" + "testing" + "time" + + "github.com/stellar/go-stellar-sdk/support/log" +) + +// Blend fixture amounts, named so test suites (a later task) can assert exact values rather than +// re-deriving them from the ops below. All reserve-token amounts are in stroops (7 decimals). +const ( + // Phase 1 — protocol-migrate coverage (history + current-state migration). + BlendSupplierSupplyUSDC = 10_000_000_000 // Supplier PoolSubmit: Supply USDC (1,000 USDC) + BlendSupplierSupplyXLM = 10_000_000_000 // Supplier PoolSubmit: Supply XLM, native (1,000 XLM; borrow liquidity) + BlendSupplierSupplyCollateralUSDC = 5_000_000_000 // Supplier PoolSubmit: SupplyCollateral USDC (500 USDC) + BlendBorrowerSupplyCollateralUSDC = 20_000_000_000 // Borrower PoolSubmit: SupplyCollateral USDC (2,000 USDC) + BlendBorrowerBorrowXLM = 3_000_000_000 // Borrower PoolSubmit: Borrow XLM (300 XLM) + BlendBorrowerRepayXLM = 1_000_000_000 // Borrower PoolSubmit: Repay XLM (100 XLM) + + // Phase 2 — live-ingestion coverage (withdrawals, backstop Q4W, emissions, liquidation). + BlendSupplierWithdrawUSDC = 2_000_000_000 // Supplier PoolSubmit: Withdraw USDC (200 USDC) + BlendSupplierWithdrawCollateralUSDC = 1_000_000_000 // Supplier PoolSubmit: WithdrawCollateral USDC (100 USDC) + BlendWhaleQ4WAmount = 50_000_000_000 // Whale BackstopQueueWithdrawal / BackstopDequeueWithdrawal (5,000 backstop shares) + BlendFillerSupplyCollateralUSDC = 30_000_000_000 // Filler PoolSubmit: SupplyCollateral USDC (3,000 USDC; auction headroom) + BlendAuctionFillPercent = 50 // Filler PoolSubmit: FillUserLiquidationAuction percent + BlendAuctionRepayXLM = 1_500_000_000 // Filler PoolSubmit: Repay XLM (150 XLM; absorbed debt) + + // blendAuctionCrashedUSDCPrice is USDC's oracle price ($0.005, 7 decimals) set right before + // PoolNewAuction, tanking the borrower's USDC collateral value enough to become liquidatable. + // The borrower must have liability_base > collateral_base: its liability is 200 XLM + // ($0.10 * 200 / l_factor 0.9 = $22.2 base) against 2,000 USDC collateral, so USDC must fall + // below ~$0.0117 ($22.2 / (2,000 * c_factor 0.95)); $0.005 leaves solid margin ($9.5 base) + // while keeping the filler healthy after the fill (3,000 USDC * $0.005 * 0.95 = $14.25 + // effective collateral vs the $11.1 absorbed debt, which the Repay in the same submit clears). + // XLM and BLND prices are left unchanged from SetupBlendStack's OracleSetPriceStable call. + blendAuctionCrashedUSDCPrice = 50_000 + blendXLMPrice = 1_000_000 + blendBLNDPrice = 500_000 +) + +// SubmitBlendPhase1Ops executes the Blend operations covered by the protocol-migrate +// (history + current-state) integration path: a supplier depositing liquidity and collateral, +// and a borrower posting collateral, borrowing, and partially repaying. +func (s *SharedContainers) SubmitBlendPhase1Ops(ctx context.Context, t *testing.T, stack *BlendStack) { + t.Helper() + + s.PoolSubmit(ctx, t, stack.PoolID, stack.Supplier, []BlendRequest{ + {RequestType: 0, Address: stack.USDCTokenID, Amount: big.NewInt(BlendSupplierSupplyUSDC)}, + {RequestType: 0, Address: stack.XLMTokenID, Amount: big.NewInt(BlendSupplierSupplyXLM)}, + {RequestType: 2, Address: stack.USDCTokenID, Amount: big.NewInt(BlendSupplierSupplyCollateralUSDC)}, + }) + log.Ctx(ctx).Info("✅ Blend phase 1: supplier supplied USDC/XLM and posted USDC collateral") + + s.PoolSubmit(ctx, t, stack.PoolID, stack.Borrower, []BlendRequest{ + {RequestType: 2, Address: stack.USDCTokenID, Amount: big.NewInt(BlendBorrowerSupplyCollateralUSDC)}, + {RequestType: 4, Address: stack.XLMTokenID, Amount: big.NewInt(BlendBorrowerBorrowXLM)}, + {RequestType: 5, Address: stack.XLMTokenID, Amount: big.NewInt(BlendBorrowerRepayXLM)}, + }) + log.Ctx(ctx).Info("✅ Blend phase 1: borrower posted USDC collateral, borrowed XLM, and partially repaid") +} + +// SubmitBlendPhase2Ops executes the Blend operations covered by live ingestion: withdrawals, +// backstop queue/dequeue, emissions claims, and a liquidation. betweenQ4W, if non-nil, is called +// after the whale's backstop withdrawal is queued but before it is dequeued, so a caller can +// observe the queued (Q4W) state — e.g. via GraphQL — while it is still pending. +func (s *SharedContainers) SubmitBlendPhase2Ops(ctx context.Context, t *testing.T, stack *BlendStack, betweenQ4W func()) { + t.Helper() + + s.PoolSubmit(ctx, t, stack.PoolID, stack.Supplier, []BlendRequest{ + {RequestType: 1, Address: stack.USDCTokenID, Amount: big.NewInt(BlendSupplierWithdrawUSDC)}, + {RequestType: 3, Address: stack.USDCTokenID, Amount: big.NewInt(BlendSupplierWithdrawCollateralUSDC)}, + }) + log.Ctx(ctx).Info("✅ Blend phase 2: supplier withdrew USDC and USDC collateral") + + s.BackstopQueueWithdrawal(ctx, t, stack.BackstopID, stack.Whale, stack.PoolID, big.NewInt(BlendWhaleQ4WAmount)) + log.Ctx(ctx).Info("✅ Blend phase 2: whale queued a backstop withdrawal") + if betweenQ4W != nil { + betweenQ4W() + } + s.BackstopDequeueWithdrawal(ctx, t, stack.BackstopID, stack.Whale, stack.PoolID, big.NewInt(BlendWhaleQ4WAmount)) + log.Ctx(ctx).Info("✅ Blend phase 2: whale dequeued the backstop withdrawal") + + s.submitBlendEmissions(ctx, t, stack) + s.submitBlendLiquidation(ctx, t, stack) +} + +// submitBlendEmissions distributes BLND emissions from the emitter to the backstop, gulps them +// into the pool, and lets both the supplier (pool emissions, via a bToken supply stream) and the +// whale (backstop emissions) claim their accrued share. +// +// The backstop's own emissions accounting (gulp_emissions on the backstop) is pool-authorized and +// invoked internally by the pool's gulp_emissions call — there is no separate backstop-side gulp +// wrapper to call here; PoolGulpEmissions is the only gulp the test needs to drive. +func (s *SharedContainers) submitBlendEmissions(ctx context.Context, t *testing.T, stack *BlendStack) { + t.Helper() + + s.EmitterDistribute(ctx, t, stack.EmitterID, s.masterKeyPair) + s.BackstopDistribute(ctx, t, stack.BackstopID, s.masterKeyPair) // first-ever call: initializes, returns 0 + + time.Sleep(6 * time.Second) + + s.EmitterDistribute(ctx, t, stack.EmitterID, s.masterKeyPair) + s.BackstopDistribute(ctx, t, stack.BackstopID, s.masterKeyPair) // now accrues since the first call + + s.PoolGulpEmissions(ctx, t, stack.PoolID, s.masterKeyPair) + log.Ctx(ctx).Info("✅ Blend phase 2: distributed and gulped emissions") + + // Let per-user emissions accrue before claiming. + time.Sleep(10 * time.Second) + + // Reserve token id = reserve_index*2 (+1 for the bToken/supply side): id 1 is USDC's (index + // 0) bToken supply stream, which the supplier holds via its Supply + SupplyCollateral USDC + // positions from phase 1. + s.PoolClaim(ctx, t, stack.PoolID, stack.Supplier, []uint32{1}) + log.Ctx(ctx).Info("✅ Blend phase 2: supplier claimed pool emissions") + + s.BackstopClaim(ctx, t, stack.BackstopID, stack.Whale, []string{stack.PoolID}, big.NewInt(0)) + log.Ctx(ctx).Info("✅ Blend phase 2: whale claimed backstop emissions") +} + +// submitBlendLiquidation crashes the USDC oracle price to make the borrower's phase-1 position +// liquidatable, opens a liquidation auction against it, waits for the auction's price to decay, +// and has the filler partially fill it (50%) while repaying part of the absorbed XLM debt. +func (s *SharedContainers) submitBlendLiquidation(ctx context.Context, t *testing.T, stack *BlendStack) { + t.Helper() + + s.PoolSubmit(ctx, t, stack.PoolID, stack.Filler, []BlendRequest{ + {RequestType: 2, Address: stack.USDCTokenID, Amount: big.NewInt(BlendFillerSupplyCollateralUSDC)}, + }) + log.Ctx(ctx).Info("✅ Blend phase 2: filler posted USDC collateral (auction headroom)") + + s.OracleSetPriceStable(ctx, t, stack.OracleID, s.masterKeyPair, []*big.Int{ + big.NewInt(blendAuctionCrashedUSDCPrice), // USDC $1.00 -> $0.005 + big.NewInt(blendXLMPrice), // XLM unchanged: $0.10 + big.NewInt(blendBLNDPrice), // BLND unchanged: $0.05 + }) + log.Ctx(ctx).Info("✅ Blend phase 2: crashed USDC oracle price to make the borrower liquidatable") + + s.PoolNewAuction(ctx, t, stack.PoolID, stack.Filler, 0, stack.Borrower.Address(), + []string{stack.XLMTokenID}, []string{stack.USDCTokenID}, 100) + log.Ctx(ctx).Info("✅ Blend phase 2: filler opened a liquidation auction against the borrower") + + // Let the auction's price decay for a few ledgers (~1s/ledger on the standalone network). + time.Sleep(6 * time.Second) + + s.PoolSubmit(ctx, t, stack.PoolID, stack.Filler, []BlendRequest{ + {RequestType: 6, Address: stack.Borrower.Address(), Amount: big.NewInt(BlendAuctionFillPercent)}, + {RequestType: 5, Address: stack.XLMTokenID, Amount: big.NewInt(BlendAuctionRepayXLM)}, + }) + log.Ctx(ctx).Info("✅ Blend phase 2: filler filled 50% of the liquidation auction and repaid absorbed XLM debt") +} diff --git a/internal/integrationtests/infrastructure/blend_operations.go b/internal/integrationtests/infrastructure/blend_operations.go new file mode 100644 index 000000000..592bd1a5e --- /dev/null +++ b/internal/integrationtests/infrastructure/blend_operations.go @@ -0,0 +1,591 @@ +// Package infrastructure provides Soroban transaction helpers for integration tests. +// +// This file adds two things needed to exercise the Blend v2 pools protocol: +// - pure ScVal builder functions for the Blend/SEP-40 contract UDTs, and +// - a Soroban executor that drives a transaction from an arbitrary actor keypair, rather than +// the shared master account executeSorobanOperation always uses. +package infrastructure + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "math/big" + "net/http" + "strconv" + "testing" + + "github.com/stellar/go-stellar-sdk/keypair" + "github.com/stellar/go-stellar-sdk/strkey" + "github.com/stellar/go-stellar-sdk/txnbuild" + "github.com/stellar/go-stellar-sdk/xdr" + "github.com/stretchr/testify/require" + + "github.com/stellar/wallet-backend/internal/entities" + "github.com/stellar/wallet-backend/internal/utils" + "github.com/stellar/wallet-backend/pkg/sorobanauth" +) + +// BlendRequest mirrors the Blend v2 pool Request struct. RequestType encodes the pool action: +// 0=Supply, 1=Withdraw, 2=SupplyCollateral, 3=WithdrawCollateral, 4=Borrow, 5=Repay, +// 6=FillUserLiquidationAuction, 7=FillBadDebtAuction, 8=FillInterestAuction, +// 9=DeleteLiquidationAuction. +type BlendRequest struct { + RequestType uint32 + Address string + Amount *big.Int +} + +// BlendReserveConfig mirrors the Blend v2 pool ReserveConfig struct. +type BlendReserveConfig struct { + CFactor uint32 + Decimals uint32 + Enabled bool + Index uint32 + LFactor uint32 + MaxUtil uint32 + RBase uint32 + ROne uint32 + RThree uint32 + RTwo uint32 + Reactivity uint32 + SupplyCap *big.Int + Util uint32 +} + +// BlendEmissionMetadata mirrors the Blend v2 pool ReserveEmissionMetadata struct. +type BlendEmissionMetadata struct { + ResIndex uint32 + ResType uint32 + Share uint64 +} + +// scMapEntry is a single symbol-keyed entry destined for an xdr.ScMap. Callers must pass entries +// to scMap in ascending symbol-sort order (Soroban encodes UDT structs as symbol-sorted ScMaps). +type scMapEntry struct { + key string + val xdr.ScVal +} + +// scAddr converts a G- or C-address into an ScvAddress ScVal. +func scAddr(t *testing.T, addr string) xdr.ScVal { + t.Helper() + scAddress, err := parseAddressToScAddress(addr) + require.NoError(t, err, "parsing address %s", addr) + return xdr.ScVal{Type: xdr.ScValTypeScvAddress, Address: &scAddress} +} + +// int128MagnitudeBytes converts v into its 16-byte, big-endian, 128-bit two's complement +// representation, or returns an error if v does not fit in 128 bits. Split into a pure function +// (rather than inlined in scI128) so the overflow/negative-encoding logic can be unit tested +// directly, without needing to trigger scI128's t.Fatal path. +func int128MagnitudeBytes(v *big.Int) ([16]byte, error) { + mag := new(big.Int) + if v.Sign() < 0 { + modulus := new(big.Int).Lsh(big.NewInt(1), 128) + mag.Add(modulus, v) + } else { + mag.Set(v) + } + + b := mag.Bytes() + if len(b) > 16 { + return [16]byte{}, fmt.Errorf("i128 value %s overflows 128 bits", v.String()) + } + + var buf [16]byte + copy(buf[16-len(b):], b) + return buf, nil +} + +// scI128 encodes v as an ScvI128 ScVal, correctly handling magnitudes above 2^63 and negative +// values via 128-bit two's complement. Fails the test if v does not fit in 128 bits. +func scI128(t *testing.T, v *big.Int) xdr.ScVal { + t.Helper() + + buf, err := int128MagnitudeBytes(v) + if err != nil { + t.Fatal(err) + } + + hi := int64(binary.BigEndian.Uint64(buf[0:8])) + lo := binary.BigEndian.Uint64(buf[8:16]) + + parts := xdr.Int128Parts{Hi: xdr.Int64(hi), Lo: xdr.Uint64(lo)} + return xdr.ScVal{Type: xdr.ScValTypeScvI128, I128: &parts} +} + +// scI128FromInt64 encodes a signed 64-bit value as an ScvI128 ScVal, sign-extending into the high +// 64 bits. +func scI128FromInt64(v int64) xdr.ScVal { + hi := int64(0) + if v < 0 { + hi = -1 + } + parts := xdr.Int128Parts{Hi: xdr.Int64(hi), Lo: xdr.Uint64(uint64(v))} //nolint:gosec // intentional two's complement bit-pattern reinterpretation + return xdr.ScVal{Type: xdr.ScValTypeScvI128, I128: &parts} +} + +// scU32 encodes v as an ScvU32 ScVal. +func scU32(v uint32) xdr.ScVal { + u := xdr.Uint32(v) + return xdr.ScVal{Type: xdr.ScValTypeScvU32, U32: &u} +} + +// scU64 encodes v as an ScvU64 ScVal. +func scU64(v uint64) xdr.ScVal { + u := xdr.Uint64(v) + return xdr.ScVal{Type: xdr.ScValTypeScvU64, U64: &u} +} + +// scString encodes s as an ScvString ScVal. +func scString(t *testing.T, s string) xdr.ScVal { + t.Helper() + str := xdr.ScString(s) + return xdr.ScVal{Type: xdr.ScValTypeScvString, Str: &str} +} + +// scSymbol encodes s as an ScvSymbol ScVal. +func scSymbol(s string) xdr.ScVal { + sym := xdr.ScSymbol(s) + return xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &sym} +} + +// scBytes32 encodes b as an ScvBytes ScVal. +func scBytes32(b [32]byte) xdr.ScVal { + bs := xdr.ScBytes(b[:]) + return xdr.ScVal{Type: xdr.ScValTypeScvBytes, Bytes: &bs} +} + +// scBool encodes v as an ScvBool ScVal. +func scBool(v bool) xdr.ScVal { + return xdr.ScVal{Type: xdr.ScValTypeScvBool, B: &v} +} + +// scVec builds an ScvVec ScVal from the given values, in order. +func scVec(vals ...xdr.ScVal) xdr.ScVal { + vec := xdr.ScVec(vals) + vecPtr := &vec + return xdr.ScVal{Type: xdr.ScValTypeScvVec, Vec: &vecPtr} +} + +// mapEntriesSortedError returns a descriptive error if entries are not in strictly ascending +// symbol-sort order (Soroban UDT ScMaps are always symbol-sorted, and duplicate keys are also +// rejected), or nil if they are already sorted. Split out from scMap so the ordering check can be +// unit tested directly, without needing to trigger scMap's t.Fatal path. +func mapEntriesSortedError(entries []scMapEntry) error { + for i := 1; i < len(entries); i++ { + if entries[i-1].key >= entries[i].key { + return fmt.Errorf("scMap entries not symbol-sorted: %q must sort before %q at index %d", entries[i-1].key, entries[i].key, i) + } + } + return nil +} + +// scMap builds an ScvMap ScVal (a Soroban UDT struct) from the given entries. Entries MUST be +// passed in ascending symbol-sort order; scMap fails the test otherwise, since Soroban UDT ScMaps +// are always symbol-sorted and an out-of-order map would not match what a real contract call +// produces or expects. +func scMap(t *testing.T, entries ...scMapEntry) xdr.ScVal { + t.Helper() + + if err := mapEntriesSortedError(entries); err != nil { + t.Fatal(err) + } + + m := make(xdr.ScMap, 0, len(entries)) + for _, e := range entries { + sym := xdr.ScSymbol(e.key) + m = append(m, xdr.ScMapEntry{ + Key: xdr.ScVal{Type: xdr.ScValTypeScvSymbol, Sym: &sym}, + Val: e.val, + }) + } + mPtr := &m + return xdr.ScVal{Type: xdr.ScValTypeScvMap, Map: &mPtr} +} + +// scRequestVec builds a Vec ScVal from the given Blend requests. Each Request struct +// encodes as a symbol-sorted map with keys "address", "amount", "request_type" (alphabetical +// order, which also matches the struct's declared field order). +func scRequestVec(t *testing.T, reqs []BlendRequest) xdr.ScVal { + t.Helper() + + vals := make([]xdr.ScVal, 0, len(reqs)) + for _, r := range reqs { + vals = append(vals, scMap(t, + scMapEntry{key: "address", val: scAddr(t, r.Address)}, + scMapEntry{key: "amount", val: scI128(t, r.Amount)}, + scMapEntry{key: "request_type", val: scU32(r.RequestType)}, + )) + } + return scVec(vals...) +} + +// scReserveConfig builds a ReserveConfig ScVal. The struct encodes as a symbol-sorted map with 13 +// keys; notably "r_three" sorts before "r_two" (byte 'h' < 'w'), and "r_two" sorts before +// "reactivity" ('_' < 'e' in ASCII). +func scReserveConfig(t *testing.T, cfg BlendReserveConfig) xdr.ScVal { + t.Helper() + + return scMap(t, + scMapEntry{key: "c_factor", val: scU32(cfg.CFactor)}, + scMapEntry{key: "decimals", val: scU32(cfg.Decimals)}, + scMapEntry{key: "enabled", val: scBool(cfg.Enabled)}, + scMapEntry{key: "index", val: scU32(cfg.Index)}, + scMapEntry{key: "l_factor", val: scU32(cfg.LFactor)}, + scMapEntry{key: "max_util", val: scU32(cfg.MaxUtil)}, + scMapEntry{key: "r_base", val: scU32(cfg.RBase)}, + scMapEntry{key: "r_one", val: scU32(cfg.ROne)}, + scMapEntry{key: "r_three", val: scU32(cfg.RThree)}, + scMapEntry{key: "r_two", val: scU32(cfg.RTwo)}, + scMapEntry{key: "reactivity", val: scU32(cfg.Reactivity)}, + scMapEntry{key: "supply_cap", val: scI128(t, cfg.SupplyCap)}, + scMapEntry{key: "util", val: scU32(cfg.Util)}, + ) +} + +// scEmissionMetadataVec builds a Vec ScVal. Each entry encodes as a +// symbol-sorted map with keys "res_index", "res_type", "share" (share is u64, not u32). +func scEmissionMetadataVec(t *testing.T, metas []BlendEmissionMetadata) xdr.ScVal { + t.Helper() + + vals := make([]xdr.ScVal, 0, len(metas)) + for _, m := range metas { + vals = append(vals, scMap(t, + scMapEntry{key: "res_index", val: scU32(m.ResIndex)}, + scMapEntry{key: "res_type", val: scU32(m.ResType)}, + scMapEntry{key: "share", val: scU64(m.Share)}, + )) + } + return scVec(vals...) +} + +// scSep40Asset builds the SEP-40 Asset::Stellar(Address) enum variant, which encodes as +// Vec[Symbol("Stellar"), Address]. +func scSep40Asset(t *testing.T, addr string) xdr.ScVal { + t.Helper() + return scVec(scSymbol("Stellar"), scAddr(t, addr)) +} + +// scSep40OtherAsset builds the SEP-40 Asset::Other(Symbol) enum variant, which encodes as +// Vec[Symbol("Other"), Symbol]. +func scSep40OtherAsset(sym string) xdr.ScVal { + return scVec(scSymbol("Other"), scSymbol(sym)) +} + +// scSep40StellarAssetVec builds a Vec of SEP-40 Asset::Stellar(Address) variants, one per address. +func scSep40StellarAssetVec(t *testing.T, addrs []string) xdr.ScVal { + t.Helper() + + vals := make([]xdr.ScVal, 0, len(addrs)) + for _, a := range addrs { + vals = append(vals, scSep40Asset(t, a)) + } + return scVec(vals...) +} + +// scAddressToString converts an xdr.ScAddress back into its G- or C-address string form. It is +// the inverse of parseAddressToScAddress, used to name the required signer when an auth entry's +// address credentials don't match any available keypair. +func scAddressToString(addr xdr.ScAddress) (string, error) { + switch addr.Type { + case xdr.ScAddressTypeScAddressTypeAccount: + if addr.AccountId == nil { + return "", fmt.Errorf("account address has a nil AccountId") + } + return addr.AccountId.Address(), nil + case xdr.ScAddressTypeScAddressTypeContract: + if addr.ContractId == nil { + return "", fmt.Errorf("contract address has a nil ContractId") + } + encoded, err := strkey.Encode(strkey.VersionByteContract, addr.ContractId[:]) + if err != nil { + return "", fmt.Errorf("encoding contract address: %w", err) + } + return encoded, nil + default: + return "", fmt.Errorf("unsupported ScAddress type %d", addr.Type) + } +} + +// signAuthEntriesAs signs simulation-returned Soroban authorization entries using whichever +// keypair among signers matches each entry's required address. Source-account-credentialed +// entries need no explicit signature (the transaction signature covers them) and are passed +// through unchanged. Unlike signAuthEntries (which always signs with a single fixed keypair and +// hardcodes nonce 0, which is only safe for the master account's first-ever Soroban call), this +// preserves the nonce simulation assigned to each entry and fails loudly, naming the required +// address, if no provided keypair can sign it. +func signAuthEntriesAs( + authEntries []xdr.SorobanAuthorizationEntry, + signers []*keypair.Full, + latestLedger int64, +) ([]xdr.SorobanAuthorizationEntry, error) { + if len(authEntries) == 0 { + return authEntries, nil + } + + authSigner := sorobanauth.AuthSigner{NetworkPassphrase: networkPassphrase} + signed := make([]xdr.SorobanAuthorizationEntry, len(authEntries)) + for i, entry := range authEntries { + switch entry.Credentials.Type { + case xdr.SorobanCredentialsTypeSorobanCredentialsSourceAccount: + signed[i] = entry + case xdr.SorobanCredentialsTypeSorobanCredentialsAddress: + requiredAddress, err := scAddressToString(entry.Credentials.Address.Address) + if err != nil { + return nil, fmt.Errorf("resolving required auth address for entry %d: %w", i, err) + } + + var matched *keypair.Full + for _, signer := range signers { + if signer.Address() == requiredAddress { + matched = signer + break + } + } + if matched == nil { + return nil, fmt.Errorf("no signer available for required auth address %s (entry %d)", requiredAddress, i) + } + + nonce := int64(entry.Credentials.Address.Nonce) + signedEntry, err := authSigner.AuthorizeEntry(entry, nonce, uint32(latestLedger+LedgerValidityBuffer), matched) + if err != nil { + return nil, fmt.Errorf("signing auth entry %d for %s: %w", i, requiredAddress, err) + } + signed[i] = signedEntry + default: + signed[i] = entry + } + } + return signed, nil +} + +// getAccountSequenceRPC fetches an account's current ledger sequence number directly via RPC. +// executeSorobanOperationAs uses this (rather than a locally tracked counter) because, unlike the +// shared master account, arbitrary actor keypairs are not tracked by SharedContainers. +func getAccountSequenceRPC(client *http.Client, rpcURL, address string) (int64, error) { + keyXDR, err := utils.GetAccountLedgerKey(address) + if err != nil { + return 0, fmt.Errorf("building account ledger key: %w", err) + } + + requestBody := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": "getLedgerEntries", + "params": map[string][]string{ + "keys": {keyXDR}, + }, + } + jsonBody, err := json.Marshal(requestBody) + if err != nil { + return 0, fmt.Errorf("marshaling request: %w", err) + } + + resp, err := client.Post(rpcURL, "application/json", bytes.NewBuffer(jsonBody)) + if err != nil { + return 0, fmt.Errorf("posting to RPC: %w", err) + } + defer func() { + _ = resp.Body.Close() //nolint:errcheck + }() + + var rpcResp struct { + Result entities.RPCGetLedgerEntriesResult `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil { + return 0, fmt.Errorf("decoding response: %w", err) + } + if rpcResp.Error != nil { + return 0, fmt.Errorf("RPC error: %s", rpcResp.Error.Message) + } + if len(rpcResp.Result.Entries) == 0 { + return 0, fmt.Errorf("no ledger entry found for account %s", address) + } + + var data xdr.LedgerEntryData + if err := xdr.SafeUnmarshalBase64(rpcResp.Result.Entries[0].DataXDR, &data); err != nil { + return 0, fmt.Errorf("decoding account ledger entry: %w", err) + } + account, ok := data.GetAccount() + if !ok { + return 0, fmt.Errorf("ledger entry for %s is not an account", address) + } + return int64(account.SeqNum), nil +} + +// executeSorobanOperationAs executes an InvokeHostFunction with source as the transaction source +// account and primary auth signer (any additional required signers are passed via extraSigners). +// +// It mirrors the 11-step pattern of executeSorobanOperation, adapted for an arbitrary actor: +// 1. Fetch source's CURRENT sequence via RPC (no locally tracked counter — only the shared +// master account gets one, since SharedContainers doesn't own arbitrary actor keypairs). +// 2. Build and simulate the transaction. +// 3. Sign simulation-returned auth entries: source-account-credentialed entries need no +// signature; address-credentialed entries are matched to a keypair in [source]+extraSigners +// and signed preserving the nonce simulation assigned (never hardcoded to 0). If no keypair +// matches, fail with an error naming the required address. +// 4. Re-simulate with the signed auth entries so the resource footprint (and MinResourceFee) +// reflects their actual signed size, mirroring the double-simulate pattern in +// Fixtures.prepareSimulateAndSignContractOp. +// 5. Apply the final simulation's SorobanData and MinResourceFee, rebuild incrementing source's +// sequence, sign with source, submit, and wait for confirmation. +// +// Every current Blend wrapper call site (blend_contracts.go) passes nil extraSigners and +// DefaultConfirmationRetries, since none of the pinned Blend contract calls need a second signer +// or a non-default retry budget; both params stay general for callers that do. +// +//nolint:unparam // see above: uniform today, kept general for future callers +func (s *SharedContainers) executeSorobanOperationAs( + ctx context.Context, + t *testing.T, + op *txnbuild.InvokeHostFunction, + source *keypair.Full, + extraSigners []*keypair.Full, + retries int, +) (string, error) { + rpcURL, err := s.RPCContainer.GetConnectionString(ctx) + if err != nil { + return "", fmt.Errorf("getting RPC connection string: %w", err) + } + + // Step 1: fetch source's current sequence from RPC. + seq, err := getAccountSequenceRPC(s.httpClient, rpcURL, source.Address()) + if err != nil { + return "", fmt.Errorf("getting source account sequence: %w", err) + } + sourceAccount := &txnbuild.SimpleAccount{AccountID: source.Address(), Sequence: seq} + + signers := make([]*keypair.Full, 0, 1+len(extraSigners)) + signers = append(signers, source) + signers = append(signers, extraSigners...) + + // Step 2: build and simulate. + tx, err := txnbuild.NewTransaction(txnbuild.TransactionParams{ + SourceAccount: sourceAccount, + Operations: []txnbuild.Operation{op}, + BaseFee: txnbuild.MinBaseFee, + IncrementSequenceNum: false, + Preconditions: txnbuild.Preconditions{ + TimeBounds: txnbuild.NewTimeout(DefaultTransactionTimeout), + }, + }) + if err != nil { + return "", fmt.Errorf("building transaction for simulation: %w", err) + } + txXDR, err := tx.Base64() + if err != nil { + return "", fmt.Errorf("encoding transaction for simulation: %w", err) + } + simulationResult, err := simulateTransactionRPC(s.httpClient, rpcURL, txXDR) + if err != nil { + return "", fmt.Errorf("simulating transaction: %w", err) + } + if simulationResult.Error != "" { + return "", fmt.Errorf("simulation failed: %s", simulationResult.Error) + } + + // Step 3+4: sign auth entries (preserving the simulation nonce) and re-simulate so the + // resource footprint accounts for the signed entries' size. + if len(simulationResult.Results) > 0 && len(simulationResult.Results[0].Auth) > 0 { + signedAuth, err := signAuthEntriesAs(simulationResult.Results[0].Auth, signers, simulationResult.LatestLedger) + if err != nil { + return "", err + } + op.Auth = signedAuth + + tx, err = txnbuild.NewTransaction(txnbuild.TransactionParams{ + SourceAccount: sourceAccount, + Operations: []txnbuild.Operation{op}, + BaseFee: txnbuild.MinBaseFee, + IncrementSequenceNum: false, + Preconditions: txnbuild.Preconditions{ + TimeBounds: txnbuild.NewTimeout(DefaultTransactionTimeout), + }, + }) + if err != nil { + return "", fmt.Errorf("rebuilding transaction with signed auth: %w", err) + } + txXDR, err = tx.Base64() + if err != nil { + return "", fmt.Errorf("encoding transaction with signed auth: %w", err) + } + simulationResult, err = simulateTransactionRPC(s.httpClient, rpcURL, txXDR) + if err != nil { + return "", fmt.Errorf("re-simulating transaction: %w", err) + } + if simulationResult.Error != "" { + return "", fmt.Errorf("re-simulation failed: %s", simulationResult.Error) + } + } + + // Step 5: apply resources, rebuild for real submission, sign, submit, wait for confirmation. + op.Ext = xdr.TransactionExt{ + V: 1, + SorobanData: &simulationResult.TransactionData, + } + + minResourceFee, err := strconv.ParseInt(simulationResult.MinResourceFee, 10, 64) + if err != nil { + return "", fmt.Errorf("parsing MinResourceFee: %w", err) + } + + tx, err = txnbuild.NewTransaction(txnbuild.TransactionParams{ + SourceAccount: sourceAccount, + Operations: []txnbuild.Operation{op}, + BaseFee: minResourceFee + txnbuild.MinBaseFee, + IncrementSequenceNum: true, + Preconditions: txnbuild.Preconditions{ + TimeBounds: txnbuild.NewTimeout(DefaultTransactionTimeout), + }, + }) + if err != nil { + return "", fmt.Errorf("rebuilding transaction: %w", err) + } + + tx, err = tx.Sign(networkPassphrase, source) + if err != nil { + return "", fmt.Errorf("signing transaction: %w", err) + } + txXDR, err = tx.Base64() + if err != nil { + return "", fmt.Errorf("encoding signed transaction: %w", err) + } + + sendResult, err := submitTransactionToRPC(s.httpClient, rpcURL, txXDR) + if err != nil { + return "", fmt.Errorf("submitting transaction: %w", err) + } + if sendResult.Status == entities.ErrorStatus { + return "", fmt.Errorf("transaction failed with status: %s, hash: %s, errorResultXdr: %s", + sendResult.Status, sendResult.Hash, sendResult.ErrorResultXDR) + } + + if err := waitForTransactionConfirmation(ctx, t, s.httpClient, rpcURL, sendResult.Hash, retries); err != nil { + return "", fmt.Errorf("waiting for confirmation: %w", err) + } + + return sendResult.Hash, nil +} + +// SyncMasterSequence refreshes the master account's local sequence counter from RPC. Call it +// before resuming master-account operations (executeSorobanOperation, executeClassicOperation) if +// the master account may have submitted transactions through a path that doesn't track +// SharedContainers' local counter — e.g. acting as an extraSigner in executeSorobanOperationAs — +// so the counter doesn't drift from the ledger's view of the account's sequence number. +func (s *SharedContainers) SyncMasterSequence(ctx context.Context, t *testing.T) { + rpcURL, err := s.RPCContainer.GetConnectionString(ctx) + require.NoError(t, err, "getting RPC connection string") + + seq, err := getAccountSequenceRPC(s.httpClient, rpcURL, s.masterKeyPair.Address()) + require.NoError(t, err, "fetching master account sequence") + + s.masterAccount.Sequence = seq +} diff --git a/internal/integrationtests/infrastructure/blend_operations_test.go b/internal/integrationtests/infrastructure/blend_operations_test.go new file mode 100644 index 000000000..37f92699b --- /dev/null +++ b/internal/integrationtests/infrastructure/blend_operations_test.go @@ -0,0 +1,426 @@ +package infrastructure + +import ( + "math/big" + "testing" + + "github.com/stellar/go-stellar-sdk/keypair" + "github.com/stellar/go-stellar-sdk/xdr" + "github.com/stretchr/testify/require" +) + +// unwrapVec dereferences an ScvVec ScVal down to its underlying xdr.ScVec. +func unwrapVec(t *testing.T, v xdr.ScVal) xdr.ScVec { + t.Helper() + require.Equal(t, xdr.ScValTypeScvVec, v.Type) + require.NotNil(t, v.Vec) + require.NotNil(t, *v.Vec) + return **v.Vec +} + +// unwrapMap dereferences an ScvMap ScVal down to its underlying xdr.ScMap. +func unwrapMap(t *testing.T, v xdr.ScVal) xdr.ScMap { + t.Helper() + require.Equal(t, xdr.ScValTypeScvMap, v.Type) + require.NotNil(t, v.Map) + require.NotNil(t, *v.Map) + return **v.Map +} + +// mapKeys extracts the ordered symbol keys of an xdr.ScMap. +func mapKeys(t *testing.T, m xdr.ScMap) []string { + t.Helper() + keys := make([]string, len(m)) + for i, e := range m { + require.Equal(t, xdr.ScValTypeScvSymbol, e.Key.Type) + require.NotNil(t, e.Key.Sym) + keys[i] = string(*e.Key.Sym) + } + return keys +} + +func TestBlendScValAddr(t *testing.T) { + g := keypair.MustRandom().Address() + v := scAddr(t, g) + require.Equal(t, xdr.ScValTypeScvAddress, v.Type) + require.NotNil(t, v.Address) + require.Equal(t, xdr.ScAddressTypeScAddressTypeAccount, v.Address.Type) + require.Equal(t, g, v.Address.AccountId.Address()) +} + +func TestBlendScValI128LargeMagnitude(t *testing.T) { + // (1<<80) + 5: bit 80 falls in the high 64 bits (bit 16 of Hi), so Hi=1<<16=65536, Lo=5. + val := new(big.Int).Lsh(big.NewInt(1), 80) + val.Add(val, big.NewInt(5)) + + v := scI128(t, val) + require.Equal(t, xdr.ScValTypeScvI128, v.Type) + require.NotNil(t, v.I128) + require.Equal(t, xdr.Int64(65536), v.I128.Hi) + require.Equal(t, xdr.Uint64(5), v.I128.Lo) +} + +func TestBlendScValI128NegativeOne(t *testing.T) { + v := scI128(t, big.NewInt(-1)) + require.Equal(t, xdr.ScValTypeScvI128, v.Type) + require.NotNil(t, v.I128) + require.Equal(t, xdr.Int64(-1), v.I128.Hi) + require.Equal(t, xdr.Uint64(0xFFFFFFFFFFFFFFFF), v.I128.Lo) +} + +func TestBlendScValI128Zero(t *testing.T) { + v := scI128(t, big.NewInt(0)) + require.Equal(t, xdr.Int64(0), v.I128.Hi) + require.Equal(t, xdr.Uint64(0), v.I128.Lo) +} + +// int128MagnitudeBytes is the pure validation logic scI128 delegates to before calling t.Fatal on +// overflow; testing it directly (rather than exercising scI128's t.Fatal path via a subtest) keeps +// this suite green, since a failed subtest in Go always propagates failure to its parent test and +// therefore to the whole package's exit code (testing.common.Fail walks up to every ancestor). +func TestInt128MagnitudeBytesOverflowFails(t *testing.T) { + huge := new(big.Int).Lsh(big.NewInt(1), 129) // exceeds 128 bits + _, err := int128MagnitudeBytes(huge) + require.Error(t, err) +} + +func TestInt128MagnitudeBytesFitsExactly128Bits(t *testing.T) { + maxUint128 := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 128), big.NewInt(1)) + buf, err := int128MagnitudeBytes(maxUint128) + require.NoError(t, err) + require.Equal(t, [16]byte{ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + }, buf) +} + +func TestBlendScValI128FromInt64(t *testing.T) { + positive := scI128FromInt64(42) + require.Equal(t, xdr.Int64(0), positive.I128.Hi) + require.Equal(t, xdr.Uint64(42), positive.I128.Lo) + + negative := scI128FromInt64(-1) + require.Equal(t, xdr.Int64(-1), negative.I128.Hi) + require.Equal(t, xdr.Uint64(0xFFFFFFFFFFFFFFFF), negative.I128.Lo) +} + +func TestBlendScValU32(t *testing.T) { + v := scU32(7) + require.Equal(t, xdr.ScValTypeScvU32, v.Type) + require.Equal(t, xdr.Uint32(7), *v.U32) +} + +func TestBlendScValU64(t *testing.T) { + v := scU64(9_000_000_000) + require.Equal(t, xdr.ScValTypeScvU64, v.Type) + require.Equal(t, xdr.Uint64(9_000_000_000), *v.U64) +} + +func TestBlendScValString(t *testing.T) { + v := scString(t, "hello") + require.Equal(t, xdr.ScValTypeScvString, v.Type) + require.Equal(t, xdr.ScString("hello"), *v.Str) +} + +func TestBlendScValSymbol(t *testing.T) { + v := scSymbol("Stellar") + require.Equal(t, xdr.ScValTypeScvSymbol, v.Type) + require.Equal(t, xdr.ScSymbol("Stellar"), *v.Sym) +} + +func TestBlendScValBytes32(t *testing.T) { + var b [32]byte + b[0] = 0xAB + b[31] = 0xCD + v := scBytes32(b) + require.Equal(t, xdr.ScValTypeScvBytes, v.Type) + require.Equal(t, xdr.ScBytes(b[:]), *v.Bytes) +} + +func TestBlendScValBool(t *testing.T) { + v := scBool(true) + require.Equal(t, xdr.ScValTypeScvBool, v.Type) + require.True(t, *v.B) + + v = scBool(false) + require.False(t, *v.B) +} + +func TestBlendScValVec(t *testing.T) { + v := scVec(scU32(1), scU32(2), scU32(3)) + vec := unwrapVec(t, v) + require.Len(t, vec, 3) + require.Equal(t, xdr.Uint32(1), *vec[0].U32) + require.Equal(t, xdr.Uint32(2), *vec[1].U32) + require.Equal(t, xdr.Uint32(3), *vec[2].U32) +} + +func TestBlendScValVecEmpty(t *testing.T) { + v := scVec() + vec := unwrapVec(t, v) + require.Empty(t, vec) +} + +func TestBlendScValMapSorted(t *testing.T) { + v := scMap(t, + scMapEntry{key: "a", val: scU32(1)}, + scMapEntry{key: "b", val: scU32(2)}, + ) + m := unwrapMap(t, v) + require.Equal(t, []string{"a", "b"}, mapKeys(t, m)) + require.Equal(t, xdr.Uint32(1), *m[0].Val.U32) + require.Equal(t, xdr.Uint32(2), *m[1].Val.U32) +} + +// mapEntriesSortedError is the pure validation logic scMap delegates to before calling t.Fatal on +// unsorted/duplicate keys; testing it directly (rather than exercising scMap's t.Fatal path via a +// subtest) keeps this suite green — see TestInt128MagnitudeBytesOverflowFails for why. +func TestMapEntriesSortedErrorDetectsUnsortedKeys(t *testing.T) { + err := mapEntriesSortedError([]scMapEntry{ + {key: "b", val: scU32(1)}, + {key: "a", val: scU32(2)}, + }) + require.Error(t, err) +} + +func TestMapEntriesSortedErrorDetectsDuplicateKeys(t *testing.T) { + err := mapEntriesSortedError([]scMapEntry{ + {key: "a", val: scU32(1)}, + {key: "a", val: scU32(2)}, + }) + require.Error(t, err) +} + +func TestMapEntriesSortedErrorAcceptsSortedKeys(t *testing.T) { + err := mapEntriesSortedError([]scMapEntry{ + {key: "a", val: scU32(1)}, + {key: "b", val: scU32(2)}, + {key: "c", val: scU32(3)}, + }) + require.NoError(t, err) +} + +func TestBlendScValRequestVec(t *testing.T) { + addr1 := keypair.MustRandom().Address() + addr2 := keypair.MustRandom().Address() + reqs := []BlendRequest{ + {RequestType: 0, Address: addr1, Amount: big.NewInt(100)}, + {RequestType: 4, Address: addr2, Amount: big.NewInt(200)}, + } + + v := scRequestVec(t, reqs) + vec := unwrapVec(t, v) + require.Len(t, vec, 2) + + for i, req := range reqs { + m := unwrapMap(t, vec[i]) + require.Equal(t, []string{"address", "amount", "request_type"}, mapKeys(t, m)) + + require.Equal(t, xdr.ScValTypeScvAddress, m[0].Val.Type) + require.Equal(t, req.Address, m[0].Val.Address.AccountId.Address()) + + require.Equal(t, xdr.ScValTypeScvI128, m[1].Val.Type) + require.Equal(t, xdr.Uint64(req.Amount.Uint64()), m[1].Val.I128.Lo) //nolint:gosec // test fixture amounts are small and non-negative + + require.Equal(t, xdr.Uint32(req.RequestType), *m[2].Val.U32) + } +} + +func TestBlendScValReserveConfig(t *testing.T) { + cfg := BlendReserveConfig{ + CFactor: 900000, + Decimals: 7, + Enabled: true, + Index: 0, + LFactor: 950000, + MaxUtil: 950000, + RBase: 10000, + ROne: 40000, + RThree: 1500000, + RTwo: 100000, + Reactivity: 1000, + SupplyCap: big.NewInt(1_000_000_000), + Util: 500000, + } + + v := scReserveConfig(t, cfg) + m := unwrapMap(t, v) + + require.Equal(t, []string{ + "c_factor", "decimals", "enabled", "index", "l_factor", "max_util", + "r_base", "r_one", "r_three", "r_two", "reactivity", "supply_cap", "util", + }, mapKeys(t, m)) + + require.Equal(t, xdr.Uint32(cfg.CFactor), *m[0].Val.U32) + require.Equal(t, xdr.Uint32(cfg.Decimals), *m[1].Val.U32) + require.True(t, *m[2].Val.B) + require.Equal(t, xdr.Uint32(cfg.Index), *m[3].Val.U32) + require.Equal(t, xdr.Uint32(cfg.LFactor), *m[4].Val.U32) + require.Equal(t, xdr.Uint32(cfg.MaxUtil), *m[5].Val.U32) + require.Equal(t, xdr.Uint32(cfg.RBase), *m[6].Val.U32) + require.Equal(t, xdr.Uint32(cfg.ROne), *m[7].Val.U32) + require.Equal(t, xdr.Uint32(cfg.RThree), *m[8].Val.U32) + require.Equal(t, xdr.Uint32(cfg.RTwo), *m[9].Val.U32) + require.Equal(t, xdr.Uint32(cfg.Reactivity), *m[10].Val.U32) + require.Equal(t, xdr.ScValTypeScvI128, m[11].Val.Type) + require.Equal(t, xdr.Uint32(cfg.Util), *m[12].Val.U32) +} + +func TestBlendScValEmissionMetadataVec(t *testing.T) { + metas := []BlendEmissionMetadata{ + {ResIndex: 0, ResType: 1, Share: 500_000_000}, + {ResIndex: 1, ResType: 0, Share: 1_500_000_000}, + } + + v := scEmissionMetadataVec(t, metas) + vec := unwrapVec(t, v) + require.Len(t, vec, 2) + + for i, meta := range metas { + m := unwrapMap(t, vec[i]) + require.Equal(t, []string{"res_index", "res_type", "share"}, mapKeys(t, m)) + require.Equal(t, xdr.Uint32(meta.ResIndex), *m[0].Val.U32) + require.Equal(t, xdr.Uint32(meta.ResType), *m[1].Val.U32) + require.Equal(t, xdr.ScValTypeScvU64, m[2].Val.Type) + require.Equal(t, xdr.Uint64(meta.Share), *m[2].Val.U64) + } +} + +func TestBlendScValSep40Asset(t *testing.T) { + addr := keypair.MustRandom().Address() + v := scSep40Asset(t, addr) + vec := unwrapVec(t, v) + require.Len(t, vec, 2) + require.Equal(t, xdr.ScValTypeScvSymbol, vec[0].Type) + require.Equal(t, xdr.ScSymbol("Stellar"), *vec[0].Sym) + require.Equal(t, xdr.ScValTypeScvAddress, vec[1].Type) + require.Equal(t, addr, vec[1].Address.AccountId.Address()) +} + +func TestBlendScValSep40OtherAsset(t *testing.T) { + v := scSep40OtherAsset("USD") + vec := unwrapVec(t, v) + require.Len(t, vec, 2) + require.Equal(t, xdr.ScSymbol("Other"), *vec[0].Sym) + require.Equal(t, xdr.ScSymbol("USD"), *vec[1].Sym) +} + +func TestBlendScValSep40StellarAssetVec(t *testing.T) { + addr1 := keypair.MustRandom().Address() + addr2 := keypair.MustRandom().Address() + v := scSep40StellarAssetVec(t, []string{addr1, addr2}) + vec := unwrapVec(t, v) + require.Len(t, vec, 2) + + for i, addr := range []string{addr1, addr2} { + inner := unwrapVec(t, vec[i]) + require.Equal(t, xdr.ScSymbol("Stellar"), *inner[0].Sym) + require.Equal(t, addr, inner[1].Address.AccountId.Address()) + } +} + +func TestScAddressToString(t *testing.T) { + g := keypair.MustRandom().Address() + scAddress, err := parseAddressToScAddress(g) + require.NoError(t, err) + + got, err := scAddressToString(scAddress) + require.NoError(t, err) + require.Equal(t, g, got) +} + +// fakeAuthEntry builds a minimal, well-formed SorobanAuthorizationEntry for exercising +// signAuthEntriesAs. RootInvocation is a throwaway contract-fn invocation; its content doesn't +// matter for these tests beyond being valid enough to marshal into the signing preimage. +func fakeAuthEntry(credType xdr.SorobanCredentialsType, address xdr.ScAddress, nonce int64) xdr.SorobanAuthorizationEntry { + creds := xdr.SorobanCredentials{Type: credType} + if credType == xdr.SorobanCredentialsTypeSorobanCredentialsAddress { + creds.Address = &xdr.SorobanAddressCredentials{ + Address: address, + Nonce: xdr.Int64(nonce), + } + } + + var contractID xdr.ContractId + return xdr.SorobanAuthorizationEntry{ + Credentials: creds, + RootInvocation: xdr.SorobanAuthorizedInvocation{ + Function: xdr.SorobanAuthorizedFunction{ + Type: xdr.SorobanAuthorizedFunctionTypeSorobanAuthorizedFunctionTypeContractFn, + ContractFn: &xdr.InvokeContractArgs{ + ContractAddress: xdr.ScAddress{Type: xdr.ScAddressTypeScAddressTypeContract, ContractId: &contractID}, + FunctionName: "test", + }, + }, + }, + } +} + +func TestSignAuthEntriesAsEmpty(t *testing.T) { + signed, err := signAuthEntriesAs(nil, nil, 100) + require.NoError(t, err) + require.Empty(t, signed) +} + +func TestSignAuthEntriesAsSourceAccountPassthrough(t *testing.T) { + entry := fakeAuthEntry(xdr.SorobanCredentialsTypeSorobanCredentialsSourceAccount, xdr.ScAddress{}, 0) + signer := keypair.MustRandom() + + signed, err := signAuthEntriesAs([]xdr.SorobanAuthorizationEntry{entry}, []*keypair.Full{signer}, 100) + require.NoError(t, err) + require.Equal(t, []xdr.SorobanAuthorizationEntry{entry}, signed) +} + +func TestSignAuthEntriesAsSignsMatchingAddressPreservingNonce(t *testing.T) { + signer := keypair.MustRandom() + addr, err := parseAddressToScAddress(signer.Address()) + require.NoError(t, err) + + const nonce = int64(424242) + const latestLedger = int64(1000) + entry := fakeAuthEntry(xdr.SorobanCredentialsTypeSorobanCredentialsAddress, addr, nonce) + + signed, err := signAuthEntriesAs([]xdr.SorobanAuthorizationEntry{entry}, []*keypair.Full{signer}, latestLedger) + require.NoError(t, err) + require.Len(t, signed, 1) + require.Equal(t, xdr.SorobanCredentialsTypeSorobanCredentialsAddress, signed[0].Credentials.Type) + require.NotNil(t, signed[0].Credentials.Address) + require.Equal(t, xdr.Int64(nonce), signed[0].Credentials.Address.Nonce, "must preserve the simulation-assigned nonce, not hardcode 0") + require.Equal(t, xdr.Uint32(latestLedger+LedgerValidityBuffer), signed[0].Credentials.Address.SignatureExpirationLedger) + require.Equal(t, xdr.ScValTypeScvVec, signed[0].Credentials.Address.Signature.Type, "AuthorizeEntry should have populated a signature") +} + +func TestSignAuthEntriesAsMatchesExtraSigner(t *testing.T) { + primary := keypair.MustRandom() + extra := keypair.MustRandom() + addr, err := parseAddressToScAddress(extra.Address()) + require.NoError(t, err) + + entry := fakeAuthEntry(xdr.SorobanCredentialsTypeSorobanCredentialsAddress, addr, 1) + + signed, err := signAuthEntriesAs([]xdr.SorobanAuthorizationEntry{entry}, []*keypair.Full{primary, extra}, 100) + require.NoError(t, err) + require.Len(t, signed, 1) + require.Equal(t, xdr.ScValTypeScvVec, signed[0].Credentials.Address.Signature.Type) +} + +func TestSignAuthEntriesAsNoMatchingSignerFailsNamingAddress(t *testing.T) { + required := keypair.MustRandom() + other := keypair.MustRandom() + addr, err := parseAddressToScAddress(required.Address()) + require.NoError(t, err) + + entry := fakeAuthEntry(xdr.SorobanCredentialsTypeSorobanCredentialsAddress, addr, 1) + + _, err = signAuthEntriesAs([]xdr.SorobanAuthorizationEntry{entry}, []*keypair.Full{other}, 100) + require.Error(t, err) + require.Contains(t, err.Error(), required.Address()) +} + +// executeSorobanOperationAs drives a live Soroban transaction (RPC sequence lookup, simulate, +// sign, submit, poll for confirmation) and is exercised end-to-end by a later Blend +// contract-wrapper task's container-backed integration run, which this package's plain unit tests +// (no Docker containers, no RPC) cannot reach. This method-value reference exists solely so static +// analysis sees it as intentionally-used infrastructure rather than dead code; it is never invoked +// from this file. +var _ = (*SharedContainers).executeSorobanOperationAs diff --git a/internal/integrationtests/infrastructure/blend_setup.go b/internal/integrationtests/infrastructure/blend_setup.go new file mode 100644 index 000000000..0f7db3be5 --- /dev/null +++ b/internal/integrationtests/infrastructure/blend_setup.go @@ -0,0 +1,393 @@ +// Package infrastructure provides Soroban transaction helpers for integration tests. +// +// This file deploys a full Blend v2 protocol stack (Comet AMM backstop LP token, mock SEP-40 +// oracle, emitter, pool factory, backstop, and one lending pool with two reserves) on the +// standalone network, using the typed contract wrappers in blend_contracts.go and the ScVal +// builders in blend_operations.go. +package infrastructure + +import ( + "context" + "crypto/rand" + "math/big" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stellar/go-stellar-sdk/keypair" + "github.com/stellar/go-stellar-sdk/strkey" + "github.com/stellar/go-stellar-sdk/support/log" + "github.com/stellar/go-stellar-sdk/txnbuild" + "github.com/stellar/go-stellar-sdk/xdr" + "github.com/stretchr/testify/require" + + "github.com/stellar/wallet-backend/internal/services" +) + +// blndPinnedSACAddress is the C-address blndTokenAddress (internal/services/blend/validator.go) +// pins for the standalone network's networkPassphrase. BLND is deployed here as classic asset +// "BLND:", so its SAC address is a deterministic function of the master +// keypair (which is itself keypair.Root(networkPassphrase)) and the network passphrase alone. +// SetupBlendStack asserts the SAC it deploys matches this constant so a drift in either pin goes +// noticed immediately rather than silently degrading blend/validator's price/emissions lookups. +const blndPinnedSACAddress = "CDYLJJT2VBKY55ZK57MTMKAVRCRPQMYB4YJ7JFFARMSJZ73I5CMCITSU" + +// backstopPinnedAddress is the C-address canonicalBackstopAddress +// (internal/services/blend/validator.go) pins for the standalone network's +// networkPassphrase. The backstop is deployed by the master keypair (itself +// keypair.Root(networkPassphrase)) with a fixed salt (0xB5), so its address is +// a deterministic function of the passphrase alone. SetupBlendStack asserts +// the backstop it deploys matches this constant so a drift in either pin goes +// noticed immediately — a mismatch would make the processor drop every +// backstop-shaped entry and event as a non-canonical impostor. +const backstopPinnedAddress = "CARICDGXKY6NZVNAHW5UHWUTOUB4QP4RL2B6PUN4BTPQZ6LC4RGPARED" + +// BlendStack holds the contract addresses and actor keypairs of a Blend v2 deployment on the +// standalone network, produced by SetupBlendStack. +type BlendStack struct { + // StartLedger is the RPC latest ledger captured before the first Blend transaction was + // submitted, so later tests (e.g. a windowed protocol-migrate run) know where Blend history + // begins. + StartLedger uint32 + + Whale, Supplier, Borrower, Filler *keypair.Full + + BLNDTokenID, USDCTokenID, XLMTokenID string + + CometFactoryID, CometID, OracleID, EmitterID string + + PoolFactoryID, BackstopID, PoolID string + + PoolName string +} + +// blendReserveConfigs returns the ReserveConfig values SetupBlendStack queues for the USDC (index +// 0) and XLM (index 1) reserves. Every field except index/decimals/enabled/supply_cap is mirrored +// verbatim from blend-capital/blend-utils (pinned commit b05242df30b6b6caf9d317646f754541824a5a8b) +// src/v2/testing-scripts/mock-example.ts's "Testnet Pool" reserve setup: +// - USDC values from testnetPoolUsdcReserveMetaData (lines 188-202): c_factor=950_0000, +// l_factor=950_0000, util=700_0000, max_util=950_0000, r_base=5000, r_one=30_0000, +// r_two=100_0000, r_three=1_000_0000, reactivity=20. +// - XLM values from testnetPoolXlmReserveMetaData (lines 116-130): c_factor=900_0000, +// l_factor=900_0000, util=500_0000, max_util=950_0000, r_base=5000, r_one=30_0000, +// r_two=200_0000, r_three=1_000_0000, reactivity=50. +// +// supply_cap mirrors that file's I128MAX (blend-sdk's max positive i128, 2^127-1) for both +// reserves, since a real supply cap is irrelevant to a test pool and blend-utils itself uses the +// unbounded sentinel. +func blendReserveConfigs() (usdc, xlm BlendReserveConfig) { + i128Max := new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 127), big.NewInt(1)) + + usdc = BlendReserveConfig{ + CFactor: 9_500_000, + Decimals: 7, + Enabled: true, + Index: 0, + LFactor: 9_500_000, + MaxUtil: 9_500_000, + RBase: 5000, + ROne: 300_000, + RThree: 10_000_000, + RTwo: 1_000_000, + Reactivity: 20, + SupplyCap: i128Max, + Util: 7_000_000, + } + xlm = BlendReserveConfig{ + CFactor: 9_000_000, + Decimals: 7, + Enabled: true, + Index: 1, + LFactor: 9_000_000, + MaxUtil: 9_500_000, + RBase: 5000, + ROne: 300_000, + RThree: 10_000_000, + RTwo: 2_000_000, + Reactivity: 50, + SupplyCap: i128Max, + Util: 5_000_000, + } + return usdc, xlm +} + +// nativeAssetContractAddress computes the C-address of the native (XLM) Stellar Asset Contract, +// which deployNativeAssetSAC (account_setup.go) deploys during setupContracts but does not store +// on SharedContainers. Mirrors createUSDCTrustlines' ContractID-then-strkey-encode pattern for +// the classic credit assets. +func nativeAssetContractAddress(t *testing.T) string { + t.Helper() + + contractID, err := xdr.Asset{Type: xdr.AssetTypeAssetTypeNative}.ContractID(networkPassphrase) + require.NoError(t, err, "computing native asset contract ID") + return strkey.MustEncode(strkey.VersionByteContract, contractID[:]) +} + +// SetupBlendStack deploys a full Blend v2 protocol stack on the standalone network: a Comet AMM +// pool holding the BLND/USDC backstop LP token, a mock SEP-40 oracle, the emitter, the pool +// factory, the backstop, and one lending pool ("TestPool") with USDC (index 0) and XLM (index 1) +// reserves, funded and activated so it is ready to accept Supply/Borrow/etc. requests. +// +// It reuses the USDC SAC and native XLM SAC already deployed by setupContracts (via +// s.usdcContractAddress and nativeAssetContractAddress) and deploys a new classic asset, BLND, +// issued by the master account, asserting its SAC address matches the pin in +// internal/services/blend/validator.go. +// +// Every wrapper call below that drives the shared master account (s.masterKeyPair) through +// executeSorobanOperationAs — i.e. every blend_contracts.go wrapper with an admin/caller/ +// controller parameter set to master — fetches its sequence fresh from RPC and does not update +// s.masterAccount's locally tracked counter (see executeSorobanOperationAs's doc comment). Any +// subsequent call that goes through executeSorobanOperation or executeClassicOperation (upload, +// deploy, classic ops), which DO rely on that local counter, would then submit with a stale +// sequence and fail. SetupBlendStack calls s.SyncMasterSequence at each such transition; look for +// the "re-sync" comments below. +func (s *SharedContainers) SetupBlendStack(ctx context.Context, t *testing.T, rpcService services.RPCService) *BlendStack { + t.Helper() + + s.SyncMasterSequence(ctx, t) + + // Capture the RPC latest ledger BEFORE any Blend transaction, so later tests know where + // Blend-specific history begins. + health, err := rpcService.GetHealth() + require.NoError(t, err, "getting RPC health for Blend stack start ledger") + startLedger := health.LatestLedger + + stack := &BlendStack{ + StartLedger: startLedger, + Whale: keypair.MustRandom(), + Supplier: keypair.MustRandom(), + Borrower: keypair.MustRandom(), + Filler: keypair.MustRandom(), + PoolName: "TestPool", + } + + s.CreateAndFundAccounts(ctx, t, []*keypair.Full{stack.Whale, stack.Supplier, stack.Borrower, stack.Filler}) + log.Ctx(ctx).Info("✅ Funded Blend actor accounts (whale, supplier, borrower, filler)") + + // --------------------------------------------------------------------------------------- + // Assets: BLND (new classic asset), USDC (reused), XLM (reused). + // --------------------------------------------------------------------------------------- + blndAsset := txnbuild.CreditAsset{Code: "BLND", Issuer: s.masterKeyPair.Address()} + blndXDRAsset, err := blndAsset.ToXDR() + require.NoError(t, err, "converting BLND asset to XDR") + blndContractID, err := blndXDRAsset.ContractID(networkPassphrase) + require.NoError(t, err, "computing BLND SAC contract ID") + stack.BLNDTokenID = strkey.MustEncode(strkey.VersionByteContract, blndContractID[:]) + require.Equal(t, blndPinnedSACAddress, stack.BLNDTokenID, "BLND SAC address must match the pin in internal/services/blend/validator.go") + + s.deployCreditAssetSAC(ctx, t, "BLND", s.masterKeyPair.Address()) + log.Ctx(ctx).Infof("✅ Deployed BLND SAC at %s", stack.BLNDTokenID) + + stack.USDCTokenID = s.usdcContractAddress + stack.XLMTokenID = nativeAssetContractAddress(t) + + // Trustlines + mints: whale gets 500,100 BLND + 12,501 USDC; supplier/borrower/filler get + // 5,000 USDC each plus an empty BLND trustline — BLND is a classic-asset SAC, so a G-account + // cannot receive claimed BLND emissions (pool.claim pays out via transfer_from) without a + // classic trustline. One batched transaction, mirroring createUSDCTrustlines. + usdcAsset := txnbuild.CreditAsset{Code: "USDC", Issuer: s.masterKeyPair.Address()} + fundOps := []txnbuild.Operation{ + &txnbuild.ChangeTrust{Line: txnbuild.ChangeTrustAssetWrapper{Asset: blndAsset}, Limit: DefaultTrustlineLimit, SourceAccount: stack.Whale.Address()}, + &txnbuild.Payment{Destination: stack.Whale.Address(), Amount: "500100", Asset: blndAsset, SourceAccount: s.masterKeyPair.Address()}, + &txnbuild.ChangeTrust{Line: txnbuild.ChangeTrustAssetWrapper{Asset: usdcAsset}, Limit: DefaultTrustlineLimit, SourceAccount: stack.Whale.Address()}, + &txnbuild.Payment{Destination: stack.Whale.Address(), Amount: "12501", Asset: usdcAsset, SourceAccount: s.masterKeyPair.Address()}, + } + for _, actor := range []*keypair.Full{stack.Supplier, stack.Borrower, stack.Filler} { + fundOps = append(fundOps, + &txnbuild.ChangeTrust{Line: txnbuild.ChangeTrustAssetWrapper{Asset: blndAsset}, Limit: DefaultTrustlineLimit, SourceAccount: actor.Address()}, + &txnbuild.ChangeTrust{Line: txnbuild.ChangeTrustAssetWrapper{Asset: usdcAsset}, Limit: DefaultTrustlineLimit, SourceAccount: actor.Address()}, + &txnbuild.Payment{Destination: actor.Address(), Amount: "5000", Asset: usdcAsset, SourceAccount: s.masterKeyPair.Address()}, + ) + } + _, err = executeClassicOperation(ctx, t, s, fundOps, []*keypair.Full{s.masterKeyPair, stack.Whale, stack.Supplier, stack.Borrower, stack.Filler}) + require.NoError(t, err, "funding Blend actors with BLND/USDC") + log.Ctx(ctx).Info("✅ Funded Blend actors with BLND/USDC trustlines and balances") + + dir := blendTestdataDir(t) + + // --------------------------------------------------------------------------------------- + // Comet AMM: backstop LP token pool. + // --------------------------------------------------------------------------------------- + cometWasmBytes, err := os.ReadFile(filepath.Join(dir, "comet.wasm")) + require.NoError(t, err, "reading comet.wasm") + cometWasmHash := s.uploadContractWasm(ctx, t, cometWasmBytes) + + cometFactoryWasmBytes, err := os.ReadFile(filepath.Join(dir, "comet_factory.wasm")) + require.NoError(t, err, "reading comet_factory.wasm") + cometFactoryWasmHash := s.uploadContractWasm(ctx, t, cometFactoryWasmBytes) + + stack.CometFactoryID = s.deployContractWithConstructor(ctx, t, cometFactoryWasmHash, []xdr.ScVal{}) + log.Ctx(ctx).Infof("✅ Deployed Comet factory at %s", stack.CometFactoryID) + + s.CometFactoryInit(ctx, t, stack.CometFactoryID, s.masterKeyPair, cometWasmHash) + + var cometSalt [32]byte + _, err = rand.Read(cometSalt[:]) + require.NoError(t, err, "generating comet pool salt") + stack.CometID = s.CometFactoryNewPool( + ctx, t, stack.CometFactoryID, s.masterKeyPair, cometSalt, + []string{stack.BLNDTokenID, stack.USDCTokenID}, + []*big.Int{big.NewInt(8_000_000), big.NewInt(2_000_000)}, // weights: 0.8e7 BLND / 0.2e7 USDC + []*big.Int{big.NewInt(10_000_000_000), big.NewInt(250_000_000)}, // balances: 1000e7 BLND / 25e7 USDC + big.NewInt(30_000), // swap fee: 0.003e7 + ) + log.Ctx(ctx).Infof("✅ Deployed Comet pool at %s", stack.CometID) + + s.CometJoinPool( + ctx, t, stack.CometID, stack.Whale, + big.NewInt(500_000_000_000), // poolAmountOut: 50,000e7 + []*big.Int{big.NewInt(5_001_000_000_000), big.NewInt(125_010_000_000)}, // maxAmountsIn: 500,100e7 BLND / 12,501e7 USDC + ) + log.Ctx(ctx).Info("✅ Whale joined Comet pool") + + // --------------------------------------------------------------------------------------- + // Mock SEP-40 oracle. + // --------------------------------------------------------------------------------------- + s.SyncMasterSequence(ctx, t) // re-sync before the next executeSorobanOperation-driven (master) call. + + oracleWasmBytes, err := os.ReadFile(filepath.Join(dir, "oracle.wasm")) + require.NoError(t, err, "reading oracle.wasm") + oracleWasmHash := s.uploadContractWasm(ctx, t, oracleWasmBytes) + stack.OracleID = s.deployContractWithConstructor(ctx, t, oracleWasmHash, []xdr.ScVal{}) + log.Ctx(ctx).Infof("✅ Deployed mock oracle at %s", stack.OracleID) + + s.OracleSetData(ctx, t, stack.OracleID, s.masterKeyPair, []string{stack.USDCTokenID, stack.XLMTokenID, stack.BLNDTokenID}, 7, 300) + s.OracleSetPriceStable(ctx, t, stack.OracleID, s.masterKeyPair, []*big.Int{ + big.NewInt(10_000_000), // USDC $1.00 + big.NewInt(1_000_000), // XLM $0.10 + big.NewInt(500_000), // BLND $0.05 + }) + log.Ctx(ctx).Info("✅ Set oracle data and stable prices") + + // --------------------------------------------------------------------------------------- + // Emitter (initialized with the backstop's precomputed address). + // --------------------------------------------------------------------------------------- + s.SyncMasterSequence(ctx, t) // re-sync: OracleSetData/OracleSetPriceStable drove master through executeSorobanOperationAs. + + emitterWasmBytes, err := os.ReadFile(filepath.Join(dir, "emitter.wasm")) + require.NoError(t, err, "reading emitter.wasm") + emitterWasmHash := s.uploadContractWasm(ctx, t, emitterWasmBytes) + stack.EmitterID = s.deployContractWithConstructor(ctx, t, emitterWasmHash, []xdr.ScVal{}) + log.Ctx(ctx).Infof("✅ Deployed emitter at %s", stack.EmitterID) + + var backstopSalt [32]byte + backstopSalt[0] = 0xB5 + stack.BackstopID, err = PrecomputeContractAddress(s.masterKeyPair.Address(), backstopSalt) + require.NoError(t, err, "precomputing backstop address") + require.Equal(t, backstopPinnedAddress, stack.BackstopID, + "backstop address must match the canonical-backstop pin in internal/services/blend/validator.go") + + s.EmitterInitialize(ctx, t, stack.EmitterID, s.masterKeyPair, stack.BLNDTokenID, stack.BackstopID, stack.CometID) + log.Ctx(ctx).Infof("✅ Initialized emitter (backstop precomputed at %s)", stack.BackstopID) + + // --------------------------------------------------------------------------------------- + // Pool factory (constructed with PoolInitMeta, referencing the precomputed backstop). + // --------------------------------------------------------------------------------------- + s.SyncMasterSequence(ctx, t) // re-sync: EmitterInitialize drove master through executeSorobanOperationAs. + + poolFactoryWasmBytes, err := os.ReadFile(filepath.Join(dir, "pool_factory.wasm")) + require.NoError(t, err, "reading pool_factory.wasm") + poolFactoryWasmHash := s.uploadContractWasm(ctx, t, poolFactoryWasmBytes) + + poolWasmBytes, err := os.ReadFile(blendPoolWasmPath(t)) + require.NoError(t, err, "reading blend_pool_v2.wasm") + poolWasmHash := s.uploadContractWasm(ctx, t, poolWasmBytes) + + poolInitMeta := scMap(t, + scMapEntry{key: "backstop", val: scAddr(t, stack.BackstopID)}, + scMapEntry{key: "blnd_id", val: scAddr(t, stack.BLNDTokenID)}, + scMapEntry{key: "pool_hash", val: scBytes32(poolWasmHash)}, + ) + stack.PoolFactoryID = s.deployContractWithConstructor(ctx, t, poolFactoryWasmHash, []xdr.ScVal{poolInitMeta}) + log.Ctx(ctx).Infof("✅ Deployed pool factory at %s", stack.PoolFactoryID) + + // --------------------------------------------------------------------------------------- + // Backstop (deployed at the precomputed address, verified to match). + // --------------------------------------------------------------------------------------- + backstopWasmBytes, err := os.ReadFile(blendBackstopWasmPath(t)) + require.NoError(t, err, "reading blend_backstop_v2.wasm") + backstopWasmHash := s.uploadContractWasm(ctx, t, backstopWasmBytes) + + backstopCtorArgs := []xdr.ScVal{ + scAddr(t, stack.CometID), // backstop_token + scAddr(t, stack.EmitterID), // emitter + scAddr(t, stack.BLNDTokenID), // blnd_token + scAddr(t, stack.USDCTokenID), // usdc_token + scAddr(t, stack.PoolFactoryID), // pool_factory + scVec(), // drop_list: empty Vec<(Address, i128)> + } + deployedBackstopID := s.deployContractWithSalt(ctx, t, backstopWasmHash, backstopCtorArgs, backstopSalt) + require.Equal(t, stack.BackstopID, deployedBackstopID, "backstop must deploy at its precomputed address") + log.Ctx(ctx).Infof("✅ Deployed backstop at %s", stack.BackstopID) + + // --------------------------------------------------------------------------------------- + // Pool ("TestPool"), reserves (USDC index 0, XLM index 1), and emissions config. + // --------------------------------------------------------------------------------------- + var poolSalt [32]byte + _, err = rand.Read(poolSalt[:]) + require.NoError(t, err, "generating pool salt") + stack.PoolID = s.PoolFactoryDeploy(ctx, t, stack.PoolFactoryID, s.masterKeyPair, stack.PoolName, poolSalt, stack.OracleID, 1_000_000, 4, big.NewInt(0)) + log.Ctx(ctx).Infof("✅ Deployed pool %q at %s", stack.PoolName, stack.PoolID) + + usdcReserveConfig, xlmReserveConfig := blendReserveConfigs() + + s.PoolQueueSetReserve(ctx, t, stack.PoolID, s.masterKeyPair, stack.USDCTokenID, usdcReserveConfig) + s.PoolSetReserve(ctx, t, stack.PoolID, s.masterKeyPair, stack.USDCTokenID) + s.PoolQueueSetReserve(ctx, t, stack.PoolID, s.masterKeyPair, stack.XLMTokenID, xlmReserveConfig) + s.PoolSetReserve(ctx, t, stack.PoolID, s.masterKeyPair, stack.XLMTokenID) + log.Ctx(ctx).Info("✅ Set USDC (index 0) and XLM (index 1) reserves") + + s.PoolSetEmissionsConfig(ctx, t, stack.PoolID, s.masterKeyPair, []BlendEmissionMetadata{ + {ResIndex: 0, ResType: 1, Share: 5_000_000}, // USDC bToken supply + {ResIndex: 1, ResType: 0, Share: 5_000_000}, // XLM dToken liabilities + }) + log.Ctx(ctx).Info("✅ Set pool emissions config") + + // --------------------------------------------------------------------------------------- + // Backstop funding + pool activation. + // --------------------------------------------------------------------------------------- + s.BackstopDeposit(ctx, t, stack.BackstopID, stack.Whale, stack.PoolID, big.NewInt(500_000_000_000)) // 50,000e7 + s.PoolSetStatus(ctx, t, stack.PoolID, s.masterKeyPair, 0) + s.BackstopAddReward(ctx, t, stack.BackstopID, s.masterKeyPair, stack.PoolID) + log.Ctx(ctx).Info("✅ Backstop funded and pool activated (status 0)") + + // The emitter must be able to mint BLND (1 BLND/sec on distribute). + s.SACSetAdmin(ctx, t, stack.BLNDTokenID, s.masterKeyPair, stack.EmitterID) + log.Ctx(ctx).Infof("✅ Transferred BLND SAC admin to emitter %s", stack.EmitterID) + + s.SyncMasterSequence(ctx, t) + + return stack +} + +// blendTestdataDir resolves the directory holding the Blend auxiliary contract WASM files +// (comet, comet_factory, oracle, emitter, pool_factory), which live alongside this source file. +func blendTestdataDir(t *testing.T) string { + t.Helper() + + _, filename, _, ok := runtime.Caller(0) + require.True(t, ok, "failed to get caller information") + return filepath.Join(filepath.Dir(filename), "testdata", "blend") +} + +// blendPoolWasmPath resolves the path to the Blend v2 pool contract WASM, which lives under +// internal/services/blend/testdata rather than this package's own testdata directory (see +// testdata/blend/README.md). +func blendPoolWasmPath(t *testing.T) string { + t.Helper() + + _, filename, _, ok := runtime.Caller(0) + require.True(t, ok, "failed to get caller information") + return filepath.Join(filepath.Dir(filename), "..", "..", "services", "blend", "testdata", "blend_pool_v2.wasm") +} + +// blendBackstopWasmPath resolves the path to the Blend v2 backstop contract WASM; see +// blendPoolWasmPath. +func blendBackstopWasmPath(t *testing.T) string { + t.Helper() + + _, filename, _, ok := runtime.Caller(0) + require.True(t, ok, "failed to get caller information") + return filepath.Join(filepath.Dir(filename), "..", "..", "services", "blend", "testdata", "blend_backstop_v2.wasm") +} diff --git a/internal/integrationtests/infrastructure/contract_deployment.go b/internal/integrationtests/infrastructure/contract_deployment.go index 1637e8298..b4ec4bc4f 100644 --- a/internal/integrationtests/infrastructure/contract_deployment.go +++ b/internal/integrationtests/infrastructure/contract_deployment.go @@ -125,9 +125,10 @@ func (s *SharedContainers) uploadContractWasm(ctx context.Context, t *testing.T, return wasmHash } -// deployContractWithConstructor deploys a Soroban contract with optional constructor arguments. -// This function uses CreateContractArgsV2 which supports passing arguments to the contract's -// __constructor function during deployment (if the contract has one). +// deployContractWithConstructor deploys a Soroban contract with optional constructor arguments, +// generating a random deployment salt. This function uses CreateContractArgsV2 which supports +// passing arguments to the contract's __constructor function during deployment (if the contract +// has one). // // For contracts WITHOUT a constructor: // - Pass an empty slice: []xdr.ScVal{} @@ -146,27 +147,46 @@ func (s *SharedContainers) uploadContractWasm(ctx context.Context, t *testing.T, // // Returns: // - Contract address (C...) of the deployed contract -// -// Process: -// 1. Generate random salt for unique contract address -// 2. Build CreateContractArgsV2 with constructor arguments -// 3. Simulate to get resource footprint -// 4. Sign and submit deployment transaction -// 5. Wait for confirmation -// 6. Calculate and return contract address func (s *SharedContainers) deployContractWithConstructor(ctx context.Context, t *testing.T, wasmHash xdr.Hash, constructorArgs []xdr.ScVal) string { - // Step 1: Generate random salt for unique contract address + // Generate random salt for unique contract address // The salt ensures each deployment creates a different contract address - salt := make([]byte, 32) - _, err := rand.Read(salt) + var salt [32]byte + _, err := rand.Read(salt[:]) require.NoError(t, err, "failed to generate salt") + + return s.deployContractWithSalt(ctx, t, wasmHash, constructorArgs, salt) +} + +// deployContractWithSalt deploys a Soroban contract with optional constructor arguments using a +// caller-supplied deployment salt, instead of a randomly generated one. This lets callers resolve +// circular deployment dependencies by precomputing a contract's address (via +// PrecomputeContractAddress) before it is actually deployed -- e.g. Blend's emitter must be +// initialized with the backstop's contract address before the backstop itself is deployed. +// +// Parameters: +// - ctx: Context for logging +// - t: Testing context for assertions +// - wasmHash: Hash of the uploaded WASM bytecode +// - constructorArgs: Constructor arguments (empty slice if no constructor) +// - salt: Deployment salt used to derive the contract address +// +// Returns: +// - Contract address (C...) of the deployed contract +// +// Process: +// 1. Build CreateContractArgsV2 with constructor arguments and the given salt +// 2. Simulate to get resource footprint +// 3. Sign and submit deployment transaction +// 4. Wait for confirmation +// 5. Calculate and return contract address +func (s *SharedContainers) deployContractWithSalt(ctx context.Context, t *testing.T, wasmHash xdr.Hash, constructorArgs []xdr.ScVal, salt [32]byte) string { var saltHash xdr.Uint256 - copy(saltHash[:], salt) + copy(saltHash[:], salt[:]) - // Step 2: Create deployer address from master account + // Create deployer address from master account deployerAccountID := xdr.MustAddress(s.masterKeyPair.Address()) - // Step 3: Create ContractIdPreimage for the deployment + // Create ContractIdPreimage for the deployment // This will be used both for the deployment operation and to compute the contract address preimage := xdr.ContractIdPreimage{ Type: xdr.ContractIdPreimageTypeContractIdPreimageFromAddress, @@ -179,7 +199,7 @@ func (s *SharedContainers) deployContractWithConstructor(ctx context.Context, t }, } - // Step 4: Create the InvokeHostFunction operation to deploy the contract + // Create the InvokeHostFunction operation to deploy the contract // We use CreateContractV2 which supports constructor arguments deployOp := &txnbuild.InvokeHostFunction{ HostFunction: xdr.HostFunction{ @@ -196,22 +216,43 @@ func (s *SharedContainers) deployContractWithConstructor(ctx context.Context, t SourceAccount: s.masterKeyPair.Address(), } - // Step 5: Execute the contract deployment operation + // Execute the contract deployment operation // requireAuth=true because CreateContractV2 with constructor requires deployer authorization - _, err = executeSorobanOperation(ctx, t, s, deployOp, true, DefaultConfirmationRetries) + _, err := executeSorobanOperation(ctx, t, s, deployOp, true, DefaultConfirmationRetries) require.NoError(t, err, "failed to deploy contract with constructor") - // Step 6: Calculate and return the contract address from the preimage + // Calculate and return the contract address from the preimage // The contract address is deterministically computed from the deployer address and salt - contractAddress, err := s.calculateContractID(networkPassphrase, preimage.MustFromAddress()) + contractAddress, err := computeContractID(networkPassphrase, preimage.MustFromAddress()) require.NoError(t, err, "failed to calculate contract ID") return contractAddress } -// calculateContractID calculates the contract ID for a wallet creation transaction based on the network passphrase, deployer account and salt. +// PrecomputeContractAddress derives the C-address that deployContractWithSalt would produce for +// the given deployer account and salt, without deploying anything. This lets callers resolve +// circular deployment dependencies -- e.g. Blend's emitter must be initialized with the +// backstop's contract address before the backstop is deployed, so the backstop's salt is chosen +// up front and its address precomputed here. +func PrecomputeContractAddress(deployer string, salt [32]byte) (string, error) { + var saltHash xdr.Uint256 + copy(saltHash[:], salt[:]) + + deployerAccountID := xdr.MustAddress(deployer) + deployerAddress := xdr.ContractIdPreimageFromAddress{ + Address: xdr.ScAddress{ + Type: xdr.ScAddressTypeScAddressTypeAccount, + AccountId: &deployerAccountID, + }, + Salt: saltHash, + } + + return computeContractID(networkPassphrase, deployerAddress) +} + +// computeContractID calculates the contract ID for a wallet creation transaction based on the network passphrase, deployer account and salt. // // More info: https://developers.stellar.org/docs/build/smart-contracts/example-contracts/deployer#how-it-works -func (s *SharedContainers) calculateContractID(networkPassphrase string, deployerAddress xdr.ContractIdPreimageFromAddress) (string, error) { +func computeContractID(networkPassphrase string, deployerAddress xdr.ContractIdPreimageFromAddress) (string, error) { networkHash := xdr.Hash(sha256.Sum256([]byte(networkPassphrase))) hashIDPreimage := xdr.HashIdPreimage{ diff --git a/internal/integrationtests/infrastructure/contract_deployment_test.go b/internal/integrationtests/infrastructure/contract_deployment_test.go new file mode 100644 index 000000000..f7237e46d --- /dev/null +++ b/internal/integrationtests/infrastructure/contract_deployment_test.go @@ -0,0 +1,34 @@ +package infrastructure + +import ( + "testing" + + "github.com/stellar/go-stellar-sdk/keypair" + "github.com/stretchr/testify/require" +) + +func TestPrecomputeContractAddress(t *testing.T) { + deployer := keypair.Root(networkPassphrase).Address() + var salt [32]byte + salt[0] = 1 + + // Golden value pinning the create-contract-v2 address preimage derivation + // (deployer account + salt + network ID) for the standalone network. + const expected = "CBXMDM5V2LLKT4IFSMNIW6PI3MK2IMLWUIK27GMX3HIX523X7BRBUQJC" + + address, err := PrecomputeContractAddress(deployer, salt) + require.NoError(t, err) + require.Equal(t, expected, address) + + // The derivation is deterministic: same inputs yield the same address. + again, err := PrecomputeContractAddress(deployer, salt) + require.NoError(t, err) + require.Equal(t, address, again) + + // A different salt yields a different address. + var otherSalt [32]byte + otherSalt[0] = 2 + otherAddress, err := PrecomputeContractAddress(deployer, otherSalt) + require.NoError(t, err) + require.NotEqual(t, address, otherAddress) +} diff --git a/internal/integrationtests/infrastructure/main_setup.go b/internal/integrationtests/infrastructure/main_setup.go index d83b5bb12..19c88cafb 100644 --- a/internal/integrationtests/infrastructure/main_setup.go +++ b/internal/integrationtests/infrastructure/main_setup.go @@ -412,6 +412,13 @@ func (s *SharedContainers) waitForIngestSync(ctx context.Context) error { } } +// WaitForIngestCatchup blocks until live ingestion has caught up with the RPC tip. Exported so +// tests that submit their own fixtures after initial setup (e.g. the Blend integration suite) can +// wait for those fixtures to be indexed without reaching into the unexported waitForIngestSync. +func (s *SharedContainers) WaitForIngestCatchup(ctx context.Context) error { + return s.waitForIngestSync(ctx) +} + // NewSharedContainers creates and starts all containers needed for integration tests func NewSharedContainers(t *testing.T) *SharedContainers { // Get the directory of the current source file diff --git a/internal/integrationtests/infrastructure/testdata/blend/README.md b/internal/integrationtests/infrastructure/testdata/blend/README.md new file mode 100644 index 000000000..f0adef465 --- /dev/null +++ b/internal/integrationtests/infrastructure/testdata/blend/README.md @@ -0,0 +1,60 @@ +# Blend Auxiliary Contract WASM Files + +This directory contains pre-compiled Soroban smart contract WASM files for the auxiliary +contracts that surround the Blend Capital v2 protocol (pool factory, emitter, Comet AMM, +and a mock SEP-40 oracle). They are used by integration tests that deploy the full Blend +protocol stack on a standalone Stellar network. + +## Source + +- **Repository**: [blend-capital/blend-utils](https://github.com/blend-capital/blend-utils) +- **Pinned commit**: `b05242df30b6b6caf9d317646f754541824a5a8b` + +## Files + +### pool_factory.wasm +- **Source path**: `wasm_v2/pool_factory.wasm` +- **sha256**: `31328050548831f63d2b72e37bcfd0bb7371b7907135755dbe09ed434d755ca9` +- **Purpose**: Deploys and tracks Blend v2 lending pool instances. + +### emitter.wasm +- **Source path**: `wasm_v1/emitter.wasm` (Blend v2 reuses the v1 emitter contract) +- **sha256**: `438a5528cff17ede6fe515f095c43c5f15727af17d006971485e52462e7e7b89` +- **Purpose**: Distributes BLND emissions to the backstop module of reward-zone pools. + +### comet.wasm +- **Source path**: `wasm_v1/comet.wasm` +- **sha256**: `8abc28913035c07411ed5d134e6bfeab4723d97ddd4d1a22a0605d35c94d1a36` +- **Purpose**: Balancer-style weighted-pool AMM used to hold the BLND/USDC backstop LP token. + +### comet_factory.wasm +- **Source path**: `wasm_v1/comet_factory.wasm` +- **sha256**: `bf7adb09076853eb3aa569278754111d86e161e35e7dc6a984ecde2b9d6700ae` +- **Purpose**: Deploys new Comet pool instances from a pinned Comet wasm hash. + +### oracle.wasm +- **Source path**: `src/external/oracle.wasm` +- **sha256**: `66c0b87b5eb481be594175d59e66ec9a9ac8945be0fec4e09f6c28bf7a1708be` +- **Purpose**: Mock SEP-40 price oracle used to feed reserve/backstop asset prices to a pool. + +## Blend pool and backstop wasms are not duplicated here + +The Blend v2 pool and backstop contract wasms already live at +`internal/services/blend/testdata/blend_pool_v2.wasm` and +`internal/services/blend/testdata/blend_backstop_v2.wasm`. They are byte-identical to +`wasm_v2/pool.wasm` and `wasm_v2/backstop.wasm` in blend-utils at the pinned commit above +(verified via sha256): + +| File | sha256 | +|---|---| +| `blend-utils/wasm_v2/pool.wasm` | `a41fc53d6753b6c04eb15b021c55052366a4c8e0e21bc72700f461264ec1350e` | +| `internal/services/blend/testdata/blend_pool_v2.wasm` | `a41fc53d6753b6c04eb15b021c55052366a4c8e0e21bc72700f461264ec1350e` | +| `blend-utils/wasm_v2/backstop.wasm` | `c1f4502a757e25c611f5a159bc1ab0eef64085adac6c68123dca66e87faffbc2` | +| `internal/services/blend/testdata/blend_backstop_v2.wasm` | `c1f4502a757e25c611f5a159bc1ab0eef64085adac6c68123dca66e87faffbc2` | + +## Updating WASM Files + +1. Clone `blend-capital/blend-utils` and pin to the desired commit. +2. Copy the updated wasm files from the source paths listed above. +3. Recompute sha256 (`shasum -a 256 `) and update this README. +4. Re-verify the pool/backstop byte-identity claim above and update the table. diff --git a/internal/integrationtests/infrastructure/testdata/blend/comet.wasm b/internal/integrationtests/infrastructure/testdata/blend/comet.wasm new file mode 100644 index 000000000..de1b1fa42 Binary files /dev/null and b/internal/integrationtests/infrastructure/testdata/blend/comet.wasm differ diff --git a/internal/integrationtests/infrastructure/testdata/blend/comet_factory.wasm b/internal/integrationtests/infrastructure/testdata/blend/comet_factory.wasm new file mode 100644 index 000000000..9db8020a7 Binary files /dev/null and b/internal/integrationtests/infrastructure/testdata/blend/comet_factory.wasm differ diff --git a/internal/integrationtests/infrastructure/testdata/blend/emitter.wasm b/internal/integrationtests/infrastructure/testdata/blend/emitter.wasm new file mode 100644 index 000000000..c6f00d0e3 Binary files /dev/null and b/internal/integrationtests/infrastructure/testdata/blend/emitter.wasm differ diff --git a/internal/integrationtests/infrastructure/testdata/blend/oracle.wasm b/internal/integrationtests/infrastructure/testdata/blend/oracle.wasm new file mode 100644 index 000000000..75e1736f1 Binary files /dev/null and b/internal/integrationtests/infrastructure/testdata/blend/oracle.wasm differ diff --git a/internal/integrationtests/infrastructure/testdata/blend/pool_factory.wasm b/internal/integrationtests/infrastructure/testdata/blend/pool_factory.wasm new file mode 100644 index 000000000..3a6fde5c8 Binary files /dev/null and b/internal/integrationtests/infrastructure/testdata/blend/pool_factory.wasm differ diff --git a/internal/integrationtests/main_test.go b/internal/integrationtests/main_test.go index e7fe7e8b6..ecd809fdd 100644 --- a/internal/integrationtests/main_test.go +++ b/internal/integrationtests/main_test.go @@ -89,4 +89,23 @@ func TestIntegrationTests(t *testing.T) { if t.Failed() { t.Fatal("AccountBalancesAfterLiveIngestionTestSuite failed, skipping remaining tests") } + + // Phase 4: Blend v2 — deploy the protocol stack and run phase-1 ops under live + // ingestion, migrate (current-state + history) via the datastore, then run + // phase-2 ops under live ingestion and assert over GraphQL. + blendStack := testEnv.Containers.SetupBlendStack(ctx, t, testEnv.RPCService) + testEnv.Containers.SubmitBlendPhase1Ops(ctx, t, blendStack) + + log.Ctx(ctx).Info("Waiting for ingest service to process Blend phase-1 transactions...") + time.Sleep(5 * time.Second) + + t.Run("BlendMigrationTestSuite", func(t *testing.T) { + suite.Run(t, &BlendMigrationTestSuite{testEnv: testEnv, stack: blendStack}) + }) + if t.Failed() { + t.Fatal("BlendMigrationTestSuite failed, skipping BlendLiveIngestionTestSuite") + } + t.Run("BlendLiveIngestionTestSuite", func(t *testing.T) { + suite.Run(t, &BlendLiveIngestionTestSuite{testEnv: testEnv, stack: blendStack}) + }) } diff --git a/internal/serve/complexity_test.go b/internal/serve/complexity_test.go index 0efe395cc..c9103b4a6 100644 --- a/internal/serve/complexity_test.go +++ b/internal/serve/complexity_test.go @@ -295,14 +295,18 @@ func TestGraphQLComplexityAccountingUsesSharedDefaultsAndExplicitArgs(t *testing } // TestGraphQLComplexityAccountingForBlendFields locks in the multipliers added for the Blend -// v2 fields in addComplexityCalculation. Each case selects a minimal field set so the expected -// complexity is easy to verify by hand and stays stable across unrelated schema edits. +// v2 fields in addComplexityCalculation: every Blend list level carries its own cardinality +// (catalog ×50, per-account lists ×10, reserves/bid/lot ×30, q4w ×20), so nested lists are +// priced as the product of the levels, not folded into a single outer multiplier. Each case +// selects a minimal field set so the expected complexity is easy to verify by hand and stays +// stable across unrelated schema edits. // -// Worst case (every field selected across the full Blend schema, derived in the comment above -// the multipliers in addComplexityCalculation): blendPools ≈ 1450, blendEarnOptions ≈ 450, -// blendPositions ≈ 370 — all comfortably under the production GRAPHQL_COMPLEXITY_LIMIT=6000. -// This does not touch AccountTransactionEdge.operations/stateChanges or any other existing -// complexity entry, so the freighter full-detail account-history query budget is unchanged. +// Worst case (every field selected, derived in the comment above the multipliers in +// addComplexityCalculation): blendPools = 26,150 and blendPositions = 7,572 — both above a +// 6,000 complexity limit by design; admitting the full selections requires a deployment-side +// limit raise. This does not touch AccountTransactionEdge.operations/stateChanges or any other +// existing complexity entry, so the freighter full-detail account-history query budget is +// unchanged. func TestGraphQLComplexityAccountingForBlendFields(t *testing.T) { testCases := []struct { name string @@ -322,8 +326,8 @@ func TestGraphQLComplexityAccountingForBlendFields(t *testing.T) { expectedMessage: "operation has complexity 50, which exceeds the limit of 49", }, { - name: "blendPools reserves are folded into the multiplied total", - limit: 149, + name: "blendPools reserves multiply by MAX_RESERVES inside the catalog multiplier", + limit: 1549, query: `query { blendPools { address @@ -332,22 +336,8 @@ func TestGraphQLComplexityAccountingForBlendFields(t *testing.T) { } } }`, - // reserves{assetContractId}: default 1+1=2. address(1)+reserves(2)=3 => 3*50=150. - expectedMessage: "operation has complexity 150, which exceeds the limit of 149", - }, - { - name: "blendEarnOptions carries the accounts-list page-size multiplier", - limit: 149, - query: `query { - blendEarnOptions { - assetContractId - pools { - poolAddress - } - } - }`, - // pools{poolAddress}: default 1+1=2. assetContractId(1)+pools(2)=3 => 3*50=150. - expectedMessage: "operation has complexity 150, which exceeds the limit of 149", + // reserves{assetContractId}: 30*1=30. address(1)+reserves(30)=31 => 31*50=1550. + expectedMessage: "operation has complexity 1550, which exceeds the limit of 1549", }, { name: "blendPool (single) is left at the default, unmultiplied", @@ -361,8 +351,8 @@ func TestGraphQLComplexityAccountingForBlendFields(t *testing.T) { expectedMessage: "operation has complexity 2, which exceeds the limit of 1", }, { - name: "account blendPositions carries the x10 multiplier", - limit: 10, + name: "account blendPositions itself is default-priced; cardinality lives on its lists", + limit: 2, query: `query { accountByAddress(address: "` + complexityTestAccountAddress + `") { blendPositions { @@ -370,13 +360,13 @@ func TestGraphQLComplexityAccountingForBlendFields(t *testing.T) { } } }`, - // blendPositions{backstopClaimedLp}: childComplexity 1 => 1*10=10. - // accountByAddress (default) = 1+10=11. - expectedMessage: "operation has complexity 11, which exceeds the limit of 10", + // blendPositions{backstopClaimedLp}: default 1+1=2. + // accountByAddress (default) = 1+2=3. + expectedMessage: "operation has complexity 3, which exceeds the limit of 2", }, { - name: "account blendPositions nested pools/backstop are folded into the x10 total", - limit: 90, + name: "account blendPositions pools/backstop multiply per level", + limit: 522, query: `query { accountByAddress(address: "` + complexityTestAccountAddress + `") { blendPositions { @@ -396,11 +386,30 @@ func TestGraphQLComplexityAccountingForBlendFields(t *testing.T) { } } }`, - // reserves{assetContractId}=1+1=2; pools item=poolAddress(1)+reserves(2)=3; pools field=1+3=4. - // q4w{amount}=1+1=2; backstop item=poolAddress(1)+q4w(2)=3; backstop field=1+3=4. - // blendPositions childComplexity = pools(4)+backstop(4)+backstopClaimedLp(1)=9 => 9*10=90. - // accountByAddress (default) = 1+90=91. - expectedMessage: "operation has complexity 91, which exceeds the limit of 90", + // reserves{assetContractId}=30*1=30; pools item=poolAddress(1)+reserves(30)=31; pools field=10*31=310. + // q4w{amount}=20*1=20; backstop item=poolAddress(1)+q4w(20)=21; backstop field=10*21=210. + // blendPositions childComplexity = pools(310)+backstop(210)+backstopClaimedLp(1)=521 => default 1+521=522. + // accountByAddress (default) = 1+522=523. + expectedMessage: "operation has complexity 523, which exceeds the limit of 522", + }, + { + name: "auction bid/lot amounts multiply under activeAuctions", + limit: 311, + query: `query { + accountByAddress(address: "` + complexityTestAccountAddress + `") { + blendPositions { + activeAuctions { + poolAddress + bid { + amount + } + } + } + } + }`, + // bid{amount}=30*1=30; auction item=poolAddress(1)+bid(30)=31; activeAuctions=10*31=310. + // blendPositions default = 1+310=311; accountByAddress = 1+311=312. + expectedMessage: "operation has complexity 312, which exceeds the limit of 311", }, } diff --git a/internal/serve/graphql/generated/generated.go b/internal/serve/graphql/generated/generated.go index 62ff70533..0c9b42f2b 100644 --- a/internal/serve/graphql/generated/generated.go +++ b/internal/serve/graphql/generated/generated.go @@ -143,22 +143,6 @@ type ComplexityRoot struct { UsdValue func(childComplexity int) int } - BlendEarnOption struct { - AssetContractID func(childComplexity int) int - Pools func(childComplexity int) int - TokenDecimals func(childComplexity int) int - TokenName func(childComplexity int) int - TokenSymbol func(childComplexity int) int - } - - BlendEarnPoolOption struct { - EmissionsSupplyApr func(childComplexity int) int - PoolAddress func(childComplexity int) int - PoolName func(childComplexity int) int - SuppliedUsd func(childComplexity int) int - SupplyApy func(childComplexity int) int - } - BlendPool struct { Address func(childComplexity int) int Admin func(childComplexity int) int @@ -220,9 +204,10 @@ type ComplexityRoot struct { BorrowedTokens func(childComplexity int) int BorrowedUsd func(childComplexity int) int CollateralTokens func(childComplexity int) int - EmissionsApr func(childComplexity int) int + EmissionsBorrowApr func(childComplexity int) int EmissionsEarnedBlnd func(childComplexity int) int EmissionsEarnedUsd func(childComplexity int) int + EmissionsSupplyApr func(childComplexity int) int InterestEarned func(childComplexity int) int InterestPaid func(childComplexity int) int PriceUsd func(childComplexity int) int @@ -330,7 +315,6 @@ type ComplexityRoot struct { Query struct { AccountByAddress func(childComplexity int, address string) int - BlendEarnOptions func(childComplexity int) int BlendPool func(childComplexity int, address string) int BlendPools func(childComplexity int) int OperationByID func(childComplexity int, id int64) int @@ -579,7 +563,6 @@ type QueryResolver interface { StateChanges(ctx context.Context, first *int32, after *string, last *int32, before *string) (*StateChangeConnection, error) BlendPools(ctx context.Context) ([]*BlendPool, error) BlendPool(ctx context.Context, address string) (*BlendPool, error) - BlendEarnOptions(ctx context.Context) ([]*BlendEarnOption, error) } type ReservesChangeResolver interface { Type(ctx context.Context, obj *types.ReservesStateChangeModel) (types.StateChangeCategory, error) @@ -1048,68 +1031,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.ComplexityRoot.BlendBackstopPosition.UsdValue(childComplexity), true - case "BlendEarnOption.assetContractId": - if e.ComplexityRoot.BlendEarnOption.AssetContractID == nil { - break - } - - return e.ComplexityRoot.BlendEarnOption.AssetContractID(childComplexity), true - case "BlendEarnOption.pools": - if e.ComplexityRoot.BlendEarnOption.Pools == nil { - break - } - - return e.ComplexityRoot.BlendEarnOption.Pools(childComplexity), true - case "BlendEarnOption.tokenDecimals": - if e.ComplexityRoot.BlendEarnOption.TokenDecimals == nil { - break - } - - return e.ComplexityRoot.BlendEarnOption.TokenDecimals(childComplexity), true - case "BlendEarnOption.tokenName": - if e.ComplexityRoot.BlendEarnOption.TokenName == nil { - break - } - - return e.ComplexityRoot.BlendEarnOption.TokenName(childComplexity), true - case "BlendEarnOption.tokenSymbol": - if e.ComplexityRoot.BlendEarnOption.TokenSymbol == nil { - break - } - - return e.ComplexityRoot.BlendEarnOption.TokenSymbol(childComplexity), true - - case "BlendEarnPoolOption.emissionsSupplyApr": - if e.ComplexityRoot.BlendEarnPoolOption.EmissionsSupplyApr == nil { - break - } - - return e.ComplexityRoot.BlendEarnPoolOption.EmissionsSupplyApr(childComplexity), true - case "BlendEarnPoolOption.poolAddress": - if e.ComplexityRoot.BlendEarnPoolOption.PoolAddress == nil { - break - } - - return e.ComplexityRoot.BlendEarnPoolOption.PoolAddress(childComplexity), true - case "BlendEarnPoolOption.poolName": - if e.ComplexityRoot.BlendEarnPoolOption.PoolName == nil { - break - } - - return e.ComplexityRoot.BlendEarnPoolOption.PoolName(childComplexity), true - case "BlendEarnPoolOption.suppliedUsd": - if e.ComplexityRoot.BlendEarnPoolOption.SuppliedUsd == nil { - break - } - - return e.ComplexityRoot.BlendEarnPoolOption.SuppliedUsd(childComplexity), true - case "BlendEarnPoolOption.supplyApy": - if e.ComplexityRoot.BlendEarnPoolOption.SupplyApy == nil { - break - } - - return e.ComplexityRoot.BlendEarnPoolOption.SupplyApy(childComplexity), true - case "BlendPool.address": if e.ComplexityRoot.BlendPool.Address == nil { break @@ -1402,12 +1323,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.BlendReservePosition.CollateralTokens(childComplexity), true - case "BlendReservePosition.emissionsApr": - if e.ComplexityRoot.BlendReservePosition.EmissionsApr == nil { + case "BlendReservePosition.emissionsBorrowApr": + if e.ComplexityRoot.BlendReservePosition.EmissionsBorrowApr == nil { break } - return e.ComplexityRoot.BlendReservePosition.EmissionsApr(childComplexity), true + return e.ComplexityRoot.BlendReservePosition.EmissionsBorrowApr(childComplexity), true case "BlendReservePosition.emissionsEarnedBlnd": if e.ComplexityRoot.BlendReservePosition.EmissionsEarnedBlnd == nil { break @@ -1420,6 +1341,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.BlendReservePosition.EmissionsEarnedUsd(childComplexity), true + case "BlendReservePosition.emissionsSupplyApr": + if e.ComplexityRoot.BlendReservePosition.EmissionsSupplyApr == nil { + break + } + + return e.ComplexityRoot.BlendReservePosition.EmissionsSupplyApr(childComplexity), true case "BlendReservePosition.interestEarned": if e.ComplexityRoot.BlendReservePosition.InterestEarned == nil { break @@ -1885,12 +1812,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.ComplexityRoot.Query.AccountByAddress(childComplexity, args["address"].(string)), true - case "Query.blendEarnOptions": - if e.ComplexityRoot.Query.BlendEarnOptions == nil { - break - } - - return e.ComplexityRoot.Query.BlendEarnOptions(childComplexity), true case "Query.blendPool": if e.ComplexityRoot.Query.BlendPool == nil { break @@ -2761,6 +2682,9 @@ type Account{ # expiration_ledger is below the latest ingested ledger are filtered out server-side. # Relay-paginated with a max page size of 100. sep41Allowances(first: Int, after: String, last: Int, before: String): SEP41AllowanceConnection! @goField(forceResolver: true) + + """An account's Blend v2 lending, collateral, borrowing, and backstop positions.""" + blendPositions: BlendAccountPositions! @goField(forceResolver: true) } `, BuiltIn: false}, {Name: "../schema/balances.graphqls", Input: `interface Balance { @@ -2856,12 +2780,14 @@ type SEP41Allowance { `, BuiltIn: false}, {Name: "../schema/blend.graphqls", Input: `# Blend v2 lending protocol GraphQL surface. # -# Query.blendPools/blendPool are pool-wide catalog views, independent of any -# account. Query.blendEarnOptions is a "where can I earn this asset" catalog -# view. Account.blendPositions is an account's lending, collateral, and -# backstop positions across every Blend v2 pool it touches, plus any active -# Dutch auctions where the account is the auction owner -# (BlendAccountPositions.activeAuctions). +# The Query.blendPools/blendPool entry points (a pool-wide catalog view, +# independent of any account) live on the base type Query in queries.graphqls, +# and Account.blendPositions (an account's lending, collateral, and backstop +# positions across every Blend v2 pool it touches, plus any active Dutch +# auctions where the account is the auction owner — +# BlendAccountPositions.activeAuctions) lives on type Account in +# account.graphqls. This file defines every Blend v2 object/enum type those +# fields resolve to. # # Money-shaped fields use Float for USD/APY values (nullable wherever a missing # oracle price makes the value uncomputable — a genuinely zero balance is still @@ -2869,17 +2795,6 @@ type SEP41Allowance { # rather than lossy float64. A stored price older than 24h counts as missing: # the pool contract itself refuses prices past that age. -extend type Query { - blendPools: [BlendPool!]! - blendPool(address: String!): BlendPool - blendEarnOptions: [BlendEarnOption!]! -} - -extend type Account { - """An account's Blend v2 lending, collateral, borrowing, and backstop positions.""" - blendPositions: BlendAccountPositions! @goField(forceResolver: true) -} - """ BlendAccountPositions aggregates one account's Blend v2 exposure across every pool it has touched. backstopClaimedLp is lifetime backstop-emission claims in @@ -2908,6 +2823,13 @@ type BlendPoolPosition { usdValue: Float suppliedUsd: Float borrowedUsd: Float + """ + Supply-vs-borrow interest netting over TOTAL SUPPLIED USD — the blend-sdk-js + PositionsEstimate convention shown by the Blend UI: + (Σ suppliedUsd·supplyApy − Σ borrowedUsd·borrowApy) / Σ suppliedUsd. + 0 for a position with debt but no supply (bad debt is forgiven). Null when + any contributing reserve is missing a fresh oracle price. + """ netApy: Float """Lifetime BLND this account has claimed from this pool's reserve emissions.""" claimedBlnd: String! @@ -2934,8 +2856,44 @@ type BlendReservePosition { borrowedUsd: Float supplyApy: Float borrowApy: Float - emissionsApr: Float + """ + The reserve's POOL-WIDE bToken (supply) emission-stream APR: annualized BLND + value over the side's pool-wide supplied USD, NOT scaled to this account's + holding. 0 when no active stream (unconfigured or expired), null when the + stream is active but the reserve or BLND price is unavailable. + """ + emissionsSupplyApr: Float + """ + The reserve's POOL-WIDE dToken (borrow) emission-stream APR: annualized BLND + value over the side's pool-wide borrowed USD, NOT scaled to this account's + holding. 0 when no active stream (unconfigured or expired), null when the + stream is active but the reserve or BLND price is unavailable. + """ + emissionsBorrowApr: Float + """ + Lifetime interest earned on the supply side of this reserve: the current + underlying value of the account's supply+collateral bTokens (at projected, + as-of-now rates) minus the net principal it contributed — a cost basis + tracked across the account's whole deposit/withdraw history. Token-denominated: + a raw integer at the reserve asset's decimals (tokenDecimals), so multiply by + priceUsd for a USD value. A liquidation reduces the cost basis by the seized + collateral's underlying value at the reserve's rate when the fill is + processed, so collateral lost to a fill cancels out instead of being reported + as earned interest. Survives a full exit: a position zeroed to no tokens still + reports the earnings realized up to the exit (current value 0 minus the + negative leftover cost basis). May be slightly negative from cost-basis dust + truncation or bRate movement after an exit. + """ interestEarned: String! + """ + Lifetime interest paid on the debt side of this reserve, mirroring + interestEarned: the current underlying value of the account's liability + dTokens (at projected, as-of-now rates) minus its net borrowed principal + (cost basis tracked from borrow/repay history). Token-denominated (raw + integer at tokenDecimals; use priceUsd for USD), lifetime, and survives a + full exit the same way. May be slightly negative for the same dust/rate + reasons. + """ interestPaid: String! emissionsEarnedBlnd: String! emissionsEarnedUsd: Float @@ -2944,13 +2902,17 @@ type BlendReservePosition { } """ -BlendBackstopPosition is an account's backstop deposit in one pool. shares -is the ACTIVE (non-queued) backstop share balance; lpTokens/usdValue value -the whole deposit — active plus queued-for-withdrawal shares — since queued -shares keep earning pool interest and remain slashable first-loss capital -until actually withdrawn. emissionsEarnedBlnd is CLAIMABLE (uncollected) -BLND accrued on the backstop emission stream for this pool; it accrues on -active shares only (queued shares earn no emissions). +BlendBackstopPosition is an account's backstop deposit in one pool. The +backstop is a single protocol-wide contract holding each pool's first-loss +capital separately, so exposure is inherently per-pool (poolAddress) even +though the backstop is not the pool contract itself — one +BlendBackstopPosition per backed pool. shares is the ACTIVE (non-queued) +backstop share balance; lpTokens/usdValue value the whole deposit — active +plus queued-for-withdrawal shares — since queued shares keep earning pool +interest and remain slashable first-loss capital until actually withdrawn. +emissionsEarnedBlnd is CLAIMABLE (uncollected) BLND accrued on the backstop +emission stream for this pool; it accrues on active shares only (queued +shares earn no emissions). """ type BlendBackstopPosition { poolAddress: String! @@ -2990,12 +2952,12 @@ type BlendPool { address: String! name: String """ - Raw on-chain pool status: 0 Admin Active, 1 Active, 2 Admin On-Ice, - 3 On-Ice, 4 Admin Frozen, 5 Frozen, 6 Setup. Statuses 0-3 accept supply - (deposits); 0-1 also allow borrowing; 4-6 reject both. Null until the - pool's config entry has been ingested. + Pool status. Statuses ADMIN_ACTIVE/ACTIVE/ADMIN_ON_ICE/ON_ICE accept supply + (deposits); ADMIN_ACTIVE/ACTIVE also allow borrowing; ADMIN_FROZEN/FROZEN/ + SETUP reject both. Null until the pool's config entry has been ingested, and + also null for an unrecognized on-chain status value. """ - status: Int + status: BlendPoolStatus oracleContractId: String """ Share of borrower interest routed to the pool's backstop, as 7-decimal @@ -3019,6 +2981,22 @@ type BlendPool { inRewardZone: Boolean! } +""" +BlendPoolStatus is a pool's operational status. On-chain encoding: +0 ADMIN_ACTIVE, 1 ACTIVE, 2 ADMIN_ON_ICE, 3 ON_ICE, 4 ADMIN_FROZEN, +5 FROZEN, 6 SETUP. Statuses 0-3 accept supply (deposits); 0-1 also allow +borrowing; 4-6 reject both. +""" +enum BlendPoolStatus { + ADMIN_ACTIVE + ACTIVE + ADMIN_ON_ICE + ON_ICE + ADMIN_FROZEN + FROZEN + SETUP +} + enum BlendAuctionType { USER_LIQUIDATION BAD_DEBT @@ -3076,35 +3054,6 @@ type BlendReserve { lFactor: Int priceUsd: Float } - -""" -BlendEarnOption is a "where can I earn this asset" catalog view: one entry -per asset with at least one enabled reserve in a pool that currently accepts -supply (status 0-3). Frozen (4-5) and Setup (6) pools reject deposits -on-chain, so their reserves are never earn options. -""" -type BlendEarnOption { - assetContractId: String! - tokenName: String - tokenSymbol: String - tokenDecimals: Int - pools: [BlendEarnPoolOption!]! -} - -""" -BlendEarnPoolOption is one pool's offer for a BlendEarnOption's asset. -supplyApy is the interest-only yield; emissionsSupplyApr is the reserve's -BLND-emission yield on the supply side (0 when no active stream, null when -the BLND price is unavailable) — supplyApy + emissionsSupplyApr is the -emissions-inclusive rate a wallet shows as the earn headline. -""" -type BlendEarnPoolOption { - poolAddress: String! - poolName: String - supplyApy: Float - emissionsSupplyApr: Float - suppliedUsd: Float -} `, BuiltIn: false}, {Name: "../schema/directives.graphqls", Input: `# GraphQL Directive - provides metadata to control gqlgen code generation # Directives are like annotations that modify how GraphQL processes fields @@ -3348,6 +3297,10 @@ type Query { operations(first: Int, after: String, last: Int, before: String): OperationConnection operationById(id: Int64!): Operation stateChanges(first: Int, after: String, last: Int, before: String): StateChangeConnection + + # Blend v2 lending - pool-wide catalog views, independent of any account + blendPools: [BlendPool!]! + blendPool(address: String!): BlendPool } `, BuiltIn: false}, {Name: "../schema/scalars.graphqls", Input: `# GraphQL Custom Scalars - extend GraphQL's built-in scalar types @@ -3521,6 +3474,41 @@ type BalanceAuthorizationChange implements BaseStateChange{ flags: [String!]! } +# LendingChange is a Blend v2 lending state change. Its reason (a LENDING-category +# StateChangeReason) determines how tokenId/amount/poolId are populated: +# +# Reason tokenId amount poolId +# SUPPLY reserve asset underlying supplied emitting pool +# SUPPLY_COLLATERAL reserve asset underlying supplied emitting pool +# WITHDRAW reserve asset underlying withdrawn emitting pool +# WITHDRAW_COLLATERAL reserve asset underlying withdrawn emitting pool +# BORROW reserve asset underlying borrowed emitting pool +# REPAY reserve asset underlying repaid emitting pool +# FLASH_LOAN reserve asset underlying loaned emitting pool +# BAD_DEBT reserve asset dTokens socialized emitting pool +# DEFAULTED_DEBT reserve asset dTokens burnt emitting pool +# CLAIM (pool) BLND SAC (see below) BLND claimed emitting pool +# CLAIM (backstop) null Comet LP minted null (see below) +# LIQUIDATION null null (see below) pool the auction is on +# BACKSTOP_DEPOSIT null LP tokens deposited target pool +# BACKSTOP_WITHDRAW_QUEUE null backstop shares queued target pool +# BACKSTOP_WITHDRAW_CANCEL null backstop shares dequeued target pool +# BACKSTOP_WITHDRAW null LP tokens returned target pool +# +# tokenId: a reserve-asset C-address for every pool-reserve movement; the BLND +# SAC C-address for a pool CLAIM (null when the network's BLND address is +# unknown, so the claim is never misattributed). Always null for LIQUIDATION +# (a fill touches many assets — see amount) and for every backstop-share row +# (backstop CLAIM/DEPOSIT/WITHDRAW_QUEUE/WITHDRAW_CANCEL/WITHDRAW), whose value +# is denominated in backstop shares or Comet LP tokens, neither of which has a +# reserve-asset token id. +# amount: a raw on-chain i128 integer at the token's own native decimals (NOT a +# USD value). Null only for LIQUIDATION, whose per-asset lot/bid amounts are +# carried in the row's key-value extras instead of this single field. +# poolId: the pool contract C-address, from the emitting pool for pool events and +# from an event topic for the per-pool backstop events. Null only for a backstop +# CLAIM, whose on-chain event aggregates across every pool claimed and carries +# no pool address at all. type LendingChange implements BaseStateChange { type: StateChangeCategory! @goField(forceResolver: true) reason: StateChangeReason! @goField(forceResolver: true) @@ -5993,377 +5981,75 @@ func (ec *executionContext) _BlendBackstopPosition_q4w(ctx context.Context, fiel field, ec.fieldContext_BlendBackstopPosition_q4w, func(ctx context.Context) (any, error) { - return obj.Q4w, nil - }, - nil, - ec.marshalNBlendQ4W2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBlendQ4wᚄ, - true, - true, - ) -} - -func (ec *executionContext) fieldContext_BlendBackstopPosition_q4w(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "BlendBackstopPosition", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - switch field.Name { - case "amount": - return ec.fieldContext_BlendQ4W_amount(ctx, field) - case "expiration": - return ec.fieldContext_BlendQ4W_expiration(ctx, field) - case "lpTokens": - return ec.fieldContext_BlendQ4W_lpTokens(ctx, field) - case "usdValue": - return ec.fieldContext_BlendQ4W_usdValue(ctx, field) - } - return nil, fmt.Errorf("no field named %q was found under type BlendQ4W", field.Name) - }, - } - return fc, nil -} - -func (ec *executionContext) _BlendBackstopPosition_emissionsEarnedBlnd(ctx context.Context, field graphql.CollectedField, obj *BlendBackstopPosition) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_BlendBackstopPosition_emissionsEarnedBlnd, - func(ctx context.Context) (any, error) { - return obj.EmissionsEarnedBlnd, nil - }, - nil, - ec.marshalNString2string, - true, - true, - ) -} - -func (ec *executionContext) fieldContext_BlendBackstopPosition_emissionsEarnedBlnd(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "BlendBackstopPosition", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type String does not have child fields") - }, - } - return fc, nil -} - -func (ec *executionContext) _BlendBackstopPosition_emissionsEarnedUsd(ctx context.Context, field graphql.CollectedField, obj *BlendBackstopPosition) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_BlendBackstopPosition_emissionsEarnedUsd, - func(ctx context.Context) (any, error) { - return obj.EmissionsEarnedUsd, nil - }, - nil, - ec.marshalOFloat2ᚖfloat64, - true, - false, - ) -} - -func (ec *executionContext) fieldContext_BlendBackstopPosition_emissionsEarnedUsd(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "BlendBackstopPosition", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Float does not have child fields") - }, - } - return fc, nil -} - -func (ec *executionContext) _BlendEarnOption_assetContractId(ctx context.Context, field graphql.CollectedField, obj *BlendEarnOption) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_BlendEarnOption_assetContractId, - func(ctx context.Context) (any, error) { - return obj.AssetContractID, nil - }, - nil, - ec.marshalNString2string, - true, - true, - ) -} - -func (ec *executionContext) fieldContext_BlendEarnOption_assetContractId(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "BlendEarnOption", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type String does not have child fields") - }, - } - return fc, nil -} - -func (ec *executionContext) _BlendEarnOption_tokenName(ctx context.Context, field graphql.CollectedField, obj *BlendEarnOption) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_BlendEarnOption_tokenName, - func(ctx context.Context) (any, error) { - return obj.TokenName, nil - }, - nil, - ec.marshalOString2ᚖstring, - true, - false, - ) -} - -func (ec *executionContext) fieldContext_BlendEarnOption_tokenName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "BlendEarnOption", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type String does not have child fields") - }, - } - return fc, nil -} - -func (ec *executionContext) _BlendEarnOption_tokenSymbol(ctx context.Context, field graphql.CollectedField, obj *BlendEarnOption) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_BlendEarnOption_tokenSymbol, - func(ctx context.Context) (any, error) { - return obj.TokenSymbol, nil - }, - nil, - ec.marshalOString2ᚖstring, - true, - false, - ) -} - -func (ec *executionContext) fieldContext_BlendEarnOption_tokenSymbol(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "BlendEarnOption", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type String does not have child fields") - }, - } - return fc, nil -} - -func (ec *executionContext) _BlendEarnOption_tokenDecimals(ctx context.Context, field graphql.CollectedField, obj *BlendEarnOption) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_BlendEarnOption_tokenDecimals, - func(ctx context.Context) (any, error) { - return obj.TokenDecimals, nil - }, - nil, - ec.marshalOInt2ᚖint32, - true, - false, - ) -} - -func (ec *executionContext) fieldContext_BlendEarnOption_tokenDecimals(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "BlendEarnOption", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Int does not have child fields") - }, - } - return fc, nil -} - -func (ec *executionContext) _BlendEarnOption_pools(ctx context.Context, field graphql.CollectedField, obj *BlendEarnOption) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_BlendEarnOption_pools, - func(ctx context.Context) (any, error) { - return obj.Pools, nil - }, - nil, - ec.marshalNBlendEarnPoolOption2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBlendEarnPoolOptionᚄ, - true, - true, - ) -} - -func (ec *executionContext) fieldContext_BlendEarnOption_pools(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "BlendEarnOption", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - switch field.Name { - case "poolAddress": - return ec.fieldContext_BlendEarnPoolOption_poolAddress(ctx, field) - case "poolName": - return ec.fieldContext_BlendEarnPoolOption_poolName(ctx, field) - case "supplyApy": - return ec.fieldContext_BlendEarnPoolOption_supplyApy(ctx, field) - case "emissionsSupplyApr": - return ec.fieldContext_BlendEarnPoolOption_emissionsSupplyApr(ctx, field) - case "suppliedUsd": - return ec.fieldContext_BlendEarnPoolOption_suppliedUsd(ctx, field) - } - return nil, fmt.Errorf("no field named %q was found under type BlendEarnPoolOption", field.Name) - }, - } - return fc, nil -} - -func (ec *executionContext) _BlendEarnPoolOption_poolAddress(ctx context.Context, field graphql.CollectedField, obj *BlendEarnPoolOption) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_BlendEarnPoolOption_poolAddress, - func(ctx context.Context) (any, error) { - return obj.PoolAddress, nil - }, - nil, - ec.marshalNString2string, - true, - true, - ) -} - -func (ec *executionContext) fieldContext_BlendEarnPoolOption_poolAddress(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "BlendEarnPoolOption", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type String does not have child fields") - }, - } - return fc, nil -} - -func (ec *executionContext) _BlendEarnPoolOption_poolName(ctx context.Context, field graphql.CollectedField, obj *BlendEarnPoolOption) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_BlendEarnPoolOption_poolName, - func(ctx context.Context) (any, error) { - return obj.PoolName, nil - }, - nil, - ec.marshalOString2ᚖstring, - true, - false, - ) -} - -func (ec *executionContext) fieldContext_BlendEarnPoolOption_poolName(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "BlendEarnPoolOption", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type String does not have child fields") - }, - } - return fc, nil -} - -func (ec *executionContext) _BlendEarnPoolOption_supplyApy(ctx context.Context, field graphql.CollectedField, obj *BlendEarnPoolOption) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_BlendEarnPoolOption_supplyApy, - func(ctx context.Context) (any, error) { - return obj.SupplyApy, nil + return obj.Q4w, nil }, nil, - ec.marshalOFloat2ᚖfloat64, + ec.marshalNBlendQ4W2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBlendQ4wᚄ, + true, true, - false, ) } -func (ec *executionContext) fieldContext_BlendEarnPoolOption_supplyApy(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_BlendBackstopPosition_q4w(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ - Object: "BlendEarnPoolOption", + Object: "BlendBackstopPosition", Field: field, IsMethod: false, IsResolver: false, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Float does not have child fields") + switch field.Name { + case "amount": + return ec.fieldContext_BlendQ4W_amount(ctx, field) + case "expiration": + return ec.fieldContext_BlendQ4W_expiration(ctx, field) + case "lpTokens": + return ec.fieldContext_BlendQ4W_lpTokens(ctx, field) + case "usdValue": + return ec.fieldContext_BlendQ4W_usdValue(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type BlendQ4W", field.Name) }, } return fc, nil } -func (ec *executionContext) _BlendEarnPoolOption_emissionsSupplyApr(ctx context.Context, field graphql.CollectedField, obj *BlendEarnPoolOption) (ret graphql.Marshaler) { +func (ec *executionContext) _BlendBackstopPosition_emissionsEarnedBlnd(ctx context.Context, field graphql.CollectedField, obj *BlendBackstopPosition) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, ec.OperationContext, field, - ec.fieldContext_BlendEarnPoolOption_emissionsSupplyApr, + ec.fieldContext_BlendBackstopPosition_emissionsEarnedBlnd, func(ctx context.Context) (any, error) { - return obj.EmissionsSupplyApr, nil + return obj.EmissionsEarnedBlnd, nil }, nil, - ec.marshalOFloat2ᚖfloat64, + ec.marshalNString2string, + true, true, - false, ) } -func (ec *executionContext) fieldContext_BlendEarnPoolOption_emissionsSupplyApr(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_BlendBackstopPosition_emissionsEarnedBlnd(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ - Object: "BlendEarnPoolOption", + Object: "BlendBackstopPosition", Field: field, IsMethod: false, IsResolver: false, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Float does not have child fields") + return nil, errors.New("field of type String does not have child fields") }, } return fc, nil } -func (ec *executionContext) _BlendEarnPoolOption_suppliedUsd(ctx context.Context, field graphql.CollectedField, obj *BlendEarnPoolOption) (ret graphql.Marshaler) { +func (ec *executionContext) _BlendBackstopPosition_emissionsEarnedUsd(ctx context.Context, field graphql.CollectedField, obj *BlendBackstopPosition) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, ec.OperationContext, field, - ec.fieldContext_BlendEarnPoolOption_suppliedUsd, + ec.fieldContext_BlendBackstopPosition_emissionsEarnedUsd, func(ctx context.Context) (any, error) { - return obj.SuppliedUsd, nil + return obj.EmissionsEarnedUsd, nil }, nil, ec.marshalOFloat2ᚖfloat64, @@ -6372,9 +6058,9 @@ func (ec *executionContext) _BlendEarnPoolOption_suppliedUsd(ctx context.Context ) } -func (ec *executionContext) fieldContext_BlendEarnPoolOption_suppliedUsd(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_BlendBackstopPosition_emissionsEarnedUsd(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ - Object: "BlendEarnPoolOption", + Object: "BlendBackstopPosition", Field: field, IsMethod: false, IsResolver: false, @@ -6453,7 +6139,7 @@ func (ec *executionContext) _BlendPool_status(ctx context.Context, field graphql return obj.Status, nil }, nil, - ec.marshalOInt2ᚖint32, + ec.marshalOBlendPoolStatus2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBlendPoolStatus, true, false, ) @@ -6466,7 +6152,7 @@ func (ec *executionContext) fieldContext_BlendPool_status(_ context.Context, fie IsMethod: false, IsResolver: false, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Int does not have child fields") + return nil, errors.New("field of type BlendPoolStatus does not have child fields") }, } return fc, nil @@ -7076,8 +6762,10 @@ func (ec *executionContext) fieldContext_BlendPoolPosition_reserves(_ context.Co return ec.fieldContext_BlendReservePosition_supplyApy(ctx, field) case "borrowApy": return ec.fieldContext_BlendReservePosition_borrowApy(ctx, field) - case "emissionsApr": - return ec.fieldContext_BlendReservePosition_emissionsApr(ctx, field) + case "emissionsSupplyApr": + return ec.fieldContext_BlendReservePosition_emissionsSupplyApr(ctx, field) + case "emissionsBorrowApr": + return ec.fieldContext_BlendReservePosition_emissionsBorrowApr(ctx, field) case "interestEarned": return ec.fieldContext_BlendReservePosition_interestEarned(ctx, field) case "interestPaid": @@ -8023,14 +7711,43 @@ func (ec *executionContext) fieldContext_BlendReservePosition_borrowApy(_ contex return fc, nil } -func (ec *executionContext) _BlendReservePosition_emissionsApr(ctx context.Context, field graphql.CollectedField, obj *BlendReservePosition) (ret graphql.Marshaler) { +func (ec *executionContext) _BlendReservePosition_emissionsSupplyApr(ctx context.Context, field graphql.CollectedField, obj *BlendReservePosition) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_BlendReservePosition_emissionsSupplyApr, + func(ctx context.Context) (any, error) { + return obj.EmissionsSupplyApr, nil + }, + nil, + ec.marshalOFloat2ᚖfloat64, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_BlendReservePosition_emissionsSupplyApr(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "BlendReservePosition", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Float does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _BlendReservePosition_emissionsBorrowApr(ctx context.Context, field graphql.CollectedField, obj *BlendReservePosition) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, ec.OperationContext, field, - ec.fieldContext_BlendReservePosition_emissionsApr, + ec.fieldContext_BlendReservePosition_emissionsBorrowApr, func(ctx context.Context) (any, error) { - return obj.EmissionsApr, nil + return obj.EmissionsBorrowApr, nil }, nil, ec.marshalOFloat2ᚖfloat64, @@ -8039,7 +7756,7 @@ func (ec *executionContext) _BlendReservePosition_emissionsApr(ctx context.Conte ) } -func (ec *executionContext) fieldContext_BlendReservePosition_emissionsApr(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { +func (ec *executionContext) fieldContext_BlendReservePosition_emissionsBorrowApr(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { fc = &graphql.FieldContext{ Object: "BlendReservePosition", Field: field, @@ -10797,47 +10514,6 @@ func (ec *executionContext) fieldContext_Query_blendPool(ctx context.Context, fi return fc, nil } -func (ec *executionContext) _Query_blendEarnOptions(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - return graphql.ResolveField( - ctx, - ec.OperationContext, - field, - ec.fieldContext_Query_blendEarnOptions, - func(ctx context.Context) (any, error) { - return ec.Resolvers.Query().BlendEarnOptions(ctx) - }, - nil, - ec.marshalNBlendEarnOption2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBlendEarnOptionᚄ, - true, - true, - ) -} - -func (ec *executionContext) fieldContext_Query_blendEarnOptions(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "Query", - Field: field, - IsMethod: true, - IsResolver: true, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - switch field.Name { - case "assetContractId": - return ec.fieldContext_BlendEarnOption_assetContractId(ctx, field) - case "tokenName": - return ec.fieldContext_BlendEarnOption_tokenName(ctx, field) - case "tokenSymbol": - return ec.fieldContext_BlendEarnOption_tokenSymbol(ctx, field) - case "tokenDecimals": - return ec.fieldContext_BlendEarnOption_tokenDecimals(ctx, field) - case "pools": - return ec.fieldContext_BlendEarnOption_pools(ctx, field) - } - return nil, fmt.Errorf("no field named %q was found under type BlendEarnOption", field.Name) - }, - } - return fc, nil -} - func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -17620,103 +17296,6 @@ func (ec *executionContext) _BlendBackstopPosition(ctx context.Context, sel ast. return out } -var blendEarnOptionImplementors = []string{"BlendEarnOption"} - -func (ec *executionContext) _BlendEarnOption(ctx context.Context, sel ast.SelectionSet, obj *BlendEarnOption) graphql.Marshaler { - fields := graphql.CollectFields(ec.OperationContext, sel, blendEarnOptionImplementors) - - out := graphql.NewFieldSet(fields) - deferred := make(map[string]*graphql.FieldSet) - for i, field := range fields { - switch field.Name { - case "__typename": - out.Values[i] = graphql.MarshalString("BlendEarnOption") - case "assetContractId": - out.Values[i] = ec._BlendEarnOption_assetContractId(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } - case "tokenName": - out.Values[i] = ec._BlendEarnOption_tokenName(ctx, field, obj) - case "tokenSymbol": - out.Values[i] = ec._BlendEarnOption_tokenSymbol(ctx, field, obj) - case "tokenDecimals": - out.Values[i] = ec._BlendEarnOption_tokenDecimals(ctx, field, obj) - case "pools": - out.Values[i] = ec._BlendEarnOption_pools(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } - default: - panic("unknown field " + strconv.Quote(field.Name)) - } - } - out.Dispatch(ctx) - if out.Invalids > 0 { - return graphql.Null - } - - atomic.AddInt32(&ec.Deferred, int32(len(deferred))) - - for label, dfs := range deferred { - ec.ProcessDeferredGroup(graphql.DeferredGroup{ - Label: label, - Path: graphql.GetPath(ctx), - FieldSet: dfs, - Context: ctx, - }) - } - - return out -} - -var blendEarnPoolOptionImplementors = []string{"BlendEarnPoolOption"} - -func (ec *executionContext) _BlendEarnPoolOption(ctx context.Context, sel ast.SelectionSet, obj *BlendEarnPoolOption) graphql.Marshaler { - fields := graphql.CollectFields(ec.OperationContext, sel, blendEarnPoolOptionImplementors) - - out := graphql.NewFieldSet(fields) - deferred := make(map[string]*graphql.FieldSet) - for i, field := range fields { - switch field.Name { - case "__typename": - out.Values[i] = graphql.MarshalString("BlendEarnPoolOption") - case "poolAddress": - out.Values[i] = ec._BlendEarnPoolOption_poolAddress(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } - case "poolName": - out.Values[i] = ec._BlendEarnPoolOption_poolName(ctx, field, obj) - case "supplyApy": - out.Values[i] = ec._BlendEarnPoolOption_supplyApy(ctx, field, obj) - case "emissionsSupplyApr": - out.Values[i] = ec._BlendEarnPoolOption_emissionsSupplyApr(ctx, field, obj) - case "suppliedUsd": - out.Values[i] = ec._BlendEarnPoolOption_suppliedUsd(ctx, field, obj) - default: - panic("unknown field " + strconv.Quote(field.Name)) - } - } - out.Dispatch(ctx) - if out.Invalids > 0 { - return graphql.Null - } - - atomic.AddInt32(&ec.Deferred, int32(len(deferred))) - - for label, dfs := range deferred { - ec.ProcessDeferredGroup(graphql.DeferredGroup{ - Label: label, - Path: graphql.GetPath(ctx), - FieldSet: dfs, - Context: ctx, - }) - } - - return out -} - var blendPoolImplementors = []string{"BlendPool"} func (ec *executionContext) _BlendPool(ctx context.Context, sel ast.SelectionSet, obj *BlendPool) graphql.Marshaler { @@ -18023,8 +17602,10 @@ func (ec *executionContext) _BlendReservePosition(ctx context.Context, sel ast.S out.Values[i] = ec._BlendReservePosition_supplyApy(ctx, field, obj) case "borrowApy": out.Values[i] = ec._BlendReservePosition_borrowApy(ctx, field, obj) - case "emissionsApr": - out.Values[i] = ec._BlendReservePosition_emissionsApr(ctx, field, obj) + case "emissionsSupplyApr": + out.Values[i] = ec._BlendReservePosition_emissionsSupplyApr(ctx, field, obj) + case "emissionsBorrowApr": + out.Values[i] = ec._BlendReservePosition_emissionsBorrowApr(ctx, field, obj) case "interestEarned": out.Values[i] = ec._BlendReservePosition_interestEarned(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -19611,28 +19192,6 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) - case "blendEarnOptions": - field := field - - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - } - }() - res = ec._Query_blendEarnOptions(ctx, field) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } - return res - } - - rrm := func(ctx context.Context) graphql.Marshaler { - return ec.OperationContext.RootResolverMiddleware(ctx, - func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - } - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "__type": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { @@ -22617,58 +22176,6 @@ func (ec *executionContext) marshalNBlendBackstopPosition2ᚖgithubᚗcomᚋstel return ec._BlendBackstopPosition(ctx, sel, v) } -func (ec *executionContext) marshalNBlendEarnOption2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBlendEarnOptionᚄ(ctx context.Context, sel ast.SelectionSet, v []*BlendEarnOption) graphql.Marshaler { - ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { - fc := graphql.GetFieldContext(ctx) - fc.Result = &v[i] - return ec.marshalNBlendEarnOption2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBlendEarnOption(ctx, sel, v[i]) - }) - - for _, e := range ret { - if e == graphql.Null { - return graphql.Null - } - } - - return ret -} - -func (ec *executionContext) marshalNBlendEarnOption2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBlendEarnOption(ctx context.Context, sel ast.SelectionSet, v *BlendEarnOption) graphql.Marshaler { - if v == nil { - if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { - graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") - } - return graphql.Null - } - return ec._BlendEarnOption(ctx, sel, v) -} - -func (ec *executionContext) marshalNBlendEarnPoolOption2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBlendEarnPoolOptionᚄ(ctx context.Context, sel ast.SelectionSet, v []*BlendEarnPoolOption) graphql.Marshaler { - ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { - fc := graphql.GetFieldContext(ctx) - fc.Result = &v[i] - return ec.marshalNBlendEarnPoolOption2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBlendEarnPoolOption(ctx, sel, v[i]) - }) - - for _, e := range ret { - if e == graphql.Null { - return graphql.Null - } - } - - return ret -} - -func (ec *executionContext) marshalNBlendEarnPoolOption2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBlendEarnPoolOption(ctx context.Context, sel ast.SelectionSet, v *BlendEarnPoolOption) graphql.Marshaler { - if v == nil { - if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { - graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") - } - return graphql.Null - } - return ec._BlendEarnPoolOption(ctx, sel, v) -} - func (ec *executionContext) marshalNBlendPool2ᚕᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBlendPoolᚄ(ctx context.Context, sel ast.SelectionSet, v []*BlendPool) graphql.Marshaler { ret := graphql.MarshalSliceConcurrently(ctx, len(v), 0, false, func(ctx context.Context, i int) graphql.Marshaler { fc := graphql.GetFieldContext(ctx) @@ -23319,6 +22826,22 @@ func (ec *executionContext) marshalOBlendPool2ᚖgithubᚗcomᚋstellarᚋwallet return ec._BlendPool(ctx, sel, v) } +func (ec *executionContext) unmarshalOBlendPoolStatus2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBlendPoolStatus(ctx context.Context, v any) (*BlendPoolStatus, error) { + if v == nil { + return nil, nil + } + var res = new(BlendPoolStatus) + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOBlendPoolStatus2ᚖgithubᚗcomᚋstellarᚋwalletᚑbackendᚋinternalᚋserveᚋgraphqlᚋgeneratedᚐBlendPoolStatus(ctx context.Context, sel ast.SelectionSet, v *BlendPoolStatus) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return v +} + func (ec *executionContext) unmarshalOBoolean2bool(ctx context.Context, v any) (bool, error) { res, err := graphql.UnmarshalBoolean(v) return res, graphql.ErrorOnPath(ctx, err) diff --git a/internal/serve/graphql/generated/models_gen.go b/internal/serve/graphql/generated/models_gen.go index 8fe723398..0dfa94d03 100644 --- a/internal/serve/graphql/generated/models_gen.go +++ b/internal/serve/graphql/generated/models_gen.go @@ -97,13 +97,17 @@ type BlendAuctionAmount struct { Amount string `json:"amount"` } -// BlendBackstopPosition is an account's backstop deposit in one pool. shares -// is the ACTIVE (non-queued) backstop share balance; lpTokens/usdValue value -// the whole deposit — active plus queued-for-withdrawal shares — since queued -// shares keep earning pool interest and remain slashable first-loss capital -// until actually withdrawn. emissionsEarnedBlnd is CLAIMABLE (uncollected) -// BLND accrued on the backstop emission stream for this pool; it accrues on -// active shares only (queued shares earn no emissions). +// BlendBackstopPosition is an account's backstop deposit in one pool. The +// backstop is a single protocol-wide contract holding each pool's first-loss +// capital separately, so exposure is inherently per-pool (poolAddress) even +// though the backstop is not the pool contract itself — one +// BlendBackstopPosition per backed pool. shares is the ACTIVE (non-queued) +// backstop share balance; lpTokens/usdValue value the whole deposit — active +// plus queued-for-withdrawal shares — since queued shares keep earning pool +// interest and remain slashable first-loss capital until actually withdrawn. +// emissionsEarnedBlnd is CLAIMABLE (uncollected) BLND accrued on the backstop +// emission stream for this pool; it accrues on active shares only (queued +// shares earn no emissions). type BlendBackstopPosition struct { PoolAddress string `json:"poolAddress"` PoolName *string `json:"poolName,omitempty"` @@ -115,31 +119,6 @@ type BlendBackstopPosition struct { EmissionsEarnedUsd *float64 `json:"emissionsEarnedUsd,omitempty"` } -// BlendEarnOption is a "where can I earn this asset" catalog view: one entry -// per asset with at least one enabled reserve in a pool that currently accepts -// supply (status 0-3). Frozen (4-5) and Setup (6) pools reject deposits -// on-chain, so their reserves are never earn options. -type BlendEarnOption struct { - AssetContractID string `json:"assetContractId"` - TokenName *string `json:"tokenName,omitempty"` - TokenSymbol *string `json:"tokenSymbol,omitempty"` - TokenDecimals *int32 `json:"tokenDecimals,omitempty"` - Pools []*BlendEarnPoolOption `json:"pools"` -} - -// BlendEarnPoolOption is one pool's offer for a BlendEarnOption's asset. -// supplyApy is the interest-only yield; emissionsSupplyApr is the reserve's -// BLND-emission yield on the supply side (0 when no active stream, null when -// the BLND price is unavailable) — supplyApy + emissionsSupplyApr is the -// emissions-inclusive rate a wallet shows as the earn headline. -type BlendEarnPoolOption struct { - PoolAddress string `json:"poolAddress"` - PoolName *string `json:"poolName,omitempty"` - SupplyApy *float64 `json:"supplyApy,omitempty"` - EmissionsSupplyApr *float64 `json:"emissionsSupplyApr,omitempty"` - SuppliedUsd *float64 `json:"suppliedUsd,omitempty"` -} - // BlendPool is a pool-wide catalog view of one Blend v2 pool, independent of // any account. suppliedUsd/borrowedUsd are the sum of each reserve's own // suppliedUsd/borrowedUsd (nil if any reserve's is nil — a missing price makes @@ -152,12 +131,12 @@ type BlendEarnPoolOption struct { type BlendPool struct { Address string `json:"address"` Name *string `json:"name,omitempty"` - // Raw on-chain pool status: 0 Admin Active, 1 Active, 2 Admin On-Ice, - // 3 On-Ice, 4 Admin Frozen, 5 Frozen, 6 Setup. Statuses 0-3 accept supply - // (deposits); 0-1 also allow borrowing; 4-6 reject both. Null until the - // pool's config entry has been ingested. - Status *int32 `json:"status,omitempty"` - OracleContractID *string `json:"oracleContractId,omitempty"` + // Pool status. Statuses ADMIN_ACTIVE/ACTIVE/ADMIN_ON_ICE/ON_ICE accept supply + // (deposits); ADMIN_ACTIVE/ACTIVE also allow borrowing; ADMIN_FROZEN/FROZEN/ + // SETUP reject both. Null until the pool's config entry has been ingested, and + // also null for an unrecognized on-chain status value. + Status *BlendPoolStatus `json:"status,omitempty"` + OracleContractID *string `json:"oracleContractId,omitempty"` // Share of borrower interest routed to the pool's backstop, as 7-decimal // fixed point (4750000 = 47.5%). BackstopRate *int32 `json:"backstopRate,omitempty"` @@ -183,7 +162,12 @@ type BlendPoolPosition struct { UsdValue *float64 `json:"usdValue,omitempty"` SuppliedUsd *float64 `json:"suppliedUsd,omitempty"` BorrowedUsd *float64 `json:"borrowedUsd,omitempty"` - NetApy *float64 `json:"netApy,omitempty"` + // Supply-vs-borrow interest netting over TOTAL SUPPLIED USD — the blend-sdk-js + // PositionsEstimate convention shown by the Blend UI: + // (Σ suppliedUsd·supplyApy − Σ borrowedUsd·borrowApy) / Σ suppliedUsd. + // 0 for a position with debt but no supply (bad debt is forgiven). Null when + // any contributing reserve is missing a fresh oracle price. + NetApy *float64 `json:"netApy,omitempty"` // Lifetime BLND this account has claimed from this pool's reserve emissions. ClaimedBlnd string `json:"claimedBlnd"` Reserves []*BlendReservePosition `json:"reserves"` @@ -232,19 +216,47 @@ type BlendReserve struct { // the reserve's b/d emission streams; claimed history is tracked per pool only // (see BlendPoolPosition.claimedBlnd), not per reserve. type BlendReservePosition struct { - AssetContractID string `json:"assetContractId"` - TokenName *string `json:"tokenName,omitempty"` - TokenSymbol *string `json:"tokenSymbol,omitempty"` - TokenDecimals *int32 `json:"tokenDecimals,omitempty"` - SuppliedTokens string `json:"suppliedTokens"` - CollateralTokens string `json:"collateralTokens"` - BorrowedTokens string `json:"borrowedTokens"` - SuppliedUsd *float64 `json:"suppliedUsd,omitempty"` - BorrowedUsd *float64 `json:"borrowedUsd,omitempty"` - SupplyApy *float64 `json:"supplyApy,omitempty"` - BorrowApy *float64 `json:"borrowApy,omitempty"` - EmissionsApr *float64 `json:"emissionsApr,omitempty"` - InterestEarned string `json:"interestEarned"` + AssetContractID string `json:"assetContractId"` + TokenName *string `json:"tokenName,omitempty"` + TokenSymbol *string `json:"tokenSymbol,omitempty"` + TokenDecimals *int32 `json:"tokenDecimals,omitempty"` + SuppliedTokens string `json:"suppliedTokens"` + CollateralTokens string `json:"collateralTokens"` + BorrowedTokens string `json:"borrowedTokens"` + SuppliedUsd *float64 `json:"suppliedUsd,omitempty"` + BorrowedUsd *float64 `json:"borrowedUsd,omitempty"` + SupplyApy *float64 `json:"supplyApy,omitempty"` + BorrowApy *float64 `json:"borrowApy,omitempty"` + // The reserve's POOL-WIDE bToken (supply) emission-stream APR: annualized BLND + // value over the side's pool-wide supplied USD, NOT scaled to this account's + // holding. 0 when no active stream (unconfigured or expired), null when the + // stream is active but the reserve or BLND price is unavailable. + EmissionsSupplyApr *float64 `json:"emissionsSupplyApr,omitempty"` + // The reserve's POOL-WIDE dToken (borrow) emission-stream APR: annualized BLND + // value over the side's pool-wide borrowed USD, NOT scaled to this account's + // holding. 0 when no active stream (unconfigured or expired), null when the + // stream is active but the reserve or BLND price is unavailable. + EmissionsBorrowApr *float64 `json:"emissionsBorrowApr,omitempty"` + // Lifetime interest earned on the supply side of this reserve: the current + // underlying value of the account's supply+collateral bTokens (at projected, + // as-of-now rates) minus the net principal it contributed — a cost basis + // tracked across the account's whole deposit/withdraw history. Token-denominated: + // a raw integer at the reserve asset's decimals (tokenDecimals), so multiply by + // priceUsd for a USD value. A liquidation reduces the cost basis by the seized + // collateral's underlying value at the reserve's rate when the fill is + // processed, so collateral lost to a fill cancels out instead of being reported + // as earned interest. Survives a full exit: a position zeroed to no tokens still + // reports the earnings realized up to the exit (current value 0 minus the + // negative leftover cost basis). May be slightly negative from cost-basis dust + // truncation or bRate movement after an exit. + InterestEarned string `json:"interestEarned"` + // Lifetime interest paid on the debt side of this reserve, mirroring + // interestEarned: the current underlying value of the account's liability + // dTokens (at projected, as-of-now rates) minus its net borrowed principal + // (cost basis tracked from borrow/repay history). Token-denominated (raw + // integer at tokenDecimals; use priceUsd for USD), lifetime, and survives a + // full exit the same way. May be slightly negative for the same dust/rate + // reasons. InterestPaid string `json:"interestPaid"` EmissionsEarnedBlnd string `json:"emissionsEarnedBlnd"` EmissionsEarnedUsd *float64 `json:"emissionsEarnedUsd,omitempty"` @@ -460,6 +472,75 @@ func (e BlendAuctionType) MarshalJSON() ([]byte, error) { return buf.Bytes(), nil } +// BlendPoolStatus is a pool's operational status. On-chain encoding: +// 0 ADMIN_ACTIVE, 1 ACTIVE, 2 ADMIN_ON_ICE, 3 ON_ICE, 4 ADMIN_FROZEN, +// 5 FROZEN, 6 SETUP. Statuses 0-3 accept supply (deposits); 0-1 also allow +// borrowing; 4-6 reject both. +type BlendPoolStatus string + +const ( + BlendPoolStatusAdminActive BlendPoolStatus = "ADMIN_ACTIVE" + BlendPoolStatusActive BlendPoolStatus = "ACTIVE" + BlendPoolStatusAdminOnIce BlendPoolStatus = "ADMIN_ON_ICE" + BlendPoolStatusOnIce BlendPoolStatus = "ON_ICE" + BlendPoolStatusAdminFrozen BlendPoolStatus = "ADMIN_FROZEN" + BlendPoolStatusFrozen BlendPoolStatus = "FROZEN" + BlendPoolStatusSetup BlendPoolStatus = "SETUP" +) + +var AllBlendPoolStatus = []BlendPoolStatus{ + BlendPoolStatusAdminActive, + BlendPoolStatusActive, + BlendPoolStatusAdminOnIce, + BlendPoolStatusOnIce, + BlendPoolStatusAdminFrozen, + BlendPoolStatusFrozen, + BlendPoolStatusSetup, +} + +func (e BlendPoolStatus) IsValid() bool { + switch e { + case BlendPoolStatusAdminActive, BlendPoolStatusActive, BlendPoolStatusAdminOnIce, BlendPoolStatusOnIce, BlendPoolStatusAdminFrozen, BlendPoolStatusFrozen, BlendPoolStatusSetup: + return true + } + return false +} + +func (e BlendPoolStatus) String() string { + return string(e) +} + +func (e *BlendPoolStatus) UnmarshalGQL(v any) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = BlendPoolStatus(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid BlendPoolStatus", str) + } + return nil +} + +func (e BlendPoolStatus) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +func (e *BlendPoolStatus) UnmarshalJSON(b []byte) error { + s, err := strconv.Unquote(string(b)) + if err != nil { + return err + } + return e.UnmarshalGQL(s) +} + +func (e BlendPoolStatus) MarshalJSON() ([]byte, error) { + var buf bytes.Buffer + e.MarshalGQL(&buf) + return buf.Bytes(), nil +} + type TokenType string const ( diff --git a/internal/serve/graphql/resolvers/account.resolvers.go b/internal/serve/graphql/resolvers/account.resolvers.go index 365bbe584..5f796c1de 100644 --- a/internal/serve/graphql/resolvers/account.resolvers.go +++ b/internal/serve/graphql/resolvers/account.resolvers.go @@ -181,6 +181,11 @@ func (r *accountResolver) Sep41Allowances(ctx context.Context, obj *types.Accoun return r.getSEP41Allowances(ctx, string(obj.StellarAddress), first, after, last, before) } +// BlendPositions is the resolver for the blendPositions field. +func (r *accountResolver) BlendPositions(ctx context.Context, obj *types.Account) (*graphql1.BlendAccountPositions, error) { + return r.getBlendPositions(ctx, string(obj.StellarAddress)) +} + // Account returns graphql1.AccountResolver implementation. func (r *Resolver) Account() graphql1.AccountResolver { return &accountResolver{r} } diff --git a/internal/serve/graphql/resolvers/blend.resolvers.go b/internal/serve/graphql/resolvers/blend.resolvers.go deleted file mode 100644 index 0e4a685f0..000000000 --- a/internal/serve/graphql/resolvers/blend.resolvers.go +++ /dev/null @@ -1,33 +0,0 @@ -package resolvers - -// This file will be automatically regenerated based on the schema, any resolver -// implementations -// will be copied through when generating and any unknown code will be moved to the end. -// Code generated by github.com/99designs/gqlgen version v0.17.88 - -import ( - "context" - - "github.com/stellar/wallet-backend/internal/indexer/types" - graphql1 "github.com/stellar/wallet-backend/internal/serve/graphql/generated" -) - -// BlendPositions is the resolver for the blendPositions field. -func (r *accountResolver) BlendPositions(ctx context.Context, obj *types.Account) (*graphql1.BlendAccountPositions, error) { - return r.getBlendPositions(ctx, string(obj.StellarAddress)) -} - -// BlendPools is the resolver for the blendPools field. -func (r *queryResolver) BlendPools(ctx context.Context) ([]*graphql1.BlendPool, error) { - return r.getBlendPools(ctx) -} - -// BlendPool is the resolver for the blendPool field. -func (r *queryResolver) BlendPool(ctx context.Context, address string) (*graphql1.BlendPool, error) { - return r.getBlendPool(ctx, address) -} - -// BlendEarnOptions is the resolver for the blendEarnOptions field. -func (r *queryResolver) BlendEarnOptions(ctx context.Context) ([]*graphql1.BlendEarnOption, error) { - return r.getBlendEarnOptions(ctx) -} diff --git a/internal/serve/graphql/resolvers/blend_earn.go b/internal/serve/graphql/resolvers/blend_earn.go deleted file mode 100644 index f59ad9148..000000000 --- a/internal/serve/graphql/resolvers/blend_earn.go +++ /dev/null @@ -1,278 +0,0 @@ -// Package resolvers — blend_earn.go assembles Query.blendEarnOptions: an -// asset-first "where can I earn this asset" catalog view across every -// enabled reserve in every Blend v2 pool the indexer has seen. It groups the -// exact same reserve-level rate-curve/pricing computation blend_pools.go's -// buildPoolReserve already exercises (via the shared blendAssembly and -// computeReserveRates) by asset instead of by pool — the two views differ -// only in that grouping, and in which reserves are in scope: a disabled -// reserve, or any reserve of a pool whose status rejects supply on-chain -// (poolAcceptsSupply), still shows up in the pool-wide catalog, but accepts -// no new deposits, so it is never an earn option here. -package resolvers - -import ( - "context" - "fmt" - "sort" - "time" - - "golang.org/x/sync/errgroup" - - "github.com/stellar/wallet-backend/internal/data" - blenddata "github.com/stellar/wallet-backend/internal/data/blend" - "github.com/stellar/wallet-backend/internal/indexer/types" - graphql1 "github.com/stellar/wallet-backend/internal/serve/graphql/generated" - blendrates "github.com/stellar/wallet-backend/internal/services/blend" -) - -// poolStatusRejectsSupply is the lowest pool status that rejects supply -// on-chain (pool.rs::require_action_allowed): 4 Admin Frozen, 5 Frozen, and -// 6 Setup refuse deposits, while Active (0-1) and On-Ice (2-3) accept them. -const poolStatusRejectsSupply int32 = 4 - -// poolAcceptsSupply reports whether poolAddr's pool accepts supply: its -// status must be known AND below poolStatusRejectsSupply. An unknown pool or -// a NULL status (config entry not ingested yet) can't be confirmed -// supply-eligible, so it is excluded rather than advertised. -func poolAcceptsSupply(poolByID map[string]blenddata.Pool, poolAddr string) bool { - pool, ok := poolByID[poolAddr] - return ok && pool.Status != nil && *pool.Status < poolStatusRejectsSupply -} - -// buildEarnPoolOption assembles one enabled reserve into a -// BlendEarnPoolOption: the reserve's projected supplyApy, its supply-side -// BLND-emission APR, and pool-wide suppliedUsd (Underlying(BSupply, pB) -// priced against the pool's own oracle) — the identical numbers -// buildPoolReserve computes for this same reserve's -// BlendReserve.supplyApy/emissionsSupplyApr/suppliedUsd. -func (d *blendAssembly) buildEarnPoolOption(reserve blenddata.Reserve) (*graphql1.BlendEarnPoolOption, error) { - poolAddr := string(reserve.PoolContractID) - rr, err := d.computeReserveRates(poolAddr, reserve) - if err != nil { - return nil, err - } - - suppliedTokens := blendrates.Underlying(rr.BSupply, rr.PB) - - oracle := types.AddressBytea("") - pool, ok := d.poolByID[poolAddr] - if ok { - oracle = pool.OracleContractID - } - price := d.priceLookup(oracle, reserve.AssetContractID) - suppliedUsd := usdValueOrNil(suppliedTokens, reserve.Decimals, price) - - bTokenID := reserve.ReserveIndex*2 + 1 - emissionsSupplyApr := d.emissionsAPRFor(poolAddr, bTokenID, rr.BSupply, rr.BRate, reserve.Decimals, price) - - supplyApy := rr.SupplyApy - return &graphql1.BlendEarnPoolOption{ - PoolAddress: poolAddr, - PoolName: pool.Name, - SupplyApy: &supplyApy, - EmissionsSupplyApr: emissionsSupplyApr, - SuppliedUsd: suppliedUsd, - }, nil -} - -// sortEarnPoolOptions orders one asset's pool options by suppliedUsd -// descending — the largest existing supply offer first. A missing -// suppliedUsd (no oracle price wired for that pool's own oracle/asset pair) -// sorts last rather than first or dropping out, since "unknown size" isn't -// "biggest" or "smallest" but IS less useful to show first. Ties — including -// nil vs. nil — break by ascending pool address so the order is fully -// deterministic. -func sortEarnPoolOptions(opts []*graphql1.BlendEarnPoolOption) { - sort.Slice(opts, func(i, j int) bool { - a, b := opts[i], opts[j] - switch { - case a.SuppliedUsd == nil && b.SuppliedUsd == nil: - return a.PoolAddress < b.PoolAddress - case a.SuppliedUsd == nil: - return false - case b.SuppliedUsd == nil: - return true - case *a.SuppliedUsd != *b.SuppliedUsd: - return *a.SuppliedUsd > *b.SuppliedUsd - default: - return a.PoolAddress < b.PoolAddress - } - }) -} - -// buildEarnOption groups one asset's enabled reserves — one per pool that -// lists it — into a BlendEarnOption, with its pools ordered per -// sortEarnPoolOptions. reserves is never empty: callers only invoke this for -// an assetID that has at least one enabled reserve backing it. -func (d *blendAssembly) buildEarnOption(assetID string, reserves []blenddata.Reserve) (*graphql1.BlendEarnOption, error) { - pools := make([]*graphql1.BlendEarnPoolOption, 0, len(reserves)) - for _, reserve := range reserves { - opt, err := d.buildEarnPoolOption(reserve) - if err != nil { - return nil, err - } - pools = append(pools, opt) - } - sortEarnPoolOptions(pools) - - // contract_tokens is the canonical source for token display metadata, but - // classification inserts a default row (nil name, decimals 0) before RPC - // enrichment fills it in. The reserve config's decimals copy (set at - // reserve creation from the token itself) is authoritative until the row - // is genuinely enriched — a real zero-decimals token still wins here once - // its name is populated. - meta, hasMeta := d.metaByContractID[assetID] - decimals := reserves[0].Decimals - if hasMeta && (meta.Name != nil || meta.Decimals > 0) { - decimals = int32(meta.Decimals) - } - - return &graphql1.BlendEarnOption{ - AssetContractID: assetID, - TokenName: meta.Name, - TokenSymbol: meta.Symbol, - TokenDecimals: &decimals, - Pools: pools, - }, nil -} - -// getBlendEarnOptions is the main implementation for Query.blendEarnOptions: -// an asset-first "where can I earn this asset" catalog view, batch-fetching -// every enabled reserve across every pool plus the oracle prices and token -// metadata they touch, then grouping by asset. Assets are ordered ascending -// by contract address (a plain C-address string comparison); see -// sortEarnPoolOptions for how each asset's own pools are ordered. -func (r *Resolver) getBlendEarnOptions(ctx context.Context) ([]*graphql1.BlendEarnOption, error) { - // reserves, pools, and the backstop LP price pair (no input) are independent. - var ( - reserves []blenddata.Reserve - pools []blenddata.Pool - backstopLPPrices []blenddata.OraclePrice - ) - baseGroup, baseCtx := errgroup.WithContext(ctx) - baseGroup.Go(func() (err error) { - reserves, err = r.models.Blend.Reserves.GetAll(baseCtx) - if err != nil { - return fmt.Errorf("getting blend reserves: %w", err) - } - return nil - }) - baseGroup.Go(func() (err error) { - pools, err = r.models.Blend.Pools.GetAll(baseCtx) - if err != nil { - return fmt.Errorf("getting blend pools: %w", err) - } - return nil - }) - baseGroup.Go(func() (err error) { - backstopLPPrices, err = r.models.Blend.OraclePrices.GetBackstopLPPrices(baseCtx) - if err != nil { - return fmt.Errorf("getting blend backstop LP prices: %w", err) - } - return nil - }) - if err := baseGroup.Wait(); err != nil { - return nil, err //nolint:wrapcheck // already wrapped inside the errgroup closures - } - - poolByID := make(map[string]blenddata.Pool, len(pools)) - oracleIDSet := map[string]struct{}{} - for _, p := range pools { - poolByID[string(p.PoolContractID)] = p - if p.OracleContractID != "" { - oracleIDSet[string(p.OracleContractID)] = struct{}{} - } - } - - reservesByAsset := map[string][]blenddata.Reserve{} - assetIDSet := map[string]struct{}{} - for _, res := range reserves { - if !res.Enabled || !poolAcceptsSupply(poolByID, string(res.PoolContractID)) { - continue - } - assetID := string(res.AssetContractID) - reservesByAsset[assetID] = append(reservesByAsset[assetID], res) - assetIDSet[assetID] = struct{}{} - } - assetIDs := make([]string, 0, len(assetIDSet)) - for id := range assetIDSet { - assetIDs = append(assetIDs, id) - } - sort.Strings(assetIDs) - - oracleIDs := make([]string, 0, len(oracleIDSet)) - for id := range oracleIDSet { - oracleIDs = append(oracleIDs, id) - } - poolIDs := make([]string, 0, len(poolByID)) - for id := range poolByID { - poolIDs = append(poolIDs, id) - } - - // Token metadata (assetIDs), oracle prices (oracleIDs), and reserve emissions - // (poolIDs) are independent now that those key sets are known. - var ( - tokenMeta []data.Contract - oraclePrices []blenddata.OraclePrice - reserveEmissions []blenddata.ReserveEmission - ) - lookupGroup, lookupCtx := errgroup.WithContext(ctx) - lookupGroup.Go(func() (err error) { - tokenMeta, err = r.models.Contract.GetTokenMetadataByContractIDs(lookupCtx, assetIDs) - if err != nil { - return fmt.Errorf("getting blend reserve asset token metadata: %w", err) - } - return nil - }) - lookupGroup.Go(func() (err error) { - oraclePrices, err = r.models.Blend.OraclePrices.GetByOracles(lookupCtx, oracleIDs) - if err != nil { - return fmt.Errorf("getting blend oracle prices: %w", err) - } - return nil - }) - lookupGroup.Go(func() (err error) { - reserveEmissions, err = r.models.Blend.ReserveEmissions.GetByPools(lookupCtx, poolIDs) - if err != nil { - return fmt.Errorf("getting blend reserve emissions: %w", err) - } - return nil - }) - if err := lookupGroup.Wait(); err != nil { - return nil, err //nolint:wrapcheck // already wrapped inside the errgroup closures - } - - metaByContractID := make(map[string]data.Contract, len(tokenMeta)) - for _, c := range tokenMeta { - metaByContractID[c.ContractID] = c - } - now := time.Now().Unix() - priceByOracleAsset := freshPriceMap(oraclePrices, now) - // Only the BLND leg matters here — it prices the emission APR; the LP-share - // price has no earn-view use. - _, blndPrice := findBackstopPrices(ctx, freshPrices(backstopLPPrices, now)) - - reserveEmissionByPoolToken := make(map[string]blenddata.ReserveEmission, len(reserveEmissions)) - for _, re := range reserveEmissions { - reserveEmissionByPoolToken[poolTokenKey(string(re.PoolContractID), re.ReserveTokenID)] = re - } - - assembly := &blendAssembly{ - poolByID: poolByID, - reserveEmissionByPoolToken: reserveEmissionByPoolToken, - priceByOracleAsset: priceByOracleAsset, - metaByContractID: metaByContractID, - blndPrice: blndPrice, - now: now, - } - - out := make([]*graphql1.BlendEarnOption, 0, len(assetIDs)) - for _, assetID := range assetIDs { - opt, err := assembly.buildEarnOption(assetID, reservesByAsset[assetID]) - if err != nil { - return nil, err - } - out = append(out, opt) - } - return out, nil -} diff --git a/internal/serve/graphql/resolvers/blend_earn_test.go b/internal/serve/graphql/resolvers/blend_earn_test.go deleted file mode 100644 index e24b4a0ed..000000000 --- a/internal/serve/graphql/resolvers/blend_earn_test.go +++ /dev/null @@ -1,354 +0,0 @@ -package resolvers - -import ( - "testing" - - "github.com/prometheus/client_golang/prometheus" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/stellar/wallet-backend/internal/data" - blenddata "github.com/stellar/wallet-backend/internal/data/blend" - "github.com/stellar/wallet-backend/internal/indexer/types" - "github.com/stellar/wallet-backend/internal/metrics" - graphql1 "github.com/stellar/wallet-backend/internal/serve/graphql/generated" -) - -// TestQueryResolver_BlendEarnOptions is a hand-computed vector exercising -// Query.blendEarnOptions' asset-first grouping. Asset X's two priced -// reserves reuse blend_pools_test's Pool Alpha r0/r1 curve/util/price -// numbers verbatim (same b/dRate/b/dSupply/curve/price), so their -// supplyApy/suppliedUsd are the exact same already-hand-verified values; -// Asset Y's single reserve reuses Pool Beta r0 likewise. This asserts: -// -// - Asset X: 2 pools (Pool One suppliedUsd=25.0, Pool Two suppliedUsd=20.0) -// plus 2 more with no oracle price at all (Pool Three/Four); Asset Y: 1 -// pool (Pool Five, suppliedUsd=1500.0); Asset Z: excluded — its only -// reserve has enabled=false, regardless of it also carrying a valid price. -// - Assets ordered ascending by contract address (plain Go string compare). -// - Each asset's pools ordered by suppliedUsd descending; a pool with no -// price sorts last, and ties (including nil vs. nil) break by ascending -// pool address. -// -// Pool One/Two/Three/Four's Asset X reserve: target=0.8, r_base=0.01, -// r_one=0.02, ir_mod=1.0. Pool One: b_supply=100_000_000, d_supply=40_000_000 -// => util=0.4, supplyApr=0.0064, supplyApy=(1+0.0064/52)^52-1= -// 0.006420127418405475 (python); price=$2.50 (7 decimals) => suppliedTokens -// (pool-wide)=100_000_000 -> USD=25.0. Pool Two: b_supply=20_000_000, -// d_supply=10_000_000 => util=0.5, supplyApr=0.009, supplyApy= -// (1+0.009/52)^52-1=0.00903983597743796 (python); price=$1.00 (6 decimals) -// => suppliedTokens=20_000_000 -> USD=20.0. Pool Three/Four: no oracle price -// row for Asset X under either pool's oracle at all => suppliedUsd nil for -// both (b_supply=10_000_000, decimals=7, so suppliedTokens is genuinely -// nonzero, not the "amount==0 => 0.0" case). -// -// Pool Five's Asset Y reserve: target=0.5, r_base=0.02, r_one=0.03, -// ir_mod=1.0, b_supply=50_000_000, d_supply=20_000_000 => util=0.4<=target; -// utilScalar=0.8; borrowApr=1.0*(0.02+0.8*0.03)=0.044; supplyApr=borrowApr* -// util*(1-bstopRate=0.1)=0.044*0.4*0.9=0.01584; supplyApy=(1+0.01584/52)^52-1 -// =0.01596366724981446 (python); price=$3.00 (5 decimals) => -// suppliedTokens=50_000_000 -> USD=1500.0. -func TestQueryResolver_BlendEarnOptions(t *testing.T) { - assetX := randomContractAddress(t) - assetY := randomContractAddress(t) - assetZ := randomContractAddress(t) - - poolOne := randomContractAddress(t) - poolTwo := randomContractAddress(t) - poolFive := randomContractAddress(t) - poolThree := randomContractAddress(t) - poolFour := randomContractAddress(t) - - // Supply-rejecting pools: the contract refuses supply for status >= 4 - // (4 Admin Frozen, 5 Frozen, 6 Setup), and an unknown (NULL) status can't - // be confirmed eligible. Their enabled Asset X reserves must not appear. - poolFrozen := randomContractAddress(t) - poolSetup := randomContractAddress(t) - poolUnknown := randomContractAddress(t) - - oracleOne := randomContractAddress(t) - oracleTwo := randomContractAddress(t) - oracleFive := randomContractAddress(t) - oracleThree := randomContractAddress(t) - oracleFour := randomContractAddress(t) - - // Asset X's ordering under Pool Three/Four (both nil suppliedUsd) falls - // back to ascending pool address; compute that order up front from the - // random addresses so the assertion below doesn't guess. - loP34, hiP34 := poolThree, poolFour - if loP34 > hiP34 { - loP34, hiP34 = hiP34, loP34 - } - - // Asset ordering (X vs. Y) is likewise a plain string compare of the - // two random C-addresses; Z is excluded from output entirely so its - // address doesn't need to be placed in this ordering. - assetLo, assetHi := assetX, assetY - if assetLo > assetHi { - assetLo, assetHi = assetHi, assetLo - } - - futureLastTime := int64(4102444800) // year 2100: ProjectRates' deltaT<=0 branch stays deterministic - - m := metrics.NewMetrics(prometheus.NewRegistry()) - dbMetrics := m.DB - blendModels := blenddata.NewModels(testDBConnectionPool, dbMetrics) - models := &data.Models{ - Contract: &data.ContractModel{DB: testDBConnectionPool, Metrics: dbMetrics}, - Blend: blendModels, - } - resolver := &queryResolver{&Resolver{models: models}} - - // --- contract_tokens metadata (Asset Z intentionally omitted: it's excluded anyway) --- - execTestDB(t, ` - INSERT INTO contract_tokens (id, contract_id, type, name, symbol, decimals) VALUES - ($1, $2, 'sep41', 'Asset X', 'AX', 7), - ($3, $4, 'sep41', 'Asset Y', 'AY', 5)`, - data.DeterministicContractID(assetX), assetX, - data.DeterministicContractID(assetY), assetY) - - // --- pools --- - execTestDB(t, ` - INSERT INTO blend_pools (pool_contract_id, name, oracle_contract_id, backstop_rate, status, max_positions, last_modified_ledger) - VALUES - ($1, 'Pool One', $2, 2000000, 0, 4, 100), - ($3, 'Pool Two', $4, 2000000, 0, 4, 100), - ($5, 'Pool Five', $6, 1000000, 0, 4, 100), - ($7, 'Pool Three', $8, 2000000, 0, 4, 100), - ($9, 'Pool Four', $10, 2000000, 0, 4, 100), - ($11, 'Pool Frozen', $2, 2000000, 4, 4, 100), - ($12, 'Pool Setup', $2, 2000000, 6, 4, 100), - ($13, 'Pool Unknown', $2, 2000000, NULL, 4, 100)`, - types.AddressBytea(poolOne), types.AddressBytea(oracleOne), - types.AddressBytea(poolTwo), types.AddressBytea(oracleTwo), - types.AddressBytea(poolFive), types.AddressBytea(oracleFive), - types.AddressBytea(poolThree), types.AddressBytea(oracleThree), - types.AddressBytea(poolFour), types.AddressBytea(oracleFour), - types.AddressBytea(poolFrozen), types.AddressBytea(poolSetup), types.AddressBytea(poolUnknown)) - - // --- reserves --- - execTestDB(t, ` - INSERT INTO blend_reserves ( - pool_contract_id, reserve_index, asset_contract_id, - b_rate, d_rate, b_supply, d_supply, ir_mod, backstop_credit, last_time, - decimals, c_factor, l_factor, util, max_util, - r_base, r_one, r_two, r_three, reactivity, supply_cap, enabled, last_modified_ledger - ) VALUES - ($1, 0, $2, '1000000000000', '1000000000000', '100000000', '40000000', '10000000', '0', $3, - 7, 9500000, 9500000, 8000000, 9500000, 100000, 200000, 5000000, 15000000, 0, '0', true, 100), - ($1, 1, $4, '1000000000000', '1000000000000', '100000', '1000', '10000000', '0', $3, - 7, 9500000, 9500000, 8000000, 9500000, 100000, 200000, 5000000, 15000000, 0, '0', false, 100), - ($5, 0, $2, '1000000000000', '1000000000000', '20000000', '10000000', '10000000', '0', $3, - 6, 9500000, 9500000, 8000000, 9500000, 100000, 200000, 5000000, 15000000, 0, '0', true, 100), - ($6, 0, $7, '1000000000000', '1000000000000', '50000000', '20000000', '10000000', '0', $3, - 5, 9000000, 9000000, 5000000, 9500000, 200000, 300000, 3000000, 10000000, 0, '0', true, 100), - ($8, 0, $2, '1000000000000', '1000000000000', '10000000', '4000000', '10000000', '0', $3, - 7, 9500000, 9500000, 8000000, 9500000, 100000, 200000, 5000000, 15000000, 0, '0', true, 100), - ($9, 0, $2, '1000000000000', '1000000000000', '10000000', '4000000', '10000000', '0', $3, - 7, 9500000, 9500000, 8000000, 9500000, 100000, 200000, 5000000, 15000000, 0, '0', true, 100), - ($10, 0, $2, '1000000000000', '1000000000000', '10000000', '4000000', '10000000', '0', $3, - 7, 9500000, 9500000, 8000000, 9500000, 100000, 200000, 5000000, 15000000, 0, '0', true, 100), - ($11, 0, $2, '1000000000000', '1000000000000', '10000000', '4000000', '10000000', '0', $3, - 7, 9500000, 9500000, 8000000, 9500000, 100000, 200000, 5000000, 15000000, 0, '0', true, 100), - ($12, 0, $2, '1000000000000', '1000000000000', '10000000', '4000000', '10000000', '0', $3, - 7, 9500000, 9500000, 8000000, 9500000, 100000, 200000, 5000000, 15000000, 0, '0', true, 100)`, - types.AddressBytea(poolOne), types.AddressBytea(assetX), futureLastTime, types.AddressBytea(assetZ), - types.AddressBytea(poolTwo), types.AddressBytea(poolFive), types.AddressBytea(assetY), - types.AddressBytea(poolThree), types.AddressBytea(poolFour), - types.AddressBytea(poolFrozen), types.AddressBytea(poolSetup), types.AddressBytea(poolUnknown)) - - // --- oracle prices: Pool Three/Four intentionally have none for Asset X; - // the Comet self-priced LP row + BLND sibling ($0.05) feed emissions APR --- - cometAddr := randomContractAddress(t) - blndAddr := randomContractAddress(t) - execTestDB(t, ` - INSERT INTO blend_oracle_prices (oracle_contract_id, asset_contract_id, price, price_decimals, price_timestamp) - VALUES - ($1, $2, '25000000', 7, EXTRACT(EPOCH FROM NOW())::bigint), - ($3, $2, '10000000', 7, EXTRACT(EPOCH FROM NOW())::bigint), - ($4, $5, '30000000', 7, EXTRACT(EPOCH FROM NOW())::bigint), - ($6, $6, '11000000', 7, EXTRACT(EPOCH FROM NOW())::bigint), - ($6, $7, '500000', 7, EXTRACT(EPOCH FROM NOW())::bigint)`, - types.AddressBytea(oracleOne), types.AddressBytea(assetX), - types.AddressBytea(oracleTwo), - types.AddressBytea(oracleFive), types.AddressBytea(assetY), - types.AddressBytea(cometAddr), types.AddressBytea(blndAddr)) - - // --- reserve emissions: Pool One's Asset X bToken (index*2+1 = 1) active, - // mirroring blend_pools_test's Pool Alpha r0 stream (emissionsSupplyApr = - // (eps/1e14 · 31536000 · blndUSD 0.05) / suppliedUsd 25.0 = 63.072) --- - execTestDB(t, ` - INSERT INTO blend_reserve_emissions (pool_contract_id, reserve_token_id, eps, emission_index, expiration, last_time, last_modified_ledger) - VALUES ($1, 1, 100000000000, '2000000000000', 4102444800, 100, 100)`, - types.AddressBytea(poolOne)) - - t.Cleanup(func() { - execTestDB(t, `DELETE FROM blend_reserves WHERE pool_contract_id IN ($1, $2, $3, $4, $5, $6, $7, $8)`, - types.AddressBytea(poolOne), types.AddressBytea(poolTwo), types.AddressBytea(poolFive), - types.AddressBytea(poolThree), types.AddressBytea(poolFour), - types.AddressBytea(poolFrozen), types.AddressBytea(poolSetup), types.AddressBytea(poolUnknown)) - execTestDB(t, `DELETE FROM blend_pools WHERE pool_contract_id IN ($1, $2, $3, $4, $5, $6, $7, $8)`, - types.AddressBytea(poolOne), types.AddressBytea(poolTwo), types.AddressBytea(poolFive), - types.AddressBytea(poolThree), types.AddressBytea(poolFour), - types.AddressBytea(poolFrozen), types.AddressBytea(poolSetup), types.AddressBytea(poolUnknown)) - execTestDB(t, `DELETE FROM blend_oracle_prices WHERE oracle_contract_id IN ($1, $2, $3, $4)`, - types.AddressBytea(oracleOne), types.AddressBytea(oracleTwo), types.AddressBytea(oracleFive), - types.AddressBytea(cometAddr)) - execTestDB(t, `DELETE FROM blend_reserve_emissions WHERE pool_contract_id = $1`, types.AddressBytea(poolOne)) - execTestDB(t, `DELETE FROM contract_tokens WHERE contract_id IN ($1, $2)`, assetX, assetY) - }) - - got, err := resolver.BlendEarnOptions(testCtx) - require.NoError(t, err) - require.Len(t, got, 2, "Asset Z must be excluded: its only reserve is disabled") - - t.Run("deterministic asset ordering", func(t *testing.T) { - assert.Equal(t, assetLo, got[0].AssetContractID) - assert.Equal(t, assetHi, got[1].AssetContractID) - }) - - optByAsset := map[string]*graphql1.BlendEarnOption{} - for _, o := range got { - optByAsset[o.AssetContractID] = o - } - x := optByAsset[assetX] - y := optByAsset[assetY] - require.NotNil(t, x) - require.NotNil(t, y) - - t.Run("Asset X metadata and pool count", func(t *testing.T) { - require.NotNil(t, x.TokenName) - assert.Equal(t, "Asset X", *x.TokenName) - require.NotNil(t, x.TokenSymbol) - assert.Equal(t, "AX", *x.TokenSymbol) - require.NotNil(t, x.TokenDecimals) - assert.EqualValues(t, 7, *x.TokenDecimals) - require.Len(t, x.Pools, 4) - }) - - t.Run("Asset X pools ordered by suppliedUsd descending, nil last, tie-break by pool address", func(t *testing.T) { - p0, p1, p2, p3 := x.Pools[0], x.Pools[1], x.Pools[2], x.Pools[3] - - assert.Equal(t, poolOne, p0.PoolAddress) - require.NotNil(t, p0.PoolName) - assert.Equal(t, "Pool One", *p0.PoolName) - require.NotNil(t, p0.SupplyApy) - assert.InDelta(t, 0.006420127418405475, *p0.SupplyApy, 1e-12) - require.NotNil(t, p0.SuppliedUsd) - assert.InDelta(t, 25.0, *p0.SuppliedUsd, 1e-9) - // Active bToken emission stream: same hand-computed vector as - // blend_pools_test's Pool Alpha r0. - require.NotNil(t, p0.EmissionsSupplyApr) - assert.InDelta(t, 63.072, *p0.EmissionsSupplyApr, 1e-9) - - assert.Equal(t, poolTwo, p1.PoolAddress) - require.NotNil(t, p1.SupplyApy) - assert.InDelta(t, 0.00903983597743796, *p1.SupplyApy, 1e-12) - require.NotNil(t, p1.SuppliedUsd) - assert.InDelta(t, 20.0, *p1.SuppliedUsd, 1e-9) - // No emission stream configured -> a concrete 0, not nil. - require.NotNil(t, p1.EmissionsSupplyApr) - assert.InDelta(t, 0.0, *p1.EmissionsSupplyApr, 1e-12) - - // Pool Three/Four: no oracle price at all -> suppliedUsd nil, sorted - // last, ties broken by ascending pool address. - assert.Nil(t, p2.SuppliedUsd) - assert.Nil(t, p3.SuppliedUsd) - assert.Equal(t, loP34, p2.PoolAddress) - assert.Equal(t, hiP34, p3.PoolAddress) - }) - - t.Run("supply-rejecting pools (status >= 4 or unknown) are not earn destinations", func(t *testing.T) { - for _, p := range x.Pools { - assert.NotEqual(t, poolFrozen, p.PoolAddress, "Admin Frozen (4) rejects supply on-chain") - assert.NotEqual(t, poolSetup, p.PoolAddress, "Setup (6) rejects supply on-chain") - assert.NotEqual(t, poolUnknown, p.PoolAddress, "a NULL status can't be confirmed supply-eligible") - } - }) - - t.Run("Asset Y metadata and single pool", func(t *testing.T) { - require.NotNil(t, y.TokenName) - assert.Equal(t, "Asset Y", *y.TokenName) - require.NotNil(t, y.TokenDecimals) - assert.EqualValues(t, 5, *y.TokenDecimals) - require.Len(t, y.Pools, 1) - - p := y.Pools[0] - assert.Equal(t, poolFive, p.PoolAddress) - require.NotNil(t, p.PoolName) - assert.Equal(t, "Pool Five", *p.PoolName) - require.NotNil(t, p.SupplyApy) - assert.InDelta(t, 0.01596366724981446, *p.SupplyApy, 1e-12) - require.NotNil(t, p.SuppliedUsd) - assert.InDelta(t, 1500.0, *p.SuppliedUsd, 1e-9) - }) -} - -func TestQueryResolver_BlendEarnOptions_Empty(t *testing.T) { - // Mirrors TestQueryResolver_BlendPools_Empty: only meaningful run in - // isolation, but exercises the no-pools/no-error path either way. - m := metrics.NewMetrics(prometheus.NewRegistry()) - models := &data.Models{ - Contract: &data.ContractModel{DB: testDBConnectionPool, Metrics: m.DB}, - Blend: blenddata.NewModels(testDBConnectionPool, m.DB), - } - resolver := &queryResolver{&Resolver{models: models}} - - got, err := resolver.BlendEarnOptions(testCtx) - require.NoError(t, err) - assert.NotNil(t, got) -} - -func TestQueryResolver_BlendEarnOptions_UnenrichedMetadataFallsBackToReserveDecimals(t *testing.T) { - // SEP-41 classification inserts a contract_tokens row (nil name/symbol, - // decimals 0) BEFORE RPC enrichment fills it. Until enrichment lands, the - // reserve config's decimals — set on-chain from the token itself — are - // authoritative for the earn view. - asset := randomContractAddress(t) - poolAddr := randomContractAddress(t) - oracleAddr := randomContractAddress(t) - - m := metrics.NewMetrics(prometheus.NewRegistry()) - models := &data.Models{ - Contract: &data.ContractModel{DB: testDBConnectionPool, Metrics: m.DB}, - Blend: blenddata.NewModels(testDBConnectionPool, m.DB), - } - resolver := &queryResolver{&Resolver{models: models}} - - execTestDB(t, ` - INSERT INTO contract_tokens (id, contract_id, type, name, symbol, decimals) VALUES - ($1, $2, 'sep41', NULL, NULL, 0)`, - data.DeterministicContractID(asset), asset) - execTestDB(t, ` - INSERT INTO blend_pools (pool_contract_id, name, oracle_contract_id, backstop_rate, status, max_positions, last_modified_ledger) - VALUES ($1, 'Pool U', $2, 2000000, 0, 4, 100)`, - types.AddressBytea(poolAddr), types.AddressBytea(oracleAddr)) - execTestDB(t, ` - INSERT INTO blend_reserves ( - pool_contract_id, reserve_index, asset_contract_id, - b_rate, d_rate, b_supply, d_supply, ir_mod, backstop_credit, last_time, - decimals, c_factor, l_factor, util, max_util, - r_base, r_one, r_two, r_three, reactivity, supply_cap, enabled, last_modified_ledger - ) VALUES ($1, 0, $2, '1000000000000', '1000000000000', '100000000', '40000000', '10000000', '0', 4102444800, - 6, 9500000, 9500000, 8000000, 9500000, 100000, 200000, 5000000, 15000000, 0, '0', true, 100)`, - types.AddressBytea(poolAddr), types.AddressBytea(asset)) - t.Cleanup(func() { - execTestDB(t, `DELETE FROM blend_reserves WHERE pool_contract_id = $1`, types.AddressBytea(poolAddr)) - execTestDB(t, `DELETE FROM blend_pools WHERE pool_contract_id = $1`, types.AddressBytea(poolAddr)) - execTestDB(t, `DELETE FROM contract_tokens WHERE contract_id = $1`, asset) - }) - - got, err := resolver.BlendEarnOptions(testCtx) - require.NoError(t, err) - - var opt *graphql1.BlendEarnOption - for _, o := range got { - if o.AssetContractID == asset { - opt = o - } - } - require.NotNil(t, opt) - assert.Nil(t, opt.TokenName, "unenriched row has no name") - require.NotNil(t, opt.TokenDecimals) - assert.EqualValues(t, 6, *opt.TokenDecimals, "reserve decimals win over the unenriched row's 0") -} diff --git a/internal/serve/graphql/resolvers/blend_pools.go b/internal/serve/graphql/resolvers/blend_pools.go index c264f4fda..72f8849c6 100644 --- a/internal/serve/graphql/resolvers/blend_pools.go +++ b/internal/serve/graphql/resolvers/blend_pools.go @@ -55,8 +55,8 @@ func priceUsdOrNil(price *blenddata.OraclePrice) *float64 { // reserve's own pool-wide BSupply/DSupply rather than an account's holdings. // emissionsSupplyApr/emissionsBorrowApr size their APR against that same // pool-wide side, matching buildReservePosition's emissionsAPRFor calls -// exactly (both use the reserve's current, unprojected BSupply/BRate and -// DSupply/DRate as the sideRaw/sideRate denominator). +// exactly (both value the sideRaw denominator at the PROJECTED PB/PD rates, +// consistent with every other as-of-now token/USD figure on the type). func (d *blendAssembly) buildPoolReserve(poolAddr string, reserve blenddata.Reserve) (*graphql1.BlendReserve, error) { rr, err := d.computeReserveRates(poolAddr, reserve) if err != nil { @@ -77,8 +77,8 @@ func (d *blendAssembly) buildPoolReserve(poolAddr string, reserve blenddata.Rese bTokenID := reserve.ReserveIndex*2 + 1 dTokenID := reserve.ReserveIndex * 2 - emissionsSupplyApr := d.emissionsAPRFor(poolAddr, bTokenID, rr.BSupply, rr.BRate, reserve.Decimals, price) - emissionsBorrowApr := d.emissionsAPRFor(poolAddr, dTokenID, rr.DSupply, rr.DRate, reserve.Decimals, price) + emissionsSupplyApr := d.emissionsAPRFor(poolAddr, bTokenID, rr.BSupply, rr.PB, reserve.Decimals, price) + emissionsBorrowApr := d.emissionsAPRFor(poolAddr, dTokenID, rr.DSupply, rr.PD, reserve.Decimals, price) utilization, _ := rr.CurrentUtil.Float64() priceUsd := priceUsdOrNil(price) @@ -127,6 +127,37 @@ func (d *blendAssembly) backstopUsdForPool(poolAddr string) (*float64, error) { return usdValueOrNil(tokens, backstopLPDecimals, d.lpPrice), nil } +// blendPoolStatusEnum maps a blend_pools.status value to its schema enum +// (on-chain encoding 0-6, per the BlendPoolStatus doc). status is a pointer +// because the column is null until the pool's config entry has been ingested; +// nil status, and any unrecognized value, both yield (nil, matching the field +// doc) — the caller surfaces null rather than a malformed enum. +func blendPoolStatusEnum(status *int32) *graphql1.BlendPoolStatus { + if status == nil { + return nil + } + var out graphql1.BlendPoolStatus + switch *status { + case 0: + out = graphql1.BlendPoolStatusAdminActive + case 1: + out = graphql1.BlendPoolStatusActive + case 2: + out = graphql1.BlendPoolStatusAdminOnIce + case 3: + out = graphql1.BlendPoolStatusOnIce + case 4: + out = graphql1.BlendPoolStatusAdminFrozen + case 5: + out = graphql1.BlendPoolStatusFrozen + case 6: + out = graphql1.BlendPoolStatusSetup + default: + return nil + } + return &out +} + // buildPool assembles one blend_pools row plus its reserves into a // pool-wide BlendPool catalog entry. // @@ -210,7 +241,7 @@ func (d *blendAssembly) buildPool(pool blenddata.Pool, reserves []blenddata.Rese return &graphql1.BlendPool{ Address: poolAddr, Name: pool.Name, - Status: pool.Status, + Status: blendPoolStatusEnum(pool.Status), OracleContractID: addressPtrOrNil(pool.OracleContractID), BackstopRate: pool.BackstopRate, MaxPositions: pool.MaxPositions, @@ -287,7 +318,7 @@ func (r *Resolver) buildBlendPoolCatalog(ctx context.Context, pools []blenddata. return nil }) poolGroup.Go(func() (err error) { - backstopLPPrices, err = r.models.Blend.OraclePrices.GetBackstopLPPrices(poolCtx) + backstopLPPrices, err = r.models.Blend.OraclePrices.GetBackstopLPPrices(poolCtx, r.blendBackstopLPContractID) if err != nil { return fmt.Errorf("getting blend backstop LP prices: %w", err) } diff --git a/internal/serve/graphql/resolvers/blend_pools_test.go b/internal/serve/graphql/resolvers/blend_pools_test.go index 39d1d3fd3..a4b85cd7b 100644 --- a/internal/serve/graphql/resolvers/blend_pools_test.go +++ b/internal/serve/graphql/resolvers/blend_pools_test.go @@ -106,7 +106,7 @@ func TestQueryResolver_BlendPools(t *testing.T) { Contract: &data.ContractModel{DB: testDBConnectionPool, Metrics: dbMetrics}, Blend: blendModels, } - resolver := &queryResolver{&Resolver{models: models}} + resolver := &queryResolver{&Resolver{models: models, blendBackstopLPContractID: cometAddr}} // --- contract_tokens metadata (name/symbol only; decimals authority is blend_reserves) --- execTestDB(t, ` @@ -208,7 +208,7 @@ func TestQueryResolver_BlendPools(t *testing.T) { require.NotNil(t, alpha.Name) assert.Equal(t, "Pool Alpha", *alpha.Name) require.NotNil(t, alpha.Status) - assert.EqualValues(t, 0, *alpha.Status) + assert.Equal(t, graphql1.BlendPoolStatusAdminActive, *alpha.Status) require.NotNil(t, alpha.OracleContractID) assert.Equal(t, oracleA, *alpha.OracleContractID) require.NotNil(t, alpha.BackstopRate) @@ -309,7 +309,7 @@ func TestQueryResolver_BlendPools(t *testing.T) { require.NotNil(t, beta.Name) assert.Equal(t, "Pool Beta", *beta.Name) require.NotNil(t, beta.Status) - assert.EqualValues(t, 1, *beta.Status) + assert.Equal(t, graphql1.BlendPoolStatusActive, *beta.Status) require.Len(t, beta.Reserves, 1) rb := beta.Reserves[0] @@ -451,6 +451,20 @@ func TestQueryResolver_BlendPools(t *testing.T) { } }) + t.Run("unset backstop LP pin nulls backstopUsd (no LP price read at all)", func(t *testing.T) { + // With the pin empty, GetBackstopLPPrices short-circuits to an empty + // result without querying, so the LP price is never found and every + // pool's backstopUsd is null — even though the Comet rows are present + // and fresh. This exercises the cmd→resolver plumbing end-to-end. + unpinned := &queryResolver{&Resolver{models: models}} + got, err := unpinned.BlendPools(testCtx) + require.NoError(t, err) + require.Len(t, got, 2) + for _, p := range got { + assert.Nil(t, p.BackstopUsd, "pool %s backstopUsd must be nil when the backstop LP pin is unset", p.Address) + } + }) + t.Run("blendPool single-pool lookup returns the matching pool", func(t *testing.T) { one, err := resolver.BlendPool(testCtx, poolA) require.NoError(t, err) diff --git a/internal/serve/graphql/resolvers/blend_positions.go b/internal/serve/graphql/resolvers/blend_positions.go index 8097d9a5b..3c8ab4de0 100644 --- a/internal/serve/graphql/resolvers/blend_positions.go +++ b/internal/serve/graphql/resolvers/blend_positions.go @@ -109,22 +109,21 @@ func usdValueOrNil(amount *big.Int, decimals int32, price *blenddata.OraclePrice // time their emissions are touched — the full index counts, from a zero // starting point, with nothing pre-accrued. // -// configIndex is the stream's own current emission_index (from -// blend_reserve_emissions or blend_backstop_pools.emis_index), used as the -// projection target even past expiration — expiry freezes further real-time -// growth but doesn't erase the last recorded index. A nil configIndex means -// no config row exists at all (the stream was never configured); in that -// case no further growth beyond the user's own Accrued is possible, so the -// user's own index is used as both sides of the delta. -func claimableStream(userEmission blenddata.Emission, hasUser bool, configIndex *string, tokenBalance, scalar *big.Int) (*big.Int, error) { +// emisIndex is the stream's emission index already projected to "now" by the +// caller (rates.go's ProjectEmissionIndex over the stored +// blend_reserve_emissions / blend_backstop_pools.emis_index value) — the +// stored index alone is only as fresh as the stream's last on-chain touch. +// Projection stops at expiration, so a past-expiration index still counts in +// full: expiry freezes further real-time growth but doesn't erase what +// accrued. A nil emisIndex means no config row exists at all (the stream was +// never configured); in that case no further growth beyond the user's own +// Accrued is possible, so the user's own index is used as both sides of the +// delta. +func claimableStream(userEmission blenddata.Emission, hasUser bool, emisIndex *big.Int, tokenBalance, scalar *big.Int) (*big.Int, error) { if !hasUser { - if configIndex == nil || tokenBalance.Sign() <= 0 { + if emisIndex == nil || tokenBalance.Sign() <= 0 { return big.NewInt(0), nil } - emisIndex, err := parseBigInt(*configIndex) - if err != nil { - return nil, fmt.Errorf("parsing blend emission config index: %w", err) - } return blendrates.ClaimableEmissions(big.NewInt(0), big.NewInt(0), emisIndex, tokenBalance, scalar), nil } accrued, err := parseBigInt(userEmission.Accrued) @@ -136,12 +135,8 @@ func claimableStream(userEmission blenddata.Emission, hasUser bool, configIndex return nil, fmt.Errorf("parsing blend emission index: %w", err) } - emisIndex := userIndex - if configIndex != nil { - emisIndex, err = parseBigInt(*configIndex) - if err != nil { - return nil, fmt.Errorf("parsing blend emission config index: %w", err) - } + if emisIndex == nil { + emisIndex = userIndex } return blendrates.ClaimableEmissions(accrued, userIndex, emisIndex, tokenBalance, scalar), nil @@ -182,52 +177,39 @@ func freshPrices(rows []blenddata.OraclePrice, now int64) []blenddata.OraclePric } // findBackstopPrices picks the (LP, BLND) price pair out of -// OraclePrices.GetBackstopLPPrices' result set. Rows share an -// oracle_contract_id in groups of (one self-priced LP row, its sibling BLND -// row) — see GetBackstopLPPrices' doc. Each group has exactly one sibling: the -// snapshot writer stores precisely two rows per Comet oracle (the self-priced LP -// row and the BLND row; see snapshotComet), so the sole sibling is unambiguously -// BLND. Blend v2 has a single protocol-wide backstop LP token — the emitter can -// swap it, but only sequentially, never two at once — so more than one complete -// group means a misconfigured Comet/oracle address: that state is logged loudly -// and resolved by always picking the lowest oracle key, keeping the choice -// deterministic instead of flapping with Go's map-iteration order. Returns -// (nil, nil) if no complete group is found. +// OraclePrices.GetBackstopLPPrices' result set. Those rows are already scoped +// to the single configured Comet oracle (BLEND_BACKSTOP_LP_CONTRACT_ID) — see +// GetBackstopLPPrices' doc — so they all share one oracle_contract_id: the +// self-priced row (asset == oracle) is the Comet LP share's own USD price, and +// its sole sibling (asset != oracle) is the BLND price quoted under that same +// oracle. +// +// The snapshot writer stores exactly those two rows per Comet oracle (the +// self-priced LP row and the BLND row; see snapshotComet), so more than two +// rows — or more than one sibling — means an extra asset was stored under the +// LP's oracle key, making the BLND row ambiguous; that state is logged loudly +// and the lowest sibling asset address is kept (rows arrive ordered by asset) +// so the choice stays deterministic. Returns (nil, nil) when either half is +// missing (no self-priced LP row, or no BLND sibling). func findBackstopPrices(ctx context.Context, rows []blenddata.OraclePrice) (lp, blnd *blenddata.OraclePrice) { - selfPriced := map[string]blenddata.OraclePrice{} - siblings := map[string][]blenddata.OraclePrice{} + var self blenddata.OraclePrice + haveSelf := false + siblings := make([]blenddata.OraclePrice, 0, len(rows)) for _, row := range rows { if string(row.OracleContractID) == string(row.AssetContractID) { - selfPriced[string(row.OracleContractID)] = row + self = row + haveSelf = true } else { - siblings[string(row.OracleContractID)] = append(siblings[string(row.OracleContractID)], row) + siblings = append(siblings, row) } } - - complete := make([]string, 0, len(selfPriced)) - for oracle := range selfPriced { - if len(siblings[oracle]) > 0 { - complete = append(complete, oracle) - } - } - if len(complete) == 0 { + if !haveSelf || len(siblings) == 0 { return nil, nil } - sort.Strings(complete) - if len(complete) > 1 { - log.Ctx(ctx).Errorf("blend: %d self-priced backstop LP price groups found (expected exactly 1 — misconfigured Comet/oracle address?); using oracle %s", len(complete), complete[0]) - } - - self := selfPriced[complete[0]] - sibs := siblings[complete[0]] - if len(sibs) > 1 { - // Exactly one sibling (the BLND row) is expected per Comet oracle. More - // than one means an extra asset was stored under the LP's oracle key, - // making the BLND row ambiguous; log it and keep the lowest asset address - // (rows are ordered by asset) so the choice stays deterministic. - log.Ctx(ctx).Errorf("blend: backstop LP oracle %s has %d sibling price rows (expected 1 — the BLND row); using the lowest asset address", complete[0], len(sibs)) + if len(rows) > 2 || len(siblings) > 1 { + log.Ctx(ctx).Errorf("blend: pinned backstop LP oracle %s has %d price rows / %d BLND siblings (expected 1 self-priced LP row + 1 BLND sibling — extra asset stored under the LP's oracle key?); using the lowest sibling asset address", self.OracleContractID, len(rows), len(siblings)) } - sib := sibs[0] + sib := siblings[0] return &self, &sib } @@ -265,12 +247,21 @@ func (d *blendAssembly) priceLookup(oracle, asset types.AddressBytea) *blenddata return nil } -func (d *blendAssembly) reserveEmissionConfigIndex(poolAddr string, tokenID int32) *string { - if re, ok := d.reserveEmissionByPoolToken[poolTokenKey(poolAddr, tokenID)]; ok { - idx := re.EmissionIndex - return &idx +// projectedReserveEmissionIndex returns a reserve emission stream's index +// projected to "now" (rates.go's ProjectEmissionIndex): the stored index plus +// the accrual earned since the stream's last on-chain touch, spread over the +// side's current raw token supply. nil (no error) when the stream was never +// configured. +func (d *blendAssembly) projectedReserveEmissionIndex(poolAddr string, tokenID int32, supply *big.Int, decimals int32) (*big.Int, error) { + re, ok := d.reserveEmissionByPoolToken[poolTokenKey(poolAddr, tokenID)] + if !ok { + return nil, nil } - return nil + stored, err := parseBigInt(re.EmissionIndex) + if err != nil { + return nil, fmt.Errorf("parsing blend emission config index: %w", err) + } + return blendrates.ProjectEmissionIndex(stored, re.Eps, re.LastTime, re.Expiration, d.now, supply, pow10(decimals)), nil } // emissionsAPRFor computes the emissions APR for one (pool, tokenID) reserve @@ -360,7 +351,7 @@ func (d *blendAssembly) computeReserveRates(poolAddr string, reserve blenddata.R supplyApy := blendrates.ToAPY(supplyAPR, blendrates.SupplyAPYCompoundingPeriods) borrowApy := blendrates.ToAPY(borrowAPR, blendrates.BorrowAPYCompoundingPeriods) - pB, pD := blendrates.ProjectRates(bRate, dRate, borrowAPR, currentUtil, bstopRate, reserve.LastTime, d.now) + pB, pD := blendrates.ProjectRates(bRate, dRate, bSupply, dSupply, borrowAPR, bstopRate, reserve.LastTime, d.now) return &reserveRates{ BRate: bRate, DRate: dRate, @@ -430,40 +421,37 @@ func (d *blendAssembly) buildReservePosition(p blenddata.Position) (*graphql1.Bl suppliedUsd := usdValueOrNil(totalSupplySide, reserve.Decimals, price) borrowedUsd := usdValueOrNil(borrowedTokens, reserve.Decimals, price) - // emissionsApr reports whichever token side (bToken/dToken) this position - // currently holds a nonzero balance on. Blend tracks one BLND-per-second - // stream per token id (dToken=idx*2, bToken=idx*2+1), and - // BlendReservePosition exposes a single emissionsApr number, so this - // resolves that ambiguity by reporting the side actually relevant to the - // account's exposure: bToken emissions when it supplies/collateralizes, - // otherwise dToken emissions when it has debt. 0 when the position holds - // neither side (a fully-exited, zeroed row). + // Blend runs two independent BLND-per-second emission streams per reserve + // (dToken=idx*2, bToken=idx*2+1), so both sides are reported as their own + // pool-wide APR rather than collapsed to whichever side this account + // happens to hold. Each is the stream's pool-wide rate (over BSupply/DSupply + // at the projected rate), not scaled to the account's balance. bTokenID := reserve.ReserveIndex*2 + 1 dTokenID := reserve.ReserveIndex * 2 bSideBalance := new(big.Int).Add(supplyB, collateralB) - var emissionsApr *float64 - switch { - case bSideBalance.Sign() > 0: - emissionsApr = d.emissionsAPRFor(poolAddr, bTokenID, rr.BSupply, rr.BRate, reserve.Decimals, price) - case liabilityD.Sign() > 0: - emissionsApr = d.emissionsAPRFor(poolAddr, dTokenID, rr.DSupply, rr.DRate, reserve.Decimals, price) - default: - zero := 0.0 - emissionsApr = &zero - } + emissionsSupplyApr := d.emissionsAPRFor(poolAddr, bTokenID, rr.BSupply, rr.PB, reserve.Decimals, price) + emissionsBorrowApr := d.emissionsAPRFor(poolAddr, dTokenID, rr.DSupply, rr.PD, reserve.Decimals, price) - // emissionsEarnedBlnd, unlike emissionsApr, always sums BOTH streams: + // emissionsEarnedBlnd always sums BOTH streams: // a position can carry claimable history on a side it no longer holds // (e.g. fully repaid debt with unclaimed dToken-stream emissions). claimScalar := reserveClaimScalar(reserve.Decimals) + bEmisIndex, err := d.projectedReserveEmissionIndex(poolAddr, bTokenID, rr.BSupply, reserve.Decimals) + if err != nil { + return nil, err + } bUserEmission, bHasUser := d.userEmissionByPoolToken[poolTokenKey(poolAddr, bTokenID)] - bClaimable, err := claimableStream(bUserEmission, bHasUser, d.reserveEmissionConfigIndex(poolAddr, bTokenID), bSideBalance, claimScalar) + bClaimable, err := claimableStream(bUserEmission, bHasUser, bEmisIndex, bSideBalance, claimScalar) + if err != nil { + return nil, err + } + dEmisIndex, err := d.projectedReserveEmissionIndex(poolAddr, dTokenID, rr.DSupply, reserve.Decimals) if err != nil { return nil, err } dUserEmission, dHasUser := d.userEmissionByPoolToken[poolTokenKey(poolAddr, dTokenID)] - dClaimable, err := claimableStream(dUserEmission, dHasUser, d.reserveEmissionConfigIndex(poolAddr, dTokenID), liabilityD, claimScalar) + dClaimable, err := claimableStream(dUserEmission, dHasUser, dEmisIndex, liabilityD, claimScalar) if err != nil { return nil, err } @@ -492,7 +480,8 @@ func (d *blendAssembly) buildReservePosition(p blenddata.Position) (*graphql1.Bl BorrowedUsd: borrowedUsd, SupplyApy: &rr.SupplyApy, BorrowApy: &rr.BorrowApy, - EmissionsApr: emissionsApr, + EmissionsSupplyApr: emissionsSupplyApr, + EmissionsBorrowApr: emissionsBorrowApr, InterestEarned: interestEarned.String(), InterestPaid: interestPaid.String(), EmissionsEarnedBlnd: claimableBLND.String(), @@ -553,10 +542,18 @@ func (d *blendAssembly) buildPoolPosition(poolAddr string, positions []blenddata if suppliedKnown && borrowedKnown { v := suppliedUsdSum - borrowedUsdSum usdValue = &v - if v != 0 { - apy := (supplyApyNumerator - borrowApyNumerator) / v - netApy = &apy + // netApy divides by total supplied — NOT by the net position value — + // matching blend-sdk-js's PositionsEstimate (user_positions_est.ts): + // netApy = (Σ suppliedUsd·supplyApy − Σ borrowedUsd·borrowApy) / + // totalSupplied, and exactly 0 for a supply-less position ("the debt + // will be forgiven as bad debt so the net APY is still 0"). Dividing + // by net value would diverge from the Blend UI and blow up as a + // leveraged position's net value approaches 0. + apy := 0.0 + if suppliedUsdSum != 0 { + apy = (supplyApyNumerator - borrowApyNumerator) / suppliedUsdSum } + netApy = &apy } claimed, ok := d.claimedByPool[poolAddr] @@ -576,6 +573,33 @@ func (d *blendAssembly) buildPoolPosition(poolAddr string, positions []blenddata }, nil } +// projectedBackstopEmissionIndex returns the backstop emission stream's index +// projected to "now" over the pool's UNQUEUED shares (shares − q4w): +// queued-for-withdrawal shares earn no emissions, so the contract distributor +// spreads accrual over active shares only (backstop/src/emissions/ +// distributor.rs's update_emission_data). nil when the pool has never had an +// emission config (emis_index NULL). The emis_* columns are written together +// (BatchUpsertEmissions), so a non-nil index implies the other three are set; +// falling back to the stored index when they aren't is defensive only. +func projectedBackstopEmissionIndex(bp blenddata.BackstopPool, poolShares *big.Int, now int64) (*big.Int, error) { + if bp.EmisIndex == nil { + return nil, nil + } + stored, err := parseBigInt(*bp.EmisIndex) + if err != nil { + return nil, fmt.Errorf("parsing blend backstop emission index: %w", err) + } + if bp.EmisEps == nil || bp.EmisLastTime == nil || bp.EmisExpiration == nil { + return stored, nil + } + poolQ4W, err := parseBigInt(bp.Q4W) + if err != nil { + return nil, fmt.Errorf("parsing blend backstop pool q4w: %w", err) + } + unqueued := new(big.Int).Sub(poolShares, poolQ4W) + return blendrates.ProjectEmissionIndex(stored, *bp.EmisEps, *bp.EmisLastTime, *bp.EmisExpiration, now, unqueued, big.NewInt(scalar7)), nil +} + // blendAuctionTypeEnum maps a blend_auctions.auction_type value to its // schema enum (0/1/2, per services/blend/entries.go's AuctionType doc). ok is // false for any unrecognized value, which the caller skips rather than @@ -636,6 +660,11 @@ func (d *blendAssembly) buildActiveAuction(ctx context.Context, a blenddata.Auct // Q4W entry also valued individually through the same shares→LP→USD chain. // Emissions are the one queued-share exception: they accrue on active // shares only, so claimableStream gets the active balance. +// +// When the pool's blend_backstop_pools row has not been ingested yet, +// poolShares/poolTokens stay zero, so BackstopLPTokens returns 0 and lpTokens +// reports "0" with usdValue 0 — a data-completeness gap that under-reports +// (never over-reports) the deposit until that pool row lands. func (d *blendAssembly) buildBackstopPosition(bp blenddata.BackstopPosition) (*graphql1.BlendBackstopPosition, error) { poolAddr := string(bp.PoolContractID) @@ -645,7 +674,7 @@ func (d *blendAssembly) buildBackstopPosition(bp blenddata.BackstopPosition) (*g } poolShares, poolTokens := big.NewInt(0), big.NewInt(0) - var configIndex *string + var emisIndex *big.Int if backstopPool, ok := d.backstopPoolByID[poolAddr]; ok { poolShares, err = parseBigInt(backstopPool.Shares) if err != nil { @@ -655,7 +684,10 @@ func (d *blendAssembly) buildBackstopPosition(bp blenddata.BackstopPosition) (*g if err != nil { return nil, fmt.Errorf("parsing blend backstop pool tokens: %w", err) } - configIndex = backstopPool.EmisIndex + emisIndex, err = projectedBackstopEmissionIndex(backstopPool, poolShares, d.now) + if err != nil { + return nil, err + } } totalShares := new(big.Int).Set(shares) @@ -679,7 +711,7 @@ func (d *blendAssembly) buildBackstopPosition(bp blenddata.BackstopPosition) (*g usdValue := usdValueOrNil(lpTokens, backstopLPDecimals, d.lpPrice) userEmission, hasUser := d.userEmissionByPoolToken[poolTokenKey(poolAddr, blenddata.BackstopEmissionTokenID)] - claimable, err := claimableStream(userEmission, hasUser, configIndex, shares, backstopClaimScalar) + claimable, err := claimableStream(userEmission, hasUser, emisIndex, shares, backstopClaimScalar) if err != nil { return nil, err } @@ -856,7 +888,7 @@ func (r *Resolver) getBlendPositions(ctx context.Context, address string) (*grap return nil }) priceGroup.Go(func() (err error) { - backstopLPPrices, err = r.models.Blend.OraclePrices.GetBackstopLPPrices(priceCtx) + backstopLPPrices, err = r.models.Blend.OraclePrices.GetBackstopLPPrices(priceCtx, r.blendBackstopLPContractID) if err != nil { return fmt.Errorf("getting blend backstop LP prices: %w", err) } diff --git a/internal/serve/graphql/resolvers/blend_positions_test.go b/internal/serve/graphql/resolvers/blend_positions_test.go index c9443f332..9e969cda7 100644 --- a/internal/serve/graphql/resolvers/blend_positions_test.go +++ b/internal/serve/graphql/resolvers/blend_positions_test.go @@ -25,14 +25,14 @@ import ( // balance but NO UserEmissionData entry is owed balance*index/scalar the // moment an emission stream exists — not zero. func TestClaimableStream(t *testing.T) { - scalar := big.NewInt(100_000_000_000_000) // 1e14: 7-decimal token, 10^7 * SCALAR_7 - index := "1234000000000000" // 1.234e15 + scalar := big.NewInt(100_000_000_000_000) // 1e14: 7-decimal token, 10^7 * SCALAR_7 + index := big.NewInt(1_234_000_000_000_000) // 1.234e15 t.Run("no user row, balance held: historical accrual owed", func(t *testing.T) { // balance*index/scalar = 5_000_000 * 1.234e15 / 1e14 = 61_700_000 — // the contract's own test vector for this branch minus the // pre-accrued 1_000_000 (there is no user row to carry accrued). - got, err := claimableStream(blenddata.Emission{}, false, &index, big.NewInt(5_000_000), scalar) + got, err := claimableStream(blenddata.Emission{}, false, index, big.NewInt(5_000_000), scalar) require.NoError(t, err) assert.Equal(t, "61700000", got.String()) }) @@ -44,7 +44,7 @@ func TestClaimableStream(t *testing.T) { }) t.Run("no user row, zero balance: zero", func(t *testing.T) { - got, err := claimableStream(blenddata.Emission{}, false, &index, big.NewInt(0), scalar) + got, err := claimableStream(blenddata.Emission{}, false, index, big.NewInt(0), scalar) require.NoError(t, err) assert.Equal(t, "0", got.String()) }) @@ -53,12 +53,71 @@ func TestClaimableStream(t *testing.T) { // The contract's test_update_user_emissions_accrues vector: // 1_000_000 + floor(5_000_000 * 1.234e15 / 1e14) = 62_700_000. userEmission := blenddata.Emission{Accrued: "1000000", EmissionIndex: "0"} - got, err := claimableStream(userEmission, true, &index, big.NewInt(5_000_000), scalar) + got, err := claimableStream(userEmission, true, index, big.NewInt(5_000_000), scalar) require.NoError(t, err) assert.Equal(t, "62700000", got.String()) }) } +// TestProjectedEmissionIndexHelpers covers the resolver-side projection +// wrappers around rates.go's ProjectEmissionIndex: an idle-but-active stream +// must report a claimable-now index ahead of the stored one, spread over the +// right supply (reserve: the side's raw token supply; backstop: unqueued +// shares only). +func TestProjectedEmissionIndexHelpers(t *testing.T) { + t.Run("reserve: idle active stream projects forward", func(t *testing.T) { + d := &blendAssembly{ + now: 1_500_000_100, + reserveEmissionByPoolToken: map[string]blenddata.ReserveEmission{ + poolTokenKey("POOL", 1): { + Eps: 1_000_000_000_000, // 0.01 BLND/s at 1e14 + EmissionIndex: "1000000", + Expiration: 1_600_000_000, + LastTime: 1_500_000_000, + }, + }, + } + // Δt=100, supply=1e9 (100.0000000 of a 7-dec token), scalar=1e7: + // additional = 100*1e12*1e7/1e9 = 1e12. + got, err := d.projectedReserveEmissionIndex("POOL", 1, big.NewInt(1_000_000_000), 7) + require.NoError(t, err) + assert.Equal(t, "1000001000000", got.String()) + }) + + t.Run("reserve: unconfigured stream is nil", func(t *testing.T) { + d := &blendAssembly{now: 1_500_000_100, reserveEmissionByPoolToken: map[string]blenddata.ReserveEmission{}} + got, err := d.projectedReserveEmissionIndex("POOL", 1, big.NewInt(1_000_000_000), 7) + require.NoError(t, err) + assert.Nil(t, got) + }) + + t.Run("backstop: projects over unqueued shares only", func(t *testing.T) { + eps := int64(1_000_000_000_000) + lastTime := int64(1_500_000_000) + expiration := int64(1_600_000_000) + emisIndex := "5000" + bp := blenddata.BackstopPool{ + Shares: "3000000000", + Q4W: "1000000000", + EmisEps: &eps, + EmisIndex: &emisIndex, + EmisExpiration: &expiration, + EmisLastTime: &lastTime, + } + // unqueued = 3e9-1e9 = 2e9 shares; Δt=100, scalar=1e7: + // additional = 100*1e12*1e7/2e9 = 5e11. + got, err := projectedBackstopEmissionIndex(bp, big.NewInt(3_000_000_000), 1_500_000_100) + require.NoError(t, err) + assert.Equal(t, "500000005000", got.String()) + }) + + t.Run("backstop: no emission config is nil", func(t *testing.T) { + got, err := projectedBackstopEmissionIndex(blenddata.BackstopPool{Shares: "3000000000", Q4W: "0"}, big.NewInt(3_000_000_000), 1_500_000_100) + require.NoError(t, err) + assert.Nil(t, got) + }) +} + // TestAccountResolver_BlendPositions is a hand-computed vector exercising the // full Account.blendPositions assembly: two reserves in one pool (a 7-decimal // supply-only reserve r0, a 6-decimal debt-only reserve r1), one active and @@ -112,7 +171,7 @@ func TestAccountResolver_BlendPositions(t *testing.T) { StateChanges: &data.StateChangeModel{DB: testDBConnectionPool, Metrics: dbMetrics}, Blend: blendModels, } - resolver := &accountResolver{&Resolver{models: models}} + resolver := &accountResolver{&Resolver{models: models, blendBackstopLPContractID: cometAddr}} // --- contract_tokens metadata (name/symbol only; decimals authority is blend_reserves) --- execTestDB(t, ` @@ -152,11 +211,18 @@ func TestAccountResolver_BlendPositions(t *testing.T) { types.AddressBytea(poolAddr), types.AddressBytea(account)) // --- reserve emissions: r0's bToken (index*2+1=1) active, r1's dToken (index*2=2) expired --- + // last_time pins ProjectEmissionIndex to its documented no-op branches, + // mirroring the reserves' futureLastTime trick: the active stream's + // last_time is in year 2100 (lastTime >= now), the expired stream's sits + // exactly at its expiration (lastTime >= expiration). Emission indexes + // stay exactly the inserted values, hand-computable with no wall-clock + // dependence; projection math itself is covered by + // TestProjectedEmissionIndexHelpers and rates_test.go. execTestDB(t, ` INSERT INTO blend_reserve_emissions (pool_contract_id, reserve_token_id, eps, emission_index, expiration, last_time, last_modified_ledger) VALUES - ($1, 1, 100000000000, '2000000000000', $2, 100, 100), - ($1, 2, 999999999999, '3000000000000', $3, 100, 100)`, + ($1, 1, 100000000000, '2000000000000', $2, $2, 100), + ($1, 2, 999999999999, '3000000000000', $3, $3, 100)`, types.AddressBytea(poolAddr), futureExpiration, pastExpiration) // --- user emission accrual: r0 bToken stream (token_id=1), r1 dToken stream (token_id=2) --- @@ -177,9 +243,11 @@ func TestAccountResolver_BlendPositions(t *testing.T) { INSERT INTO blend_backstop_positions (pool_contract_id, user_account_id, shares, q4w, last_modified_ledger) VALUES ($1, $2, '1000000', $3::jsonb, 100)`, types.AddressBytea(poolAddr), types.AddressBytea(account), string(q4wJSON)) + // emis_last_time in year 2100 pins ProjectEmissionIndex's lastTime>=now + // no-op branch, same as the reserve emission rows above. execTestDB(t, ` INSERT INTO blend_backstop_pools (pool_contract_id, shares, tokens, q4w, emis_eps, emis_index, emis_expiration, emis_last_time, last_modified_ledger) - VALUES ($1, '10000000', '50000000', '0', 999999999999, '5000000000000', 4102444800, 100, 100)`, + VALUES ($1, '10000000', '50000000', '0', 999999999999, '5000000000000', 4102444800, 4102444800, 100)`, types.AddressBytea(poolAddr)) // backstop emission stream: token_id = BackstopEmissionTokenID (-1), source = pool execTestDB(t, ` @@ -260,8 +328,12 @@ func TestAccountResolver_BlendPositions(t *testing.T) { require.NotNil(t, r0.BorrowApy) assert.InDelta(t, 0.020200781032923, *r0.BorrowApy, 1e-12) - require.NotNil(t, r0.EmissionsApr) - assert.InDelta(t, 63.072, *r0.EmissionsApr, 1e-9) + // Supply and borrow emission streams are independent: r0's bToken stream + // (token_id=1) is active, its dToken stream (token_id=0) is unconfigured. + require.NotNil(t, r0.EmissionsSupplyApr) + assert.InDelta(t, 63.072, *r0.EmissionsSupplyApr, 1e-9) + require.NotNil(t, r0.EmissionsBorrowApr) + assert.InDelta(t, 0.0, *r0.EmissionsBorrowApr, 1e-12) require.NotNil(t, r0.PriceUsd) assert.InDelta(t, 2.5, *r0.PriceUsd, 1e-12) @@ -296,9 +368,13 @@ func TestAccountResolver_BlendPositions(t *testing.T) { require.NotNil(t, r1.BorrowApy) assert.InDelta(t, 0.022754324920237545, *r1.BorrowApy, 1e-12) - // dToken emission config is EXPIRED (pastExpiration < now) -> a concrete 0, not nil. - require.NotNil(t, r1.EmissionsApr) - assert.InDelta(t, 0.0, *r1.EmissionsApr, 1e-12) + // r1's dToken emission config (token_id=2) is EXPIRED (pastExpiration < + // now) -> a concrete 0, not nil; its bToken stream (token_id=3) is + // unconfigured -> also a concrete 0. + require.NotNil(t, r1.EmissionsBorrowApr) + assert.InDelta(t, 0.0, *r1.EmissionsBorrowApr, 1e-12) + require.NotNil(t, r1.EmissionsSupplyApr) + assert.InDelta(t, 0.0, *r1.EmissionsSupplyApr, 1e-12) require.NotNil(t, r1.PriceUsd) assert.InDelta(t, 1.0, *r1.PriceUsd, 1e-12) @@ -322,8 +398,12 @@ func TestAccountResolver_BlendPositions(t *testing.T) { assert.InDelta(t, 6.0, *pool.BorrowedUsd, 1e-9) require.NotNil(t, pool.UsdValue) assert.InDelta(t, -4.0, *pool.UsdValue, 1e-9) + // netApy = (suppliedUsd·supplyApy − borrowedUsd·borrowApy) / suppliedUsd + // = (2.0·0.006420127418405475 − 6.0·0.022754324920237545) / 2.0 + // = −0.12368569468461432 / 2.0 — divided by TOTAL SUPPLIED, not the + // net value, matching blend-sdk-js's PositionsEstimate. require.NotNil(t, pool.NetApy) - assert.InDelta(t, 0.03092142367115358, *pool.NetApy, 1e-9) + assert.InDelta(t, -0.06184284734230716, *pool.NetApy, 1e-9) }) t.Run("backstop position", func(t *testing.T) { @@ -402,10 +482,11 @@ func TestAccountResolver_BlendPositions(t *testing.T) { }) } -// TestFindBackstopPrices pins findBackstopPrices' selection contract: -// (nil, nil) without a complete (self-priced LP, sibling BLND) group, and a -// deterministic lowest-oracle-key pick if more than one complete group ever -// appears (a config-error state — see the function's doc). +// TestFindBackstopPrices pins findBackstopPrices' selection contract over rows +// already scoped to the pinned Comet oracle (see the function's doc): (nil, +// nil) unless both a self-priced LP row and a sibling BLND row are present, and +// a deterministic lowest-sibling-asset pick if an extra sibling ever appears (a +// config-error state). func TestFindBackstopPrices(t *testing.T) { mk := func(oracle, asset string) blenddata.OraclePrice { return blenddata.OraclePrice{ @@ -426,6 +507,12 @@ func TestFindBackstopPrices(t *testing.T) { assert.Nil(t, blnd) }) + t.Run("sibling row without a self-priced LP row is incomplete", func(t *testing.T) { + lp, blnd := findBackstopPrices(testCtx, []blenddata.OraclePrice{mk("COMET", "BLND")}) + assert.Nil(t, lp) + assert.Nil(t, blnd) + }) + t.Run("one complete group", func(t *testing.T) { lp, blnd := findBackstopPrices(testCtx, []blenddata.OraclePrice{mk("COMET", "COMET"), mk("COMET", "BLND")}) require.NotNil(t, lp) @@ -434,18 +521,17 @@ func TestFindBackstopPrices(t *testing.T) { assert.EqualValues(t, "BLND", blnd.AssetContractID) }) - t.Run("two complete groups pick the lowest oracle key, deterministically", func(t *testing.T) { + t.Run("extra sibling picks the lowest asset address, deterministically", func(t *testing.T) { + // The query orders rows by asset, so an ambiguous extra sibling resolves + // to the lowest asset address. Rows are passed pre-ordered here. rows := []blenddata.OraclePrice{ - mk("COMET_B", "COMET_B"), mk("COMET_B", "BLND_B"), - mk("COMET_A", "COMET_A"), mk("COMET_A", "BLND_A"), + mk("COMET", "COMET"), mk("COMET", "BLND_A"), mk("COMET", "BLND_B"), } - // Repeat to flush out Go map-iteration randomness: every call must - // land on the same group. for range 50 { lp, blnd := findBackstopPrices(testCtx, rows) require.NotNil(t, lp) require.NotNil(t, blnd) - assert.EqualValues(t, "COMET_A", lp.OracleContractID) + assert.EqualValues(t, "COMET", lp.AssetContractID) assert.EqualValues(t, "BLND_A", blnd.AssetContractID) } }) diff --git a/internal/serve/graphql/resolvers/queries.resolvers.go b/internal/serve/graphql/resolvers/queries.resolvers.go index 9f9f46183..9492d8257 100644 --- a/internal/serve/graphql/resolvers/queries.resolvers.go +++ b/internal/serve/graphql/resolvers/queries.resolvers.go @@ -154,6 +154,16 @@ func (r *queryResolver) StateChanges(ctx context.Context, first *int32, after *s }, nil } +// BlendPools is the resolver for the blendPools field. +func (r *queryResolver) BlendPools(ctx context.Context) ([]*graphql1.BlendPool, error) { + return r.getBlendPools(ctx) +} + +// BlendPool is the resolver for the blendPool field. +func (r *queryResolver) BlendPool(ctx context.Context, address string) (*graphql1.BlendPool, error) { + return r.getBlendPool(ctx, address) +} + // Query returns graphql1.QueryResolver implementation. func (r *Resolver) Query() graphql1.QueryResolver { return &queryResolver{r} } diff --git a/internal/serve/graphql/resolvers/resolver.go b/internal/serve/graphql/resolvers/resolver.go index 7ea29efa2..8b60039c1 100644 --- a/internal/serve/graphql/resolvers/resolver.go +++ b/internal/serve/graphql/resolvers/resolver.go @@ -55,16 +55,24 @@ type Resolver struct { balanceReader BalanceReader metrics *metrics.Metrics config ResolverConfig + // blendBackstopLPContractID is the configured Comet BLND:USDC pool contract + // (BLEND_BACKSTOP_LP_CONTRACT_ID) — the same pin the ingest-side price + // snapshot writer targets. The Blend read waves pass it to + // OraclePrices.GetBackstopLPPrices so the protocol-wide backstop LP/BLND + // prices are read from exactly that oracle. Empty disables the leg (backstop + // USD fields resolve to null). + blendBackstopLPContractID string } // NewResolver creates a new resolver instance with required dependencies. -func NewResolver(models *data.Models, rpcService services.RPCService, balanceReader BalanceReader, m *metrics.Metrics, config ResolverConfig) *Resolver { +func NewResolver(models *data.Models, rpcService services.RPCService, balanceReader BalanceReader, m *metrics.Metrics, config ResolverConfig, blendBackstopLPContractID string) *Resolver { return &Resolver{ - models: models, - rpcService: rpcService, - balanceReader: balanceReader, - metrics: m, - config: config, + models: models, + rpcService: rpcService, + balanceReader: balanceReader, + metrics: m, + config: config, + blendBackstopLPContractID: blendBackstopLPContractID, } } diff --git a/internal/serve/graphql/schema/account.graphqls b/internal/serve/graphql/schema/account.graphqls index 2734a50c1..fc40910c6 100644 --- a/internal/serve/graphql/schema/account.graphqls +++ b/internal/serve/graphql/schema/account.graphqls @@ -39,4 +39,7 @@ type Account{ # expiration_ledger is below the latest ingested ledger are filtered out server-side. # Relay-paginated with a max page size of 100. sep41Allowances(first: Int, after: String, last: Int, before: String): SEP41AllowanceConnection! @goField(forceResolver: true) + + """An account's Blend v2 lending, collateral, borrowing, and backstop positions.""" + blendPositions: BlendAccountPositions! @goField(forceResolver: true) } diff --git a/internal/serve/graphql/schema/blend.graphqls b/internal/serve/graphql/schema/blend.graphqls index 6723698b8..cd72e830a 100644 --- a/internal/serve/graphql/schema/blend.graphqls +++ b/internal/serve/graphql/schema/blend.graphqls @@ -1,11 +1,13 @@ # Blend v2 lending protocol GraphQL surface. # -# Query.blendPools/blendPool are pool-wide catalog views, independent of any -# account. Query.blendEarnOptions is a "where can I earn this asset" catalog -# view. Account.blendPositions is an account's lending, collateral, and -# backstop positions across every Blend v2 pool it touches, plus any active -# Dutch auctions where the account is the auction owner -# (BlendAccountPositions.activeAuctions). +# The Query.blendPools/blendPool entry points (a pool-wide catalog view, +# independent of any account) live on the base type Query in queries.graphqls, +# and Account.blendPositions (an account's lending, collateral, and backstop +# positions across every Blend v2 pool it touches, plus any active Dutch +# auctions where the account is the auction owner — +# BlendAccountPositions.activeAuctions) lives on type Account in +# account.graphqls. This file defines every Blend v2 object/enum type those +# fields resolve to. # # Money-shaped fields use Float for USD/APY values (nullable wherever a missing # oracle price makes the value uncomputable — a genuinely zero balance is still @@ -13,17 +15,6 @@ # rather than lossy float64. A stored price older than 24h counts as missing: # the pool contract itself refuses prices past that age. -extend type Query { - blendPools: [BlendPool!]! - blendPool(address: String!): BlendPool - blendEarnOptions: [BlendEarnOption!]! -} - -extend type Account { - """An account's Blend v2 lending, collateral, borrowing, and backstop positions.""" - blendPositions: BlendAccountPositions! @goField(forceResolver: true) -} - """ BlendAccountPositions aggregates one account's Blend v2 exposure across every pool it has touched. backstopClaimedLp is lifetime backstop-emission claims in @@ -52,6 +43,13 @@ type BlendPoolPosition { usdValue: Float suppliedUsd: Float borrowedUsd: Float + """ + Supply-vs-borrow interest netting over TOTAL SUPPLIED USD — the blend-sdk-js + PositionsEstimate convention shown by the Blend UI: + (Σ suppliedUsd·supplyApy − Σ borrowedUsd·borrowApy) / Σ suppliedUsd. + 0 for a position with debt but no supply (bad debt is forgiven). Null when + any contributing reserve is missing a fresh oracle price. + """ netApy: Float """Lifetime BLND this account has claimed from this pool's reserve emissions.""" claimedBlnd: String! @@ -78,8 +76,44 @@ type BlendReservePosition { borrowedUsd: Float supplyApy: Float borrowApy: Float - emissionsApr: Float + """ + The reserve's POOL-WIDE bToken (supply) emission-stream APR: annualized BLND + value over the side's pool-wide supplied USD, NOT scaled to this account's + holding. 0 when no active stream (unconfigured or expired), null when the + stream is active but the reserve or BLND price is unavailable. + """ + emissionsSupplyApr: Float + """ + The reserve's POOL-WIDE dToken (borrow) emission-stream APR: annualized BLND + value over the side's pool-wide borrowed USD, NOT scaled to this account's + holding. 0 when no active stream (unconfigured or expired), null when the + stream is active but the reserve or BLND price is unavailable. + """ + emissionsBorrowApr: Float + """ + Lifetime interest earned on the supply side of this reserve: the current + underlying value of the account's supply+collateral bTokens (at projected, + as-of-now rates) minus the net principal it contributed — a cost basis + tracked across the account's whole deposit/withdraw history. Token-denominated: + a raw integer at the reserve asset's decimals (tokenDecimals), so multiply by + priceUsd for a USD value. A liquidation reduces the cost basis by the seized + collateral's underlying value at the reserve's rate when the fill is + processed, so collateral lost to a fill cancels out instead of being reported + as earned interest. Survives a full exit: a position zeroed to no tokens still + reports the earnings realized up to the exit (current value 0 minus the + negative leftover cost basis). May be slightly negative from cost-basis dust + truncation or bRate movement after an exit. + """ interestEarned: String! + """ + Lifetime interest paid on the debt side of this reserve, mirroring + interestEarned: the current underlying value of the account's liability + dTokens (at projected, as-of-now rates) minus its net borrowed principal + (cost basis tracked from borrow/repay history). Token-denominated (raw + integer at tokenDecimals; use priceUsd for USD), lifetime, and survives a + full exit the same way. May be slightly negative for the same dust/rate + reasons. + """ interestPaid: String! emissionsEarnedBlnd: String! emissionsEarnedUsd: Float @@ -88,13 +122,17 @@ type BlendReservePosition { } """ -BlendBackstopPosition is an account's backstop deposit in one pool. shares -is the ACTIVE (non-queued) backstop share balance; lpTokens/usdValue value -the whole deposit — active plus queued-for-withdrawal shares — since queued -shares keep earning pool interest and remain slashable first-loss capital -until actually withdrawn. emissionsEarnedBlnd is CLAIMABLE (uncollected) -BLND accrued on the backstop emission stream for this pool; it accrues on -active shares only (queued shares earn no emissions). +BlendBackstopPosition is an account's backstop deposit in one pool. The +backstop is a single protocol-wide contract holding each pool's first-loss +capital separately, so exposure is inherently per-pool (poolAddress) even +though the backstop is not the pool contract itself — one +BlendBackstopPosition per backed pool. shares is the ACTIVE (non-queued) +backstop share balance; lpTokens/usdValue value the whole deposit — active +plus queued-for-withdrawal shares — since queued shares keep earning pool +interest and remain slashable first-loss capital until actually withdrawn. +emissionsEarnedBlnd is CLAIMABLE (uncollected) BLND accrued on the backstop +emission stream for this pool; it accrues on active shares only (queued +shares earn no emissions). """ type BlendBackstopPosition { poolAddress: String! @@ -134,12 +172,12 @@ type BlendPool { address: String! name: String """ - Raw on-chain pool status: 0 Admin Active, 1 Active, 2 Admin On-Ice, - 3 On-Ice, 4 Admin Frozen, 5 Frozen, 6 Setup. Statuses 0-3 accept supply - (deposits); 0-1 also allow borrowing; 4-6 reject both. Null until the - pool's config entry has been ingested. + Pool status. Statuses ADMIN_ACTIVE/ACTIVE/ADMIN_ON_ICE/ON_ICE accept supply + (deposits); ADMIN_ACTIVE/ACTIVE also allow borrowing; ADMIN_FROZEN/FROZEN/ + SETUP reject both. Null until the pool's config entry has been ingested, and + also null for an unrecognized on-chain status value. """ - status: Int + status: BlendPoolStatus oracleContractId: String """ Share of borrower interest routed to the pool's backstop, as 7-decimal @@ -163,6 +201,22 @@ type BlendPool { inRewardZone: Boolean! } +""" +BlendPoolStatus is a pool's operational status. On-chain encoding: +0 ADMIN_ACTIVE, 1 ACTIVE, 2 ADMIN_ON_ICE, 3 ON_ICE, 4 ADMIN_FROZEN, +5 FROZEN, 6 SETUP. Statuses 0-3 accept supply (deposits); 0-1 also allow +borrowing; 4-6 reject both. +""" +enum BlendPoolStatus { + ADMIN_ACTIVE + ACTIVE + ADMIN_ON_ICE + ON_ICE + ADMIN_FROZEN + FROZEN + SETUP +} + enum BlendAuctionType { USER_LIQUIDATION BAD_DEBT @@ -220,32 +274,3 @@ type BlendReserve { lFactor: Int priceUsd: Float } - -""" -BlendEarnOption is a "where can I earn this asset" catalog view: one entry -per asset with at least one enabled reserve in a pool that currently accepts -supply (status 0-3). Frozen (4-5) and Setup (6) pools reject deposits -on-chain, so their reserves are never earn options. -""" -type BlendEarnOption { - assetContractId: String! - tokenName: String - tokenSymbol: String - tokenDecimals: Int - pools: [BlendEarnPoolOption!]! -} - -""" -BlendEarnPoolOption is one pool's offer for a BlendEarnOption's asset. -supplyApy is the interest-only yield; emissionsSupplyApr is the reserve's -BLND-emission yield on the supply side (0 when no active stream, null when -the BLND price is unavailable) — supplyApy + emissionsSupplyApr is the -emissions-inclusive rate a wallet shows as the earn headline. -""" -type BlendEarnPoolOption { - poolAddress: String! - poolName: String - supplyApy: Float - emissionsSupplyApr: Float - suppliedUsd: Float -} diff --git a/internal/serve/graphql/schema/queries.graphqls b/internal/serve/graphql/schema/queries.graphqls index dd58bcefb..8df0c5689 100644 --- a/internal/serve/graphql/schema/queries.graphqls +++ b/internal/serve/graphql/schema/queries.graphqls @@ -7,4 +7,8 @@ type Query { operations(first: Int, after: String, last: Int, before: String): OperationConnection operationById(id: Int64!): Operation stateChanges(first: Int, after: String, last: Int, before: String): StateChangeConnection + + # Blend v2 lending - pool-wide catalog views, independent of any account + blendPools: [BlendPool!]! + blendPool(address: String!): BlendPool } diff --git a/internal/serve/graphql/schema/statechange.graphqls b/internal/serve/graphql/schema/statechange.graphqls index f6c0d2c6a..8b3be8aa8 100644 --- a/internal/serve/graphql/schema/statechange.graphqls +++ b/internal/serve/graphql/schema/statechange.graphqls @@ -150,6 +150,41 @@ type BalanceAuthorizationChange implements BaseStateChange{ flags: [String!]! } +# LendingChange is a Blend v2 lending state change. Its reason (a LENDING-category +# StateChangeReason) determines how tokenId/amount/poolId are populated: +# +# Reason tokenId amount poolId +# SUPPLY reserve asset underlying supplied emitting pool +# SUPPLY_COLLATERAL reserve asset underlying supplied emitting pool +# WITHDRAW reserve asset underlying withdrawn emitting pool +# WITHDRAW_COLLATERAL reserve asset underlying withdrawn emitting pool +# BORROW reserve asset underlying borrowed emitting pool +# REPAY reserve asset underlying repaid emitting pool +# FLASH_LOAN reserve asset underlying loaned emitting pool +# BAD_DEBT reserve asset dTokens socialized emitting pool +# DEFAULTED_DEBT reserve asset dTokens burnt emitting pool +# CLAIM (pool) BLND SAC (see below) BLND claimed emitting pool +# CLAIM (backstop) null Comet LP minted null (see below) +# LIQUIDATION null null (see below) pool the auction is on +# BACKSTOP_DEPOSIT null LP tokens deposited target pool +# BACKSTOP_WITHDRAW_QUEUE null backstop shares queued target pool +# BACKSTOP_WITHDRAW_CANCEL null backstop shares dequeued target pool +# BACKSTOP_WITHDRAW null LP tokens returned target pool +# +# tokenId: a reserve-asset C-address for every pool-reserve movement; the BLND +# SAC C-address for a pool CLAIM (null when the network's BLND address is +# unknown, so the claim is never misattributed). Always null for LIQUIDATION +# (a fill touches many assets — see amount) and for every backstop-share row +# (backstop CLAIM/DEPOSIT/WITHDRAW_QUEUE/WITHDRAW_CANCEL/WITHDRAW), whose value +# is denominated in backstop shares or Comet LP tokens, neither of which has a +# reserve-asset token id. +# amount: a raw on-chain i128 integer at the token's own native decimals (NOT a +# USD value). Null only for LIQUIDATION, whose per-asset lot/bid amounts are +# carried in the row's key-value extras instead of this single field. +# poolId: the pool contract C-address, from the emitting pool for pool events and +# from an event topic for the per-pool backstop events. Null only for a backstop +# CLAIM, whose on-chain event aggregates across every pool claimed and carries +# no pool address at all. type LendingChange implements BaseStateChange { type: StateChangeCategory! @goField(forceResolver: true) reason: StateChangeReason! @goField(forceResolver: true) diff --git a/internal/serve/serve.go b/internal/serve/serve.go index ad15c3b6a..50bc5a1ad 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -58,6 +58,14 @@ type Configs struct { // GraphQL GraphQLComplexityLimit int + // BlendBackstopLPContractID is the C-address of the Blend v2 backstop's Comet + // BLND:USDC weighted pool (BLEND_BACKSTOP_LP_CONTRACT_ID) — the same pin the + // ingest-side price snapshot writer targets. It scopes the backstop LP/BLND + // price read so a permissionless pool's coincidentally self-priced group can + // never be mistaken for the protocol-wide backstop prices. Empty disables the + // leg (backstop USD fields resolve to null). + BlendBackstopLPContractID string + // Error Tracker AppTracker apptracker.AppTracker @@ -107,7 +115,8 @@ type handlerDeps struct { SEP41AllowanceModel sep41data.AllowanceModelInterface // GraphQL - GraphQLComplexityLimit int + GraphQLComplexityLimit int + BlendBackstopLPContractID string // Error Tracker AppTracker apptracker.AppTracker @@ -203,6 +212,7 @@ func initHandlerDeps(ctx context.Context, cfg Configs) (handlerDeps, error) { AppTracker: cfg.AppTracker, NetworkPassphrase: cfg.NetworkPassphrase, GraphQLComplexityLimit: cfg.GraphQLComplexityLimit, + BlendBackstopLPContractID: cfg.BlendBackstopLPContractID, }, nil } @@ -238,6 +248,7 @@ func handler(deps handlerDeps) http.Handler { resolvers.NewBalanceReader(deps.TrustlineBalanceModel, deps.NativeBalanceModel, deps.SACBalanceModel, deps.LiquidityPoolBalanceModel, deps.SEP41BalanceModel, deps.SEP41AllowanceModel), deps.Metrics, resolvers.ResolverConfig{}, + deps.BlendBackstopLPContractID, ) config := generated.Config{ @@ -368,33 +379,41 @@ func addComplexityCalculation(config *generated.Config) { config.Complexity.Transaction.Accounts = accountsListComplexityFunc config.Complexity.Operation.Accounts = accountsListComplexityFunc - // Blend fields are unpaginated but bounded by on-chain limits rather than client - // first/last args: Query.blendPools/blendEarnOptions fan out over a bounded catalog - // (pools ≈ dozens; each pool ≤ MAX_RESERVES=30 reserves on-chain), the same - // "unpaginated but bounded" shape as the accounts fan-out above, so they reuse - // accountsListComplexityFunc's ×50 multiplier. Account.blendPositions is bounded - // per-pool by that pool's max_positions (well under a full page), so it gets a - // smaller ×10 multiplier. - // - // Worst case (every field selected, derived from the actual schema + the complexity - // math above: default field complexity = 1 + sum(child complexities)): - // blendPools: BlendPool = 11 scalars + reserves(1 + 17 BlendReserve scalars = 18) - // => childComplexity 29 => 29*50 = 1450 - // blendEarnOptions: BlendEarnOption = 4 scalars + pools(1 + 4 BlendEarnPoolOption - // scalars = 5) => childComplexity 9 => 9*50 = 450 - // blendPositions: BlendAccountPositions = pools(1 + [7 scalars + reserves(1+16=17)] - // = 25) + backstop(1 + [7 scalars + q4w(1+2=3)] = 11) - // + backstopClaimedLp(1) => childComplexity 37 => 37*10 = 370 - // All comfortably under GRAPHQL_COMPLEXITY_LIMIT=6000. None of this touches - // AccountTransactionEdge.operations/stateChanges or any other existing entry above — - // those stay exactly as budgeted for the freighter full-detail query. + // Blend lists are unpaginated but bounded, so each list level carries its own + // cardinality multiplier — nested lists are NOT free-riding under a single outer + // multiplier; a full blendPools selection is priced as pools × reserves-per-pool. + // Bounds are on-chain constants where the contract has one (blend-contracts-v2 @ + // ba22b487: pool MAX_RESERVES=30, backstop MAX_Q4W_SIZE=20; an auction's bid/lot + // asset maps are keyed by the pool's reserve tokens, so ≤ 30ish entries) and + // documented assumptions where it doesn't (catalog ≈ dozens of pools → 50, matching + // the accounts fan-out above; pools/backstop-deposits/open-auctions per ACCOUNT have + // no on-chain cap → priced at 10, a deliberate pricing assumption, not a runtime + // truncation — the resolver always returns every row). + blendReservesBound := func(childComplexity int) int { return childComplexity * 30 } + config.Complexity.BlendPool.Reserves = blendReservesBound + config.Complexity.BlendPoolPosition.Reserves = blendReservesBound + config.Complexity.BlendBackstopPosition.Q4w = func(childComplexity int) int { return childComplexity * 20 } + config.Complexity.BlendAuction.Bid = blendReservesBound + config.Complexity.BlendAuction.Lot = blendReservesBound + blendPerAccountBound := func(childComplexity int) int { return childComplexity * 10 } + config.Complexity.BlendAccountPositions.Pools = blendPerAccountBound + config.Complexity.BlendAccountPositions.Backstop = blendPerAccountBound + config.Complexity.BlendAccountPositions.ActiveAuctions = blendPerAccountBound config.Complexity.Query.BlendPools = accountsListComplexityFunc - config.Complexity.Query.BlendEarnOptions = accountsListComplexityFunc - // Query.blendPool (single pool by address) is left at the default (1 + childComplexity): - // same treatment as the other single-item lookups (accountByAddress, operationByID, - // transactionByHash) below, which also have no custom entry. It returns one object, not - // a list, so no page-size-style multiplier is needed. - config.Complexity.Account.BlendPositions = func(childComplexity int) int { - return childComplexity * 10 - } + // Query.blendPool (single pool by address) and Account.blendPositions (one object) + // stay at the default (1 + childComplexity): the cardinality now lives on the list + // fields inside them, so an outer multiplier would double-charge. + // + // Worst case with every field selected (complexity_test.go locks the multipliers; + // counts follow the schema: BlendReserve 17 scalars, BlendPool 13 scalars, + // BlendReservePosition 18, BlendPoolPosition 7, BlendBackstopPosition 7, BlendQ4W 4, + // BlendAuction 3 + bid/lot(2 each)): + // blendPools: 50 × (13 + 30×17) = 50 × 523 = 26,150 + // blendPositions: 1 + 10×(7 + 30×18) + 10×(7 + 20×4) + 10×(3 + 30×2 + 30×2) + 1 + // = 1 + 5,470 + 870 + 1,230 + 1 = 7,572 + // Both exceed a 6,000 complexity limit for the FULL selection — deliberate: the + // limit must be raised deployment-side to admit them (see the PR notes), or clients + // select fewer fields. None of this touches AccountTransactionEdge.operations/ + // stateChanges or any other existing entry above — those stay exactly as budgeted + // for the freighter full-detail query. } diff --git a/internal/services/blend/rates.go b/internal/services/blend/rates.go index 3eaa523bd..08ec381f4 100644 --- a/internal/services/blend/rates.go +++ b/internal/services/blend/rates.go @@ -27,14 +27,15 @@ // argument instead of hardcoding 52; BorrowAPYCompoundingPeriods and // SupplyAPYCompoundingPeriods document which to pass. // - Rate projection: Reserve.accrue applies simple (non-compounding) -// per-call growth: new_dRate = dRate*(1+borrowApr*Δt/year). new_bRate's -// growth is driven by the same liabilities/supply split as util, so it -// reduces algebraically to bRate*(1+supplyApr*Δt/year) (supplyApr -// already folds in util and bstopRate — see SupplyAPR) without needing -// bSupply/dSupply at all. Verified by hand and against a real mainnet -// reserve — see rates_test.go. This drops the SDK's separate ir_mod -// reactivity update (ir_mod is an input here, not itself projected) — -// out of scope for a display/estimate helper, not a settlement path. +// per-call growth: new_dRate = dRate*(1+borrowApr*Δt/year); new_bRate +// grows by the accrued interest's supplier share over total supply, +// which reduces algebraically to bRate*(1+borrowApr*rawUtil* +// (1-bstopRate)*Δt/year) with rawUtil the UNCLAMPED liabilities/supply +// ratio — see ProjectRates for why the clamp applies only to the curve, +// not the accrual split. Verified by hand and against a real mainnet +// reserve — see rates_test.go. ir_mod is an input here, not itself +// projected: the contract, too, applies the stored ir_mod across the +// whole elapsed window and re-derives it only for the next one. // - Emissions: eps (ReserveEmissionData/BackstopEmissionData) is // uniformly SCALAR_14 (1e14) fixed-point "BLND per second" for BOTH // backstop and pool-reserve emissions (manager.rs: "Eps is scaled by 14 @@ -109,6 +110,12 @@ func ratFromFixed7(v int32) *big.Rat { // underlying supply currently borrowed — as (dSupply*dRate)/(bSupply* // bRate). dRate and bRate share the same fixed-point scale (SCALAR_12 for // v2), so that scale cancels in the ratio and needs no explicit descaling. +// The contract's own path descales the two sides first with asymmetric +// rounding — ceil on liabilities, floor on supply (reserve.rs's +// to_asset_from_d_token / to_asset_from_b_token) — and then ceils the +// resulting 7-decimal ratio, whereas this function keeps the exact rational. +// Go's result is therefore always <= the contract's, by less than 1e-7 for +// any funded reserve. // // Branch order mirrors reserve.rs's utilization exactly: zero liabilities // is 0, and liabilities >= supply clamps to exactly 1 ("This is capped at @@ -217,28 +224,45 @@ func roundRat(r *big.Rat) *big.Int { } // ProjectRates projects bRate/dRate forward from lastTime to now given the -// reserve's current borrowAPR and utilization. The real Reserve.accrue -// on-chain update also re-derives ir_mod from time-weighted reactivity and -// splits accrued interest against backstop_credit before dividing by -// bSupply to get the new bRate — but because bRate's growth rate is driven -// by the same liabilities/supply split as util, that reduces algebraically -// to bRate*(1+supplyAPR*Δt/year) (supplyAPR already folds in util and -// bstopRate — see SupplyAPR), letting this function avoid needing bSupply/ -// dSupply at all. Verified by hand and against a real mainnet reserve (see -// rates_test.go). Δt<=0 (now at or before lastTime — e.g. a stale -// projection request) returns bRate/dRate unchanged. Results are rounded -// to the nearest raw integer, ties away from zero. -func ProjectRates(bRate, dRate *big.Int, borrowAPR, util *big.Rat, bstopRate int32, lastTime, now int64) (pB, pD *big.Int) { +// reserve's current borrowAPR (derived from the CLAMPED utilization curve — +// see Utilization/BorrowAPR) and raw token supplies. It reproduces +// Reserve::load's accrual (pool/src/pool/reserve.rs) in exact rational form: +// +// pD = dRate·(1 + borrowAPR·Δt/year) +// pB = bRate·(1 + borrowAPR·rawUtil·(1−bstopRate)·Δt/year) +// +// where rawUtil is the UNCLAMPED liabilities/supply ratio +// (dSupply·dRate)/(bSupply·bRate). The contract grows bRate by +// accruedInterest·(1−bstopRate)/totalSupply, which reduces to exactly that — +// the clamp only shapes the curve's borrowAPR, not the accrual split, so in +// a bad-debt reserve (liabilities ≥ supply) rawUtil > 1 keeps bRate growing +// faster than a clamped ratio would (suppliers accrue the full borrower +// interest). The contract's on-load guards are mirrored exactly: Δt<=0, zero +// bSupply, or zero liabilities (utilization()==0, i.e. no borrowers) all +// return bRate/dRate unchanged — the contract bumps last_time and skips the +// rate update in those states. +// +// The real update also re-derives ir_mod from time-weighted reactivity, but +// only for the NEXT interval: calc_accrual applies the stored ir_mod across +// the whole elapsed window, which is exactly what holding ir_mod fixed here +// does. Results are rounded to the nearest raw integer, ties away from zero; +// the contract's fixed-point path (ceil on dRate accrual, floor on bRate) +// can differ from this exact-rational form by a few 1e-12 rate units over +// long idle windows — rates_test.go's mainnet vector pins the exact-rational +// value (the on-chain fixed-point result is 1 unit higher on pD). +func ProjectRates(bRate, dRate, bSupply, dSupply *big.Int, borrowAPR *big.Rat, bstopRate int32, lastTime, now int64) (pB, pD *big.Int) { deltaT := now - lastTime - if deltaT <= 0 { + liabilities := new(big.Int).Mul(dSupply, dRate) + supply := new(big.Int).Mul(bSupply, bRate) + if deltaT <= 0 || bSupply.Sign() == 0 || liabilities.Sign() == 0 || supply.Sign() == 0 { return new(big.Int).Set(bRate), new(big.Int).Set(dRate) } - supplyAPR := SupplyAPR(borrowAPR, util, bstopRate) elapsedFraction := big.NewRat(deltaT, SecondsPerYear) - growthD := new(big.Rat).Mul(borrowAPR, elapsedFraction) - growthB := new(big.Rat).Mul(supplyAPR, elapsedFraction) + rawUtil := new(big.Rat).SetFrac(liabilities, supply) + capture := new(big.Rat).Sub(big.NewRat(1, 1), ratFromFixed7(bstopRate)) + growthB := new(big.Rat).Mul(new(big.Rat).Mul(growthD, rawUtil), capture) newD := new(big.Rat).Mul(new(big.Rat).SetInt(dRate), new(big.Rat).Add(big.NewRat(1, 1), growthD)) newB := new(big.Rat).Mul(new(big.Rat).SetInt(bRate), new(big.Rat).Add(big.NewRat(1, 1), growthB)) @@ -274,6 +298,42 @@ func EmissionsAPR(eps int64, expiration, now int64, blndUSD, sideUSD *big.Rat) * return new(big.Rat).Quo(annualUSD, sideUSD) } +// ProjectEmissionIndex advances a stream's stored emission index from +// lastTime to now — the contract distributors' update_emission_data step +// (pool/src/emissions/distributor.rs, backstop/src/emissions/distributor.rs, +// mirrored by blend-sdk-js's Emissions.accrue), which every on-chain claim +// runs BEFORE applying a user's balance. A stored index is only as fresh as +// the stream's last on-chain touch, so claimable amounts computed against it +// alone under-report until someone else interacts; projecting closes that gap. +// +// index + floor(Δt·eps·scalar/supply), Δt = min(now, expiration) − lastTime +// +// supply is the total balance the stream emits to — a reserve's raw b/dToken +// supply, or the backstop's UNQUEUED shares (shares − q4w; queued shares earn +// no emissions). scalar is the supply's own fixed-point unit, 10^decimals for +// a pool reserve or 1e7 for backstop shares — NOT the claim divisor (that +// stays scalar·1e7, see ClaimableEmissions): the index carries BLND-per-unit +// at eps's 1e14 scale divided by 1e7, so advance-by-10^decimals and +// claim-by-10^decimals·1e7 cancel exactly to raw 7-decimal BLND. +// +// Matching the contract's guards, the index is returned unchanged (a copy) +// when the stream is already fully accrued or can't accrue: lastTime at or +// past expiration or now, zero eps, or zero/negative supply. +func ProjectEmissionIndex(index *big.Int, eps int64, lastTime, expiration, now int64, supply, scalar *big.Int) *big.Int { + out := new(big.Int).Set(index) + if lastTime >= expiration || lastTime >= now || eps == 0 || supply.Sign() <= 0 { + return out + } + t := now + if t > expiration { + t = expiration + } + additional := new(big.Int).Mul(big.NewInt(t-lastTime), big.NewInt(eps)) + additional.Mul(additional, scalar) + additional.Quo(additional, supply) + return out.Add(out, additional) +} + // ClaimableEmissions is the contract distributor's claim-conversion step // (pool/src/emissions/distributor.rs's update_user_emissions, // backstop/src/emissions/distributor.rs's mirror): accrued + @@ -281,11 +341,17 @@ func EmissionsAPR(eps int64, expiration, now int64, blndUSD, sideUSD *big.Rat) * // caller's job to supply correctly for the context — see package doc: pass // `10^ReserveConfig.decimals * 1e7` for a pool reserve emission (b/dToken), // or `1e7 * 1e7` (=1e14) for a backstop emission (LP shares are always -// 7-decimal). Assumes non-negative delta_index and tokenBalance, matching -// the contract's own require_nonnegative guard on delta_index — a negative -// scalar or a negative product is a caller bug, not handled here. +// 7-decimal). A negative index delta is clamped to 0 — on-chain it cannot +// happen (the distributor advances the stream index before any user write, +// and traps via require_nonnegative as defense-in-depth), so off-chain it +// would only reflect an ingestion gap between the stream row and the user +// row; clamping mirrors the contract's guard instead of surfacing a +// negative claimable. func ClaimableEmissions(accrued, userIndex, emisIndex, tokenBalance, scalar *big.Int) *big.Int { delta := new(big.Int).Sub(emisIndex, userIndex) + if delta.Sign() < 0 { + return new(big.Int).Set(accrued) + } toAccrue := new(big.Int).Mul(tokenBalance, delta) toAccrue.Quo(toAccrue, scalar) return new(big.Int).Add(accrued, toAccrue) diff --git a/internal/services/blend/rates_test.go b/internal/services/blend/rates_test.go index 0c9dd1775..d413a48f0 100644 --- a/internal/services/blend/rates_test.go +++ b/internal/services/blend/rates_test.go @@ -248,10 +248,12 @@ func TestToAPY(t *testing.T) { } func TestProjectRates(t *testing.T) { + rateOne := mustBigInt("1000000000000") // rate exactly 1.0 at SCALAR_12 + t.Run("zero elapsed returns unchanged rates", func(t *testing.T) { bRate := mustBigInt("1130417963074") dRate := mustBigInt("1206619920020") - pB, pD := ProjectRates(bRate, dRate, big.NewRat(1, 10), big.NewRat(1, 2), 2_000_000, 1000, 1000) + pB, pD := ProjectRates(bRate, dRate, big.NewInt(2), big.NewInt(1), big.NewRat(1, 10), 2_000_000, 1000, 1000) assert.Equal(t, bRate, pB) assert.Equal(t, dRate, pD) }) @@ -259,32 +261,60 @@ func TestProjectRates(t *testing.T) { t.Run("negative elapsed (stale request) returns unchanged rates", func(t *testing.T) { bRate := mustBigInt("1130417963074") dRate := mustBigInt("1206619920020") - pB, pD := ProjectRates(bRate, dRate, big.NewRat(1, 10), big.NewRat(1, 2), 2_000_000, 1000, 900) + pB, pD := ProjectRates(bRate, dRate, big.NewInt(2), big.NewInt(1), big.NewRat(1, 10), 2_000_000, 1000, 900) assert.Equal(t, bRate, pB) assert.Equal(t, dRate, pD) }) + t.Run("zero liabilities: contract's util==0 short-circuit, rates unchanged", func(t *testing.T) { + // Reserve::load bumps last_time and skips the rate update entirely + // when utilization()==0 (reserve.rs) — without this branch dRate + // would wrongly grow by irMod·rBase while nobody borrows. + pB, pD := ProjectRates(rateOne, rateOne, big.NewInt(1_000_000), big.NewInt(0), big.NewRat(1, 100), 2_000_000, 0, SecondsPerYear) + assert.Equal(t, rateOne, pB) + assert.Equal(t, rateOne, pD) + }) + + t.Run("zero bSupply: contract's on-load guard, rates unchanged", func(t *testing.T) { + pB, pD := ProjectRates(rateOne, rateOne, big.NewInt(0), big.NewInt(1_000_000), big.NewRat(1, 100), 2_000_000, 0, SecondsPerYear) + assert.Equal(t, rateOne, pB) + assert.Equal(t, rateOne, pD) + }) + t.Run("1 year at known APR: clean round-number growth", func(t *testing.T) { - // bRate=dRate=1e12 (rate 1.0), borrowAPR=0.1, util=0.5, bstop=0.2 -> - // supplyAPR = 0.1*0.5*0.8 = 0.04. Over exactly one year: + // bRate=dRate=1e12 (rate 1.0), borrowAPR=0.1, bstop=0.2; + // supplies give rawUtil = (1·1e12)/(2·1e12) = 0.5 -> + // growthB = 0.1*0.5*0.8 = 0.04. Over exactly one year: // newD = 1e12*(1+0.1) = 1.1e12; newB = 1e12*(1+0.04) = 1.04e12. - bRate := mustBigInt("1000000000000") - dRate := mustBigInt("1000000000000") - pB, pD := ProjectRates(bRate, dRate, big.NewRat(1, 10), big.NewRat(1, 2), 2_000_000, 0, SecondsPerYear) + pB, pD := ProjectRates(rateOne, rateOne, big.NewInt(2), big.NewInt(1), big.NewRat(1, 10), 2_000_000, 0, SecondsPerYear) assert.Equal(t, mustBigInt("1040000000000"), pB) assert.Equal(t, mustBigInt("1100000000000"), pD) }) + t.Run("bad debt (liabilities > supply): bRate grows by the UNCLAMPED ratio", func(t *testing.T) { + // rawUtil = 102/100 = 1.02 — past the clamp. The contract's accrual + // split still applies the raw liabilities/supply ratio to bRate + // (accrued·(1−bstop)/totalSupply), so with borrowAPR=0.5, bstop=0, + // 1 year: newD = 1.5e12; newB = 1e12·(1+0.5·1.02) = 1.51e12. + // A clamped ratio would understate newB at 1.5e12. + pB, pD := ProjectRates(rateOne, rateOne, big.NewInt(100), big.NewInt(102), big.NewRat(1, 2), 0, 0, SecondsPerYear) + assert.Equal(t, mustBigInt("1510000000000"), pB) + assert.Equal(t, mustBigInt("1500000000000"), pD) + }) + t.Run("real mainnet vector projected 6 seconds forward", func(t *testing.T) { - // borrowAPR/util as computed in TestBorrowAPR/TestUtilization's real - // vectors; lastTime=1783543369, now=1783543375 (Δt=6s, the gap - // observed between the `get_reserve` and `get_config` RPC calls made - // back-to-back while gathering this vector). Expected pB/pD computed - // independently in Python using exact fractions.Fraction arithmetic - // then rounded half-away-from-zero: pD=1206619947101, pB=1130417979435. + // borrowAPR as computed in TestBorrowAPR's real vector; rawUtil is + // derived internally from the same realB/DSupply·realB/DRate products + // TestUtilization checks. lastTime=1783543369, now=1783543375 (Δt=6s, + // the gap observed between the `get_reserve` and `get_config` RPC + // calls made back-to-back while gathering this vector). Expected + // pB/pD computed independently in Python using exact + // fractions.Fraction arithmetic on the SAME formula, then rounded + // half-away-from-zero: pD=1206619947101, pB=1130417979435. (The + // contract's own fixed-point ceil/floor path lands 1 unit higher on + // pD — this pins the exact-rational form, not the on-chain rounding.) borrowAPR := bigRatFromDecimalString(t, "59393658265844428560644300155817", "503481312112680364527162250000000") - util := bigRatFromDecimalString(t, "24350456626743911085123661", "30208878726760821871629735") - pB, pD := ProjectRates(realBRate, realDRate, borrowAPR, util, realBstop, 1783543369, 1783543375) + pB, pD := ProjectRates(realBRate, realDRate, realBSupply, realDSupply, borrowAPR, realBstop, 1783543369, 1783543375) assert.Equal(t, mustBigInt("1130417979435"), pB) assert.Equal(t, mustBigInt("1206619947101"), pD) }) @@ -346,6 +376,65 @@ func TestEmissionsAPR(t *testing.T) { }) } +func TestProjectEmissionIndex(t *testing.T) { + scalar7Big := big.NewInt(10_000_000) + + t.Run("lastTime at expiration: unchanged", func(t *testing.T) { + got := ProjectEmissionIndex(big.NewInt(12345), 1_000_000_000_000, 1600000000, 1600000000, 1700000000, big.NewInt(1_000_000_000), scalar7Big) + assert.Equal(t, big.NewInt(12345), got) + }) + + t.Run("lastTime at or past now: unchanged", func(t *testing.T) { + got := ProjectEmissionIndex(big.NewInt(12345), 1_000_000_000_000, 1500000000, 1600000000, 1500000000, big.NewInt(1_000_000_000), scalar7Big) + assert.Equal(t, big.NewInt(12345), got) + }) + + t.Run("zero eps: unchanged", func(t *testing.T) { + got := ProjectEmissionIndex(big.NewInt(12345), 0, 1500000000, 1600000000, 1500000005, big.NewInt(1_000_000_000), scalar7Big) + assert.Equal(t, big.NewInt(12345), got) + }) + + t.Run("zero supply: unchanged (all backstop shares queued)", func(t *testing.T) { + got := ProjectEmissionIndex(big.NewInt(12345), 1_000_000_000_000, 1500000000, 1600000000, 1500000005, big.NewInt(0), scalar7Big) + assert.Equal(t, big.NewInt(12345), got) + }) + + t.Run("result is a copy, input index untouched", func(t *testing.T) { + index := big.NewInt(12345) + got := ProjectEmissionIndex(index, 1_000_000_000_000, 1500000000, 1600000000, 1500000005, big.NewInt(1_000_000_000), scalar7Big) + assert.NotEqual(t, index, got) + assert.Equal(t, big.NewInt(12345), index) + }) + + t.Run("real contract-test vector: projection clamps at expiration (test_update_emission_data_past_exp)", func(t *testing.T) { + // blend-contracts-v2 pool/src/emissions/distributor.rs: now=1700000000 + // is past expiration=1600000001, so Δt=100000001 from + // last_time=1500000000; eps=0_01000000000000, supply=100_0000000, + // supply_scalar=1e7. Contract asserts index == 10012_34577890000000. + got := ProjectEmissionIndex( + mustBigInt("1234567890000000"), + 1_000_000_000_000, + 1500000000, 1600000001, 1700000000, + big.NewInt(100_0000000), + scalar7Big, + ) + assert.Equal(t, mustBigInt("1001234577890000000"), got) + }) + + t.Run("real contract-test vector: floor division (test_update_emission_data_rounds_down)", func(t *testing.T) { + // Δt=5, eps=0_01000000000000, supply=100_0001111 (indivisible), + // supply_scalar=1e7. Contract asserts index == 1234617889944450. + got := ProjectEmissionIndex( + mustBigInt("1234567890000000"), + 1_000_000_000_000, + 1500000000, 1600000000, 1500000005, + big.NewInt(100_0001111), + scalar7Big, + ) + assert.Equal(t, mustBigInt("1234617889944450"), got) + }) +} + func TestClaimableEmissions(t *testing.T) { t.Run("zero balance: no accrual regardless of index delta", func(t *testing.T) { got := ClaimableEmissions(big.NewInt(500), big.NewInt(0), big.NewInt(999_999_999), big.NewInt(0), mustBigInt("100000000000000")) @@ -373,6 +462,13 @@ func TestClaimableEmissions(t *testing.T) { assert.Equal(t, big.NewInt(62_700_000), got) }) + t.Run("negative index delta clamps to accrued only", func(t *testing.T) { + // A user index ahead of the stream index can only mean an ingestion + // gap (impossible on-chain); the claimable must not go negative. + got := ClaimableEmissions(big.NewInt(500), big.NewInt(2000), big.NewInt(1000), big.NewInt(999_999), mustBigInt("100000000000000")) + assert.Equal(t, big.NewInt(500), got) + }) + t.Run("varying per-reserve scalar (non-7-decimal reserve)", func(t *testing.T) { // A 5-decimal reserve: supply_scalar=1e5, scalar=1e5*1e7=1e12. // toAccrue = floor(4e12*500/1e12) = 2000. diff --git a/internal/services/blend/validator.go b/internal/services/blend/validator.go index 110afa9fb..baacfed33 100644 --- a/internal/services/blend/validator.go +++ b/internal/services/blend/validator.go @@ -41,17 +41,24 @@ const ( // blndTokenAddress returns the C-address of the BLND Stellar Asset Contract // for the given network passphrase. Blend v2 (pool, backstop, and the BLND -// SAC) is deployed byte-identically on pubnet and testnet. Any other -// passphrase (futurenet, a custom standalone network, etc.) has no known -// BLND SAC, so callers get the empty string and must degrade gracefully — -// mirrored on the ContractMetadataService nil-degradation pattern used -// throughout this package. +// SAC) is deployed byte-identically on pubnet and testnet. On the standalone +// network (passphrase "Standalone Network ; February 2017", used by the +// integration test suite), BLND is the classic asset "BLND:" issued by the network's master account (keypair.Root of the +// passphrase), so its SAC address is a deterministic function of the +// passphrase and is pinned here as a constant. Any other passphrase +// (futurenet, a custom network, etc.) has no known BLND SAC, so callers get +// the empty string and must degrade gracefully — mirrored on the +// ContractMetadataService nil-degradation pattern used throughout this +// package. func blndTokenAddress(networkPassphrase string) string { switch networkPassphrase { case network.PublicNetworkPassphrase: return "CD25MNVTZDL4Y3XBCPCJXGXATV5WUHHOWMYFF4YBEGU5FCPGMYTVG5JY" case network.TestNetworkPassphrase: return "CB22KRA3YZVCNCQI64JQ5WE7UY2VAV7WFLK6A2JN3HEX56T2EDAFO7QF" + case "Standalone Network ; February 2017": + return "CDYLJJT2VBKY55ZK57MTMKAVRCRPQMYB4YJ7JFFARMSJZ73I5CMCITSU" default: return "" } @@ -69,9 +76,14 @@ func blndTokenAddress(networkPassphrase string) string { // classified by WASM-interface match, any contract deployed from the backstop // WASM would otherwise be tracked and could write pool/user-keyed rows that // silently overwrite the real backstop's. Folding backstop-shaped state only -// from this address closes that impostor-collision hole. Any other passphrase -// (futurenet, a custom standalone network) has no known backstop and returns -// the empty string — the same nil-degradation contract as blndTokenAddress. +// from this address closes that impostor-collision hole. On the standalone +// network the integration suite deploys the backstop from the master account +// (keypair.Root of the passphrase) with a fixed salt, so its address is a +// deterministic function of the passphrase alone and is pinned here — the +// suite asserts the deployed address matches at deploy time. Any other +// passphrase (futurenet, a custom network) has no known backstop and returns +// the empty string — the same nil-degradation contract as blndTokenAddress; +// on such networks all backstop-shaped state is dropped. // // Operational caveat: the emitter can swap the active backstop via a 31-day // queued swap. If Blend ever executes one, these pinned addresses must be @@ -83,6 +95,8 @@ func canonicalBackstopAddress(networkPassphrase string) string { return "CAQQR5SWBXKIGZKPBZDH3KM5GQ5GUTPKB7JAFCINLZBC5WXPJKRG3IM7" case network.TestNetworkPassphrase: return "CBDVWXT433PRVTUNM56C3JREF3HIZHRBA64NB2C3B2UNCKIS65ZYCLZA" + case "Standalone Network ; February 2017": + return "CARICDGXKY6NZVNAHW5UHWUTOUB4QP4RL2B6PUN4BTPQZ6LC4RGPARED" default: return "" } diff --git a/internal/services/blend/validator_test.go b/internal/services/blend/validator_test.go index a3e0b496c..46385a63a 100644 --- a/internal/services/blend/validator_test.go +++ b/internal/services/blend/validator_test.go @@ -301,10 +301,19 @@ func TestValidator_RealWasm(t *testing.T) { func TestBlndTokenAddress(t *testing.T) { assert.Equal(t, "CD25MNVTZDL4Y3XBCPCJXGXATV5WUHHOWMYFF4YBEGU5FCPGMYTVG5JY", blndTokenAddress(network.PublicNetworkPassphrase)) assert.Equal(t, "CB22KRA3YZVCNCQI64JQ5WE7UY2VAV7WFLK6A2JN3HEX56T2EDAFO7QF", blndTokenAddress(network.TestNetworkPassphrase)) + assert.Equal(t, "CDYLJJT2VBKY55ZK57MTMKAVRCRPQMYB4YJ7JFFARMSJZ73I5CMCITSU", blndTokenAddress("Standalone Network ; February 2017")) assert.Empty(t, blndTokenAddress(network.FutureNetworkPassphrase)) assert.Empty(t, blndTokenAddress("some custom standalone network")) } +func TestCanonicalBackstopAddress(t *testing.T) { + assert.Equal(t, "CAQQR5SWBXKIGZKPBZDH3KM5GQ5GUTPKB7JAFCINLZBC5WXPJKRG3IM7", canonicalBackstopAddress(network.PublicNetworkPassphrase)) + assert.Equal(t, "CBDVWXT433PRVTUNM56C3JREF3HIZHRBA64NB2C3B2UNCKIS65ZYCLZA", canonicalBackstopAddress(network.TestNetworkPassphrase)) + assert.Equal(t, "CARICDGXKY6NZVNAHW5UHWUTOUB4QP4RL2B6PUN4BTPQZ6LC4RGPARED", canonicalBackstopAddress("Standalone Network ; February 2017")) + assert.Empty(t, canonicalBackstopAddress(network.FutureNetworkPassphrase)) + assert.Empty(t, canonicalBackstopAddress("some custom standalone network")) +} + // Validator construction tests ------------------------------------------------ func TestNewValidator(t *testing.T) { diff --git a/pkg/wbclient/client.go b/pkg/wbclient/client.go index b2a5d5ee9..08255fa1c 100644 --- a/pkg/wbclient/client.go +++ b/pkg/wbclient/client.go @@ -110,6 +110,20 @@ type AccountTransactionsWithOpsAndStateChangesData struct { } `json:"accountByAddress"` } +type BlendPoolsData struct { + BlendPools []types.BlendPool `json:"blendPools"` +} + +type BlendPoolData struct { + BlendPool *types.BlendPool `json:"blendPool"` +} + +type AccountBlendPositionsData struct { + AccountByAddress *struct { + BlendPositions *types.BlendAccountPositions `json:"blendPositions"` + } `json:"accountByAddress"` +} + // QueryOptions allows clients to specify which fields to fetch for each entity type type QueryOptions struct { // TransactionFields specifies which transaction fields to fetch @@ -640,6 +654,48 @@ func (c *Client) GetAllAccountBalances(ctx context.Context, address string) ([]t return balances, nil } +// GetBlendPools returns the pool-wide catalog view of every Blend v2 pool. +func (c *Client) GetBlendPools(ctx context.Context) ([]types.BlendPool, error) { + data, err := executeGraphQL[BlendPoolsData](c, ctx, buildBlendPoolsQuery(), nil) + if err != nil { + return nil, err + } + + return data.BlendPools, nil +} + +// GetBlendPool returns one Blend v2 pool's catalog view, or nil if the pool is unknown to the server. +func (c *Client) GetBlendPool(ctx context.Context, address string) (*types.BlendPool, error) { + variables := map[string]interface{}{ + "address": address, + } + + data, err := executeGraphQL[BlendPoolData](c, ctx, buildBlendPoolQuery(), variables) + if err != nil { + return nil, err + } + + return data.BlendPool, nil +} + +// GetAccountBlendPositions returns an account's Blend v2 lending, collateral, and backstop positions. +func (c *Client) GetAccountBlendPositions(ctx context.Context, address string) (*types.BlendAccountPositions, error) { + variables := map[string]interface{}{ + "address": address, + } + + data, err := executeGraphQL[AccountBlendPositionsData](c, ctx, buildAccountBlendPositionsQuery(), variables) + if err != nil { + return nil, err + } + + if data.AccountByAddress == nil { + return nil, fmt.Errorf("%w: %s", ErrAccountNotFound, address) + } + + return data.AccountByAddress.BlendPositions, nil +} + func (c *Client) request(ctx context.Context, bodyObj any) (*http.Response, error) { reqBody, err := json.Marshal(bodyObj) if err != nil { diff --git a/pkg/wbclient/client_blend_test.go b/pkg/wbclient/client_blend_test.go new file mode 100644 index 000000000..b4e46bac8 --- /dev/null +++ b/pkg/wbclient/client_blend_test.go @@ -0,0 +1,150 @@ +package wbclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetBlendPools(t *testing.T) { + ctx := context.Background() + + t.Run("decodes pools with nested reserves", func(t *testing.T) { + body := `{"blendPools":[{"address":"CPOOL1","name":"Pool One","status":"ACTIVE","reserves":[ + {"assetContractId":"CASSET1","tokenSymbol":"USDC","enabled":true,"suppliedTokens":"100","borrowedTokens":"10"} + ]}]}` + srv := graphqlServer(t, body) + defer srv.Close() + + c := NewClient(srv.URL, nil) + pools, err := c.GetBlendPools(ctx) + require.NoError(t, err) + require.Len(t, pools, 1) + assert.Equal(t, "CPOOL1", pools[0].Address) + require.Len(t, pools[0].Reserves, 1) + assert.Equal(t, "CASSET1", pools[0].Reserves[0].AssetContractID) + }) + + t.Run("sends well-formed request", func(t *testing.T) { + type gqlReq struct { + Query string `json:"query"` + } + var received gqlReq + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewDecoder(r.Body).Decode(&received) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, err = w.Write([]byte(`{"data":{"blendPools":[]}}`)) + require.NoError(t, err) + })) + defer srv.Close() + + c := NewClient(srv.URL, nil) + pools, err := c.GetBlendPools(ctx) + require.NoError(t, err) + assert.Empty(t, pools) + assert.Contains(t, received.Query, "blendPools") + }) +} + +func TestGetBlendPool(t *testing.T) { + ctx := context.Background() + + t.Run("returns nil when the pool does not exist", func(t *testing.T) { + srv := graphqlServer(t, `{"blendPool": null}`) + defer srv.Close() + + c := NewClient(srv.URL, nil) + pool, err := c.GetBlendPool(ctx, "CPOOL1") + require.NoError(t, err) + assert.Nil(t, pool) + }) + + t.Run("decodes a populated pool and sends the address variable", func(t *testing.T) { + type gqlReq struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + var received gqlReq + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewDecoder(r.Body).Decode(&received) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, err = w.Write([]byte(`{"data":{"blendPool":{"address":"CPOOL1","reserves":[]}}}`)) + require.NoError(t, err) + })) + defer srv.Close() + + c := NewClient(srv.URL, nil) + pool, err := c.GetBlendPool(ctx, "CPOOL1") + require.NoError(t, err) + require.NotNil(t, pool) + assert.Equal(t, "CPOOL1", pool.Address) + assert.Equal(t, "CPOOL1", received.Variables["address"]) + assert.Contains(t, received.Query, "blendPool(address: $address)") + }) +} + +func TestGetAccountBlendPositions(t *testing.T) { + ctx := context.Background() + + t.Run("returns ErrAccountNotFound when accountByAddress is null", func(t *testing.T) { + srv := graphqlServer(t, `{"accountByAddress": null}`) + defer srv.Close() + + c := NewClient(srv.URL, nil) + positions, err := c.GetAccountBlendPositions(ctx, "GABC") + assert.Nil(t, positions) + require.ErrorIs(t, err, ErrAccountNotFound) + }) + + t.Run("decodes positions including backstop and q4w", func(t *testing.T) { + body := `{"accountByAddress":{"blendPositions":{ + "pools":[{"poolAddress":"CPOOL1","claimedBlnd":"5","reserves":[]}], + "backstop":[{"poolAddress":"CPOOL1","shares":"100","lpTokens":"200","emissionsEarnedBlnd":"1","q4w":[ + {"amount":"10","expiration":1735689600,"lpTokens":"20"} + ]}], + "backstopClaimedLp":"42" + }}}` + srv := graphqlServer(t, body) + defer srv.Close() + + c := NewClient(srv.URL, nil) + positions, err := c.GetAccountBlendPositions(ctx, "GABC") + require.NoError(t, err) + require.NotNil(t, positions) + assert.Equal(t, "42", positions.BackstopClaimedLp) + require.Len(t, positions.Pools, 1) + assert.Equal(t, "5", positions.Pools[0].ClaimedBlnd) + require.Len(t, positions.Backstop, 1) + require.Len(t, positions.Backstop[0].Q4W, 1) + assert.Equal(t, int64(1735689600), positions.Backstop[0].Q4W[0].Expiration) + }) + + t.Run("sends the address variable", func(t *testing.T) { + type gqlReq struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + var received gqlReq + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewDecoder(r.Body).Decode(&received) + require.NoError(t, err) + w.Header().Set("Content-Type", "application/json") + _, err = w.Write([]byte(`{"data":{"accountByAddress": null}}`)) + require.NoError(t, err) + })) + defer srv.Close() + + c := NewClient(srv.URL, nil) + _, err := c.GetAccountBlendPositions(ctx, "GABC") + require.ErrorIs(t, err, ErrAccountNotFound) + assert.Equal(t, "GABC", received.Variables["address"]) + assert.Contains(t, received.Query, "blendPositions") + }) +} diff --git a/pkg/wbclient/queries.go b/pkg/wbclient/queries.go index c92f1254d..d505c7579 100644 --- a/pkg/wbclient/queries.go +++ b/pkg/wbclient/queries.go @@ -84,6 +84,105 @@ const ( balanceAuthLiquidityPoolId: liquidityPoolId flags } + ... on LendingChange { + lendingTokenId: tokenId + lendingAmount: amount + poolId + } + ` + + // blendReserveFields are the pool-wide reserve catalog fields shared by BlendPool.reserves. + blendReserveFields = ` + assetContractId + tokenName + tokenSymbol + tokenDecimals + enabled + utilization + supplyApy + borrowApy + emissionsSupplyApr + emissionsBorrowApr + suppliedTokens + borrowedTokens + suppliedUsd + borrowedUsd + cFactor + lFactor + priceUsd + ` + + // blendPoolFields are the fields of BlendPool, including its nested reserves. + blendPoolFields = ` + address + name + status + oracleContractId + backstopRate + maxPositions + suppliedUsd + borrowedUsd + backstopUsd + interestApy + netApy + reserves { + ` + blendReserveFields + ` + } + ` + + // blendReservePositionFields are the fields of BlendReservePosition. + blendReservePositionFields = ` + assetContractId + tokenName + tokenSymbol + tokenDecimals + suppliedTokens + collateralTokens + borrowedTokens + suppliedUsd + borrowedUsd + supplyApy + borrowApy + emissionsSupplyApr + emissionsBorrowApr + interestEarned + interestPaid + emissionsEarnedBlnd + emissionsEarnedUsd + priceUsd + ` + + // blendAccountPositionsFields are the fields of BlendAccountPositions, including nested + // pool/reserve positions, backstop positions, and queued withdrawals. + blendAccountPositionsFields = ` + pools { + poolAddress + poolName + usdValue + suppliedUsd + borrowedUsd + netApy + claimedBlnd + reserves { + ` + blendReservePositionFields + ` + } + } + backstop { + poolAddress + poolName + shares + lpTokens + usdValue + q4w { + amount + expiration + lpTokens + usdValue + } + emissionsEarnedBlnd + emissionsEarnedUsd + } + backstopClaimedLp ` ) @@ -443,6 +542,42 @@ func buildAccountBalancesQuery() string { `, balanceFragments) } +// buildBlendPoolsQuery builds the GraphQL query for fetching every Blend v2 pool's catalog view. +func buildBlendPoolsQuery() string { + return fmt.Sprintf(` + query BlendPools { + blendPools { + %s + } + } + `, blendPoolFields) +} + +// buildBlendPoolQuery builds the GraphQL query for fetching one Blend v2 pool by address. +func buildBlendPoolQuery() string { + return fmt.Sprintf(` + query BlendPool($address: String!) { + blendPool(address: $address) { + %s + } + } + `, blendPoolFields) +} + +// buildAccountBlendPositionsQuery builds the GraphQL query for fetching an account's Blend v2 +// lending, collateral, and backstop positions. +func buildAccountBlendPositionsQuery() string { + return fmt.Sprintf(` + query AccountBlendPositions($address: String!) { + accountByAddress(address: $address) { + blendPositions { + %s + } + } + } + `, blendAccountPositionsFields) +} + // buildFieldList constructs a field list string from a slice of field names // If fields is nil or empty, returns the defaultFields func buildFieldList(fields []string, defaultFields string) string { diff --git a/pkg/wbclient/queries_test.go b/pkg/wbclient/queries_test.go index 25bef9746..c6a8ab35b 100644 --- a/pkg/wbclient/queries_test.go +++ b/pkg/wbclient/queries_test.go @@ -44,6 +44,53 @@ func TestBalanceFragmentRequestsAllFields(t *testing.T) { } } +// TestLendingChangeFragmentRequestsAllFields guards against the same drift as +// TestBalanceFragmentRequestsAllFields, for the LendingChange state-change fragment: every +// LendingChange-specific JSON tag (the aliased tokenId/amount/poolId) must appear in the +// fragment's inline selection. +func TestLendingChangeFragmentRequestsAllFields(t *testing.T) { + block := inlineFragmentBlock(t, stateChangeFragments, "LendingChange") + for _, tag := range []string{"lendingTokenId", "lendingAmount", "poolId"} { + assert.Contains(t, block, tag, + "stateChangeFragments '... on LendingChange' must request %q, else the SDK never fetches it", tag) + } +} + +// TestBlendQueryFieldsRequestAllStructFields guards against the recurring bug where a field is +// added to a Blend response struct (and the GraphQL schema + resolver) but not to the query +// field list: the SDK then never requests it and it silently unmarshals to its zero value. +func TestBlendQueryFieldsRequestAllStructFields(t *testing.T) { + nested := map[string]bool{"reserves": true, "pools": true, "backstop": true, "q4w": true} + + testCases := []struct { + name string + fieldBlock string + sample any + }{ + {"blendPoolFields/BlendPool", blendPoolFields, types.BlendPool{}}, + {"blendReserveFields/BlendReserve", blendReserveFields, types.BlendReserve{}}, + {"blendAccountPositionsFields/BlendAccountPositions", blendAccountPositionsFields, types.BlendAccountPositions{}}, + {"blendAccountPositionsFields/BlendPoolPosition", blendAccountPositionsFields, types.BlendPoolPosition{}}, + {"blendAccountPositionsFields/BlendBackstopPosition", blendAccountPositionsFields, types.BlendBackstopPosition{}}, + {"blendAccountPositionsFields/BlendQ4W", blendAccountPositionsFields, types.BlendQ4W{}}, + {"blendReservePositionFields/BlendReservePosition", blendReservePositionFields, types.BlendReservePosition{}}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rt := reflect.TypeOf(tc.sample) + for i := 0; i < rt.NumField(); i++ { + name := strings.Split(rt.Field(i).Tag.Get("json"), ",")[0] + if name == "" || name == "-" || nested[name] { + continue + } + assert.Contains(t, tc.fieldBlock, name, + "%s must request %q, else the SDK never fetches it", tc.name, name) + } + }) + } +} + // inlineFragmentBlock returns the body of the `... on { ... }` inline fragment, // matching braces so nested selection sets (e.g. reserves { ... }) don't terminate it early. func inlineFragmentBlock(t *testing.T, fragment, typeName string) string { diff --git a/pkg/wbclient/types/blend.go b/pkg/wbclient/types/blend.go new file mode 100644 index 000000000..ca80d5102 --- /dev/null +++ b/pkg/wbclient/types/blend.go @@ -0,0 +1,116 @@ +package types + +// BlendPoolStatus is a pool's operational status. The on-chain integer status +// (0-6) is surfaced by the GraphQL API as one of these enum names; see the +// BlendPoolStatus enum in the schema for the numeric encoding. +type BlendPoolStatus string + +const ( + BlendPoolStatusAdminActive BlendPoolStatus = "ADMIN_ACTIVE" + BlendPoolStatusActive BlendPoolStatus = "ACTIVE" + BlendPoolStatusAdminOnIce BlendPoolStatus = "ADMIN_ON_ICE" + BlendPoolStatusOnIce BlendPoolStatus = "ON_ICE" + BlendPoolStatusAdminFrozen BlendPoolStatus = "ADMIN_FROZEN" + BlendPoolStatusFrozen BlendPoolStatus = "FROZEN" + BlendPoolStatusSetup BlendPoolStatus = "SETUP" +) + +// BlendPool is a pool-wide catalog view of one Blend v2 pool, independent of any account. +type BlendPool struct { + Address string `json:"address"` + Name *string `json:"name,omitempty"` + Status *BlendPoolStatus `json:"status,omitempty"` + OracleContractID *string `json:"oracleContractId,omitempty"` + BackstopRate *int32 `json:"backstopRate,omitempty"` + MaxPositions *int32 `json:"maxPositions,omitempty"` + SuppliedUsd *float64 `json:"suppliedUsd,omitempty"` + BorrowedUsd *float64 `json:"borrowedUsd,omitempty"` + BackstopUsd *float64 `json:"backstopUsd,omitempty"` + InterestApy *float64 `json:"interestApy,omitempty"` + NetApy *float64 `json:"netApy,omitempty"` + Reserves []BlendReserve `json:"reserves"` +} + +// BlendReserve is a pool-wide reserve catalog view: utilization, APYs, emissions APRs, and +// pool-wide underlying token amounts, all as of "now". +type BlendReserve struct { + AssetContractID string `json:"assetContractId"` + TokenName *string `json:"tokenName,omitempty"` + TokenSymbol *string `json:"tokenSymbol,omitempty"` + TokenDecimals *int32 `json:"tokenDecimals,omitempty"` + Enabled bool `json:"enabled"` + Utilization *float64 `json:"utilization,omitempty"` + SupplyApy *float64 `json:"supplyApy,omitempty"` + BorrowApy *float64 `json:"borrowApy,omitempty"` + EmissionsSupplyApr *float64 `json:"emissionsSupplyApr,omitempty"` + EmissionsBorrowApr *float64 `json:"emissionsBorrowApr,omitempty"` + SuppliedTokens string `json:"suppliedTokens"` + BorrowedTokens string `json:"borrowedTokens"` + SuppliedUsd *float64 `json:"suppliedUsd,omitempty"` + BorrowedUsd *float64 `json:"borrowedUsd,omitempty"` + CFactor *int32 `json:"cFactor,omitempty"` + LFactor *int32 `json:"lFactor,omitempty"` + PriceUsd *float64 `json:"priceUsd,omitempty"` +} + +// BlendAccountPositions aggregates one account's Blend v2 exposure across every pool it has +// touched. +type BlendAccountPositions struct { + Pools []BlendPoolPosition `json:"pools"` + Backstop []BlendBackstopPosition `json:"backstop"` + BackstopClaimedLp string `json:"backstopClaimedLp"` +} + +// BlendPoolPosition rolls up an account's reserve positions within one pool. +type BlendPoolPosition struct { + PoolAddress string `json:"poolAddress"` + PoolName *string `json:"poolName,omitempty"` + UsdValue *float64 `json:"usdValue,omitempty"` + SuppliedUsd *float64 `json:"suppliedUsd,omitempty"` + BorrowedUsd *float64 `json:"borrowedUsd,omitempty"` + NetApy *float64 `json:"netApy,omitempty"` + ClaimedBlnd string `json:"claimedBlnd"` + Reserves []BlendReservePosition `json:"reserves"` +} + +// BlendReservePosition is an account's position in one reserve of a pool. +type BlendReservePosition struct { + AssetContractID string `json:"assetContractId"` + TokenName *string `json:"tokenName,omitempty"` + TokenSymbol *string `json:"tokenSymbol,omitempty"` + TokenDecimals *int32 `json:"tokenDecimals,omitempty"` + SuppliedTokens string `json:"suppliedTokens"` + CollateralTokens string `json:"collateralTokens"` + BorrowedTokens string `json:"borrowedTokens"` + SuppliedUsd *float64 `json:"suppliedUsd,omitempty"` + BorrowedUsd *float64 `json:"borrowedUsd,omitempty"` + SupplyApy *float64 `json:"supplyApy,omitempty"` + BorrowApy *float64 `json:"borrowApy,omitempty"` + EmissionsSupplyApr *float64 `json:"emissionsSupplyApr,omitempty"` + EmissionsBorrowApr *float64 `json:"emissionsBorrowApr,omitempty"` + InterestEarned string `json:"interestEarned"` + InterestPaid string `json:"interestPaid"` + EmissionsEarnedBlnd string `json:"emissionsEarnedBlnd"` + EmissionsEarnedUsd *float64 `json:"emissionsEarnedUsd,omitempty"` + PriceUsd *float64 `json:"priceUsd,omitempty"` +} + +// BlendBackstopPosition is an account's backstop deposit in one pool. +type BlendBackstopPosition struct { + PoolAddress string `json:"poolAddress"` + PoolName *string `json:"poolName,omitempty"` + Shares string `json:"shares"` + LpTokens string `json:"lpTokens"` + UsdValue *float64 `json:"usdValue,omitempty"` + Q4W []BlendQ4W `json:"q4w"` + EmissionsEarnedBlnd string `json:"emissionsEarnedBlnd"` + EmissionsEarnedUsd *float64 `json:"emissionsEarnedUsd,omitempty"` +} + +// BlendQ4W is one queued backstop withdrawal, unlocking at expiration (unix seconds). +type BlendQ4W struct { + Amount string `json:"amount"` + Expiration int64 `json:"expiration"` + LpTokens string `json:"lpTokens"` + UsdValue *float64 `json:"usdValue,omitempty"` +} diff --git a/pkg/wbclient/types/blend_test.go b/pkg/wbclient/types/blend_test.go new file mode 100644 index 000000000..b38b9d1f5 --- /dev/null +++ b/pkg/wbclient/types/blend_test.go @@ -0,0 +1,214 @@ +package types + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_LendingChange_DecodesViaStateChangeNode(t *testing.T) { + payload := []byte(`{ + "__typename": "LendingChange", + "type": "LENDING", + "reason": "SUPPLY", + "ingestedAt": "2024-01-02T00:00:00Z", + "ledgerCreatedAt": "2024-01-01T00:00:00Z", + "ledgerNumber": 42, + "lendingTokenId": "CDMLFMKMMD7MWZP3FKUBZPVHTUEDLSX4BYGYKH4GCESXYHS3IHQ4EIG4", + "lendingAmount": "1000000000", + "poolId": "CPOOLADDRESS" + }`) + + node, err := UnmarshalStateChangeNode(payload) + require.NoError(t, err) + + lc, ok := node.(*LendingChange) + require.True(t, ok, "expected *LendingChange, got %T", node) + require.NotNil(t, lc.TokenID) + assert.Equal(t, "CDMLFMKMMD7MWZP3FKUBZPVHTUEDLSX4BYGYKH4GCESXYHS3IHQ4EIG4", *lc.TokenID) + require.NotNil(t, lc.Amount) + assert.Equal(t, "1000000000", *lc.Amount) + require.NotNil(t, lc.PoolID) + assert.Equal(t, "CPOOLADDRESS", *lc.PoolID) + assert.Equal(t, uint32(42), lc.GetLedgerNumber()) +} + +func Test_LendingChange_DecodesNullTokenAndPool(t *testing.T) { + payload := []byte(`{ + "__typename": "LendingChange", + "type": "LENDING", + "reason": "WITHDRAW", + "ingestedAt": "2024-01-02T00:00:00Z", + "ledgerCreatedAt": "2024-01-01T00:00:00Z", + "ledgerNumber": 7, + "lendingTokenId": null, + "lendingAmount": null, + "poolId": null + }`) + + node, err := UnmarshalStateChangeNode(payload) + require.NoError(t, err) + + lc, ok := node.(*LendingChange) + require.True(t, ok, "expected *LendingChange, got %T", node) + assert.Nil(t, lc.TokenID) + assert.Nil(t, lc.Amount) + assert.Nil(t, lc.PoolID) +} + +func Test_BlendPool_DecodesWithNestedReservesAndNullFloats(t *testing.T) { + payload := []byte(`{ + "address": "CPOOLADDRESS", + "name": "Fixed Pool", + "status": "ACTIVE", + "oracleContractId": "CORACLE", + "backstopRate": 4750000, + "maxPositions": 4, + "suppliedUsd": null, + "borrowedUsd": 123.45, + "backstopUsd": 500.0, + "interestApy": null, + "netApy": 0.05, + "reserves": [ + { + "assetContractId": "CASSET1", + "tokenName": "USD Coin", + "tokenSymbol": "USDC", + "tokenDecimals": 7, + "enabled": true, + "utilization": 0.5, + "supplyApy": 0.03, + "borrowApy": 0.08, + "emissionsSupplyApr": null, + "emissionsBorrowApr": null, + "suppliedTokens": "1000000000", + "borrowedTokens": "500000000", + "suppliedUsd": null, + "borrowedUsd": 250.0, + "cFactor": 9000000, + "lFactor": 9500000, + "priceUsd": null + } + ] + }`) + + var pool BlendPool + err := json.Unmarshal(payload, &pool) + require.NoError(t, err) + + assert.Equal(t, "CPOOLADDRESS", pool.Address) + require.NotNil(t, pool.Name) + assert.Equal(t, "Fixed Pool", *pool.Name) + require.NotNil(t, pool.Status) + assert.Equal(t, BlendPoolStatusActive, *pool.Status) + assert.Nil(t, pool.SuppliedUsd) + require.NotNil(t, pool.BorrowedUsd) + assert.InDelta(t, 123.45, *pool.BorrowedUsd, 0.0001) + + require.Len(t, pool.Reserves, 1) + reserve := pool.Reserves[0] + assert.Equal(t, "CASSET1", reserve.AssetContractID) + require.NotNil(t, reserve.TokenSymbol) + assert.Equal(t, "USDC", *reserve.TokenSymbol) + assert.True(t, reserve.Enabled) + assert.Nil(t, reserve.EmissionsSupplyApr) + assert.Nil(t, reserve.SuppliedUsd) + require.NotNil(t, reserve.CFactor) + assert.Equal(t, int32(9000000), *reserve.CFactor) +} + +func Test_BlendReservePosition_DecodesWithNullFloats(t *testing.T) { + payload := []byte(`{ + "assetContractId": "CASSET1", + "tokenName": null, + "tokenSymbol": null, + "tokenDecimals": null, + "suppliedTokens": "100", + "collateralTokens": "50", + "borrowedTokens": "0", + "suppliedUsd": null, + "borrowedUsd": null, + "supplyApy": null, + "borrowApy": null, + "emissionsSupplyApr": null, + "emissionsBorrowApr": null, + "interestEarned": "1", + "interestPaid": "0", + "emissionsEarnedBlnd": "2", + "emissionsEarnedUsd": null, + "priceUsd": null + }`) + + var rp BlendReservePosition + err := json.Unmarshal(payload, &rp) + require.NoError(t, err) + + assert.Equal(t, "CASSET1", rp.AssetContractID) + assert.Nil(t, rp.TokenName) + assert.Nil(t, rp.TokenDecimals) + assert.Equal(t, "100", rp.SuppliedTokens) + assert.Equal(t, "50", rp.CollateralTokens) + assert.Nil(t, rp.SuppliedUsd) + assert.Nil(t, rp.PriceUsd) + assert.Nil(t, rp.EmissionsSupplyApr) + assert.Nil(t, rp.EmissionsBorrowApr) + assert.Equal(t, "2", rp.EmissionsEarnedBlnd) +} + +func Test_BlendAccountPositions_DecodesBackstopAndQ4W(t *testing.T) { + payload := []byte(`{ + "pools": [ + { + "poolAddress": "CPOOL1", + "poolName": "Pool One", + "usdValue": 10.5, + "suppliedUsd": 10.5, + "borrowedUsd": null, + "netApy": 0.04, + "claimedBlnd": "5", + "reserves": [] + } + ], + "backstop": [ + { + "poolAddress": "CPOOL1", + "poolName": "Pool One", + "shares": "1000", + "lpTokens": "2000", + "usdValue": null, + "q4w": [ + { + "amount": "100", + "expiration": 1735689600, + "lpTokens": "200", + "usdValue": null + } + ], + "emissionsEarnedBlnd": "3", + "emissionsEarnedUsd": null + } + ], + "backstopClaimedLp": "42" + }`) + + var positions BlendAccountPositions + err := json.Unmarshal(payload, &positions) + require.NoError(t, err) + + assert.Equal(t, "42", positions.BackstopClaimedLp) + require.Len(t, positions.Pools, 1) + assert.Equal(t, "CPOOL1", positions.Pools[0].PoolAddress) + assert.Equal(t, "5", positions.Pools[0].ClaimedBlnd) + assert.Nil(t, positions.Pools[0].BorrowedUsd) + + require.Len(t, positions.Backstop, 1) + bp := positions.Backstop[0] + assert.Equal(t, "1000", bp.Shares) + assert.Nil(t, bp.UsdValue) + require.Len(t, bp.Q4W, 1) + assert.Equal(t, "100", bp.Q4W[0].Amount) + assert.Equal(t, int64(1735689600), bp.Q4W[0].Expiration) + assert.Nil(t, bp.Q4W[0].UsdValue) +} diff --git a/pkg/wbclient/types/statechange.go b/pkg/wbclient/types/statechange.go index 804ab968b..916ebc76d 100644 --- a/pkg/wbclient/types/statechange.go +++ b/pkg/wbclient/types/statechange.go @@ -118,6 +118,14 @@ type BalanceAuthorizationChange struct { Flags []string `json:"flags"` } +// LendingChange represents a Blend v2 lending, borrowing, or backstop state change. +type LendingChange struct { + BaseStateChangeFields + TokenID *string `json:"lendingTokenId,omitempty"` + Amount *string `json:"lendingAmount,omitempty"` + PoolID *string `json:"poolId,omitempty"` +} + // stateChangeNodeWrapper is used for unmarshaling polymorphic state change responses type stateChangeNodeWrapper struct { TypeName string `json:"__typename"` @@ -197,6 +205,13 @@ func UnmarshalStateChangeNode(data []byte) (StateChangeNode, error) { } return &sc, nil + case "LendingChange": + var sc LendingChange + if err := json.Unmarshal(data, &sc); err != nil { + return nil, fmt.Errorf("unmarshaling LendingChange: %w", err) + } + return &sc, nil + default: return nil, fmt.Errorf("unknown state change type: %s", wrapper.TypeName) }