Skip to content

Conversation

@aditya1702
Copy link
Contributor

@aditya1702 aditya1702 commented Jan 14, 2026

What

Add SAC (Stellar Asset Contract) balance tracking for contract addresses (C...) by storing balances directly in PostgreSQL and reading them from the database instead of making RPC calls. This includes a dedicated SACBalancesProcessor for extracting SAC balance changes from ledger entries during live ingestion, and a new sac_balances table for persistent storage.

Why

  1. Performance - Reading SAC balances from local PostgreSQL is significantly faster than making RPC calls for each contract address balance
  2. Complete state tracking - Stores SAC balance fields (balance, is_authorized, is_clawback_enabled, last_modified_ledger) enabling richer balance queries
  3. Consistent with architecture - Follows the same pattern established for native XLM balances and trustlines in `
  4. Contract address support - Classic G-addresses have SAC balances in their trustlines; contract C-addresses need separate storage since they don't have trustlines

Key Architectural Changes

BEFORE:

flowchart LR
    A[GraphQL Resolver] --> B[RPC getLedgerEntries]
    B --> C[Parse SAC Balance Map]
    C --> D[Return Balance]
Loading

AFTER:

flowchart LR
    subgraph Ingestion
        I1[SACBalancesProcessor] --> I2[(sac_balances)]
    end
    subgraph Query
        A[GraphQL Resolver] --> B{Address Type?}
        B -->|C-address| C[SACBalanceModel.GetByAccount]
        C --> D[(PostgreSQL)]
        B -->|G-address| E[TrustlineBalanceModel]
    end
Loading

Per-File Change Summary

File Type Description
internal/db/migrations/2026-01-16.0-account-sac-balances.sql ✨ New Creates account_sac_balances table with account_address, contract_id, balance, is_authorized, is_clawback_enabled, last_modified_ledger
internal/data/sac_balances.go ✨ New SACBalanceModel with GetByAccount, BatchUpsert, BatchCopy methods for CRUD operations
internal/data/mocks.go 📝 Modified Added SACBalanceModelInterface mock for testing
internal/data/models.go 📝 Modified Added SACBalanceModel to models initialization
internal/indexer/types/types.go 📝 Modified Added SACBalanceChange struct and SACBalanceOp enum (ADD, UPDATE, REMOVE)
internal/indexer/processors/sac_balances.go ✨ New SACBalancesProcessor extracts SAC balance changes from contract data ledger entries using sac.ContractBalanceFromContractData SDK function
internal/indexer/indexer_buffer.go 📝 Modified Added SACBalanceChangeKey composite key, sacBalanceChangesByKey map, PushSACBalanceChange/GetSACBalanceChanges methods with deduplication
internal/indexer/indexer.go 📝 Modified Wired SACBalancesProcessor into indexer, calls processor in processTransaction
internal/services/token_ingestion.go 📝 Modified Added processSACBalanceChanges method, updated ProcessTokenChanges interface to accept SAC balance changes, streams SAC balances during checkpointing
internal/services/contract_metadata.go 📝 Modified SAC contract metadata now extracted from ledger data (no RPC needed), only SEP-41 contracts need RPC metadata fetch
internal/services/ingest.go 📝 Modified Updated to pass SAC balance changes to ProcessTokenChanges
internal/services/ingest_live.go 📝 Modified Added SAC balance changes to BatchTokenChanges struct
internal/services/ingest_backfill.go 📝 Modified Added SAC balance changes handling during backfill
internal/serve/graphql/resolvers/queries.resolvers.go 📝 Modified For C-addresses: reads SAC balances from DB; for G-addresses: reads trustlines from DB. Removed RPC calls for SAC balances
internal/serve/graphql/resolvers/account_balances_utils.go 📝 Modified Added buildSACBalanceFromDB helper, removed parseSACBalance (no longer needed), added sacBalances field to accountKeyInfo
internal/serve/graphql/resolvers/balance_reader.go 📝 Modified Added GetSACBalances method to BalanceReader interface
internal/serve/graphql/resolvers/account_balances_test.go 📝 Modified Updated tests to reflect new DB-based SAC balance reading
internal/serve/serve.go 📝 Modified Added SACBalanceModel to resolver dependencies
internal/utils/utils.go 📝 Modified Removed unused SAC balance parsing utilities

Issue that this PR addresses

Closes #460

aditya1702 and others added 30 commits January 13, 2026 12:36
- Replace GetOrInsertTrustlineAssets with EnsureTrustlineAssetsExist (no return)
- Update ProcessTokenChanges to compute IDs directly via DeterministicAssetID
- Add streaming batch processing (50K batches) for checkpoint trustlines
- Remove TrustlineFrequency tracking and processTrustlineAssets (no longer needed)
- Simplify storeAccountTokensInPostgres to storeContractsInPostgres (contracts only)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Rename insertTrustlinesAndContractTokensWithRetry to ensureTokensExistWithRetry
- Call EnsureTrustlineAssetsExist instead of GetOrInsertTrustlineAssets
- Remove trustlineAssetIDMap passing (IDs computed via DeterministicAssetID)
- Update ProcessTokenChanges call to remove assetIDMap parameter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Call EnsureTrustlineAssetsExist instead of GetOrInsertTrustlineAssets
- Remove trustlineAssetIDMap passing (IDs computed via DeterministicAssetID)
- Update ProcessTokenChanges call to remove assetIDMap parameter

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
…icipation

Changed ProcessTokenChanges signature to accept pgx.Tx parameter, removing
the internal db.RunInPgxTransaction call. This allows the function to
participate in an outer transaction for atomicity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Changed EnsureTrustlineAssetsExist signature to accept pgx.Tx parameter,
removing the internal db.RunInPgxTransaction call. This allows the function
to participate in an outer transaction for atomicity with other operations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Replaced in-memory knownContractIDs cache with a DB query to get existing
contract IDs. For < 10k contracts, this DB query is fast enough and
eliminates cache maintenance complexity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Merge ensureTokensExistWithRetry into ingestProcessedDataWithRetry
- All operations (trustline assets, contracts, txs, ops, token changes,
  cursor update) now execute in a single transaction
- Remove initializeKnownContractIDs (no longer needed with DB queries)
- Remove identifyNewContractIDs (cache-related, now unused)
- Remove unused set package import

This ensures atomicity: if cursor update fails, no partial data is committed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
The cache is no longer needed as GetOrInsertContractTokens now queries
the database directly for existing contract IDs. For < 10k contracts,
this DB query is fast enough and eliminates cache maintenance complexity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Updated ingest_backfill.go to use the new signatures:
- EnsureTrustlineAssetsExist now accepts dbTx
- GetOrInsertContractTokens no longer takes cache parameter
- ProcessTokenChanges now accepts dbTx

All three operations now execute in a single atomic transaction.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Updated mock methods to match interface changes:
- EnsureTrustlineAssetsExist now accepts dbTx
- GetOrInsertContractTokens no longer takes cache parameter
- ProcessTokenChanges now accepts dbTx

Removed unused set package import.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
…ert in PopulateAccountTokens

Part of simplification to remove duplicate code in ContractMetadataService.
Use the same pattern as EnsureContractTokensExist for consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
…nterface

Simplify interface to only include FetchMetadata and FetchSingleField methods.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This method is no longer needed as callers use FetchMetadata + BatchInsert directly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This helper was only used by FetchAndStoreMetadata which is now removed.
Also updated file header comment to reflect the service now only fetches metadata.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
These tests are no longer needed as the functions were removed.
Also removed unused imports (db, dbtest, metrics).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
… types

- account_tokens_test.go: setupTrustlineAssets and setupContractTokens
  now use DeterministicAssetID/DeterministicContractID to compute IDs
  since tables require explicit IDs (not auto-generated)
- account_balances_test.go: Partial fix - updated GetAccountContracts
  mock return types from []string{} to []*data.Contract{} and removed
  obsolete BatchGetByIDs mock expectations (still in progress)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
…urn type

GetAccountContracts now returns []*data.Contract instead of []string.
Removed all BatchGetByIDs mock expectations since the implementation
no longer calls this method - contracts are returned directly from
GetAccountContracts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Fixed two additional tests that were still using []string{} instead of
[]*data.Contract{} for GetAccountContracts mock returns, and removed
unnecessary mockContract references.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
…tion

The implementation now:
1. Calls models.TrustlineAsset.BatchInsert (not EnsureTrustlineAssetsExist)
2. Calls contractMetadataService.FetchMetadata (not FetchAndStoreMetadata)
3. Calls ProcessTokenChanges(ctx, dbTx, trustlineChanges, contractChanges)

Removed obsolete EnsureTrustlineAssetsExist mock expectations and updated
tests to use the new ProcessTokenChanges signature.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Removed obsolete EnsureTrustlineAssetsExist and GetOrInsertContractTokens
mock expectations. The implementation now only calls
ProcessTokenChanges(ctx, dbTx, trustlineChanges, contractChanges) on
tokenCacheWriter.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Part of migration from FNV-64 hash to UUID v5 for deterministic IDs
to eliminate collision risk.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Part of migration from FNV-64 hash to UUID v5 for deterministic IDs.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
@aditya1702 aditya1702 self-assigned this Jan 20, 2026
Base automatically changed from store-native-balance to main_balances_postgres January 28, 2026 22:10
@aditya1702 aditya1702 merged commit 45d3e48 into main_balances_postgres Jan 28, 2026
7 checks passed
@aditya1702 aditya1702 deleted the store-sep41-balance branch January 28, 2026 22:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants