// Package explorer implements the read-only queries used by GL's web explorer. package explorer import ( "context" "errors" "fmt" "sort" "strings" "time" "gl/domain/ledger" ) const ( recentJournalLimit = 12 transactionPageSize = 20 topAssetLimit = 5 topHoldersPerAsset = 5 ) type Stats struct { JournalCount int64 EntryCount int64 AccountCount int64 LastRecordedAt time.Time } type Holder struct { Rank int64 OwnerType string OwnerID string AssetID int64 Balance ledger.Amount } type HolderReference struct { OwnerType string OwnerID string AssetID int64 } type Repository interface { Stats(context.Context) (Stats, error) List(context.Context, ledger.JournalFilter) ([]ledger.Journal, error) GetByTransactionHash(context.Context, string) ([]ledger.Journal, error) GetByID(context.Context, string) (ledger.Journal, error) Balance(context.Context, ledger.AccountReference, time.Time) (ledger.Amount, error) TopHolders(context.Context, int, int) ([]Holder, error) } type Service struct { repository Repository } type Dashboard struct { Stats Stats Journals []ledger.Journal } type Assets struct { TopHolders []Holder } type TransactionListing struct { Journals []ledger.Journal Filter TransactionFilter HasPrevious bool HasNext bool } type TransactionFilter struct { Page int Wallet string EffectKind string } type Account struct { Reference ledger.AccountReference Balance ledger.Amount Journals []ledger.Journal } type HolderAccount struct { Reference HolderReference Balance ledger.Amount Journals []ledger.Journal } func NewService(repository Repository) *Service { return &Service{repository: repository} } func (s *Service) Dashboard(ctx context.Context) (Dashboard, error) { stats, err := s.repository.Stats(ctx) if err != nil { return Dashboard{}, fmt.Errorf("read explorer stats: %w", err) } journals, err := s.repository.List(ctx, ledger.JournalFilter{Limit: recentJournalLimit}) if err != nil { return Dashboard{}, fmt.Errorf("read recent journals: %w", err) } return Dashboard{Stats: stats, Journals: journals}, nil } func (s *Service) Assets(ctx context.Context) (Assets, error) { holders, err := s.repository.TopHolders(ctx, topAssetLimit, topHoldersPerAsset) if err != nil { return Assets{}, fmt.Errorf("read top asset holders: %w", err) } return Assets{TopHolders: holders}, nil } func (s *Service) Transactions(ctx context.Context, filter TransactionFilter) (TransactionListing, error) { filter.Wallet = strings.TrimSpace(filter.Wallet) filter.EffectKind = strings.TrimSpace(filter.EffectKind) if filter.Page < 1 { filter.Page = 1 } if filter.Page > 1_000_000 { return TransactionListing{}, fmt.Errorf("transaction page is too large") } if len(filter.Wallet) > 256 || len(filter.EffectKind) > 128 { return TransactionListing{}, fmt.Errorf("transaction filter is too long") } repositoryFilter := ledger.JournalFilter{ Limit: transactionPageSize + 1, Offset: (filter.Page - 1) * transactionPageSize, } if filter.Wallet != "" { repositoryFilter.OwnerID = &filter.Wallet } if filter.EffectKind != "" { repositoryFilter.EffectKind = &filter.EffectKind } journals, err := s.repository.List(ctx, repositoryFilter) if err != nil { return TransactionListing{}, fmt.Errorf("read transaction page: %w", err) } listing := TransactionListing{Journals: journals, Filter: filter, HasPrevious: filter.Page > 1} if len(listing.Journals) > transactionPageSize { listing.HasNext = true listing.Journals = listing.Journals[:transactionPageSize] } return listing, nil } func (s *Service) Transaction(ctx context.Context, reference string) ([]ledger.Journal, error) { reference = strings.TrimSpace(reference) if reference == "" { return nil, fmt.Errorf("transaction reference is required") } if len(reference) > 256 { return nil, fmt.Errorf("transaction reference is too long") } journals, err := s.repository.GetByTransactionHash(ctx, reference) if err != nil { return nil, fmt.Errorf("read transaction: %w", err) } if len(journals) != 0 { return journals, nil } if !isJournalID(reference) { return nil, ledger.ErrNotFound } journal, err := s.repository.GetByID(ctx, reference) if err != nil { if errors.Is(err, ledger.ErrNotFound) { return nil, ledger.ErrNotFound } return nil, fmt.Errorf("read internal transaction: %w", err) } return []ledger.Journal{journal}, nil } func isJournalID(value string) bool { if len(value) != 36 { return false } for index, character := range value { switch index { case 8, 13, 18, 23: if character != '-' { return false } default: if !((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f') || (character >= 'A' && character <= 'F')) { return false } } } return true } func (s *Service) Account(ctx context.Context, reference ledger.AccountReference) (Account, error) { if err := reference.Validate(); err != nil { return Account{}, fmt.Errorf("invalid account: %w", err) } balance, err := s.repository.Balance(ctx, reference, time.Time{}) if err != nil { return Account{}, fmt.Errorf("read account balance: %w", err) } journals, err := s.repository.List(ctx, ledger.JournalFilter{ Account: &reference, AssetID: &reference.AssetID, Limit: 50, }) if err != nil { return Account{}, fmt.Errorf("read account journals: %w", err) } return Account{Reference: reference, Balance: balance, Journals: journals}, nil } func (s *Service) Holder(ctx context.Context, reference HolderReference) (HolderAccount, error) { if strings.TrimSpace(reference.OwnerType) == "" || strings.TrimSpace(reference.OwnerID) == "" || reference.AssetID <= 0 { return HolderAccount{}, fmt.Errorf("invalid holder reference") } result := HolderAccount{Reference: reference} seen := make(map[string]struct{}) for _, class := range []ledger.AccountClass{ledger.AccountClassUserAvailable, ledger.AccountClassUserFrozen} { account := ledger.AccountReference{Class: class, OwnerType: reference.OwnerType, OwnerID: reference.OwnerID, AssetID: reference.AssetID} balance, err := s.repository.Balance(ctx, account, time.Time{}) if err != nil { return HolderAccount{}, fmt.Errorf("read holder balance: %w", err) } result.Balance = result.Balance.Add(balance) journals, err := s.repository.List(ctx, ledger.JournalFilter{Account: &account, AssetID: &reference.AssetID, Limit: 50}) if err != nil { return HolderAccount{}, fmt.Errorf("read holder journals: %w", err) } for _, journal := range journals { if _, exists := seen[journal.ID]; exists { continue } seen[journal.ID] = struct{}{} result.Journals = append(result.Journals, journal) } } sort.Slice(result.Journals, func(left, right int) bool { if result.Journals[left].RecordedAt.Equal(result.Journals[right].RecordedAt) { return result.Journals[left].ID > result.Journals[right].ID } return result.Journals[left].RecordedAt.After(result.Journals[right].RecordedAt) }) if len(result.Journals) > 50 { result.Journals = result.Journals[:50] } return result, nil } func IsNotFound(err error) bool { return errors.Is(err, ledger.ErrNotFound) }