Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions internal/data/native_balances.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type NativeBalance struct {
MinimumBalance int64 `db:"minimum_balance"`
BuyingLiabilities int64 `db:"buying_liabilities"`
SellingLiabilities int64 `db:"selling_liabilities"`
NumSubEntries uint32 `db:"num_subentries"`
LedgerNumber uint32 `db:"last_modified_ledger"`
}

Expand Down Expand Up @@ -54,7 +55,7 @@ func (m *NativeBalanceModel) GetByAccount(ctx context.Context, accountAddress st
}

const query = `
SELECT account_id, balance, minimum_balance, buying_liabilities, selling_liabilities, last_modified_ledger
SELECT account_id, balance, minimum_balance, buying_liabilities, selling_liabilities, num_subentries, last_modified_ledger
FROM native_balances
WHERE account_id = $1`

Expand Down Expand Up @@ -87,6 +88,7 @@ func (m *NativeBalanceModel) BatchUpsert(ctx context.Context, dbTx pgx.Tx, upser
minimumBalances := make([]int64, len(upserts))
buyingLiabilities := make([]int64, len(upserts))
sellingLiabilities := make([]int64, len(upserts))
numSubentries := make([]int32, len(upserts))
ledgerNumbers := make([]int64, len(upserts))

for i, nb := range upserts {
Expand All @@ -103,20 +105,22 @@ func (m *NativeBalanceModel) BatchUpsert(ctx context.Context, dbTx pgx.Tx, upser
minimumBalances[i] = nb.MinimumBalance
buyingLiabilities[i] = nb.BuyingLiabilities
sellingLiabilities[i] = nb.SellingLiabilities
numSubentries[i] = int32(nb.NumSubEntries)
ledgerNumbers[i] = int64(nb.LedgerNumber)
}

const upsertQuery = `
INSERT INTO native_balances (account_id, balance, minimum_balance, buying_liabilities, selling_liabilities, last_modified_ledger)
SELECT * FROM UNNEST($1::bytea[], $2::bigint[], $3::bigint[], $4::bigint[], $5::bigint[], $6::bigint[])
INSERT INTO native_balances (account_id, balance, minimum_balance, buying_liabilities, selling_liabilities, num_subentries, last_modified_ledger)
SELECT * FROM UNNEST($1::bytea[], $2::bigint[], $3::bigint[], $4::bigint[], $5::bigint[], $6::int[], $7::bigint[])
ON CONFLICT (account_id) DO UPDATE SET
balance = EXCLUDED.balance,
minimum_balance = EXCLUDED.minimum_balance,
buying_liabilities = EXCLUDED.buying_liabilities,
selling_liabilities = EXCLUDED.selling_liabilities,
num_subentries = EXCLUDED.num_subentries,
last_modified_ledger = EXCLUDED.last_modified_ledger`

if _, err := dbTx.Exec(ctx, upsertQuery, accountIDs, balances, minimumBalances, buyingLiabilities, sellingLiabilities, ledgerNumbers); err != nil {
if _, err := dbTx.Exec(ctx, upsertQuery, accountIDs, balances, minimumBalances, buyingLiabilities, sellingLiabilities, numSubentries, ledgerNumbers); err != nil {
m.Metrics.QueryDuration.WithLabelValues("BatchUpsert", "native_balances").Observe(time.Since(start).Seconds())
m.Metrics.QueriesTotal.WithLabelValues("BatchUpsert", "native_balances").Inc()
m.Metrics.QueryErrors.WithLabelValues("BatchUpsert", "native_balances", utils.GetDBErrorType(err)).Inc()
Expand Down Expand Up @@ -164,14 +168,14 @@ func (m *NativeBalanceModel) BatchCopy(ctx context.Context, dbTx pgx.Tx, balance
copyCount, err := dbTx.CopyFrom(
ctx,
pgx.Identifier{"native_balances"},
[]string{"account_id", "balance", "minimum_balance", "buying_liabilities", "selling_liabilities", "last_modified_ledger"},
[]string{"account_id", "balance", "minimum_balance", "buying_liabilities", "selling_liabilities", "num_subentries", "last_modified_ledger"},
pgx.CopyFromSlice(len(balances), func(i int) ([]any, error) {
nb := balances[i]
accountIDBytes, err := nb.AccountID.Value()
if err != nil {
return nil, fmt.Errorf("converting account address to bytes: %w", err)
}
return []any{accountIDBytes, nb.Balance, nb.MinimumBalance, nb.BuyingLiabilities, nb.SellingLiabilities, nb.LedgerNumber}, nil
return []any{accountIDBytes, nb.Balance, nb.MinimumBalance, nb.BuyingLiabilities, nb.SellingLiabilities, int32(nb.NumSubEntries), nb.LedgerNumber}, nil
}),
)
if err != nil {
Expand Down
32 changes: 23 additions & 9 deletions internal/data/native_balances_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,8 @@ func TestNativeBalanceModel_GetByAccount(t *testing.T) {
accountAddr := keypair.MustRandom().Address()
_, err := dbConnectionPool.Exec(ctx, `
INSERT INTO native_balances
(account_id, balance, buying_liabilities, selling_liabilities, last_modified_ledger)
VALUES ($1, 1000000000, 100, 200, 12345)
(account_id, balance, buying_liabilities, selling_liabilities, num_subentries, last_modified_ledger)
VALUES ($1, 1000000000, 100, 200, 5, 12345)
`, types.AddressBytea(accountAddr))
require.NoError(t, err)

Expand All @@ -81,6 +81,7 @@ func TestNativeBalanceModel_GetByAccount(t *testing.T) {
require.Equal(t, int64(1000000000), balance.Balance)
require.Equal(t, int64(100), balance.BuyingLiabilities)
require.Equal(t, int64(200), balance.SellingLiabilities)
require.Equal(t, uint32(5), balance.NumSubEntries)
require.Equal(t, uint32(12345), balance.LedgerNumber)
})
}
Expand Down Expand Up @@ -134,6 +135,7 @@ func TestNativeBalanceModel_BatchUpsert(t *testing.T) {
Balance: 1000000000,
BuyingLiabilities: 100,
SellingLiabilities: 200,
NumSubEntries: 7,
LedgerNumber: 12345,
},
}
Expand All @@ -147,6 +149,12 @@ func TestNativeBalanceModel_BatchUpsert(t *testing.T) {
err = dbConnectionPool.QueryRow(ctx, `SELECT COUNT(*) FROM native_balances WHERE account_id = $1`, types.AddressBytea(accountAddr)).Scan(&count)
require.NoError(t, err)
require.Equal(t, 1, count)

// Verify num_subentries round-trips via GetByAccount
got, err := m.GetByAccount(ctx, accountAddr)
require.NoError(t, err)
require.NotNil(t, got)
require.Equal(t, uint32(7), got.NumSubEntries)
})

t.Run("updates existing balance on conflict", func(t *testing.T) {
Expand All @@ -164,9 +172,10 @@ func TestNativeBalanceModel_BatchUpsert(t *testing.T) {
require.NoError(t, err)
upserts := []NativeBalance{
{
AccountID: types.AddressBytea(accountAddr),
Balance: 1000,
LedgerNumber: 100,
AccountID: types.AddressBytea(accountAddr),
Balance: 1000,
NumSubEntries: 3,
LedgerNumber: 100,
},
}
err = m.BatchUpsert(ctx, pgxTx1, upserts, nil)
Expand All @@ -177,6 +186,7 @@ func TestNativeBalanceModel_BatchUpsert(t *testing.T) {
pgxTx2, err := dbConnectionPool.Begin(ctx)
require.NoError(t, err)
upserts[0].Balance = 2000
upserts[0].NumSubEntries = 9
upserts[0].LedgerNumber = 200
err = m.BatchUpsert(ctx, pgxTx2, upserts, nil)
require.NoError(t, err)
Expand All @@ -185,11 +195,13 @@ func TestNativeBalanceModel_BatchUpsert(t *testing.T) {
// Verify update
var balance int64
var ledger uint32
var numSubentries uint32
err = dbConnectionPool.QueryRow(ctx,
`SELECT balance, last_modified_ledger FROM native_balances WHERE account_id = $1`,
types.AddressBytea(accountAddr)).Scan(&balance, &ledger)
`SELECT balance, num_subentries, last_modified_ledger FROM native_balances WHERE account_id = $1`,
types.AddressBytea(accountAddr)).Scan(&balance, &numSubentries, &ledger)
require.NoError(t, err)
require.Equal(t, int64(2000), balance)
require.Equal(t, uint32(9), numSubentries)
require.Equal(t, uint32(200), ledger)
})

Expand Down Expand Up @@ -327,6 +339,7 @@ func TestNativeBalanceModel_BatchCopy(t *testing.T) {
Balance: 1000000000,
BuyingLiabilities: 100,
SellingLiabilities: 200,
NumSubEntries: 11,
LedgerNumber: 12345,
},
}
Expand All @@ -338,14 +351,15 @@ func TestNativeBalanceModel_BatchCopy(t *testing.T) {
// Verify all fields
var b NativeBalance
err = dbConnectionPool.QueryRow(ctx, `
SELECT account_id, balance, buying_liabilities, selling_liabilities, last_modified_ledger
SELECT account_id, balance, buying_liabilities, selling_liabilities, num_subentries, last_modified_ledger
FROM native_balances WHERE account_id = $1
`, types.AddressBytea(accountAddr)).Scan(&b.AccountID, &b.Balance, &b.BuyingLiabilities, &b.SellingLiabilities, &b.LedgerNumber)
`, types.AddressBytea(accountAddr)).Scan(&b.AccountID, &b.Balance, &b.BuyingLiabilities, &b.SellingLiabilities, &b.NumSubEntries, &b.LedgerNumber)
require.NoError(t, err)
require.Equal(t, balances[0].AccountID, b.AccountID)
require.Equal(t, balances[0].Balance, b.Balance)
require.Equal(t, balances[0].BuyingLiabilities, b.BuyingLiabilities)
require.Equal(t, balances[0].SellingLiabilities, b.SellingLiabilities)
require.Equal(t, balances[0].NumSubEntries, b.NumSubEntries)
require.Equal(t, balances[0].LedgerNumber, b.LedgerNumber)
})

Expand Down
3 changes: 2 additions & 1 deletion internal/db/migrations/2026-01-15.0-native_balances.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
-- Stores native XLM balance data for accounts during ingestion.
-- Storage parameters tuned for heavy UPSERT/DELETE during ledger ingestion.
-- UPSERTs only modify non-indexed columns (balance, minimum_balance, liabilities,
-- last_modified_ledger) while the PK column (account_id) is never changed.
-- num_subentries, last_modified_ledger) while the PK column (account_id) is never changed.
CREATE TABLE native_balances (
account_id BYTEA PRIMARY KEY,
balance BIGINT NOT NULL DEFAULT 0,
minimum_balance BIGINT NOT NULL DEFAULT 0,
buying_liabilities BIGINT NOT NULL DEFAULT 0,
selling_liabilities BIGINT NOT NULL DEFAULT 0,
num_subentries INTEGER NOT NULL DEFAULT 0,
Comment thread
aditya1702 marked this conversation as resolved.
last_modified_ledger BIGINT NOT NULL DEFAULT 0
) WITH (
-- Reserve 20% free space per page so PostgreSQL can do HOT (Heap-Only Tuple) updates.
Expand Down
1 change: 1 addition & 0 deletions internal/indexer/processors/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ func (p *AccountsProcessor) buildAccountChange(change ingest.Change, ledgerSeq u

// Calculate the minimum balance for base reserves: https://developers.stellar.org/docs/build/guides/transactions/sponsored-reserves#effect-on-minimum-balance
numSubEntries := account.NumSubEntries
accChange.NumSubEntries = uint32(numSubEntries)
numSponsoring := account.NumSponsoring()
numSponsored := account.NumSponsored()
accChange.MinimumBalance = int64(MinimumBaseReserveCount+numSubEntries+numSponsoring-numSponsored)*BaseReserveStroops + accChange.SellingLiabilities
Expand Down
35 changes: 35 additions & 0 deletions internal/indexer/processors/accounts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,41 @@ func TestAccountsProcessor_ProcessOperation(t *testing.T) {
}
}

func TestAccountsProcessor_ProcessOperation_PersistsNumSubEntries(t *testing.T) {
processor := NewAccountsProcessor(nil)
testAccount := accountA.ToAccountId()

// accountLedgerEntry defaults NumSubEntries to 0; set 3 to prove the count is carried through.
entry := accountLedgerEntry(testAccount, 10000000)
entry.Data.Account.NumSubEntries = 3

op := xdr.Operation{
SourceAccount: &accountA,
Body: xdr.OperationBody{
Type: xdr.OperationTypeCreateAccount,
CreateAccountOp: &xdr.CreateAccountOp{},
},
}
changes := xdr.LedgerEntryChanges{
{Type: xdr.LedgerEntryChangeTypeLedgerEntryCreated, Created: entry},
}
tx := createTx(op, changes, nil, false)
wrapper := &TransactionOperationWrapper{
Index: 0,
Transaction: tx,
Operation: op,
LedgerSequence: 12345,
Network: networkPassphrase,
}

got, err := processor.ProcessOperation(context.Background(), wrapper)
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, uint32(3), got[0].NumSubEntries)
// MinimumBalance folds the 3 subentries into the base reserve: (2 + 3) * 5_000_000 + 200.
assert.Equal(t, int64(25000200), got[0].MinimumBalance)
}

func TestAccountsProcessor_ProcessTransactionFees(t *testing.T) {
processor := NewAccountsProcessor(nil)
testAccount := accountA.ToAccountId()
Expand Down
1 change: 1 addition & 0 deletions internal/indexer/types/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ type AccountChange struct {
MinimumBalance int64
BuyingLiabilities int64
SellingLiabilities int64
NumSubEntries uint32
}

type AccountOpType string
Expand Down
42 changes: 42 additions & 0 deletions internal/serve/graphql/generated/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions internal/serve/graphql/generated/models_gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions internal/serve/graphql/resolvers/account_balances_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func buildNativeBalanceFromDB(nativeBalance *data.NativeBalance, networkPassphra
MinimumBalance: minimumBalanceStr,
BuyingLiabilities: buyingLiabilitiesStr,
SellingLiabilities: sellingLiabilitiesStr,
NumSubentries: nativeBalance.NumSubEntries,
LastModifiedLedger: nativeBalance.LedgerNumber,
}, nil
}
Expand Down
30 changes: 30 additions & 0 deletions internal/serve/graphql/resolvers/account_balances_utils_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package resolvers

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/stellar/wallet-backend/internal/data"
graphql1 "github.com/stellar/wallet-backend/internal/serve/graphql/generated"
)

func TestBuildNativeBalanceFromDB(t *testing.T) {
nb := &data.NativeBalance{
Balance: 1000000000,
MinimumBalance: 10000200,
BuyingLiabilities: 100,
SellingLiabilities: 200,
NumSubEntries: 4,
LedgerNumber: 12345,
}

got, err := buildNativeBalanceFromDB(nb, "Test SDF Network ; September 2015")
require.NoError(t, err)
require.NotNil(t, got)

assert.Equal(t, graphql1.TokenTypeNative, got.TokenType)
assert.Equal(t, uint32(4), got.NumSubentries)
assert.Equal(t, uint32(12345), got.LastModifiedLedger)
}
1 change: 1 addition & 0 deletions internal/serve/graphql/schema/balances.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type NativeBalance implements Balance {
minimumBalance: String!
buyingLiabilities: String!
sellingLiabilities: String!
numSubentries: UInt32!
lastModifiedLedger: UInt32!
}

Expand Down
Loading
Loading