Files

256 lines
7.1 KiB
Go

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