diff --git a/database/query/interval.go b/database/query/interval.go index 08bff39..8539f74 100644 --- a/database/query/interval.go +++ b/database/query/interval.go @@ -8,6 +8,8 @@ type Interval uint8 const ( IntervalRaw Interval = iota + IntervalSecond + IntervalMinute IntervalHour IntervalDay IntervalWeek @@ -16,6 +18,8 @@ const ( var AllIntervals = []Interval{ IntervalRaw, + IntervalSecond, + IntervalMinute, IntervalHour, IntervalDay, IntervalWeek, @@ -26,6 +30,10 @@ func ToInterval(val string) (Interval, error) { switch val { case "raw": return IntervalRaw, nil + case "second": + return IntervalSecond, nil + case "minute": + return IntervalMinute, nil case "hour": return IntervalHour, nil case "day": @@ -43,6 +51,10 @@ func FromInterval(val Interval) (string, error) { switch val { case IntervalRaw: return "raw", nil + case IntervalSecond: + return "second", nil + case IntervalMinute: + return "minute", nil case IntervalHour: return "hour", nil case IntervalDay: diff --git a/go.mod b/go.mod index 44dae3f..c870383 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( filippo.io/edwards25519 v1.1.0 github.com/aws/aws-sdk-go-v2 v0.17.0 github.com/code-payments/code-vm-indexer v1.2.0 - github.com/code-payments/ocp-protobuf-api v0.7.0 + github.com/code-payments/ocp-protobuf-api v0.8.0 github.com/emirpasic/gods v1.12.0 github.com/envoyproxy/protoc-gen-validate v1.2.1 github.com/golang/protobuf v1.5.4 diff --git a/go.sum b/go.sum index e19533f..70987d7 100644 --- a/go.sum +++ b/go.sum @@ -78,8 +78,8 @@ github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= github.com/code-payments/code-vm-indexer v1.2.0 h1:rSHpBMiT9BKgmKcXg/VIoi/h0t7jNxGx07Qz59m+6Q0= github.com/code-payments/code-vm-indexer v1.2.0/go.mod h1:vn91YN2qNqb+gGJeZe2+l+TNxVmEEiRHXXnIn2Y40h8= -github.com/code-payments/ocp-protobuf-api v0.7.0 h1:pHIVYXmDus32LEzaj92qDWKYrPawuzBIJ+Xlzzf9udg= -github.com/code-payments/ocp-protobuf-api v0.7.0/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= +github.com/code-payments/ocp-protobuf-api v0.8.0 h1:Fm1dH7dFQu/xn5ohuPrJqcju8pWtIi4upH5f97KVrvY= +github.com/code-payments/ocp-protobuf-api v0.8.0/go.mod h1:tw6BooY5a8l6CtSZnKOruyKII0W04n89pcM4BizrgG8= github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6 h1:NmTXa/uVnDyp0TY5MKi197+3HWcnYWfnHGyaFthlnGw= github.com/containerd/continuity v0.0.0-20190827140505-75bee3e2ccb6/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= diff --git a/ocp/data/currency/memory/store.go b/ocp/data/currency/memory/store.go index 8232c98..a67d7c6 100644 --- a/ocp/data/currency/memory/store.go +++ b/ocp/data/currency/memory/store.go @@ -235,3 +235,32 @@ func (s *store) GetReserveAtTime(ctx context.Context, mint string, t time.Time) return results[0].Clone(), nil } + +func (s *store) GetReservesInRange(ctx context.Context, mint string, interval query.Interval, start time.Time, end time.Time, ordering query.Ordering) ([]*currency.ReserveRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + + sort.Sort(ReserveByTime(s.reserveRecords)) + + // Not ideal but fine for testing the currency store + var all []*currency.ReserveRecord + for _, item := range s.reserveRecords { + if item.Mint == mint && item.Time.Unix() >= start.Unix() && item.Time.Unix() <= end.Unix() { + all = append(all, item.Clone()) + } + } + + // TODO: handle the interval + + if len(all) == 0 { + return nil, currency.ErrNotFound + } + + if ordering == query.Ascending { + for i, j := 0, len(all)-1; i < j; i, j = i+1, j-1 { + all[i], all[j] = all[j], all[i] + } + } + + return all, nil +} diff --git a/ocp/data/currency/postgres/model.go b/ocp/data/currency/postgres/model.go index 25f1b67..a277353 100644 --- a/ocp/data/currency/postgres/model.go +++ b/ocp/data/currency/postgres/model.go @@ -361,3 +361,20 @@ func dbGetReserveByMintAndTime(ctx context.Context, db *sqlx.DB, mint string, t ) return res, pgutil.CheckNoRows(err, currency.ErrNotFound) } + +func dbGetAllReservesForRange(ctx context.Context, db *sqlx.DB, mint string, interval q.Interval, start time.Time, end time.Time, ordering q.Ordering) ([]*reserveModel, error) { + res := []*reserveModel{} + err := db.SelectContext(ctx, &res, + makeTimeBasedRangeQuery(reserveTableName, "mint = $1 AND for_timestamp >= $2 AND for_timestamp <= $3", ordering, interval), + mint, start.UTC(), end.UTC(), + ) + + if err != nil { + return nil, pgutil.CheckNoRows(err, currency.ErrNotFound) + } + if len(res) == 0 { + return nil, currency.ErrNotFound + } + + return res, nil +} diff --git a/ocp/data/currency/postgres/store.go b/ocp/data/currency/postgres/store.go index 4a4f9e0..d5d03eb 100644 --- a/ocp/data/currency/postgres/store.go +++ b/ocp/data/currency/postgres/store.go @@ -147,3 +147,34 @@ func (s *store) GetReserveAtTime(ctx context.Context, mint string, t time.Time) } return fromReserveModel(model), nil } + +func (s *store) GetReservesInRange(ctx context.Context, mint string, interval query.Interval, start time.Time, end time.Time, ordering query.Ordering) ([]*currency.ReserveRecord, error) { + if interval > query.IntervalMonth { + return nil, currency.ErrInvalidInterval + } + + if start.IsZero() || end.IsZero() { + return nil, currency.ErrInvalidRange + } + + var actualStart, actualEnd time.Time + if start.Unix() > end.Unix() { + actualStart = end + actualEnd = start + } else { + actualStart = start + actualEnd = end + } + + list, err := dbGetAllReservesForRange(ctx, s.db, mint, interval, actualStart, actualEnd, ordering) + if err != nil { + return nil, err + } + + res := []*currency.ReserveRecord{} + for _, item := range list { + res = append(res, fromReserveModel(item)) + } + + return res, nil +} diff --git a/ocp/data/currency/store.go b/ocp/data/currency/store.go index 616a7fa..964ede2 100644 --- a/ocp/data/currency/store.go +++ b/ocp/data/currency/store.go @@ -58,4 +58,13 @@ type Store interface { // // ErrNotFound is returned if no reserve data was found for the provided Timestamp. GetReserveAtTime(ctx context.Context, mint string, t time.Time) (*ReserveRecord, error) + + // GetReservesInRange gets the reserve records for a range of time given a currency + // creator mint and interval. The start and end timestamps are provided along with + // the interval. + // + // ErrNotFound is returned if the mint or the reserves for the mint cannot be found + // ErrInvalidRange is returned if the range is not valid + // ErrInvalidInterval is returned if the interval is not valid + GetReservesInRange(ctx context.Context, mint string, interval query.Interval, start time.Time, end time.Time, ordering query.Ordering) ([]*ReserveRecord, error) } diff --git a/ocp/data/currency/tests/tests.go b/ocp/data/currency/tests/tests.go index 28d27bb..29947de 100644 --- a/ocp/data/currency/tests/tests.go +++ b/ocp/data/currency/tests/tests.go @@ -19,6 +19,7 @@ func RunTests(t *testing.T, s currency.Store, teardown func()) { testGetExchangeRatesInRange, testMetadataRoundTrip, testReserveRoundTrip, + testGetReservesInRange, } { tf(t, s) teardown() @@ -100,12 +101,31 @@ func testGetExchangeRatesInRange(t *testing.T, s currency.Store) { result, err := s.GetExchangeRatesInRange(context.Background(), "usd", query.IntervalRaw, rates[0].Time, rates[99].Time, query.Ascending) require.NoError(t, err) assert.Equal(t, len(result), 100) + for i, item := range result { + assert.Equal(t, rates[i].Time.Unix(), item.Time.Unix()) + assert.EqualValues(t, rates[i].Rates["usd"], item.Rate) + } + result, err = s.GetExchangeRatesInRange(context.Background(), "usd", query.IntervalRaw, rates[0].Time, rates[49].Time, query.Ascending) + require.NoError(t, err) + assert.Equal(t, len(result), 50) for i, item := range result { assert.Equal(t, rates[i].Time.Unix(), item.Time.Unix()) assert.EqualValues(t, rates[i].Rates["usd"], item.Rate) } + result, err = s.GetExchangeRatesInRange(context.Background(), "usd", query.IntervalRaw, rates[0].Time, rates[99].Time, query.Descending) + require.NoError(t, err) + assert.Equal(t, len(result), 100) + for i, item := range result { + assert.Equal(t, rates[99-i].Time.Unix(), item.Time.Unix()) + assert.EqualValues(t, rates[99-i].Rates["usd"], item.Rate) + } + + _, err = s.GetExchangeRatesInRange(context.Background(), "usd", query.IntervalSecond, rates[0].Time, rates[99].Time, query.Ascending) + require.NoError(t, err) + _, err = s.GetExchangeRatesInRange(context.Background(), "usd", query.IntervalMinute, rates[0].Time, rates[99].Time, query.Ascending) + require.NoError(t, err) _, err = s.GetExchangeRatesInRange(context.Background(), "usd", query.IntervalHour, rates[0].Time, rates[99].Time, query.Ascending) require.NoError(t, err) _, err = s.GetExchangeRatesInRange(context.Background(), "usd", query.IntervalDay, rates[0].Time, rates[99].Time, query.Ascending) @@ -196,6 +216,67 @@ func testReserveRoundTrip(t *testing.T, s currency.Store) { assert.Equal(t, currency.ErrNotFound, err) } +func testGetReservesInRange(t *testing.T, s currency.Store) { + var reserves []currency.ReserveRecord + + now := time.Now().UTC() + mint := "test-mint" + + for i := 0; i < 100; i++ { + reserves = append(reserves, currency.ReserveRecord{ + Mint: mint, + SupplyFromBonding: uint64(1000 + i), + Time: now.Add(time.Duration(i) * time.Hour), + }) + } + + record, err := s.GetReserveAtTime(context.Background(), mint, reserves[0].Time) + assert.Nil(t, record) + assert.Equal(t, currency.ErrNotFound, err) + + for _, item := range reserves { + itemCopy := item + require.NoError(t, s.PutReserveRecord(context.Background(), &itemCopy)) + } + + result, err := s.GetReservesInRange(context.Background(), mint, query.IntervalRaw, reserves[0].Time, reserves[99].Time, query.Ascending) + require.NoError(t, err) + assert.Equal(t, len(result), 100) + for i, item := range result { + assert.Equal(t, reserves[i].Time.Unix(), item.Time.Unix()) + assert.EqualValues(t, reserves[i].SupplyFromBonding, item.SupplyFromBonding) + } + + result, err = s.GetReservesInRange(context.Background(), mint, query.IntervalRaw, reserves[0].Time, reserves[49].Time, query.Ascending) + require.NoError(t, err) + assert.Equal(t, len(result), 50) + for i, item := range result { + assert.Equal(t, reserves[i].Time.Unix(), item.Time.Unix()) + assert.EqualValues(t, reserves[i].SupplyFromBonding, item.SupplyFromBonding) + } + + result, err = s.GetReservesInRange(context.Background(), mint, query.IntervalRaw, reserves[0].Time, reserves[99].Time, query.Descending) + require.NoError(t, err) + assert.Equal(t, len(result), 100) + for i, item := range result { + assert.Equal(t, reserves[99-i].Time.Unix(), item.Time.Unix()) + assert.EqualValues(t, reserves[99-i].SupplyFromBonding, item.SupplyFromBonding) + } + + _, err = s.GetReservesInRange(context.Background(), mint, query.IntervalSecond, reserves[0].Time, reserves[99].Time, query.Ascending) + require.NoError(t, err) + _, err = s.GetReservesInRange(context.Background(), mint, query.IntervalMinute, reserves[0].Time, reserves[99].Time, query.Ascending) + require.NoError(t, err) + _, err = s.GetReservesInRange(context.Background(), mint, query.IntervalHour, reserves[0].Time, reserves[99].Time, query.Ascending) + require.NoError(t, err) + _, err = s.GetReservesInRange(context.Background(), mint, query.IntervalDay, reserves[0].Time, reserves[99].Time, query.Ascending) + require.NoError(t, err) + _, err = s.GetReservesInRange(context.Background(), mint, query.IntervalWeek, reserves[0].Time, reserves[99].Time, query.Ascending) + require.NoError(t, err) + _, err = s.GetReservesInRange(context.Background(), mint, query.IntervalMonth, reserves[0].Time, reserves[99].Time, query.Ascending) + require.NoError(t, err) +} + func assertEquivalentMetadataRecords(t *testing.T, obj1, obj2 *currency.MetadataRecord) { assert.Equal(t, obj1.Name, obj2.Name) assert.Equal(t, obj1.Symbol, obj2.Symbol) diff --git a/ocp/data/internal.go b/ocp/data/internal.go index 6bc6a62..1f1f356 100644 --- a/ocp/data/internal.go +++ b/ocp/data/internal.go @@ -136,6 +136,7 @@ type DatabaseData interface { GetCurrencyMetadata(ctx context.Context, mint string) (*currency.MetadataRecord, error) PutCurrencyReserve(ctx context.Context, record *currency.ReserveRecord) error GetCurrencyReserveAtTime(ctx context.Context, mint string, t time.Time) (*currency.ReserveRecord, error) + GetCurrencyReserveHistory(ctx context.Context, mint string, opts ...query.Option) ([]*currency.ReserveRecord, error) // Deposits // -------------------------------------------------------------------------------- @@ -515,6 +516,24 @@ func (dp *DatabaseProvider) PutCurrencyReserve(ctx context.Context, record *curr func (dp *DatabaseProvider) GetCurrencyReserveAtTime(ctx context.Context, mint string, t time.Time) (*currency.ReserveRecord, error) { return dp.currencies.GetReserveAtTime(ctx, mint, t) } +func (dp *DatabaseProvider) GetCurrencyReserveHistory(ctx context.Context, mint string, opts ...query.Option) ([]*currency.ReserveRecord, error) { + req := query.QueryOptions{ + Limit: maxCurrencyHistoryReqSize, + End: time.Now(), + SortBy: query.Ascending, + Supported: query.CanLimitResults | query.CanSortBy | query.CanBucketBy | query.CanQueryByStartTime | query.CanQueryByEndTime, + } + req.Apply(opts...) + + if req.Start.IsZero() { + return nil, query.ErrQueryNotSupported + } + if req.Limit > maxCurrencyHistoryReqSize { + return nil, query.ErrQueryNotSupported + } + + return dp.currencies.GetReservesInRange(ctx, mint, req.Interval, req.Start, req.End, req.SortBy) +} // Deposits // -------------------------------------------------------------------------------- diff --git a/ocp/rpc/currency/server.go b/ocp/rpc/currency/server.go index 7c147ca..0833a70 100644 --- a/ocp/rpc/currency/server.go +++ b/ocp/rpc/currency/server.go @@ -2,23 +2,28 @@ package currency import ( "context" + "fmt" "strings" "time" - "github.com/pkg/errors" "go.uber.org/zap" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" currencypb "github.com/code-payments/ocp-protobuf-api/generated/go/currency/v1" + "github.com/pkg/errors" + "github.com/code-payments/ocp-server/cache" + currency_lib "github.com/code-payments/ocp-server/currency" + "github.com/code-payments/ocp-server/database/query" "github.com/code-payments/ocp-server/grpc/client" "github.com/code-payments/ocp-server/ocp/common" "github.com/code-payments/ocp-server/ocp/config" currency_util "github.com/code-payments/ocp-server/ocp/currency" ocp_data "github.com/code-payments/ocp-server/ocp/data" "github.com/code-payments/ocp-server/ocp/data/currency" + "github.com/code-payments/ocp-server/solana/currencycreator" timelock_token "github.com/code-payments/ocp-server/solana/timelock/v1" ) @@ -26,6 +31,9 @@ type currencyServer struct { log *zap.Logger data ocp_data.Provider + exchangeRateHistoryCache cache.Cache + reserveHistoryCache cache.Cache + currencypb.UnimplementedCurrencyServer } @@ -36,6 +44,9 @@ func NewCurrencyServer( return ¤cyServer{ log: log, data: data, + + exchangeRateHistoryCache: cache.NewCache(1_000), + reserveHistoryCache: cache.NewCache(1_000), } } @@ -45,9 +56,9 @@ func (s *currencyServer) GetAllRates(ctx context.Context, req *currencypb.GetAll var record *currency.MultiRateRecord if req.Timestamp != nil && req.Timestamp.AsTime().Before(time.Now().Add(-15*time.Minute)) { - record, err = s.LoadExchangeRatesForTime(ctx, req.Timestamp.AsTime()) + record, err = s.loadExchangeRatesForTime(ctx, req.Timestamp.AsTime()) } else if req.Timestamp == nil || req.Timestamp.AsTime().Sub(time.Now()) < time.Hour { - record, err = s.LoadExchangeRatesLatest(ctx) + record, err = s.loadExchangeRatesLatest(ctx) } else { return nil, status.Error(codes.InvalidArgument, "timestamp too far in the future") } @@ -189,7 +200,120 @@ func (s *currencyServer) GetMints(ctx context.Context, req *currencypb.GetMintsR return resp, nil } -func (s *currencyServer) LoadExchangeRatesForTime(ctx context.Context, t time.Time) (*currency.MultiRateRecord, error) { +func (s *currencyServer) GetHistoricalMintData(ctx context.Context, req *currencypb.GetHistoricalMintDataRequest) (*currencypb.GetHistoricalMintDataResponse, error) { + log := s.log.With(zap.String("method", "GetHistoricalMintData")) + log = client.InjectLoggingMetadata(ctx, log) + + mintAccount, err := common.NewAccountFromProto(req.Address) + if err != nil { + log.With(zap.Error(err)).Warn("invalid mint address") + return nil, status.Error(codes.Internal, "") + } + + log = log.With( + zap.String("mint", mintAccount.PublicKey().ToBase58()), + zap.String("range", req.GetPredefinedRange().String()), + ) + + // Only support currency creator mints, not the core mint + if common.IsCoreMint(mintAccount) { + return ¤cypb.GetHistoricalMintDataResponse{ + Result: currencypb.GetHistoricalMintDataResponse_NOT_FOUND, + }, nil + } + + // Get currency code + currencyCode := currency_lib.Code(strings.ToLower(req.CurrencyCode)) + if currencyCode == "" { + return ¤cypb.GetHistoricalMintDataResponse{ + Result: currencypb.GetHistoricalMintDataResponse_NOT_FOUND, + }, nil + } + + // Verify the mint exists as a currency creator mint + _, err = s.data.GetCurrencyMetadata(ctx, mintAccount.PublicKey().ToBase58()) + if err == currency.ErrNotFound { + return ¤cypb.GetHistoricalMintDataResponse{ + Result: currencypb.GetHistoricalMintDataResponse_NOT_FOUND, + }, nil + } else if err != nil { + log.With(zap.Error(err)).Warn("failed to load currency metadata") + return nil, status.Error(codes.Internal, "") + } + + // Determine the time range and interval based on the predefined range. + // endTime is GetLatestExchangeRateTime() which is used in cache keys to + // invalidate entries when new market data is generated (every 15 minutes). + startTime, endTime, interval := getTimeRangeForPredefinedRange(req.GetPredefinedRange()) + + // Get reserve history (cached by mint + range) + reserveHistory, err := s.getCachedReserveHistory( + ctx, + mintAccount.PublicKey().ToBase58(), + req.GetPredefinedRange(), + startTime, + endTime, + interval, + ) + if err != nil { + log.With(zap.Error(err)).Warn("failed to load currency reserve history") + return nil, status.Error(codes.Internal, "") + } + + if len(reserveHistory) == 0 { + return ¤cypb.GetHistoricalMintDataResponse{ + Result: currencypb.GetHistoricalMintDataResponse_MISSING_DATA, + }, nil + } + + // Get exchange rate history (cached by currency code + range) + exchangeRateHistory, err := s.getCachedExchangeRateHistory( + ctx, + currencyCode, + req.GetPredefinedRange(), + startTime, + endTime, + interval, + ) + if err != nil { + log.With(zap.Error(err)).Warn("failed to load exchange rate history") + return nil, status.Error(codes.Internal, "") + } + + if len(exchangeRateHistory) == 0 { + return ¤cypb.GetHistoricalMintDataResponse{ + Result: currencypb.GetHistoricalMintDataResponse_MISSING_DATA, + }, nil + } + + // Build historical data points with market cap + data := make([]*currencypb.HistoricalMintData, 0, len(reserveHistory)) + for _, reserve := range reserveHistory { + // Find the closest exchange rate for this time point + exchangeRate, ok := findClosestExchangeRate(reserve.Time, exchangeRateHistory) + if !ok { + continue + } + + data = append(data, ¤cypb.HistoricalMintData{ + Timestamp: timestamppb.New(reserve.Time), + MarketCap: calculateMarketCap(reserve.SupplyFromBonding, exchangeRate), + }) + } + + if len(data) == 0 { + return ¤cypb.GetHistoricalMintDataResponse{ + Result: currencypb.GetHistoricalMintDataResponse_MISSING_DATA, + }, nil + } + + return ¤cypb.GetHistoricalMintDataResponse{ + Result: currencypb.GetHistoricalMintDataResponse_OK, + Data: data, + }, nil +} + +func (s *currencyServer) loadExchangeRatesForTime(ctx context.Context, t time.Time) (*currency.MultiRateRecord, error) { record, err := s.data.GetAllExchangeRates(ctx, t) if err != nil { return nil, errors.Wrap(err, "failed to get price record by date") @@ -197,10 +321,125 @@ func (s *currencyServer) LoadExchangeRatesForTime(ctx context.Context, t time.Ti return record, nil } -func (s *currencyServer) LoadExchangeRatesLatest(ctx context.Context) (*currency.MultiRateRecord, error) { +func (s *currencyServer) loadExchangeRatesLatest(ctx context.Context) (*currency.MultiRateRecord, error) { latest, err := s.data.GetAllExchangeRates(ctx, currency_util.GetLatestExchangeRateTime()) if err != nil { return nil, errors.Wrap(err, "failed to get latest price record") } return latest, nil } + +func (s *currencyServer) getCachedReserveHistory( + ctx context.Context, + mint string, + predefinedRange currencypb.GetHistoricalMintDataRequest_PredefinedRange, + startTime, endTime time.Time, + interval query.Interval, +) ([]*currency.ReserveRecord, error) { + cacheKey := fmt.Sprintf("%s:%s:%d", mint, predefinedRange.String(), endTime.Unix()) + + if cached, ok := s.reserveHistoryCache.Retrieve(cacheKey); ok { + return cached.([]*currency.ReserveRecord), nil + } + + reserveHistory, err := s.data.GetCurrencyReserveHistory( + ctx, + mint, + query.WithStartTime(startTime), + query.WithEndTime(endTime), + query.WithInterval(interval), + query.WithDirection(query.Ascending), + ) + if err != nil { + return nil, err + } + + s.reserveHistoryCache.Insert(cacheKey, reserveHistory, 1) + return reserveHistory, nil +} + +func (s *currencyServer) getCachedExchangeRateHistory( + ctx context.Context, + currencyCode currency_lib.Code, + predefinedRange currencypb.GetHistoricalMintDataRequest_PredefinedRange, + startTime, endTime time.Time, + interval query.Interval, +) ([]*currency.ExchangeRateRecord, error) { + cacheKey := fmt.Sprintf("%s:%s:%d", currencyCode, predefinedRange.String(), endTime.Unix()) + + if cached, ok := s.exchangeRateHistoryCache.Retrieve(cacheKey); ok { + return cached.([]*currency.ExchangeRateRecord), nil + } + + exchangeRateHistory, err := s.data.GetExchangeRateHistory( + ctx, + currencyCode, + query.WithStartTime(startTime), + query.WithEndTime(endTime), + query.WithInterval(interval), + query.WithDirection(query.Ascending), + ) + if err != nil { + return nil, err + } + + s.exchangeRateHistoryCache.Insert(cacheKey, exchangeRateHistory, 1) + return exchangeRateHistory, nil +} + +// findClosestExchangeRate finds the exchange rate closest to the given time. +func findClosestExchangeRate(t time.Time, history []*currency.ExchangeRateRecord) (float64, bool) { + // Find the closest preceding exchange rate + var closestRate float64 + var found bool + for _, record := range history { + if record.Time.After(t) { + break + } + closestRate = record.Rate + found = true + } + + return closestRate, found +} + +// calculateMarketCap calculates the market cap for a currency creator mint. +// Market cap = price per token × circulating supply. +func calculateMarketCap(supplyFromBonding uint64, exchangeRate float64) float64 { + // Calculate the spot price per token using the bonding curve + // This returns the price in core mint tokens per currency creator token + spotPrice := currencycreator.EstimateCurrentPrice(supplyFromBonding) + spotPriceFloat, _ := spotPrice.Float64() + + // Convert spot price to the requested currency + // spotPriceFloat is in core mint tokens, exchangeRate is currency per core mint token + pricePerToken := spotPriceFloat * exchangeRate + + // Calculate circulating supply in token units (not quarks) + circulatingSupply := float64(supplyFromBonding) / float64(currencycreator.DefaultMintQuarksPerUnit) + + // Market cap = price per token × circulating supply + return pricePerToken * circulatingSupply +} + +// getTimeRangeForPredefinedRange returns the start time and appropriate interval +// for the given predefined range. +func getTimeRangeForPredefinedRange(predefinedRange currencypb.GetHistoricalMintDataRequest_PredefinedRange) (time.Time, time.Time, query.Interval) { + now := currency_util.GetLatestExchangeRateTime() + + switch predefinedRange { + case currencypb.GetHistoricalMintDataRequest_LAST_DAY: + return now.Add(-24 * time.Hour), now, query.IntervalMinute + case currencypb.GetHistoricalMintDataRequest_LAST_WEEK: + return now.Add(-7 * 24 * time.Hour), now, query.IntervalHour + case currencypb.GetHistoricalMintDataRequest_LAST_MONTH: + return now.Add(-30 * 24 * time.Hour), now, query.IntervalHour + case currencypb.GetHistoricalMintDataRequest_LAST_YEAR: + return now.Add(-365 * 24 * time.Hour), now, query.IntervalDay + case currencypb.GetHistoricalMintDataRequest_ALL_TIME: + fallthrough + default: + // For all time, go back 100 years with daily intervals + return now.Add(-100 * 365 * 24 * time.Hour), now, query.IntervalDay + } +}