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
4 changes: 4 additions & 0 deletions ocp/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,15 @@ const (

CoreMintVmAccountPublicKey = "JACkaKsm2Rd6TNJwH4UB7G6tHrWUATJPTgNNnRVsg4ip"
CoreMintVmOmnibusPublicKey = "D8oUTXRvarxhx9cjYdFJqWAVj2rmzry58bS6JSTiQsv5"

DefaultCurrencyIconImageUrl = "https://flipcash-currency-assets.s3.us-east-1.amazonaws.com/default/icon.jpg"
)

var (
CoreMintImageUrl = fmt.Sprintf("https://flipcash-currency-assets.s3.us-east-1.amazonaws.com/%s/icon.png", CoreMintPublicKeyString)
CoreMintPublicKeyBytes []byte

DefaultBillColors = []string{"#AAAAAA", "#2C2C2C"}
)

func init() {
Expand Down
324 changes: 324 additions & 0 deletions ocp/rpc/currency/historical_data.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,324 @@
package currency

import (
"context"
"fmt"
"strings"
"time"

"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"
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/data/currency"
)

const (
timePerHistoricalUpdate = 5 * time.Minute
)

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 &currencypb.GetHistoricalMintDataResponse{
Result: currencypb.GetHistoricalMintDataResponse_NOT_FOUND,
}, nil
}

// Get currency code
currencyCode := currency_lib.Code(strings.ToLower(req.CurrencyCode))
if currencyCode == "" {
return &currencypb.GetHistoricalMintDataResponse{
Result: currencypb.GetHistoricalMintDataResponse_NOT_FOUND,
}, nil
}

// Verify the mint exists as a currency creator mint
metadataRecord, err := s.data.GetCurrencyMetadata(ctx, mintAccount.PublicKey().ToBase58())
if err == currency.ErrNotFound {
return &currencypb.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, "")
}
if metadataRecord.State != currency.MetadataStateAvailable {
return &currencypb.GetHistoricalMintDataResponse{
Result: currencypb.GetHistoricalMintDataResponse_NOT_FOUND,
}, nil
}

// Determine the time range and interval based on the predefined range.
// The interval is adjusted based on the currency's age so younger
// currencies get finer-grained data points.
// endTime is getLatestHistoricalTime, which is used in cache keys to
// invalidate entries when new market data is generated.
startTime, endTime, interval := getTimeRangeForPredefinedRange(req.GetPredefinedRange(), metadataRecord.CreatedAt)

// Get reserve history (cached by mint + range)
reserveHistory, err := s.getCachedReserveHistory(
ctx,
mintAccount.PublicKey().ToBase58(),
req.GetPredefinedRange(),
startTime,
endTime,
interval,
)
if err == currency.ErrNotFound {
return &currencypb.GetHistoricalMintDataResponse{
Result: currencypb.GetHistoricalMintDataResponse_MISSING_DATA,
}, nil
} else 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 &currencypb.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 == currency.ErrNotFound {
return &currencypb.GetHistoricalMintDataResponse{
Result: currencypb.GetHistoricalMintDataResponse_MISSING_DATA,
}, nil
} else 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 &currencypb.GetHistoricalMintDataResponse{
Result: currencypb.GetHistoricalMintDataResponse_MISSING_DATA,
}, nil
}

// Build historical data points with market cap
var data []*currencypb.HistoricalMintData

if startTime.Before(metadataRecord.CreatedAt) {
if req.GetPredefinedRange() != currencypb.GetHistoricalMintDataRequest_ALL_TIME {
data = append(
data,
// 0 market cap value at the range start time
&currencypb.HistoricalMintData{
Timestamp: timestamppb.New(startTime),
MarketCap: 0,
},
)
}
data = append(
data,
// 0 market cap value at time of currency creation
&currencypb.HistoricalMintData{
Timestamp: timestamppb.New(metadataRecord.CreatedAt),
MarketCap: 0,
},
)
}

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, &currencypb.HistoricalMintData{
Timestamp: timestamppb.New(reserve.Time),
MarketCap: calculateMarketCap(reserve.SupplyFromBonding, exchangeRate),
})
}

// Always include a latest data point based on getLatestHistoricalTime
// if it's newer than the last historical point
latestTime := getLatestHistoricalTime()
if len(data) == 0 || data[len(data)-1].Timestamp.AsTime().Before(latestTime) {
func() {
latestReserve, err := s.data.GetCurrencyReserveAtTime(ctx, mintAccount.PublicKey().ToBase58(), latestTime)
if err != nil {
log.With(zap.Error(err)).Warn("failed to load latest currency reserve")
return
}

latestExchangeRate, err := s.data.GetExchangeRate(ctx, currencyCode, latestTime)
if err != nil {
log.With(zap.Error(err)).Warn("failed to load latest exchange rate")
return
}

data = append(data, &currencypb.HistoricalMintData{
Timestamp: timestamppb.New(latestTime),
MarketCap: calculateMarketCap(latestReserve.SupplyFromBonding, latestExchangeRate.Rate),
})
}()
}

if len(data) == 0 {
return &currencypb.GetHistoricalMintDataResponse{
Result: currencypb.GetHistoricalMintDataResponse_MISSING_DATA,
}, nil
}

return &currencypb.GetHistoricalMintDataResponse{
Result: currencypb.GetHistoricalMintDataResponse_OK,
Data: data,
}, 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
}

// getTimeRangeForPredefinedRange returns the start time and appropriate interval
// for the given predefined range. The interval is adjusted based on the
// currency's age so that younger currencies get finer-grained data points.
func getTimeRangeForPredefinedRange(predefinedRange currencypb.GetHistoricalMintDataRequest_PredefinedRange, createdAt time.Time) (time.Time, time.Time, query.Interval) {
now := getLatestHistoricalTime()
currencyAge := now.Sub(createdAt)

switch predefinedRange {
case currencypb.GetHistoricalMintDataRequest_LAST_DAY:
return now.Add(-24 * time.Hour), now, query.IntervalMinute
case currencypb.GetHistoricalMintDataRequest_LAST_WEEK:
interval := query.IntervalHour
if currencyAge < 2*24*time.Hour {
interval = query.IntervalMinute
}
return now.Add(-7 * 24 * time.Hour), now, interval
case currencypb.GetHistoricalMintDataRequest_LAST_MONTH:
interval := query.IntervalHour
if currencyAge < 2*24*time.Hour {
interval = query.IntervalMinute
}
return now.Add(-30 * 24 * time.Hour), now, interval
case currencypb.GetHistoricalMintDataRequest_LAST_YEAR:
interval := query.IntervalDay
if currencyAge < 2*24*time.Hour {
interval = query.IntervalMinute
} else if currencyAge < 2*7*24*time.Hour {
interval = query.IntervalHour
}
return now.Add(-365 * 24 * time.Hour), now, interval
case currencypb.GetHistoricalMintDataRequest_ALL_TIME:
fallthrough
default:
interval := query.IntervalDay
if currencyAge < 2*24*time.Hour {
interval = query.IntervalMinute
} else if currencyAge < 2*7*24*time.Hour {
interval = query.IntervalHour
}
// For all time, go back 100 years
return now.Add(-100 * 365 * 24 * time.Hour), now, interval
}
}

func getLatestHistoricalTime() time.Time {
secondsInUpdateInterval := int64(timePerHistoricalUpdate / time.Second)
queryTimeUnix := time.Now().Unix()
queryTimeUnix = queryTimeUnix - (queryTimeUnix % secondsInUpdateInterval)
return time.Unix(queryTimeUnix, 0)
}
Loading
Loading