From 047e766ce4799d8505ad1d4e328ea9731a408e04 Mon Sep 17 00:00:00 2001 From: ice-myles <96409608+ice-myles@users.noreply.github.com> Date: Wed, 30 Oct 2024 12:49:31 +0300 Subject: [PATCH 1/7] KYC task verification scenarios. --- application.yaml | 16 +- cmd/eskimo-hut/api/docs.go | 224 ++++++++++++ cmd/eskimo-hut/api/swagger.json | 224 ++++++++++++ cmd/eskimo-hut/api/swagger.yaml | 154 +++++++++ cmd/eskimo-hut/contract.go | 26 +- cmd/eskimo-hut/eskimo.go | 35 +- cmd/eskimo-hut/eskimo_hut.go | 3 + cmd/eskimo-hut/kyc.go | 112 +++++- go.mod | 9 +- go.sum | 24 +- kyc/linking/contract.go | 2 +- kyc/linking/linking.go | 24 +- kyc/social/contract.go | 1 + kyc/social/social.go | 68 ++++ kyc/verification_scenarios/contract.go | 87 +++++ .../verification_scenarios.go | 320 ++++++++++++++++++ .../verification_scenarios_test.go | 157 +++++++++ users/DDL.sql | 2 + users/contract.go | 35 +- users/users_modify.go | 6 + 20 files changed, 1477 insertions(+), 52 deletions(-) create mode 100644 kyc/verification_scenarios/contract.go create mode 100644 kyc/verification_scenarios/verification_scenarios.go create mode 100644 kyc/verification_scenarios/verification_scenarios_test.go diff --git a/application.yaml b/application.yaml index 3fa50135..1b3d807e 100644 --- a/application.yaml +++ b/application.yaml @@ -86,7 +86,21 @@ kyc/linking: password: pass replicaURLs: - postgresql://root:pass@localhost:5438/eskimo-global?pool_max_conn_idle_time=1000ms&pool_health_check_period=500ms&pool_max_conns=20 - +kyc/coinDistributionEligibility: + santaTasksUrl: https://localhost:7443/v1r/tasks/x/users/ + tenant: sunwaves + # If urls does not match hostname>/$tenant/ schema. + # tenantURLs: + # callfluent: https://localhost:1444/ + # doctorx: https://localhost:1445/ + wintr/connectors/storage/v2: + runDDL: true + primaryURL: postgresql://root:pass@localhost:5438/eskimo-global?pool_max_conn_idle_time=1000ms&pool_health_check_period=500ms&pool_max_conns=40 + credentials: + user: root + password: pass + replicaURLs: + - postgresql://root:pass@localhost:5438/eskimo-global?pool_max_conn_idle_time=1000ms&pool_health_check_period=500ms&pool_max_conns=20 kyc/quiz: environment: local enable-alerts: false diff --git a/cmd/eskimo-hut/api/docs.go b/cmd/eskimo-hut/api/docs.go index 64591901..ad7313fb 100644 --- a/cmd/eskimo-hut/api/docs.go +++ b/cmd/eskimo-hut/api/docs.go @@ -20,6 +20,91 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/v1r/kyc/verifyCoinDistributionEligibility/users/{userId}": { + "get": { + "description": "Returns the non-completed kyc verification scenarios", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "KYC" + ], + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cAdd access token here\u003e", + "description": "Insert your access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "default": "\u003cAdd metadata token here\u003e", + "description": "Insert your metadata token", + "name": "X-Account-Metadata", + "in": "header" + }, + { + "type": "string", + "description": "ID of the user", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/verificationscenarios.Scenario" + } + } + }, + "400": { + "description": "if validations fail", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "401": { + "description": "if not authorized", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "403": { + "description": "if not allowed", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "422": { + "description": "if syntax fails", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "504": { + "description": "if request times out", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, "/v1r/user-statistics/top-countries": { "get": { "description": "Returns the paginated view of users per country.", @@ -1600,6 +1685,103 @@ const docTemplate = `{ } } }, + "/v1w/kyc/verifyCoinDistributionEligibility/users/{userId}/scenarios/{scenarioEnum}": { + "post": { + "description": "Verifies if a user is eligible for coin verificationscenarios.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "KYC" + ], + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cAdd access token here\u003e", + "description": "Insert your access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "default": "\u003cAdd metadata token here\u003e", + "description": "Insert your metadata token", + "name": "X-Account-Metadata", + "in": "header" + }, + { + "type": "string", + "description": "ID of the user", + "name": "userId", + "in": "path", + "required": true + }, + { + "enum": [ + "join_cmc", + "join_twitter", + "join_telegram", + "signup_tenants" + ], + "type": "string", + "description": "the scenario", + "name": "scenarioEnum", + "in": "path", + "required": true + }, + { + "description": "Request params", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/verificationscenarios.VerificationMetadata" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/social.Verification" + } + }, + "400": { + "description": "if validations fail", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "401": { + "description": "if not authorized", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "403": { + "description": "not allowed due to various reasons", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "422": { + "description": "if syntax fails", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, "/v1w/kyc/verifySocialKYCStep/users/{userId}": { "post": { "description": "Verifies if the user has posted the expected verification post on their social media account.", @@ -3550,6 +3732,48 @@ const docTemplate = `{ "example": 11 } } + }, + "verificationscenarios.Scenario": { + "type": "string", + "enum": [ + "join_cmc", + "join_twitter", + "join_telegram", + "signup_tenants" + ], + "x-enum-varnames": [ + "CoinDistributionScenarioCmc", + "CoinDistributionScenarioTwitter", + "CoinDistributionScenarioTelegram", + "CoinDistributionScenarioSignUpTenants" + ] + }, + "verificationscenarios.VerificationMetadata": { + "type": "object", + "properties": { + "cmcProfileLink": { + "type": "string", + "example": "some profile" + }, + "telegramUsername": { + "type": "string", + "example": "some telegram username" + }, + "tenantTokens": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "example": { + "sealsend": "sometoken", + "sunwaves": "sometoken" + } + }, + "tweetUrl": { + "type": "string", + "example": "some tweet" + } + } } } }` diff --git a/cmd/eskimo-hut/api/swagger.json b/cmd/eskimo-hut/api/swagger.json index 5e2db1d5..18251bc0 100644 --- a/cmd/eskimo-hut/api/swagger.json +++ b/cmd/eskimo-hut/api/swagger.json @@ -13,6 +13,91 @@ "version": "latest" }, "paths": { + "/v1r/kyc/verifyCoinDistributionEligibility/users/{userId}": { + "get": { + "description": "Returns the non-completed kyc verification scenarios", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "KYC" + ], + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cAdd access token here\u003e", + "description": "Insert your access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "default": "\u003cAdd metadata token here\u003e", + "description": "Insert your metadata token", + "name": "X-Account-Metadata", + "in": "header" + }, + { + "type": "string", + "description": "ID of the user", + "name": "userId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/verificationscenarios.Scenario" + } + } + }, + "400": { + "description": "if validations fail", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "401": { + "description": "if not authorized", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "403": { + "description": "if not allowed", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "422": { + "description": "if syntax fails", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "504": { + "description": "if request times out", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, "/v1r/user-statistics/top-countries": { "get": { "description": "Returns the paginated view of users per country.", @@ -1593,6 +1678,103 @@ } } }, + "/v1w/kyc/verifyCoinDistributionEligibility/users/{userId}/scenarios/{scenarioEnum}": { + "post": { + "description": "Verifies if a user is eligible for coin verificationscenarios.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "KYC" + ], + "parameters": [ + { + "type": "string", + "default": "Bearer \u003cAdd access token here\u003e", + "description": "Insert your access token", + "name": "Authorization", + "in": "header", + "required": true + }, + { + "type": "string", + "default": "\u003cAdd metadata token here\u003e", + "description": "Insert your metadata token", + "name": "X-Account-Metadata", + "in": "header" + }, + { + "type": "string", + "description": "ID of the user", + "name": "userId", + "in": "path", + "required": true + }, + { + "enum": [ + "join_cmc", + "join_twitter", + "join_telegram", + "signup_tenants" + ], + "type": "string", + "description": "the scenario", + "name": "scenarioEnum", + "in": "path", + "required": true + }, + { + "description": "Request params", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/verificationscenarios.VerificationMetadata" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/social.Verification" + } + }, + "400": { + "description": "if validations fail", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "401": { + "description": "if not authorized", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "403": { + "description": "not allowed due to various reasons", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "422": { + "description": "if syntax fails", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/server.ErrorResponse" + } + } + } + } + }, "/v1w/kyc/verifySocialKYCStep/users/{userId}": { "post": { "description": "Verifies if the user has posted the expected verification post on their social media account.", @@ -3543,6 +3725,48 @@ "example": 11 } } + }, + "verificationscenarios.Scenario": { + "type": "string", + "enum": [ + "join_cmc", + "join_twitter", + "join_telegram", + "signup_tenants" + ], + "x-enum-varnames": [ + "CoinDistributionScenarioCmc", + "CoinDistributionScenarioTwitter", + "CoinDistributionScenarioTelegram", + "CoinDistributionScenarioSignUpTenants" + ] + }, + "verificationscenarios.VerificationMetadata": { + "type": "object", + "properties": { + "cmcProfileLink": { + "type": "string", + "example": "some profile" + }, + "telegramUsername": { + "type": "string", + "example": "some telegram username" + }, + "tenantTokens": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "example": { + "sealsend": "sometoken", + "sunwaves": "sometoken" + } + }, + "tweetUrl": { + "type": "string", + "example": "some tweet" + } + } } } } \ No newline at end of file diff --git a/cmd/eskimo-hut/api/swagger.yaml b/cmd/eskimo-hut/api/swagger.yaml index 1b388fc3..e1ff0558 100644 --- a/cmd/eskimo-hut/api/swagger.yaml +++ b/cmd/eskimo-hut/api/swagger.yaml @@ -837,6 +837,37 @@ definitions: example: 11 type: integer type: object + verificationscenarios.Scenario: + enum: + - join_cmc + - join_twitter + - join_telegram + - signup_tenants + type: string + x-enum-varnames: + - CoinDistributionScenarioCmc + - CoinDistributionScenarioTwitter + - CoinDistributionScenarioTelegram + - CoinDistributionScenarioSignUpTenants + verificationscenarios.VerificationMetadata: + properties: + cmcProfileLink: + example: some profile + type: string + telegramUsername: + example: some telegram username + type: string + tenantTokens: + additionalProperties: + type: string + example: + sealsend: sometoken + sunwaves: sometoken + type: object + tweetUrl: + example: some tweet + type: string + type: object info: contact: name: ice.io @@ -846,6 +877,63 @@ info: title: User Accounts, User Devices, User Statistics API version: latest paths: + /v1r/kyc/verifyCoinDistributionEligibility/users/{userId}: + get: + consumes: + - application/json + description: Returns the non-completed kyc verification scenarios + parameters: + - default: Bearer + description: Insert your access token + in: header + name: Authorization + required: true + type: string + - default: + description: Insert your metadata token + in: header + name: X-Account-Metadata + type: string + - description: ID of the user + in: path + name: userId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/verificationscenarios.Scenario' + type: array + "400": + description: if validations fail + schema: + $ref: '#/definitions/server.ErrorResponse' + "401": + description: if not authorized + schema: + $ref: '#/definitions/server.ErrorResponse' + "403": + description: if not allowed + schema: + $ref: '#/definitions/server.ErrorResponse' + "422": + description: if syntax fails + schema: + $ref: '#/definitions/server.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.ErrorResponse' + "504": + description: if request times out + schema: + $ref: '#/definitions/server.ErrorResponse' + tags: + - KYC /v1r/user-statistics/top-countries: get: consumes: @@ -1912,6 +2000,72 @@ paths: $ref: '#/definitions/server.ErrorResponse' tags: - KYC + /v1w/kyc/verifyCoinDistributionEligibility/users/{userId}/scenarios/{scenarioEnum}: + post: + consumes: + - application/json + description: Verifies if a user is eligible for coin verificationscenarios. + parameters: + - default: Bearer + description: Insert your access token + in: header + name: Authorization + required: true + type: string + - default: + description: Insert your metadata token + in: header + name: X-Account-Metadata + type: string + - description: ID of the user + in: path + name: userId + required: true + type: string + - description: the scenario + enum: + - join_cmc + - join_twitter + - join_telegram + - signup_tenants + in: path + name: scenarioEnum + required: true + type: string + - description: Request params + in: body + name: request + schema: + $ref: '#/definitions/verificationscenarios.VerificationMetadata' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/social.Verification' + "400": + description: if validations fail + schema: + $ref: '#/definitions/server.ErrorResponse' + "401": + description: if not authorized + schema: + $ref: '#/definitions/server.ErrorResponse' + "403": + description: not allowed due to various reasons + schema: + $ref: '#/definitions/server.ErrorResponse' + "422": + description: if syntax fails + schema: + $ref: '#/definitions/server.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/server.ErrorResponse' + tags: + - KYC /v1w/kyc/verifySocialKYCStep/users/{userId}: post: consumes: diff --git a/cmd/eskimo-hut/contract.go b/cmd/eskimo-hut/contract.go index 5f5d5b4f..84718b9d 100644 --- a/cmd/eskimo-hut/contract.go +++ b/cmd/eskimo-hut/contract.go @@ -15,6 +15,7 @@ import ( linkerkyc "github.com/ice-blockchain/eskimo/kyc/linking" kycquiz "github.com/ice-blockchain/eskimo/kyc/quiz" kycsocial "github.com/ice-blockchain/eskimo/kyc/social" + verificationscenarios "github.com/ice-blockchain/eskimo/kyc/verification_scenarios" "github.com/ice-blockchain/eskimo/users" ) @@ -197,6 +198,10 @@ type ( ForwardToFaceKYCResponse struct { KycFaceAvailable bool `json:"kycFaceAvailable" example:"true"` } + GetRequiredVerificationEligibilityScenariosArg struct { + Authorization string `header:"Authorization" swaggerignore:"true" required:"true" example:"some token"` + UserID string `uri:"userId" required:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` + } ) // Private API. @@ -241,6 +246,10 @@ const ( linkingNotOwnedProfile = "NOT_OWNER_OF_REMOTE_USER" linkingDuplicate = "DUPLICATE" + kycVerificationScenariosVadidationFailedErrorCode = "VALIDATION_FAILED" + kycVerificationScenariosNoPendingScenariosErrorCode = "NO_PENDING_SCENARIOS" + kycVerificationScenariosNoTenantTokens = "NO_TENANT_TOKENS" //nolint:gosec // . + deviceIDTokenClaim = "deviceUniqueID" //nolint:gosec // . adminRole = "admin" @@ -256,14 +265,15 @@ var ( type ( // | service implements server.State and is responsible for managing the state and lifecycle of the package. service struct { - usersProcessor users.Processor - quizRepository kycquiz.Repository - authEmailLinkClient emaillink.Client - telegramAuthClient telegramauth.Client - tokenRefresher auth.TokenRefresher - socialRepository kycsocial.Repository - faceKycClient facekyc.Client - usersLinker linkerkyc.Linker + usersProcessor users.Processor + quizRepository kycquiz.Repository + authEmailLinkClient emaillink.Client + telegramAuthClient telegramauth.Client + tokenRefresher auth.TokenRefresher + socialRepository kycsocial.Repository + faceKycClient facekyc.Client + usersLinker linkerkyc.Linker + verificationScenariosRepository verificationscenarios.Repository } config struct { APIKey string `yaml:"api-key" mapstructure:"api-key"` //nolint:tagliatelle // Nope. diff --git a/cmd/eskimo-hut/eskimo.go b/cmd/eskimo-hut/eskimo.go index 91418fc7..44330761 100644 --- a/cmd/eskimo-hut/eskimo.go +++ b/cmd/eskimo-hut/eskimo.go @@ -10,6 +10,7 @@ import ( "github.com/pkg/errors" + verificationscenarios "github.com/ice-blockchain/eskimo/kyc/verification_scenarios" "github.com/ice-blockchain/eskimo/users" "github.com/ice-blockchain/wintr/server" ) @@ -267,7 +268,8 @@ func (s *service) setupUserReadRoutes(router *server.Router) { Group("v1r"). GET("users", server.RootHandler(s.GetUsers)). GET("users/:userId", server.RootHandler(s.GetUserByID)). - GET("user-views/username", server.RootHandler(s.GetUserByUsername)) + GET("user-views/username", server.RootHandler(s.GetUserByUsername)). + GET("kyc/verifyCoinDistributionEligibility/users/:userId", server.RootHandler(s.GetPendingKYCVerificationScenarios)) } // GetUsers godoc @@ -386,3 +388,34 @@ func (s *service) GetUserByUsername( //nolint:gocritic // False negative. return server.OK(&UserProfile{UserProfile: resp}), nil } + +// GetPendingKYCVerificationScenarios godoc +// +// @Schemes +// @Description Returns the non-completed kyc verification scenarios +// @Tags KYC +// @Accept json +// @Produce json +// @Param Authorization header string true "Insert your access token" default(Bearer ) +// @Param X-Account-Metadata header string false "Insert your metadata token" default() +// @Param userId path string true "ID of the user" +// @Success 200 {array} verificationscenarios.Scenario +// @Failure 400 {object} server.ErrorResponse "if validations fail" +// @Failure 401 {object} server.ErrorResponse "if not authorized" +// @Failure 403 {object} server.ErrorResponse "if not allowed" +// @Failure 422 {object} server.ErrorResponse "if syntax fails" +// @Failure 500 {object} server.ErrorResponse +// @Failure 504 {object} server.ErrorResponse "if request times out" +// @Router /v1r/kyc/verifyCoinDistributionEligibility/users/{userId} [GET]. +func (s *service) GetPendingKYCVerificationScenarios( //nolint:gocritic // . + ctx context.Context, + req *server.Request[GetRequiredVerificationEligibilityScenariosArg, []*verificationscenarios.Scenario], +) (*server.Response[[]*verificationscenarios.Scenario], *server.Response[server.ErrorResponse]) { + ctx = users.ContextWithAuthorization(ctx, req.Data.Authorization) //nolint:revive // . + scenarios, err := s.verificationScenariosRepository.GetPendingVerificationScenarios(ctx, req.Data.UserID) + if err = errors.Wrapf(err, "failed to GetRequiredVerificationEligibilityScenarios for userID:%v", req.Data.UserID); err != nil { + return nil, server.Unexpected(err) + } + + return server.OK(&scenarios), nil +} diff --git a/cmd/eskimo-hut/eskimo_hut.go b/cmd/eskimo-hut/eskimo_hut.go index 05538a93..7f376f79 100644 --- a/cmd/eskimo-hut/eskimo_hut.go +++ b/cmd/eskimo-hut/eskimo_hut.go @@ -17,6 +17,7 @@ import ( linkerkyc "github.com/ice-blockchain/eskimo/kyc/linking" kycquiz "github.com/ice-blockchain/eskimo/kyc/quiz" "github.com/ice-blockchain/eskimo/kyc/social" + verificationscenarios "github.com/ice-blockchain/eskimo/kyc/verification_scenarios" "github.com/ice-blockchain/eskimo/users" appcfg "github.com/ice-blockchain/wintr/config" "github.com/ice-blockchain/wintr/log" @@ -70,6 +71,7 @@ func (s *service) Init(ctx context.Context, cancel context.CancelFunc) { s.quizRepository = kycquiz.NewRepository(ctx, s.usersProcessor) s.usersLinker = linkerkyc.NewAccountLinker(ctx, cfg.Host) s.faceKycClient = facekyc.New(ctx, s.usersProcessor, s.usersLinker) + s.verificationScenariosRepository = verificationscenarios.New(ctx, s.usersProcessor, cfg.Host) } func (s *service) Close(ctx context.Context) error { @@ -84,6 +86,7 @@ func (s *service) Close(ctx context.Context) error { errors.Wrap(s.usersProcessor.Close(), "could not close usersProcessor"), errors.Wrap(s.usersLinker.Close(), "could not close usersLinker"), errors.Wrap(s.faceKycClient.Close(), "could not close faceKycClient"), + errors.Wrap(s.verificationScenariosRepository.Close(), "could not close verificationScenariosRepository"), ).ErrorOrNil() } diff --git a/cmd/eskimo-hut/kyc.go b/cmd/eskimo-hut/kyc.go index 5b42ff9c..d9418ed3 100644 --- a/cmd/eskimo-hut/kyc.go +++ b/cmd/eskimo-hut/kyc.go @@ -14,6 +14,7 @@ import ( "github.com/ice-blockchain/eskimo/kyc/linking" kycquiz "github.com/ice-blockchain/eskimo/kyc/quiz" kycsocial "github.com/ice-blockchain/eskimo/kyc/social" + verificationscenarios "github.com/ice-blockchain/eskimo/kyc/verification_scenarios" "github.com/ice-blockchain/eskimo/users" "github.com/ice-blockchain/wintr/log" "github.com/ice-blockchain/wintr/server" @@ -26,7 +27,8 @@ func (s *service) setupKYCRoutes(router *server.Router) { POST("kyc/checkKYCStep4Status/users/:userId", server.RootHandler(s.CheckKYCStep4Status)). POST("kyc/verifySocialKYCStep/users/:userId", server.RootHandler(s.VerifySocialKYCStep)). POST("kyc/tryResetKYCSteps/users/:userId", server.RootHandler(s.TryResetKYCSteps)). - POST("kyc/checkFaceKYCStatus/users/:userId", server.RootHandler(s.ForwardToFaceKYC)) + POST("kyc/checkFaceKYCStatus/users/:userId", server.RootHandler(s.ForwardToFaceKYC)). + POST("kyc/verifyCoinDistributionEligibility/users/:userId/scenarios/:scenarioEnum", server.RootHandler(s.VerifyKYCScenarios)) } func (s *service) startQuizSession(ctx context.Context, userID users.UserID, lang string) (*kycquiz.Quiz, error) { @@ -359,3 +361,111 @@ func (s *service) ForwardToFaceKYC( return server.OK(&ForwardToFaceKYCResponse{KycFaceAvailable: kycFaceAvailable}), nil } + +// VerifyCoinDistributionEligibility godoc +// +// @Schemes +// @Description Verifies if a user is eligible for coin verificationscenarios. +// @Tags KYC +// @Accept json +// @Produce json +// +// @Param Authorization header string true "Insert your access token" default(Bearer ) +// @Param X-Account-Metadata header string false "Insert your metadata token" default() +// @Param userId path string true "ID of the user" +// @Param scenarioEnum path string true "the scenario" enums(join_cmc,join_twitter,join_telegram,signup_tenants) +// @Param request body verificationscenarios.VerificationMetadata false "Request params" +// @Success 200 {object} kycsocial.Verification +// @Failure 400 {object} server.ErrorResponse "if validations fail" +// @Failure 401 {object} server.ErrorResponse "if not authorized" +// @Failure 403 {object} server.ErrorResponse "not allowed due to various reasons" +// @Failure 422 {object} server.ErrorResponse "if syntax fails" +// @Failure 500 {object} server.ErrorResponse +// @Router /v1w/kyc/verifyCoinDistributionEligibility/users/{userId}/scenarios/{scenarioEnum} [POST]. +func (s *service) VerifyKYCScenarios( //nolint:gocritic,funlen // . + ctx context.Context, + req *server.Request[verificationscenarios.VerificationMetadata, kycsocial.Verification], +) (*server.Response[kycsocial.Verification], *server.Response[server.ErrorResponse]) { + if err := validateScenariosData(req.Data); err != nil { + return nil, server.UnprocessableEntity(errors.Wrapf(err, "validations failed for %#v", req.Data), invalidPropertiesErrorCode) + } + ctx = users.ContextWithAuthorization(ctx, req.Data.Authorization) //nolint:revive // . + result, err := s.verificationScenariosRepository.VerifyScenarios(ctx, req.Data) + if err = errors.Wrapf(err, "failed to VerifyCoinDistributionEligibility for userID:%v", req.Data.UserID); err != nil { + switch { + case errors.Is(err, verificationscenarios.ErrVerificationNotPassed): + return nil, server.BadRequest(err, kycVerificationScenariosVadidationFailedErrorCode) + case errors.Is(err, users.ErrRelationNotFound): + return nil, server.NotFound(err, userNotFoundErrorCode) + case errors.Is(err, users.ErrNotFound): + return nil, server.NotFound(err, userNotFoundErrorCode) + case errors.Is(err, kycsocial.ErrDuplicate): + return nil, server.Conflict(err, socialKYCStepAlreadyCompletedSuccessfullyErrorCode) + case errors.Is(err, kycsocial.ErrNotAvailable): + return nil, server.ForbiddenWithCode(err, socialKYCStepNotAvailableErrorCode) + case errors.Is(err, linking.ErrNotOwnRemoteUser): + return nil, server.BadRequest(err, linkingNotOwnedProfile) + case errors.Is(err, verificationscenarios.ErrNoPendingScenarios): + return nil, server.NotFound(err, kycVerificationScenariosNoPendingScenariosErrorCode) + case errors.Is(err, verificationscenarios.ErrWrongTenantTokens): + return nil, server.BadRequest(err, kycVerificationScenariosNoTenantTokens) + default: + return nil, server.Unexpected(err) + } + } + if result != nil { + return server.OK(result), nil + } + + return server.OK[kycsocial.Verification](nil), nil +} + +//nolint:funlen,gocognit,revive // . +func validateScenariosData(data *verificationscenarios.VerificationMetadata) error { + switch data.ScenarioEnum { + case verificationscenarios.CoinDistributionScenarioCmc: + if data.CMCProfileLink == "" { + return errors.Errorf("empty cmc profile link `%v`", data.CMCProfileLink) + } + case verificationscenarios.CoinDistributionScenarioTwitter: + if data.TweetURL == "" { + return errors.Errorf("empty tweet url `%v`", data.TweetURL) + } + if data.Language == "" { + return errors.Errorf("empty language `%v`", data.Language) + } + case verificationscenarios.CoinDistributionScenarioTelegram: + if data.TelegramUsername == "" { + return errors.Errorf("empty telegram username `%v`", data.TelegramUsername) + } + case verificationscenarios.CoinDistributionScenarioSignUpTenants: + if len(data.TenantTokens) == 0 { + return errors.Errorf("empty tenant tokens `%v`", data.TenantTokens) + } + var ( + supportedTenants = []verificationscenarios.TenantScenario{ + verificationscenarios.CoinDistributionScenarioSignUpSunwaves, + verificationscenarios.CoinDistributionScenarioSignUpCallfluent, + verificationscenarios.CoinDistributionScenarioSignUpSealsend, + verificationscenarios.CoinDistributionScenarioSignUpSauces, + verificationscenarios.CoinDistributionScenarioSignUpDoctorx, + } + unsupportedTenants []verificationscenarios.TenantScenario + ) + for tenant, token := range data.TenantTokens { + if token == "" { //nolint:gosec // . + return errors.Errorf("empty token for tenant `%v`", tenant) + } + if !slices.Contains(supportedTenants, tenant) { + unsupportedTenants = append(unsupportedTenants, tenant) + } + } + if len(unsupportedTenants) > 0 { + return errors.Errorf("unsupported tenants `%v`", unsupportedTenants) + } + default: + return errors.Errorf("unsupported scenario `%v`", data.ScenarioEnum) + } + + return nil +} diff --git a/go.mod b/go.mod index 9a3339c3..0f262fd0 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/google/uuid v1.6.0 github.com/hashicorp/go-multierror v1.1.1 github.com/ice-blockchain/go-tarantool-client v0.0.0-20230327200757-4fc71fa3f7bb + github.com/ice-blockchain/santa v1.160.0 github.com/ice-blockchain/wintr v1.154.0 github.com/imroc/req/v3 v3.48.0 github.com/ip2location/ip2location-go/v9 v9.7.0 @@ -46,7 +47,7 @@ require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/Microsoft/hcsshim v0.12.8 // indirect + github.com/Microsoft/hcsshim v0.12.9 // indirect github.com/andybalholm/brotli v1.1.1 // indirect github.com/andybalholm/cascadia v1.3.2 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -61,7 +62,9 @@ require ( github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect github.com/containerd/cgroups/v3 v3.0.3 // indirect github.com/containerd/containerd v1.7.23 // indirect - github.com/containerd/continuity v0.4.3 // indirect + github.com/containerd/errdefs v0.3.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/typeurl/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dennwc/varint v1.0.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -126,7 +129,7 @@ require ( github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/onsi/ginkgo/v2 v2.20.2 // indirect + github.com/onsi/ginkgo/v2 v2.21.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.0 // indirect github.com/opencontainers/runc v1.2.0 // indirect diff --git a/go.sum b/go.sum index 6b88b37e..6f97be48 100644 --- a/go.sum +++ b/go.sum @@ -52,8 +52,8 @@ github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/Microsoft/hcsshim v0.12.8 h1:BtDWYlFMcWhorrvSSo2M7z0csPdw6t7no/C3FsSvqiI= -github.com/Microsoft/hcsshim v0.12.8/go.mod h1:cibQ4BqhJ32FXDwPdQhKhwrwophnh3FuT4nwQZF907w= +github.com/Microsoft/hcsshim v0.12.9 h1:2zJy5KA+l0loz1HzEGqyNnjd3fyZA31ZBCGKacp6lLg= +github.com/Microsoft/hcsshim v0.12.9/go.mod h1:fJ0gkFAna6ukt0bLdKB8djt4XIJhF/vEPuoIWYVvZ8Y= github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbavY5Wv4= github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4= github.com/alecthomas/units v0.0.0-20240626203959-61d1e3462e30 h1:t3eaIm0rUkzbrIewtiFmMK5RXHej2XnoXNhxVsAYUfg= @@ -100,8 +100,14 @@ github.com/containerd/cgroups/v3 v3.0.3 h1:S5ByHZ/h9PMe5IOQoN7E+nMc2UcLEM/V48DGD github.com/containerd/cgroups/v3 v3.0.3/go.mod h1:8HBe7V3aWGLFPd/k03swSIsGjZhHI2WzJmticMgVuz0= github.com/containerd/containerd v1.6.19 h1:F0qgQPrG0P2JPgwpxWxYavrVeXAG0ezUIB9Z/4FTUAU= github.com/containerd/containerd v1.6.19/go.mod h1:HZCDMn4v/Xl2579/MvtOC2M206i+JJ6VxFWU/NetrGY= -github.com/containerd/continuity v0.4.3 h1:6HVkalIp+2u1ZLH1J/pYX2oBVXlJZvh1X1A7bEZ9Su8= -github.com/containerd/continuity v0.4.3/go.mod h1:F6PTNCKepoxEaXLQp3wDAjygEnImnZ/7o4JzpodfroQ= +github.com/containerd/continuity v0.4.4 h1:/fNVfTJ7wIl/YPMHjf+5H32uFhl63JucB34PlCpMKII= +github.com/containerd/continuity v0.4.4/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE= +github.com/containerd/errdefs v0.3.0 h1:FSZgGOeK4yuT/+DnF07/Olde/q4KBoMsaamhXxIMDp4= +github.com/containerd/errdefs v0.3.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/typeurl/v2 v2.2.0 h1:6NBDbQzr7I5LHgp34xAXYF5DOTQDn05X58lsPEmzLso= +github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= @@ -245,6 +251,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/ice-blockchain/go-tarantool-client v0.0.0-20230327200757-4fc71fa3f7bb h1:8TnFP3mc7O+tc44kv2e0/TpZKnEVUaKH+UstwfBwRkk= github.com/ice-blockchain/go-tarantool-client v0.0.0-20230327200757-4fc71fa3f7bb/go.mod h1:ZsQU7i3mxhgBBu43Oev7WPFbIjP4TniN/b1UPNGbrq8= +github.com/ice-blockchain/santa v1.160.0 h1:ZvOZIDnWJOqIJZgUSaFenAn/DnEw4VPowtUA1pHTRK0= +github.com/ice-blockchain/santa v1.160.0/go.mod h1:DfVQbVX1E/wjKg9ekQip/iAEUJLhnpUlTBC7QqgqtvE= github.com/ice-blockchain/wintr v1.154.0 h1:yZSQtAEwGHTSmJ5pXjX0tpui1TNnG615QfpBkhY99a4= github.com/ice-blockchain/wintr v1.154.0/go.mod h1:DoUn66XJGzPzfCZTsHyMjfgj2aVLGvjqDSuKj2pa3KE= github.com/imroc/req/v3 v3.48.0 h1:IYuMGetuwLzOOTzDCquDqs912WNwpsPK0TBXWPIvoqg= @@ -320,10 +328,10 @@ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+ github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= -github.com/onsi/ginkgo/v2 v2.20.2 h1:7NVCeyIWROIAheY21RLS+3j2bb52W0W82tkberYytp4= -github.com/onsi/ginkgo/v2 v2.20.2/go.mod h1:K9gyxPIlb+aIvnZ8bd9Ak+YP18w3APlR+5coaZoE2ag= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= +github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8= +github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= diff --git a/kyc/linking/contract.go b/kyc/linking/contract.go index 0ec03660..bd020d6d 100644 --- a/kyc/linking/contract.go +++ b/kyc/linking/contract.go @@ -47,7 +47,7 @@ const ( var ( //go:embed global_ddl.sql ddl string - errRemoteUserNotFound = errors.New("remote user not found") + ErrRemoteUserNotFound = errors.New("remote user not found") ErrNotOwnRemoteUser = errors.New("not own remote user") ErrDuplicate = storage.ErrDuplicate ) diff --git a/kyc/linking/linking.go b/kyc/linking/linking.go index 81cd1551..0b83bae5 100644 --- a/kyc/linking/linking.go +++ b/kyc/linking/linking.go @@ -138,30 +138,30 @@ func (l *linker) Get(ctx context.Context, userID UserID) (allLinkedProfiles Link } func (l *linker) verifyToken(ctx context.Context, userID, tenant, token string) (remoteID UserID, hasFaceResult bool, err error) { - usr, err := l.fetchTokenData(ctx, tenant, token) + usr, err := FetchTokenData(ctx, tenant, token, l.host, l.cfg.TenantURLs) if err != nil { - if errors.Is(err, errRemoteUserNotFound) { - return "", false, ErrNotOwnRemoteUser + if errors.Is(err, ErrRemoteUserNotFound) { + return "", false, errors.Wrapf(ErrNotOwnRemoteUser, "token is not belong to %v", userID) } - return "", false, errors.Wrapf(err, "failed to fwtch remote user data for %v", userID) + return "", false, errors.Wrapf(err, "failed to fetch remote user data for %v", userID) } if usr.CreatedAt == nil || usr.ReferredBy == "" || usr.Username == "" { - return "", false, ErrNotOwnRemoteUser + return "", false, errors.Wrapf(ErrNotOwnRemoteUser, "token is not belong to %v", userID) } return usr.ID, usr.HasFaceKYCResult(), nil } //nolint:funlen // Single http call. -func (l *linker) fetchTokenData(ctx context.Context, tenant, token string) (*users.User, error) { +func FetchTokenData(ctx context.Context, tenant, token, host string, tenantURLs map[Tenant]string) (*users.User, error) { tok, err := server.Auth(ctx).ParseToken(token, false) if err != nil { return nil, errors.Wrapf(err, "invalid token passed") } var resp *req.Response var usr users.User - getUserURL, err := l.buildGetUserURL(tenant, tok.Subject) + getUserURL, err := buildGetUserURL(tenant, tok.Subject, host, tenantURLs) if err != nil { log.Panic(errors.Wrapf(err, "failed to detect tenant url")) } @@ -197,7 +197,7 @@ func (l *linker) fetchTokenData(ctx context.Context, tenant, token string) (*use return nil, errors.Wrap(err, "failed to link accounts") } else if statusCode := resp.GetStatusCode(); statusCode != http.StatusOK { if statusCode == http.StatusNotFound { - return nil, errRemoteUserNotFound + return nil, errors.Wrapf(ErrRemoteUserNotFound, "wrong status code for fetch token data for user %v", tok.Subject) } return nil, errors.Errorf("[%v]failed to link accounts", statusCode) @@ -210,15 +210,15 @@ func (l *linker) fetchTokenData(ctx context.Context, tenant, token string) (*use return &usr, nil } -func (l *linker) buildGetUserURL(tenant, userID string) (string, error) { +func buildGetUserURL(tenant, userID, host string, tenantURLs map[Tenant]string) (string, error) { var hasURL bool var baseURL string - if len(l.cfg.TenantURLs) > 0 { - baseURL, hasURL = l.cfg.TenantURLs[tenant] + if len(tenantURLs) > 0 { + baseURL, hasURL = tenantURLs[tenant] } if !hasURL { var err error - if baseURL, err = url.JoinPath("https://"+l.host, tenant); err != nil { + if baseURL, err = url.JoinPath("https://"+host, tenant); err != nil { return "", errors.Wrapf(err, "failed to build user url for tenant %v", tenant) } } diff --git a/kyc/social/contract.go b/kyc/social/contract.go index ee076c05..2fbc376d 100644 --- a/kyc/social/contract.go +++ b/kyc/social/contract.go @@ -68,6 +68,7 @@ type ( Repository interface { io.Closer VerifyPost(ctx context.Context, metadata *VerificationMetadata) (*Verification, error) + VerifyPostForDistibutionVerification(ctx context.Context, metadata *VerificationMetadata) (*Verification, error) SkipVerification(ctx context.Context, kycStep users.KYCStep, userID string) error } UserRepository interface { diff --git a/kyc/social/social.go b/kyc/social/social.go index 2ada1d74..512d9a6e 100644 --- a/kyc/social/social.go +++ b/kyc/social/social.go @@ -160,6 +160,7 @@ func (r *repository) VerifyPost(ctx context.Context, metadata *VerificationMetad if err != nil { return nil, errors.Wrapf(err, "failed to verifySkipped for metadata:%#v", metadata) } + //nolint:goconst // . sql := `SELECT ARRAY_AGG(x.created_at) AS unsuccessful_attempts FROM (SELECT created_at FROM social_kyc_unsuccessful_attempts @@ -257,6 +258,73 @@ func (r *repository) VerifyPost(ctx context.Context, metadata *VerificationMetad return &Verification{Result: SuccessVerificationResult}, nil } +//nolint:funlen,gocognit,revive // . +func (r *repository) VerifyPostForDistibutionVerification(ctx context.Context, metadata *VerificationMetadata) (*Verification, error) { + now := time.Now() + user, err := r.user.GetUserByID(ctx, metadata.UserID) + if err != nil { + return nil, errors.Wrapf(err, "failed to GetUserByID: %v", metadata.UserID) + } + sql := `SELECT ARRAY_AGG(x.created_at) AS unsuccessful_attempts + FROM (SELECT created_at + FROM social_kyc_unsuccessful_attempts + WHERE user_id = $1 + AND kyc_step = $2 + AND reason != ANY($3) + ORDER BY created_at DESC) x` + res, err := storage.Get[struct { + UnsuccessfulAttempts *[]time.Time `db:"unsuccessful_attempts"` + }](ctx, r.db, sql, metadata.UserID, metadata.KYCStep, []string{skippedReason, exhaustedRetriesReason}) + if err != nil { + return nil, errors.Wrapf(err, "failed to get unsuccessful_attempts for userID:%v", metadata.UserID) + } + remainingAttempts := r.cfg.MaxAttemptsAllowed + if res.UnsuccessfulAttempts != nil { + for _, unsuccessfulAttempt := range *res.UnsuccessfulAttempts { + if unsuccessfulAttempt.After(now.Add(-r.cfg.SessionWindow)) { + remainingAttempts-- + if remainingAttempts == 0 { + break + } + } + } + } + if remainingAttempts < 1 { + return nil, ErrNotAvailable + } + if metadata.Twitter.TweetURL == "" && metadata.Facebook.AccessToken == "" { + return &Verification{ExpectedPostText: r.expectedPostText(user.User, metadata)}, nil + } + pvm := &social.Metadata{ + AccessToken: metadata.Facebook.AccessToken, + PostURL: metadata.Twitter.TweetURL, + ExpectedPostText: r.expectedPostSubtext(user.User, metadata), + ExpectedPostURL: r.expectedPostURL(metadata), + } + userHandle, err := r.socialVerifiers[metadata.Social].VerifyPost(ctx, pvm) + if err != nil { //nolint:nestif // . + log.Error(errors.Wrapf(err, "social verification failed for Social:%v,Language:%v,userID:%v", + metadata.Social, metadata.Language, metadata.UserID)) + reason := detectReason(err) + if userHandle != "" { + reason = strings.ToLower(userHandle) + ": " + reason + } + if err = r.saveUnsuccessfulAttempt(ctx, now, reason, metadata); err != nil { + return nil, errors.Wrapf(err, "[1]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", reason, metadata) + } + remainingAttempts-- + if remainingAttempts == 0 { + if err = r.saveUnsuccessfulAttempt(ctx, time.New(now.Add(stdlibtime.Microsecond)), exhaustedRetriesReason, metadata); err != nil { + return nil, errors.Wrapf(err, "[1]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", exhaustedRetriesReason, metadata) + } + } + + return &Verification{RemainingAttempts: &remainingAttempts, Result: FailureVerificationResult}, nil + } + + return &Verification{Result: SuccessVerificationResult}, nil +} + func (r *repository) validateKycStep(user *users.User, kycStep users.KYCStep, now *time.Time) error { allowSocialBeforeFace := true if !allowSocialBeforeFace && (user.KYCStepPassed == nil || diff --git a/kyc/verification_scenarios/contract.go b/kyc/verification_scenarios/contract.go new file mode 100644 index 00000000..78305cf1 --- /dev/null +++ b/kyc/verification_scenarios/contract.go @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: ice License 1.0 + +package verificationscenarios + +import ( + "context" + "errors" + "io" + "mime/multipart" + + "github.com/ice-blockchain/eskimo/kyc/social" + "github.com/ice-blockchain/eskimo/users" + storage "github.com/ice-blockchain/wintr/connectors/storage/v2" +) + +// Public API. + +const ( + // Scenarios. + CoinDistributionScenarioCmc Scenario = "join_cmc" + CoinDistributionScenarioTwitter Scenario = "join_twitter" + CoinDistributionScenarioTelegram Scenario = "join_telegram" + CoinDistributionScenarioSignUpTenants Scenario = "signup_tenants" + + // Tenant scenarios. + CoinDistributionScenarioSignUpSunwaves TenantScenario = "signup_sunwaves" + CoinDistributionScenarioSignUpSealsend TenantScenario = "signup_sealsend" + CoinDistributionScenarioSignUpCallfluent TenantScenario = "signup_callfluent" + CoinDistributionScenarioSignUpSauces TenantScenario = "signup_sauces" + CoinDistributionScenarioSignUpDoctorx TenantScenario = "signup_doctorx" +) + +// . +var ( + ErrVerificationNotPassed = errors.New("not passed") + ErrNoPendingScenarios = errors.New("not pending scenarios") + ErrWrongTenantTokens = errors.New("wrong tenant tokens") +) + +type ( + Tenant string + Token string + Scenario string + TenantScenario string + Repository interface { + io.Closer + VerifyScenarios(ctx context.Context, metadata *VerificationMetadata) (*social.Verification, error) + GetPendingVerificationScenarios(ctx context.Context, userID string) ([]*Scenario, error) + } + UserRepository interface { + io.Closer + GetUserByID(ctx context.Context, userID string) (*users.UserProfile, error) + ModifyUser(ctx context.Context, usr *users.User, profilePicture *multipart.FileHeader) (*users.UserProfile, error) + } + VerificationMetadata struct { + Authorization string `header:"Authorization" swaggerignore:"true" required:"true" example:"some token"` + UserID string `uri:"userId" required:"true" allowForbiddenWriteOperation:"true" swaggerignore:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` //nolint:lll // . + ScenarioEnum Scenario `uri:"scenarioEnum" example:"cmc" swaggerignore:"true" required:"true" enums:"cmc,twitter,telegram,tenant"` //nolint:lll // . + Language string `json:"language" required:"false" swaggerignore:"true" example:"en"` + TenantTokens map[TenantScenario]Token `json:"tenantTokens" required:"false" example:"sunwaves:sometoken,sealsend:sometoken"` + CMCProfileLink string `json:"cmcProfileLink" required:"false" example:"some profile"` + TweetURL string `json:"tweetUrl" required:"false" example:"some tweet"` + TelegramUsername string `json:"telegramUsername" required:"false" example:"some telegram username"` + } +) + +// Private API. + +const ( + applicationYamlKey = "kyc/coinDistributionEligibility" + authorizationCtxValueKey = "authorizationCtxValueKey" +) + +type ( + repository struct { + cfg *config + globalDB *storage.DB + userRepo UserRepository + socialClient social.Repository + host string + } + config struct { + TenantURLs map[string]string `yaml:"tenantURLs" mapstructure:"tenantURLs"` //nolint:tagliatelle // . + Tenant string `yaml:"tenant" mapstructure:"tenant"` + SantaTasksURL string `yaml:"santaTasksUrl" mapstructure:"santaTasksUrl"` + } +) diff --git a/kyc/verification_scenarios/verification_scenarios.go b/kyc/verification_scenarios/verification_scenarios.go new file mode 100644 index 00000000..8e9add9f --- /dev/null +++ b/kyc/verification_scenarios/verification_scenarios.go @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: ice License 1.0 + +package verificationscenarios + +import ( + "context" + "fmt" + "net/http" + "strings" + stdlibtime "time" + + "github.com/goccy/go-json" + "github.com/hashicorp/go-multierror" + "github.com/imroc/req/v3" + "github.com/pkg/errors" + + "github.com/ice-blockchain/eskimo/kyc/linking" + "github.com/ice-blockchain/eskimo/kyc/social" + "github.com/ice-blockchain/eskimo/users" + "github.com/ice-blockchain/santa/tasks" + appcfg "github.com/ice-blockchain/wintr/config" + storage "github.com/ice-blockchain/wintr/connectors/storage/v2" + "github.com/ice-blockchain/wintr/log" + "github.com/ice-blockchain/wintr/time" +) + +func New(ctx context.Context, usrRepo UserRepository, host string) Repository { + var cfg config + appcfg.MustLoadFromKey(applicationYamlKey, &cfg) + repo := &repository{ + userRepo: usrRepo, + cfg: &cfg, + host: host, + socialClient: social.New(ctx, usrRepo), + globalDB: storage.MustConnect(ctx, "", applicationYamlKey), + } + + return repo +} + +func (r *repository) Close() error { + return errors.Wrap(multierror.Append(nil, + errors.Wrap(r.socialClient.Close(), "closing distribution repository failed"), + errors.Wrap(r.userRepo.Close(), "closing users repository failed"), + errors.Wrap(r.globalDB.Close(), "closing db connection failed"), + ).ErrorOrNil(), "some of close functions failed") +} + +//nolint:funlen,gocognit,gocyclo,revive,cyclop // . +func (r *repository) VerifyScenarios(ctx context.Context, metadata *VerificationMetadata) (*social.Verification, error) { + userIDScenarioMap := make(map[TenantScenario]users.UserID, 0) + usr, err := r.userRepo.GetUserByID(ctx, metadata.UserID) + if err != nil { + return nil, errors.Wrapf(err, "failed to get user by id: %v", metadata.UserID) + } + completedSantaTasks, err := r.getCompletedSantaTasks(ctx, usr.ID) + if err != nil { + return nil, errors.Wrapf(err, "failed to getCompletedSantaTasks for userID: %v", usr.ID) + } + pendingScenarios := r.getPendingScenarios(usr.User, completedSantaTasks) + if len(pendingScenarios) == 0 || !isScenarioPending(pendingScenarios, string(metadata.ScenarioEnum)) { + return nil, errors.Wrapf(ErrNoPendingScenarios, "no pending scenarios for user: %v", metadata.UserID) + } + switch metadata.ScenarioEnum { + case CoinDistributionScenarioCmc: + if false { + return nil, errors.Wrapf(ErrVerificationNotPassed, "haven't passed the CMC verification for userID:%v", metadata.UserID) + } + case CoinDistributionScenarioTwitter: + verification, sErr := r.socialClient.VerifyPostForDistibutionVerification(ctx, &social.VerificationMetadata{ + UserID: metadata.UserID, + Language: metadata.Language, + Social: social.TwitterType, + Twitter: social.Twitter{ + TweetURL: metadata.TweetURL, + }, + KYCStep: users.Social1KYCStep, + }) + if sErr != nil { + return verification, errors.Wrapf(sErr, "failed to call VerifyPostForDistibutionVerification for userID:%v", metadata.UserID) + } + if verification.Result != social.SuccessVerificationResult { + return verification, nil + } + case CoinDistributionScenarioTelegram: + case CoinDistributionScenarioSignUpTenants: + skippedTokenCount := 0 + for tenantScenario, token := range metadata.TenantTokens { + if !isScenarioPending(pendingScenarios, string(tenantScenario)) { + skippedTokenCount++ + + continue + } + splitted := strings.Split(string(tenantScenario), "_") + tenantUsr, fErr := linking.FetchTokenData(ctx, splitted[1], string(token), r.host, r.cfg.TenantURLs) + if fErr != nil { + if errors.Is(fErr, linking.ErrRemoteUserNotFound) { + return nil, errors.Wrapf(linking.ErrNotOwnRemoteUser, "foreign token of userID:%v for the tenant: %v", metadata.UserID, tenantScenario) + } + + return nil, errors.Wrapf(fErr, "failed to fetch remote user data for %v", metadata.UserID) + } + if tenantUsr.CreatedAt == nil || tenantUsr.ReferredBy == "" || tenantUsr.Username == "" { + return nil, errors.Wrapf(linking.ErrNotOwnRemoteUser, "foreign token of userID:%v for the tenant: %v", metadata.UserID, tenantScenario) + } + userIDScenarioMap[tenantScenario] = tenantUsr.ID + } + if skippedTokenCount == len(metadata.TenantTokens) { + return nil, errors.Wrapf(ErrWrongTenantTokens, "all passed tenant tokens don't wait for verification for userID:%v", metadata.UserID) + } + if sErr := r.storeLinkedAccounts(ctx, usr.ID, userIDScenarioMap); sErr != nil { + return nil, errors.Wrap(sErr, "failed to store linked accounts") + } + } + + return nil, errors.Wrapf(r.setCompletedDistributionScenario(ctx, usr.User, metadata.ScenarioEnum, userIDScenarioMap), + "failed to setCompletedDistributionScenario for userID:%v", metadata.UserID) +} + +func (r *repository) GetPendingVerificationScenarios(ctx context.Context, userID string) ([]*Scenario, error) { + usr, err := r.userRepo.GetUserByID(ctx, userID) + if err != nil { + return nil, errors.Wrapf(err, "failed to get user by id: %v", userID) + } + completedSantaTasks, err := r.getCompletedSantaTasks(ctx, usr.ID) + if err != nil { + return nil, errors.Wrapf(err, "failed to getCompletedSantaTasks for userID: %v", usr.ID) + } + + return r.getPendingScenarios(usr.User, completedSantaTasks), nil +} + +//nolint:funlen,gocognit,gocyclo,revive,cyclop // . +func (r *repository) getPendingScenarios(usr *users.User, completedSantaTasks []*tasks.Task) []*Scenario { + var ( + joinBulllishCMCTaskCompleted, joinIONCMCTaskCompleted, joinWatchlistCMCTaskCompleted, cmcScenarioCompleted = false, false, false, false + joinTwitterTaskCompleted, twitterScenarioCompleted = false, false + joinTelegramTaskCompleted, telegramScenarioCompleted = false, false + tenantsScenariosCompleted = map[TenantScenario]bool{ + CoinDistributionScenarioSignUpSunwaves: false, + CoinDistributionScenarioSignUpSealsend: false, + CoinDistributionScenarioSignUpCallfluent: false, + CoinDistributionScenarioSignUpSauces: false, + CoinDistributionScenarioSignUpDoctorx: false, + } + ) + const singUpPrefix = "signup" + if usr.DistributionScenariosCompleted != nil { + for _, completedScenario := range *usr.DistributionScenariosCompleted { + switch completedScenario { + case string(CoinDistributionScenarioCmc): + cmcScenarioCompleted = true + case string(CoinDistributionScenarioTwitter): + twitterScenarioCompleted = true + case string(CoinDistributionScenarioTelegram): + telegramScenarioCompleted = true + default: + splitted := strings.Split(completedScenario, "_") + if splitted[0] == singUpPrefix { + tenantsScenariosCompleted[TenantScenario(completedScenario)] = true + } + } + } + } + scenarios := make([]*Scenario, 0) + for _, task := range completedSantaTasks { + switch task.Type { //nolint:exhaustive // We handle only tasks related to distribution verification. + case tasks.JoinBullishCMCType: + joinBulllishCMCTaskCompleted = true + case tasks.JoinIONCMCType: + joinIONCMCTaskCompleted = true + case tasks.JoinWatchListCMCType: + joinWatchlistCMCTaskCompleted = true + case tasks.JoinTwitterType: + joinTwitterTaskCompleted = true + case tasks.JoinTelegramType: + joinTelegramTaskCompleted = true + default: + if splitted := strings.Split(string(task.Type), "_"); len(splitted) > 1 && splitted[0] == singUpPrefix { + if completed, ok := tenantsScenariosCompleted[TenantScenario(task.Type)]; !ok || !completed { + scenario := Scenario(task.Type) + scenarios = append(scenarios, &scenario) + } + } + } + } + if joinBulllishCMCTaskCompleted && joinIONCMCTaskCompleted && joinWatchlistCMCTaskCompleted && !cmcScenarioCompleted { + val := CoinDistributionScenarioCmc + scenarios = append(scenarios, &val) + } + if joinTwitterTaskCompleted && !twitterScenarioCompleted { + val := CoinDistributionScenarioTwitter + scenarios = append(scenarios, &val) + } + if joinTelegramTaskCompleted && !telegramScenarioCompleted { + val := CoinDistributionScenarioTelegram + scenarios = append(scenarios, &val) + } + + return scenarios +} + +func isScenarioPending(pendingScenarios []*Scenario, scenario string) bool { + if scenario == string(CoinDistributionScenarioSignUpTenants) { + return true + } + for _, pending := range pendingScenarios { + if string(*pending) == scenario { + return true + } + } + + return false +} + +func (r *repository) setCompletedDistributionScenario( + ctx context.Context, usr *users.User, scenario Scenario, userIDMap map[TenantScenario]users.UserID, +) error { + var lenScenarios int + if usr.DistributionScenariosCompleted != nil { + lenScenarios = len(*usr.DistributionScenariosCompleted) + } + scenarios := make(users.Enum[string], 0, lenScenarios+1) + if usr.DistributionScenariosCompleted != nil { + scenarios = append(scenarios, *usr.DistributionScenariosCompleted...) + } + if scenario != CoinDistributionScenarioSignUpTenants { + scenarios = append(scenarios, string(scenario)) + } else { + for tenant := range userIDMap { + scenarios = append(scenarios, string(tenant)) + } + } + updUsr := new(users.User) + updUsr.ID = usr.ID + updUsr.DistributionScenariosCompleted = &scenarios + _, err := r.userRepo.ModifyUser(ctx, updUsr, nil) + if err != nil { + return errors.Wrapf(err, "failed to modify user for userID: %v, error: %v", usr.ID, err) + } + + return errors.Wrapf(err, "failed to set completed distribution scenarios:%v", scenarios) +} + +//nolint:funlen // . +func (r *repository) getCompletedSantaTasks(ctx context.Context, userID string) (res []*tasks.Task, err error) { + resp, err := req. + SetContext(ctx). + SetRetryCount(25). //nolint:gomnd,mnd // . + SetRetryInterval(func(_ *req.Response, attempt int) stdlibtime.Duration { + switch { + case attempt <= 1: + return 100 * stdlibtime.Millisecond //nolint:gomnd // . + case attempt == 2: //nolint:gomnd // . + return 1 * stdlibtime.Second + default: + return 10 * stdlibtime.Second //nolint:gomnd // . + } + }). + SetRetryHook(func(resp *req.Response, err error) { + if err != nil { + log.Error(errors.Wrap(err, "failed to fetch completed santa tasks, retrying...")) //nolint:revive // . + } else { + log.Error(errors.Errorf("failed to fetch completed santa tasks with status code:%v, retrying...", resp.GetStatusCode())) //nolint:revive // . + } + }). + SetRetryCondition(func(resp *req.Response, err error) bool { + return err != nil || resp.GetStatusCode() != http.StatusOK + }). + SetHeader("Accept", "application/json"). + SetHeader("Accept", "application/json"). + SetHeader("Authorization", authorization(ctx)). + SetHeader("Cache-Control", "no-cache, no-store, must-revalidate"). + SetHeader("Pragma", "no-cache"). + SetHeader("Expires", "0"). + Get(fmt.Sprintf("%v%v?language=en&status=completed", r.cfg.SantaTasksURL, userID)) + if err != nil { + return nil, errors.Wrapf(err, "failed to get fetch `%v`", r.cfg.SantaTasksURL) + } + data, err2 := resp.ToBytes() + if err2 != nil { + return nil, errors.Wrapf(err2, "failed to read body of `%v`", r.cfg.SantaTasksURL) + } + var tasksResp []*tasks.Task + if err = json.UnmarshalContext(ctx, data, &tasksResp); err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal into %#v, data: %v", tasksResp, string(data)) + } + + return tasksResp, nil +} + +func authorization(ctx context.Context) (authorization string) { + authorization, _ = ctx.Value(authorizationCtxValueKey).(string) //nolint:errcheck // Not needed. + + return +} + +func (r *repository) storeLinkedAccounts(ctx context.Context, userID string, res map[TenantScenario]string) error { + now := time.Now() + params := []any{} + values := []string{} + idx := 1 + for linkTenant, linkUserID := range res { + lingTenantVal := strings.Split(string(linkTenant), "_")[1] + params = append(params, now.Time, r.cfg.Tenant, userID, lingTenantVal, linkUserID) + //nolint:gomnd // . + values = append(values, fmt.Sprintf("($%[1]v,$%[2]v,$%[3]v,$%[4]v,$%[5]v)", idx, idx+1, idx+2, idx+3, idx+4)) + idx += 5 + } + sql := fmt.Sprintf(`INSERT INTO + linked_user_accounts(linked_at, tenant, user_id, linked_tenant, linked_user_id) + VALUES %v + ON CONFLICT(user_id, linked_user_id, tenant, linked_tenant) DO NOTHING`, strings.Join(values, ",\n")) + _, err := storage.Exec(ctx, r.globalDB, sql, params...) + if err != nil { + return errors.Wrapf(err, "failed to save linked accounts for usr %v: %#v", userID, res) + } + + return nil +} diff --git a/kyc/verification_scenarios/verification_scenarios_test.go b/kyc/verification_scenarios/verification_scenarios_test.go new file mode 100644 index 00000000..0648a5ca --- /dev/null +++ b/kyc/verification_scenarios/verification_scenarios_test.go @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: ice License 1.0 + +package verificationscenarios + +import ( + "testing" + + "github.com/ice-blockchain/eskimo/users" + "github.com/ice-blockchain/santa/tasks" + "github.com/stretchr/testify/require" +) + +func TestGetPendingKYCVerificationScenarios(t *testing.T) { + tests := []struct { + name string + completedSantaTasks []string + distributionScenariosCompleted []string + expectedPendingScenarios []string + }{ + { + name: "All tasks and scenarios completed", + completedSantaTasks: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", "signup_sauces", "signup_doctorx", + "join_twitter", "join_telegram", "join_bullish_cmc", "join_ion_cmc", "join_watchlist_cmc", + }, + distributionScenariosCompleted: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", "signup_sauces", "signup_doctorx", + "join_twitter", "join_telegram", "join_cmc", + }, + expectedPendingScenarios: nil, + }, + { + name: "Some tasks and scenarios completed, but sauces task copmpleted, distribution scenario not completed", + completedSantaTasks: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", "signup_sauces", + }, + distributionScenariosCompleted: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", + }, + expectedPendingScenarios: []string{ + "signup_sauces", + }, + }, + { + name: "No completed santa tasks, but all scenarios completed", + completedSantaTasks: []string{}, + distributionScenariosCompleted: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", "signup_sauces", "signup_doctorx", + "join_twitter", "join_telegram", "join_cmc", + }, + expectedPendingScenarios: nil, + }, + { + name: "Has all completed santa tasks and no scenarios completed", + completedSantaTasks: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", "signup_sauces", "signup_doctorx", + "join_twitter", "join_telegram", "join_bullish_cmc", "join_ion_cmc", "join_watchlist_cmc", + }, + distributionScenariosCompleted: []string{}, + expectedPendingScenarios: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", "signup_sauces", "signup_doctorx", + "join_twitter", "join_telegram", "join_cmc", + }, + }, + { + name: "Has other santa completed tasks that are not related to distribution scenarios", + completedSantaTasks: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", "signup_sauces", "signup_doctorx", + "join_twitter", "join_telegram", "join_bullish_cmc", "join_ion_cmc", "join_watchlist_cmc", + "join_reddit_ion", "join_instagram_ion", "join_youtube", "join_portfolio_coingecko", + }, + distributionScenariosCompleted: []string{}, + expectedPendingScenarios: []string{ + "signup_sunwaves", "signup_sealsend", "signup_callfluent", "signup_sauces", "signup_doctorx", + "join_twitter", "join_telegram", "join_cmc", + }, + }, + { + name: "CMC", + completedSantaTasks: []string{ + "join_bullish_cmc", "join_ion_cmc", "join_watchlist_cmc", + }, + distributionScenariosCompleted: []string{}, + expectedPendingScenarios: []string{"join_cmc"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + enums := make(users.Enum[string], 0) + if tt.distributionScenariosCompleted != nil { + for _, scenario := range tt.distributionScenariosCompleted { + enums = append(enums, scenario) + } + } + usr := &users.User{ + DistributionScenariosCompleted: &enums, + } + tsks := make([]*tasks.Task, len(tt.completedSantaTasks)) + for i, taskType := range tt.completedSantaTasks { + tsks[i] = &tasks.Task{ + Type: tasks.Type(taskType), + } + } + repo := &repository{} + pendingScenarios := repo.getPendingScenarios(usr, tsks) + require.NotNil(t, pendingScenarios) + require.Len(t, pendingScenarios, len(tt.expectedPendingScenarios)) + for _, expectedScenario := range tt.expectedPendingScenarios { + require.True(t, isScenarioPending(pendingScenarios, expectedScenario)) + } + }) + } +} + +func TestIsScenarioPending(t *testing.T) { + cmcScenario := CoinDistributionScenarioCmc + signUpTenantsScenario := CoinDistributionScenarioSignUpTenants + tests := []struct { + name string + pendingScenarios []*Scenario + scenario Scenario + expectedIsPending bool + }{ + { + name: "Scenario is pending", + pendingScenarios: []*Scenario{&cmcScenario, &signUpTenantsScenario}, + scenario: CoinDistributionScenarioCmc, + expectedIsPending: true, + }, + { + name: "Scenario is not pending", + pendingScenarios: []*Scenario{&cmcScenario}, + scenario: CoinDistributionScenarioTelegram, + expectedIsPending: false, + }, + { + name: "Scenario is CoinDistributionScenarioSignUpTenants, we return true to check tenants tokens later", + pendingScenarios: []*Scenario{&cmcScenario}, + scenario: CoinDistributionScenarioSignUpTenants, + expectedIsPending: true, + }, + { + name: "No pending scenarios", + pendingScenarios: []*Scenario{}, + scenario: CoinDistributionScenarioCmc, + expectedIsPending: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isPending := isScenarioPending(tt.pendingScenarios, string(tt.scenario)) + require.Equal(t, tt.expectedIsPending, isPending) + }) + } +} diff --git a/users/DDL.sql b/users/DDL.sql index b5207412..bd54f3ad 100644 --- a/users/DDL.sql +++ b/users/DDL.sql @@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS users ( agenda_contact_user_ids text[], kyc_steps_last_updated_at timestamp[], kyc_steps_created_at timestamp[], + distribution_scenarios_completed text[], mining_blockchain_account_address text NOT NULL UNIQUE, blockchain_account_address text NOT NULL UNIQUE, telegram_user_id text NOT NULL UNIQUE, @@ -119,6 +120,7 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS kyc_step_blocked smallint NOT NULL DE ALTER TABLE users ADD COLUMN IF NOT EXISTS kyc_steps_last_updated_at timestamp[]; ALTER TABLE users ADD COLUMN IF NOT EXISTS kyc_steps_created_at timestamp[]; ALTER TABLE users ADD COLUMN IF NOT EXISTS telegram_user_id text; +ALTER TABLE users ADD COLUMN IF NOT EXISTS distribution_scenarios_completed text[]; DO $$ BEGIN if NOT exists (select constraint_name from information_schema.table_constraints where table_name = 'users' and constraint_name = 'users_telegram_user_id_key') then ALTER TABLE users ADD CONSTRAINT users_telegram_user_id_key UNIQUE (telegram_user_id); diff --git a/users/contract.go b/users/contract.go index f5fceb9b..715c3fd9 100644 --- a/users/contract.go +++ b/users/contract.go @@ -104,23 +104,24 @@ type ( ProfilePictureURL string `json:"profilePictureUrl,omitempty" example:"https://somecdn.com/p1.jpg" db:"profile_picture_name"` } User struct { - CreatedAt *time.Time `json:"createdAt,omitempty" example:"2022-01-03T16:20:52.156534Z" db:"created_at"` - UpdatedAt *time.Time `json:"updatedAt,omitempty" example:"2022-01-03T16:20:52.156534Z" db:"updated_at"` - LastMiningStartedAt *time.Time `json:"lastMiningStartedAt,omitempty" example:"2022-01-03T16:20:52.156534Z" swaggerignore:"true" db:"last_mining_started_at"` //nolint:lll // . - LastMiningEndedAt *time.Time `json:"lastMiningEndedAt,omitempty" example:"2022-01-03T16:20:52.156534Z" swaggerignore:"true" db:"last_mining_ended_at"` //nolint:lll // . - LastPingCooldownEndedAt *time.Time `json:"lastPingCooldownEndedAt,omitempty" example:"2022-01-03T16:20:52.156534Z" swaggerignore:"true" db:"last_ping_cooldown_ended_at"` //nolint:lll // . - ClaimedByThirdPartyAt *time.Time `json:"-" example:"2022-01-03T16:20:52.156534Z" swaggerignore:"true" db:"claimed_by_third_party_at"` //nolint:lll // . - HiddenProfileElements *Enum[HiddenProfileElement] `json:"hiddenProfileElements,omitempty" swaggertype:"array,string" example:"level" enums:"globalRank,referralCount,level,role,badges" db:"hidden_profile_elements"` //nolint:lll // . - RandomReferredBy *bool `json:"randomReferredBy,omitempty" example:"true" swaggerignore:"true" db:"random_referred_by"` - Verified *bool `json:"verified" example:"true" db:"verified"` - QuizCompleted *bool `json:"-" db:"quiz_completed"` - T1ReferralsSharingEnabled *bool `json:"t1ReferralsSharingEnabled" example:"true" db:"t1_referrals_sharing_enabled"` - KYCStepsLastUpdatedAt *[]*time.Time `json:"kycStepsLastUpdatedAt,omitempty" swaggertype:"array,string" example:"2022-01-03T16:20:52.156534Z" db:"kyc_steps_last_updated_at"` //nolint:lll // . - KYCStepsCreatedAt *[]*time.Time `json:"kycStepsCreatedAt,omitempty" swaggertype:"array,string" example:"2022-01-03T16:20:52.156534Z" db:"kyc_steps_created_at"` //nolint:lll // . - KYCStepPassed *KYCStep `json:"kycStepPassed,omitempty" example:"0" db:"kyc_step_passed"` - KYCStepBlocked *KYCStep `json:"kycStepBlocked,omitempty" example:"0" db:"kyc_step_blocked"` - ClientData *JSON `json:"clientData,omitempty" db:"client_data"` - RepeatableKYCSteps *map[KYCStep]*time.Time `json:"repeatableKYCSteps,omitempty" db:"-"` //nolint:tagliatelle // Nope. + CreatedAt *time.Time `json:"createdAt,omitempty" example:"2022-01-03T16:20:52.156534Z" db:"created_at"` + UpdatedAt *time.Time `json:"updatedAt,omitempty" example:"2022-01-03T16:20:52.156534Z" db:"updated_at"` + LastMiningStartedAt *time.Time `json:"lastMiningStartedAt,omitempty" example:"2022-01-03T16:20:52.156534Z" swaggerignore:"true" db:"last_mining_started_at"` //nolint:lll // . + LastMiningEndedAt *time.Time `json:"lastMiningEndedAt,omitempty" example:"2022-01-03T16:20:52.156534Z" swaggerignore:"true" db:"last_mining_ended_at"` //nolint:lll // . + LastPingCooldownEndedAt *time.Time `json:"lastPingCooldownEndedAt,omitempty" example:"2022-01-03T16:20:52.156534Z" swaggerignore:"true" db:"last_ping_cooldown_ended_at"` //nolint:lll // . + ClaimedByThirdPartyAt *time.Time `json:"-" example:"2022-01-03T16:20:52.156534Z" swaggerignore:"true" db:"claimed_by_third_party_at"` //nolint:lll // . + HiddenProfileElements *Enum[HiddenProfileElement] `json:"hiddenProfileElements,omitempty" swaggertype:"array,string" example:"level" enums:"globalRank,referralCount,level,role,badges" db:"hidden_profile_elements"` //nolint:lll // . + DistributionScenariosCompleted *Enum[string] `json:"distributionScenariosCompleted,omitempty" swaggerignore:"true" db:"distribution_scenarios_completed" enums:"join_cmc,join_twitter,join_telegram,signup_sunwaves,signup_doctorx,signup_callfluent,signup_sealsend,signup_sauces"` //nolint:lll // . + RandomReferredBy *bool `json:"randomReferredBy,omitempty" example:"true" swaggerignore:"true" db:"random_referred_by"` + Verified *bool `json:"verified" example:"true" db:"verified"` + QuizCompleted *bool `json:"-" db:"quiz_completed"` + T1ReferralsSharingEnabled *bool `json:"t1ReferralsSharingEnabled" example:"true" db:"t1_referrals_sharing_enabled"` + KYCStepsLastUpdatedAt *[]*time.Time `json:"kycStepsLastUpdatedAt,omitempty" swaggertype:"array,string" example:"2022-01-03T16:20:52.156534Z" db:"kyc_steps_last_updated_at"` //nolint:lll // . + KYCStepsCreatedAt *[]*time.Time `json:"kycStepsCreatedAt,omitempty" swaggertype:"array,string" example:"2022-01-03T16:20:52.156534Z" db:"kyc_steps_created_at"` //nolint:lll // . + KYCStepPassed *KYCStep `json:"kycStepPassed,omitempty" example:"0" db:"kyc_step_passed"` + KYCStepBlocked *KYCStep `json:"kycStepBlocked,omitempty" example:"0" db:"kyc_step_blocked"` + ClientData *JSON `json:"clientData,omitempty" db:"client_data"` + RepeatableKYCSteps *map[KYCStep]*time.Time `json:"repeatableKYCSteps,omitempty" db:"-"` //nolint:tagliatelle // Nope. PrivateUserInformation PublicUserInformation ReferredBy UserID `json:"referredBy,omitempty" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2" db:"referred_by"` diff --git a/users/users_modify.go b/users/users_modify.go index 76f3440b..23121ee0 100644 --- a/users/users_modify.go +++ b/users/users_modify.go @@ -155,6 +155,7 @@ func (u *User) override(user *User) *User { usr.PhoneNumberHash = mergeStringField(u.PhoneNumberHash, user.PhoneNumberHash) usr.BlockchainAccountAddress = mergeStringField(u.BlockchainAccountAddress, user.BlockchainAccountAddress) usr.MiningBlockchainAccountAddress = mergeStringField(u.MiningBlockchainAccountAddress, user.MiningBlockchainAccountAddress) + usr.DistributionScenariosCompleted = mergePointerToArrayField(u.DistributionScenariosCompleted, user.DistributionScenariosCompleted) usr.TelegramUserID = mergeStringField(u.TelegramUserID, user.TelegramUserID) usr.TelegramBotID = mergeStringField(u.TelegramBotID, user.TelegramBotID) @@ -333,6 +334,11 @@ func (u *User) genSQLUpdate(ctx context.Context, agendaUserIDs []UserID) (sql st sql += fmt.Sprintf(", telegram_bot_id = $%v", nextIndex) nextIndex++ } + if u.DistributionScenariosCompleted != nil { + params = append(params, u.DistributionScenariosCompleted) + sql += fmt.Sprintf(", DISTRIBUTION_SCENARIOS_COMPLETED = $%v", nextIndex) + nextIndex++ + } sql += " WHERE ID = $1" From d1cfc7222611809afe5b9529ecf35fa7c34aad07 Mon Sep 17 00:00:00 2001 From: ice-myles <96409608+ice-myles@users.noreply.github.com> Date: Wed, 30 Oct 2024 12:55:53 +0300 Subject: [PATCH 2/7] auto generated fix --- cmd/eskimo-hut/kyc.go | 8 ++++---- kyc/verification_scenarios/verification_scenarios_test.go | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/cmd/eskimo-hut/kyc.go b/cmd/eskimo-hut/kyc.go index d9418ed3..9adf20e0 100644 --- a/cmd/eskimo-hut/kyc.go +++ b/cmd/eskimo-hut/kyc.go @@ -370,10 +370,10 @@ func (s *service) ForwardToFaceKYC( // @Accept json // @Produce json // -// @Param Authorization header string true "Insert your access token" default(Bearer ) -// @Param X-Account-Metadata header string false "Insert your metadata token" default() -// @Param userId path string true "ID of the user" -// @Param scenarioEnum path string true "the scenario" enums(join_cmc,join_twitter,join_telegram,signup_tenants) +// @Param Authorization header string true "Insert your access token" default(Bearer ) +// @Param X-Account-Metadata header string false "Insert your metadata token" default() +// @Param userId path string true "ID of the user" +// @Param scenarioEnum path string true "the scenario" enums(join_cmc,join_twitter,join_telegram,signup_tenants) // @Param request body verificationscenarios.VerificationMetadata false "Request params" // @Success 200 {object} kycsocial.Verification // @Failure 400 {object} server.ErrorResponse "if validations fail" diff --git a/kyc/verification_scenarios/verification_scenarios_test.go b/kyc/verification_scenarios/verification_scenarios_test.go index 0648a5ca..a5146c52 100644 --- a/kyc/verification_scenarios/verification_scenarios_test.go +++ b/kyc/verification_scenarios/verification_scenarios_test.go @@ -5,9 +5,10 @@ package verificationscenarios import ( "testing" + "github.com/stretchr/testify/require" + "github.com/ice-blockchain/eskimo/users" "github.com/ice-blockchain/santa/tasks" - "github.com/stretchr/testify/require" ) func TestGetPendingKYCVerificationScenarios(t *testing.T) { From 7b9b92997032dd213d265ccf234909bf41ba67c9 Mon Sep 17 00:00:00 2001 From: ice-myles <96409608+ice-myles@users.noreply.github.com> Date: Wed, 30 Oct 2024 20:56:42 +0300 Subject: [PATCH 3/7] Work on comments: moved the social scraper functionality from internal to separate package to be accessible from scenario verification. Separate table for twitter scenario verification unsuccesful attempts and moved twitter scenario verify function to verification_scenario package to be isolated from social twitter kyc. --- application.yaml | 31 +-- cmd/eskimo-hut/eskimo.go | 3 +- cmd/eskimo-hut/eskimo_hut.go | 3 +- cmd/eskimo-hut/kyc.go | 8 +- go.mod | 6 +- go.sum | 12 +- kyc/linking/contract.go | 1 + kyc/linking/linking.go | 2 +- .../.testdata/application.yaml | 0 kyc/{social/internal => scraper}/contract.go | 2 +- kyc/{social/internal => scraper}/facebook.go | 2 +- .../internal => scraper}/facebook_test.go | 2 +- kyc/{social/internal => scraper}/scraper.go | 2 +- .../internal => scraper}/scraper_test.go | 2 +- kyc/{social/internal => scraper}/social.go | 2 +- .../internal => scraper}/social_test.go | 2 +- kyc/{social/internal => scraper}/twitter.go | 2 +- .../internal => scraper}/twitter_test.go | 2 +- kyc/{social/internal => scraper}/validator.go | 2 +- .../internal => scraper}/validator_test.go | 2 +- kyc/social/contract.go | 31 ++- kyc/social/remote_kyc_config.go | 16 +- kyc/social/slack_alerts.go | 2 +- kyc/social/social.go | 118 ++------- kyc/social/social_test.go | 2 +- kyc/verification_scenarios/DDL.sql | 8 + kyc/verification_scenarios/contract.go | 36 ++- .../verification_scenarios.go | 239 ++++++++++++++++-- 28 files changed, 344 insertions(+), 196 deletions(-) rename kyc/{social/internal => scraper}/.testdata/application.yaml (100%) rename kyc/{social/internal => scraper}/contract.go (99%) rename kyc/{social/internal => scraper}/facebook.go (99%) rename kyc/{social/internal => scraper}/facebook_test.go (99%) rename kyc/{social/internal => scraper}/scraper.go (99%) rename kyc/{social/internal => scraper}/scraper_test.go (98%) rename kyc/{social/internal => scraper}/social.go (98%) rename kyc/{social/internal => scraper}/social_test.go (99%) rename kyc/{social/internal => scraper}/twitter.go (99%) rename kyc/{social/internal => scraper}/twitter_test.go (99%) rename kyc/{social/internal => scraper}/validator.go (97%) rename kyc/{social/internal => scraper}/validator_test.go (98%) create mode 100644 kyc/verification_scenarios/DDL.sql diff --git a/application.yaml b/application.yaml index 1b3d807e..32342ac6 100644 --- a/application.yaml +++ b/application.yaml @@ -72,12 +72,7 @@ kyc/face: availabilityUrl: secretApiToken: bogus-secret concurrentUsers: 1 -kyc/linking: - tenant: sunwaves - # If urls does not match hostname>/$tenant/ schema. - #tenantURLs: - # callfluent: https://localhost:1444/ - # doctorx: https://localhost:1445/ +globalDb: wintr/connectors/storage/v2: runDDL: true primaryURL: postgresql://root:pass@localhost:5438/eskimo-global?pool_max_conn_idle_time=1000ms&pool_health_check_period=500ms&pool_max_conns=40 @@ -86,21 +81,23 @@ kyc/linking: password: pass replicaURLs: - postgresql://root:pass@localhost:5438/eskimo-global?pool_max_conn_idle_time=1000ms&pool_health_check_period=500ms&pool_max_conns=20 +kyc/linking: + tenant: sunwaves + # If urls does not match hostname>/$tenant/ schema. + #tenantURLs: + # callfluent: https://localhost:1444/ + # doctorx: https://localhost:1445/ kyc/coinDistributionEligibility: - santaTasksUrl: https://localhost:7443/v1r/tasks/x/users/ tenant: sunwaves + maxAttemptsAllowed: 3 + sessionWindow: 2m + configJsonUrl1: https://somewhere.com/something/somebogus.json # If urls does not match hostname>/$tenant/ schema. # tenantURLs: - # callfluent: https://localhost:1444/ - # doctorx: https://localhost:1445/ - wintr/connectors/storage/v2: - runDDL: true - primaryURL: postgresql://root:pass@localhost:5438/eskimo-global?pool_max_conn_idle_time=1000ms&pool_health_check_period=500ms&pool_max_conns=40 - credentials: - user: root - password: pass - replicaURLs: - - postgresql://root:pass@localhost:5438/eskimo-global?pool_max_conn_idle_time=1000ms&pool_health_check_period=500ms&pool_max_conns=20 + # sunwaves: https://localhost:7443/ + # callfluent: https://localhost:7444/ + # doctorx: https://localhost:7445/ + wintr/connectors/storage/v2: *db kyc/quiz: environment: local enable-alerts: false diff --git a/cmd/eskimo-hut/eskimo.go b/cmd/eskimo-hut/eskimo.go index 44330761..d72b5ee2 100644 --- a/cmd/eskimo-hut/eskimo.go +++ b/cmd/eskimo-hut/eskimo.go @@ -268,8 +268,7 @@ func (s *service) setupUserReadRoutes(router *server.Router) { Group("v1r"). GET("users", server.RootHandler(s.GetUsers)). GET("users/:userId", server.RootHandler(s.GetUserByID)). - GET("user-views/username", server.RootHandler(s.GetUserByUsername)). - GET("kyc/verifyCoinDistributionEligibility/users/:userId", server.RootHandler(s.GetPendingKYCVerificationScenarios)) + GET("user-views/username", server.RootHandler(s.GetUserByUsername)) } // GetUsers godoc diff --git a/cmd/eskimo-hut/eskimo_hut.go b/cmd/eskimo-hut/eskimo_hut.go index 7f376f79..ea38cb8e 100644 --- a/cmd/eskimo-hut/eskimo_hut.go +++ b/cmd/eskimo-hut/eskimo_hut.go @@ -56,7 +56,8 @@ func main() { func (s *service) RegisterRoutes(router *server.Router) { s.registerEskimoRoutes(router) - s.setupKYCRoutes(router) + s.setupKYCWriteRoutes(router) + s.setupKYCReadRoutes(router) s.setupUserRoutes(router) s.setupDevicesRoutes(router) s.setupAuthRoutes(router) diff --git a/cmd/eskimo-hut/kyc.go b/cmd/eskimo-hut/kyc.go index 9adf20e0..806bed66 100644 --- a/cmd/eskimo-hut/kyc.go +++ b/cmd/eskimo-hut/kyc.go @@ -20,7 +20,7 @@ import ( "github.com/ice-blockchain/wintr/server" ) -func (s *service) setupKYCRoutes(router *server.Router) { +func (s *service) setupKYCWriteRoutes(router *server.Router) { router. Group("v1w"). POST("kyc/startOrContinueKYCStep4Session/users/:userId", server.RootHandler(s.StartOrContinueKYCStep4Session)). @@ -31,6 +31,12 @@ func (s *service) setupKYCRoutes(router *server.Router) { POST("kyc/verifyCoinDistributionEligibility/users/:userId/scenarios/:scenarioEnum", server.RootHandler(s.VerifyKYCScenarios)) } +func (s *service) setupKYCReadRoutes(router *server.Router) { + router. + Group("v1r"). + GET("kyc/verifyCoinDistributionEligibility/users/:userId", server.RootHandler(s.GetPendingKYCVerificationScenarios)) +} + func (s *service) startQuizSession(ctx context.Context, userID users.UserID, lang string) (*kycquiz.Quiz, error) { const defaultLanguage = "en" diff --git a/go.mod b/go.mod index 0f262fd0..0f31e7e9 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/google/uuid v1.6.0 github.com/hashicorp/go-multierror v1.1.1 github.com/ice-blockchain/go-tarantool-client v0.0.0-20230327200757-4fc71fa3f7bb - github.com/ice-blockchain/santa v1.160.0 + github.com/ice-blockchain/santa v1.161.0 github.com/ice-blockchain/wintr v1.154.0 github.com/imroc/req/v3 v3.48.0 github.com/ip2location/ip2location-go/v9 v9.7.0 @@ -31,8 +31,8 @@ require ( require ( cel.dev/expr v0.18.0 // indirect cloud.google.com/go v0.116.0 // indirect - cloud.google.com/go/auth v0.9.9 // indirect - cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect + cloud.google.com/go/auth v0.10.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.5 // indirect cloud.google.com/go/compute/metadata v0.5.2 // indirect cloud.google.com/go/firestore v1.17.0 // indirect cloud.google.com/go/iam v1.2.2 // indirect diff --git a/go.sum b/go.sum index 6f97be48..6d7bef32 100644 --- a/go.sum +++ b/go.sum @@ -3,10 +3,10 @@ cel.dev/expr v0.18.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= -cloud.google.com/go/auth v0.9.9 h1:BmtbpNQozo8ZwW2t7QJjnrQtdganSdmqeIBxHxNkEZQ= -cloud.google.com/go/auth v0.9.9/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI= -cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY= -cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc= +cloud.google.com/go/auth v0.10.0 h1:tWlkvFAh+wwTOzXIjrwM64karR1iTBZ/GRr0S/DULYo= +cloud.google.com/go/auth v0.10.0/go.mod h1:xxA5AqpDrvS+Gkmo9RqrGGRh6WSNKKOXhY3zNOr38tI= +cloud.google.com/go/auth/oauth2adapt v0.2.5 h1:2p29+dePqsCHPP1bqDJcKj4qxRyYCcbzKpFyKGt3MTk= +cloud.google.com/go/auth/oauth2adapt v0.2.5/go.mod h1:AlmsELtlEBnaNTL7jCj8VQFLy6mbZv0s4Q7NGBeQ5E8= cloud.google.com/go/compute/metadata v0.5.2 h1:UxK4uu/Tn+I3p2dYWTfiX4wva7aYlKixAHn3fyqngqo= cloud.google.com/go/compute/metadata v0.5.2/go.mod h1:C66sj2AluDcIqakBq/M8lw8/ybHgOZqin2obFxa/E5k= cloud.google.com/go/firestore v1.17.0 h1:iEd1LBbkDZTFsLw3sTH50eyg4qe8eoG6CjocmEXO9aQ= @@ -251,8 +251,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/ice-blockchain/go-tarantool-client v0.0.0-20230327200757-4fc71fa3f7bb h1:8TnFP3mc7O+tc44kv2e0/TpZKnEVUaKH+UstwfBwRkk= github.com/ice-blockchain/go-tarantool-client v0.0.0-20230327200757-4fc71fa3f7bb/go.mod h1:ZsQU7i3mxhgBBu43Oev7WPFbIjP4TniN/b1UPNGbrq8= -github.com/ice-blockchain/santa v1.160.0 h1:ZvOZIDnWJOqIJZgUSaFenAn/DnEw4VPowtUA1pHTRK0= -github.com/ice-blockchain/santa v1.160.0/go.mod h1:DfVQbVX1E/wjKg9ekQip/iAEUJLhnpUlTBC7QqgqtvE= +github.com/ice-blockchain/santa v1.161.0 h1:OWZ8xz8PFIM9hIGlxZ18xX17B3634/I7aU+Gzd4oRd0= +github.com/ice-blockchain/santa v1.161.0/go.mod h1:QqjfCjiPiB9vAsDFv/RClnC1+FT0abcsznEWg4GxqQ8= github.com/ice-blockchain/wintr v1.154.0 h1:yZSQtAEwGHTSmJ5pXjX0tpui1TNnG615QfpBkhY99a4= github.com/ice-blockchain/wintr v1.154.0/go.mod h1:DoUn66XJGzPzfCZTsHyMjfgj2aVLGvjqDSuKj2pa3KE= github.com/imroc/req/v3 v3.48.0 h1:IYuMGetuwLzOOTzDCquDqs912WNwpsPK0TBXWPIvoqg= diff --git a/kyc/linking/contract.go b/kyc/linking/contract.go index bd020d6d..ee9e705a 100644 --- a/kyc/linking/contract.go +++ b/kyc/linking/contract.go @@ -42,6 +42,7 @@ type ( const ( requestDeadline = 25 * stdlibtime.Second applicationYamlKey = "kyc/linking" + globalDBYamlKey = "globalDB" ) var ( diff --git a/kyc/linking/linking.go b/kyc/linking/linking.go index 0b83bae5..55b4139c 100644 --- a/kyc/linking/linking.go +++ b/kyc/linking/linking.go @@ -34,7 +34,7 @@ func NewAccountLinker(ctx context.Context, host string) Linker { if len(cfg.TenantURLs) == 0 && host == "" { log.Panic("kyc/linking: Must provide tenantURLs or host") } - db := storage.MustConnect(ctx, ddl, applicationYamlKey) + db := storage.MustConnect(ctx, ddl, globalDBYamlKey) return &linker{ globalDB: db, diff --git a/kyc/social/internal/.testdata/application.yaml b/kyc/scraper/.testdata/application.yaml similarity index 100% rename from kyc/social/internal/.testdata/application.yaml rename to kyc/scraper/.testdata/application.yaml diff --git a/kyc/social/internal/contract.go b/kyc/scraper/contract.go similarity index 99% rename from kyc/social/internal/contract.go rename to kyc/scraper/contract.go index 4033ef4c..3f7cf44c 100644 --- a/kyc/social/internal/contract.go +++ b/kyc/scraper/contract.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "context" diff --git a/kyc/social/internal/facebook.go b/kyc/scraper/facebook.go similarity index 99% rename from kyc/social/internal/facebook.go rename to kyc/scraper/facebook.go index 022975cd..500a95d0 100644 --- a/kyc/social/internal/facebook.go +++ b/kyc/scraper/facebook.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "context" diff --git a/kyc/social/internal/facebook_test.go b/kyc/scraper/facebook_test.go similarity index 99% rename from kyc/social/internal/facebook_test.go rename to kyc/scraper/facebook_test.go index c867f85a..bb1e8e7b 100644 --- a/kyc/social/internal/facebook_test.go +++ b/kyc/scraper/facebook_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "context" diff --git a/kyc/social/internal/scraper.go b/kyc/scraper/scraper.go similarity index 99% rename from kyc/social/internal/scraper.go rename to kyc/scraper/scraper.go index 9b8aa58b..7c94754a 100644 --- a/kyc/social/internal/scraper.go +++ b/kyc/scraper/scraper.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "context" diff --git a/kyc/social/internal/scraper_test.go b/kyc/scraper/scraper_test.go similarity index 98% rename from kyc/social/internal/scraper_test.go rename to kyc/scraper/scraper_test.go index a886b47f..3810f805 100644 --- a/kyc/social/internal/scraper_test.go +++ b/kyc/scraper/scraper_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "context" diff --git a/kyc/social/internal/social.go b/kyc/scraper/social.go similarity index 98% rename from kyc/social/internal/social.go rename to kyc/scraper/social.go index 9b35e4cb..9ecd1857 100644 --- a/kyc/social/internal/social.go +++ b/kyc/scraper/social.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "os" diff --git a/kyc/social/internal/social_test.go b/kyc/scraper/social_test.go similarity index 99% rename from kyc/social/internal/social_test.go rename to kyc/scraper/social_test.go index a699964b..8c938892 100644 --- a/kyc/social/internal/social_test.go +++ b/kyc/scraper/social_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "context" diff --git a/kyc/social/internal/twitter.go b/kyc/scraper/twitter.go similarity index 99% rename from kyc/social/internal/twitter.go rename to kyc/scraper/twitter.go index 48146477..5d5b1529 100644 --- a/kyc/social/internal/twitter.go +++ b/kyc/scraper/twitter.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "bytes" diff --git a/kyc/social/internal/twitter_test.go b/kyc/scraper/twitter_test.go similarity index 99% rename from kyc/social/internal/twitter_test.go rename to kyc/scraper/twitter_test.go index ec274653..4e08d536 100644 --- a/kyc/social/internal/twitter_test.go +++ b/kyc/scraper/twitter_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "context" diff --git a/kyc/social/internal/validator.go b/kyc/scraper/validator.go similarity index 97% rename from kyc/social/internal/validator.go rename to kyc/scraper/validator.go index 28a0d46e..90e69385 100644 --- a/kyc/social/internal/validator.go +++ b/kyc/scraper/validator.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "net/url" diff --git a/kyc/social/internal/validator_test.go b/kyc/scraper/validator_test.go similarity index 98% rename from kyc/social/internal/validator_test.go rename to kyc/scraper/validator_test.go index 8fd759cc..65e9fb71 100644 --- a/kyc/social/internal/validator_test.go +++ b/kyc/scraper/validator_test.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: ice License 1.0 -package social +package scraper import ( "testing" diff --git a/kyc/social/contract.go b/kyc/social/contract.go index 2fbc376d..72c2fee5 100644 --- a/kyc/social/contract.go +++ b/kyc/social/contract.go @@ -14,7 +14,7 @@ import ( "github.com/pkg/errors" - social "github.com/ice-blockchain/eskimo/kyc/social/internal" + "github.com/ice-blockchain/eskimo/kyc/scraper" "github.com/ice-blockchain/eskimo/users" "github.com/ice-blockchain/wintr/connectors/storage/v2" ) @@ -22,8 +22,8 @@ import ( // Public API. const ( - FacebookType = social.StrategyFacebook - TwitterType = social.StrategyTwitter + FacebookType = scraper.StrategyFacebook + TwitterType = scraper.StrategyTwitter ) const ( @@ -44,7 +44,8 @@ var ( ) type ( - Type = social.StrategyType + Type = scraper.StrategyType + Metadata = scraper.Metadata VerificationResult string Verification struct { RemainingAttempts *uint8 `json:"remainingAttempts,omitempty" example:"3"` @@ -65,10 +66,15 @@ type ( Social Type `form:"social" required:"true" swaggerignore:"true" example:"twitter"` KYCStep users.KYCStep `form:"kycStep" required:"true" swaggerignore:"true" example:"1"` } + KycConfigJSON struct { + XPostPatternTemplate *template.Template `json:"-"` + XPostPattern string `json:"xPostPattern"` + XPostLink string `json:"xPostLink"` + XPostPatternExactMatch bool `json:"xPostPatternExactMatch"` + } Repository interface { io.Closer VerifyPost(ctx context.Context, metadata *VerificationMetadata) (*Verification, error) - VerifyPostForDistibutionVerification(ctx context.Context, metadata *VerificationMetadata) (*Verification, error) SkipVerification(ctx context.Context, kycStep users.KYCStep, userID string) error } UserRepository interface { @@ -91,7 +97,7 @@ const ( const ( skippedReason = "skipped" - exhaustedRetriesReason = "exhausted_retries" + ExhaustedRetriesReason = "exhausted_retries" ) var ( @@ -115,22 +121,15 @@ type ( } repository struct { user UserRepository - socialVerifiers map[Type]social.Verifier + socialVerifiers map[Type]scraper.Verifier cfg *config db *storage.DB } - kycConfigJSON struct { - xPostPatternTemplate *template.Template `json:"-"` //nolint:revive // . - XPostPattern string `json:"xPostPattern"` - XPostLink string `json:"xPostLink"` - XPostPatternExactMatch bool `json:"xPostPatternExactMatch"` - } - config struct { alertFrequency *sync.Map // .map[users.KYCStep]stdlibtime.Duration. - kycConfigJSON1 *atomic.Pointer[kycConfigJSON] - kycConfigJSON2 *atomic.Pointer[kycConfigJSON] + kycConfigJSON1 *atomic.Pointer[KycConfigJSON] + kycConfigJSON2 *atomic.Pointer[KycConfigJSON] ConfigJSONURL1 string `yaml:"config-json-url1" mapstructure:"config-json-url1"` //nolint:tagliatelle // . ConfigJSONURL2 string `yaml:"config-json-url2" mapstructure:"config-json-url2"` //nolint:tagliatelle // . Environment string `yaml:"environment" mapstructure:"environment"` diff --git a/kyc/social/remote_kyc_config.go b/kyc/social/remote_kyc_config.go index 881a00d4..72ad485d 100644 --- a/kyc/social/remote_kyc_config.go +++ b/kyc/social/remote_kyc_config.go @@ -26,8 +26,8 @@ func init() { //nolint:gochecknoinits // It's the only way to tweak the client. func (r *repository) startKYCConfigJSONSyncer(ctx context.Context) { ticker := stdlibtime.NewTicker(stdlibtime.Minute) defer ticker.Stop() - r.cfg.kycConfigJSON1 = new(atomic.Pointer[kycConfigJSON]) - r.cfg.kycConfigJSON2 = new(atomic.Pointer[kycConfigJSON]) + r.cfg.kycConfigJSON1 = new(atomic.Pointer[KycConfigJSON]) + r.cfg.kycConfigJSON2 = new(atomic.Pointer[KycConfigJSON]) log.Panic(errors.Wrap(r.syncKYCConfigJSON1(ctx), "failed to syncKYCConfigJSON1")) //nolint:revive // . log.Panic(errors.Wrap(r.syncKYCConfigJSON2(ctx), "failed to syncKYCConfigJSON2")) @@ -80,7 +80,7 @@ func (r *repository) syncKYCConfigJSON1(ctx context.Context) error { } else if data, err2 := resp.ToBytes(); err2 != nil { return errors.Wrapf(err2, "failed to read body of `%v`", r.cfg.ConfigJSONURL1) } else { //nolint:revive // . - var kycConfig kycConfigJSON + var kycConfig KycConfigJSON if err = json.UnmarshalContext(ctx, data, &kycConfig); err != nil { return errors.Wrapf(err, "failed to unmarshal into %#v, data: %v", kycConfig, string(data)) } @@ -88,8 +88,8 @@ func (r *repository) syncKYCConfigJSON1(ctx context.Context) error { return errors.Errorf("there's something wrong with the KYCConfigJSON body: %v", body) } if pattern := kycConfig.XPostPattern; pattern != "" { - if kycConfig.xPostPatternTemplate, err = template.New("kycCfg.Social1KYC.XPostPattern").Parse(pattern); err != nil { - return errors.Wrapf(err, "failed to parse kycCfg.Social1KYC.xPostPatternTemplate `%v`", pattern) + if kycConfig.XPostPatternTemplate, err = template.New("kycCfg.Social1KYC.XPostPattern").Parse(pattern); err != nil { + return errors.Wrapf(err, "failed to parse kycCfg.Social1KYC.XPostPatternTemplate `%v`", pattern) } } r.cfg.kycConfigJSON1.Swap(&kycConfig) @@ -132,7 +132,7 @@ func (r *repository) syncKYCConfigJSON2(ctx context.Context) error { } else if data, err2 := resp.ToBytes(); err2 != nil { return errors.Wrapf(err2, "failed to read body of `%v`", r.cfg.ConfigJSONURL2) } else { //nolint:revive // . - var kycConfig kycConfigJSON + var kycConfig KycConfigJSON if err = json.UnmarshalContext(ctx, data, &kycConfig); err != nil { return errors.Wrapf(err, "failed to unmarshal into %#v, data: %v", kycConfig, string(data)) } @@ -140,8 +140,8 @@ func (r *repository) syncKYCConfigJSON2(ctx context.Context) error { return errors.Errorf("there's something wrong with the KYCConfigJSON body: %v", body) } if pattern := kycConfig.XPostPattern; pattern != "" { - if kycConfig.xPostPatternTemplate, err = template.New("kycCfg.Social2KYC.XPostPattern").Parse(pattern); err != nil { - return errors.Wrapf(err, "failed to parse kycCfg.Social2KYC.xPostPatternTemplate `%v`", pattern) + if kycConfig.XPostPatternTemplate, err = template.New("kycCfg.Social2KYC.XPostPattern").Parse(pattern); err != nil { + return errors.Wrapf(err, "failed to parse kycCfg.Social2KYC.XPostPatternTemplate `%v`", pattern) } } r.cfg.kycConfigJSON2.Swap(&kycConfig) diff --git a/kyc/social/slack_alerts.go b/kyc/social/slack_alerts.go index f7fb2dbc..2019203c 100644 --- a/kyc/social/slack_alerts.go +++ b/kyc/social/slack_alerts.go @@ -148,7 +148,7 @@ func (r *repository) sendSlackMessage(ctx context.Context, kycStep users.KYCStep rows := make([]string, 0, len(stats)) var hasExhaustedRetries bool for _, stat := range stats { - if stat.Reason == exhaustedRetriesReason && stat.Counter > 0 { + if stat.Reason == ExhaustedRetriesReason && stat.Counter > 0 { hasExhaustedRetries = true } rows = append(rows, fmt.Sprintf("`%v`: `%v`", stat.Reason, stat.Counter)) diff --git a/kyc/social/social.go b/kyc/social/social.go index 512d9a6e..e051d116 100644 --- a/kyc/social/social.go +++ b/kyc/social/social.go @@ -16,7 +16,7 @@ import ( "github.com/pkg/errors" - social "github.com/ice-blockchain/eskimo/kyc/social/internal" + scraper "github.com/ice-blockchain/eskimo/kyc/scraper" "github.com/ice-blockchain/eskimo/users" appcfg "github.com/ice-blockchain/wintr/config" "github.com/ice-blockchain/wintr/connectors/storage/v2" @@ -50,10 +50,10 @@ func loadTranslations() { //nolint:funlen,gocognit,revive // . tmpl.content = template.Must(template.New(templName).Parse(tmpl.Content)) if _, found := allTemplates[tenantName(tenantFile.Name())]; !found { - allTemplates[tenantName(tenantFile.Name())] = make(map[users.KYCStep]map[social.StrategyType]map[languageTemplateType]map[string][]*languageTemplate) + allTemplates[tenantName(tenantFile.Name())] = make(map[users.KYCStep]map[scraper.StrategyType]map[languageTemplateType]map[string][]*languageTemplate) } if _, found := allTemplates[tenantName(tenantFile.Name())][kycStep]; !found { - allTemplates[tenantName(tenantFile.Name())][kycStep] = make(map[social.StrategyType]map[languageTemplateType]map[string][]*languageTemplate, len(AllTypes)) //nolint:lll // . + allTemplates[tenantName(tenantFile.Name())][kycStep] = make(map[scraper.StrategyType]map[languageTemplateType]map[string][]*languageTemplate, len(AllTypes)) //nolint:lll // . } if _, found := allTemplates[tenantName(tenantFile.Name())][kycStep][socialType]; !found { allTemplates[tenantName(tenantFile.Name())][kycStep][socialType] = make(map[languageTemplateType]map[string][]*languageTemplate, len(&allLanguageTemplateType)) //nolint:lll // . @@ -73,9 +73,9 @@ func New(ctx context.Context, usrRepo UserRepository) Repository { var cfg config appcfg.MustLoadFromKey(applicationYamlKey, &cfg) - socialVerifiers := make(map[Type]social.Verifier, len(AllTypes)) + socialVerifiers := make(map[Type]scraper.Verifier, len(AllTypes)) for _, tp := range AllTypes { - socialVerifiers[tp] = social.New(tp) + socialVerifiers[tp] = scraper.New(tp) } cfg.alertFrequency = new(sync.Map) @@ -130,7 +130,7 @@ func (r *repository) verifySkipped(ctx context.Context, metadata *VerificationMe res, err := storage.Get[struct { LatestCreatedAt *time.Time `db:"latest_created_at"` SkippedCount int `db:"skipped"` - }](ctx, r.db, sql, metadata.UserID, metadata.KYCStep, []string{skippedReason, exhaustedRetriesReason}) + }](ctx, r.db, sql, metadata.UserID, metadata.KYCStep, []string{skippedReason, ExhaustedRetriesReason}) if err != nil { return 0, errors.Wrapf(err, "failed to get skipped attempt count for kycStep:%v,userID:%v", metadata.KYCStep, metadata.UserID) } @@ -160,7 +160,6 @@ func (r *repository) VerifyPost(ctx context.Context, metadata *VerificationMetad if err != nil { return nil, errors.Wrapf(err, "failed to verifySkipped for metadata:%#v", metadata) } - //nolint:goconst // . sql := `SELECT ARRAY_AGG(x.created_at) AS unsuccessful_attempts FROM (SELECT created_at FROM social_kyc_unsuccessful_attempts @@ -170,7 +169,7 @@ func (r *repository) VerifyPost(ctx context.Context, metadata *VerificationMetad ORDER BY created_at DESC) x` res, err := storage.Get[struct { UnsuccessfulAttempts *[]time.Time `db:"unsuccessful_attempts"` - }](ctx, r.db, sql, metadata.UserID, metadata.KYCStep, []string{skippedReason, exhaustedRetriesReason}) + }](ctx, r.db, sql, metadata.UserID, metadata.KYCStep, []string{skippedReason, ExhaustedRetriesReason}) if err != nil { return nil, errors.Wrapf(err, "failed to get unsuccessful_attempts for kycStep:%v,userID:%v", metadata.KYCStep, metadata.UserID) } @@ -188,7 +187,7 @@ func (r *repository) VerifyPost(ctx context.Context, metadata *VerificationMetad if metadata.Twitter.TweetURL == "" && metadata.Facebook.AccessToken == "" { return &Verification{ExpectedPostText: r.expectedPostText(user.User, metadata)}, nil } - pvm := &social.Metadata{ + pvm := &Metadata{ AccessToken: metadata.Facebook.AccessToken, PostURL: metadata.Twitter.TweetURL, ExpectedPostText: r.expectedPostSubtext(user.User, metadata), @@ -198,7 +197,7 @@ func (r *repository) VerifyPost(ctx context.Context, metadata *VerificationMetad if err != nil { //nolint:nestif // . log.Error(errors.Wrapf(err, "social verification failed for KYCStep:%v,Social:%v,Language:%v,userID:%v", metadata.KYCStep, metadata.Social, metadata.Language, metadata.UserID)) - reason := detectReason(err) + reason := DetectReason(err) if userHandle != "" { reason = strings.ToLower(userHandle) + ": " + reason } @@ -207,8 +206,8 @@ func (r *repository) VerifyPost(ctx context.Context, metadata *VerificationMetad } remainingAttempts-- if remainingAttempts == 0 { - if err = r.saveUnsuccessfulAttempt(ctx, time.New(now.Add(stdlibtime.Microsecond)), exhaustedRetriesReason, metadata); err != nil { - return nil, errors.Wrapf(err, "[1]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", exhaustedRetriesReason, metadata) + if err = r.saveUnsuccessfulAttempt(ctx, time.New(now.Add(stdlibtime.Microsecond)), ExhaustedRetriesReason, metadata); err != nil { + return nil, errors.Wrapf(err, "[1]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", ExhaustedRetriesReason, metadata) } end := skippedCount+1 == r.cfg.MaxSessionsAllowed @@ -225,14 +224,14 @@ func (r *repository) VerifyPost(ctx context.Context, metadata *VerificationMetad if storage.IsErr(err, storage.ErrDuplicate) { log.Error(errors.Wrapf(err, "[duplicate]social verification failed for KYCStep:%v,Social:%v,Language:%v,userID:%v,userHandle:%v", metadata.KYCStep, metadata.Social, metadata.Language, metadata.UserID, userHandle)) - reason := detectReason(terror.New(err, map[string]any{"user_handle": userHandle})) + reason := DetectReason(terror.New(err, map[string]any{"user_handle": userHandle})) if err = r.saveUnsuccessfulAttempt(ctx, now, reason, metadata); err != nil { return nil, errors.Wrapf(err, "[2]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", reason, metadata) } remainingAttempts-- if remainingAttempts == 0 { - if err = r.saveUnsuccessfulAttempt(ctx, time.New(now.Add(stdlibtime.Microsecond)), exhaustedRetriesReason, metadata); err != nil { - return nil, errors.Wrapf(err, "[2]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", exhaustedRetriesReason, metadata) + if err = r.saveUnsuccessfulAttempt(ctx, time.New(now.Add(stdlibtime.Microsecond)), ExhaustedRetriesReason, metadata); err != nil { + return nil, errors.Wrapf(err, "[2]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", ExhaustedRetriesReason, metadata) } end := skippedCount+1 == r.cfg.MaxSessionsAllowed if err = r.modifyUser(ctx, end, end, metadata.KYCStep, now, user.User); err != nil { @@ -258,73 +257,6 @@ func (r *repository) VerifyPost(ctx context.Context, metadata *VerificationMetad return &Verification{Result: SuccessVerificationResult}, nil } -//nolint:funlen,gocognit,revive // . -func (r *repository) VerifyPostForDistibutionVerification(ctx context.Context, metadata *VerificationMetadata) (*Verification, error) { - now := time.Now() - user, err := r.user.GetUserByID(ctx, metadata.UserID) - if err != nil { - return nil, errors.Wrapf(err, "failed to GetUserByID: %v", metadata.UserID) - } - sql := `SELECT ARRAY_AGG(x.created_at) AS unsuccessful_attempts - FROM (SELECT created_at - FROM social_kyc_unsuccessful_attempts - WHERE user_id = $1 - AND kyc_step = $2 - AND reason != ANY($3) - ORDER BY created_at DESC) x` - res, err := storage.Get[struct { - UnsuccessfulAttempts *[]time.Time `db:"unsuccessful_attempts"` - }](ctx, r.db, sql, metadata.UserID, metadata.KYCStep, []string{skippedReason, exhaustedRetriesReason}) - if err != nil { - return nil, errors.Wrapf(err, "failed to get unsuccessful_attempts for userID:%v", metadata.UserID) - } - remainingAttempts := r.cfg.MaxAttemptsAllowed - if res.UnsuccessfulAttempts != nil { - for _, unsuccessfulAttempt := range *res.UnsuccessfulAttempts { - if unsuccessfulAttempt.After(now.Add(-r.cfg.SessionWindow)) { - remainingAttempts-- - if remainingAttempts == 0 { - break - } - } - } - } - if remainingAttempts < 1 { - return nil, ErrNotAvailable - } - if metadata.Twitter.TweetURL == "" && metadata.Facebook.AccessToken == "" { - return &Verification{ExpectedPostText: r.expectedPostText(user.User, metadata)}, nil - } - pvm := &social.Metadata{ - AccessToken: metadata.Facebook.AccessToken, - PostURL: metadata.Twitter.TweetURL, - ExpectedPostText: r.expectedPostSubtext(user.User, metadata), - ExpectedPostURL: r.expectedPostURL(metadata), - } - userHandle, err := r.socialVerifiers[metadata.Social].VerifyPost(ctx, pvm) - if err != nil { //nolint:nestif // . - log.Error(errors.Wrapf(err, "social verification failed for Social:%v,Language:%v,userID:%v", - metadata.Social, metadata.Language, metadata.UserID)) - reason := detectReason(err) - if userHandle != "" { - reason = strings.ToLower(userHandle) + ": " + reason - } - if err = r.saveUnsuccessfulAttempt(ctx, now, reason, metadata); err != nil { - return nil, errors.Wrapf(err, "[1]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", reason, metadata) - } - remainingAttempts-- - if remainingAttempts == 0 { - if err = r.saveUnsuccessfulAttempt(ctx, time.New(now.Add(stdlibtime.Microsecond)), exhaustedRetriesReason, metadata); err != nil { - return nil, errors.Wrapf(err, "[1]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", exhaustedRetriesReason, metadata) - } - } - - return &Verification{RemainingAttempts: &remainingAttempts, Result: FailureVerificationResult}, nil - } - - return &Verification{Result: SuccessVerificationResult}, nil -} - func (r *repository) validateKycStep(user *users.User, kycStep users.KYCStep, now *time.Time) error { allowSocialBeforeFace := true if !allowSocialBeforeFace && (user.KYCStepPassed == nil || @@ -419,23 +351,23 @@ func (r *repository) saveSocial(ctx context.Context, socialType Type, userID, us return errors.Wrapf(err, "failed to `%v`; userID:%v, social:%v, userHandle:%v", sql, userID, socialType, userHandle) } -func detectReason(err error) string { +func DetectReason(err error) string { switch { - case errors.Is(err, social.ErrInvalidPageContent): + case errors.Is(err, scraper.ErrInvalidPageContent): return "invalid page content" - case errors.Is(err, social.ErrTextNotFound): + case errors.Is(err, scraper.ErrTextNotFound): return "expected text not found" - case errors.Is(err, social.ErrUsernameNotFound): + case errors.Is(err, scraper.ErrUsernameNotFound): return "username not found" - case errors.Is(err, social.ErrPostNotFound): + case errors.Is(err, scraper.ErrPostNotFound): return "post not found" - case errors.Is(err, social.ErrInvalidURL): + case errors.Is(err, scraper.ErrInvalidURL): return "invalid URL" case errors.Is(err, context.DeadlineExceeded): return "timeout" case errors.Is(err, context.Canceled): return "cancellation" - case errors.Is(err, social.ErrFetchFailed): + case errors.Is(err, scraper.ErrFetchFailed): return "post fetch failed" case storage.IsErr(err, storage.ErrDuplicate): if tErr := terror.As(err); tErr != nil { @@ -489,9 +421,9 @@ func (r *repository) expectedPostTextIsExactMatch(metadata *VerificationMetadata if metadata.Social == TwitterType { switch metadata.KYCStep { //nolint:exhaustive // Not needed. Everything else is validated before this. case users.Social1KYCStep: - return r.cfg.kycConfigJSON1.Load().xPostPatternTemplate != nil && r.cfg.kycConfigJSON1.Load().XPostPatternExactMatch + return r.cfg.kycConfigJSON1.Load().XPostPatternTemplate != nil && r.cfg.kycConfigJSON1.Load().XPostPatternExactMatch case users.Social2KYCStep: - return r.cfg.kycConfigJSON2.Load().xPostPatternTemplate != nil && r.cfg.kycConfigJSON2.Load().XPostPatternExactMatch + return r.cfg.kycConfigJSON2.Load().XPostPatternTemplate != nil && r.cfg.kycConfigJSON2.Load().XPostPatternExactMatch default: panic(fmt.Sprintf("social step `%v` not implemented ", metadata.KYCStep)) } @@ -505,9 +437,9 @@ func (r *repository) expectedPostSubtext(user *users.User, metadata *Verificatio var tmpl *template.Template switch metadata.KYCStep { //nolint:exhaustive // Not needed. Everything else is validated before this. case users.Social1KYCStep: - tmpl = r.cfg.kycConfigJSON1.Load().xPostPatternTemplate + tmpl = r.cfg.kycConfigJSON1.Load().XPostPatternTemplate case users.Social2KYCStep: - tmpl = r.cfg.kycConfigJSON2.Load().xPostPatternTemplate + tmpl = r.cfg.kycConfigJSON2.Load().XPostPatternTemplate default: panic(fmt.Sprintf("social step `%v` not implemented ", metadata.KYCStep)) } diff --git a/kyc/social/social_test.go b/kyc/social/social_test.go index cc91b30e..7e193f7e 100644 --- a/kyc/social/social_test.go +++ b/kyc/social/social_test.go @@ -45,7 +45,7 @@ func TestSocialSave(t *testing.T) { err := repo.saveSocial(ctx, TwitterType, userName, "foo") require.ErrorIs(t, err, storage.ErrDuplicate) - reason := detectReason(terror.New(err, map[string]any{"user_handle": "foo"})) + reason := DetectReason(terror.New(err, map[string]any{"user_handle": "foo"})) require.Equal(t, `duplicate userhandle 'foo'`, reason) }) diff --git a/kyc/verification_scenarios/DDL.sql b/kyc/verification_scenarios/DDL.sql new file mode 100644 index 00000000..b73d732d --- /dev/null +++ b/kyc/verification_scenarios/DDL.sql @@ -0,0 +1,8 @@ +-- SPDX-License-Identifier: ice License 1.0 +CREATE TABLE IF NOT EXISTS verification_distribution_kyc_unsuccessful_attempts ( + created_at timestamp NOT NULL, + reason text NOT NULL, + user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, + social text NOT NULL CHECK (social = 'twitter' OR social = 'cmc'), + PRIMARY KEY (user_id, created_at, social)); + diff --git a/kyc/verification_scenarios/contract.go b/kyc/verification_scenarios/contract.go index 78305cf1..88e4fd7e 100644 --- a/kyc/verification_scenarios/contract.go +++ b/kyc/verification_scenarios/contract.go @@ -4,10 +4,14 @@ package verificationscenarios import ( "context" + _ "embed" "errors" "io" "mime/multipart" + "sync/atomic" + stdlibtime "time" + "github.com/ice-blockchain/eskimo/kyc/scraper" "github.com/ice-blockchain/eskimo/kyc/social" "github.com/ice-blockchain/eskimo/users" storage "github.com/ice-blockchain/wintr/connectors/storage/v2" @@ -55,7 +59,7 @@ type ( VerificationMetadata struct { Authorization string `header:"Authorization" swaggerignore:"true" required:"true" example:"some token"` UserID string `uri:"userId" required:"true" allowForbiddenWriteOperation:"true" swaggerignore:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` //nolint:lll // . - ScenarioEnum Scenario `uri:"scenarioEnum" example:"cmc" swaggerignore:"true" required:"true" enums:"cmc,twitter,telegram,tenant"` //nolint:lll // . + ScenarioEnum Scenario `uri:"scenarioEnum" example:"join_cmc" swaggerignore:"true" required:"true" enums:"join_cmc,join_twitter,join_telegram,signup_tenants"` //nolint:lll // . Language string `json:"language" required:"false" swaggerignore:"true" example:"en"` TenantTokens map[TenantScenario]Token `json:"tenantTokens" required:"false" example:"sunwaves:sometoken,sealsend:sometoken"` CMCProfileLink string `json:"cmcProfileLink" required:"false" example:"some profile"` @@ -68,20 +72,34 @@ type ( const ( applicationYamlKey = "kyc/coinDistributionEligibility" + globalApplicationYamlKey = "globalDb" authorizationCtxValueKey = "authorizationCtxValueKey" + + verificationTwitterScenarioKYCStep int8 = 120 + requestDeadline = 25 * stdlibtime.Second +) + +// . +var ( + //go:embed DDL.sql + ddl string ) type ( repository struct { - cfg *config - globalDB *storage.DB - userRepo UserRepository - socialClient social.Repository - host string + cfg *config + globalDB *storage.DB + db *storage.DB + userRepo UserRepository + twitterVerifier scraper.Verifier + host string } config struct { - TenantURLs map[string]string `yaml:"tenantURLs" mapstructure:"tenantURLs"` //nolint:tagliatelle // . - Tenant string `yaml:"tenant" mapstructure:"tenant"` - SantaTasksURL string `yaml:"santaTasksUrl" mapstructure:"santaTasksUrl"` + TenantURLs map[string]string `yaml:"tenantURLs" mapstructure:"tenantURLs"` //nolint:tagliatelle // . + kycConfigJSON1 *atomic.Pointer[social.KycConfigJSON] + Tenant string `yaml:"tenant" mapstructure:"tenant"` + ConfigJSONURL1 string `yaml:"configJsonUrl1" mapstructure:"configJsonUrl1"` //nolint:tagliatelle // . + SessionWindow stdlibtime.Duration `yaml:"sessionWindow" mapstructure:"sessionWindow"` //nolint:tagliatelle // . + MaxAttemptsAllowed uint8 `yaml:"maxAttemptsAllowed" mapstructure:"maxAttemptsAllowed"` } ) diff --git a/kyc/verification_scenarios/verification_scenarios.go b/kyc/verification_scenarios/verification_scenarios.go index 8e9add9f..283da6f9 100644 --- a/kyc/verification_scenarios/verification_scenarios.go +++ b/kyc/verification_scenarios/verification_scenarios.go @@ -3,10 +3,14 @@ package verificationscenarios import ( + "bytes" "context" "fmt" "net/http" + "net/url" "strings" + "sync/atomic" + "text/template" stdlibtime "time" "github.com/goccy/go-json" @@ -15,6 +19,7 @@ import ( "github.com/pkg/errors" "github.com/ice-blockchain/eskimo/kyc/linking" + "github.com/ice-blockchain/eskimo/kyc/scraper" "github.com/ice-blockchain/eskimo/kyc/social" "github.com/ice-blockchain/eskimo/users" "github.com/ice-blockchain/santa/tasks" @@ -28,21 +33,22 @@ func New(ctx context.Context, usrRepo UserRepository, host string) Repository { var cfg config appcfg.MustLoadFromKey(applicationYamlKey, &cfg) repo := &repository{ - userRepo: usrRepo, - cfg: &cfg, - host: host, - socialClient: social.New(ctx, usrRepo), - globalDB: storage.MustConnect(ctx, "", applicationYamlKey), + userRepo: usrRepo, + cfg: &cfg, + host: host, + globalDB: storage.MustConnect(ctx, "", globalApplicationYamlKey), + db: storage.MustConnect(ctx, ddl, applicationYamlKey), + twitterVerifier: scraper.New(scraper.StrategyTwitter), } + go repo.startKYCConfigJSONSyncer(ctx) return repo } func (r *repository) Close() error { return errors.Wrap(multierror.Append(nil, - errors.Wrap(r.socialClient.Close(), "closing distribution repository failed"), - errors.Wrap(r.userRepo.Close(), "closing users repository failed"), errors.Wrap(r.globalDB.Close(), "closing db connection failed"), + errors.Wrap(r.db.Close(), "closing db connection failed"), ).ErrorOrNil(), "some of close functions failed") } @@ -67,15 +73,7 @@ func (r *repository) VerifyScenarios(ctx context.Context, metadata *Verification return nil, errors.Wrapf(ErrVerificationNotPassed, "haven't passed the CMC verification for userID:%v", metadata.UserID) } case CoinDistributionScenarioTwitter: - verification, sErr := r.socialClient.VerifyPostForDistibutionVerification(ctx, &social.VerificationMetadata{ - UserID: metadata.UserID, - Language: metadata.Language, - Social: social.TwitterType, - Twitter: social.Twitter{ - TweetURL: metadata.TweetURL, - }, - KYCStep: users.Social1KYCStep, - }) + verification, sErr := r.VerifyTwitterPost(ctx, metadata) if sErr != nil { return verification, errors.Wrapf(sErr, "failed to call VerifyPostForDistibutionVerification for userID:%v", metadata.UserID) } @@ -235,19 +233,20 @@ func (r *repository) setCompletedDistributionScenario( updUsr.ID = usr.ID updUsr.DistributionScenariosCompleted = &scenarios _, err := r.userRepo.ModifyUser(ctx, updUsr, nil) - if err != nil { - return errors.Wrapf(err, "failed to modify user for userID: %v, error: %v", usr.ID, err) - } return errors.Wrapf(err, "failed to set completed distribution scenarios:%v", scenarios) } //nolint:funlen // . func (r *repository) getCompletedSantaTasks(ctx context.Context, userID string) (res []*tasks.Task, err error) { - resp, err := req. - SetContext(ctx). - SetRetryCount(25). //nolint:gomnd,mnd // . - SetRetryInterval(func(_ *req.Response, attempt int) stdlibtime.Duration { + getCompletedTasksURL, err := buildGetCompletedTasksURL(r.cfg.Tenant, userID, r.host, r.cfg.TenantURLs) + if err != nil { + log.Panic(errors.Wrapf(err, "failed to detect completed santa task url")) + } + resp, err := req. //nolint:dupl // . + SetContext(ctx). + SetRetryCount(25). //nolint:gomnd,mnd // . + SetRetryInterval(func(_ *req.Response, attempt int) stdlibtime.Duration { switch { case attempt <= 1: return 100 * stdlibtime.Millisecond //nolint:gomnd // . @@ -273,13 +272,13 @@ func (r *repository) getCompletedSantaTasks(ctx context.Context, userID string) SetHeader("Cache-Control", "no-cache, no-store, must-revalidate"). SetHeader("Pragma", "no-cache"). SetHeader("Expires", "0"). - Get(fmt.Sprintf("%v%v?language=en&status=completed", r.cfg.SantaTasksURL, userID)) + Get(fmt.Sprintf("%v?language=en&status=completed", getCompletedTasksURL)) if err != nil { - return nil, errors.Wrapf(err, "failed to get fetch `%v`", r.cfg.SantaTasksURL) + return nil, errors.Wrapf(err, "failed to get fetch `%v`", getCompletedTasksURL) } data, err2 := resp.ToBytes() if err2 != nil { - return nil, errors.Wrapf(err2, "failed to read body of `%v`", r.cfg.SantaTasksURL) + return nil, errors.Wrapf(err2, "failed to read body of `%v`", getCompletedTasksURL) } var tasksResp []*tasks.Task if err = json.UnmarshalContext(ctx, data, &tasksResp); err != nil { @@ -318,3 +317,191 @@ func (r *repository) storeLinkedAccounts(ctx context.Context, userID string, res return nil } + +func buildGetCompletedTasksURL(tenant, userID, host string, tenantURLs map[string]string) (string, error) { + var hasURL bool + var baseURL string + if len(tenantURLs) > 0 { + baseURL, hasURL = tenantURLs[tenant] + } + if !hasURL { + var err error + if baseURL, err = url.JoinPath("https://"+host, tenant); err != nil { + return "", errors.Wrapf(err, "failed to build user url for get completed tasks %v", tenant) + } + } + userURL, err := url.JoinPath(baseURL, "/v1r/tasks/x/users/", userID) + if err != nil { + return "", errors.Wrapf(err, "failed to build user url for tenant %v", tenant) + } + + return userURL, nil +} + +//nolint:funlen,gocognit,revive // . +func (r *repository) VerifyTwitterPost(ctx context.Context, metadata *VerificationMetadata) (*social.Verification, error) { + now := time.Now() + user, err := r.userRepo.GetUserByID(ctx, metadata.UserID) + if err != nil { + return nil, errors.Wrapf(err, "failed to GetUserByID: %v", metadata.UserID) + } + sql := `SELECT ARRAY_AGG(x.created_at) AS unsuccessful_attempts + FROM (SELECT created_at + FROM verification_distribution_kyc_unsuccessful_attempts + WHERE user_id = $1 + AND reason != ANY($2) + ORDER BY created_at DESC) x` + res, err := storage.Get[struct { + UnsuccessfulAttempts *[]time.Time `db:"unsuccessful_attempts"` + }](ctx, r.db, sql, metadata.UserID, []string{social.ExhaustedRetriesReason}) + if err != nil { + return nil, errors.Wrapf(err, "failed to get unsuccessful_attempts for userID:%v", metadata.UserID) + } + remainingAttempts := r.cfg.MaxAttemptsAllowed + if res.UnsuccessfulAttempts != nil { + for _, unsuccessfulAttempt := range *res.UnsuccessfulAttempts { + if unsuccessfulAttempt.After(now.Add(-r.cfg.SessionWindow)) { + remainingAttempts-- + if remainingAttempts == 0 { + break + } + } + } + } + if remainingAttempts < 1 { + return nil, social.ErrNotAvailable + } + pvm := &social.Metadata{ + PostURL: metadata.TweetURL, + ExpectedPostText: r.expectedPostSubtext(user.User, metadata), + ExpectedPostURL: r.expectedPostURL(), + } + userHandle, err := r.twitterVerifier.VerifyPost(ctx, pvm) + if err != nil { //nolint:nestif // . + log.Error(errors.Wrapf(err, "social verification failed for twitter verifier,Language:%v,userID:%v", metadata.Language, metadata.UserID)) + reason := social.DetectReason(err) + if userHandle != "" { + reason = strings.ToLower(userHandle) + ": " + reason + } + if err = r.saveUnsuccessfulAttempt(ctx, now, reason, metadata); err != nil { + return nil, errors.Wrapf(err, "[1]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", reason, metadata) + } + remainingAttempts-- + if remainingAttempts == 0 { + if err = r.saveUnsuccessfulAttempt(ctx, time.New(now.Add(stdlibtime.Microsecond)), social.ExhaustedRetriesReason, metadata); err != nil { + return nil, errors.Wrapf(err, "[1]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", social.ExhaustedRetriesReason, metadata) + } + } + + return &social.Verification{RemainingAttempts: &remainingAttempts, Result: social.FailureVerificationResult}, nil + } + + return &social.Verification{Result: social.SuccessVerificationResult}, nil +} + +func (r *repository) saveUnsuccessfulAttempt(ctx context.Context, now *time.Time, reason string, metadata *VerificationMetadata) error { + var socialName string + switch metadata.ScenarioEnum { //nolint:exhaustive // We know what socials we can use here. + case CoinDistributionScenarioTwitter: + socialName = "twitter" + default: + return errors.Errorf("unknown scenario: %v", metadata.ScenarioEnum) + } + sql := `INSERT INTO verification_distribution_kyc_unsuccessful_attempts(created_at, reason, user_id, social) VALUES ($1,$2,$3,$4)` + _, err := storage.Exec(ctx, r.db, sql, now.Time, reason, metadata.UserID, socialName) + + return errors.Wrapf(err, "failed to `%v`; userId:%v,social:%v,reason:%v", sql, metadata.UserID, socialName, reason) +} + +func (r *repository) expectedPostSubtext(user *users.User, metadata *VerificationMetadata) string { + if tmpl := r.cfg.kycConfigJSON1.Load().XPostPatternTemplate; tmpl != nil { + bf := new(bytes.Buffer) + cpy := *user + cpy.Username = strings.ReplaceAll(cpy.Username, ".", "-") + log.Panic(errors.Wrapf(tmpl.Execute(bf, cpy), "failed to execute expectedPostSubtext template for metadata:%+v user:%+v", metadata, user)) + + return bf.String() + } + + return "" +} + +func (r *repository) expectedPostURL() (resp string) { + resp = r.cfg.kycConfigJSON1.Load().XPostLink + resp = strings.Replace(resp, `https://x.com`, "", 1) + if paramsIndex := strings.IndexRune(resp, '?'); resp != "" && paramsIndex > 0 { + resp = resp[:paramsIndex] + } + + return resp +} + +func (r *repository) startKYCConfigJSONSyncer(ctx context.Context) { + ticker := stdlibtime.NewTicker(stdlibtime.Minute) + defer ticker.Stop() + r.cfg.kycConfigJSON1 = new(atomic.Pointer[social.KycConfigJSON]) + log.Panic(errors.Wrap(r.syncKYCConfigJSON1(ctx), "failed to syncKYCConfigJSON1")) //nolint:revive // . + + for { + select { + case <-ticker.C: + reqCtx, cancel := context.WithTimeout(ctx, requestDeadline) + log.Error(errors.Wrap(r.syncKYCConfigJSON1(reqCtx), "failed to syncKYCConfigJSON1")) + cancel() + case <-ctx.Done(): + return + } + } +} + +//nolint:funlen,gomnd,nestif,dupl,revive // . +func (r *repository) syncKYCConfigJSON1(ctx context.Context) error { + if resp, err := req. + SetContext(ctx). + SetRetryCount(25). + SetRetryInterval(func(_ *req.Response, attempt int) stdlibtime.Duration { + switch { + case attempt <= 1: + return 100 * stdlibtime.Millisecond //nolint:gomnd // . + case attempt == 2: //nolint:gomnd // . + return 1 * stdlibtime.Second + default: + return 10 * stdlibtime.Second //nolint:gomnd // . + } + }). + SetRetryHook(func(resp *req.Response, err error) { + if err != nil { + log.Error(errors.Wrap(err, "failed to fetch KYCConfigJSON, retrying...")) //nolint:revive // . + } else { + log.Error(errors.Errorf("failed to fetch KYCConfigJSON with status code:%v, retrying...", resp.GetStatusCode())) //nolint:revive // . + } + }). + SetRetryCondition(func(resp *req.Response, err error) bool { + return err != nil || resp.GetStatusCode() != http.StatusOK + }). + SetHeader("Accept", "application/json"). + SetHeader("Cache-Control", "no-cache, no-store, must-revalidate"). + SetHeader("Pragma", "no-cache"). + SetHeader("Expires", "0"). + Get(r.cfg.ConfigJSONURL1); err != nil { + return errors.Wrapf(err, "failed to get fetch `%v`", r.cfg.ConfigJSONURL1) + } else if data, err2 := resp.ToBytes(); err2 != nil { + return errors.Wrapf(err2, "failed to read body of `%v`", r.cfg.ConfigJSONURL1) + } else { //nolint:revive // . + var kycConfig social.KycConfigJSON + if err = json.UnmarshalContext(ctx, data, &kycConfig); err != nil { + return errors.Wrapf(err, "failed to unmarshal into %#v, data: %v", kycConfig, string(data)) + } + if body := string(data); !strings.Contains(body, "xPostPattern") && !strings.Contains(body, "xPostLink") { + return errors.Errorf("there's something wrong with the KYCConfigJSON body: %v", body) + } + if pattern := kycConfig.XPostPattern; pattern != "" { + if kycConfig.XPostPatternTemplate, err = template.New("kycCfg.Social1KYC.XPostPattern").Parse(pattern); err != nil { + return errors.Wrapf(err, "failed to parse kycCfg.Social1KYC.xPostPatternTemplate `%v`", pattern) + } + } + r.cfg.kycConfigJSON1.Swap(&kycConfig) + + return nil + } +} From e85b3cc43681bbb196ec6a22d8554ef76b5a31ee Mon Sep 17 00:00:00 2001 From: ice-myles <96409608+ice-myles@users.noreply.github.com> Date: Thu, 31 Oct 2024 14:38:39 +0300 Subject: [PATCH 4/7] Work on comments. --- application.yaml | 5 +- cmd/eskimo-hut/api/docs.go | 11 +- cmd/eskimo-hut/api/swagger.json | 11 +- cmd/eskimo-hut/api/swagger.yaml | 10 +- cmd/eskimo-hut/eskimo_hut.go | 3 +- cmd/eskimo-hut/kyc.go | 20 +-- go.mod | 2 +- go.sum | 4 +- kyc/linking/contract.go | 2 +- kyc/linking/linking.go | 15 +- kyc/verification_scenarios/DDL.sql | 8 - kyc/verification_scenarios/contract.go | 21 +-- .../verification_scenarios.go | 143 ++++-------------- 13 files changed, 71 insertions(+), 184 deletions(-) delete mode 100644 kyc/verification_scenarios/DDL.sql diff --git a/application.yaml b/application.yaml index 32342ac6..afd37fd6 100644 --- a/application.yaml +++ b/application.yaml @@ -72,7 +72,8 @@ kyc/face: availabilityUrl: secretApiToken: bogus-secret concurrentUsers: 1 -globalDb: +kyc/linking: + tenant: sunwaves wintr/connectors/storage/v2: runDDL: true primaryURL: postgresql://root:pass@localhost:5438/eskimo-global?pool_max_conn_idle_time=1000ms&pool_health_check_period=500ms&pool_max_conns=40 @@ -81,8 +82,6 @@ globalDb: password: pass replicaURLs: - postgresql://root:pass@localhost:5438/eskimo-global?pool_max_conn_idle_time=1000ms&pool_health_check_period=500ms&pool_max_conns=20 -kyc/linking: - tenant: sunwaves # If urls does not match hostname>/$tenant/ schema. #tenantURLs: # callfluent: https://localhost:1444/ diff --git a/cmd/eskimo-hut/api/docs.go b/cmd/eskimo-hut/api/docs.go index ad7313fb..eb3401c7 100644 --- a/cmd/eskimo-hut/api/docs.go +++ b/cmd/eskimo-hut/api/docs.go @@ -1745,9 +1745,7 @@ const docTemplate = `{ "responses": { "200": { "description": "OK", - "schema": { - "$ref": "#/definitions/social.Verification" - } + "schema": {} }, "400": { "description": "if validations fail", @@ -3765,8 +3763,11 @@ const docTemplate = `{ "type": "string" }, "example": { - "sealsend": "sometoken", - "sunwaves": "sometoken" + "signup_callfluent": "sometoken", + "signup_doctorx": "sometoken", + "signup_sauces": "sometoken", + "signup_sealsend": "sometoken", + "signup_sunwaves": "sometoken" } }, "tweetUrl": { diff --git a/cmd/eskimo-hut/api/swagger.json b/cmd/eskimo-hut/api/swagger.json index 18251bc0..5a044080 100644 --- a/cmd/eskimo-hut/api/swagger.json +++ b/cmd/eskimo-hut/api/swagger.json @@ -1738,9 +1738,7 @@ "responses": { "200": { "description": "OK", - "schema": { - "$ref": "#/definitions/social.Verification" - } + "schema": {} }, "400": { "description": "if validations fail", @@ -3758,8 +3756,11 @@ "type": "string" }, "example": { - "sealsend": "sometoken", - "sunwaves": "sometoken" + "signup_callfluent": "sometoken", + "signup_doctorx": "sometoken", + "signup_sauces": "sometoken", + "signup_sealsend": "sometoken", + "signup_sunwaves": "sometoken" } }, "tweetUrl": { diff --git a/cmd/eskimo-hut/api/swagger.yaml b/cmd/eskimo-hut/api/swagger.yaml index e1ff0558..6f25dd2b 100644 --- a/cmd/eskimo-hut/api/swagger.yaml +++ b/cmd/eskimo-hut/api/swagger.yaml @@ -861,8 +861,11 @@ definitions: additionalProperties: type: string example: - sealsend: sometoken - sunwaves: sometoken + signup_callfluent: sometoken + signup_doctorx: sometoken + signup_sauces: sometoken + signup_sealsend: sometoken + signup_sunwaves: sometoken type: object tweetUrl: example: some tweet @@ -2042,8 +2045,7 @@ paths: responses: "200": description: OK - schema: - $ref: '#/definitions/social.Verification' + schema: {} "400": description: if validations fail schema: diff --git a/cmd/eskimo-hut/eskimo_hut.go b/cmd/eskimo-hut/eskimo_hut.go index ea38cb8e..93929f5b 100644 --- a/cmd/eskimo-hut/eskimo_hut.go +++ b/cmd/eskimo-hut/eskimo_hut.go @@ -72,7 +72,7 @@ func (s *service) Init(ctx context.Context, cancel context.CancelFunc) { s.quizRepository = kycquiz.NewRepository(ctx, s.usersProcessor) s.usersLinker = linkerkyc.NewAccountLinker(ctx, cfg.Host) s.faceKycClient = facekyc.New(ctx, s.usersProcessor, s.usersLinker) - s.verificationScenariosRepository = verificationscenarios.New(ctx, s.usersProcessor, cfg.Host) + s.verificationScenariosRepository = verificationscenarios.New(ctx, s.usersProcessor, s.usersLinker, cfg.Host) } func (s *service) Close(ctx context.Context) error { @@ -87,7 +87,6 @@ func (s *service) Close(ctx context.Context) error { errors.Wrap(s.usersProcessor.Close(), "could not close usersProcessor"), errors.Wrap(s.usersLinker.Close(), "could not close usersLinker"), errors.Wrap(s.faceKycClient.Close(), "could not close faceKycClient"), - errors.Wrap(s.verificationScenariosRepository.Close(), "could not close verificationScenariosRepository"), ).ErrorOrNil() } diff --git a/cmd/eskimo-hut/kyc.go b/cmd/eskimo-hut/kyc.go index 806bed66..d31811d6 100644 --- a/cmd/eskimo-hut/kyc.go +++ b/cmd/eskimo-hut/kyc.go @@ -381,23 +381,22 @@ func (s *service) ForwardToFaceKYC( // @Param userId path string true "ID of the user" // @Param scenarioEnum path string true "the scenario" enums(join_cmc,join_twitter,join_telegram,signup_tenants) // @Param request body verificationscenarios.VerificationMetadata false "Request params" -// @Success 200 {object} kycsocial.Verification +// @Success 200 {object} any // @Failure 400 {object} server.ErrorResponse "if validations fail" // @Failure 401 {object} server.ErrorResponse "if not authorized" // @Failure 403 {object} server.ErrorResponse "not allowed due to various reasons" // @Failure 422 {object} server.ErrorResponse "if syntax fails" // @Failure 500 {object} server.ErrorResponse // @Router /v1w/kyc/verifyCoinDistributionEligibility/users/{userId}/scenarios/{scenarioEnum} [POST]. -func (s *service) VerifyKYCScenarios( //nolint:gocritic,funlen // . +func (s *service) VerifyKYCScenarios( //nolint:gocritic // . ctx context.Context, - req *server.Request[verificationscenarios.VerificationMetadata, kycsocial.Verification], -) (*server.Response[kycsocial.Verification], *server.Response[server.ErrorResponse]) { + req *server.Request[verificationscenarios.VerificationMetadata, any], +) (*server.Response[any], *server.Response[server.ErrorResponse]) { if err := validateScenariosData(req.Data); err != nil { return nil, server.UnprocessableEntity(errors.Wrapf(err, "validations failed for %#v", req.Data), invalidPropertiesErrorCode) } ctx = users.ContextWithAuthorization(ctx, req.Data.Authorization) //nolint:revive // . - result, err := s.verificationScenariosRepository.VerifyScenarios(ctx, req.Data) - if err = errors.Wrapf(err, "failed to VerifyCoinDistributionEligibility for userID:%v", req.Data.UserID); err != nil { + if err := s.verificationScenariosRepository.VerifyScenarios(ctx, req.Data); err != nil { switch { case errors.Is(err, verificationscenarios.ErrVerificationNotPassed): return nil, server.BadRequest(err, kycVerificationScenariosVadidationFailedErrorCode) @@ -405,10 +404,6 @@ func (s *service) VerifyKYCScenarios( //nolint:gocritic,funlen // . return nil, server.NotFound(err, userNotFoundErrorCode) case errors.Is(err, users.ErrNotFound): return nil, server.NotFound(err, userNotFoundErrorCode) - case errors.Is(err, kycsocial.ErrDuplicate): - return nil, server.Conflict(err, socialKYCStepAlreadyCompletedSuccessfullyErrorCode) - case errors.Is(err, kycsocial.ErrNotAvailable): - return nil, server.ForbiddenWithCode(err, socialKYCStepNotAvailableErrorCode) case errors.Is(err, linking.ErrNotOwnRemoteUser): return nil, server.BadRequest(err, linkingNotOwnedProfile) case errors.Is(err, verificationscenarios.ErrNoPendingScenarios): @@ -419,11 +414,8 @@ func (s *service) VerifyKYCScenarios( //nolint:gocritic,funlen // . return nil, server.Unexpected(err) } } - if result != nil { - return server.OK(result), nil - } - return server.OK[kycsocial.Verification](nil), nil + return server.OK[any](nil), nil } //nolint:funlen,gocognit,revive // . diff --git a/go.mod b/go.mod index 0f31e7e9..0c7349f7 100644 --- a/go.mod +++ b/go.mod @@ -76,7 +76,7 @@ require ( github.com/envoyproxy/go-control-plane v0.13.1 // indirect github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/gabriel-vasile/mimetype v1.4.6 // indirect github.com/georgysavva/scany/v2 v2.1.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect diff --git a/go.sum b/go.sum index 6d7bef32..27d7564e 100644 --- a/go.sum +++ b/go.sum @@ -141,8 +141,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc= github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc= github.com/georgysavva/scany/v2 v2.1.3 h1:Zd4zm/ej79Den7tBSU2kaTDPAH64suq4qlQdhiBeGds= diff --git a/kyc/linking/contract.go b/kyc/linking/contract.go index ee9e705a..58c3ff84 100644 --- a/kyc/linking/contract.go +++ b/kyc/linking/contract.go @@ -24,6 +24,7 @@ type ( Verify(ctx context.Context, now *time.Time, userID UserID, tokens map[Tenant]Token) (allLinkedProfiles LinkedProfiles, verified Tenant, err error) Get(ctx context.Context, userID UserID) (allLinkedProfiles LinkedProfiles, verified Tenant, err error) SetTenantVerified(ctx context.Context, userID UserID, tenant Tenant) error + StoreLinkedAccounts(ctx context.Context, now *time.Time, userID, verifiedTenant string, res map[Tenant]UserID) error } ) @@ -42,7 +43,6 @@ type ( const ( requestDeadline = 25 * stdlibtime.Second applicationYamlKey = "kyc/linking" - globalDBYamlKey = "globalDB" ) var ( diff --git a/kyc/linking/linking.go b/kyc/linking/linking.go index 55b4139c..8511fd7a 100644 --- a/kyc/linking/linking.go +++ b/kyc/linking/linking.go @@ -34,7 +34,7 @@ func NewAccountLinker(ctx context.Context, host string) Linker { if len(cfg.TenantURLs) == 0 && host == "" { log.Panic("kyc/linking: Must provide tenantURLs or host") } - db := storage.MustConnect(ctx, ddl, globalDBYamlKey) + db := storage.MustConnect(ctx, ddl, applicationYamlKey) return &linker{ globalDB: db, @@ -69,14 +69,14 @@ func (l *linker) Verify(ctx context.Context, now *time.Time, userID UserID, toke } } allProfiles[l.cfg.Tenant] = userID - if err = l.storeLinkedAccounts(ctx, now, userID, verified, allProfiles); err != nil { + if err = l.StoreLinkedAccounts(ctx, now, userID, verified, allProfiles); err != nil { return nil, "", errors.Wrapf(err, "failed to save linked accounts for %v", userID) } return allProfiles, verified, nil } -func (l *linker) storeLinkedAccounts(ctx context.Context, now *time.Time, userID, verifiedTenant string, res map[Tenant]UserID) error { +func (l *linker) StoreLinkedAccounts(ctx context.Context, now *time.Time, userID, verifiedTenant string, res map[Tenant]UserID) error { params := []any{} values := []string{} idx := 1 @@ -88,16 +88,15 @@ func (l *linker) storeLinkedAccounts(ctx context.Context, now *time.Time, userID } sql := fmt.Sprintf(`INSERT INTO linked_user_accounts(linked_at, tenant, user_id, linked_tenant, linked_user_id, has_kyc) - VALUES %v`, strings.Join(values, ",\n")) + VALUES %v + ON CONFLICT(user_id, linked_user_id, tenant, linked_tenant) + DO UPDATE SET has_kyc = EXCLUDED.has_kyc`, strings.Join(values, ",\n")) rows, err := storage.Exec(ctx, l.globalDB, sql, params...) - if err != nil { - return errors.Wrapf(err, "failed to save linked accounts for usr %v: %#v", userID, res) - } if rows != uint64(len(res)) { return errors.Errorf("failed unexpected rows on saving linked accounts for usr %v %v instead of %v", userID, rows, len(res)) } - return nil + return errors.Wrapf(err, "failed to save linked accounts for usr %v: %#v", userID, res) } //nolint:funlen // . diff --git a/kyc/verification_scenarios/DDL.sql b/kyc/verification_scenarios/DDL.sql deleted file mode 100644 index b73d732d..00000000 --- a/kyc/verification_scenarios/DDL.sql +++ /dev/null @@ -1,8 +0,0 @@ --- SPDX-License-Identifier: ice License 1.0 -CREATE TABLE IF NOT EXISTS verification_distribution_kyc_unsuccessful_attempts ( - created_at timestamp NOT NULL, - reason text NOT NULL, - user_id text NOT NULL REFERENCES users(id) ON DELETE CASCADE, - social text NOT NULL CHECK (social = 'twitter' OR social = 'cmc'), - PRIMARY KEY (user_id, created_at, social)); - diff --git a/kyc/verification_scenarios/contract.go b/kyc/verification_scenarios/contract.go index 88e4fd7e..4b25eb38 100644 --- a/kyc/verification_scenarios/contract.go +++ b/kyc/verification_scenarios/contract.go @@ -4,17 +4,16 @@ package verificationscenarios import ( "context" - _ "embed" "errors" "io" "mime/multipart" "sync/atomic" stdlibtime "time" + "github.com/ice-blockchain/eskimo/kyc/linking" "github.com/ice-blockchain/eskimo/kyc/scraper" "github.com/ice-blockchain/eskimo/kyc/social" "github.com/ice-blockchain/eskimo/users" - storage "github.com/ice-blockchain/wintr/connectors/storage/v2" ) // Public API. @@ -47,8 +46,7 @@ type ( Scenario string TenantScenario string Repository interface { - io.Closer - VerifyScenarios(ctx context.Context, metadata *VerificationMetadata) (*social.Verification, error) + VerifyScenarios(ctx context.Context, metadata *VerificationMetadata) error GetPendingVerificationScenarios(ctx context.Context, userID string) ([]*Scenario, error) } UserRepository interface { @@ -61,7 +59,7 @@ type ( UserID string `uri:"userId" required:"true" allowForbiddenWriteOperation:"true" swaggerignore:"true" example:"did:ethr:0x4B73C58370AEfcEf86A6021afCDe5673511376B2"` //nolint:lll // . ScenarioEnum Scenario `uri:"scenarioEnum" example:"join_cmc" swaggerignore:"true" required:"true" enums:"join_cmc,join_twitter,join_telegram,signup_tenants"` //nolint:lll // . Language string `json:"language" required:"false" swaggerignore:"true" example:"en"` - TenantTokens map[TenantScenario]Token `json:"tenantTokens" required:"false" example:"sunwaves:sometoken,sealsend:sometoken"` + TenantTokens map[TenantScenario]Token `json:"tenantTokens" required:"false" example:"signup_sunwaves:sometoken,signup_sealsend:sometoken,signup_callfluent:sometoken,signup_doctorx:sometoken,signup_sauces:sometoken"` //nolint:lll // . CMCProfileLink string `json:"cmcProfileLink" required:"false" example:"some profile"` TweetURL string `json:"tweetUrl" required:"false" example:"some tweet"` TelegramUsername string `json:"telegramUsername" required:"false" example:"some telegram username"` @@ -72,26 +70,17 @@ type ( const ( applicationYamlKey = "kyc/coinDistributionEligibility" - globalApplicationYamlKey = "globalDb" authorizationCtxValueKey = "authorizationCtxValueKey" - verificationTwitterScenarioKYCStep int8 = 120 - requestDeadline = 25 * stdlibtime.Second -) - -// . -var ( - //go:embed DDL.sql - ddl string + requestDeadline = 25 * stdlibtime.Second ) type ( repository struct { cfg *config - globalDB *storage.DB - db *storage.DB userRepo UserRepository twitterVerifier scraper.Verifier + linkerRepo linking.Linker host string } config struct { diff --git a/kyc/verification_scenarios/verification_scenarios.go b/kyc/verification_scenarios/verification_scenarios.go index 283da6f9..694188f1 100644 --- a/kyc/verification_scenarios/verification_scenarios.go +++ b/kyc/verification_scenarios/verification_scenarios.go @@ -14,7 +14,6 @@ import ( stdlibtime "time" "github.com/goccy/go-json" - "github.com/hashicorp/go-multierror" "github.com/imroc/req/v3" "github.com/pkg/errors" @@ -24,65 +23,54 @@ import ( "github.com/ice-blockchain/eskimo/users" "github.com/ice-blockchain/santa/tasks" appcfg "github.com/ice-blockchain/wintr/config" - storage "github.com/ice-blockchain/wintr/connectors/storage/v2" "github.com/ice-blockchain/wintr/log" "github.com/ice-blockchain/wintr/time" ) -func New(ctx context.Context, usrRepo UserRepository, host string) Repository { +func New(ctx context.Context, usrRepo UserRepository, linker linking.Linker, host string) Repository { var cfg config appcfg.MustLoadFromKey(applicationYamlKey, &cfg) repo := &repository{ userRepo: usrRepo, cfg: &cfg, host: host, - globalDB: storage.MustConnect(ctx, "", globalApplicationYamlKey), - db: storage.MustConnect(ctx, ddl, applicationYamlKey), twitterVerifier: scraper.New(scraper.StrategyTwitter), + linkerRepo: linker, } go repo.startKYCConfigJSONSyncer(ctx) return repo } -func (r *repository) Close() error { - return errors.Wrap(multierror.Append(nil, - errors.Wrap(r.globalDB.Close(), "closing db connection failed"), - errors.Wrap(r.db.Close(), "closing db connection failed"), - ).ErrorOrNil(), "some of close functions failed") -} - //nolint:funlen,gocognit,gocyclo,revive,cyclop // . -func (r *repository) VerifyScenarios(ctx context.Context, metadata *VerificationMetadata) (*social.Verification, error) { +func (r *repository) VerifyScenarios(ctx context.Context, metadata *VerificationMetadata) error { + now := time.Now() userIDScenarioMap := make(map[TenantScenario]users.UserID, 0) usr, err := r.userRepo.GetUserByID(ctx, metadata.UserID) if err != nil { - return nil, errors.Wrapf(err, "failed to get user by id: %v", metadata.UserID) + return errors.Wrapf(err, "failed to get user by id: %v", metadata.UserID) } completedSantaTasks, err := r.getCompletedSantaTasks(ctx, usr.ID) if err != nil { - return nil, errors.Wrapf(err, "failed to getCompletedSantaTasks for userID: %v", usr.ID) + return errors.Wrapf(err, "failed to getCompletedSantaTasks for userID: %v", usr.ID) } pendingScenarios := r.getPendingScenarios(usr.User, completedSantaTasks) if len(pendingScenarios) == 0 || !isScenarioPending(pendingScenarios, string(metadata.ScenarioEnum)) { - return nil, errors.Wrapf(ErrNoPendingScenarios, "no pending scenarios for user: %v", metadata.UserID) + return errors.Wrapf(ErrNoPendingScenarios, "no pending scenarios for user: %v", metadata.UserID) } switch metadata.ScenarioEnum { case CoinDistributionScenarioCmc: if false { - return nil, errors.Wrapf(ErrVerificationNotPassed, "haven't passed the CMC verification for userID:%v", metadata.UserID) + return errors.Wrapf(ErrVerificationNotPassed, "haven't passed the CMC verification for userID:%v", metadata.UserID) } case CoinDistributionScenarioTwitter: - verification, sErr := r.VerifyTwitterPost(ctx, metadata) - if sErr != nil { - return verification, errors.Wrapf(sErr, "failed to call VerifyPostForDistibutionVerification for userID:%v", metadata.UserID) - } - if verification.Result != social.SuccessVerificationResult { - return verification, nil + if sErr := r.VerifyTwitterPost(ctx, metadata); sErr != nil { + return errors.Wrapf(sErr, "failed to call VerifyPostForDistibutionVerification for userID:%v", metadata.UserID) } case CoinDistributionScenarioTelegram: case CoinDistributionScenarioSignUpTenants: skippedTokenCount := 0 + linkedUserIDs := make(map[linking.Tenant]users.UserID, 0) for tenantScenario, token := range metadata.TenantTokens { if !isScenarioPending(pendingScenarios, string(tenantScenario)) { skippedTokenCount++ @@ -93,25 +81,26 @@ func (r *repository) VerifyScenarios(ctx context.Context, metadata *Verification tenantUsr, fErr := linking.FetchTokenData(ctx, splitted[1], string(token), r.host, r.cfg.TenantURLs) if fErr != nil { if errors.Is(fErr, linking.ErrRemoteUserNotFound) { - return nil, errors.Wrapf(linking.ErrNotOwnRemoteUser, "foreign token of userID:%v for the tenant: %v", metadata.UserID, tenantScenario) + return errors.Wrapf(linking.ErrNotOwnRemoteUser, "foreign token of userID:%v for the tenant: %v", metadata.UserID, tenantScenario) } - return nil, errors.Wrapf(fErr, "failed to fetch remote user data for %v", metadata.UserID) + return errors.Wrapf(fErr, "failed to fetch remote user data for %v", metadata.UserID) } if tenantUsr.CreatedAt == nil || tenantUsr.ReferredBy == "" || tenantUsr.Username == "" { - return nil, errors.Wrapf(linking.ErrNotOwnRemoteUser, "foreign token of userID:%v for the tenant: %v", metadata.UserID, tenantScenario) + return errors.Wrapf(linking.ErrNotOwnRemoteUser, "foreign token of userID:%v for the tenant: %v", metadata.UserID, tenantScenario) } userIDScenarioMap[tenantScenario] = tenantUsr.ID + linkedUserIDs[splitted[1]] = tenantUsr.ID } if skippedTokenCount == len(metadata.TenantTokens) { - return nil, errors.Wrapf(ErrWrongTenantTokens, "all passed tenant tokens don't wait for verification for userID:%v", metadata.UserID) + return errors.Wrapf(ErrWrongTenantTokens, "no pending tenant tokens for userID:%v", metadata.UserID) } - if sErr := r.storeLinkedAccounts(ctx, usr.ID, userIDScenarioMap); sErr != nil { - return nil, errors.Wrap(sErr, "failed to store linked accounts") + if sErr := r.linkerRepo.StoreLinkedAccounts(ctx, now, usr.ID, "", linkedUserIDs); sErr != nil { + return errors.Wrap(sErr, "failed to store linked accounts") } } - return nil, errors.Wrapf(r.setCompletedDistributionScenario(ctx, usr.User, metadata.ScenarioEnum, userIDScenarioMap), + return errors.Wrapf(r.setCompletedDistributionScenario(ctx, usr.User, metadata.ScenarioEnum, userIDScenarioMap), "failed to setCompletedDistributionScenario for userID:%v", metadata.UserID) } @@ -294,30 +283,6 @@ func authorization(ctx context.Context) (authorization string) { return } -func (r *repository) storeLinkedAccounts(ctx context.Context, userID string, res map[TenantScenario]string) error { - now := time.Now() - params := []any{} - values := []string{} - idx := 1 - for linkTenant, linkUserID := range res { - lingTenantVal := strings.Split(string(linkTenant), "_")[1] - params = append(params, now.Time, r.cfg.Tenant, userID, lingTenantVal, linkUserID) - //nolint:gomnd // . - values = append(values, fmt.Sprintf("($%[1]v,$%[2]v,$%[3]v,$%[4]v,$%[5]v)", idx, idx+1, idx+2, idx+3, idx+4)) - idx += 5 - } - sql := fmt.Sprintf(`INSERT INTO - linked_user_accounts(linked_at, tenant, user_id, linked_tenant, linked_user_id) - VALUES %v - ON CONFLICT(user_id, linked_user_id, tenant, linked_tenant) DO NOTHING`, strings.Join(values, ",\n")) - _, err := storage.Exec(ctx, r.globalDB, sql, params...) - if err != nil { - return errors.Wrapf(err, "failed to save linked accounts for usr %v: %#v", userID, res) - } - - return nil -} - func buildGetCompletedTasksURL(tenant, userID, host string, tenantURLs map[string]string) (string, error) { var hasURL bool var baseURL string @@ -338,38 +303,10 @@ func buildGetCompletedTasksURL(tenant, userID, host string, tenantURLs map[strin return userURL, nil } -//nolint:funlen,gocognit,revive // . -func (r *repository) VerifyTwitterPost(ctx context.Context, metadata *VerificationMetadata) (*social.Verification, error) { - now := time.Now() +func (r *repository) VerifyTwitterPost(ctx context.Context, metadata *VerificationMetadata) error { user, err := r.userRepo.GetUserByID(ctx, metadata.UserID) if err != nil { - return nil, errors.Wrapf(err, "failed to GetUserByID: %v", metadata.UserID) - } - sql := `SELECT ARRAY_AGG(x.created_at) AS unsuccessful_attempts - FROM (SELECT created_at - FROM verification_distribution_kyc_unsuccessful_attempts - WHERE user_id = $1 - AND reason != ANY($2) - ORDER BY created_at DESC) x` - res, err := storage.Get[struct { - UnsuccessfulAttempts *[]time.Time `db:"unsuccessful_attempts"` - }](ctx, r.db, sql, metadata.UserID, []string{social.ExhaustedRetriesReason}) - if err != nil { - return nil, errors.Wrapf(err, "failed to get unsuccessful_attempts for userID:%v", metadata.UserID) - } - remainingAttempts := r.cfg.MaxAttemptsAllowed - if res.UnsuccessfulAttempts != nil { - for _, unsuccessfulAttempt := range *res.UnsuccessfulAttempts { - if unsuccessfulAttempt.After(now.Add(-r.cfg.SessionWindow)) { - remainingAttempts-- - if remainingAttempts == 0 { - break - } - } - } - } - if remainingAttempts < 1 { - return nil, social.ErrNotAvailable + return errors.Wrapf(err, "failed to GetUserByID: %v", metadata.UserID) } pvm := &social.Metadata{ PostURL: metadata.TweetURL, @@ -377,40 +314,16 @@ func (r *repository) VerifyTwitterPost(ctx context.Context, metadata *Verificati ExpectedPostURL: r.expectedPostURL(), } userHandle, err := r.twitterVerifier.VerifyPost(ctx, pvm) - if err != nil { //nolint:nestif // . - log.Error(errors.Wrapf(err, "social verification failed for twitter verifier,Language:%v,userID:%v", metadata.Language, metadata.UserID)) - reason := social.DetectReason(err) - if userHandle != "" { - reason = strings.ToLower(userHandle) + ": " + reason - } - if err = r.saveUnsuccessfulAttempt(ctx, now, reason, metadata); err != nil { - return nil, errors.Wrapf(err, "[1]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", reason, metadata) - } - remainingAttempts-- - if remainingAttempts == 0 { - if err = r.saveUnsuccessfulAttempt(ctx, time.New(now.Add(stdlibtime.Microsecond)), social.ExhaustedRetriesReason, metadata); err != nil { - return nil, errors.Wrapf(err, "[1]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", social.ExhaustedRetriesReason, metadata) - } - } - - return &social.Verification{RemainingAttempts: &remainingAttempts, Result: social.FailureVerificationResult}, nil + if err != nil { + return errors.Wrapf(ErrVerificationNotPassed, + "can't verify post for twitter verifier userID:%v,Language:%v,reason:%v", metadata.UserID, metadata.Language, social.DetectReason(err)) } - - return &social.Verification{Result: social.SuccessVerificationResult}, nil -} - -func (r *repository) saveUnsuccessfulAttempt(ctx context.Context, now *time.Time, reason string, metadata *VerificationMetadata) error { - var socialName string - switch metadata.ScenarioEnum { //nolint:exhaustive // We know what socials we can use here. - case CoinDistributionScenarioTwitter: - socialName = "twitter" - default: - return errors.Errorf("unknown scenario: %v", metadata.ScenarioEnum) + if userHandle == "" { + return errors.Wrapf(ErrVerificationNotPassed, + "user handle is empty after the verifyPost call for twitter verifier,Language:%v,userID:%v", metadata.Language, metadata.UserID) } - sql := `INSERT INTO verification_distribution_kyc_unsuccessful_attempts(created_at, reason, user_id, social) VALUES ($1,$2,$3,$4)` - _, err := storage.Exec(ctx, r.db, sql, now.Time, reason, metadata.UserID, socialName) - return errors.Wrapf(err, "failed to `%v`; userId:%v,social:%v,reason:%v", sql, metadata.UserID, socialName, reason) + return nil } func (r *repository) expectedPostSubtext(user *users.User, metadata *VerificationMetadata) string { From 04c8933b6c01dea8461dc8ee18e343ff92fb6ef0 Mon Sep 17 00:00:00 2001 From: ice-myles <96409608+ice-myles@users.noreply.github.com> Date: Thu, 31 Oct 2024 14:45:35 +0300 Subject: [PATCH 5/7] Removed obsolete code/config. --- application.yaml | 3 --- kyc/verification_scenarios/contract.go | 10 ++++------ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/application.yaml b/application.yaml index afd37fd6..b42bf3d0 100644 --- a/application.yaml +++ b/application.yaml @@ -88,15 +88,12 @@ kyc/linking: # doctorx: https://localhost:1445/ kyc/coinDistributionEligibility: tenant: sunwaves - maxAttemptsAllowed: 3 - sessionWindow: 2m configJsonUrl1: https://somewhere.com/something/somebogus.json # If urls does not match hostname>/$tenant/ schema. # tenantURLs: # sunwaves: https://localhost:7443/ # callfluent: https://localhost:7444/ # doctorx: https://localhost:7445/ - wintr/connectors/storage/v2: *db kyc/quiz: environment: local enable-alerts: false diff --git a/kyc/verification_scenarios/contract.go b/kyc/verification_scenarios/contract.go index 4b25eb38..88367f9f 100644 --- a/kyc/verification_scenarios/contract.go +++ b/kyc/verification_scenarios/contract.go @@ -84,11 +84,9 @@ type ( host string } config struct { - TenantURLs map[string]string `yaml:"tenantURLs" mapstructure:"tenantURLs"` //nolint:tagliatelle // . - kycConfigJSON1 *atomic.Pointer[social.KycConfigJSON] - Tenant string `yaml:"tenant" mapstructure:"tenant"` - ConfigJSONURL1 string `yaml:"configJsonUrl1" mapstructure:"configJsonUrl1"` //nolint:tagliatelle // . - SessionWindow stdlibtime.Duration `yaml:"sessionWindow" mapstructure:"sessionWindow"` //nolint:tagliatelle // . - MaxAttemptsAllowed uint8 `yaml:"maxAttemptsAllowed" mapstructure:"maxAttemptsAllowed"` + TenantURLs map[string]string `yaml:"tenantURLs" mapstructure:"tenantURLs"` //nolint:tagliatelle // . + kycConfigJSON1 *atomic.Pointer[social.KycConfigJSON] + Tenant string `yaml:"tenant" mapstructure:"tenant"` + ConfigJSONURL1 string `yaml:"configJsonUrl1" mapstructure:"configJsonUrl1"` //nolint:tagliatelle // . } ) From f1c605a5fde26b489c7b99913a908e6d546e7e59 Mon Sep 17 00:00:00 2001 From: ice-myles <96409608+ice-myles@users.noreply.github.com> Date: Thu, 31 Oct 2024 14:46:51 +0300 Subject: [PATCH 6/7] Work on comments. --- kyc/linking/linking.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/kyc/linking/linking.go b/kyc/linking/linking.go index 8511fd7a..92cbe0a2 100644 --- a/kyc/linking/linking.go +++ b/kyc/linking/linking.go @@ -92,11 +92,14 @@ func (l *linker) StoreLinkedAccounts(ctx context.Context, now *time.Time, userID ON CONFLICT(user_id, linked_user_id, tenant, linked_tenant) DO UPDATE SET has_kyc = EXCLUDED.has_kyc`, strings.Join(values, ",\n")) rows, err := storage.Exec(ctx, l.globalDB, sql, params...) + if err != nil { + return errors.Wrapf(err, "failed to save linked accounts for usr %v: %#v", userID, res) + } if rows != uint64(len(res)) { return errors.Errorf("failed unexpected rows on saving linked accounts for usr %v %v instead of %v", userID, rows, len(res)) } - return errors.Wrapf(err, "failed to save linked accounts for usr %v: %#v", userID, res) + return nil } //nolint:funlen // . From 64544eeb7752c04809599a5a6dfb78277893e505 Mon Sep 17 00:00:00 2001 From: ice-myles <96409608+ice-myles@users.noreply.github.com> Date: Thu, 31 Oct 2024 14:49:36 +0300 Subject: [PATCH 7/7] Revert exposed social error to private. --- kyc/social/contract.go | 2 +- kyc/social/slack_alerts.go | 2 +- kyc/social/social.go | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/kyc/social/contract.go b/kyc/social/contract.go index 72c2fee5..873b166c 100644 --- a/kyc/social/contract.go +++ b/kyc/social/contract.go @@ -97,7 +97,7 @@ const ( const ( skippedReason = "skipped" - ExhaustedRetriesReason = "exhausted_retries" + exhaustedRetriesReason = "exhausted_retries" ) var ( diff --git a/kyc/social/slack_alerts.go b/kyc/social/slack_alerts.go index 2019203c..f7fb2dbc 100644 --- a/kyc/social/slack_alerts.go +++ b/kyc/social/slack_alerts.go @@ -148,7 +148,7 @@ func (r *repository) sendSlackMessage(ctx context.Context, kycStep users.KYCStep rows := make([]string, 0, len(stats)) var hasExhaustedRetries bool for _, stat := range stats { - if stat.Reason == ExhaustedRetriesReason && stat.Counter > 0 { + if stat.Reason == exhaustedRetriesReason && stat.Counter > 0 { hasExhaustedRetries = true } rows = append(rows, fmt.Sprintf("`%v`: `%v`", stat.Reason, stat.Counter)) diff --git a/kyc/social/social.go b/kyc/social/social.go index e051d116..8f775c9f 100644 --- a/kyc/social/social.go +++ b/kyc/social/social.go @@ -130,7 +130,7 @@ func (r *repository) verifySkipped(ctx context.Context, metadata *VerificationMe res, err := storage.Get[struct { LatestCreatedAt *time.Time `db:"latest_created_at"` SkippedCount int `db:"skipped"` - }](ctx, r.db, sql, metadata.UserID, metadata.KYCStep, []string{skippedReason, ExhaustedRetriesReason}) + }](ctx, r.db, sql, metadata.UserID, metadata.KYCStep, []string{skippedReason, exhaustedRetriesReason}) if err != nil { return 0, errors.Wrapf(err, "failed to get skipped attempt count for kycStep:%v,userID:%v", metadata.KYCStep, metadata.UserID) } @@ -169,7 +169,7 @@ func (r *repository) VerifyPost(ctx context.Context, metadata *VerificationMetad ORDER BY created_at DESC) x` res, err := storage.Get[struct { UnsuccessfulAttempts *[]time.Time `db:"unsuccessful_attempts"` - }](ctx, r.db, sql, metadata.UserID, metadata.KYCStep, []string{skippedReason, ExhaustedRetriesReason}) + }](ctx, r.db, sql, metadata.UserID, metadata.KYCStep, []string{skippedReason, exhaustedRetriesReason}) if err != nil { return nil, errors.Wrapf(err, "failed to get unsuccessful_attempts for kycStep:%v,userID:%v", metadata.KYCStep, metadata.UserID) } @@ -206,8 +206,8 @@ func (r *repository) VerifyPost(ctx context.Context, metadata *VerificationMetad } remainingAttempts-- if remainingAttempts == 0 { - if err = r.saveUnsuccessfulAttempt(ctx, time.New(now.Add(stdlibtime.Microsecond)), ExhaustedRetriesReason, metadata); err != nil { - return nil, errors.Wrapf(err, "[1]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", ExhaustedRetriesReason, metadata) + if err = r.saveUnsuccessfulAttempt(ctx, time.New(now.Add(stdlibtime.Microsecond)), exhaustedRetriesReason, metadata); err != nil { + return nil, errors.Wrapf(err, "[1]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", exhaustedRetriesReason, metadata) } end := skippedCount+1 == r.cfg.MaxSessionsAllowed @@ -230,8 +230,8 @@ func (r *repository) VerifyPost(ctx context.Context, metadata *VerificationMetad } remainingAttempts-- if remainingAttempts == 0 { - if err = r.saveUnsuccessfulAttempt(ctx, time.New(now.Add(stdlibtime.Microsecond)), ExhaustedRetriesReason, metadata); err != nil { - return nil, errors.Wrapf(err, "[2]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", ExhaustedRetriesReason, metadata) + if err = r.saveUnsuccessfulAttempt(ctx, time.New(now.Add(stdlibtime.Microsecond)), exhaustedRetriesReason, metadata); err != nil { + return nil, errors.Wrapf(err, "[2]failed to saveUnsuccessfulAttempt reason:%v,metadata:%#v", exhaustedRetriesReason, metadata) } end := skippedCount+1 == r.cfg.MaxSessionsAllowed if err = r.modifyUser(ctx, end, end, metadata.KYCStep, now, user.User); err != nil {