diff --git a/application.yaml b/application.yaml index fd8efa1..db2978e 100644 --- a/application.yaml +++ b/application.yaml @@ -32,6 +32,7 @@ cmd/freezer-refrigerant: jwtSecret: bogus tokenomics: &tokenomics kyc: + get-eskimo-user-state-url: https://something.example.com config-json-url: https://ice-staging.b-cdn.net/something/somebogus.json liveness-delay: 5m bookkeeper/storage: &bookkeeperStorage diff --git a/cmd/freezer-refrigerant/contract.go b/cmd/freezer-refrigerant/contract.go index 0ef5593..eab4058 100644 --- a/cmd/freezer-refrigerant/contract.go +++ b/cmd/freezer-refrigerant/contract.go @@ -13,9 +13,11 @@ type ( StartNewMiningSessionRequestBody struct { // Specify this if you want to resurrect the user. // `true` recovers all the lost balance, `false` deletes it forever, `null/undefined` does nothing. Default is `null/undefined`. - Resurrect *bool `json:"resurrect" example:"true"` - UserID string `uri:"userId" swaggerignore:"true" required:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` - XClientType string `form:"x_client_type" swaggerignore:"true" required:"false" example:"web"` + Resurrect *bool `json:"resurrect" example:"true"` + UserID string `uri:"userId" swaggerignore:"true" required:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` + XClientType string `form:"x_client_type" swaggerignore:"true" required:"false" example:"web"` + Authorization string `header:"Authorization" swaggerignore:"true" required:"true" example:"some token"` + XAccountMetadata string `header:"X-Account-Metadata" swaggerignore:"true" required:"false" example:"some token"` // Specify this if you want to skip one or more specific KYC steps before starting a new mining session or extending an existing one. // Some KYC steps are not skippable. SkipKYCSteps []users.KYCStep `json:"skipKYCSteps" example:"0,1"` diff --git a/cmd/freezer-refrigerant/tokenomics.go b/cmd/freezer-refrigerant/tokenomics.go index 2bfc085..bcc34a6 100644 --- a/cmd/freezer-refrigerant/tokenomics.go +++ b/cmd/freezer-refrigerant/tokenomics.go @@ -46,8 +46,11 @@ func (s *service) StartNewMiningSession( //nolint:gocritic // False negative. req *server.Request[StartNewMiningSessionRequestBody, tokenomics.MiningSummary], ) (*server.Response[tokenomics.MiningSummary], *server.Response[server.ErrorResponse]) { ms := &tokenomics.MiningSummary{MiningSession: &tokenomics.MiningSession{UserID: &req.Data.UserID}} - enhancedCtx := tokenomics.ContextWithClientType(contextWithHashCode(ctx, req), req.Data.XClientType) - if err := s.tokenomicsProcessor.StartNewMiningSession(enhancedCtx, ms, req.Data.Resurrect, req.Data.SkipKYCSteps); err != nil { + ctx = contextWithHashCode(ctx, req) + ctx = tokenomics.ContextWithClientType(ctx, req.Data.XClientType) + ctx = tokenomics.ContextWithAuthorization(ctx, req.Data.Authorization) + ctx = tokenomics.ContextWithXAccountMetadata(ctx, req.Data.XAccountMetadata) + if err := s.tokenomicsProcessor.StartNewMiningSession(ctx, ms, req.Data.Resurrect, req.Data.SkipKYCSteps); err != nil { err = errors.Wrapf(err, "failed to start a new mining session for userID:%v, data:%#v", req.Data.UserID, req.Data) switch { case errors.Is(err, tokenomics.ErrNegativeMiningProgressDecisionRequired): diff --git a/model/model.go b/model/model.go index 95c4afa..a6e5363 100644 --- a/model/model.go +++ b/model/model.go @@ -4,6 +4,7 @@ package model import ( "bytes" + "context" "fmt" "strconv" stdlibtime "time" @@ -252,16 +253,16 @@ type ( HideRanking bool `redis:"hide_ranking"` } KYCStepsCreatedAtField struct { - KYCStepsCreatedAt *TimeSlice `redis:"kyc_steps_created_at"` + KYCStepsCreatedAt *TimeSlice `json:"kycStepsCreatedAt" redis:"kyc_steps_created_at"` } KYCStepsLastUpdatedAtField struct { - KYCStepsLastUpdatedAt *TimeSlice `redis:"kyc_steps_last_updated_at"` + KYCStepsLastUpdatedAt *TimeSlice `json:"kycStepsLastUpdatedAt" redis:"kyc_steps_last_updated_at"` } KYCStepPassedField struct { - KYCStepPassed users.KYCStep `redis:"kyc_step_passed"` + KYCStepPassed users.KYCStep `json:"kycStepPassed" redis:"kyc_step_passed"` } KYCStepBlockedField struct { - KYCStepBlocked users.KYCStep `redis:"kyc_step_blocked"` + KYCStepBlocked users.KYCStep `json:"kycStepBlocked" redis:"kyc_step_blocked"` } DeserializedBackupUsersKey struct { ID int64 `redis:"-"` @@ -369,14 +370,37 @@ func (t *TimeSlice) UnmarshalBinary(text []byte) error { return t.UnmarshalText(text) } +func (t *TimeSlice) UnmarshalJSON(text []byte) error { + if len(text) == 0 || (len(text) == 2 && string(text) == "[]") { + return nil + } + sep := make([]byte, 1) + sep[0] = ',' + elems := bytes.Split(text[1:len(text)-1], sep) + timeSlice := make(TimeSlice, 0, len(elems)) + for _, val := range elems { + unmarshalledTime := new(time.Time) + if err := unmarshalledTime.UnmarshalJSON(context.Background(), val); err != nil { + return errors.Wrapf(err, "failed to UnmarshalJSON %#v:%v", unmarshalledTime, string(val)) + } + if !unmarshalledTime.IsNil() { + timeSlice = append(timeSlice, unmarshalledTime) + } + } + *t = timeSlice + + return nil +} + func (t *TimeSlice) UnmarshalText(text []byte) error { if len(text) == 0 || (len(text) == 1 && string(text) == "") { return nil } - timeSlice := *t sep := make([]byte, 1) sep[0] = ',' - for _, val := range bytes.Split(text, sep) { + elems := bytes.Split(text, sep) + timeSlice := make(TimeSlice, 0, len(elems)) + for _, val := range elems { unmarshalledTime := new(time.Time) if err := unmarshalledTime.UnmarshalText(val); err != nil { return errors.Wrapf(err, "failed to unmarshall %#v:%v", unmarshalledTime, string(val)) diff --git a/model/model_test.go b/model/model_test.go index fc9675c..2be4ceb 100644 --- a/model/model_test.go +++ b/model/model_test.go @@ -7,8 +7,10 @@ import ( "testing" stdlibtime "time" + "github.com/goccy/go-json" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/ice-blockchain/eskimo/users" "github.com/ice-blockchain/wintr/connectors/storage/v3" @@ -103,3 +105,36 @@ func TestTimeSliceEquals(t *testing.T) { assert.False(t, (&TimeSlice{now, now}).Equals(&TimeSlice{now})) assert.False(t, (&TimeSlice{nil, now}).Equals(&TimeSlice{now, nil})) } + +func TestEskimoToFreezerKYCStateDeserialization(t *testing.T) { + t.Parallel() + stepA := users.LivenessDetectionKYCStep + stepB := users.Social2KYCStep + usr := &users.User{ + KYCStepsCreatedAt: &[]*time.Time{time.Now()}, + KYCStepsLastUpdatedAt: &[]*time.Time{time.Now(), time.Now()}, + KYCStepPassed: &stepB, + KYCStepBlocked: &stepA, + } + serializedUser, err := json.Marshal(usr) + require.NoError(t, err) + + type ( + KYCState struct { + KYCStepsCreatedAtField + KYCStepsLastUpdatedAtField + KYCStepPassedField + KYCStepBlockedField + } + ) + var deserializedUser KYCState + err = json.Unmarshal(serializedUser, &deserializedUser) + require.NoError(t, err) + assert.EqualValues(t, stepA, deserializedUser.KYCStepBlocked) + assert.EqualValues(t, stepB, deserializedUser.KYCStepPassed) + assert.EqualValues(t, 1, len(*usr.KYCStepsCreatedAt)) + assert.EqualValues(t, (*usr.KYCStepsCreatedAt)[0], (*deserializedUser.KYCStepsCreatedAt)[0]) + assert.EqualValues(t, 2, len(*usr.KYCStepsLastUpdatedAt)) + assert.EqualValues(t, (*usr.KYCStepsLastUpdatedAt)[0], (*deserializedUser.KYCStepsLastUpdatedAt)[0]) + assert.EqualValues(t, (*usr.KYCStepsLastUpdatedAt)[1], (*deserializedUser.KYCStepsLastUpdatedAt)[1]) +} diff --git a/tokenomics/contract.go b/tokenomics/contract.go index 47397a1..1ca2e7a 100644 --- a/tokenomics/contract.go +++ b/tokenomics/contract.go @@ -213,6 +213,8 @@ const ( requestingUserIDCtxValueKey = "requestingUserIDCtxValueKey" clientTypeCtxValueKey = "clientTypeCtxValueKey" userHashCodeCtxValueKey = "userHashCodeCtxValueKey" + authorizationCtxValueKey = "authorizationCtxValueKey" + xAccountMetadataCtxValueKey = "xAccountMetadataCtxValueKey" requestDeadline = 25 * stdlibtime.Second floatToStringFormatter = "%.2f" @@ -268,8 +270,9 @@ type ( disableAdvancedTeam *atomic.Pointer[[]string] kycConfigJSON *atomic.Pointer[kycConfigJSON] KYC struct { - ConfigJSONURL string `yaml:"config-json-url" mapstructure:"config-json-url"` - LivenessDelay stdlibtime.Duration `yaml:"liveness-delay" mapstructure:"liveness-delay"` + GetEskimoUserStateURL string `yaml:"get-eskimo-user-state-url" mapstructure:"get-eskimo-user-state-url"` + ConfigJSONURL string `yaml:"config-json-url" mapstructure:"config-json-url"` + LivenessDelay stdlibtime.Duration `yaml:"liveness-delay" mapstructure:"liveness-delay"` } `yaml:"kyc" mapstructure:"kyc"` AdoptionMilestoneSwitch struct { ActiveUserMilestones []struct { diff --git a/tokenomics/kyc.go b/tokenomics/kyc.go index a5074bb..71d1d3b 100644 --- a/tokenomics/kyc.go +++ b/tokenomics/kyc.go @@ -4,6 +4,7 @@ package tokenomics import ( "context" + "fmt" "math/rand" "net/http" "strings" @@ -108,7 +109,7 @@ func (r *repository) validateKYC(ctx context.Context, state *getCurrentMiningSes case users.NoneKYCStep: if !state.MiningSessionSoloLastStartedAt.IsNil() && r.isKYCEnabled(ctx, users.FacialRecognitionKYCStep) { if doFallbackRecursion { - if err := r.overrideKYCStateWithEskimoKYCState(ctx, &state.KYCState); err != nil { + if err := r.overrideKYCStateWithEskimoKYCState(ctx, state.UserID, &state.KYCState); err != nil { return errors.Wrapf(err, "failed to overrideKYCStateWithEskimoKYCState for %#v", state) } @@ -159,10 +160,37 @@ func (r *repository) isKYCEnabled(ctx context.Context, _ users.KYCStep) bool { Because existing users have empty KYCState in dragonfly cuz usersTableSource might not have updated it yet. So we need to query Eskimo for the valid kyc state of the user to be sure. */ -func (r *repository) overrideKYCStateWithEskimoKYCState(ctx context.Context, state *KYCState) error { - // TODO impl this or remove it if not needed. - - return nil +func (r *repository) overrideKYCStateWithEskimoKYCState(ctx context.Context, userID string, state *KYCState) error { + if resp, err := req. + SetContext(ctx). + SetRetryCount(25). + SetRetryBackoffInterval(10*stdlibtime.Millisecond, 1*stdlibtime.Second). + SetRetryHook(func(resp *req.Response, err error) { + if err != nil { + log.Error(errors.Wrap(err, "failed to fetch eskimo user's state, retrying...")) + } else { + body, bErr := resp.ToString() + log.Error(errors.Wrapf(bErr, "failed to parse negative response body for eskimo user's state")) + log.Error(errors.Errorf("failed to fetch eskimo user's state with status code:%v, body:%v, retrying...", resp.GetStatusCode(), body)) + } + }). + SetRetryCondition(func(resp *req.Response, err error) bool { + return err != nil || (resp.GetStatusCode() != http.StatusOK && resp.GetStatusCode() != http.StatusUnauthorized) + }). + AddQueryParam("caller", "freezer-refrigerant"). + SetHeader("Authorization", authorization(ctx)). + SetHeader("X-Account-Metadata", xAccountMetadata(ctx)). + SetHeader("Accept", "application/json"). + SetHeader("Cache-Control", "no-cache, no-store, must-revalidate"). + SetHeader("Pragma", "no-cache"). + SetHeader("Expires", "0"). + Get(fmt.Sprintf("%v/users/%v", r.cfg.KYC.GetEskimoUserStateURL, userID)); err != nil { + return errors.Wrapf(err, "failed to fetch eskimo user state for userID:%v", userID) + } else if data, err2 := resp.ToBytes(); err2 != nil { + return errors.Wrapf(err2, "failed to read body of eskimo user state request for userID:%v", userID) + } else { + return errors.Wrapf(json.Unmarshal(data, state), "failed to unmarshal into %#v, data: %v", state, string(data)) + } } func mustGetLivenessLoadDistributionStartDate(ctx context.Context, db storage.DB) (livenessLoadDistributionStartDate *time.Time) { diff --git a/tokenomics/tokenomics.go b/tokenomics/tokenomics.go index fbdd5d2..ba7b2f5 100644 --- a/tokenomics/tokenomics.go +++ b/tokenomics/tokenomics.go @@ -243,6 +243,34 @@ func requestingUserID(ctx context.Context) (requestingUserID string) { return } +func ContextWithAuthorization(ctx context.Context, authorization string) context.Context { + if authorization == "" { + return ctx + } + + return context.WithValue(ctx, authorizationCtxValueKey, authorization) //nolint:revive,staticcheck // Not an issue. +} + +func ContextWithXAccountMetadata(ctx context.Context, xAccountMetadata string) context.Context { + if xAccountMetadata == "" { + return ctx + } + + return context.WithValue(ctx, xAccountMetadataCtxValueKey, xAccountMetadata) //nolint:revive,staticcheck // Not an issue. +} + +func authorization(ctx context.Context) (authorization string) { + authorization, _ = ctx.Value(authorizationCtxValueKey).(string) //nolint:errcheck // Not needed. + + return +} + +func xAccountMetadata(ctx context.Context) (xAccountMetadata string) { + xAccountMetadata, _ = ctx.Value(xAccountMetadataCtxValueKey).(string) //nolint:errcheck // Not needed. + + return +} + func ContextWithClientType(ctx context.Context, clientType string) context.Context { if clientType == "" { return ctx