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)
}