diff --git a/.air-debug.toml b/.air-debug.toml new file mode 100644 index 00000000..a339a8dd --- /dev/null +++ b/.air-debug.toml @@ -0,0 +1,37 @@ +root = "." +testdata_dir = "pkg/test" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -gcflags='all=-N -l' -o ./tmp/main ./cmd/app/main.go" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata", "test"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "dlv exec --accept-multiclient --log --headless --continue --listen :2346 --api-version 2 ./tmp/main" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html", "env"] + kill_delay = "0s" + log = "build-errors.log" + send_interrupt = false + stop_on_error = true + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false \ No newline at end of file diff --git a/.air.toml b/.air.toml index 9c0f451f..5ca12481 100644 --- a/.air.toml +++ b/.air.toml @@ -7,14 +7,14 @@ tmp_dir = "tmp" bin = "./tmp/main" cmd = "go build -o ./tmp/main ./cmd/app/main.go" delay = 1000 - exclude_dir = ["assets", "tmp", "vendor", "testdata", "test"] + exclude_dir = ["assets", "tmp", "vendor", "testdata"] exclude_file = [] exclude_regex = ["_test.go"] exclude_unchanged = false follow_symlink = false full_bin = "" include_dir = [] - include_ext = ["go", "tpl", "tmpl", "html", "env"] + include_ext = ["go", "tpl", "tmpl", "html"] kill_delay = "0s" log = "build-errors.log" send_interrupt = false diff --git a/.env.example b/.env.example index 6e9a8bd3..4d352fd5 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,12 @@ -BASE_URL=https://localhost:5555/ +BASE_URL=http://localhost:5555/ ENV=local PORT=5555 + +##### SET THIS TO YOUR WALLET FOR LOCAL TESTING +STRING_HOTWALLET_ADDRESS=0xb4D168E584dA81B6712412472F163bcf0Af5171C + COINGECKO_API_URL=https://api.coingecko.com/api/v3/ +COINCAP_API_URL=https://api.coincap.io/v2/ OWLRACLE_API_URL=https://api.owlracle.info/v3/ OWLRACLE_API_KEY= OWLRACLE_API_SECRET= @@ -30,14 +35,19 @@ UNIT21_RTR_URL=https://rtr.sandbox2.unit21.com/evaluate TWILIO_ACCOUNT_SID=AC034879a536d54325687e48544403cb4d TWILIO_AUTH_TOKEN= TWILIO_SMS_SID=MG367a4f51ea6f67a28db4d126eefc734f -DEV_PHONE_NUMBERS=+14088675309,+14155555555 +TEAM_PHONE_NUMBERS=+14088675309,+14155555555 STRING_ENCRYPTION_KEY=secret_encryption_key_0123456789 SENDGRID_API_KEY= -IPSTACK_API_KEY= FINGERPRINT_API_KEY= FINGERPRINT_API_URL=https://api.fpjs.io/ STRING_INTERNAL_ID=00000000-0000-0000-0000-000000000000 STRING_WALLET_ID=00000000-0000-0000-0000-000000000001 STRING_BANK_ID=00000000-0000-0000-0000-000000000002 -STRING_PLACEHOLDER_PLATFORM_ID=00000000-0000-0000-0000-000000000003 -SERVICE_NAME=string_api \ No newline at end of file +SERVICE_NAME=string_api +DEBUG_MODE=false +AUTH_EMAIL_ADDRESS=auth@stringxyz.com +RECEIPTS_EMAIL_ADDRESS=receipts@stringxyz.com +CARD_FAIL_PROBABILITY= [0.0 - 1.0] +WEBHOOK_SECRET_KEY=secret +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/<>/<> +PERSONA_API_KEY= diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 00000000..acdfca0c --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,70 @@ +name: deploy to development +permissions: + id-token: write + contents: read +on: + push: + branches: [develop] +jobs: + deploy: + environment: + name: development + url: https://api.dev.string-api.xyz + + name: build push and deploy + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: setup go + uses: actions/setup-go@v3 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + - name: install deps + run: | + go mod download + + - name: configure aws credentials + uses: aws-actions/configure-aws-credentials@v1.7.0 + with: + aws-region: us-west-2 + role-to-assume: ${{ secrets.ASSUME_ROLE }} + + - name: login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: build,tag and push to Amazon ECR + id: image-builder + env: + ECR_REPO: ${{ secrets.AWS_ACCT }}.dkr.ecr.us-west-2.amazonaws.com + SERVICE: api + IMAGE_TAG: ${{ github.sha }} + run: | + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./cmd/app/main ./cmd/app/main.go + docker build --platform linux/amd64 -t $ECR_REPO/$SERVICE:$IMAGE_TAG ./cmd/app/ + docker push $ECR_REPO/$SERVICE:$IMAGE_TAG + echo "image=$ECR_REPO/$SERVICE:$IMAGE_TAG" >> $GITHUB_OUTPUT + + - name: get latest task definition + run: | + aws ecs describe-task-definition --task-definition api --query taskDefinition > task-definition.json + + - name: update task definition + id: task + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: task-definition.json + container-name: api + image: ${{ steps.image-builder.outputs.image }} + + - name: deploy + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: ${{ steps.task.outputs.task-definition }} + cluster: core + service: api + wait-for-service-stability: true diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml new file mode 100644 index 00000000..8408cb19 --- /dev/null +++ b/.github/workflows/deploy-prod.yml @@ -0,0 +1,76 @@ +name: deploy +permissions: + id-token: write + contents: read +on: + push: + tags: + - "*" +jobs: + deploy-prod: + environment: + name: production + url: https://api.string-api.xyz + if: startsWith(github.ref, 'refs/tags/') + name: deploy to ECS - Production + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: setup go + uses: actions/setup-go@v3 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + - name: install deps + run: | + go mod download + + - name: configure aws credentials + uses: aws-actions/configure-aws-credentials@v1.7.0 + with: + aws-region: us-west-2 + role-to-assume: ${{ secrets.PROD_ASSUME_ROLE }} + + - name: login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: Extract tag + id: extract_tag + run: | + echo ::set-output name=tag::${GITHUB_REF#refs/tags/} + + - name: build,tag and push to Amazon ECR + id: image-builder + env: + ECR_REPO: ${{ secrets.PROD_AWS_ACCT }}.dkr.ecr.us-west-2.amazonaws.com + SERVICE: api + IMAGE_TAG: ${{ steps.extract_tag.tag }} + run: | + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./cmd/app/main ./cmd/app/main.go + docker build --platform linux/amd64 -t $ECR_REPO/$SERVICE:$IMAGE_TAG ./cmd/app/ + docker push $ECR_REPO/$SERVICE:$IMAGE_TAG + echo "image=$ECR_REPO/$SERVICE:$IMAGE_TAG" >> $GITHUB_OUTPUT + + - name: get latest task definition + run: | + aws ecs describe-task-definition --task-definition api --query taskDefinition > task-definition.json + + - name: update task definition + id: task + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: task-definition.json + container-name: api + image: ${{ steps.image-builder.outputs.image }} + + - name: deploy + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: ${{ steps.task.outputs.task-definition }} + cluster: core + service: api + wait-for-service-stability: true diff --git a/.github/workflows/deploy-sandbox.yml b/.github/workflows/deploy-sandbox.yml new file mode 100644 index 00000000..e895c761 --- /dev/null +++ b/.github/workflows/deploy-sandbox.yml @@ -0,0 +1,70 @@ +name: deploy to sandbox +permissions: + id-token: write + contents: read +on: + push: + branches: [sandbox] +jobs: + deploy: + environment: + name: sandbox + url: https://api.sandbox.string-api.xyz + + name: build push and deploy + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: setup go + uses: actions/setup-go@v3 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + - name: install deps + run: | + go mod download + + - name: configure aws credentials + uses: aws-actions/configure-aws-credentials@v1.7.0 + with: + aws-region: us-west-2 + role-to-assume: ${{ secrets.ASSUME_ROLE }} + + - name: login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v1 + + - name: build,tag and push to Amazon ECR + id: image-builder + env: + ECR_REPO: ${{ secrets.AWS_ACCT }}.dkr.ecr.us-west-2.amazonaws.com + SERVICE: sandbox-string-api + IMAGE_TAG: ${{ github.sha }} + run: | + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./cmd/app/main ./cmd/app/main.go + docker build --platform linux/amd64 -t $ECR_REPO/$SERVICE:$IMAGE_TAG ./cmd/app/ + docker push $ECR_REPO/$SERVICE:$IMAGE_TAG + echo "image=$ECR_REPO/$SERVICE:$IMAGE_TAG" >> $GITHUB_OUTPUT + + - name: get latest task definition + run: | + aws ecs describe-task-definition --task-definition sandbox-api --query taskDefinition > task-definition.json + + - name: update task definition + id: task + uses: aws-actions/amazon-ecs-render-task-definition@v1 + with: + task-definition: task-definition.json + container-name: api + image: ${{ steps.image-builder.outputs.image }} + + - name: deploy + uses: aws-actions/amazon-ecs-deploy-task-definition@v1 + with: + task-definition: ${{ steps.task.outputs.task-definition }} + cluster: sandbox-core + service: api + wait-for-service-stability: true diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml deleted file mode 100644 index 9665865f..00000000 --- a/.github/workflows/dev-deploy.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: deploy to development -permissions: - id-token: write - contents: read -on: - push: - branches: [develop] -jobs: - deploy: - environment: - name: development - url: https://string-api.dev.string-api.xyz - - name: build push and deploy - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - - - name: setup go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - cache: true - cache-dependency-path: go.sum - - name: install deps and build - ## TODO: Move all building into the docker container - run: | - go mod download - GOOS=linux GOARCH=amd64 go build -o ./cmd/app/main ./cmd/app/main.go - - - name: configure aws credentials - uses: aws-actions/configure-aws-credentials@v1.7.0 - with: - aws-region: us-west-2 - role-to-assume: ${{ secrets.ASSUME_ROLE }} - - - name: login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@v1 - - - name: tag and push to Amazon ECR - env: - ECR_REPO: ${{ secrets.AWS_ACCT }}.dkr.ecr.us-west-2.amazonaws.com - SERVICE: string-api - IMAGE_TAG: latest - run: | - docker build -t $ECR_REPO/$SERVICE:$IMAGE_TAG ./cmd/app/ - docker push $ECR_REPO/$SERVICE:$IMAGE_TAG - - - name: deploy - env: - CLUSTER: string-core - SERVICE: string-api - AWS_REGION: us-west-2 - run: | - aws ecs --region $AWS_REGION update-service --cluster $CLUSTER --service $SERVICE --force-new-deployment - diff --git a/.github/workflows/rdme-openapi.yml b/.github/workflows/rdme-openapi.yml new file mode 100644 index 00000000..444c23b1 --- /dev/null +++ b/.github/workflows/rdme-openapi.yml @@ -0,0 +1,33 @@ +# This GitHub Actions workflow was auto-generated by the `rdme` cli on 2023-05-10T21:33:14.389Z +# You can view our full documentation here: https://docs.readme.com/docs/rdme +name: ReadMe GitHub Action 🦉 + +on: + push: + branches: + # This workflow will run every time you push code to the following branch: `develop` + # Check out GitHub's docs for more info on configuring this: + # https://docs.github.com/actions/using-workflows/events-that-trigger-workflows + - develop + +jobs: + rdme-openapi: + runs-on: ubuntu-latest + steps: + - name: Check out repo 📚 + uses: actions/checkout@v3 + + - name: Set up Go environment + uses: actions/setup-go@v2 + with: + go-version: 1.19 + + - name: Run `swag init` command 📦 + run: | + go install github.com/swaggo/swag/cmd/swag@latest + swag init -g /api/api.go + + - name: Run `openapi` command 🚀 + uses: readmeio/rdme@v8 + with: + rdme: openapi docs/swagger.yaml --key=${{ secrets.README_API_KEY }} --id=${{ vars.README_DEV_DOCS_ID }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7c9b380f..5063c34d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,6 +3,50 @@ on: branches: - develop name: run tests +env: + BASE_URL: http://localhost:5555/ + ENV: local + PORT: 5555 + STRING_HOTWALLET_ADDRESS: ${{ secrets.HOT_WALLET }} + COINGECKO_API_URL: ${{ vars.COINCAP_API_URL }} + COINCAP_API_URL: ${{ vars.COINCAP_API_URL }} + OWLRACLE_API_URL: ${{ vars.OWLRACLE_API_KEY }} + OWLRACLE_API_KEY: ${{ secrets.OWLRACLE_API_KEY }} + OWLRACLE_API_SECRET: ${{ secrets.OWLRACLE_API_SECRET }} + AWS_REGION: us-west-2 + AWS_KMS_KEY_ID: ${{ secrets.AWS_KMS_KEY_ID }} + CHECKOUT_PUBLIC_KEY: ${{ secrets.CHECKOUT_PUBLIC_KEY }} + CHECKOUT_SECRET_KEY: ${{ secrets.CHECKOUT_SECRET_KEY }} + CHECKOUT_ENV: ${{ vars.CHECKOUT_ENV }} + EVM_PRIVATE_KEY: ${{ secrets.EVM_PRIVATE_KEY }} + DB_NAME: stringdb + DB_USERNAME: string + DB_PASSWORD: string + DB_HOST: localhost + DB_PORT: 5432 + REDIS_PASSWORD: password + REDIS_HOST: localhost + REDIS_PORT: 6379 + JWT_SECRET_KEY: ${{ secrets.JWT_SECRET_KEY }} + UNIT21_API_KEY: ${{ secrets.UNIT21_API_KEY }} + UNIT21_ENV: sandbox + UNIT21_ORG_NAME: string + UNIT21_RTR_URL: ${{ vars.UNIT21_RTR_URL }} + TWILIO_ACCOUNT_SID: ${{ secrets.TWILIO_ACCOUNT_SID }} + TWILIO_AUTH_TOKEN: ${{ secrets.TWILIO_AUTH_TOKEN }} + TWILIO_SMS_SID: ${{ secrets.TWILIO_SMS_SID }} + TEAM_PHONE_NUMBERS: ${{ secrets.TEAM_PHONE_NUMBERS }} + STRING_ENCRYPTION_KEY: ${{ secrets.STRING_ENCRYPTION_KEY }} + SENDGRID_API_KEY: ${{ secrets.SENDGRID_API_KEY }} + FINGERPRINT_API_KEY: ${{ secrets.FINGERPRINT_API_KEY }} + FINGERPRINT_API_URL: ${{ vars.FINGERPRINT_API_URL }} + STRING_INTERNAL_ID: ${{ secrets.STRING_INTERNAL_ID }} + STRING_WALLET_ID: ${{ secrets.STRING_WALLET_ID }} + STRING_BANK_ID: ${{ secrets.STRING_BANK_ID }} + SERVICE_NAME: string-api + DEBUG_MODE: false + AUTH_EMAIL_ADDRESS: ${{ vars.AUTH_EMAIL_ADDRESS }} + RECEIPTS_EMAIL_ADDRESS: ${{ vars.RECEIPTS_EMAIL_ADDRESS }} jobs: lint: runs-on: ubuntu-latest @@ -34,6 +78,7 @@ jobs: go-version: ${{ matrix.go-version }} - name: Checkout code uses: actions/checkout@v2 + - name: Run tests run: go test ./pkg/... -v -covermode=count @@ -55,5 +100,4 @@ jobs: - name: Coveralls uses: coverallsapp/github-action@v1.1.2 with: - github-token: ${{ secrets.GITHUB_TOKEN }} path-to-lcov: coverage.lcov diff --git a/.gitignore b/.gitignore index 774bda0f..28326470 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ tmp/ .vscode/ .terraform/ bin/ +docs/ +.DS_Store diff --git a/Makefile b/Makefile index 8f02b72f..1beb1b94 100644 --- a/Makefile +++ b/Makefile @@ -2,40 +2,46 @@ include .env.deploy export AWS_DEFAULT_PROFILE=${env}-string -API=string-api -ECS_CLUSTER=string-core -SERVICE_TAG=${tag} ECR=${${env}_AWS_ACCT}.dkr.ecr.us-west-2.amazonaws.com -ECS_API_REPO=${ECR}/${API} -INTERNAL_REPO=${ECR}/admin + +TAG=${tag} +SERVICE=api +CLUSTER=core +REPO=${ECR}/${SERVICE} + +SANDBOX_CLUSTER=sandbox-core +SANDBOX_REPO=${ECR}/sandbox-${SERVICE} all: build push deploy -all-internal: build-internal push-internal deploy-internal +all-sandbox: build-sandbox push-sandbox deploy-sandbox + +test: + direnv exec . go test -run $(TEST_FUNCTION) $(TEST_PATH) -v test-envvars: @[ "${env}" ] || ( echo "env var is not set"; exit 1 ) @[ "${tag}" ] || ( echo "env tag is not set"; exit 1 ) build: test-envvars - GOOS=linux GOARCH=amd64 go build -o ./cmd/app/main ./cmd/app/main.go - docker build --platform linux/amd64 -t $(ECS_API_REPO):${SERVICE_TAG} cmd/app/ + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./cmd/app/main ./cmd/app/main.go + docker build --platform linux/amd64 -t $(REPO):${TAG} cmd/app/ rm cmd/app/main push: test-envvars aws ecr get-login-password --region $(AWS_REGION) | docker login --username AWS --password-stdin $(ECR) - docker push $(ECS_API_REPO):${SERVICE_TAG} + docker push $(REPO):${TAG} deploy: test-envvars - aws ecs --region $(AWS_REGION) update-service --cluster $(ECS_CLUSTER) --service ${API} --force-new-deployment - -build-internal:test-envvars - GOOS=linux GOARCH=amd64 go build -o ./cmd/internal/main ./cmd/internal/main.go - docker build --platform linux/amd64 -t $(INTERNAL_REPO):${SERVICE_TAG} cmd/internal/ - rm cmd/internal/main - -push-internal:test-envvars - aws ecr get-login-password --region $(AWS_REGION) | docker login --username AWS --password-stdin $(INTERNAL_REPO) - docker push $(INTERNAL_REPO):${SERVICE_TAG} - -deploy-internal: test-envvars - aws ecs --region $(AWS_REGION) update-service --cluster admin --service admin --force-new-deployment \ No newline at end of file + aws ecs --region $(AWS_REGION) update-service --cluster $(CLUSTER) --service ${SERVICE} --force-new-deployment + +build-sandbox: test-envvars + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ./cmd/app/main ./cmd/app/main.go + docker build --platform linux/amd64 -t $(SANDBOX_REPO):${TAG} cmd/app/ + rm cmd/app/main + +push-sandbox: test-envvars + aws ecr get-login-password --region $(AWS_REGION) | docker login --username AWS --password-stdin $(ECR) + docker push $(SANDBOX_REPO):${TAG} + +deploy-sandbox: test-envvars + aws ecs --region $(AWS_REGION) update-service --cluster $(SANDBOX_CLUSTER) --service ${SERVICE} --force-new-deployment diff --git a/README.md b/README.md index 19f0392b..8402476e 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,10 @@ run `go test` or if you want to run a specific test, use `go test -run [TestName] [./path/to/dir] -v -count 1` ie `go test -run TestGetSwapPayload ./pkg/service -v -count 1` +### Test using Makefile and load .env file + run `make test TEST_FUNCTION= TEST_PATH=` + ### Unit21: ### This is a 3rd party service that offers the ability to evaluate risk at a transaction level and identify fraud. A client file exists to connect to their API. Documentation is here: https://docs.unit21.ai/reference/entities-api You can create a test API key on the Unit21 dashboard. You will need to be setup as an Admin. Here are the instructions: https://docs.unit21.ai/reference/generate-api-keys -When setting up the production env variables, their URL will be: https://api.unit21.com/v1 \ No newline at end of file +When setting up the production env variables, their URL will be: https://api.unit21.com/v1 diff --git a/api/api.go b/api/api.go index 8ec6217a..a774935d 100644 --- a/api/api.go +++ b/api/api.go @@ -3,19 +3,24 @@ package api import ( "net/http" + "github.com/String-xyz/go-lib/v2/database" + libmiddleware "github.com/String-xyz/go-lib/v2/middleware" + "github.com/String-xyz/go-lib/v2/validator" + "github.com/String-xyz/string-api/api/handler" "github.com/String-xyz/string-api/api/middleware" - "github.com/String-xyz/string-api/api/validator" - "github.com/String-xyz/string-api/pkg/service" - "github.com/String-xyz/string-api/pkg/store" + "github.com/String-xyz/string-api/config" + "github.com/jmoiron/sqlx" "github.com/labstack/echo/v4" "github.com/rs/zerolog" + + "github.com/String-xyz/string-api/pkg/service" ) type APIConfig struct { DB *sqlx.DB - Redis store.RedisStore + Redis database.RedisStore Logger *zerolog.Logger Port string } @@ -24,6 +29,20 @@ func heartbeat(c echo.Context) error { return c.JSON(http.StatusOK, "alive") } +// @title String API +// @version 1.0 +// @description String API for executing transactions and managing users + +// @contact.name String API Support +// @contact.url http://string.xyz +// @contact.email support@stringxyz.com + +// @host string-api.xyz +// @BasePath / + +// @SecurityDefinitions.api JWT +// @Scheme bearer +// @BearerFormat JWT func Start(config APIConfig) { e := echo.New() e.Validator = validator.New() @@ -40,12 +59,13 @@ func Start(config APIConfig) { services := NewServices(config, repos) // initialize routes - A route group only needs access to the services layer. It should'n access the repos layer directly - AuthAPIKey(services, e, handler.IsLocalEnv()) transactRoute(services, e) quoteRoute(services, e) userRoute(services, e) loginRoute(services, e) verificationRoute(services, e) + cardRoute(services, e) + webhookRoute(services, e) e.Logger.Fatal(e.Start(":" + config.Port)) } @@ -56,48 +76,33 @@ func StartInternal(config APIConfig) { baseMiddleware(config.Logger, e) e.GET("/heartbeat", heartbeat) - // initialize route dependencies - repos := NewRepos(config) - services := NewServices(config, repos) - // initialize routes - A route group only needs access to the services layer. It doesn't need access to the repos layer - platformRoute(services, e) - AuthAPIKey(services, e, true) e.Logger.Fatal(e.Start(":" + config.Port)) } func baseMiddleware(logger *zerolog.Logger, e *echo.Echo) { - e.Use(middleware.Tracer()) - e.Use(middleware.CORS()) - e.Use(middleware.RequestID()) - e.Use(middleware.Recover()) - e.Use(middleware.Logger(logger)) - e.Use(middleware.LogRequest()) -} - -func platformRoute(services service.Services, e *echo.Echo) { - handler := handler.NewPlatform(services.Platform) - handler.RegisterRoutes(e.Group("/platforms"), middleware.BearerAuth()) -} - -func AuthAPIKey(services service.Services, e *echo.Echo, internal bool) { - handler := handler.NewAuthAPIKey(services.ApiKey, internal) - handler.RegisterRoutes(e.Group("/apikeys")) + e.Use(libmiddleware.Recover()) + e.Use(libmiddleware.RequestId()) + e.Use(libmiddleware.Tracer("api")) + e.Use(libmiddleware.CORS()) + e.Use(libmiddleware.Logger(logger)) + e.Use(libmiddleware.LogRequest()) } func transactRoute(services service.Services, e *echo.Echo) { handler := handler.NewTransaction(e, services.Transaction) - handler.RegisterRoutes(e.Group("/transactions"), middleware.APIKeyAuth(services.Auth), middleware.BearerAuth()) + handler.RegisterRoutes(e.Group("/transactions"), middleware.JWTAuth()) } func userRoute(services service.Services, e *echo.Echo) { handler := handler.NewUser(e, services.User, services.Verification) - handler.RegisterRoutes(e.Group("/users"), middleware.APIKeyAuth(services.Auth), middleware.BearerAuth()) + handler.RegisterRoutes(e.Group("/users"), middleware.APIKeyPublicAuth(services.Auth), middleware.JWTAuth()) + handler.RegisterPrivateRoutes(e.Group("/users"), middleware.APIKeySecretAuth(services.Auth)) } func loginRoute(services service.Services, e *echo.Echo) { - handler := handler.NewLogin(e, services.Auth) - handler.RegisterRoutes(e.Group("/login"), middleware.APIKeyAuth(services.Auth)) + handler := handler.NewLogin(e, services.Auth, services.Device) + handler.RegisterRoutes(e.Group("/login"), middleware.APIKeyPublicAuth(services.Auth)) } func verificationRoute(services service.Services, e *echo.Echo) { @@ -107,5 +112,15 @@ func verificationRoute(services service.Services, e *echo.Echo) { func quoteRoute(services service.Services, e *echo.Echo) { handler := handler.NewQuote(e, services.Transaction) - handler.RegisterRoutes(e.Group("/quotes"), middleware.APIKeyAuth(services.Auth), middleware.BearerAuth()) + handler.RegisterRoutes(e.Group("/quotes"), middleware.JWTAuth()) +} + +func cardRoute(services service.Services, e *echo.Echo) { + handler := handler.NewCard(e, services.Card) + handler.RegisterRoutes(e.Group("/cards"), middleware.JWTAuth()) +} + +func webhookRoute(services service.Services, e *echo.Echo) { + handler := handler.NewWebhook(e, services.Webhook) + handler.RegisterRoutes(e.Group("/webhooks"), middleware.VerifyWebhookPayload(config.Var.PERSONA_WEBHOOK_SECRET_KEY, config.Var.CHECKOUT_WEBHOOK_SECRET_KEY)) } diff --git a/api/config.go b/api/config.go index 9a0d8273..f81368a0 100644 --- a/api/config.go +++ b/api/config.go @@ -10,18 +10,20 @@ import ( func NewRepos(config APIConfig) repository.Repositories { // TODO: Make sure all of the repos are initialized here return repository.Repositories{ - Auth: repository.NewAuth(config.Redis, config.DB), - User: repository.NewUser(config.DB), - Contact: repository.NewContact(config.DB), - Instrument: repository.NewInstrument(config.DB), - Device: repository.NewDevice(config.DB), - UserToPlatform: repository.NewUserToPlatform(config.DB), - Asset: repository.NewAsset(config.DB), - Network: repository.NewNetwork(config.DB), - Platform: repository.NewPlatform(config.DB), - Transaction: repository.NewTransaction(config.DB), - TxLeg: repository.NewTxLeg(config.DB), - Location: repository.NewLocation(config.DB), + Auth: repository.NewAuth(config.Redis, config.DB), + Apikey: repository.NewApikey(config.DB), + User: repository.NewUser(config.DB), + Contact: repository.NewContact(config.DB), + Contract: repository.NewContract(config.DB), + Instrument: repository.NewInstrument(config.DB), + Device: repository.NewDevice(config.DB), + Asset: repository.NewAsset(config.DB), + Network: repository.NewNetwork(config.DB), + Transaction: repository.NewTransaction(config.DB), + TxLeg: repository.NewTxLeg(config.DB), + Location: repository.NewLocation(config.DB), + Platform: repository.NewPlatform(config.DB), + Identity: repository.NewIdentity(config.DB), } } @@ -31,40 +33,38 @@ func NewRepos(config APIConfig) repository.Repositories { * Not every service needs access to all of the repos, so we can pass in only the ones it needs. This will make it easier to test */ func NewServices(config APIConfig, repos repository.Repositories) service.Services { + unit21 := service.NewUnit21(repos) httpClient := service.NewHTTPClient(service.HTTPConfig{Timeout: time.Duration(30) * time.Second}) client := service.NewFingerprintClient(httpClient) fingerprint := service.NewFingerprint(client) - // we don't need to pass in the entire repos struct, just the ones we need - verificationRepos := repository.Repositories{Contact: repos.Contact, User: repos.User, Device: repos.Device} - verification := service.NewVerification(verificationRepos) + verification := service.NewVerification(repos, unit21) // device service deviceRepos := repository.Repositories{Device: repos.Device} device := service.NewDevice(deviceRepos, fingerprint) auth := service.NewAuth(repos, verification, device) - apiKey := service.NewAPIKeyStrategy(repos.Auth) - cost := service.NewCost(config.Redis) + cost := service.NewCost(config.Redis, repos) executor := service.NewExecutor() geofencing := service.NewGeofencing(config.Redis) - // we don't need to pass in the entire repos struct, just the ones we need - platformRepos := repository.Repositories{Auth: repos.Auth, Platform: repos.Platform} - platform := service.NewPlatform(platformRepos) + transaction := service.NewTransaction(repos, config.Redis, unit21) + user := service.NewUser(repos, auth, fingerprint, device, unit21, verification) - transaction := service.NewTransaction(repos, config.Redis) - user := service.NewUser(repos, auth, fingerprint) + card := service.NewCard(repos) + + kyc := service.NewKYC(repos) return service.Services{ Auth: auth, - ApiKey: apiKey, Cost: cost, Executor: executor, Geofencing: geofencing, - Platform: platform, Transaction: transaction, User: user, Verification: verification, Device: device, + Card: card, + KYC: kyc, } } diff --git a/api/handler/auth_key.go b/api/handler/auth_key.go deleted file mode 100644 index ef0aa904..00000000 --- a/api/handler/auth_key.go +++ /dev/null @@ -1,88 +0,0 @@ -package handler - -import ( - "net/http" - - "github.com/String-xyz/string-api/pkg/service" - "github.com/labstack/echo/v4" - "github.com/rs/zerolog" -) - -type AuthAPIKey interface { - Create(c echo.Context) error - Approve(c echo.Context) error - List(c echo.Context) error - RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) -} - -type authAPIKey struct { - isInternal bool - service service.APIKeyStrategy - logger *zerolog.Logger -} - -func NewAuthAPIKey(service service.APIKeyStrategy, internal bool) AuthAPIKey { - return &authAPIKey{service: service, isInternal: internal} -} - -func (o authAPIKey) Create(c echo.Context) error { - key, err := o.service.Create() - if err != nil { - LogStringError(c, err, "authKey approve: create") - return echo.NewHTTPError(http.StatusInternalServerError, "Unable to process request") - } - return c.JSON(http.StatusOK, key) -} - -func (o authAPIKey) List(c echo.Context) error { - if !o.isInternal { - return NotAllowedError(c) - } - body := struct { - Status string `query:"status"` - Limit int `query:"limit"` - Offset int `query:"offset"` - }{} - err := c.Bind(&body) - if err != nil { - LogStringError(c, err, "authKey list: bind") - return echo.NewHTTPError(http.StatusBadRequest) - } - list, err := o.service.List(body.Limit, body.Offset, body.Status) - if err != nil { - LogStringError(c, err, "authKey list") - return echo.NewHTTPError(http.StatusInternalServerError, "ApiKey Service Failed") - } - return c.JSON(http.StatusCreated, list) -} - -func (o authAPIKey) Approve(c echo.Context) error { - if !o.isInternal { - return NotAllowedError(c) - } - params := struct { - ID string `param:"id"` - }{} - err := c.Bind(¶ms) - - if err != nil { - LogStringError(c, err, "authKey approve: bind") - return echo.NewHTTPError(http.StatusInternalServerError, "Unable to process request") - } - err = o.service.Approve(params.ID) - if err != nil { - LogStringError(c, err, "authKey approve: approve") - return echo.NewHTTPError(http.StatusInternalServerError, "Unable to process request") - } - return c.JSON(http.StatusOK, ResultMessage{Status: "Success"}) -} - -func (o authAPIKey) RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) { - if g == nil { - panic("no group attached to the authKey handler") - } - g.Use(ms...) - g.POST("", o.Create) - g.GET("", o.List) - g.POST("/:id/approve", o.Approve) -} diff --git a/api/handler/card.go b/api/handler/card.go new file mode 100644 index 00000000..c1c998de --- /dev/null +++ b/api/handler/card.go @@ -0,0 +1,66 @@ +package handler + +import ( + "net/http" + + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/httperror" + "github.com/labstack/echo/v4" + "github.com/pkg/errors" + + "github.com/String-xyz/string-api/pkg/service" +) + +type Card interface { + GetAll(c echo.Context) error + RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) +} + +type card struct { + Service service.Card + Group *echo.Group +} + +func NewCard(route *echo.Echo, service service.Card) Card { + return &card{service, nil} +} + +// @Summary Get all saved cards +// @Description Get all saved cards +// @Tags Cards +// @Accept json +// @Produce json +// @Security JWT +// @Success 200 {object} []checkout.CardInstrument +// @Failure 400 {object} error +// @Failure 401 {object} error +// @Failure 500 {object} error +// @Router /cards [get] +func (card card) GetAll(c echo.Context) error { + ctx := c.Request().Context() + + userId, ok := c.Get("userId").(string) + if !ok { + return httperror.Internal500(c, "missing or invalid userId") + } + + platformId, ok := c.Get("platformId").(string) + if !ok { + return httperror.Internal500(c, "missing or invalid platformId") + } + + res, err := card.Service.ListByUserId(ctx, userId, platformId) + if err != nil && errors.Cause(err).Error() != "404 Not Found" { // Not a string error + libcommon.LogStringError(c, err, "cards: get All") + return httperror.Internal500(c, "Cards Service Failed") + } + + // 200 + return c.JSON(http.StatusOK, res) +} + +func (card card) RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) { + card.Group = g + g.Use(ms...) + g.GET("", card.GetAll) +} diff --git a/api/handler/common.go b/api/handler/common.go index a3a42d84..4bb72ca0 100644 --- a/api/handler/common.go +++ b/api/handler/common.go @@ -1,84 +1,49 @@ package handler import ( - "fmt" "net/http" - "os" "regexp" "strings" "time" - service "github.com/String-xyz/string-api/pkg/service" + "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/httperror" + serror "github.com/String-xyz/go-lib/v2/stringerror" + "github.com/String-xyz/string-api/pkg/model" "golang.org/x/crypto/sha3" - "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" "github.com/labstack/echo/v4" - "github.com/pkg/errors" - "github.com/rs/zerolog" - "github.com/rs/zerolog/log" ) -func LogError(c echo.Context, err error, handlerMsg string) { - lg := c.Get("logger").(*zerolog.Logger) - sp, _ := tracer.SpanFromContext(c.Request().Context()) - lg.Error().Stack().Err(err).Uint64("trace_id", sp.Context().TraceID()). - Uint64("span_id", sp.Context().SpanID()).Msg(handlerMsg) -} - -func LogStringError(c echo.Context, err error, handlerMsg string) { - type stackTracer interface { - StackTrace() errors.StackTrace - } - - tracer, ok := errors.Cause(err).(stackTracer) - if !ok { - log.Warn().Str("error", err.Error()).Msg("error does not implement stack trace") - return - } - - cause := errors.Cause(err) - st := tracer.StackTrace() - - if IsLocalEnv() { - st2 := fmt.Sprintf("\nSTACK TRACE:\n%+v: [%+v ]\n\n", cause.Error(), st[0:5]) - // delete the string_api docker path from the stack trace - st2 = strings.ReplaceAll(st2, "/string_api/", "") - fmt.Print(st2) - return - } - - LogError(c, err, handlerMsg) -} - -func SetJWTCookie(c echo.Context, jwt service.JWT) error { +func SetJWTCookie(c echo.Context, jwt model.JWT) error { cookie := new(http.Cookie) cookie.Name = "StringJWT" cookie.Value = jwt.Token - // cookie.HttpOnly = true // due the short expiration time it is not needed to be http only + cookie.HttpOnly = true cookie.Expires = jwt.ExpAt // we want the cookie to expire at the same time as the token cookie.SameSite = getCookieSameSiteMode() - cookie.Path = "/" // Send cookie in every sub path request - cookie.Secure = !IsLocalEnv() // in production allow https only + cookie.Path = "/" // Send cookie in every sub path request + cookie.Secure = !common.IsLocalEnv() // in production allow https only c.SetCookie(cookie) return nil } -func SetRefreshTokenCookie(c echo.Context, refresh service.RefreshTokenResponse) error { +func SetRefreshTokenCookie(c echo.Context, refresh model.RefreshTokenResponse) error { cookie := new(http.Cookie) - cookie.Name = "refresh_token" + cookie.Name = "StringRefreshToken" cookie.Value = refresh.Token cookie.HttpOnly = true cookie.Expires = refresh.ExpAt // we want the cookie to expire at the same time as the token cookie.SameSite = getCookieSameSiteMode() - cookie.Path = "/login/" // Send cookie only in /login path request - cookie.Secure = !IsLocalEnv() // in production allow https only + cookie.Path = "/login/" // Send cookie only in /login path request + cookie.Secure = !common.IsLocalEnv() // in production allow https only c.SetCookie(cookie) return nil } -func SetAuthCookies(c echo.Context, jwt service.JWT) error { +func SetAuthCookies(c echo.Context, jwt model.JWT) error { err := SetJWTCookie(c, jwt) if err != nil { return err @@ -97,28 +62,26 @@ func DeleteAuthCookies(c echo.Context) error { cookie := new(http.Cookie) cookie.Name = "StringJWT" cookie.Value = "" + cookie.HttpOnly = true cookie.Expires = time.Now() cookie.SameSite = getCookieSameSiteMode() cookie.Path = "/" // Send cookie in every sub path request - cookie.Secure = !IsLocalEnv() + cookie.Secure = !common.IsLocalEnv() c.SetCookie(cookie) cookie = new(http.Cookie) - cookie.Name = "refresh_token" + cookie.Name = "StringRefreshToken" cookie.Value = "" + cookie.HttpOnly = true cookie.Expires = time.Now() cookie.SameSite = getCookieSameSiteMode() cookie.Path = "/login/" // Send cookie only in refresh path request - cookie.Secure = !IsLocalEnv() + cookie.Secure = !common.IsLocalEnv() c.SetCookie(cookie) return nil } -func IsLocalEnv() bool { - return os.Getenv("ENV") == "local" -} - func validAddress(addr string) bool { re := regexp.MustCompile("^0x[0-9a-fA-F]{40}$") return re.MatchString(addr) @@ -126,7 +89,7 @@ func validAddress(addr string) bool { func getCookieSameSiteMode() http.SameSite { sameSiteMode := http.SameSiteNoneMode // allow cors - if IsLocalEnv() { + if common.IsLocalEnv() { sameSiteMode = http.SameSiteLaxMode // because SameSiteNoneMode is not allowed in localhost we use lax mode } return sameSiteMode @@ -155,3 +118,38 @@ func SanitizeChecksums(addrs ...*string) { *addr = valid } } + +func DefaultErrorHandler(c echo.Context, err error, handlerName string) error { + if err == nil { + return nil + } + + // always log the error + common.LogStringError(c, err, handlerName) + + if serror.Is(err, serror.NOT_FOUND) { + return httperror.NotFound404(c) + } + + if serror.Is(err, serror.FORBIDDEN) { + return httperror.Forbidden403(c, "Invoking member lacks authority") + } + + if serror.Is(err, serror.INVALID_RESET_TOKEN) { + return httperror.BadRequest400(c, "Invalid password reset token") + } + + if serror.Is(err, serror.INVALID_PASSWORD) { + return httperror.BadRequest400(c, "Invalid password") + } + + if serror.Is(err, serror.ALREADY_IN_USE) { + return httperror.Conflict409(c, "Already in use") + } + + if serror.Is(err, serror.INVALID_DATA) { + return httperror.BadRequest400(c, "Invalid data") + } + + return httperror.Internal500(c) +} diff --git a/api/handler/http_error.go b/api/handler/http_error.go deleted file mode 100644 index 044aa47d..00000000 --- a/api/handler/http_error.go +++ /dev/null @@ -1,99 +0,0 @@ -package handler - -import ( - "net/http" - "strings" - - validator "github.com/String-xyz/string-api/api/validator" - "github.com/labstack/echo/v4" -) - -type JSONError struct { - Message string `json:"message"` - Code string `json:"code"` - Details any `json:"details"` -} - -func InvalidPayloadError(c echo.Context, err error) error { - errorParams := validator.ExtractErrorParams(err) - return c.JSON(http.StatusBadRequest, JSONError{Message: "Invalid Payload", Code: "INVALID_PAYLOAD", Details: errorParams}) -} - -func InternalError(c echo.Context, message ...string) error { - if len(message) > 0 { - return c.JSON(http.StatusInternalServerError, JSONError{Message: strings.Join(message, " "), Code: "INTERNAL_SERVER"}) - } - return c.JSON(http.StatusInternalServerError, JSONError{Message: "Something went wrong", Code: "INTERNAL_SERVER"}) -} - -func BadRequestError(c echo.Context, message ...string) error { - if len(message) > 0 { - return c.JSON(http.StatusBadRequest, JSONError{Message: strings.Join(message, " "), Code: "BAD_REQUEST"}) - } - return c.JSON(http.StatusBadRequest, JSONError{Message: "Bad Request", Code: "BAD_REQUEST"}) -} - -func NotFoundError(c echo.Context, message ...string) error { - if len(message) > 0 { - return c.JSON(http.StatusNotFound, JSONError{Message: strings.Join(message, " "), Code: "NOT_FOUND"}) - } - return c.JSON(http.StatusNotFound, JSONError{Message: "Resource Not Found", Code: "NOT_FOUND"}) -} - -func NotAllowedError(c echo.Context, message ...string) error { - if len(message) > 0 { - return c.JSON(http.StatusMethodNotAllowed, JSONError{Message: strings.Join(message, " "), Code: "NOT_ALLOWED"}) - } - return c.JSON(http.StatusMethodNotAllowed, JSONError{Message: "Not Allowed", Code: "NOT_ALLOWED"}) -} - -func Unprocessable(c echo.Context, message ...string) error { - if len(message) > 0 { - return c.JSON(http.StatusUnprocessableEntity, JSONError{Message: strings.Join(message, " "), Code: "UNPROCESSABLE_ENTITY"}) - } - return c.JSON(http.StatusUnprocessableEntity, JSONError{Message: "Unable to process entity", Code: "UNPROCESSABLE_ENTITY"}) -} - -func Unauthorized(c echo.Context, message ...string) error { - if len(message) > 0 { - return c.JSON(http.StatusUnauthorized, JSONError{Message: strings.Join(message, " "), Code: "UNAUTHORIZED"}) - } - return c.JSON(http.StatusUnauthorized, JSONError{Message: "Unauthorized", Code: "UNAUTHORIZED"}) -} - -func TokenExpired(c echo.Context, message ...string) error { - msg := "Token Expired" - if len(message) > 0 { - msg = strings.Join(message, " ") - } - return c.JSON(http.StatusUnauthorized, JSONError{Message: msg, Code: "TOKEN_EXPIRED"}) -} - -func MissingToken(c echo.Context, message ...string) error { - msg := "Missing or malformed token" - if len(message) > 0 { - msg = strings.Join(message, " ") - } - return c.JSON(http.StatusUnauthorized, JSONError{Message: msg, Code: "MISSING_TOKEN"}) -} - -func Conflict(c echo.Context, message ...string) error { - if len(message) > 0 { - return c.JSON(http.StatusConflict, JSONError{Message: strings.Join(message, " "), Code: "CONFLICT"}) - } - return c.JSON(http.StatusConflict, JSONError{Message: "Conflict", Code: "CONFLICT"}) -} - -func LinkExpired(c echo.Context, message ...string) error { - if len(message) > 0 { - return c.JSON(http.StatusForbidden, JSONError{Message: strings.Join(message, " "), Code: "LINK_EXPIRED"}) - } - return c.JSON(http.StatusForbidden, JSONError{Message: "Forbidden", Code: "LINK_EXPIRED"}) -} - -func InvalidEmail(c echo.Context, message ...string) error { - if len(message) > 0 { - return c.JSON(http.StatusUnprocessableEntity, JSONError{Message: strings.Join(message, " "), Code: "INVALID_EMAIL"}) - } - return c.JSON(http.StatusUnprocessableEntity, JSONError{Message: "Invalid email", Code: "INVALID_EMAIL"}) -} diff --git a/api/handler/login.go b/api/handler/login.go index 17098f97..52b38d6e 100644 --- a/api/handler/login.go +++ b/api/handler/login.go @@ -3,153 +3,237 @@ package handler import ( b64 "encoding/base64" "net/http" - "strings" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/httperror" + serror "github.com/String-xyz/go-lib/v2/stringerror" + "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/service" + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/golang-jwt/jwt" "github.com/labstack/echo/v4" ) type Login interface { - // NoncePayload send the user a nonce payload to be signed for authentication/login purpose - // User must provide a valid wallet address - NoncePayload(c echo.Context) error - - //VerifySignature receives the signed noncePaylod and verifies the signature to authenticate the user. - VerifySignature(c echo.Context) error + RequestToSign(c echo.Context) error + Login(c echo.Context) error RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) RefreshToken(c echo.Context) error } type login struct { Service service.Auth + Device service.Device Group *echo.Group } -func NewLogin(route *echo.Echo, service service.Auth) Login { - return &login{service, nil} +func NewLogin(route *echo.Echo, service service.Auth, device service.Device) Login { + return &login{service, device, nil} } -func (l login) NoncePayload(c echo.Context) error { +// @Summary Request To Sign +// @Description RequestToSign sends the user a nonce payload to be signed for authentication/login purposes. User must provide a valid wallet address +// @Tags Login +// @Accept json +// @Produce json +// @Param walletAddress query string true "wallet address" +// @Success 200 {object} model.SignatureRequest +// @Failure 400 {object} error +// @Failure 401 {object} error +// @Failure 500 {object} error +// @Router /login [get] +func (l login) RequestToSign(c echo.Context) error { walletAddress := c.QueryParam("walletAddress") - if walletAddress == "" { - return BadRequestError(c, "WalletAddress must be provided") + + if !ethcommon.IsHexAddress(walletAddress) { + return httperror.BadRequest400(c, "Invalid wallet address") } + SanitizeChecksums(&walletAddress) - payload, err := l.Service.PayloadToSign(walletAddress) + + // get nonce payload + signatureRequest, err := l.Service.PayloadToSign(c.Request().Context(), walletAddress) if err != nil { - LogStringError(c, err, "login: request wallet login") - return InternalError(c) + // 500 + return DefaultErrorHandler(c, err, "login: RequestToSign") } - encodedNonce := b64.StdEncoding.EncodeToString([]byte(payload.Nonce)) - return c.JSON(http.StatusOK, map[string]string{"nonce": encodedNonce}) + // 200 + return c.JSON(http.StatusOK, signatureRequest) } -func (l login) VerifySignature(c echo.Context) error { +// @Summary Login +// @Description Login receives the signed noncePayload and verifies the signature to authenticate the user. +// @Tags Login +// @Accept json +// @Produce json +// @Param bypassDevice query boolean false "bypass device" +// @Param payload body model.WalletSignaturePayloadSigned true "Wallet Signature Payload" +// @Success 200 {object} model.UserLoginResponse +// @Failure 400 {object} error +// @Failure 401 {object} error +// @Failure 422 {object} error +// @Failure 500 {object} error +// @Router /login/sign [post] +func (l login) Login(c echo.Context) error { + ctx := c.Request().Context() + platformId, ok := c.Get("platformId").(string) + if !ok { + return httperror.Internal500(c, "missing or invalid platformId") + } + + strBypassDevice := c.QueryParam("bypassDevice") + bypassDevice := strBypassDevice == "true" // convert to bool. default is false + var body model.WalletSignaturePayloadSigned - err := c.Bind(&body) - if err != nil { - LogStringError(c, err, "login: binding body") - return BadRequestError(c) + if err := c.Bind(&body); err != nil { + libcommon.LogStringError(c, err, "login: binding body") + return httperror.BadRequest400(c) } if err := c.Validate(body); err != nil { - return InvalidPayloadError(c, err) + return httperror.InvalidPayload400(c, err) } // base64 decode nonce - decodedNonce, _ := b64.URLEncoding.DecodeString(body.Nonce) + decodedNonce, err := b64.URLEncoding.DecodeString(body.Nonce) if err != nil { - LogStringError(c, err, "login: verify signature decode nonce") - return BadRequestError(c) + libcommon.LogStringError(c, err, "login: verify signature decode nonce") + return httperror.BadRequest400(c) } body.Nonce = string(decodedNonce) - resp, err := l.Service.VerifySignedPayload(body) + resp, err := l.Service.VerifySignedPayload(ctx, body, platformId, bypassDevice) if err != nil { - if strings.Contains(err.Error(), "unknown device") { - return Unprocessable(c) + libcommon.LogStringError(c, err, "login: verify signature") + + if serror.Is(err, serror.UNKNOWN_DEVICE) { + return httperror.Unprocessable422(c) } - if strings.Contains(err.Error(), "invalid email") { - return InvalidEmail(c) + + if serror.Is(err, serror.INVALID_DATA) { + return httperror.BadRequest400(c, "Invalid Email") } - LogStringError(c, err, "login: verify signature") - return BadRequestError(c, "Invalid Payload") + if serror.Is(err, serror.EXPIRED) { + return httperror.BadRequest400(c, "Expired, request a new payload") + } + + return httperror.BadRequest400(c, "Invalid Payload") } + + // Upsert IP address in user's device + var claims = &model.JWTClaims{} + _, _ = jwt.ParseWithClaims(resp.JWT.Token, claims, func(t *jwt.Token) (interface{}, error) { + return []byte(config.Var.JWT_SECRET_KEY), nil + }) + ip := c.RealIP() + l.Device.UpsertDeviceIP(ctx, claims.DeviceId, ip) + // set auth cookies err = SetAuthCookies(c, resp.JWT) if err != nil { - LogStringError(c, err, "login: unable to set auth cookies") - return InternalError(c) + libcommon.LogStringError(c, err, "login: unable to set auth cookies") + return httperror.Internal500(c) } + + // 200 return c.JSON(http.StatusOK, resp) } +// @Summary Refresh Token +// @Description Refresh Token +// @Tags Login +// @Accept json +// @Produce json +// @Param walletAddress body string true "wallet address" +// @Success 200 {object} model.RefreshTokenResponse +// @Failure 400 {object} error +// @Failure 401 {object} error +// @Failure 500 {object} error +// @Router /login/refresh [post] func (l login) RefreshToken(c echo.Context) error { + ctx := c.Request().Context() + platformId, ok := c.Get("platformId").(string) + if !ok { + return httperror.Internal500(c, "missing or invalid platformId") + } + var body model.RefreshTokenPayload err := c.Bind(&body) if err != nil { - LogStringError(c, err, "login: binding body") - return BadRequestError(c) + libcommon.LogStringError(c, err, "login: binding body") + return httperror.BadRequest400(c) } if err := c.Validate(body); err != nil { - return InvalidPayloadError(c, err) + return httperror.InvalidPayload400(c, err) } SanitizeChecksums(&body.WalletAddress) - cookie, err := c.Cookie("refresh_token") + cookie, err := c.Cookie("StringRefreshToken") if err != nil { - LogStringError(c, err, "RefreshToken: unable to get refresh_token cookie") - return Unauthorized(c) + libcommon.LogStringError(c, err, "RefreshToken: unable to get StringRefreshToken cookie") + return httperror.Unauthorized401(c) } - resp, err := l.Service.RefreshToken(cookie.Value, body.WalletAddress) + resp, err := l.Service.RefreshToken(ctx, cookie.Value, body.WalletAddress, platformId) if err != nil { - if strings.Contains(err.Error(), "wallet address not associated with this user") { - return BadRequestError(c, "wallet address not associated with this user") + libcommon.LogStringError(c, err, "login: refresh token") + + if serror.Is(err, serror.NOT_FOUND) { + return httperror.BadRequest400(c, "wallet address not associated with this user") } - LogStringError(c, err, "login: refresh token") - return BadRequestError(c, "Invalid or expired token") + return httperror.BadRequest400(c, "Invalid or expired token") } // set auth in cookies err = SetAuthCookies(c, resp.JWT) if err != nil { - LogStringError(c, err, "RefreshToken: unable to set auth cookies") - return InternalError(c) + libcommon.LogStringError(c, err, "RefreshToken: unable to set auth cookies") + return httperror.Internal500(c) } + // 200 return c.JSON(http.StatusOK, resp) } -// logout +// @Summary Logout +// @Description Logout of the application, invalidating the auth cookies +// @Tags Login +// @Accept json +// @Produce json +// @Success 204 +// @Failure 400 {object} error +// @Failure 401 {object} error +// @Failure 500 {object} error +// @Router /login/logout [post] func (l login) Logout(c echo.Context) error { // get refresh token from cookie - cookie, err := c.Cookie("refresh_token") + cookie, err := c.Cookie("StringRefreshToken") if err != nil { - LogStringError(c, err, "Logout: unable to get refresh_token cookie") - return Unauthorized(c) + libcommon.LogStringError(c, err, "Logout: unable to get StringRefreshToken cookie") + return httperror.Unauthorized401(c) } // invalidate refresh token. Returns error if token is not found err = l.Service.InvalidateRefreshToken(cookie.Value) if err != nil { - LogStringError(c, err, "Token not found") + libcommon.LogStringError(c, err, "Token not found") + // if error continue anyway, at least delete the cookies } - // There is no need to invalidate the access token since it is a short lived token // delete auth cookies err = DeleteAuthCookies(c) if err != nil { - LogStringError(c, err, "Logout: unable to delete auth cookies") - return InternalError(c) + libcommon.LogStringError(c, err, "Logout: unable to delete auth cookies") + return httperror.Internal500(c) } + // 204 return c.JSON(http.StatusNoContent, nil) } @@ -158,9 +242,8 @@ func (l login) RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) { panic("No group attached to the User Handler") } l.Group = g - g.Use(ms...) - g.GET("", l.NoncePayload) - g.POST("/sign", l.VerifySignature) - g.POST("/refresh", l.RefreshToken) + g.GET("", l.RequestToSign, ms...) + g.POST("/sign", l.Login, ms...) + g.POST("/refresh", l.RefreshToken, ms...) g.POST("/logout", l.Logout) } diff --git a/api/handler/login_test.go b/api/handler/login_test.go index 4a05e618..0588797b 100644 --- a/api/handler/login_test.go +++ b/api/handler/login_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/String-xyz/string-api/api/validator" + "github.com/String-xyz/go-lib/v2/validator" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/test/stubs" "github.com/labstack/echo/v4" @@ -21,7 +21,7 @@ const ( testSignature = "3a36eb06f09a1d8d4097101ecf656d11c64bac5bfcd66d0b2325ddabb20f38fdbd1a71e0a04365856c779f6517b4fc2fe8b90d7a93e0b9be9f5b27c3961c150c5f93" ) -func TestStatus200LoginNoncePayload(t *testing.T) { +func TestStatus200LoginRequestToSign(t *testing.T) { e := echo.New() q := make(url.Values) @@ -30,16 +30,16 @@ func TestStatus200LoginNoncePayload(t *testing.T) { request := httptest.NewRequest(http.MethodGet, "/?"+q.Encode(), nil) request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - handler := NewLogin(nil, stubs.Auth{}) - handler.RegisterRoutes(e.Group("/login")) + handler := NewLogin(nil, stubs.Auth{}, stubs.Device{}) + handler.RegisterRoutes(e.Group("/login"), nil) rec := httptest.NewRecorder() c := e.NewContext(request, rec) - if assert.NoError(t, handler.NoncePayload(c)) { + if assert.NoError(t, handler.RequestToSign(c)) { assert.Equal(t, http.StatusOK, rec.Code) } } -func TestStatus200LoginVerifySignature(t *testing.T) { +func TestStatus200LoginLogin(t *testing.T) { e := echo.New() e.Validator = validator.New() body := model.WalletSignaturePayloadSigned{ @@ -53,26 +53,26 @@ func TestStatus200LoginVerifySignature(t *testing.T) { request := httptest.NewRequest(http.MethodPost, "/sign", strings.NewReader(string(jsonBody))) request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - handler := NewLogin(nil, stubs.Auth{}) - handler.RegisterRoutes(e.Group("/login")) + handler := NewLogin(nil, stubs.Auth{}, stubs.Device{}) + handler.RegisterRoutes(e.Group("/login"), nil) rec := httptest.NewRecorder() c := e.NewContext(request, rec) - if assert.NoError(t, handler.VerifySignature(c)) { + if assert.NoError(t, handler.Login(c)) { assert.Equal(t, http.StatusOK, rec.Code) } } -func TestStatus400MissingWalletLoginNoncePayload(t *testing.T) { +func TestStatus400MissingWalletLoginRequestToSign(t *testing.T) { e := echo.New() request := httptest.NewRequest(http.MethodGet, "/", nil) request.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) - handler := NewLogin(nil, stubs.Auth{}) + handler := NewLogin(nil, stubs.Auth{}, stubs.Device{}) handler.RegisterRoutes(e.Group("/login")) rec := httptest.NewRecorder() c := e.NewContext(request, rec) - if assert.NoError(t, handler.NoncePayload(c)) { + if assert.NoError(t, handler.RequestToSign(c)) { assert.Equal(t, http.StatusBadRequest, rec.Code) } } diff --git a/api/handler/platform.go b/api/handler/platform.go deleted file mode 100644 index 80f5ed77..00000000 --- a/api/handler/platform.go +++ /dev/null @@ -1,45 +0,0 @@ -package handler - -import ( - "net/http" - - "github.com/String-xyz/string-api/pkg/service" - "github.com/labstack/echo/v4" -) - -type Platform interface { - Create(e echo.Context) error - RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) -} - -type platform struct { - service service.Platform -} - -func NewPlatform(service service.Platform) Platform { - return &platform{service: service} -} - -func (p platform) Create(c echo.Context) error { - body := service.CreatePlatform{} - err := c.Bind(&body) - if err != nil { - LogStringError(c, err, "platform: create bind") - return echo.NewHTTPError(http.StatusBadRequest) - } - - m, err := p.service.Create(body) - if err != nil { - LogStringError(c, err, "platform: create") - return echo.NewHTTPError(http.StatusInternalServerError) - } - return c.JSON(http.StatusCreated, m) -} - -func (p platform) RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) { - if g == nil { - panic("no group attached to the platform handler") - } - g.Use(ms...) - g.POST("", p.Create) -} diff --git a/api/handler/quotes.go b/api/handler/quotes.go index e736a28b..8184daa1 100644 --- a/api/handler/quotes.go +++ b/api/handler/quotes.go @@ -3,6 +3,9 @@ package handler import ( "net/http" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/httperror" + serror "github.com/String-xyz/go-lib/v2/stringerror" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/service" "github.com/labstack/echo/v4" @@ -23,27 +26,70 @@ func NewQuote(route *echo.Echo, service service.Transaction) Quotes { return "e{service, nil} } +// @Summary Quote +// @Description Quote returns the estimated cost of a transaction +// @Tags Transactions +// @Accept json +// @Produce json +// @Security JWT +// @Param body body model.TransactionRequest true "Transaction Request" +// @Success 200 {object} model.Quote +// @Failure 400 {object} error +// @Failure 401 {object} error +// @Failure 403 {object} error +// @Failure 500 {object} error +// @Router /quote [post] func (q quote) Quote(c echo.Context) error { + ctx := c.Request().Context() var body model.TransactionRequest + err := c.Bind(&body) // 'tag' binding: struct fields are annotated if err != nil { - LogStringError(c, err, "quote: quote bind") - return BadRequestError(c) + libcommon.LogStringError(c, err, "quote: quote bind") + return httperror.BadRequest400(c) + } + + err = c.Validate(&body) + if err != nil { + libcommon.LogStringError(c, err, "quote: quote validate") + return httperror.InvalidPayload400(c, err) } - SanitizeChecksums(&body.CxAddr, &body.UserAddress) - // Sanitize Checksum for body.CxParams? It might look like this: - for i := range body.CxParams { - SanitizeChecksums(&body.CxParams[i]) + + // TODO: See if there's a way to batch these into a single call of SanitizeChecksums + SanitizeChecksums(&body.UserAddress) + for i := range body.Actions { + SanitizeChecksums(&body.Actions[i].CxAddr, &body.UserAddress) + for j := range body.Actions[i].CxParams { + SanitizeChecksums(&body.Actions[i].CxParams[j]) + } + } + + platformId, ok := c.Get("platformId").(string) + if !ok { + return httperror.Internal500(c, "missing or invalid platformId") } - // userId := c.Get("userId").(string) - res, err := q.Service.Quote(body) // TODO: pass in userId and use it - if err != nil && errors.Cause(err).Error() == "w3: response handling failed: execution reverted" { - return c.JSON(http.StatusBadRequest, JSONError{Message: "The requested blockchain operation will revert"}) - } else if err != nil { - LogStringError(c, err, "quote: quote") - return c.JSON(http.StatusInternalServerError, JSONError{Message: "Quote Service Failed"}) + res, err := q.Service.Quote(ctx, body, platformId) + + if err != nil && errors.Cause(err).Error() == "insufficient level" { // TODO: use a custom error + return c.JSON(http.StatusForbidden, res) } + + if err != nil { + libcommon.LogStringError(c, err, "quote: quote") + + if errors.Cause(err).Error() == "w3: response handling failed: execution reverted" { // TODO: use a custom error + return httperror.BadRequest400(c, "The requested blockchain operation will revert") + } + + if serror.Is(err, serror.FUNC_NOT_ALLOWED, serror.CONTRACT_NOT_ALLOWED) { + return httperror.Forbidden403(c, "The requested blockchain operation is not allowed") + } + + return httperror.Internal500(c, "Quote Service Failed") + } + + // 200 return c.JSON(http.StatusOK, res) } diff --git a/api/handler/transact.go b/api/handler/transact.go index 2a4c56c5..d644277a 100644 --- a/api/handler/transact.go +++ b/api/handler/transact.go @@ -4,6 +4,8 @@ import ( "net/http" "strings" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/httperror" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/service" "github.com/labstack/echo/v4" @@ -23,31 +25,76 @@ func NewTransaction(route *echo.Echo, service service.Transaction) Transaction { return &transaction{service, nil} } +// @Summary Transact +// @Description Transact executes a transaction +// @Tags Transactions +// @Accept json +// @Produce json +// @Security JWT +// @Param saveCard query boolean false "do not save payment info" +// @Param body body model.ExecutionRequest true "Execution Request" +// @Success 200 {object} model.TransactionReceipt +// @Failure 400 {object} error +// @Failure 401 {object} error +// @Failure 403 {object} error +// @Failure 500 {object} error +// @Router /transaction [post] func (t transaction) Transact(c echo.Context) error { + ctx := c.Request().Context() + userId, ok := c.Get("userId").(string) + if !ok { + return httperror.Internal500(c, "missing or invalid userId") + } + + deviceId, ok := c.Get("deviceId").(string) + if !ok { + return httperror.Internal500(c, "missing or invalid deviceId") + } + + platformId, ok := c.Get("platformId").(string) + if !ok { + return httperror.Internal500(c, "missing or invalid platformId") + } + var body model.ExecutionRequest + err := c.Bind(&body) if err != nil { - LogStringError(c, err, "transact: execute bind") - return BadRequestError(c) + libcommon.LogStringError(c, err, "transact: execute bind") + return httperror.BadRequest400(c) } - SanitizeChecksums(&body.CxAddr, &body.UserAddress) - // Sanitize Checksum for body.CxParams? It might look like this: - for i := range body.CxParams { - SanitizeChecksums(&body.CxParams[i]) + err = c.Validate(&body) + if err != nil { + libcommon.LogStringError(c, err, "transact: execute validate") + return httperror.InvalidPayload400(c, err) } - userId := c.Get("userId").(string) - deviceId := c.Get("deviceId").(string) - res, err := t.Service.Execute(body, userId, deviceId) - if err != nil && (strings.Contains(err.Error(), "risk:") || strings.Contains(err.Error(), "payment:")) { - LogStringError(c, err, "transact: execute") - return Unprocessable(c) + + transactionRequest := body.Quote.TransactionRequest + + // TODO: These should already be sanitized by the quote, double check when there's time + SanitizeChecksums(&transactionRequest.UserAddress) + for i := range transactionRequest.Actions { + SanitizeChecksums(&transactionRequest.Actions[i].CxAddr, &transactionRequest.UserAddress) + for j := range transactionRequest.Actions[i].CxParams { + SanitizeChecksums(&transactionRequest.Actions[i].CxParams[j]) + } } + + ip := c.RealIP() + + res, err := t.Service.Execute(ctx, body, userId, deviceId, platformId, ip) if err != nil { - LogStringError(c, err, "transact: execute") - return InternalError(c) + libcommon.LogStringError(c, err, "transact: execute") + + if strings.Contains(err.Error(), "risk:") || strings.Contains(err.Error(), "payment:") { + return httperror.Unprocessable422(c) + } + + return httperror.Internal500(c) } + // 200 return c.JSON(http.StatusOK, res) } diff --git a/api/handler/user.go b/api/handler/user.go index aa7f25ab..25e95b17 100644 --- a/api/handler/user.go +++ b/api/handler/user.go @@ -3,19 +3,27 @@ package handler import ( b64 "encoding/base64" "net/http" - "strings" + + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/httperror" + serror "github.com/String-xyz/go-lib/v2/stringerror" + "github.com/String-xyz/go-lib/v2/validator" + "github.com/labstack/echo/v4" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/service" - "github.com/labstack/echo/v4" ) type User interface { Create(c echo.Context) error Status(c echo.Context) error Update(c echo.Context) error + PreviewEmail(c echo.Context) error VerifyEmail(c echo.Context) error + PreValidateEmail(c echo.Context) error + GetPersonaAccountId(c echo.Context) error RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) + RegisterPrivateRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) } type ResultMessage struct { @@ -23,109 +31,418 @@ type ResultMessage struct { } type user struct { - userService service.User - verificationService service.Verification - Group *echo.Group + userService service.User + verification service.Verification + Group *echo.Group } func NewUser(route *echo.Echo, userSrv service.User, verificationSrv service.Verification) User { return &user{userSrv, verificationSrv, nil} } +// @Summary Create user +// @Description Create user +// @Tags Users +// @Accept json +// @Produce json +// @Param body body model.WalletSignaturePayloadSigned true "Wallet Signature Payload Signed" +// @Success 200 {object} model.UserLoginResponse +// @Failure 400 {object} error +// @Failure 401 {object} error +// @Failure 403 {object} error +// @Failure 409 {object} error +// @Failure 500 {object} error +// @Router /users [post] func (u user) Create(c echo.Context) error { + platformId, ok := c.Get("platformId").(string) + if !ok { + return httperror.Internal500(c, "missing or invalid platformId") + } + + ctx := c.Request().Context() var body model.WalletSignaturePayloadSigned err := c.Bind(&body) if err != nil { - LogStringError(c, err, "user:create user bind") - return BadRequestError(c) + libcommon.LogStringError(c, err, "user:create user bind") + return httperror.BadRequest400(c) } if err := c.Validate(body); err != nil { - return InvalidPayloadError(c, err) + libcommon.LogStringError(c, err, "user:create user validate body") + return httperror.InvalidPayload400(c, err) } // base64 decode nonce decodedNonce, _ := b64.URLEncoding.DecodeString(body.Nonce) if err != nil { - LogStringError(c, err, "user: create user decode nonce") - return BadRequestError(c) + libcommon.LogStringError(c, err, "user: create user decode nonce") + return httperror.BadRequest400(c) } body.Nonce = string(decodedNonce) - resp, err := u.userService.Create(body) + resp, err := u.userService.Create(ctx, body, platformId) if err != nil { - if strings.Contains(err.Error(), "wallet already associated with user") { - return Conflict(c) + libcommon.LogStringError(c, err, "user: creating user") + + if serror.Is(err, serror.ALREADY_IN_USE) { + return httperror.Conflict409(c) + } + + if serror.Is(err, serror.NOT_FOUND) { + return httperror.NotFound404(c) } - LogStringError(c, err, "user: creating user") - return InternalError(c) + if serror.Is(err, serror.EXPIRED) { + return httperror.Forbidden403(c, "Nonce expired. Request a new one") + } + + return httperror.Internal500(c) } // set auth cookies err = SetAuthCookies(c, resp.JWT) if err != nil { - LogStringError(c, err, "user: unable to set auth cookies") - return InternalError(c) + libcommon.LogStringError(c, err, "user: unable to set auth cookies") + return httperror.Internal500(c) } + // 200 return c.JSON(http.StatusOK, resp) } +// @Summary Get user status +// @Description Get user status +// @Tags Users +// @Accept json +// @Produce json +// @Security JWT +// @Param id path string true "User ID" +// @Success 200 {object} model.UserOnboardingStatus +// @Failure 401 {object} error +// @Failure 500 {object} error +// @Router /users/{id}/status [get] func (u user) Status(c echo.Context) error { - valid, userId := validUserID(IDParam(c), c) + ctx := c.Request().Context() + valid, userId := validUserId(IdParam(c), c) if !valid { - return Unauthorized(c) + return httperror.Unauthorized401(c) } - status, err := u.userService.GetStatus(userId) + status, err := u.userService.GetStatus(ctx, userId) if err != nil { - LogStringError(c, err, "user: get status") - return InternalError(c) + libcommon.LogStringError(c, err, "user: get status") + return httperror.Internal500(c) } + // 200 return c.JSON(http.StatusOK, status) } +// @Summary Update user +// @Description Update user +// @Tags Users +// @Accept json +// @Produce json +// @Security JWT +// @Param id path string true "User ID" +// @Param body body model.UpdateUserName true "Update User Name" +// @Success 200 {object} model.User +// @Failure 400 {object} error +// @Failure 401 {object} error +// @Failure 500 {object} error +// @Router /users/{id} [patch] func (u user) Update(c echo.Context) error { + ctx := c.Request().Context() var body model.UpdateUserName + platformId, ok := c.Get("platformId").(string) + + if !ok { + return httperror.Internal500(c, "missing or invalid platformId") + } + err := c.Bind(&body) if err != nil { - LogStringError(c, err, "user: update bind") - return BadRequestError(c) + libcommon.LogStringError(c, err, "user: update bind") + return httperror.BadRequest400(c) + } + + err = c.Validate(body) + if err != nil { + libcommon.LogStringError(c, err, "user: update validate body") + return httperror.InvalidPayload400(c, err) } - _, userId := validUserID(IDParam(c), c) - user, err := u.userService.Update(userId, body) + + _, userId := validUserId(IdParam(c), c) + + user, err := u.userService.Update(ctx, userId, platformId, body) if err != nil { - LogStringError(c, err, "user: update") - return InternalError(c) + libcommon.LogStringError(c, err, "user: update") + return httperror.Internal500(c) } + // 200 return c.JSON(http.StatusOK, user) } -// VerifyEmail send an email with a link, the user must click on the link for the email to be verified -// the link sent is handled by (verification.VerifyEmail) handler +// @Summary Verify email +// @Description Verify email sends an email with a link, the user must click on the link for the email to be verified. The link sent is handled by (verification.VerifyEmail) handler +// @Tags Users +// @Accept json +// @Produce json +// @Security JWT +// @Param id path string true "User ID" +// @Param email query string true "Email to verify" +// @Success 200 {object} ResultMessage +// @Failure 400 {object} error +// @Failure 401 {object} error +// @Failure 409 {object} error +// @Failure 500 {object} error +// @Router /users/{id}/verify-email [get] func (u user) VerifyEmail(c echo.Context) error { - _, userId := validUserID(IDParam(c), c) + ctx := c.Request().Context() email := c.QueryParam("email") - if email == "" { - return BadRequestError(c, "Missing or invalid email") + platformId, ok := c.Get("platformId").(string) + + if !ok { + return httperror.Internal500(c, "missing or invalid platformId") + } + + valid, userId := validUserId(IdParam(c), c) + if !valid { + return httperror.BadRequest400(c, "Missing or invalid user id") + } + + if !validator.ValidEmail(email) { + return httperror.BadRequest400(c, "Invalid email") + } + + err := u.verification.SendEmailVerification(ctx, platformId, userId, email) + if err != nil { + libcommon.LogStringError(c, err, "user: email verification") + + if serror.Is(err, serror.ALREADY_IN_USE) { + return httperror.Conflict409(c) + } + + return httperror.Internal500(c, "Unable to send email verification") + } + + // 200 + return c.JSON(http.StatusOK, ResultMessage{Status: "Verification email sent"}) +} + +// @Summary Request device verification +// @Description Sends an email with a link, the user must click on the link for the device to be verified. +// @Tags Users +// @Accept json +// @Produce json +// @Security ApiKeyAuth +func (u user) RequestDeviceVerification(c echo.Context) error { + ctx := c.Request().Context() + + var body model.WalletSignaturePayloadSigned + + err := c.Bind(&body) + if err != nil { + libcommon.LogStringError(c, err, "user: request device verify bind") + return httperror.BadRequest400(c) + } + + err = c.Validate(body) + if err != nil { + libcommon.LogStringError(c, err, "user: request device verify validate body") + return httperror.InvalidPayload400(c, err) + } + + // base64 decode nonce + decodedNonce, err := b64.URLEncoding.DecodeString(body.Nonce) + if err != nil { + libcommon.LogStringError(c, err, "login: verify signature decode nonce") + return httperror.BadRequest400(c) + } + body.Nonce = string(decodedNonce) + + err = u.userService.RequestDeviceVerification(ctx, body) + if err != nil { + libcommon.LogStringError(c, err, "user: device verification") + + if serror.Is(err, serror.NOT_FOUND) { + return httperror.NotFound404(c) + } + + if serror.Is(err, serror.EXPIRED) { + return httperror.BadRequest400(c, "Expired, request a new payload") + } + + return httperror.Internal500(c, "Unable to send device verification") + } + + // 200 + return c.JSON(http.StatusOK, ResultMessage{Status: "Device verification email sent"}) +} + +func (u user) GetDeviceStatus(c echo.Context) error { + ctx := c.Request().Context() + + var body model.WalletSignaturePayloadSigned + + err := c.Bind(&body) + if err != nil { + libcommon.LogStringError(c, err, "user: request device verify bind") + return httperror.BadRequest400(c) } - err := u.verificationService.SendEmailVerification(userId, email) + err = c.Validate(body) if err != nil { - if strings.Contains(err.Error(), "email already verified") { - return Conflict(c) + libcommon.LogStringError(c, err, "user: request device verify validate body") + return httperror.InvalidPayload400(c, err) + } + + // base64 decode nonce + decodedNonce, err := b64.URLEncoding.DecodeString(body.Nonce) + if err != nil { + libcommon.LogStringError(c, err, "login: verify signature decode nonce") + return httperror.BadRequest400(c) + } + body.Nonce = string(decodedNonce) + + status, err := u.userService.GetDeviceStatus(ctx, body) + if err != nil { + libcommon.LogStringError(c, err, "user: get device status") + + if serror.Is(err, serror.NOT_FOUND) { + return httperror.NotFound404(c) + } + + if serror.Is(err, serror.EXPIRED) { + return httperror.BadRequest400(c, "Expired, request a new payload") + } + + return httperror.BadRequest400(c, "Invalid Payload") + } + + // 200 + return c.JSON(http.StatusOK, status) +} + +// @Summary Pre validate email +// @Description Pre validate email allows an organization to pre validate an email before the user signs up +// @Tags Users +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param id path string true "User ID" +// @Param body body model.PreValidateEmail true "Pre Validate Email" +// @Success 200 {object} ResultMessage +// @Failure 400 {object} error +// @Failure 401 {object} error +// @Failure 409 {object} error +// @Failure 500 {object} error +// @Router /users/{id}/email/pre-validate [post] +func (u user) PreValidateEmail(c echo.Context) error { + ctx := c.Request().Context() + userId := c.Param("id") + platformId, ok := c.Get("platformId").(string) + if !ok { + return httperror.Internal500(c, "missing or invalid platformId") + } + + // Get email from body + var body model.PreValidateEmail + err := c.Bind(&body) + if err != nil { + libcommon.LogStringError(c, err, "user: pre validate email bind") + return httperror.BadRequest400(c) + } + + if !validator.ValidEmail(body.Email) { + return httperror.BadRequest400(c, "Invalid email") + } + + err = u.verification.PreValidateEmail(ctx, platformId, userId, body.Email) + if err != nil { + // ? + libcommon.LogStringError(c, err, "user: pre validate email") + return DefaultErrorHandler(c, err, "platformInternal: PreValidateEmail") + } + + // 200 + return c.JSON(http.StatusOK, ResultMessage{Status: "validated"}) +} + +// @Summary Get user persona account id +// @Description Get user persona account id +// @Tags Users +// @Accept json +// @Produce json +// @Security JWT +// @Success 200 {object} string +// @Failure 400 {object} error +// @Failure 401 {object} error +// @Failure 500 {object} error +// @Router /users/persona-account-id [get] +func (u user) GetPersonaAccountId(c echo.Context) error { + ctx := c.Request().Context() + + userId, ok := c.Get("userId").(string) + if !ok { + return httperror.Internal500(c, "missing or invalid userId") + } + + accountId, err := u.userService.GetPersonaAccountId(ctx, userId) + if err != nil { + libcommon.LogStringError(c, err, "user: get persona account id") + return httperror.Internal500(c) + } + return c.JSON(http.StatusOK, accountId) +} + +// @Summary Get user email preview +// @Description Get obscured user email +// @Tags Users +// @Accept json +// @Produce json +// @Security ApiKeyAuth +func (u user) PreviewEmail(c echo.Context) error { + ctx := c.Request().Context() + + var body model.WalletSignaturePayloadSigned + + err := c.Bind(&body) + if err != nil { + libcommon.LogStringError(c, err, "user: preview email bind") + return httperror.BadRequest400(c) + } + + err = c.Validate(body) + if err != nil { + libcommon.LogStringError(c, err, "user: preview email validate body") + return httperror.InvalidPayload400(c, err) + } + + // base64 decode nonce + decodedNonce, err := b64.URLEncoding.DecodeString(body.Nonce) + if err != nil { + libcommon.LogStringError(c, err, "login: verify signature decode nonce") + return httperror.BadRequest400(c) + } + body.Nonce = string(decodedNonce) + + email, err := u.userService.PreviewEmail(ctx, body) + if err != nil { + libcommon.LogStringError(c, err, "user: preview email") + + if serror.Is(err, serror.NOT_FOUND) { + return httperror.NotFound404(c) } - if strings.Contains(err.Error(), "link expired") { - return LinkExpired(c, "Link expired, please request a new one") + if serror.Is(err, serror.EXPIRED) { + return httperror.BadRequest400(c, "Expired, request a new payload") } - LogStringError(c, err, "user: email verification") - return InternalError(c, "Unable to send email verification") + return httperror.BadRequest400(c, "Invalid Payload") } - return c.JSON(http.StatusOK, ResultMessage{Status: "Email Successfully Verified"}) + // 200 + return c.JSON(http.StatusOK, email) } func (u user) RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) { @@ -133,24 +450,39 @@ func (u user) RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) { panic("No group attached to the User Handler") } u.Group = g - // create does not require JWT auth middleware + // These endpoints use the API key and do not require JWT auth middleware // hence adding only the first middleware only which is APIKey - // Please pass the middle in order of API -> JWT otherwise this wont work. - if len(ms) > 0 { - g.POST("", u.Create, ms[0]) - } + g.POST("", u.Create, ms[0]) + g.POST("/preview-email", u.PreviewEmail, ms[0]) + g.POST("/verify-device", u.RequestDeviceVerification, ms[0]) + g.POST("/device-status", u.GetDeviceStatus, ms[0]) + + // the rest of the endpoints use the JWT auth and do not require an API Key + // hence removing the first (API key) middleware + ms = ms[1:] + g.GET("/:id/status", u.Status, ms...) g.GET("/:id/verify-email", u.VerifyEmail, ms...) - g.PUT("/:id", u.Update, ms...) + g.GET("/persona-account-id", u.GetPersonaAccountId, ms...) + g.PATCH("/:id", u.Update, ms...) +} + +func (u user) RegisterPrivateRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) { + if g == nil { + panic("No private group attached to the User Handler") + } + u.Group = g + + g.POST("/:id/email/pre-validate", u.PreValidateEmail, ms...) } // get userId from context and also compare if both are valid -// this is useful for path params validation and userID from JWT -func validUserID(userID string, c echo.Context) (bool, string) { - userId := c.Get("userId").(string) - return userId == userID, userId +// this is useful for path params validation and userId from JWT +func validUserId(userId string, c echo.Context) (bool, string) { + _userId := c.Get("userId").(string) + return _userId == userId, _userId } -func IDParam(c echo.Context) string { +func IdParam(c echo.Context) string { return c.Param("id") } diff --git a/api/handler/user_test.go b/api/handler/user_test.go index 9d0e593f..35e4f4bf 100644 --- a/api/handler/user_test.go +++ b/api/handler/user_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/String-xyz/string-api/api/validator" + "github.com/String-xyz/go-lib/v2/validator" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/test/stubs" "github.com/labstack/echo/v4" diff --git a/api/handler/verification.go b/api/handler/verification.go index 02f2df34..d5cd50ec 100644 --- a/api/handler/verification.go +++ b/api/handler/verification.go @@ -3,18 +3,14 @@ package handler import ( "net/http" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/httperror" "github.com/String-xyz/string-api/pkg/service" "github.com/labstack/echo/v4" ) type Verification interface { - // VerifyEmail receives payload from an email link sent previsouly - // it creates a contact and sets the email as verified - // this is a public endpoint since is called outside of a platform - VerifyEmail(c echo.Context) error - - //VerifyDevice receives a payload from the email link sent from login/sign - VerifyDevice(c echo.Context) error + Verify(c echo.Context) error RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) } @@ -28,36 +24,65 @@ func NewVerification(route *echo.Echo, service service.Verification, deviceServi return &verification{service, deviceService, nil} } -func (v verification) VerifyEmail(c echo.Context) error { - token := c.QueryParam("token") - err := v.service.VerifyEmail(token) +// @Summary Verify +// @Description Verify receives payload from an email link sent previsouly. +// @Tags Verification +// @Accept json +// @Produce json +// @Param type query string true "type" +// @Param token query string true "token" +// @Success 200 {object} ResultMessage +// @Failure 400 {object} error +// @Router /verification [get] +func (v verification) Verify(c echo.Context) error { + verificationType := c.QueryParam("type") + if verificationType == "" { + return httperror.BadRequest400(c) + } + if verificationType == "email" { + // ? + err := v.verifyEmail(c) + if err != nil { + libcommon.LogStringError(c, err, "verification: email verification") + } + return err + } + + // ? + err := v.verifyDevice(c) if err != nil { - LogStringError(c, err, "verification: email verification") - return BadRequestError(c) + libcommon.LogStringError(c, err, "verification: device verification") } - return c.JSON(http.StatusOK, ResultMessage{Status: "Email successfully verified"}) + return err } -func (v verification) VerifyDevice(c echo.Context) error { +// Verify Email receives payload from an email link sent previsouly. +// It creates a contact and sets the email as verified. +// This is a public endpoint since is called outside of a platform +func (v verification) verifyEmail(c echo.Context) error { + ctx := c.Request().Context() + token := c.QueryParam("token") - err := v.deviceService.VerifyDevice(token) + err := v.service.VerifyEmailWithEncryptedToken(ctx, token) if err != nil { - LogStringError(c, err, "verification: device verification") - return BadRequestError(c) + libcommon.LogStringError(c, err, "verification: email verification") + return httperror.BadRequest400(c) } - return c.JSON(http.StatusOK, ResultMessage{Status: "Device successfully verified"}) + // 200 + return c.JSON(http.StatusOK, ResultMessage{Status: "Email successfully verified"}) } -func (v verification) verify(c echo.Context) error { - verificationType := c.QueryParam("type") - if verificationType == "" { - return BadRequestError(c) - } - if verificationType == "email" { - return v.VerifyEmail(c) +// Verify Device receives payload from an email link sent in the login/sign. +func (v verification) verifyDevice(c echo.Context) error { + ctx := c.Request().Context() + token := c.QueryParam("token") + err := v.deviceService.VerifyDevice(ctx, token) + if err != nil { + libcommon.LogStringError(c, err, "verification: device verification") + return httperror.BadRequest400(c) } - - return v.VerifyDevice(c) + // 200 + return c.JSON(http.StatusOK, ResultMessage{Status: "Device successfully verified"}) } func (v verification) RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) { @@ -66,5 +91,5 @@ func (v verification) RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) { } v.group = g g.Use(ms...) - g.GET("", v.verify) + g.GET("", v.Verify) } diff --git a/api/handler/webhook.go b/api/handler/webhook.go new file mode 100644 index 00000000..63423170 --- /dev/null +++ b/api/handler/webhook.go @@ -0,0 +1,62 @@ +package handler + +import ( + "io" + + "github.com/labstack/echo/v4" + + "github.com/String-xyz/go-lib/v2/httperror" + + "github.com/String-xyz/string-api/pkg/service" +) + +type Webhook interface { + HandleCheckout(c echo.Context) error + HandlePersona(c echo.Context) error + RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) +} + +type webhook struct { + service service.Webhook + group *echo.Group +} + +func NewWebhook(route *echo.Echo, service service.Webhook) Webhook { + return &webhook{service, nil} +} + +func (w webhook) HandleCheckout(c echo.Context) error { + cxt := c.Request().Context() + body, err := io.ReadAll(c.Request().Body) + if err != nil { + return httperror.BadRequest400(c, "Failed to read body") + } + + err = w.service.Handle(cxt, body, service.WebhookTypeCheckout) + if err != nil { + return httperror.Internal500(c, "Failed to handle checkout webhook") + } + + return nil +} + +func (w webhook) HandlePersona(c echo.Context) error { + cxt := c.Request().Context() + body, err := io.ReadAll(c.Request().Body) + if err != nil { + return httperror.BadRequest400(c, "Failed to read body") + } + + err = w.service.Handle(cxt, body, service.WebhookTypePersona) + if err != nil { + return httperror.Internal500(c, "Failed to handle persona webhook") + } + + return nil +} + +func (w webhook) RegisterRoutes(g *echo.Group, ms ...echo.MiddlewareFunc) { + w.group = g + g.POST("/checkout", w.HandleCheckout, ms...) + g.POST("/persona", w.HandlePersona, ms...) +} diff --git a/api/middleware/middleware.go b/api/middleware/middleware.go index 1726555e..8c802191 100644 --- a/api/middleware/middleware.go +++ b/api/middleware/middleware.go @@ -1,116 +1,83 @@ package middleware import ( - "net/http" - "os" + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "io" "strings" - "github.com/String-xyz/string-api/api/handler" - "github.com/String-xyz/string-api/pkg/service" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/httperror" "github.com/golang-jwt/jwt" "github.com/labstack/echo/v4" echoMiddleware "github.com/labstack/echo/v4/middleware" - "github.com/pkg/errors" - "github.com/rs/zerolog" - echoDatadog "gopkg.in/DataDog/dd-trace-go.v1/contrib/labstack/echo.v4" -) - -func CORS() echo.MiddlewareFunc { - return echoMiddleware.CORSWithConfig(echoMiddleware.CORSConfig{ - AllowOrigins: []string{"*"}, - AllowMethods: []string{http.MethodGet, http.MethodPut, http.MethodPost, http.MethodDelete}, - AllowCredentials: true, // allow cookie auth - }) -} - -func Recover() echo.MiddlewareFunc { - return echoMiddleware.Recover() -} - -func Logger(logger *zerolog.Logger) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - c.Set("logger", logger) - return next(c) - } - } -} - -func LogRequest() echo.MiddlewareFunc { - return echoMiddleware.RequestLoggerWithConfig(echoMiddleware.RequestLoggerConfig{ - LogURI: true, - LogStatus: true, - LogRequestID: true, - LogLatency: true, - LogMethod: true, - LogHost: true, - LogError: true, - LogValuesFunc: func(c echo.Context, v echoMiddleware.RequestLoggerValues) error { - env := os.Getenv("ENV") - logger := c.Get("logger").(*zerolog.Logger) - logger.Info(). - Str("path", v.URI). - Str("method", v.Method). - Int("status_code", v.Status). - Str("request_id", v.RequestID). - Str("host", v.Host). - Dur("latency", v.Latency). - Str("env", env). - Err(v.Error). - Msg("request") - - return nil - }, - }) -} -// RequestID generates a unique request ID -func RequestID() echo.MiddlewareFunc { - return echoMiddleware.RequestID() -} + "github.com/String-xyz/string-api/config" + "github.com/String-xyz/string-api/pkg/model" + "github.com/String-xyz/string-api/pkg/service" +) -func BearerAuth() echo.MiddlewareFunc { +func JWTAuth() echo.MiddlewareFunc { config := echoMiddleware.JWTConfig{ TokenLookup: "header:Authorization,cookie:StringJWT", ParseTokenFunc: func(auth string, c echo.Context) (interface{}, error) { - var claims = &service.JWTClaims{} + var claims = &model.JWTClaims{} t, err := jwt.ParseWithClaims(auth, claims, func(t *jwt.Token) (interface{}, error) { - return []byte(os.Getenv("JWT_SECRET_KEY")), nil + return []byte(config.Var.JWT_SECRET_KEY), nil }) c.Set("userId", claims.UserId) c.Set("deviceId", claims.DeviceId) + c.Set("platformId", claims.PlatformId) + return t, err }, - SigningKey: []byte(os.Getenv("JWT_SECRET_KEY")), + SigningKey: []byte(config.Var.JWT_SECRET_KEY), ErrorHandlerWithContext: func(err error, c echo.Context) error { - if strings.Contains(err.Error(), "token is expired") { - return handler.TokenExpired(c) - } - - if strings.Contains(errors.Cause(err).Error(), "missing or malformed jwt") { - return handler.MissingToken(c) - } + libcommon.LogStringError(c, err, "Error in JWTAuth middleware") - return handler.Unauthorized(c) + return httperror.Unauthorized401(c) }, } return echoMiddleware.JWTWithConfig(config) } -func APIKeyAuth(service service.Auth) echo.MiddlewareFunc { +func APIKeyPublicAuth(service service.Auth) echo.MiddlewareFunc { config := echoMiddleware.KeyAuthConfig{ KeyLookup: "header:X-Api-Key", Validator: func(auth string, c echo.Context) (bool, error) { - valid := service.ValidateAPIKey(auth) - return valid, nil + platformId, err := service.ValidateAPIKeyPublic(c.Request().Context(), auth) + if err != nil { + libcommon.LogStringError(c, err, "Error in APIKeyPublicAuth middleware") + return false, err + } + + c.Set("platformId", platformId) + + return true, nil }, } return echoMiddleware.KeyAuthWithConfig(config) } -func Tracer() echo.MiddlewareFunc { - return echoDatadog.Middleware() +func APIKeySecretAuth(service service.Auth) echo.MiddlewareFunc { + config := echoMiddleware.KeyAuthConfig{ + KeyLookup: "header:X-Api-Key", + Validator: func(auth string, c echo.Context) (bool, error) { + platformId, err := service.ValidateAPIKeySecret(c.Request().Context(), auth) + if err != nil { + libcommon.LogStringError(c, err, "Error in APIKeySecretAuth middleware") + return false, err + } + + // TODO: Validate platformId + c.Set("platformId", platformId) + return true, nil + }, + } + return echoMiddleware.KeyAuthWithConfig(config) } func Georestrict(service service.Geofencing) echo.MiddlewareFunc { @@ -125,13 +92,87 @@ func Georestrict(service service.Geofencing) echo.MiddlewareFunc { // For now we are denying if err != nil || !isAllowed { if err != nil { - // TODO: Move the common.go file to the upper level - handler.LogStringError(c, err, "Error in georestrict middleware") + libcommon.LogStringError(c, err, "Error in georestrict middleware") } - return c.JSON(http.StatusForbidden, "Error: Geo Location Forbidden") + return httperror.Forbidden403(c, "Error: Geo Location Forbidden") } return next(c) } } } + +func VerifyWebhookPayload(pskey string, ckoskey string) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + var signatureHeaderName string + var secretKey string + var validateFunc func([]byte, string, string) bool + + switch c.Path() { + case "/webhooks/checkout": + signatureHeaderName = "Cko-Signature" + secretKey = ckoskey + validateFunc = validateSignatureCheckout + case "/webhooks/persona": + signatureHeaderName = "Persona-Signature" + secretKey = pskey + validateFunc = validateSignaturePersona + default: + return httperror.BadRequest400(c, "Invalid path") + } + + signatureHeader := c.Request().Header.Get(signatureHeaderName) + body, err := io.ReadAll(c.Request().Body) + if err != nil { + return httperror.BadRequest400(c, "Failed to read body") + } + + c.Request().Body = io.NopCloser(bytes.NewBuffer(body)) + + if !validateFunc(body, signatureHeader, secretKey) { + return httperror.Unauthorized401(c, "Failed to verify payload") + } + + return next(c) + } + } +} + +func validateSignatureCheckout(body []byte, signature string, secretKey string) bool { + mac := hmac.New(sha256.New, []byte(secretKey)) + mac.Write(body) + expectedMAC := mac.Sum(nil) + + receivedMAC, err := hex.DecodeString(signature) + if err != nil { + return false + } + + return hmac.Equal(receivedMAC, expectedMAC) +} + +func validateSignaturePersona(body []byte, signatureHeader string, secretKey string) bool { + parts := strings.Split(signatureHeader, ",") + var timestamp, signature string + for _, part := range parts { + if strings.HasPrefix(part, "t=") { + timestamp = strings.TrimPrefix(part, "t=") + } else if strings.HasPrefix(part, "v1=") { + signature = strings.TrimPrefix(part, "v1=") + } + } + + macData := timestamp + "." + string(body) + + mac := hmac.New(sha256.New, []byte(secretKey)) + mac.Write([]byte(macData)) + expectedMAC := mac.Sum(nil) + + receivedMAC, err := hex.DecodeString(signature) + if err != nil { + return false + } + + return hmac.Equal(expectedMAC, receivedMAC) +} diff --git a/api/middleware/middleware_test.go b/api/middleware/middleware_test.go new file mode 100644 index 00000000..82f66a19 --- /dev/null +++ b/api/middleware/middleware_test.go @@ -0,0 +1,88 @@ +package middleware + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/assert" +) + +func TestVerifyWebhookPayload(t *testing.T) { + checkoutSecretKey := "checkout_secret_key" + personaSecretKey := "persona_secret_key" + + tests := []struct { + name string + path string + signatureKey string + signatureName string + secretKey string + expectCode int + }{ + { + name: "Test unauthorized access due to invalid signature for Checkout", + path: "/webhooks/checkout", + signatureKey: "invalid_signature", + signatureName: "Cko-Signature", + secretKey: checkoutSecretKey, + expectCode: http.StatusUnauthorized, + }, + { + name: "Test successful access for Checkout", + path: "/webhooks/checkout", + signatureKey: computeHmacSha256("hello", checkoutSecretKey), + signatureName: "Cko-Signature", + secretKey: checkoutSecretKey, + expectCode: http.StatusOK, + }, + { + name: "Test unauthorized access due to invalid signature for Persona", + path: "/webhooks/persona", + signatureKey: "t=1629478952,v1=invalid_signature", + signatureName: "Persona-Signature", + secretKey: personaSecretKey, + expectCode: http.StatusUnauthorized, + }, + { + name: "Test successful access for Persona", + path: "/webhooks/persona", + signatureKey: fmt.Sprintf("t=1629478952,v1=%s", computeHmacSha256("1629478952.hello", personaSecretKey)), + signatureName: "Persona-Signature", + secretKey: personaSecretKey, + expectCode: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := echo.New() + req := httptest.NewRequest(echo.POST, "/", strings.NewReader("hello")) + req.Header.Set(tt.signatureName, tt.signatureKey) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath(tt.path) + + middleware := VerifyWebhookPayload(personaSecretKey, checkoutSecretKey) + middleware(func(c echo.Context) error { + return c.String(http.StatusOK, "Test") + })(c) + + assert.Equal(t, tt.expectCode, rec.Code) + }) + } +} + +// Utility function to compute HMAC for testing +func computeHmacSha256(message string, secret string) string { + key := []byte(secret) + h := hmac.New(sha256.New, key) + h.Write([]byte(message)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/api/validator/validator.go b/api/validator/validator.go deleted file mode 100644 index b77ad2d8..00000000 --- a/api/validator/validator.go +++ /dev/null @@ -1,82 +0,0 @@ -package validator - -import ( - "fmt" - "reflect" - "strings" - - "github.com/go-playground/validator/v10" -) - -var tagsMessage = map[string]string{ - "required": "is required", - "email": "must be a valid email", - "gte": "must be greater or equal to", - "gt": "must be at least", - "numeric": "must be a valid numeric value", -} - -type InvalidParamError struct { - Param string `json:"param"` - Value any `json:"value"` - ExpectedType string `json:"expectedType"` - Message string `json:"message"` -} - -type InvalidParams []InvalidParamError - -type Validator struct { - validator *validator.Validate -} - -// Validate runs validation on structs as default -func (v *Validator) Validate(i interface{}) error { - return v.validator.Struct(i) -} - -// New Returns an API Validator with the underlying struct validator -func New() *Validator { - v := validator.New() - v.RegisterTagNameFunc(func(fld reflect.StructField) string { - name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] - if name == "-" { - return "" - } - return name - }) - - return &Validator{v} -} - -// ExtractErrorParams loops over the errors returned by a validation -// this is the simplest validation, we at some point will want to extend it -func ExtractErrorParams(err error) InvalidParams { - params := InvalidParams{} - if _, ok := err.(*validator.InvalidValidationError); ok { - return params - } - - for _, err := range err.(validator.ValidationErrors) { - p := InvalidParamError{ - Param: err.Field(), - Value: err.Value(), - ExpectedType: err.Type().String(), - Message: message(err), - } - - params = append(params, p) - } - - return params -} - -func message(f validator.FieldError) string { - message := tagsMessage[f.Tag()] - if strings.HasPrefix(f.Tag(), "g") { - return fmt.Sprintf("%v %s %s", f.Value(), message, f.Param()) - } - if message == "" { - return "Some fields are missing or invalid, please provide all required data" - } - return fmt.Sprintf("%s %s", f.Field(), message) -} diff --git a/api/validator/validator_test.go b/api/validator/validator_test.go deleted file mode 100644 index 2920f762..00000000 --- a/api/validator/validator_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package validator - -import ( - "encoding/json" - "testing" - - "github.com/stretchr/testify/assert" -) - -// tester has all its field as required fields -type tester struct { - Email string `json:"email" validate:"required,email"` // email must be a valid email - Name string `json:"name" validate:"required,gt=2"` // the name field should have at least 2 letters(this can be adjusted) - Age int `json:"age" validate:"required,gte=18,numeric"` // user must be 18yr or older -} - -func TestValid(t *testing.T) { - v := New() - js := `{"email":"marlon@string.xyz", "name":"marlon", "age": 18}` - ts := tester{} - err := json.Unmarshal([]byte(js), &ts) - assert.NoError(t, err) - err = v.Validate(ts) - assert.NoError(t, err) -} - -func TestInvalidEmail(t *testing.T) { - v := New() - js := `{"email":"marlon@string", "name":"marlon", "age": 18}` - ts := tester{} - err := json.Unmarshal([]byte(js), &ts) - assert.NoError(t, err) - err = v.Validate(ts) - assert.Error(t, err) - bt, _ := json.Marshal(ExtractErrorParams(err)) - t.Log(string(bt)) -} - -func TestInvalidAge(t *testing.T) { - v := New() - js := `{"email":"marlon@string.xyz", "name":"marlon", "age": 10}` - ts := tester{} - err := json.Unmarshal([]byte(js), &ts) - assert.NoError(t, err) - err = v.Validate(ts) - assert.Error(t, err) - bt, _ := json.Marshal(ExtractErrorParams(err)) - t.Log(string(bt)) - -} - -func TestInvalidName(t *testing.T) { - v := New() - js := `{"email":"marlon@string.xyz", "name":"m", "age": 18}` - ts := tester{} - err := json.Unmarshal([]byte(js), &ts) - assert.NoError(t, err) - err = v.Validate(ts) - assert.Error(t, err) - bt, _ := json.Marshal(ExtractErrorParams(err)) - t.Log(string(bt)) - -} - -func TestMissingEmail(t *testing.T) { - v := New() - js := `{"name":"marlon", "age": 18}` - ts := tester{} - err := json.Unmarshal([]byte(js), &ts) - assert.NoError(t, err) - err = v.Validate(ts) - assert.Error(t, err) - bt, _ := json.Marshal(ExtractErrorParams(err)) - t.Log(string(bt)) -} - -func TestMissingName(t *testing.T) { - v := New() - js := `{"email":"marlon@string.xyz", "age": 18}` - ts := tester{} - err := json.Unmarshal([]byte(js), &ts) - assert.NoError(t, err) - err = v.Validate(ts) - assert.Error(t, err) - bt, _ := json.Marshal(ExtractErrorParams(err)) - t.Log(string(bt)) -} - -func TestMissingAge(t *testing.T) { - v := New() - js := `{"email":"marlon@string.xyz", "name":"m"}` - ts := tester{} - err := json.Unmarshal([]byte(js), &ts) - assert.NoError(t, err) - err = v.Validate(ts) - assert.Error(t, err) - bt, _ := json.Marshal(ExtractErrorParams(err)) - t.Log(string(bt)) -} - -func TestInvalidNumeric(t *testing.T) { - v := New() - js := `{"email":"marlon@string.xyz", "name":"marlon", "age":"string"}` - ts := tester{} - err := json.Unmarshal([]byte(js), &ts) - assert.Error(t, err) - err = v.Validate(ts) - assert.Error(t, err) - bt, _ := json.Marshal(ExtractErrorParams(err)) - t.Log(string(bt)) -} diff --git a/cmd/app/main.go b/cmd/app/main.go index f720ffd8..2e16d04a 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -1,40 +1,66 @@ package main import ( + "log" "os" + libcommon "github.com/String-xyz/go-lib/v2/common" + env "github.com/String-xyz/go-lib/v2/config" "github.com/String-xyz/string-api/api" - "github.com/String-xyz/string-api/api/handler" + "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/store" - "github.com/joho/godotenv" "github.com/rs/zerolog" "github.com/rs/zerolog/pkgerrors" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + "gopkg.in/DataDog/dd-trace-go.v1/profiler" ) func main() { - // load .env file - godotenv.Load(".env") // removed the err since in cloud this wont be loaded + // load env vars + err := env.LoadEnv(&config.Var) + if err != nil { + panic(err) + } lg := zerolog.New(os.Stdout) - if !handler.IsLocalEnv() { - tracer.Start() + if !libcommon.IsLocalEnv() { + setupTracer() + defer profiler.Stop() defer tracer.Stop() } - port := os.Getenv("PORT") - if port == "" { - panic("no port!") - } + port := config.Var.PORT zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack - // zerolog.SetGlobalLevel(zerolog.Disabled) // quiet mode db := store.MustNewPG() + redis := store.NewRedis() + // setup api api.Start(api.APIConfig{ DB: db, - Redis: store.NewRedisStore(), + Redis: redis, Port: port, Logger: &lg, }) } + +func setupTracer() { + rules := []tracer.SamplingRule{tracer.RateRule(1)} + tracer.Start( + tracer.WithSamplingRules(rules), + tracer.WithService("api"), + tracer.WithEnv(config.Var.ENV), + ) + + err := profiler.Start( + profiler.WithService("api"), + profiler.WithEnv(config.Var.ENV), + profiler.WithProfileTypes( + profiler.CPUProfile, + profiler.HeapProfile, + )) + + if err != nil { + log.Fatal(err) + } +} diff --git a/cmd/internal/main.go b/cmd/internal/main.go index fe41c72d..d8c49d0f 100644 --- a/cmd/internal/main.go +++ b/cmd/internal/main.go @@ -3,36 +3,37 @@ package main import ( "os" + libcommon "github.com/String-xyz/go-lib/v2/common" + env "github.com/String-xyz/go-lib/v2/config" "github.com/String-xyz/string-api/api" - "github.com/String-xyz/string-api/api/handler" + "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/store" - "github.com/joho/godotenv" "github.com/rs/zerolog" "github.com/rs/zerolog/pkgerrors" "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" ) func main() { - // load .env file - godotenv.Load(".env") // removed the err since in cloud this wont be loaded + // load env vars + env.LoadEnv(&config.Var) - if !handler.IsLocalEnv() { + if !libcommon.IsLocalEnv() { tracer.Start() defer tracer.Stop() } - port := os.Getenv("PORT") - if port == "" { - panic("no port!") - } + port := config.Var.PORT zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack db := store.MustNewPG() lg := zerolog.New(os.Stdout) + + redis := store.NewRedis() + // setup api api.StartInternal(api.APIConfig{ DB: db, - Redis: store.NewRedisStore(), + Redis: redis, Port: port, Logger: &lg, }) diff --git a/config/config.go b/config/config.go new file mode 100644 index 00000000..c0cd7079 --- /dev/null +++ b/config/config.go @@ -0,0 +1,57 @@ +package config + +type vars struct { + AWS_ACCT string `required:"false"` + AWS_ACCESS_KEY_ID string `required:"false"` + AWS_SECRET_ACCESS_KEY string `required:"false"` + DEBUG_MODE string `required:"false"` + SERVICE_NAME string `required:"false"` + STRING_HOTWALLET_ADDRESS string `required:"false"` + CARD_FAIL_PROBABILITY string `required:"false"` + BASE_URL string `required:"true"` + ENV string `required:"true"` + PORT string `required:"true"` + COINCAP_API_URL string `required:"true"` + COINGECKO_API_URL string `required:"true"` + OWLRACLE_API_URL string `required:"true"` + OWLRACLE_API_KEY string `required:"true"` + OWLRACLE_API_SECRET string `required:"true"` + AWS_REGION string `required:"true"` + AWS_KMS_KEY_ID string `required:"true"` + CHECKOUT_PUBLIC_KEY string `required:"true"` + CHECKOUT_WEBHOOK_SECRET_KEY string `required:"true"` + CHECKOUT_SECRET_KEY string `required:"true"` + CHECKOUT_ENV string `required:"true"` + EVM_PRIVATE_KEY string `required:"true"` + DB_NAME string `required:"true"` + DB_USERNAME string `required:"true"` + DB_PASSWORD string `required:"true"` + DB_HOST string `required:"true"` + DB_PORT string `required:"true"` + REDIS_PASSWORD string `required:"true"` + REDIS_HOST string `required:"true"` + REDIS_PORT string `required:"true"` + JWT_SECRET_KEY string `required:"true"` + UNIT21_API_KEY string `required:"true"` + UNIT21_ENV string `required:"true"` + UNIT21_ORG_NAME string `required:"true"` + UNIT21_RTR_URL string `required:"true"` + TWILIO_ACCOUNT_SID string `required:"true"` + TWILIO_AUTH_TOKEN string `required:"true"` + TWILIO_SMS_SID string `required:"true"` + TEAM_PHONE_NUMBERS string `required:"true"` + STRING_ENCRYPTION_KEY string `required:"true"` + SENDGRID_API_KEY string `required:"true"` + FINGERPRINT_API_KEY string `required:"true"` + FINGERPRINT_API_URL string `required:"true"` + STRING_INTERNAL_ID string `required:"true"` + STRING_WALLET_ID string `required:"true"` + STRING_BANK_ID string `required:"true"` + AUTH_EMAIL_ADDRESS string `required:"true"` + RECEIPTS_EMAIL_ADDRESS string `required:"true"` + SLACK_WEBHOOK_URL string `required:"true"` + PERSONA_API_KEY string `required:"true"` + PERSONA_WEBHOOK_SECRET_KEY string `required:"true"` +} + +var Var vars diff --git a/entrypoint.sh b/entrypoint.sh index c630ff82..3d6cd131 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -3,18 +3,11 @@ # export env variables from .env file export $(grep -v '^#' .env | xargs) -# run db migrations -echo "----- Running migrations..." -cd migrations - -DB_CONFIG="host=$DB_HOST user=$DB_USERNAME dbname=$DB_NAME sslmode=disable password=$DB_PASSWORD" -goose postgres "$DB_CONFIG" reset -goose postgres "$DB_CONFIG" up -cd .. -echo "----- ...Migrations done" -echo "----- Seeding data..." -go run script.go data_seeding local -echo "----- ...Data seeded" - # run app -air \ No newline at end of file +if [ "$DEBUG_MODE" = "true" ]; then + echo "----- DEBUG_MODE is true" + air -c .air-debug.toml +else + echo "----- DEBUG_MODE is false" + air +fi \ No newline at end of file diff --git a/go.mod b/go.mod index c71f1e3d..0835b566 100644 --- a/go.mod +++ b/go.mod @@ -4,37 +4,39 @@ go 1.19 require ( github.com/DATA-DOG/go-sqlmock v1.5.0 + github.com/String-xyz/go-lib/v2 v2.1.1 github.com/aws/aws-sdk-go v1.44.168 github.com/aws/aws-sdk-go-v2/config v1.18.7 github.com/aws/aws-sdk-go-v2/service/ssm v1.33.4 - github.com/checkout/checkout-sdk-go v0.0.22 - github.com/ethereum/go-ethereum v1.10.25 - github.com/go-playground/validator/v10 v10.11.1 - github.com/go-redis/redis/v8 v8.11.5 + github.com/checkout/checkout-sdk-go v1.0.9 + github.com/cockroachdb/errors v1.9.1 + github.com/ethereum/go-ethereum v1.11.4 github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt/v4 v4.4.2 github.com/google/uuid v1.3.0 github.com/jmoiron/sqlx v1.3.5 - github.com/joho/godotenv v1.4.0 - github.com/labstack/echo/v4 v4.8.0 + github.com/labstack/echo/v4 v4.10.0 github.com/lib/pq v1.10.6 - github.com/lmittmann/w3 v0.9.1 + github.com/lmittmann/w3 v0.11.0 github.com/pkg/errors v0.9.1 github.com/rs/zerolog v1.28.0 github.com/sendgrid/sendgrid-go v3.12.0+incompatible github.com/stretchr/testify v1.8.1 github.com/twilio/twilio-go v1.1.0 - golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be - gopkg.in/DataDog/dd-trace-go.v1 v1.45.1 + golang.org/x/crypto v0.2.0 + golang.org/x/net v0.7.0 + gopkg.in/DataDog/dd-trace-go.v1 v1.49.1 ) require ( - github.com/DataDog/datadog-agent/pkg/obfuscate v0.0.0-20211129110424-6491aa3bf583 // indirect - github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.42.0-rc.1 // indirect - github.com/DataDog/datadog-go v4.8.2+incompatible // indirect - github.com/DataDog/datadog-go/v5 v5.0.2 // indirect + github.com/DataDog/datadog-agent/pkg/obfuscate v0.43.0 // indirect + github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.43.1 // indirect + github.com/DataDog/datadog-go/v5 v5.1.1 // indirect + github.com/DataDog/go-libddwaf v1.0.0 // indirect github.com/DataDog/go-tuf v0.3.0--fix-localmeta-fork // indirect + github.com/DataDog/gostackparse v0.5.0 // indirect github.com/DataDog/sketches-go v1.2.1 // indirect + github.com/DataDog/zstd v1.5.2 // indirect github.com/Microsoft/go-winio v0.5.1 // indirect github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6 // indirect github.com/VictoriaMetrics/fastcache v1.6.0 // indirect @@ -49,56 +51,80 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.11 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.17.7 // indirect github.com/aws/smithy-go v1.13.5 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/btcsuite/btcd/btcec/v2 v2.2.0 // indirect - github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect + github.com/cockroachdb/pebble v0.0.0-20230209160836-829675f94811 // indirect + github.com/cockroachdb/redact v1.1.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/deckarep/golang-set v1.8.0 // indirect + github.com/deckarep/golang-set/v2 v2.1.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect - github.com/dgraph-io/ristretto v0.1.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.1 // indirect + github.com/getsentry/sentry-go v0.18.0 // indirect github.com/go-ole/go-ole v1.2.1 // indirect github.com/go-playground/locales v0.14.0 // indirect github.com/go-playground/universal-translator v0.18.0 // indirect - github.com/go-stack/stack v1.8.0 // indirect - github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b // indirect + github.com/go-playground/validator/v10 v10.11.1 // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect + github.com/go-stack/stack v1.8.1 // indirect + github.com/gofrs/flock v0.8.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/mock v1.6.0 // indirect + github.com/golang/protobuf v1.5.2 // indirect github.com/golang/snappy v0.0.4 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/pprof v0.0.0-20210423192551-a2663126120b // indirect github.com/gorilla/websocket v1.4.2 // indirect - github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d // indirect github.com/holiman/bloomfilter/v2 v2.0.3 // indirect - github.com/holiman/uint256 v1.2.0 // indirect + github.com/holiman/uint256 v1.2.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/josharian/intern v1.0.0 // indirect - github.com/labstack/gommon v0.3.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/klauspost/compress v1.15.15 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/labstack/gommon v0.4.0 // indirect github.com/leodido/go-urn v1.2.1 // indirect - github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/outcaste-io/ristretto v0.2.1 // indirect github.com/philhofer/fwd v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/tsdb v0.7.1 // indirect - github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect + github.com/prometheus/client_golang v1.14.0 // indirect + github.com/prometheus/client_model v0.3.0 // indirect + github.com/prometheus/common v0.39.0 // indirect + github.com/prometheus/procfs v0.9.0 // indirect + github.com/richardartoul/molecule v1.0.1-0.20221107223329-32cfee06a052 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect + github.com/secure-systems-lab/go-securesystemslib v0.5.0 // indirect github.com/sendgrid/rest v2.6.9+incompatible // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 // indirect github.com/tinylib/msgp v1.1.6 // indirect github.com/tklauser/go-sysconf v0.3.5 // indirect github.com/tklauser/numcpus v0.2.2 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasttemplate v1.2.1 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + go.uber.org/atomic v1.10.0 // indirect go4.org/intern v0.0.0-20211027215823-ae77deb06f29 // indirect go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760 // indirect - golang.org/x/net v0.1.0 // indirect - golang.org/x/sys v0.1.0 // indirect - golang.org/x/text v0.4.0 // indirect - golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 // indirect + golang.org/x/exp v0.0.0-20230206171751-46f607a40771 // indirect + golang.org/x/sys v0.5.0 // indirect + golang.org/x/text v0.7.0 // indirect + golang.org/x/time v0.2.0 // indirect golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df // indirect - google.golang.org/grpc v1.32.0 // indirect - google.golang.org/protobuf v1.28.0 // indirect + google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84 // indirect + google.golang.org/grpc v1.38.0 // indirect + google.golang.org/protobuf v1.28.1 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect gopkg.in/yaml.v3 v3.0.1 // indirect inet.af/netaddr v0.0.0-20220617031823-097006376321 // indirect ) + +// replace github.com/String-xyz/go-lib => ../go-lib diff --git a/go.sum b/go.sum index 007b1179..eafb6ec5 100644 --- a/go.sum +++ b/go.sum @@ -1,31 +1,45 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= +github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= -github.com/DataDog/datadog-agent/pkg/obfuscate v0.0.0-20211129110424-6491aa3bf583 h1:3nVO1nQyh64IUY6BPZUpMYMZ738Pu+LsMt3E0eqqIYw= -github.com/DataDog/datadog-agent/pkg/obfuscate v0.0.0-20211129110424-6491aa3bf583/go.mod h1:EP9f4GqaDJyP1F5jTNMtzdIpw3JpNs3rMSJOnYywCiw= -github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.42.0-rc.1 h1:Rmz52Xlc5k3WzAHzD0SCH4USCzyti7EbK4HtrHys3ME= -github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.42.0-rc.1/go.mod h1:VVMDDibJxYEkwcLdZBT2g8EHKpbMT4JdOhRbQ9GdjbM= -github.com/DataDog/datadog-go v4.8.2+incompatible h1:qbcKSx29aBLD+5QLvlQZlGmRMF/FfGqFLFev/1TDzRo= -github.com/DataDog/datadog-go v4.8.2+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= -github.com/DataDog/datadog-go/v5 v5.0.2 h1:UFtEe7662/Qojxkw1d6SboAeA0CPI3naKhVASwFn+04= -github.com/DataDog/datadog-go/v5 v5.0.2/go.mod h1:ZI9JFB4ewXbw1sBnF4sxsR2k1H3xjV+PUAOUsHvKpcU= +github.com/DataDog/datadog-agent/pkg/obfuscate v0.43.0 h1:wWHh/c+AboewQcXQnCdyb3gOnQlO8aaGgvrMgGz0IPo= +github.com/DataDog/datadog-agent/pkg/obfuscate v0.43.0/go.mod h1:o+rJy3B2o+Zb+wCgLSkMlkD7EiUEA5Q63cid53fZkQY= +github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.43.1 h1:1yg8/bJTJwwqwmQ+z9ctlqRJ09e7WjectGdtWlZvFYw= +github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.43.1/go.mod h1:VVMDDibJxYEkwcLdZBT2g8EHKpbMT4JdOhRbQ9GdjbM= +github.com/DataDog/datadog-go/v5 v5.1.1 h1:JLZ6s2K1pG2h9GkvEvMdEGqMDyVLEAccdX5TltWcLMU= +github.com/DataDog/datadog-go/v5 v5.1.1/go.mod h1:KhiYb2Badlv9/rofz+OznKoEF5XKTonWyhx5K83AP8E= +github.com/DataDog/go-libddwaf v1.0.0 h1:C0cHE++wMFWf5/BDO8r/3dTDCj21U/UmPIT0PiFMvsA= +github.com/DataDog/go-libddwaf v1.0.0/go.mod h1:DI5y8obPajk+Tvy2o+nZc2g/5Ria/Rfq5/624k7pHpE= github.com/DataDog/go-tuf v0.3.0--fix-localmeta-fork h1:yBq5PrAtrM4yVeSzQ+bn050+Ysp++RKF1QmtkL4VqvU= github.com/DataDog/go-tuf v0.3.0--fix-localmeta-fork/go.mod h1:yA5JwkZsHTLuqq3zaRgUQf35DfDkpOZqgtBqHKpwrBs= +github.com/DataDog/gostackparse v0.5.0 h1:jb72P6GFHPHz2W0onsN51cS3FkaMDcjb0QzgxxA4gDk= +github.com/DataDog/gostackparse v0.5.0/go.mod h1:lTfqcJKqS9KnXQGnyQMCugq3u1FP6UZMfWR0aitKFMM= github.com/DataDog/sketches-go v1.2.1 h1:qTBzWLnZ3kM2kw39ymh6rMcnN+5VULwFs++lEYUUsro= github.com/DataDog/sketches-go v1.2.1/go.mod h1:1xYmPLY1So10AwxV6MJV0J53XVH+WL9Ad1KetxVivVI= +github.com/DataDog/zstd v1.5.2 h1:vUG4lAyuPCXO0TLbXvPv7EB7cNK1QV/luu55UHLrrn8= +github.com/DataDog/zstd v1.5.2/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= +github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= github.com/Microsoft/go-winio v0.5.1 h1:aPJp2QD7OOrhO5tQXqQoGSJc+DjDtWTGLOmNyAm6FgY= github.com/Microsoft/go-winio v0.5.1/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= -github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0= github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6 h1:fLjPD/aNc3UIOA6tDi6QXUemppXK3P9BI7mr2hd6gx8= github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= +github.com/String-xyz/go-lib/v2 v2.0.3 h1:Ls+kgTuZfhjv3J5zDK61ZZ/nFyip3+0AnIRk+Ciq1Ps= +github.com/String-xyz/go-lib/v2 v2.0.3/go.mod h1:ifMnKUbxfLatYJjFMcrhgjdMicJNAlCIa1vNOoqc24w= +github.com/String-xyz/go-lib/v2 v2.1.0 h1:vhrqe/gkZpS8ROtyK9B2Bav3Xjc0gWxsNOVnE48HtJM= +github.com/String-xyz/go-lib/v2 v2.1.0/go.mod h1:ifMnKUbxfLatYJjFMcrhgjdMicJNAlCIa1vNOoqc24w= +github.com/String-xyz/go-lib/v2 v2.1.1 h1:fHVsH5konF17O8c/hX71vE1LxlMjDZ3dTLUYuDilQNs= +github.com/String-xyz/go-lib/v2 v2.1.1/go.mod h1:ifMnKUbxfLatYJjFMcrhgjdMicJNAlCIa1vNOoqc24w= github.com/VictoriaMetrics/fastcache v1.6.0 h1:C/3Oi3EiBCqufydp1neRZkqcwmEiuRT9c3fqvvgKm5o= github.com/VictoriaMetrics/fastcache v1.6.0/go.mod h1:0qHz5QP0GMX4pfmMA/zt5RgfNuXJrTP0zS7DqpHGGTw= -github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= -github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156 h1:eMwmnE/GDgah4HI848JfFxHt+iPb26b4zyfspmqY0/8= github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/aws/aws-sdk-go v1.44.168 h1:/NNDLkjcgW8UrvAUk7QvQS9yzo/CFu9Zp4BCiPHoV+E= github.com/aws/aws-sdk-go v1.44.168/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go-v2 v1.17.3 h1:shN7NlnVzvDUgPQ+1rLMSxY8OWRNDRYtiqe0p/PgrhY= @@ -54,63 +68,92 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.17.7 h1:9Mtq1KM6nD8/+HStvWcvYnixJ5N8 github.com/aws/aws-sdk-go-v2/service/sts v1.17.7/go.mod h1:+lGbb3+1ugwKrNTWcf2RT05Xmp543B06zDFTwiTLp7I= github.com/aws/smithy-go v1.13.5 h1:hgz0X/DX0dGqTYpGALqXJoRKRj5oQ7150i5FdTePzO8= github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= +github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= -github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/btcsuite/btcd/btcec/v2 v2.2.0 h1:fzn1qaOt32TuLjFlkzYSsBC35Q3KUjT1SwPxiMSCF5k= github.com/btcsuite/btcd/btcec/v2 v2.2.0/go.mod h1:U7MHm051Al6XmscBQ0BoNydpOTsFAn707034b5nY8zU= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/checkout/checkout-sdk-go v0.0.22 h1:mIfHIPBNJPCgZjIS1BdsRQewuVpgTwxd9jU3nFv55H8= -github.com/checkout/checkout-sdk-go v0.0.22/go.mod h1:8nX+8d2K+vvK/ROMXpCe8ds5T+nbv3LMlyKS9dbU1VU= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/checkout/checkout-sdk-go v1.0.9 h1:xTNLr/Kr8VXqgdzGKr6U9waZWVjFObYGgK0cG0eUZK4= +github.com/checkout/checkout-sdk-go v1.0.9/go.mod h1:NL0iaELZA1BleG4dUINHaa8LgcizKUnzWWuTVukTswo= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cockroachdb/datadriven v1.0.2 h1:H9MtNqVoVhvd9nCBwOyDjUEdZCREqbIdCJD93PBm/jA= +github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= +github.com/cockroachdb/errors v1.9.1 h1:yFVvsI0VxmRShfawbt/laCIDy/mtTqqnvoNgiy5bEV8= +github.com/cockroachdb/errors v1.9.1/go.mod h1:2sxOtL2WIc096WSZqZ5h8fa17rdDq9HZOZLBCor4mBk= +github.com/cockroachdb/logtags v0.0.0-20211118104740-dabe8e521a4f/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v0.0.0-20230209160836-829675f94811 h1:ytcWPaNPhNoGMWEhDvS3zToKcDpRsLuRolQJBVGdozk= +github.com/cockroachdb/pebble v0.0.0-20230209160836-829675f94811/go.mod h1:Nb5lgvnQ2+oGlE/EyZy4+2/CxRh9KfvCXnag1vtpxVM= +github.com/cockroachdb/redact v1.1.3 h1:AKZds10rFSIj7qADf0g46UixK8NNLwWTNdCIGS5wfSQ= +github.com/cockroachdb/redact v1.1.3/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= +github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/deckarep/golang-set v1.8.0 h1:sk9/l/KqpunDwP7pSjUg0keiOOLEnOBHzykLrsPppp4= -github.com/deckarep/golang-set v1.8.0/go.mod h1:5nI87KwE7wgsBU1F4GKAw2Qod7p5kyS383rP6+o6qqo= +github.com/deckarep/golang-set/v2 v2.1.0 h1:g47V4Or+DUdzbs8FxCCmgb6VYd+ptPAngjM6dtGktsI= +github.com/deckarep/golang-set/v2 v2.1.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= github.com/denisenkom/go-mssqldb v0.11.0 h1:9rHa233rhdOyrz2GcP9NM+gi2psgJZ4GWDpL/7ND8HI= -github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI= -github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug= +github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dvyukov/go-fuzz v0.0.0-20210103155950-6a8e9d1f2415/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= github.com/edsrzf/mmap-go v1.0.0 h1:CEBF7HpRnUCSJgGUb5h1Gm7e3VkmVDrR8lvWVLtrOFw= +github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/ethereum/go-ethereum v1.10.25 h1:5dFrKJDnYf8L6/5o42abCE6a9yJm9cs4EJVRyYMr55s= -github.com/ethereum/go-ethereum v1.10.25/go.mod h1:EYFyF19u3ezGLD4RqOkLq+ZCXzYbLoNDdZlMt7kyKFg= +github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw= +github.com/ethereum/go-ethereum v1.11.4 h1:KG81SnUHXWk8LJB3mBcHg/E2yLvXoiPmRMCIRxgx3cE= +github.com/ethereum/go-ethereum v1.11.4/go.mod h1:it7x0DWnTDMfVFdXcU6Ti4KEFQynLHVRarcSlPr0HBo= +github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fjl/memsize v0.0.0-20190710130421-bcb5799ab5e5 h1:FtmdgXiUlNeRsoNMFlKLDt+S+6hbjVMEW6RGQ7aUf7c= github.com/flynn/go-docopt v0.0.0-20140912013429-f6dd2ebbb31e/go.mod h1:HyVoz1Mz5Co8TFO8EupIdlcpwShBmY98dkT2xeHkvEI= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q= +github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M= +github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI= -github.com/go-kit/kit v0.8.0 h1:Wz+5lgoB0kkuqLEc6NVmwRknTKP6dTGbSqvhZtBI/j0= -github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= -github.com/go-logfmt/logfmt v0.4.0 h1:MP4Eh7ZCb31lleYCFuwm0oe4/YGak+5l1vA2NOE80nA= +github.com/getsentry/sentry-go v0.12.0/go.mod h1:NSap0JBYWzHND8oMbyi0+XZhUalc1TBdRL1M71JZW2c= +github.com/getsentry/sentry-go v0.18.0 h1:MtBW5H9QgdcJabtZcuJG80BMOwaBpkRDZkxRkNC1sN0= +github.com/getsentry/sentry-go v0.18.0/go.mod h1:Kgon4Mby+FJ7ZWHFUAZgVaIa8sxHtnRJRLTXZr51aKQ= +github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= +github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= +github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= github.com/go-ole/go-ole v1.2.1 h1:2lOsA72HgjxAuMlKpFiCbHTvu44PIVkZ5hqm3RSdI/E= github.com/go-ole/go-ole v1.2.1/go.mod h1:7FAglXiTm7HKlQRDeOQ6ZNUHidzCWXuZWq/1dTyBNF8= github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= @@ -125,29 +168,40 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= -github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= -github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= +github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= +github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gogo/googleapis v0.0.0-20180223154316-0cd9801be74a/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= +github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4= +github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/gogo/status v1.1.0/go.mod h1:BFv9nrluPLmrS0EmGVvLaPNmRosr9KapBYd5/hpY1WM= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.4.2 h1:rcc4lwaZgFMCZ5jxF9ABolDcIHdBytAFgqFPbSJQAYs= github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= @@ -155,31 +209,51 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210423192551-a2663126120b h1:l2YRhr+YLzmSp7KJMswRVk/lO5SwoFIcCLzJsVj+YPc= +github.com/google/pprof v0.0.0-20210423192551-a2663126120b/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= -github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d h1:dg1dEPuWpEqDnvIw251EVy4zlP8gWbsGj4BsUKCRpYs= -github.com/hashicorp/golang-lru v0.5.5-0.20210104140557-80c98217689d/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= -github.com/holiman/uint256 v1.2.0 h1:gpSYcPLWGv4sG43I2mVLiDZCNDh/EpGjSk8tmtxitHM= -github.com/holiman/uint256 v1.2.0/go.mod h1:y4ga/t+u+Xwd7CpDgZESaRcWy0I7XMlTMA25ApIH5Jw= +github.com/holiman/uint256 v1.2.1 h1:XRtyuda/zw2l+Bq/38n5XUoEF72aSOu/77Thd9pPp2o= +github.com/holiman/uint256 v1.2.1/go.mod h1:y4ga/t+u+Xwd7CpDgZESaRcWy0I7XMlTMA25ApIH5Jw= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ= +github.com/hydrogen18/memlistener v0.0.0-20200120041712-dcc25e7acd91/go.mod h1:qEIFzExnS6016fRpRfxrExeVn2gbClQA99gQhnIcdhE= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI= +github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0= +github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7UaeZTSWddIk= +github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0GqwkjqxNd0u65g= +github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= @@ -187,39 +261,60 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGw github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= -github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= -github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= -github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515 h1:T+h1c/A9Gawja4Y9mFVWj2vyii2bbUNDw3kt9VxK2EY= -github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= +github.com/kataras/golog v0.0.10/go.mod h1:yJ8YKCmyL+nWjERB90Qwn+bdyBZsaQwU3bTVFgkFIp8= +github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYbq3UhfoFmE= +github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE= +github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro= +github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= +github.com/klauspost/compress v1.15.15 h1:EF27CXIuDsYJ6mmvtBRlEuB2UVOqHG1tAXgZ7yIO+lw= +github.com/klauspost/compress v1.15.15/go.mod h1:ZcK2JAFqKOpnBlxcLsJzYfrS9X1akm9fHZNnD9+Vo/4= +github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v4 v4.8.0 h1:wdc6yKVaHxkNOEdz4cRZs1pQkwSXPiRjq69yWP4QQS8= -github.com/labstack/echo/v4 v4.8.0/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks= -github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o= -github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/labstack/echo/v4 v4.5.0/go.mod h1:czIriw4a0C1dFun+ObrXp7ok03xON0N1awStJ6ArI7Y= +github.com/labstack/echo/v4 v4.10.0 h1:5CiyngihEO4HXsz3vVsJn7f8xAlWwRr3aY6Ih280ZKA= +github.com/labstack/echo/v4 v4.10.0/go.mod h1:S/T/5fy/GigaXnHTkh0ZGe4LpkkQysvRjFMSUTkDRNQ= +github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= +github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= +github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w= github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY= github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs= github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lmittmann/w3 v0.9.1 h1:OG51KKm60RjlbSP6xqNbCVK1dWQNNqMwm9Z/o9Wd+pY= -github.com/lmittmann/w3 v0.9.1/go.mod h1:a+KVDcUBmYeBZfM4Jl2gO4KL/0hBrIFdj3TEmxP66bo= +github.com/lmittmann/w3 v0.11.0 h1:/L7rSv04v7v8/nR8+fXewCNxYswdFDEjjy9RnYpp4Rw= +github.com/lmittmann/w3 v0.11.0/go.mod h1:CQ8EYOV7BnRb+vqVVVeHk6MXOjEBrWcpupGbBNUhENU= github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275 h1:IZycmTpoUtQK3PD60UYBwjaCUHUP7cML494ao9/O8+Q= github.com/localtunnel/go-localtunnel v0.0.0-20170326223115-8a804488f275/go.mod h1:zt6UU74K6Z6oMOYJbJzYpYucqdcQwSMPBEdSvGiaUMw= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -227,17 +322,32 @@ github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/Qd github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0= -github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8= +github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.4.2 h1:6h7AQ0yhTcIsmFmnAwQls75jp2Gzs4iB8W7pjMO+rqo= github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ= +github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg= +github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w= +github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= @@ -246,45 +356,71 @@ github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3 github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= -github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/onsi/gomega v1.25.0 h1:Vw7br2PCDYijJHSfBOWhov+8cAnUf8MfMaIOV323l6Y= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/outcaste-io/ristretto v0.2.0/go.mod h1:iBZA7RCt6jaOr0z6hiBQ6t662/oZ6Gx/yauuPvIWHAI= +github.com/outcaste-io/ristretto v0.2.1 h1:KCItuNIGJZcursqHr3ghO7fc5ddZLEHspL9UR0cQM64= +github.com/outcaste-io/ristretto v0.2.1/go.mod h1:W8HywhmtlopSB1jeMg3JtdIhf+DYkLAr0VN/s4+MHac= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/philhofer/fwd v1.1.1 h1:GdGcTjf5RNAxwS4QLsiMzJYj5KEvPJD3Abr261yRQXQ= github.com/philhofer/fwd v1.1.1/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU= +github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= +github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw= +github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= -github.com/prometheus/tsdb v0.7.1 h1:YZcsG11NqnK4czYLrWd9mpEuAJIHVQLwdrleYfszMAA= -github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= -github.com/rjeczalik/notify v0.9.1 h1:CLCKso/QK1snAlnhNR/CNvNiFU2saUtjV0bx3EwNeCE= +github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= +github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/common v0.39.0 h1:oOyhkDq05hPZKItWVBkJ6g6AtGxi+fy7F4JvUV8uhsI= +github.com/prometheus/common v0.39.0/go.mod h1:6XBZ7lYdLCbkAVhwRsWTZn+IN5AB9F/NXd5w0BbEX0Y= +github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= +github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= +github.com/richardartoul/molecule v1.0.1-0.20221107223329-32cfee06a052 h1:Qp27Idfgi6ACvFQat5+VJvlYToylpM/hcyLBI3WaKPA= +github.com/richardartoul/molecule v1.0.1-0.20221107223329-32cfee06a052/go.mod h1:uvX/8buq8uVeiZiFht+0lqSLBHF+uGV8BrTv8W/SIwk= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY= github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= +github.com/russross/blackfriday v1.5.2 h1:HyvC0ARfnZBqnXwABFeSZHpKvJHJJfPz81GNueLj0oo= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= github.com/secure-systems-lab/go-securesystemslib v0.3.1/go.mod h1:o8hhjkbNl2gOamKUA/eNW3xUrntHT9L4W89W1nfj43U= -github.com/secure-systems-lab/go-securesystemslib v0.4.0 h1:b23VGrQhTA8cN2CbBw7/FulN9fTtqYUdS5+Oxzt+DUE= -github.com/secure-systems-lab/go-securesystemslib v0.4.0/go.mod h1:FGBZgq2tXWICsxWQW1msNf49F0Pf2Op5Htayx335Qbs= +github.com/secure-systems-lab/go-securesystemslib v0.5.0 h1:oTiNu0QnulMQgN/hLK124wJD/r2f9ZhIUuKIeBsCBT8= +github.com/secure-systems-lab/go-securesystemslib v0.5.0/go.mod h1:uoCqUC0Ap7jrBSEanxT+SdACYJTVplRXWLkGMuDjXqk= github.com/sendgrid/rest v2.6.9+incompatible h1:1EyIcsNdn9KIisLW50MKwmSRSK+ekueiEMJ7NEoxJo0= github.com/sendgrid/rest v2.6.9+incompatible/go.mod h1:kXX7q3jZtJXK5c5qK83bSGMdV6tsOE70KbHoqJls4lE= github.com/sendgrid/sendgrid-go v3.12.0+incompatible h1:/N2vx18Fg1KmQOh6zESc5FJB8pYwt5QFBDflYPh1KVg= github.com/sendgrid/sendgrid-go v3.12.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= -github.com/status-im/keycard-go v0.0.0-20190316090335-8537d3370df4 h1:Gb2Tyox57NRNuZ2d3rmvB3pcmbu7O1RS3m8WRx7ilrg= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/status-im/keycard-go v0.2.0 h1:QDLFswOQu1r5jsycloeQh3bVU8n/NatHHaZobtDnDzA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= @@ -309,72 +445,119 @@ github.com/tklauser/numcpus v0.2.2 h1:oyhllyrScuYI6g+h/zUvNXNp1wy7x8qQy3t/piefld github.com/tklauser/numcpus v0.2.2/go.mod h1:x3qojaO3uyYt0i56EW/VUYs7uBvdl2fkfZFu0T9wgjM= github.com/twilio/twilio-go v1.1.0 h1:HdU4gROn6JAKijWIJKEx4kt8co97IDJYkSiVZyyuTUI= github.com/twilio/twilio-go v1.1.0/go.mod h1:tdnfQ5TjbewoAu4lf9bMsGvfuJ/QU9gYuv9yx3TSIXU= -github.com/tyler-smith/go-bip39 v1.0.1-0.20181017060643-dbb3b84ba2ef h1:wHSqTBrZW24CsNJDfeh9Ex6Pm0Rcpc7qrgKBiL44vF4= -github.com/urfave/cli/v2 v2.10.2 h1:x3p8awjp/2arX+Nl/G2040AZpOCHS/eMJJ1/a+mye4Y= +github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= +github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= +github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= +github.com/urfave/cli/v2 v2.17.2-0.20221006022127-8f469abc00aa h1:5SqCsI/2Qya2bCzK15ozrqo2sZxkh0FHynJZOTVoV6Q= +github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4= +github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w= +github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI= +github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= +go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go4.org/intern v0.0.0-20211027215823-ae77deb06f29 h1:UXLjNohABv4S58tHmeuIZDO6e3mHpW2Dx33gaNt03LE= go4.org/intern v0.0.0-20211027215823-ae77deb06f29/go.mod h1:cS2ma+47FKrLPdXFpr7CuxiTW3eyJbWew4qx0qtQWDA= go4.org/unsafe/assume-no-moving-gc v0.0.0-20211027215541-db492cf91b37/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760 h1:FyBZqvoA/jbNzuAWLQE2kG820zMAkcilx6BMjGbL/E4= go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be h1:fmw3UbQh+nxngCAHrDCCztao/kbYFnWjoqop8dHx05A= -golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.2.0 h1:BRXPfhNivWL5Yq0BGQ39a2sW6t44aODpfxkWjYdzewE= +golang.org/x/crypto v0.2.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20230206171751-46f607a40771 h1:xP7rWLUr1e1n2xkK5YB4LI0hPEy3LJC6Wk+D4pGlOJg= +golang.org/x/exp v0.0.0-20230206171751-46f607a40771/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211008194852-3b03d305991f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 h1:uVc8UZUe6tr40fFVnUP5Oj+veunVezqYl9z7DYw9xzw= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -386,41 +569,56 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210316164454-77fc1eacc6aa/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11 h1:GZokNIeuVkl3aZHJchRrr13WCsols02MLUcz1U9is6M= -golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.2.0 h1:52I/1L54xyEQAYdtcSuxtiT84KGYTBGXwayxmIpNJhE= +golang.org/x/time v0.2.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20201022035929-9cf592e881e9/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -430,35 +628,47 @@ golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df h1:5Pf6pFKu98ODmgnpvkJ3k golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180518175338-11a468237815/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20200726014623-da3ae01ef02d h1:HJaAqDnKreMkv+AQyf1Mcw0jEmL9kKBNL07RDJu1N/k= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84 h1:R1r5J0u6Cx+RNl/6mezTw6oA14cmKC96FeUwL6A9bd4= +google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= +google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.32.0 h1:zWTV+LMdc3kaiJMSTOFz2UgSBgx8RNQoTGiZu3fR9S0= -google.golang.org/grpc v1.32.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.38.0 h1:/9BgsAsa5nWe26HqOlvlgJnqBuktYOLCgjCPqsa56W0= +google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= -google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -gopkg.in/DataDog/dd-trace-go.v1 v1.45.1 h1:yx7Hv2It/xxa/ETigd4bSvYMB22PxgGP8Y5N+K+Ibpo= -gopkg.in/DataDog/dd-trace-go.v1 v1.45.1/go.mod h1:kaa8caaECrtY0V/MUtPQAh1lx/euFzPJwrY1taTx3O4= -gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/DataDog/dd-trace-go.v1 v1.49.1 h1:fif50dazXYzwPN9fo3HL9B5WortmUTHxPvzP4pdl68o= +gopkg.in/DataDog/dd-trace-go.v1 v1.49.1/go.mod h1:Yp02hgfGPr9RXeVx4BgQa8uGKm6QD3DG7PohX2pg7bA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y= +gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= @@ -469,10 +679,12 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= inet.af/netaddr v0.0.0-20220617031823-097006376321 h1:B4dC8ySKTQXasnjDTMsoCMf1sQG4WsMej0WXaHxunmU= diff --git a/infra/dev/.terraform.lock.hcl b/infra/dev/.terraform.lock.hcl index e7c4ac7d..f36afb80 100644 --- a/infra/dev/.terraform.lock.hcl +++ b/infra/dev/.terraform.lock.hcl @@ -2,22 +2,22 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { - version = "4.14.0" - constraints = "4.14.0" + version = "4.37.0" + constraints = "4.37.0" hashes = [ - "h1:RqBO9RnwTLRLqBtFdzeBq/2WxFqZMaHUfKcUbK5dpZ8=", - "h1:wJ1F5KqM9XLQqotZ82YFQywpN4pwtVFR35uc5ckqGKw=", - "zh:00d03c06e6a7f8ccf8a5a8e03d71842ebe75c9bf4a94112429cf457ae50e9ec4", - "zh:1dc73df493294451a8a5bf80575d083958b8e33051f5a37764dcfd6264e0fd37", - "zh:4427e14bf3e1e0879f44edcf81a7091c67f7dd3c0b4a842f70ab2c5108452108", - "zh:4c9d8e627881207354020bcc2c6fede891d85a1893ee1a60c96e96f26bb792a7", - "zh:69c1dd3e8d1cfe85529d201ac6390df5e28bc353cf340b1ec3c5981d696f6373", - "zh:76df2d46384d7bf3c10e799145ee16c829f5bbf9218896aab4a73ec57dae0e90", - "zh:863ce9721e6d1f8554d77541545b6081e2afb1f38cb0c73a0491e58235ed588e", - "zh:9a8184398f83781623b2257361a1c038fb0eeb8361bb4714d1897f2479398b49", + "h1:LFWMFPtcsxlzbzNlR5XQNfO9/teX2pD60XYycSU4gjQ=", + "h1:RQ6CqIhVwJQ0EMeNCH0y9ztLlJalC6QO/CyqmeQUUJ4=", + "zh:12c2eb60cb1eb0a41d1afbca6fc6f0eed6ca31a12c51858f951a9e71651afbe0", + "zh:1e17482217c39a12e930e71fd2c9af8af577bec6736b184674476ebcaad28477", + "zh:1e8163c3d871bbd54c189bf2fe5e60e556d67fa399e4c88c8e6ee0834525dc33", + "zh:399c41a3e096fd75d487b98b1791f7cea5bd38567ac4e621c930cb67ec45977c", + "zh:40d4329eef2cc130e4cbed7a6345cb053dd258bf6f5f8eb0f8ce777ae42d5a01", + "zh:625db5fa75638d543b418be7d8046c4b76dc753d9d2184daa0faaaaebc02d207", + "zh:7785c8259f12b45d19fa5abdac6268f3b749fe5a35c8be762c27b7a634a4952b", + "zh:8a7611f33cc6422799c217ec2eeb79c779035ef05331d12505a6002bc48582f0", + "zh:9188178235a73c829872d2e82d88ac6d334d8bb01433e9be31615f1c1633e921", + "zh:994895b57bf225232a5fa7422e6ab87d8163a2f0605f54ff6a18cdd71f0aeadf", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:bbf27af267e5a77780ccc83b2f79e75f47ce7b8ed4f864b34baad01cbf2f54fb", - "zh:f31cfa54f3951d4623a25712964724a57f491ab17b3944802d55072768b41043", - "zh:fe17dfac4954873faf340088949e2434058f6f6b2f228fe3e349527f1ecde92d", + "zh:b57de6903ef30c9f22d38d595d64b4f92a89ea717b65782e1f44f57020ce8b1f", ] } diff --git a/infra/dev/alb.tf b/infra/dev/alb.tf index be7f4443..ccd20e73 100644 --- a/infra/dev/alb.tf +++ b/infra/dev/alb.tf @@ -1,11 +1,10 @@ module "alb_acm" { source = "../acm" - alternative_names = ["www.string-api.${local.root_domain}"] - domain_name = "string-api.${local.root_domain}" + domain_name = "api.${local.root_domain}" aws_region = "us-west-2" zone_id = data.aws_route53_zone.root.zone_id tags = { - Name = "string-api-${local.root_domain}-alb" + Name = "api-${local.root_domain}-alb" } } @@ -95,7 +94,7 @@ resource "aws_alb_listener_rule" "ecs_alb_listener_rule" { condition { host_header { - values = ["string-api.${local.root_domain}"] + values = ["api.${local.root_domain}"] } } } diff --git a/infra/dev/backend.tf b/infra/dev/backend.tf index 099cd9cb..cd113856 100644 --- a/infra/dev/backend.tf +++ b/infra/dev/backend.tf @@ -12,7 +12,7 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = "4.14.0" + version = "4.37.0" } } diff --git a/infra/dev/cloudfront.tf b/infra/dev/cloudfront.tf index 66ba69da..866d81e3 100644 --- a/infra/dev/cloudfront.tf +++ b/infra/dev/cloudfront.tf @@ -1,7 +1,7 @@ resource "aws_cloudfront_distribution" "this" { enabled = true is_ipv6_enabled = true - aliases = ["string-api.${local.root_domain}", "www.string-api.${local.root_domain}"] + aliases = ["api.${local.root_domain}"] origin { domain_name = aws_alb.alb.dns_name diff --git a/infra/dev/domain.tf b/infra/dev/domain.tf index cbc6abb3..d84e253f 100644 --- a/infra/dev/domain.tf +++ b/infra/dev/domain.tf @@ -3,18 +3,7 @@ data "aws_route53_zone" "root" { } resource "aws_route53_record" "domain" { - name = "string-api.${local.root_domain}" - type = "A" - zone_id = data.aws_route53_zone.root.zone_id - alias { - evaluate_target_health = false - name = aws_cloudfront_distribution.this.domain_name - zone_id = aws_cloudfront_distribution.this.hosted_zone_id - } -} - -resource "aws_route53_record" "www_domain" { - name = "www.string-api.${local.root_domain}" + name = "api.${local.root_domain}" type = "A" zone_id = data.aws_route53_zone.root.zone_id alias { @@ -26,12 +15,11 @@ resource "aws_route53_record" "www_domain" { module "acm" { source = "../acm" - domain_name = "string-api.${local.root_domain}" - alternative_names = ["www.string-api.${local.root_domain}"] + domain_name = "api.${local.root_domain}" aws_region = "us-east-1" zone_id = data.aws_route53_zone.root.zone_id tags = { Environment = local.env - Name = "string-api.${local.root_domain}" + Name = "api.${local.root_domain}" } -} \ No newline at end of file +} diff --git a/infra/dev/ecs.tf b/infra/dev/ecs.tf index 8c71ef2a..3805031e 100644 --- a/infra/dev/ecs.tf +++ b/infra/dev/ecs.tf @@ -1,5 +1,5 @@ -resource "aws_ecs_cluster" "cluster" { - name = local.cluster_name +data "aws_ecs_cluster" "cluster" { + cluster_name = local.cluster_name } resource "aws_ecs_task_definition" "task_definition" { @@ -31,7 +31,7 @@ resource "aws_ecs_service" "ecs_service" { name = local.service_name task_definition = local.service_name desired_count = local.desired_task_count - cluster = aws_ecs_cluster.cluster.name + cluster = data.aws_ecs_cluster.cluster.cluster_name launch_type = "FARGATE" network_configuration { diff --git a/infra/dev/iam_roles.tf b/infra/dev/iam_roles.tf index ee8f43bc..35826eb1 100644 --- a/infra/dev/iam_roles.tf +++ b/infra/dev/iam_roles.tf @@ -11,7 +11,7 @@ data "aws_iam_policy_document" "ecs_task_policy" { } resource "aws_iam_role" "task_ecs_role" { - name = "${local.service_name}-task-ecs-role" + name = "${local.env}-${local.service_name}-task-ecs-role" assume_role_policy = data.aws_iam_policy_document.ecs_task_policy.json } @@ -36,15 +36,18 @@ data "aws_iam_policy_document" "task_policy" { resources = [ data.aws_ssm_parameter.datadog.arn, data.aws_ssm_parameter.evm_private_key.arn, + data.aws_ssm_parameter.jwt_secret.arn, data.aws_ssm_parameter.string_encryption_secret.arn, data.aws_ssm_parameter.string_internal_id.arn, data.aws_ssm_parameter.string_wallet_id.arn, data.aws_ssm_parameter.string_bank_id.arn, - data.aws_ssm_parameter.string_platform_id.arn, - data.aws_ssm_parameter.ipstack_api_key.arn, data.aws_ssm_parameter.unit21_api_key.arn, data.aws_ssm_parameter.checkout_public_key.arn, data.aws_ssm_parameter.checkout_private_key.arn, + data.aws_ssm_parameter.checkout_webhook_secret.arn, + data.aws_ssm_parameter.persona_api_key.arn, + data.aws_ssm_parameter.persona_webhook_secret.arn, + data.aws_ssm_parameter.slack_webhook_url.arn, data.aws_ssm_parameter.owlracle_api_key.arn, data.aws_ssm_parameter.owlracle_api_secret.arn, data.aws_ssm_parameter.db_password.arn, diff --git a/infra/dev/security_group.tf b/infra/dev/security_group.tf index 2c294c1a..a636502f 100644 --- a/infra/dev/security_group.tf +++ b/infra/dev/security_group.tf @@ -81,4 +81,4 @@ resource "aws_security_group_rule" "redis_to_ecs" { to_port = local.redis_port source_security_group_id = aws_security_group.ecs_task_sg.id security_group_id = data.aws_security_group.redis.id -} \ No newline at end of file +} diff --git a/infra/dev/ssm.tf b/infra/dev/ssm.tf index 05fb18b5..9a6982d8 100644 --- a/infra/dev/ssm.tf +++ b/infra/dev/ssm.tf @@ -22,26 +22,14 @@ data "aws_ssm_parameter" "string_bank_id" { name = "string-bank-id" } -data "aws_ssm_parameter" "string_platform_id" { - name = "string-placeholder-platform-id" -} - -data "aws_ssm_parameter" "user_jwt_secret" { - name = "user-jwt-secret" +data "aws_ssm_parameter" "jwt_secret" { + name = "api-jwt-secret" } data "aws_ssm_parameter" "unit21_api_key" { name = "unit21-api-key" } -data "aws_ssm_parameter" "ipstack_api_key" { - name = "ipstack-api-key" -} - -data "aws_ssm_parameter" "customer_jwt_secret" { - name = "customer-jwt-secret" -} - data "aws_ssm_parameter" "checkout_public_key" { name = "dev-checkout-public-key" } @@ -50,6 +38,22 @@ data "aws_ssm_parameter" "checkout_private_key" { name = "dev-checkout-private-key" } +data "aws_ssm_parameter" "checkout_webhook_secret" { + name = "checkout-signature-key" +} + +data "aws_ssm_parameter" "persona_api_key" { + name = "persona-api-key" +} + +data "aws_ssm_parameter" "persona_webhook_secret" { + name = "persona-signature-key" +} + +data "aws_ssm_parameter" "slack_webhook_url" { + name = "slack-webhook-url" +} + data "aws_ssm_parameter" "owlracle_api_key" { name = "dev-owlracle-api-key" } @@ -105,3 +109,4 @@ data "aws_ssm_parameter" "redis_host_url" { data "aws_kms_key" "kms_key" { key_id = "alias/main-kms-key" } + diff --git a/infra/dev/variables.tf b/infra/dev/variables.tf index e525775a..46886c76 100644 --- a/infra/dev/variables.tf +++ b/infra/dev/variables.tf @@ -1,10 +1,10 @@ locals { - cluster_name = "string-core" + cluster_name = "core" env = "dev" - service_name = "string-api" + service_name = "api" root_domain = "dev.string-api.xyz" container_port = "3000" - origin_id = "string-api" + origin_id = "api" desired_task_count = "1" db_port = "5432" redis_port = "6379" @@ -26,7 +26,11 @@ locals { essential = true, dockerLabels = { "com.datadoghq.ad.instances" : "[{\"host\":\"%%host%%\"}]", - "com.datadoghq.ad.check_names" : "[\"${local.service_name}\"]", + "com.datadoghq.ad.logs" : "[{\"service\":\"${local.service_name}\"}]", + "com.datadoghq.ad.check_names" :local.service_name, + "com.datadoghq.tags.env": local.env, + "com.datadoghq.tags.service": local.service_name, + "com.datadoghq.tags.version": var.versioning }, portMappings = [ { @@ -38,6 +42,10 @@ locals { name = "EVM_PRIVATE_KEY" valueFrom = data.aws_ssm_parameter.evm_private_key.arn }, + { + name = "JWT_SECRET_KEY" + valueFrom = data.aws_ssm_parameter.jwt_secret.arn + }, { name = "STRING_ENCRYPTION_KEY" valueFrom = data.aws_ssm_parameter.string_encryption_secret.arn @@ -54,18 +62,10 @@ locals { name = "STRING_BANK_ID" valueFrom = data.aws_ssm_parameter.string_bank_id.arn }, - { - name = "STRING_PLACEHOLDER_PLATFORM_ID" - valueFrom = data.aws_ssm_parameter.string_platform_id.arn - }, { name = "UNIT21_API_KEY" valueFrom = data.aws_ssm_parameter.unit21_api_key.arn }, - { - name = "IPSTACK_API_KEY" - valueFrom = data.aws_ssm_parameter.ipstack_api_key.arn - }, { name = "CHECKOUT_PUBLIC_KEY" valueFrom = data.aws_ssm_parameter.checkout_public_key.arn @@ -74,6 +74,22 @@ locals { name = "CHECKOUT_SECRET_KEY" valueFrom = data.aws_ssm_parameter.checkout_private_key.arn }, + { + name = "CHECKOUT_WEBHOOK_SECRET_KEY" + valueFrom = data.aws_ssm_parameter.checkout_webhook_secret.arn + }, + { + name = "PERSONA_API_KEY" + valueFrom = data.aws_ssm_parameter.persona_api_key.arn + }, + { + name = "PERSONA_WEBHOOK_SECRET_KEY" + valueFrom = data.aws_ssm_parameter.persona_webhook_secret.arn + }, + { + name = "SLACK_WEBHOOK_URL" + valueFrom = data.aws_ssm_parameter.slack_webhook_url.arn + }, { name = "OWLRACLE_API_KEY" valueFrom = data.aws_ssm_parameter.owlracle_api_key.arn @@ -160,13 +176,17 @@ locals { name = "COINGECKO_API_URL" value = "https://api.coingecko.com/api/v3/" }, + { + name = "COINCAP_API_URL" + value = "https://api.coincap.io/v2/" + }, { name = "FINGERPRINT_API_URL" value = "https://api.fpjs.io/" }, { name = "BASE_URL" - value = "https://string-api.dev.string-api.xyz/" + value = "https://api.dev.string-api.xyz/" }, { name = "UNIT21_ENV" @@ -177,42 +197,26 @@ locals { value = "string" }, { - name = "CHECKOUT_ENV" - value = local.env - }, - { - name = "DD_LOGS_ENABLED" - value = "true" + name = "TEAM_PHONE_NUMBERS" + value = "+12062000000" }, { - name = "DD_LOGS_CONFIG_CONTAINER_COLLECT_ALL" - value = "true" + name = "AUTH_EMAIL_ADDRESS" + value = "auth@string.xyz" }, { - name = "DD_SERVICE" - value = local.service_name + name = "RECEIPTS_EMAIL_ADDRESS" + value = "receipts@stringxyz.com" }, { - name = "DD_VERSION" - value = var.versioning + name = "UNIT21_RTR_URL" + value ="https://rtr.sandbox2.unit21.com/evaluate" }, { - name = "DD_ENV" + name = "CHECKOUT_ENV" value = local.env }, - { - name = "DD_APM_ENABLED" - value = "true" - }, - { - name = "DD_SITE" - value = "datadoghq.com" - }, - { - name = "ECS_FARGATE" - value = "true" - } - ], + ] logConfiguration = { logDriver = "awsfirelens" secretOptions = [{ @@ -221,9 +225,9 @@ locals { }] options = { Name = "datadog" - "dd_service" = "${local.service_name}" + "dd_service" = local.service_name "Host" = "http-intake.logs.datadoghq.com" - "dd_source" = "${local.service_name}" + "dd_source" = local.service_name "dd_message_key" = "log" "dd_tags" = "project:${local.service_name}" "TLS" = "on" @@ -244,6 +248,20 @@ locals { protocol = "tcp", containerPort = 8126 } + ], + environment = [ + { + name = "DD_SERVICE" + value = local.service_name + }, + { + name = "DD_VERSION" + value = var.versioning + }, + { + name = "DD_ENV" + value = local.env + } ] }, { diff --git a/infra/internal/dev/.terraform.lock.hcl b/infra/internal/dev/.terraform.lock.hcl index afce80b2..a15f8b30 100644 --- a/infra/internal/dev/.terraform.lock.hcl +++ b/infra/internal/dev/.terraform.lock.hcl @@ -5,6 +5,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "4.37.0" constraints = "4.37.0" hashes = [ + "h1:LFWMFPtcsxlzbzNlR5XQNfO9/teX2pD60XYycSU4gjQ=", "h1:fLTymOb7xIdMkjQU1VDzPA5s+d2vNLZ2shpcFPF7KaY=", "zh:12c2eb60cb1eb0a41d1afbca6fc6f0eed6ca31a12c51858f951a9e71651afbe0", "zh:1e17482217c39a12e930e71fd2c9af8af577bec6736b184674476ebcaad28477", diff --git a/infra/prod/.terraform.lock.hcl b/infra/prod/.terraform.lock.hcl index afce80b2..57b349b1 100644 --- a/infra/prod/.terraform.lock.hcl +++ b/infra/prod/.terraform.lock.hcl @@ -5,6 +5,8 @@ provider "registry.terraform.io/hashicorp/aws" { version = "4.37.0" constraints = "4.37.0" hashes = [ + "h1:LFWMFPtcsxlzbzNlR5XQNfO9/teX2pD60XYycSU4gjQ=", + "h1:RQ6CqIhVwJQ0EMeNCH0y9ztLlJalC6QO/CyqmeQUUJ4=", "h1:fLTymOb7xIdMkjQU1VDzPA5s+d2vNLZ2shpcFPF7KaY=", "zh:12c2eb60cb1eb0a41d1afbca6fc6f0eed6ca31a12c51858f951a9e71651afbe0", "zh:1e17482217c39a12e930e71fd2c9af8af577bec6736b184674476ebcaad28477", diff --git a/infra/prod/ecs.tf b/infra/prod/ecs.tf index 94532273..e411ca15 100644 --- a/infra/prod/ecs.tf +++ b/infra/prod/ecs.tf @@ -1,5 +1,5 @@ -resource "aws_ecs_cluster" "cluster" { - name = local.cluster_name +data "aws_ecs_cluster" "cluster" { + cluster_name = local.cluster_name } resource "aws_ecs_task_definition" "task_definition" { @@ -31,7 +31,7 @@ resource "aws_ecs_service" "ecs_service" { name = local.service_name task_definition = local.service_name desired_count = local.desired_task_count - cluster = aws_ecs_cluster.cluster.name + cluster = data.aws_ecs_cluster.cluster.cluster_name launch_type = "FARGATE" network_configuration { @@ -61,7 +61,7 @@ resource "aws_ecs_service" "ecs_service" { resource "aws_appautoscaling_target" "ecs_target" { max_capacity = 20 min_capacity = 1 - resource_id = "service/${aws_ecs_cluster.cluster.name}/${aws_ecs_service.ecs_service.name}" + resource_id = "service/${data.aws_ecs_cluster.cluster.cluster_name}/${aws_ecs_service.ecs_service.name}" scalable_dimension = "ecs:service:DesiredCount" service_namespace = "ecs" } diff --git a/infra/prod/iam_roles.tf b/infra/prod/iam_roles.tf index 16c62482..9b07b609 100644 --- a/infra/prod/iam_roles.tf +++ b/infra/prod/iam_roles.tf @@ -36,15 +36,16 @@ data "aws_iam_policy_document" "task_policy" { resources = [ data.aws_ssm_parameter.datadog.arn, data.aws_ssm_parameter.evm_private_key.arn, + data.aws_ssm_parameter.jwt_secret.arn, data.aws_ssm_parameter.string_encryption_secret.arn, data.aws_ssm_parameter.string_internal_id.arn, data.aws_ssm_parameter.string_wallet_id.arn, data.aws_ssm_parameter.string_bank_id.arn, data.aws_ssm_parameter.string_platform_id.arn, - data.aws_ssm_parameter.ipstack_api_key.arn, data.aws_ssm_parameter.unit21_api_key.arn, data.aws_ssm_parameter.checkout_public_key.arn, data.aws_ssm_parameter.checkout_private_key.arn, + data.aws_ssm_parameter.checkout_signature_key.arn, data.aws_ssm_parameter.owlracle_api_key.arn, data.aws_ssm_parameter.owlracle_api_secret.arn, data.aws_ssm_parameter.db_password.arn, @@ -57,7 +58,9 @@ data "aws_iam_policy_document" "task_policy" { data.aws_ssm_parameter.sendgrid_api_key.arn, data.aws_ssm_parameter.twilio_sms_sid.arn, data.aws_ssm_parameter.twilio_account_sid.arn, - data.aws_ssm_parameter.twilio_auth_token.arn + data.aws_ssm_parameter.twilio_auth_token.arn, + data.aws_ssm_parameter.slack_webhook_url.arn, + data.aws_ssm_parameter.team_phone_numbers.arn ] } diff --git a/infra/prod/ssm.tf b/infra/prod/ssm.tf index f03a0f13..66502d0c 100644 --- a/infra/prod/ssm.tf +++ b/infra/prod/ssm.tf @@ -26,18 +26,14 @@ data "aws_ssm_parameter" "string_platform_id" { name = "string-placeholder-platform-id" } -data "aws_ssm_parameter" "user_jwt_secret" { - name = "user-jwt-secret" +data "aws_ssm_parameter" "jwt_secret" { + name = "api-jwt-secret" } data "aws_ssm_parameter" "unit21_api_key" { name = "unit21-api-key" } -data "aws_ssm_parameter" "ipstack_api_key" { - name = "ipstack-api-key" -} - data "aws_ssm_parameter" "checkout_public_key" { name = "checkout-public-key" } @@ -46,6 +42,10 @@ data "aws_ssm_parameter" "checkout_private_key" { name = "checkout-private-key" } +data "aws_ssm_parameter" "checkout_signature_key" { + name = "checkout-signature-key" +} + data "aws_ssm_parameter" "owlracle_api_key" { name = "owlracle-api-key" } @@ -98,6 +98,14 @@ data "aws_ssm_parameter" "redis_host_url" { name = "redis-host-url" } +data "aws_ssm_parameter" "slack_webhook_url" { + name = "slack-webhook-url" +} + +data "aws_ssm_parameter" "team_phone_numbers" { + name = "team-phone-numbers" +} + data "aws_kms_key" "kms_key" { key_id = "alias/main-kms-key" } diff --git a/infra/prod/variables.tf b/infra/prod/variables.tf index 1fef5cc0..977f9112 100644 --- a/infra/prod/variables.tf +++ b/infra/prod/variables.tf @@ -1,10 +1,10 @@ locals { - cluster_name = "string-core" + cluster_name = "core" env = "prod" - service_name = "string-api" + service_name = "api" domain = "api.string-api.xyz" container_port = "3000" - origin_id = "string-api" + origin_id = "api" desired_task_count = "1" db_port = "5432" redis_port = "6379" @@ -15,7 +15,7 @@ locals { variable "versioning" { type = string - default = "v1.0.0-alpha" + default = "v1.0.0" } locals { @@ -26,7 +26,11 @@ locals { essential = true, dockerLabels = { "com.datadoghq.ad.instances" : "[{\"host\":\"%%host%%\"}]", - "com.datadoghq.ad.check_names" : "[\"${local.service_name}\"]", + "com.datadoghq.ad.logs" : "[{\"service\":\"${local.service_name}\"}]", + "com.datadoghq.ad.check_names" :local.service_name, + "com.datadoghq.tags.env": local.env, + "com.datadoghq.tags.service": local.service_name, + "com.datadoghq.tags.version": var.versioning }, portMappings = [ { containerPort = 3000 } @@ -36,6 +40,10 @@ locals { name = "EVM_PRIVATE_KEY" valueFrom = data.aws_ssm_parameter.evm_private_key.arn }, + { + name = "JWT_SECRET_KEY" + valueFrom = data.aws_ssm_parameter.jwt_secret.arn + }, { name = "STRING_ENCRYPTION_KEY" valueFrom = data.aws_ssm_parameter.string_encryption_secret.arn @@ -52,18 +60,10 @@ locals { name = "STRING_BANK_ID" valueFrom = data.aws_ssm_parameter.string_bank_id.arn }, - { - name = "STRING_PLACEHOLDER_PLATFORM_ID" - valueFrom = data.aws_ssm_parameter.string_platform_id.arn - }, { name = "UNIT21_API_KEY" valueFrom = data.aws_ssm_parameter.unit21_api_key.arn }, - { - name = "IPSTACK_API_KEY" - valueFrom = data.aws_ssm_parameter.ipstack_api_key.arn - }, { name = "CHECKOUT_PUBLIC_KEY" valueFrom = data.aws_ssm_parameter.checkout_public_key.arn @@ -72,6 +72,10 @@ locals { name = "CHECKOUT_SECRET_KEY" valueFrom = data.aws_ssm_parameter.checkout_private_key.arn }, + { + name = "WEBHOOK_SECRET_KEY" + valueFrom = data.aws_ssm_parameter.checkout_signature_key.arn + }, { name = "OWLRACLE_API_KEY" valueFrom = data.aws_ssm_parameter.owlracle_api_key.arn @@ -123,6 +127,14 @@ locals { { name = "REDIS_PASSWORD", valuefrom = data.aws_ssm_parameter.redis_auth_token.arn + }, + { + name = "SLACK_WEBHOOK_URL" + valueFrom = data.aws_ssm_parameter.slack_webhook_url.arn + }, + { + name = "TEAM_PHONE_NUMBERS" + valuefrom = data.aws_ssm_parameter.team_phone_numbers.arn } ] environment = [ @@ -158,6 +170,10 @@ locals { name = "COINGECKO_API_URL" value = "https://api.coingecko.com/api/v3/" }, + { + name = "COINCAP_API_URL" + value = "https://api.coincap.io/v2/" + }, { name = "FINGERPRINT_API_URL" value = "https://api.fpjs.io/" @@ -170,21 +186,25 @@ locals { name = "UNIT21_ENV" value = "api.prod2" }, - { - name = "CHECKOUT_ENV" - value = local.env + { + name = "AUTH_EMAIL_ADDRESS" + value = "auth@string.xyz" }, { - name = "UNIT21_ORG_NAME" - value = "string" + name = "RECEIPTS_EMAIL_ADDRESS" + value = "receipts@stringxyz.com" }, { - name = "DD_LOGS_ENABLED" - value = "true" + name = "UNIT21_RTR_URL" + value ="https://rtr.prod2.unit21.com/evaluate" }, { - name = "DD_LOGS_CONFIG_CONTAINER_COLLECT_ALL" - value = "true" + name = "CHECKOUT_ENV" + value = local.env + }, + { + name = "UNIT21_ORG_NAME" + value = "string" }, { name = "DD_SERVICE" @@ -198,14 +218,6 @@ locals { name = "DD_ENV" value = local.env }, - { - name = "DD_APM_ENABLED" - value = "true" - }, - { - name = "DD_SITE" - value = "datadoghq.com" - }, { name = "ECS_FARGATE" value = "true" @@ -219,9 +231,9 @@ locals { }] options = { Name = "datadog" - "dd_service" = "${local.service_name}" + "dd_service" = local.service_name "Host" = "http-intake.logs.datadoghq.com" - "dd_source" = "${local.service_name}" + "dd_source" = local.service_name "dd_message_key" = "log" "dd_tags" = "project:${local.service_name}" "TLS" = "on" diff --git a/infra/sandbox/.terraform.lock.hcl b/infra/sandbox/.terraform.lock.hcl new file mode 100644 index 00000000..f36afb80 --- /dev/null +++ b/infra/sandbox/.terraform.lock.hcl @@ -0,0 +1,23 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "4.37.0" + constraints = "4.37.0" + hashes = [ + "h1:LFWMFPtcsxlzbzNlR5XQNfO9/teX2pD60XYycSU4gjQ=", + "h1:RQ6CqIhVwJQ0EMeNCH0y9ztLlJalC6QO/CyqmeQUUJ4=", + "zh:12c2eb60cb1eb0a41d1afbca6fc6f0eed6ca31a12c51858f951a9e71651afbe0", + "zh:1e17482217c39a12e930e71fd2c9af8af577bec6736b184674476ebcaad28477", + "zh:1e8163c3d871bbd54c189bf2fe5e60e556d67fa399e4c88c8e6ee0834525dc33", + "zh:399c41a3e096fd75d487b98b1791f7cea5bd38567ac4e621c930cb67ec45977c", + "zh:40d4329eef2cc130e4cbed7a6345cb053dd258bf6f5f8eb0f8ce777ae42d5a01", + "zh:625db5fa75638d543b418be7d8046c4b76dc753d9d2184daa0faaaaebc02d207", + "zh:7785c8259f12b45d19fa5abdac6268f3b749fe5a35c8be762c27b7a634a4952b", + "zh:8a7611f33cc6422799c217ec2eeb79c779035ef05331d12505a6002bc48582f0", + "zh:9188178235a73c829872d2e82d88ac6d334d8bb01433e9be31615f1c1633e921", + "zh:994895b57bf225232a5fa7422e6ab87d8163a2f0605f54ff6a18cdd71f0aeadf", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:b57de6903ef30c9f22d38d595d64b4f92a89ea717b65782e1f44f57020ce8b1f", + ] +} diff --git a/infra/sandbox/Makefile b/infra/sandbox/Makefile new file mode 100644 index 00000000..850cb8b0 --- /dev/null +++ b/infra/sandbox/Makefile @@ -0,0 +1,12 @@ +export +AWS_PROFILE=dev-string + +init: + terraform init +plan: + terraform plan +apply: + terraform apply + +destroy: + terraform destroy diff --git a/infra/sandbox/alb.tf b/infra/sandbox/alb.tf new file mode 100644 index 00000000..51dc457c --- /dev/null +++ b/infra/sandbox/alb.tf @@ -0,0 +1,103 @@ +module "alb_acm" { + source = "../acm" + domain_name = "api.${local.root_domain}" + aws_region = "us-west-2" + zone_id = data.aws_route53_zone.root.zone_id + tags = { + Name = "${local.env}-api-${local.root_domain}-alb" + Environment = local.env + } +} + +resource "aws_alb" "alb" { + name = "${local.env}-${local.service_name}-alb" + drop_invalid_header_fields = true + security_groups = [aws_security_group.ecs_alb_https_sg.id] + subnets = data.terraform_remote_state.vpc.outputs.public_subnets + + tags = { + Name = "${local.env}-${local.service_name}-alb" + Environment = local.env + } + + lifecycle { + create_before_destroy = true + } +} + + resource "aws_ssm_parameter" "alb" { + name = "${local.env}-${local.service_name}-alb-arn" + value = aws_alb.alb.arn + type = "String" + } + + resource "aws_ssm_parameter" "alb_dns" { + name = "${local.env}-${local.service_name}-alb-dns" + value = aws_alb.alb.dns_name + type = "String" + } + +resource "aws_alb_target_group" "ecs_task_target_group" { + name = "${local.env}-${local.service_name}-tg" + port = local.container_port + vpc_id = data.terraform_remote_state.vpc.outputs.id + target_type = "ip" + protocol = "HTTP" + + lifecycle { + create_before_destroy = true + } + + health_check { + path = "/heartbeat" + protocol = "HTTP" + matcher = "200" + interval = 60 + timeout = 30 + unhealthy_threshold = "3" + healthy_threshold = "3" + } + + tags = { + Name = "${local.env}-${local.service_name}-tg" + } +} + +resource "aws_alb_listener" "alb_https_listener" { + load_balancer_arn = aws_alb.alb.arn + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS-1-2-2017-01" + certificate_arn = module.alb_acm.arn + + lifecycle { + create_before_destroy = true + } + + default_action { + type = "forward" + target_group_arn = aws_alb_target_group.ecs_task_target_group.arn + } +} + + resource "aws_ssm_parameter" "alb_listerner" { + name = "${local.env}-${local.service_name}-alb-listener-arn" + value = aws_alb_listener.alb_https_listener.arn + type = "String" + } + +resource "aws_alb_listener_rule" "ecs_alb_listener_rule" { + listener_arn = aws_alb_listener.alb_https_listener.arn + priority = 100 + action { + type = "forward" + target_group_arn = aws_alb_target_group.ecs_task_target_group.arn + } + + condition { + host_header { + values = ["api.${local.root_domain}"] + } + } +} + diff --git a/infra/sandbox/backend.tf b/infra/sandbox/backend.tf new file mode 100644 index 00000000..e427d0bb --- /dev/null +++ b/infra/sandbox/backend.tf @@ -0,0 +1,35 @@ +locals { + remote_state_bucket = "dev-string-terraform-state" + backend_region = "us-west-2" + vpc_remote_state_key = "vpc.tfstate" +} + +provider "aws" { + region = "us-west-2" +} + +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = "4.37.0" + } + } + + backend "s3" { + encrypt = true + key = "sandbox-api.tfstate" + bucket = "dev-string-terraform-state" + dynamodb_table = "dev-string-terraform-state-lock" + region = "us-west-2" + } +} + +data "terraform_remote_state" "vpc" { + backend = "s3" + config = { + region = local.backend_region + bucket = local.remote_state_bucket + key = local.vpc_remote_state_key + } +} diff --git a/infra/sandbox/cloudfront.tf b/infra/sandbox/cloudfront.tf new file mode 100644 index 00000000..866d81e3 --- /dev/null +++ b/infra/sandbox/cloudfront.tf @@ -0,0 +1,51 @@ +resource "aws_cloudfront_distribution" "this" { + enabled = true + is_ipv6_enabled = true + aliases = ["api.${local.root_domain}"] + + origin { + domain_name = aws_alb.alb.dns_name + origin_id = local.origin_id + custom_origin_config { + http_port = 80 + https_port = 443 + origin_protocol_policy = "https-only" + origin_ssl_protocols = ["TLSv1", "TLSv1.1", "TLSv1.2"] + } + } + + restrictions { + geo_restriction { + restriction_type = "none" + locations = [] + } + } + + + default_cache_behavior { + target_origin_id = local.origin_id + compress = true + allowed_methods = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT"] + cached_methods = ["GET", "HEAD", "OPTIONS"] + + forwarded_values { + query_string = true + headers = ["X-Forwarded-For", "Host", "X-Api-Key"] + cookies { + forward = "all" + } + } + + viewer_protocol_policy = "redirect-to-https" + min_ttl = 0 + default_ttl = 60 + max_ttl = 120 + } + + viewer_certificate { + ssl_support_method = "sni-only" + acm_certificate_arn = module.acm.arn + minimum_protocol_version = "TLSv1.1_2016" + cloudfront_default_certificate = false + } +} diff --git a/infra/sandbox/domain.tf b/infra/sandbox/domain.tf new file mode 100644 index 00000000..84f29152 --- /dev/null +++ b/infra/sandbox/domain.tf @@ -0,0 +1,25 @@ +data "aws_route53_zone" "root" { + name = local.root_domain +} + +resource "aws_route53_record" "domain" { + name = "api" + type = "A" + zone_id = data.aws_route53_zone.root.zone_id + alias { + evaluate_target_health = false + name = aws_cloudfront_distribution.this.domain_name + zone_id = aws_cloudfront_distribution.this.hosted_zone_id + } +} + +module "acm" { + source = "../acm" + domain_name = "api.${local.root_domain}" + aws_region = "us-east-1" + zone_id = data.aws_route53_zone.root.zone_id + tags = { + Environment = local.env + Name = "${local.env}-api.${local.root_domain}" + } +} diff --git a/infra/sandbox/ecs.tf b/infra/sandbox/ecs.tf new file mode 100644 index 00000000..e2df47ab --- /dev/null +++ b/infra/sandbox/ecs.tf @@ -0,0 +1,59 @@ +resource "aws_ecs_cluster" "cluster" { + name = local.cluster_name +} + +resource "aws_ecs_task_definition" "task_definition" { + container_definitions = local.task_definition + family = "${local.env}-${local.service_name}" + cpu = local.cpu + memory = local.memory + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + execution_role_arn = aws_iam_role.task_ecs_role.arn + task_role_arn = aws_iam_role.task_ecs_role.arn +} + +resource "aws_ecr_repository" "repo" { + name = "${local.env}-${local.service_name}" + image_tag_mutability = "IMMUTABLE" + + image_scanning_configuration { + scan_on_push = true + } + + tags = { + Environment = local.env + Name = "${local.env}-${local.service_name}" + } +} + +resource "aws_ecs_service" "ecs_service" { + name = local.service_name + task_definition = "${local.env}-${local.service_name}" + desired_count = local.desired_task_count + cluster = aws_ecs_cluster.cluster.name + launch_type = "FARGATE" + + network_configuration { + subnets = data.terraform_remote_state.vpc.outputs.public_subnets + security_groups = [aws_security_group.ecs_task_sg.id] + assign_public_ip = true + } + + load_balancer { + container_name = local.service_name + container_port = local.container_port + target_group_arn = aws_alb_target_group.ecs_task_target_group.arn + } + + depends_on = [ + aws_alb_listener_rule.ecs_alb_listener_rule, + aws_iam_role_policy.task_ecs_policy, + aws_ecs_task_definition.task_definition + ] + + tags = { + Environment = local.env + Name = "${local.env}-${local.service_name}" + } +} diff --git a/infra/sandbox/iam_roles.tf b/infra/sandbox/iam_roles.tf new file mode 100644 index 00000000..5abb5d54 --- /dev/null +++ b/infra/sandbox/iam_roles.tf @@ -0,0 +1,82 @@ +data "aws_iam_policy_document" "ecs_task_policy" { + statement { + sid = "AllowECSAndTaskAssumeRole" + actions = ["sts:AssumeRole"] + effect = "Allow" + principals { + type = "Service" + identifiers = ["ecs.amazonaws.com", "ecs-tasks.amazonaws.com"] + } + } +} + +resource "aws_iam_role" "task_ecs_role" { + name = "${local.env}-${local.service_name}-task-ecs-role" + assume_role_policy = data.aws_iam_policy_document.ecs_task_policy.json +} + +data "aws_iam_policy_document" "task_policy" { + statement { + sid = "AllowReadToResourcesInListToTask" + effect = "Allow" + actions = [ + "ecs:*", + "ecr:*" + ] + + resources = ["*"] + } + + statement { + sid = "AllowAccessToSSM" + effect = "Allow" + actions = [ + "ssm:GetParameters" + ] + resources = [ + data.aws_ssm_parameter.datadog.arn, + data.aws_ssm_parameter.evm_private_key.arn, + data.aws_ssm_parameter.string_encryption_secret.arn, + data.aws_ssm_parameter.jwt_secret.arn, + data.aws_ssm_parameter.string_internal_id.arn, + data.aws_ssm_parameter.string_wallet_id.arn, + data.aws_ssm_parameter.string_bank_id.arn, + data.aws_ssm_parameter.unit21_api_key.arn, + data.aws_ssm_parameter.checkout_public_key.arn, + data.aws_ssm_parameter.checkout_private_key.arn, + data.aws_ssm_parameter.checkout_webhook_secret.arn, + data.aws_ssm_parameter.persona_api_key.arn, + data.aws_ssm_parameter.persona_webhook_secret.arn, + data.aws_ssm_parameter.owlracle_api_key.arn, + data.aws_ssm_parameter.owlracle_api_secret.arn, + data.aws_ssm_parameter.db_password.arn, + data.aws_ssm_parameter.db_username.arn, + data.aws_ssm_parameter.db_name.arn, + data.aws_ssm_parameter.db_host.arn, + data.aws_ssm_parameter.redis_host_url.arn, + data.aws_ssm_parameter.redis_auth_token.arn, + data.aws_ssm_parameter.fingerprint_api_key.arn, + data.aws_ssm_parameter.sendgrid_api_key.arn, + data.aws_ssm_parameter.twilio_sms_sid.arn, + data.aws_ssm_parameter.twilio_account_sid.arn, + data.aws_ssm_parameter.twilio_auth_token.arn, + data.aws_ssm_parameter.team_phone_numbers.arn, + data.aws_ssm_parameter.slack_webhook_url.arn + ] + } + + statement { + sid = "AllowDecrypt" + effect = "Allow" + actions = [ + "kms:Decrypt" + ] + resources = [data.aws_kms_key.kms_key.arn] + } +} + +resource "aws_iam_role_policy" "task_ecs_policy" { + name = "${local.env}-${local.service_name}-task-ecs-policy" + role = aws_iam_role.task_ecs_role.id + policy = data.aws_iam_policy_document.task_policy.json +} diff --git a/infra/sandbox/security_group.tf b/infra/sandbox/security_group.tf new file mode 100644 index 00000000..4f50d81a --- /dev/null +++ b/infra/sandbox/security_group.tf @@ -0,0 +1,84 @@ +resource "aws_security_group" "ecs_alb_https_sg" { + name = "${local.env}-${local.service_name}-alb-https-sg" + description = "Security group for ALB to cluster" + vpc_id = data.terraform_remote_state.vpc.outputs.id + + ingress { + from_port = 443 + to_port = 443 + protocol = "TCP" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + from_port = 0 + to_port = 0 + protocol = -1 + cidr_blocks = ["0.0.0.0/0"] + } + + lifecycle { + create_before_destroy = true + } + + tags = { + Name = "${local.env}-${local.service_name}-alb-https-sg" + Environment = local.env + } +} + +resource "aws_security_group" "ecs_task_sg" { + name = "${local.env}-${local.service_name}-task-sg" + vpc_id = data.terraform_remote_state.vpc.outputs.id + ingress { + from_port = local.container_port + to_port = local.container_port + protocol = "TCP" + cidr_blocks = [data.terraform_remote_state.vpc.outputs.cidr_block] + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + lifecycle { + create_before_destroy = true + } + + tags = { + Name = "${local.env}-${local.service_name}-task-sg" + environment = local.env + } +} + +# Give access to DB through security group rule +data "aws_security_group" "rds" { + name = "${local.env}-string-write-master-client-rds" + vpc_id = data.terraform_remote_state.vpc.outputs.id +} + +data "aws_security_group" "redis" { + name = "redis-client-redis" + vpc_id = data.terraform_remote_state.vpc.outputs.id +} + +resource "aws_security_group_rule" "rds_to_ecs" { + type = "ingress" + protocol = "TCP" + from_port = local.db_port + to_port = local.db_port + source_security_group_id = aws_security_group.ecs_task_sg.id + security_group_id = data.aws_security_group.rds.id +} + +resource "aws_security_group_rule" "redis_to_ecs" { + type = "ingress" + protocol = "TCP" + from_port = local.redis_port + to_port = local.redis_port + source_security_group_id = aws_security_group.ecs_task_sg.id + security_group_id = data.aws_security_group.redis.id +} diff --git a/infra/sandbox/ssm.tf b/infra/sandbox/ssm.tf new file mode 100644 index 00000000..6d4ea058 --- /dev/null +++ b/infra/sandbox/ssm.tf @@ -0,0 +1,115 @@ +data "aws_ssm_parameter" "datadog" { + name = "datadog-key" +} + +data "aws_ssm_parameter" "evm_private_key" { + name = "string-encrypted-sk" +} + +data "aws_ssm_parameter" "string_encryption_secret" { + name = "string-encryption-secret" +} + +data "aws_ssm_parameter" "string_internal_id" { + name = "string-internal-id" +} + +data "aws_ssm_parameter" "string_wallet_id" { + name = "string-wallet-id" +} + +data "aws_ssm_parameter" "string_bank_id" { + name = "string-bank-id" +} + +data "aws_ssm_parameter" "jwt_secret" { + name = "sandbox-api-jwt-secret" +} + +data "aws_ssm_parameter" "unit21_api_key" { + name = "unit21-api-key" +} + +data "aws_ssm_parameter" "checkout_public_key" { + name = "dev-checkout-public-key" +} + +data "aws_ssm_parameter" "checkout_private_key" { + name = "dev-checkout-private-key" +} + +data "aws_ssm_parameter" "checkout_webhook_secret" { + name = "checkout-signature-key" +} + +data "aws_ssm_parameter" "persona_api_key" { + name = "persona-api-key" +} + +data "aws_ssm_parameter" "persona_webhook_secret" { + name = "persona-signature-key" +} + +data "aws_ssm_parameter" "owlracle_api_key" { + name = "dev-owlracle-api-key" +} + +data "aws_ssm_parameter" "fingerprint_api_key" { + name = "fingerprint-api-key" +} + +data "aws_ssm_parameter" "sendgrid_api_key" { + name = "sendgrid-api-key" +} + +data "aws_ssm_parameter" "twilio_sms_sid" { + name = "twilio-sms-sid" +} + +data "aws_ssm_parameter" "twilio_account_sid" { + name = "twilio-account-sid" +} + +data "aws_ssm_parameter" "twilio_auth_token" { + name = "twilio-auth-token" +} + +data "aws_ssm_parameter" "owlracle_api_secret" { + name = "dev-owlracle-api-secret" +} + +data "aws_ssm_parameter" "db_password" { + name = "${local.env}-rds-pg-db-password" +} + +data "aws_ssm_parameter" "db_username" { + name = "${local.env}-rds-pg-db-username" +} + +data "aws_ssm_parameter" "db_name" { + name = "${local.env}-rds-pg-db-name" +} + +data "aws_ssm_parameter" "db_host" { + name = "${local.env}-write-db-host-url" +} + +data "aws_ssm_parameter" "redis_auth_token" { + name = "redis-auth-token" +} + +data "aws_ssm_parameter" "redis_host_url" { + name = "redis-host-url" +} + +data "aws_ssm_parameter" "team_phone_numbers" { + name = "team-phone-numbers" +} + +data "aws_ssm_parameter" "slack_webhook_url" { + name = "slack-webhook-url" +} + +data "aws_kms_key" "kms_key" { + key_id = "alias/main-kms-key" +} diff --git a/infra/sandbox/variables.tf b/infra/sandbox/variables.tf new file mode 100644 index 00000000..a3819a5a --- /dev/null +++ b/infra/sandbox/variables.tf @@ -0,0 +1,285 @@ +locals { + cluster_name = "sandbox-core" + env = "sandbox" + service_name = "api" + root_domain = "sandbox.string-api.xyz" + container_port = "3000" + origin_id = "sandbox-api" + desired_task_count = "2" + db_port = "5432" + redis_port = "6379" + memory = 512 + cpu = 256 + region = "us-west-2" +} + +variable "versioning" { + type = string + default = "v2.1.2" +} + +locals { + task_definition = jsonencode([ + { + name = local.service_name + image = "${aws_ecr_repository.repo.repository_url}:${var.versioning}" + essential = true, + dockerLabels = { + "com.datadoghq.ad.instances" : "[{\"host\":\"%%host%%\"}]", + "com.datadoghq.ad.logs" : "[{\"service\":\"${local.service_name}\"}]", + "com.datadoghq.ad.check_names" :local.service_name, + "com.datadoghq.tags.env": local.env, + "com.datadoghq.tags.service": local.service_name, + "com.datadoghq.tags.version": var.versioning + }, + portMappings = [ + { + containerPort = 3000 + } + ], + secrets = [ + { + name = "EVM_PRIVATE_KEY" + valueFrom = data.aws_ssm_parameter.evm_private_key.arn + }, + { + name = "JWT_SECRET_KEY" + valueFrom = data.aws_ssm_parameter.jwt_secret.arn + }, + { + name = "STRING_ENCRYPTION_KEY" + valueFrom = data.aws_ssm_parameter.string_encryption_secret.arn + }, + { + name = "STRING_INTERNAL_ID" + valueFrom = data.aws_ssm_parameter.string_internal_id.arn + }, + { + name = "STRING_WALLET_ID" + valueFrom = data.aws_ssm_parameter.string_wallet_id.arn + }, + { + name = "STRING_BANK_ID" + valueFrom = data.aws_ssm_parameter.string_bank_id.arn + }, + { + name = "UNIT21_API_KEY" + valueFrom = data.aws_ssm_parameter.unit21_api_key.arn + }, + { + name = "CHECKOUT_PUBLIC_KEY" + valueFrom = data.aws_ssm_parameter.checkout_public_key.arn + }, + { + name = "CHECKOUT_SECRET_KEY" + valueFrom = data.aws_ssm_parameter.checkout_private_key.arn + }, + { + name = "CHECKOUT_WEBHOOK_SECRET_KEY" + valueFrom = data.aws_ssm_parameter.checkout_webhook_secret.arn + }, + { + name = "PERSONA_API_KEY" + valueFrom = data.aws_ssm_parameter.persona_api_key.arn + }, + { + name = "PERSONA_WEBHOOK_SECRET_KEY" + valueFrom = data.aws_ssm_parameter.persona_webhook_secret.arn + }, + { + name = "SLACK_WEBHOOK_URL" + valueFrom = data.aws_ssm_parameter.slack_webhook_url.arn + }, + { + name = "OWLRACLE_API_KEY" + valueFrom = data.aws_ssm_parameter.owlracle_api_key.arn + }, + { + name = "OWLRACLE_API_SECRET" + valueFrom = data.aws_ssm_parameter.owlracle_api_secret.arn + }, + { + name = "FINGERPRINT_API_KEY" + valueFrom = data.aws_ssm_parameter.fingerprint_api_key.arn + }, + { + name = "SENDGRID_API_KEY" + valuefrom = data.aws_ssm_parameter.sendgrid_api_key.arn + }, + { + name = "TWILIO_ACCOUNT_SID" + valueFrom = data.aws_ssm_parameter.twilio_account_sid.arn + }, + { + name = "TWILIO_SMS_SID" + valuefrom = data.aws_ssm_parameter.twilio_sms_sid.arn + }, + { + name = "TWILIO_AUTH_TOKEN" + valuefrom = data.aws_ssm_parameter.twilio_auth_token.arn + }, + { + name = "DB_USERNAME" + valueFrom = data.aws_ssm_parameter.db_username.arn + }, + { + name = "DB_PASSWORD" + valueFrom = data.aws_ssm_parameter.db_password.arn + }, + { + name = "DB_HOST" + valueFrom = data.aws_ssm_parameter.db_host.arn + }, + { + name = "DB_NAME" + valueFrom = data.aws_ssm_parameter.db_name.arn + }, + { + name = "REDIS_HOST", + valuefrom = data.aws_ssm_parameter.redis_host_url.arn + }, + { + name = "REDIS_PASSWORD", + valuefrom = data.aws_ssm_parameter.redis_auth_token.arn + }, + { + name = "SLACK_WEBHOOK_URL" + valueFrom = data.aws_ssm_parameter.slack_webhook_url.arn + }, + { + name = "TEAM_PHONE_NUMBERS" + valuefrom = data.aws_ssm_parameter.team_phone_numbers.arn + } + ] + environment = [ + { + name = "PORT" + value = local.container_port + }, + { + name = "REDIS_PORT" + value = local.redis_port + }, + { + name = "DB_PORT", + value = local.db_port + }, + { + name = "ENV" + value = local.env + }, + { + name = "AWS_REGION" + value = local.region + }, + { + name = "AWS_KMS_KEY_ID" + value = data.aws_kms_key.kms_key.key_id + }, + { + name = "OWLRACLE_API_URL" + value = "https://api.owlracle.info/v3/" + }, + { + name = "COINGECKO_API_URL" + value = "https://api.coingecko.com/api/v3/" + }, + { + name = "COINCAP_API_URL" + value = "https://api.coincap.io/v2/" + }, + { + name = "FINGERPRINT_API_URL" + value = "https://api.fpjs.io/" + }, + { + name = "BASE_URL" + value = "https://api.sandbox.string-api.xyz/" + }, + { + name = "UNIT21_ENV" + value = "sandbox2-api" + }, + { + name = "UNIT21_ORG_NAME" + value = "string" + }, + { + name = "AUTH_EMAIL_ADDRESS" + value = "auth@string.xyz" + }, + { + name = "RECEIPTS_EMAIL_ADDRESS" + value = "receipts@stringxyz.com" + }, + { + name = "UNIT21_RTR_URL" + value ="https://rtr.sandbox2.unit21.com/evaluate" + }, + { + name = "CHECKOUT_ENV" + value = local.env + }, + { + name = "DD_SERVICE" + value = local.service_name + }, + { + name = "DD_VERSION" + value = var.versioning + }, + { + name = "DD_ENV" + value = local.env + }, + { + name = "ECS_FARGATE" + value = "true" + } + ], + logConfiguration = { + logDriver = "awsfirelens" + secretOptions = [{ + name = "apiKey", + valueFrom = data.aws_ssm_parameter.datadog.arn + }] + options = { + Name = "datadog" + "dd_service" = local.service_name + "Host" = "http-intake.logs.datadoghq.com" + "dd_source" = local.service_name + "dd_message_key" = "log" + "dd_tags" = "project:${local.service_name}" + "TLS" = "on" + "provider" = "ecs" + } + } + }, + { + name = "datadog-agent" + image = "public.ecr.aws/datadog/agent:latest" + essential = true + secrets = [{ + name = "DD_API_KEY" + valueFrom = data.aws_ssm_parameter.datadog.arn + }], + portMappings = [{ + hostPort = 8126, + protocol = "tcp", + containerPort = 8126 + } + ] + }, + { + name = "log_router" + image = "public.ecr.aws/aws-observability/aws-for-fluent-bit:stable" + essential = true + firelensConfiguration = { + type = "fluentbit" + options = { + "enable-ecs-log-metadata" = "true" + } + } + } + ]) +} diff --git a/dev.Dockerfile b/local.Dockerfile similarity index 83% rename from dev.Dockerfile rename to local.Dockerfile index 77154435..64e28c8f 100644 --- a/dev.Dockerfile +++ b/local.Dockerfile @@ -14,8 +14,8 @@ RUN go mod download # install the air tool RUN go install github.com/cosmtrek/air@latest -# install goose for db migrations -RUN go install github.com/pressly/goose/v3/cmd/goose@latest +# install the dlv debugger +RUN go install github.com/go-delve/delve/cmd/dlv@latest # will run from an entrypoint.sh file COPY entrypoint.sh /entrypoint.sh diff --git a/migrations/0001_string-user_platform_asset_network.sql b/migrations/0001_string-user_platform_asset_network.sql deleted file mode 100644 index d816e3c3..00000000 --- a/migrations/0001_string-user_platform_asset_network.sql +++ /dev/null @@ -1,138 +0,0 @@ -------------------------------------------------------------------------- --- +goose Up - -------------------------------------------------------------------------- --- create extension for UUID -------------------------------------------- -CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; - -------------------------------------------------------------------------- --- UPDATE_UPDATED_AT_COLUMN() ------------------------------------------- -------------------------------------------------------------------------- --- +goose StatementBegin -CREATE OR REPLACE FUNCTION update_updated_at_column() - RETURNS TRIGGER AS -$$ -BEGIN - NEW.updated_at = now(); - RETURN NEW; -END; -$$ language 'plpgsql'; --- +goose StatementEnd -------------------------------------------------------------------------- - -------------------------------------------------------------------------- --- STRING_USER ---------------------------------------------------------- -CREATE TABLE string_user ( - id UUID PRIMARY KEY NOT NULL DEFAULT UUID_GENERATE_V4(), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - type TEXT NOT NULL, -- enum: to be defined at struct level in Go - status TEXT NOT NULL, -- enum: to be defined at struct level in Go - tags JSONB DEFAULT '{}'::JSONB, -- platforms should be listed in the tags - first_name TEXT DEFAULT '', -- name in separate table? - middle_name TEXT DEFAULT '', - last_name TEXT DEFAULT '' -); - -CREATE OR REPLACE TRIGGER update_string_user_updated_at - BEFORE UPDATE - ON string_user - FOR EACH ROW -EXECUTE PROCEDURE update_updated_at_column(); - -------------------------------------------------------------------------- --- PLATFORM ------------------------------------------------------------- -CREATE TABLE platform ( - id UUID PRIMARY KEY NOT NULL DEFAULT UUID_GENERATE_V4(), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - type TEXT NOT NULL, -- enum: to be defined at struct level in Go - status TEXT NOT NULL, -- enum: to be defined at struct level in Go - name TEXT DEFAULT '', - api_key TEXT DEFAULT '', - authentication TEXT DEFAULT '' --enum [email, phone, wallet] -); -CREATE OR REPLACE TRIGGER update_platform_updated_at - BEFORE UPDATE - ON platform - FOR EACH ROW -EXECUTE PROCEDURE update_updated_at_column(); - -------------------------------------------------------------------------- --- NETWORK -------------------------------------------------------------- -CREATE TABLE network ( - id UUID PRIMARY KEY NOT NULL DEFAULT UUID_GENERATE_V4(), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - name TEXT NOT NULL, - network_id TEXT DEFAULT '', -- might actually be big.Int - chain_id TEXT NOT NULL, -- might actually be big.Int - gas_token_id TEXT DEFAULT '', -- INDEX CREATED BELOW - gas_oracle TEXT DEFAULT '', -- the name of the network in oracle (i.e. in owlracle) - rpc_url TEXT DEFAULT '', -- The RPC used to access the network (ie "https://mainnet.infura.io/v3") - explorer_url TEXT DEFAULT '' -- The Block Explorer URL used to view transactions and entities in the browser -); -CREATE OR REPLACE TRIGGER update_network_updated_at - BEFORE UPDATE - ON network - FOR EACH ROW -EXECUTE PROCEDURE update_updated_at_column(); - -------------------------------------------------------------------------- --- ASSET ---------------------------------------------------------------- -CREATE TABLE asset ( -- We will write sql commands to add/update these in bulk. - id UUID PRIMARY KEY NOT NULL DEFAULT UUID_GENERATE_V4(), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - name TEXT NOT NULL, - description TEXT DEFAULT '', - decimals INT DEFAULT 0, - is_crypto BOOLEAN NOT NULL, - network_id UUID REFERENCES network (id) DEFAULT NULL, - value_oracle TEXT DEFAULT '' -- the name of the asset in oracle (i.e. in coingecko). -); - -CREATE OR REPLACE TRIGGER update_asset_updated_at - BEFORE UPDATE - ON asset - FOR EACH ROW -EXECUTE PROCEDURE update_updated_at_column(); - -CREATE INDEX network_gas_token_id_fk ON network (gas_token_id); - - -------------------------------------------------------------------------- --- +goose Down - -------------------------------------------------------------------------- --- ASSET ---------------------------------------------------------------- -DROP TRIGGER IF EXISTS update_asset_updated_at ON asset; -DROP INDEX IF EXISTS network_gas_token_id_fk; -DROP TABLE IF EXISTS asset; - -------------------------------------------------------------------------- --- NETWORK -------------------------------------------------------------- -DROP TRIGGER IF EXISTS update_network_updated_at ON network; -DROP TABLE IF EXISTS network; - -------------------------------------------------------------------------- --- PLATFORM ------------------------------------------------------------- -DROP TRIGGER IF EXISTS update_platform_updated_at ON platform; -DROP TABLE IF EXISTS platform; - -------------------------------------------------------------------------- --- STRING_USER ---------------------------------------------------------- -DROP TRIGGER IF EXISTS update_string_user_updated_at ON string_user; -DROP TABLE IF EXISTS string_user; - -------------------------------------------------------------------------- --- UPDATE_UPDATED_AT_COLUMN() ------------------------------------------- -DROP FUNCTION IF EXISTS update_updated_at_column; - -------------------------------------------------------------------------- --- UUID EXTENSION ------------------------------------------------------- -DROP EXTENSION IF EXISTS "uuid-ossp"; \ No newline at end of file diff --git a/migrations/0002_user-to-platform_device_contact_location_instrument.sql b/migrations/0002_user-to-platform_device_contact_location_instrument.sql deleted file mode 100644 index 4315b060..00000000 --- a/migrations/0002_user-to-platform_device_contact_location_instrument.sql +++ /dev/null @@ -1,131 +0,0 @@ -------------------------------------------------------------------------- --- +goose Up - -------------------------------------------------------------------------- --- USER_PLATFORM -------------------------------------------------------- -CREATE TABLE user_platform ( - user_id UUID REFERENCES string_user (id), - platform_id UUID REFERENCES platform (id) -); - -CREATE UNIQUE INDEX user_platform_user_id_platform_id_idx ON user_platform(user_id, platform_id); - -------------------------------------------------------------------------- --- DEVICE --------------------------------------------------------------- -CREATE TABLE device ( - id UUID PRIMARY KEY NOT NULL DEFAULT UUID_GENERATE_V4(), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_used_at TIMESTAMP WITH TIME ZONE NOT NULL, - validated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - type TEXT DEFAULT '', -- enum: to be defined at struct level in Go - description TEXT DEFAULT '', - fingerprint TEXT DEFAULT '', - ip_addresses TEXT[] DEFAULT NULL, - user_id UUID NOT NULL REFERENCES string_user (id) -); - -CREATE OR REPLACE TRIGGER update_device_updated_at - BEFORE UPDATE - ON device - FOR EACH ROW -EXECUTE PROCEDURE update_updated_at_column(); - -CREATE UNIQUE INDEX device_fingerprint_id_idx ON device(fingerprint, user_id); -------------------------------------------------------------------------- --- CONTACT --------------------------------------------------------------- -CREATE TABLE contact ( - id UUID PRIMARY KEY NOT NULL DEFAULT UUID_GENERATE_V4(), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - last_authenticated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - validated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - type TEXT NOT NULL, -- enum: [phone, email, etc...] to be defined at struct level in Go - status TEXT DEFAULT '', -- enum: [primary, inactive] to be defined at struct level in Go - data TEXT DEFAULT '', -- the contact information - user_id UUID NOT NULL REFERENCES string_user (id) -); - -CREATE OR REPLACE TRIGGER update_contact_updated_at - BEFORE UPDATE - ON contact - FOR EACH ROW -EXECUTE PROCEDURE update_updated_at_column(); - -------------------------------------------------------------------------- --- LOCATION --------------------------------------------------------------- -CREATE TABLE location ( - id UUID PRIMARY KEY NOT NULL DEFAULT UUID_GENERATE_V4(), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - type TEXT DEFAULT '', - status TEXT NOT NULL, -- enum: - tags JSONB DEFAULT '{}'::JSONB, - building_number TEXT DEFAULT '', - unit_number TEXT DEFAULT '', - street_name TEXT DEFAULT '', - city TEXT DEFAULT '', - state TEXT DEFAULT '', - postal_code TEXT DEFAULT '', - country TEXT DEFAULT '' -- ISO 3166-1 standard -); - -CREATE OR REPLACE TRIGGER update_location_updated_at - BEFORE UPDATE - ON location - FOR EACH ROW -EXECUTE PROCEDURE update_updated_at_column(); - -------------------------------------------------------------------------- --- INSTRUMENT --------------------------------------------------------------- -CREATE TABLE instrument ( - id UUID PRIMARY KEY NOT NULL DEFAULT UUID_GENERATE_V4(), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - type TEXT NOT NULL, -- enum: includes crypto wallet - status TEXT NOT NULL, -- enum: - tags JSONB DEFAULT '{}'::JSONB, - network TEXT NOT NULL, -- enum: - public_key TEXT DEFAULT '', - last_4 TEXT DEFAULT '', - user_id UUID REFERENCES string_user (id), -- instrument can be null in the circumstance that a user sends an asset to an unknown wallet - location_id UUID REFERENCES location (id) DEFAULT NULL -); - -CREATE OR REPLACE TRIGGER update_instrument_updated_at - BEFORE UPDATE - ON instrument - FOR EACH ROW -EXECUTE PROCEDURE update_updated_at_column(); - -------------------------------------------------------------------------- --- +goose Down - -------------------------------------------------------------------------- --- INSTRUMENT ----------------------------------------------------------- -DROP TRIGGER IF EXISTS update_instrument_updated_at ON instrument; -DROP TABLE IF EXISTS instrument; - -------------------------------------------------------------------------- --- LOCATION ----------------------------------------------------------- -DROP TRIGGER IF EXISTS update_location_updated_at ON location; -DROP TABLE IF EXISTS location; - -------------------------------------------------------------------------- --- CONTACT -------------------------------------------------------------- -DROP TRIGGER IF EXISTS update_contact_updated_at ON contact; -DROP TABLE IF EXISTS contact; - -------------------------------------------------------------------------- --- DEVICE --------------------------------------------------------------- -DROP TRIGGER IF EXISTS update_device_updated_at ON device; -DROP INDEX IF EXISTS device_fingerprint_id_idx; -DROP TABLE IF EXISTS device; - -------------------------------------------------------------------------- --- USER_PLATFORM ----------------------------------------------------- -DROP TABLE IF EXISTS user_platform; diff --git a/migrations/0003_contact-to-platform_device-to-instrument_tx-leg_transaction.sql b/migrations/0003_contact-to-platform_device-to-instrument_tx-leg_transaction.sql deleted file mode 100644 index 99d8a319..00000000 --- a/migrations/0003_contact-to-platform_device-to-instrument_tx-leg_transaction.sql +++ /dev/null @@ -1,111 +0,0 @@ -------------------------------------------------------------------------- --- +goose Up - -------------------------------------------------------------------------- --- CONTACT_PLATFORM ----------------------------------------------------- -CREATE TABLE contact_platform ( - contact_id UUID REFERENCES contact (id), - platform_id UUID REFERENCES platform (id) -); - -CREATE UNIQUE INDEX contact_platform_contact_id_platform_id_idx ON contact_platform(contact_id, platform_id); - -------------------------------------------------------------------------- --- DEVICE_INSTRUMENT ---------------------------------------------------- -CREATE TABLE device_instrument ( - device_id UUID REFERENCES device (id), - instrument_id UUID REFERENCES instrument (id) -); - -CREATE UNIQUE INDEX device_instrument_device_id_instrument_id_idx ON device_instrument(device_id, instrument_id); - -------------------------------------------------------------------------- --- TX_LEG --------------------------------------------------------------- -CREATE TABLE tx_leg ( - id UUID PRIMARY KEY NOT NULL DEFAULT UUID_GENERATE_V4(), -- unique identifier for the TX leg which we generate - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, -- initial timestamp of creation - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, -- timestamp whenever this tx_leg is updated - deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - -- TIMESTAMP: - -- For CC send = auth timestamp - -- For CC receive = capture timestamp - -- For EVM send = txid generation timestamp - -- For EVM receive = txid confirmation timestamp - timestamp TIMESTAMP WITH TIME ZONE, - amount TEXT DEFAULT '', -- Quantity of financial asset in Asset wei - value TEXT DEFAULT '', -- Quantity of financial asset in [USD wei (6 digits precision)] - asset_id UUID REFERENCES asset (id), -- ID of table entry of Asset (for USD, ETH, AVAX etc) - -- USER_ID: - -- For CC send = id that correlates to the end-user in our user table - -- For CC receive = id that correlates to the STRING entry in our user table - -- For EVM send = id that correlates to the STRING entry in our user table - -- For EVM receive = NULL if recipient is not an end user, or id that corrlates to end-user recipient in our user table - user_id UUID REFERENCES string_user (id) DEFAULT NULL, - -- INSTRUMENT_ID: - -- For CC send = id that correlates to the users credit card in our Instrument table - -- For CC receive = id that correlates to STRING bank account in our Instrument table - -- For EVM send = id that correlates to our wallet address in our Instrument table - -- For EVM receive = id that correlates to RECIPIENTS wallet in our Instrument table, regardless of if they are a user - instrument_id UUID NOT NULL REFERENCES instrument (id) -); - -CREATE OR REPLACE TRIGGER update_tx_leg_updated_at - BEFORE UPDATE - ON tx_leg - FOR EACH ROW -EXECUTE PROCEDURE update_updated_at_column(); - -------------------------------------------------------------------------- --- TRANSACTION ---------------------------------------------------------- -CREATE TABLE transaction ( - id UUID PRIMARY KEY NOT NULL DEFAULT UUID_GENERATE_V4(), -- unique idenfier for the transaction which we generate - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, -- time transaction entry was initially created - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, -- time transaction entry was last updated, including adding tags - deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - type TEXT DEFAULT '', -- enum [fiat-to-crypto, crypto-to-fiat] (these types may eventually have subtypes, ie NFT_MINT) - status TEXT DEFAULT '', --enum State of the transaction in the /transact endpoint - tags JSONB DEFAULT '{}'::JSONB, -- Empty but will be used for Unit21. These are key-val pairs for flagging transactions - device_id UUID REFERENCES device (id), -- id that correlates to end-users device in our Device table, we get the data from fingerprint.com -- TODO: Get this with fingerprint integration - ip_address TEXT DEFAULT '', -- we get this data from fingerprint.com, whatever is being used at time of transaction - platform_id UUID REFERENCES platform (id), -- id that correlates to CUSTOMER in our Platform table (ie gamefi.xyz) - transaction_hash TEXT DEFAULT '', -- EVM/network TX ID after it is generated by executor - network_id UUID NOT NULL REFERENCES network (id), -- id that correlates to the Network (Chain) in our Network table - network_fee TEXT DEFAULT '', -- The true amount of gas in wei that was used to facilitate the transaction - contract_params TEXT[] DEFAULT NULL, -- The parameters passed into the EVM function call passed into the endpoint - contract_func TEXT DEFAULT '', -- The declaration of the EVM function call passed into the endpoint - transaction_amount TEXT DEFAULT '', -- The cost in native token wei of the transaction passed into the endpoint - origin_tx_leg_id UUID REFERENCES tx_leg (id) DEFAULT NULL, -- id that correlates to the Leg of the CC send in our Leg table - receipt_tx_leg_id UUID REFERENCES tx_leg (id) DEFAULT NULL, -- id that correlates to the Leg of the CC receive in our Leg table - response_tx_leg_id UUID REFERENCES tx_leg (id) DEFAULT NULL, -- id that correlates to the leg of the EVM send in our Leg table - destination_tx_leg_id UUID REFERENCES tx_leg (id) DEFAULT NULL, -- id that correlates to the leg of the EVM receive in our Leg table - processing_fee TEXT DEFAULT '', -- CC (ie checkout) processing fee in (USD/???) wei - processing_fee_asset UUID REFERENCES asset (id), -- CC processing fee asset id in asset table (ie id for USD) - string_fee TEXT DEFAULT '' -- Amount in (USD!) wei that we charged to facilitate this transaction, likely always will be USD -); - -CREATE OR REPLACE TRIGGER update_transaction_updated_at - BEFORE UPDATE - ON transaction - FOR EACH ROW -EXECUTE PROCEDURE update_updated_at_column(); - -------------------------------------------------------------------------- --- +goose Down - -------------------------------------------------------------------------- --- TRANSACTION ---------------------------------------------------------- -DROP TRIGGER IF EXISTS update_transaction_updated_at ON transaction; -DROP TABLE IF EXISTS transaction; - -------------------------------------------------------------------------- --- TX_LEG --------------------------------------------------------------- -DROP TRIGGER IF EXISTS update_tx_leg_updated_at ON tx_leg; -DROP TABLE IF EXISTS tx_leg; - -------------------------------------------------------------------------- --- DEVICE_INSTRUMENT ---------------------------------------------------- -DROP TABLE IF EXISTS device_instrument; - -------------------------------------------------------------------------- --- CONTACT_PLATFORM ----------------------------------------------------- -DROP TABLE IF EXISTS contact_platform; \ No newline at end of file diff --git a/migrations/0004_auth_key.sql b/migrations/0004_auth_key.sql deleted file mode 100644 index 9295d939..00000000 --- a/migrations/0004_auth_key.sql +++ /dev/null @@ -1,23 +0,0 @@ --- +goose Up -CREATE TABLE auth_strategy ( - id UUID PRIMARY KEY NOT NULL DEFAULT UUID_GENERATE_V4(), - type TEXT NOT NULL DEFAULT '', -- this auth strat is email/password type - contact_id UUID REFERENCES contact(id) DEFAULT NULL, - status TEXT NOT NULL DEFAULT 'pending', -- temp use for API approval - data TEXT NOT NULL, -- the hashed password - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL -); - -CREATE OR REPLACE TRIGGER update_auth_strategy_updated_at - BEFORE UPDATE - ON auth_strategy - FOR EACH ROW -EXECUTE PROCEDURE update_updated_at_column(); - -CREATE INDEX auth_strategy_status_idx ON auth_strategy(status); --- +goose Down -DROP INDEX IF EXISTS auth_strategy_status_idx; -DROP TRIGGER IF EXISTS update_auth_strategy_updated_at ON auth_strategy; -DROP TABLE IF EXISTS auth_strategy; \ No newline at end of file diff --git a/migrations/0005_user-to-platform_contact-to-platform_device-to-instrument_platform.sql b/migrations/0005_user-to-platform_contact-to-platform_device-to-instrument_platform.sql deleted file mode 100644 index d3bc9927..00000000 --- a/migrations/0005_user-to-platform_contact-to-platform_device-to-instrument_platform.sql +++ /dev/null @@ -1,103 +0,0 @@ -------------------------------------------------------------------------- --- +goose Up - -------------------------------------------------------------------------- --- USER_TO_PLATFORM ----------------------------------------------------- -ALTER TABLE user_platform - RENAME TO user_to_platform; - -DROP INDEX IF EXISTS user_platform_user_id_platform_id_idx; - -CREATE UNIQUE INDEX user_to_platform_user_id_platform_id_idx ON user_to_platform(user_id, platform_id); - - -------------------------------------------------------------------------- --- CONTACT_TO_PLATFORM -------------------------------------------------- -ALTER TABLE contact_platform - RENAME TO contact_to_platform; - -DROP INDEX IF EXISTS contact_platform_contact_id_platform_id_idx; - -CREATE UNIQUE INDEX contact_to_platform_contact_id_platform_id_idx ON contact_to_platform(contact_id, platform_id); - - -------------------------------------------------------------------------- --- DEVICE_TO_INSTRUMENT ------------------------------------------------- -ALTER TABLE device_instrument - RENAME TO device_to_instrument; - -DROP INDEX IF EXISTS device_instrument_device_id_instrument_id_idx; - -CREATE UNIQUE INDEX device_to_instrument_device_id_instrument_id_idx ON device_to_instrument(device_id, instrument_id); - - -------------------------------------------------------------------------- --- PLATFORM ------------------------------------------------------------- -ALTER TABLE platform - DROP COLUMN IF EXISTS type, - DROP COLUMN IF EXISTS status, - DROP COLUMN IF EXISTS name, - DROP COLUMN IF EXISTS api_key, - DROP COLUMN IF EXISTS authentication, - ADD COLUMN activated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, -- for activating prod users - ADD COLUMN name TEXT NOT NULL DEFAULT '', - ADD COLUMN description TEXT DEFAULT '', - ADD COLUMN domains TEXT[] DEFAULT '{}'::TEXT[], -- define which domains can make calls to API (web-to-API) - ADD COLUMN ip_addresses TEXT[] DEFAULT '{}'::TEXT[]; -- define which API ips can make calls (API-to-API) - -------------------------------------------------------------------------- --- TRANSACTION ------------------------------------------------------------- -ALTER TABLE transaction - ADD COLUMN payment_code TEXT DEFAULT ''; - -------------------------------------------------------------------------- --- +goose Down - -------------------------------------------------------------------------- --- TRANSACTION ------------------------------------------------------------- -ALTER TABLE transaction - DROP COLUMN IF EXISTS payment_code; - -------------------------------------------------------------------------- --- PLATFORM ------------------------------------------------------------- -ALTER TABLE platform - DROP COLUMN IF EXISTS activated_at, - DROP COLUMN IF EXISTS name, - DROP COLUMN IF EXISTS description, - DROP COLUMN IF EXISTS domains, - DROP COLUMN IF EXISTS ip_addresses, - ADD COLUMN type TEXT DEFAULT '', -- enum: to be defined at struct level in Go - ADD COLUMN status TEXT DEFAULT '', -- enum: to be defined at struct level in Go - ADD COLUMN name TEXT DEFAULT '', - ADD COLUMN api_key TEXT DEFAULT '', - ADD COLUMN authentication TEXT DEFAULT ''; --enum [email, phone, wallet] - - -------------------------------------------------------------------------- --- USER_PLATFORM ----------------------------------------------------- -ALTER TABLE user_to_platform - RENAME TO user_platform; - -DROP INDEX IF EXISTS user_to_platform_user_id_platform_id_idx; - -CREATE UNIQUE INDEX user_platform_user_id_platform_id_idx ON user_platform(user_id, platform_id); - - -------------------------------------------------------------------------- --- CONTACT_PLATFORM -------------------------------------------------- -ALTER TABLE contact_to_platform - RENAME TO contact_platform; - -DROP INDEX IF EXISTS contact_to_platform_contact_id_platform_id_idx; - -CREATE UNIQUE INDEX contact_platform_contact_id_platform_id_idx ON contact_platform(contact_id, platform_id); - - -------------------------------------------------------------------------- --- DEVICE_INSTRUMENT ------------------------------------------------- -ALTER TABLE device_to_instrument - RENAME TO device_instrument; - -DROP INDEX IF EXISTS device_to_instrument_device_id_instrument_id_idx; - -CREATE UNIQUE INDEX device_instrument_device_id_instrument_id_idx ON device_instrument(device_id, instrument_id); \ No newline at end of file diff --git a/migrations/0006_platform-member_member-to-platform_member-role_member-to-role_member-invite_apikey.sql b/migrations/0006_platform-member_member-to-platform_member-role_member-to-role_member-invite_apikey.sql deleted file mode 100644 index e340d880..00000000 --- a/migrations/0006_platform-member_member-to-platform_member-role_member-to-role_member-invite_apikey.sql +++ /dev/null @@ -1,103 +0,0 @@ -------------------------------------------------------------------------- --- +goose Up - -------------------------------------------------------------------------- --- PLATFORM_MEMBER ------------------------------------------------------ -CREATE TABLE platform_member ( - id UUID PRIMARY KEY NOT NULL DEFAULT UUID_GENERATE_V4(), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - email TEXT NOT NULL, - password TEXT DEFAULT '', -- how do we maintain this? - name TEXT DEFAULT '' -); - -------------------------------------------------------------------------- --- PLATFORM_MEMBER ------------------------------------------------------ -CREATE TABLE member_to_platform ( - member_id UUID REFERENCES platform_member (id), - platform_id UUID REFERENCES platform (id) -); - -CREATE UNIQUE INDEX member_to_platform_platform_id_member_id_idx ON member_to_platform(platform_id, member_id); - -------------------------------------------------------------------------- --- MEMBER_ROLE ---------------------------------------------------------- -CREATE TABLE member_role ( - id UUID PRIMARY KEY NOT NULL DEFAULT UUID_GENERATE_V4(), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - name TEXT NOT NULL -); - -------------------------------------------------------------------------- --- MEMBER_TO_ROLE ------------------------------------------------------- -CREATE TABLE member_to_role ( - member_id UUID REFERENCES platform_member (id), - role_id UUID REFERENCES member_role (id) -); - -CREATE UNIQUE INDEX member_to_role_member_id_role_id_idx ON member_to_role(member_id, role_id); - -------------------------------------------------------------------------- --- MEMBER_INVITE -------------------------------------------------------- -CREATE TABLE member_invite ( - id UUID PRIMARY KEY NOT NULL DEFAULT UUID_GENERATE_V4(), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - expired_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - accepted_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - email TEXT NOT NULL, - name TEXT DEFAULT '', - invited_by UUID REFERENCES platform_member (id) DEFAULT NULL, - platform_id UUID REFERENCES platform (id), - role_id UUID REFERENCES member_role (id) -); - -------------------------------------------------------------------------- --- APIKEY --------------------------------------------------------------- -CREATE TABLE apikey ( - id UUID PRIMARY KEY NOT NULL DEFAULT UUID_GENERATE_V4(), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, - deactivated_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, - type TEXT NOT NULL, -- [public,private] for now all public? - data TEXT NOT NULL, -- the key itself - description TEXT DEFAULT '', - created_by UUID REFERENCES platform_member (id), - platform_id UUID REFERENCES platform (id) -); - - -------------------------------------------------------------------------- --- +goose Down - -------------------------------------------------------------------------- --- APIKEY --------------------------------------------------------------- -DROP TABLE IF EXISTS apikey; - -------------------------------------------------------------------------- --- MEMBER_INVITE -------------------------------------------------------- -DROP TABLE IF EXISTS member_invite; - -------------------------------------------------------------------------- --- MEMBER_TO_ROLE ------------------------------------------------------- -DROP TABLE IF EXISTS member_to_role; - -------------------------------------------------------------------------- --- MEMBER_ROLE ---------------------------------------------------------- -DROP TABLE IF EXISTS member_role; - -------------------------------------------------------------------------- --- PLATFORM_TO_MEMBER --------------------------------------------------- -DROP TABLE IF EXISTS member_to_platform; - -------------------------------------------------------------------------- --- PLATFORM_MEMBER ------------------------------------------------------ -DROP TABLE IF EXISTS platform_member; - - - diff --git a/pkg/internal/checkout/checkout.go b/pkg/internal/checkout/checkout.go new file mode 100644 index 00000000..369e1cf1 --- /dev/null +++ b/pkg/internal/checkout/checkout.go @@ -0,0 +1,212 @@ +package checkout + +import ( + "errors" + "math/rand" + "strconv" + "time" + + "github.com/String-xyz/go-lib/v2/common" + instruments "github.com/checkout/checkout-sdk-go/instruments/nas" + "github.com/checkout/checkout-sdk-go/nas" + "github.com/checkout/checkout-sdk-go/payments" + "github.com/checkout/checkout-sdk-go/tokens" + "github.com/rs/zerolog/log" + + "github.com/String-xyz/string-api/config" +) + +type Checkout struct { + ckoAPI *nas.Api + Payment Payments + Customer Customers +} + +func New() *Checkout { + client := defaultAPI() + return &Checkout{ + Payment: Payments{client}, + Customer: Customers{client}, + } +} + +func (p Payments) Authorize(source Source, request PaymentRequest) (*PaymentResponse, error) { + switch source.GetType() { + case payments.TokenSource: + token := source.(TokenSource) + return p.AuthorizeWithToken(token, request) + case payments.IdSource: + id := source.(IdSource) + return p.AuthorizeWithId(id, request) + } + return nil, common.StringError(errors.New("invalid source type, please provide either token or id type")) +} + +func (p Payments) AuthorizeWithToken(token TokenSource, request PaymentRequest) (*PaymentResponse, error) { + request.Source = tokenToSource(token) + resp, err := p.authorizing(request) + if err != nil { + log.Err(err).Msg("internal checkout error while authorizing payment with token") + return nil, err + } + return resp, nil +} + +// AuthorizeWithId authorizes a payment with an id(instrument) type and returns a PaymentResponse and an error if any. +func (p Payments) AuthorizeWithId(id IdSource, request PaymentRequest) (*PaymentResponse, error) { + request.Source = idToSource(id) + resp, err := p.authorizing(request) + if err != nil { + log.Err(err).Msg("internal checkout error while authorizing payment with id") + return nil, err + } + return resp, nil +} + +// AuthorizeWithCard authorizes a payment with a card source type and returns a PaymentResponse +// and an error if any. +// PCI compliance is required to use this method. +func (p Payments) AuthorizeWithCard(card CardSource, request PaymentRequest) (*PaymentResponse, error) { + request.Source = cardToSource(card) + resp, err := p.authorizing(request) + if err != nil { + log.Err(err).Msg("internal checkout error while authorizing payment with card") + return nil, err + } + return resp, nil +} + +// AuthorizeWithCustomer authorizes a payment with a customer source type and returns a PaymentResponse and an error if any. +// A default card is required to use this method. +func (p Payments) AuthorizeWithCustomer(request PaymentRequest) (*PaymentResponse, error) { + resp, err := p.authorizing(request) + if err != nil { + log.Err(err).Msg("internal checkout error while authorizing payment with customerId") + return nil, common.StringError(err) + } + return resp, nil +} + +// authorizing authorizes a payment with a payment request and returns a PaymentResponse and an error if any. +func (p Payments) authorizing(request PaymentRequest) (*PaymentResponse, error) { + resp, err := p.client.Payments.RequestPayment(request, nil) + if err != nil { + return nil, common.StringError(err) + } + return resp, nil +} + +// Capture captures a payment with a paymentId and returns a CaptureResponse and an error if any. +// is important to note that the capturing of a payment happens asychronously and the response does not indicate +// a successful capture. The response only indicates that the request was successfully sent to the Checkout API. +// To know if the capture was successful, we need to listen to the webhook event that is sent to the webhook endpoint +func (p Payments) Capture(paymentId string, request CaptureRequest) (*CaptureResponse, error) { + resp, err := p.client.Payments.CapturePayment(paymentId, request, nil) + if err != nil { + return nil, common.StringError(err) + } + return resp, nil +} + +func (p Payments) GetById(paymentId string) (*GetPaymentResponse, error) { + resp, err := p.client.Payments.GetPaymentDetails(paymentId) + if err != nil { + log.Err(err).Msg("internal checkout error while getting payment by id") + return nil, common.StringError(err) + } + return resp, nil +} + +func (c Customers) Create(request CustomerRequest) (*CreateResponse, error) { + resp, err := c.client.Customers.Create(request) + if err != nil { + log.Err(err).Msg("internal checkout error while creating customer") + return nil, common.StringError(err) + } + return resp, nil +} + +// GetById gets a customer by id and returns a GetCustomerResponse and an error if any. +// This method also returns all the instruments associated with the customer. +func (c Customers) GetById(customerId string) (*CustomerResponse, error) { + resp, err := c.client.Customers.Get(customerId) + if err != nil { + log.Err(err).Msg("internal checkout error while getting customer by id") + return nil, common.StringError(err) + } + return resp, nil +} + +// ListInstruments is a convenience method that gets all the instruments associated with a customer. +// It returns InstrumentList and an error if any. +func (c Customers) ListInstruments(customerId string) ([]CardInstrument, error) { + resp, err := c.client.Customers.Get(customerId) + if err != nil { + log.Err(err).Msg("internal checkout error while getting the customers instruments") + return []CardInstrument{}, common.StringError(err) + } + + return hydrateCardInstrument(resp.Instruments), nil +} + +func hydrateCardInstrument(resp []instruments.GetInstrumentResponse) []CardInstrument { + instruments := []CardInstrument{} + for _, instrument := range resp { + card := instrument.GetCardInstrumentResponse + instruments = append(instruments, CardInstrument{ + Id: card.Id, + Last4: card.Last4, + ExpiryMonth: card.ExpiryMonth, + ExpiryYear: card.ExpiryYear, + Scheme: card.Scheme, + Type: string(card.Type), + CardType: string(card.CardType), + Expired: isCardExpired(card.ExpiryMonth, card.ExpiryYear), + }) + } + return instruments +} + +func isCardExpired(expiryMonth int, expiryYear int) bool { + if expiryYear < time.Now().Year() { + return true + } + if expiryYear == time.Now().Year() && expiryMonth < int(time.Now().Month()) { + return true + } + return false +} + +// DevCardToken returns a token for a test card +func DevCardToken() string { + failChance, _ := strconv.ParseFloat(config.Var.CARD_FAIL_PROBABILITY, 64) + request := tokens.CardTokenRequest{ + Type: tokens.Card, + Number: getTestCard(failChance), + ExpiryMonth: 10, + ExpiryYear: 2025, + Name: "DEV TOKEN", + CVV: "123", + } + + response, err := defaultAPI().Tokens.RequestCardToken(request) + if err != nil { + log.Err(err).Msg("internal checkout error while getting dev card token") + return "" + } + return response.Token +} + +func getTestCard(failProbability float64) string { + rand.Seed(time.Now().UnixNano()) + // Generate a random number between 0 and 1 + random := rand.Float64() + if random < failProbability { + // Choose a random fail card + index := rand.Intn(len(failCards)) + return failCards[index] + } + // Choose a random success card + index := rand.Intn(len(successCards)) + return successCards[index] +} diff --git a/pkg/internal/checkout/checkout_test.go b/pkg/internal/checkout/checkout_test.go new file mode 100644 index 00000000..525e3aea --- /dev/null +++ b/pkg/internal/checkout/checkout_test.go @@ -0,0 +1,96 @@ +package checkout + +import ( + "fmt" + "testing" + + env "github.com/String-xyz/go-lib/v2/config" + "github.com/String-xyz/string-api/config" + "github.com/checkout/checkout-sdk-go/tokens" +) + +func init() { + err := env.LoadEnv(&config.Var, "../../../.env") + if err != nil { + fmt.Printf("error loading env: %v", err) + } +} + +func generateToken() string { + request := tokens.CardTokenRequest{ + Type: tokens.Card, + Number: "4242424242424242", + ExpiryMonth: 10, + ExpiryYear: 2025, + Name: "Name", + CVV: "123", + } + + response, err := defaultAPI().Tokens.RequestCardToken(request) + if err != nil { + fmt.Println(err) + } + return response.Token +} + +func TestAuthorizeWithToken(t *testing.T) { + p := &Payments{defaultAPI()} + token := TokenSource{Token: generateToken()} + resp, err := p.AuthorizeWithToken(token, PaymentRequest{Capture: true, Currency: "USD", Amount: 1000}) + if err != nil { + t.Errorf("authorizeWithToken returned an error: %v", err) + } + if resp == nil { + t.Errorf("authorizeWithToken returned a nil response") + } +} + +func TestAuthorizeWithCard(t *testing.T) { + card := CardSource{ + Number: "4242424242424242", + ExpiryMonth: 10, + ExpiryYear: 2025, + Name: "Name", + Cvv: "123", + } + p := &Payments{defaultAPI()} + resp, err := p.AuthorizeWithCard(card, PaymentRequest{Capture: true, Currency: "USD", Amount: 1000}) + + if err != nil { + t.Errorf("authorizeWithCard returned an error: %v", err) + } + if resp == nil { + t.Errorf("authorizeWithCard returned a nil response") + } +} + +func TestAuthorizeWithId(t *testing.T) { + p := &Payments{defaultAPI()} + tokenResp, err := p.AuthorizeWithToken(TokenSource{Token: generateToken(), StoreForFutureUse: true}, PaymentRequest{Capture: true, Currency: "USD", Amount: 1000}) + if err != nil { + t.Errorf("authorizeWithToken returned an error: %v", err) + } + instrumentId := tokenResp.Source.ResponseCardSource.Id + resp, err := p.AuthorizeWithId(IdSource{Id: instrumentId}, PaymentRequest{Capture: true, Currency: "USD", Amount: 1000}) + if err != nil { + t.Errorf("authorizeWithId returned an error: %v", err) + } + if resp == nil { + t.Errorf("authorizeWithId returned a nil response") + } +} + +func TestCapture(t *testing.T) { + p := &Payments{defaultAPI()} + tokenResp, err := p.AuthorizeWithToken(TokenSource{Token: generateToken()}, PaymentRequest{Capture: false, Currency: "USD", Amount: 1000}) + if err != nil { + t.Errorf("authorizeWithToken returned an error: %v", err) + } + resp, err := p.Capture(tokenResp.Id, CaptureRequest{Amount: 1000}) + if err != nil { + t.Errorf("capture returned an error: %v", err) + } + if resp == nil { + t.Errorf("capture returned a nil response") + } +} diff --git a/pkg/internal/checkout/config.go b/pkg/internal/checkout/config.go new file mode 100644 index 00000000..0fd4a587 --- /dev/null +++ b/pkg/internal/checkout/config.go @@ -0,0 +1,35 @@ +package checkout + +import ( + cko "github.com/checkout/checkout-sdk-go" + "github.com/checkout/checkout-sdk-go/configuration" + "github.com/checkout/checkout-sdk-go/nas" + "github.com/rs/zerolog/log" + + "github.com/String-xyz/string-api/config" +) + +func ckoEnv() configuration.Environment { + if config.Var.CHECKOUT_ENV == "prod" { + return configuration.Production() + } + + return configuration.Sandbox() +} + +func defaultAPI() *nas.Api { + api, err := cko. + Builder(). + StaticKeys(). + WithPublicKey(config.Var.CHECKOUT_PUBLIC_KEY). + WithSecretKey(config.Var.CHECKOUT_SECRET_KEY). + WithEnvironment(ckoEnv()). // or Environment.PRODUCTION + Build() + + if err != nil { + log.Err(err).Msg("error getting a default Checkout API Client") + return nil + } + + return api +} diff --git a/pkg/internal/checkout/event.go b/pkg/internal/checkout/event.go new file mode 100644 index 00000000..6e438b5c --- /dev/null +++ b/pkg/internal/checkout/event.go @@ -0,0 +1,134 @@ +package checkout + +import ( + "encoding/json" + "time" +) + +type EventType string + +const ( + AuthorizationApprovedEvent EventType = "authorization_approved" + AuthorizationDeclinedEvent EventType = "authorization_declined" + PaymentApprovedEvent EventType = "payment_approved" + PaymentCapturedEvent EventType = "Payment_captured" + PaymentDeclinedEvent EventType = "payment_declined" + PaymentRefundedEvent EventType = "payment_refunded" + PaymentaReturedEvent EventType = "payment_returned" + PaymentPendingEvent EventType = "payment_pending" + PaymentVoidedEvent EventType = "payment_voided" +) + +type WebhookEvent struct { + Id string `json:"id,omitempty"` + Type EventType `json:"type,omitempty"` + CreatedOn string `json:"created_on,omitempty"` + Data EventPayload `json:"data,omitempty"` +} + +type AuthorizationEvent struct { + CardId string `json:"card_id,omitempty"` + TransactionId string `json:"transaction_id,omitempty"` + TransactionType string `json:"transaction_type,omitempty"` + TransmissionDateTime time.Time `json:"transmission_date_time,omitempty"` + AuthorizationType string `json:"authorization_type,omitempty"` + TransactionAmount int `json:"transaction_amount,omitempty"` + TransactionCurrency string `json:"transaction_currency,omitempty"` + BillingAmount int `json:"billing_amount,omitempty"` +} + +type PaymentEvent struct { + Id string `json:"id,omitempty"` + ActionId string `json:"action_id,omitempty"` + Reference string `json:"reference,omitempty"` + Amount int `json:"amount,omitempty"` + AuthCode string `json:"auth_code,omitempty"` + Currency string `json:"currency,omitempty"` + PaymentType string `json:"payment_type,omitempty"` + ProccesedOn time.Time `json:"processed_on,omitempty"` + ResponseCode string `json:"response_code,omitempty"` + ResponseSummary string `json:"response_summary,omitempty"` +} + +type AuthorizationApproved AuthorizationEvent + +type AuthorizationDeclined struct { + AuthorizationEvent + DeclineReason string `json:"decline_reason,omitempty"` +} + +type ( + PaymentApproved PaymentEvent + PaymentCaptured PaymentEvent + PaymentDeclined PaymentEvent + PaymentRefunded PaymentEvent + PaymentReturned PaymentEvent + PaymentPending PaymentEvent + PaymentVoided PaymentEvent +) + +type EventPayload interface { + GetType() EventType +} + +func (a AuthorizationApproved) GetType() EventType { + return AuthorizationApprovedEvent +} + +func (a AuthorizationDeclined) GetType() EventType { + return AuthorizationDeclinedEvent +} + +func (a PaymentApproved) GetType() EventType { + return PaymentApprovedEvent +} + +func (a PaymentCaptured) GetType() EventType { + return PaymentCapturedEvent +} + +func (a PaymentDeclined) GetType() EventType { + return PaymentDeclinedEvent +} + +// for the time being only 4 events are supported +// if we need to support more events we need to add them here +func (e *WebhookEvent) UnmarshalJSON(data []byte) error { + type Alias WebhookEvent + alias := struct { + *Alias + // RawData lets us delay parsing the data field until we know the type + RawData json.RawMessage `json:"data,omitempty"` + }{ + Alias: (*Alias)(e), + } + + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + + switch e.Type { + case AuthorizationApprovedEvent: + var a AuthorizationApproved + err := json.Unmarshal(alias.RawData, &a) + e.Data = a + return err + case AuthorizationDeclinedEvent: + var a AuthorizationDeclined + err := json.Unmarshal(alias.RawData, &a) + e.Data = a + return err + case PaymentApprovedEvent: + var p PaymentApproved + err := json.Unmarshal(alias.RawData, &p) + e.Data = p + return err + case PaymentCapturedEvent: + var p PaymentCaptured + err := json.Unmarshal(alias.RawData, &p) + e.Data = p + return err + } + + return nil +} diff --git a/pkg/internal/checkout/slack.go b/pkg/internal/checkout/slack.go new file mode 100644 index 00000000..294e1049 --- /dev/null +++ b/pkg/internal/checkout/slack.go @@ -0,0 +1,39 @@ +package checkout + +import ( + "bytes" + "encoding/json" + "net/http" + + "github.com/rs/zerolog/log" + + "github.com/String-xyz/string-api/config" +) + +type SlackMessage struct { + Text string `json:"text"` +} + +// This function sends a message to a Slack channel via Incoming Webhooks +func PostToSlack(event EventType) { + webhookURL := config.Var.SLACK_WEBHOOK_URL + msg := SlackMessage{ + Text: "Event of type " + string(event) + " received.", + } + msgBytes, err := json.Marshal(msg) + if err != nil { + log.Error().Msgf("Error marshalling Slack message: %s", err.Error()) + return + } + + resp, err := http.Post(webhookURL, "application/json", bytes.NewBuffer(msgBytes)) + if err != nil { + log.Error().Msgf("Error posting to Slack: %s", err.Error()) + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + log.Error().Msgf("Received non-2xx response code: %d", resp.StatusCode) + } +} diff --git a/pkg/internal/checkout/types.go b/pkg/internal/checkout/types.go new file mode 100644 index 00000000..49858b85 --- /dev/null +++ b/pkg/internal/checkout/types.go @@ -0,0 +1,139 @@ +package checkout + +import ( + ckocommon "github.com/checkout/checkout-sdk-go/common" + "github.com/checkout/checkout-sdk-go/customers" + instruments "github.com/checkout/checkout-sdk-go/instruments/nas" + ckonas "github.com/checkout/checkout-sdk-go/nas" + "github.com/checkout/checkout-sdk-go/payments" + "github.com/checkout/checkout-sdk-go/payments/nas" + "github.com/checkout/checkout-sdk-go/payments/nas/sources" +) + +type ( + Payments struct { + client *ckonas.Api + } + Instruments struct { + client *ckonas.Api + } + Customers struct { + client *ckonas.Api + } + Events struct { + client *ckonas.Api + } +) + +const ( + SourceTypeId = payments.IdSource + SourceTypeToken = payments.TokenSource +) + +type ( + SourceType = payments.SourceType + Customer = ckocommon.CustomerRequest + CustomerRequest = customers.CustomerRequest + CreateResponse = ckocommon.IdResponse + InstrumentList = []instruments.GetInstrumentResponse + CustomerResponse = customers.GetCustomerResponse + PaymentRequest = nas.PaymentRequest + PaymentResponse = nas.PaymentResponse + GetPaymentResponse = nas.GetPaymentResponse + CaptureRequest = nas.CaptureRequest + CaptureResponse = payments.CaptureResponse + PaymentStatus = payments.PaymentStatus +) + +type CardInstrument struct { + Id string `json:"id,omitempty"` + Cvv string `json:"cvv,omitempty"` + Last4 string `json:"last4,omitempty"` + ExpiryMonth int `json:"expiryMonth,omitempty"` + ExpiryYear int `json:"expiryYear,omitempty"` + Type string `json:"type,omitempty"` + CardType string `json:"cardType,omitempty"` + Scheme string `json:"scheme,omitempty"` + Expired bool `json:"expired,omitempty"` +} + +type Source interface { + GetType() payments.SourceType +} + +type BaseSource struct { + Type payments.SourceType +} + +type CardSource struct { + BaseSource + Number string + ExpiryMonth int + ExpiryYear int + Name string + Cvv string + Stored bool +} + +type TokenSource struct { + BaseSource + // The token value returned by the Checkout.com API + Token string + // Wether the token can be used for future payments + // by default this is set to true, but can be set to false + // and if set to false, an instrument(which on this context is called source) will not be included in the response + StoreForFutureUse bool +} + +type IdSource struct { + BaseSource + // the id of the instrument, required. + Id string + // the cvv of the instrument if is a card, optional. + CVV string + // the payment method, only required for ACH, optional. + PaymentMethod string +} + +func cardToSource(card CardSource) Source { + src := sources.NewRequestCardSource() + src.Name = card.Name + src.Number = card.Number + src.Cvv = card.Cvv + src.ExpiryMonth = card.ExpiryMonth + src.ExpiryYear = card.ExpiryYear + return src +} + +func tokenToSource(token TokenSource) Source { + src := sources.NewRequestTokenSource() + src.Token = token.Token + src.StoreForFutureUse = token.StoreForFutureUse + return src +} + +func idToSource(id IdSource) Source { + src := sources.NewRequestIdSource() + src.Id = id.Id + src.Cvv = id.CVV + return src +} + +func (b BaseSource) GetType() payments.SourceType { + return b.Type +} + +// Test Credit Cards Only for Sandbox +var successCards = []string{ + "4242424242424242", + "5436031030606378", + "5305484748800098", + "345678901234564", +} + +var failCards = []string{ + "4644968546281686", + "5355228287185489", + "4546381219393284", + "5355229757805879", +} diff --git a/pkg/internal/common/base64.go b/pkg/internal/common/base64.go index 8477b6b7..c97fc8ee 100644 --- a/pkg/internal/common/base64.go +++ b/pkg/internal/common/base64.go @@ -3,12 +3,14 @@ package common import ( "encoding/base64" "encoding/json" + + libcommon "github.com/String-xyz/go-lib/v2/common" ) func EncodeToBase64(object interface{}) (string, error) { buffer, err := json.Marshal(object) if err != nil { - return "", StringError(err) + return "", libcommon.StringError(err) } return base64.StdEncoding.EncodeToString(buffer), nil } @@ -17,11 +19,11 @@ func DecodeFromBase64[T any](from string) (T, error) { var result *T = new(T) buffer, err := base64.StdEncoding.DecodeString(from) if err != nil { - return *result, StringError(err) + return *result, libcommon.StringError(err) } err = json.Unmarshal(buffer, &result) if err != nil { - return *result, StringError(err) + return *result, libcommon.StringError(err) } return *result, nil } diff --git a/pkg/internal/common/cost.go b/pkg/internal/common/cost.go index b9b34f62..fca08835 100644 --- a/pkg/internal/common/cost.go +++ b/pkg/internal/common/cost.go @@ -1,10 +1,10 @@ package common -func NativeTokenBuffer(chainID uint64) float64 { +func NativeTokenBuffer(chainId uint64) float64 { return 0.05 } -func GasBuffer(chainID uint64) float64 { +func GasBuffer(chainId uint64) float64 { return 0.05 } diff --git a/pkg/internal/common/crypt.go b/pkg/internal/common/crypt.go index 46e49162..baf334c8 100644 --- a/pkg/internal/common/crypt.go +++ b/pkg/internal/common/crypt.go @@ -1,91 +1,31 @@ package common import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" "encoding/base64" - "encoding/json" - "io" - "os" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/string-api/config" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/kms" ) -func Encrypt(object interface{}, secret string) (string, error) { - buffer, err := json.Marshal(object) - if err != nil { - return "", StringError(err) - } - return EncryptString(string(buffer), secret) -} - -func Decrypt[T any](from string, secret string) (T, error) { - var result T - decrypted, err := DecryptString(from, secret) - if err != nil { - return result, StringError(err) - } - err = json.Unmarshal([]byte(decrypted), &result) - if err != nil { - return result, StringError(err) - } - return result, nil -} - -func EncryptString(data string, secret string) (string, error) { - block, err := aes.NewCipher([]byte(secret)) - if err != nil { - return "", StringError(err) - } - plainText := []byte(data) - cipherText := make([]byte, aes.BlockSize+len(plainText)) - iv := cipherText[:aes.BlockSize] - if _, err := io.ReadFull(rand.Reader, iv); err != nil { - return "", StringError(err) - } - cfb := cipher.NewCFBEncrypter(block, iv) - cfb.XORKeyStream(cipherText[aes.BlockSize:], plainText) - return base64.StdEncoding.EncodeToString(cipherText), nil -} - -func DecryptString(data string, secret string) (string, error) { - block, err := aes.NewCipher([]byte(secret)) - if err != nil { - return "", StringError(err) - } - cipherText, err := base64.StdEncoding.DecodeString(data) - if err != nil { - return "", StringError(err) - } - iv := cipherText[:aes.BlockSize] - - cipherText = cipherText[aes.BlockSize:] - - cfb := cipher.NewCFBDecrypter(block, iv) - plainText := make([]byte, len(cipherText)) - cfb.XORKeyStream(plainText, cipherText) - return string(plainText), nil -} - func EncryptBytesToKMS(data []byte) (string, error) { - region := os.Getenv("AWS_REGION") + region := config.Var.AWS_REGION session, err := session.NewSession(&aws.Config{ Region: aws.String(region), }) if err != nil { - return "", StringError(err) + return "", libcommon.StringError(err) } kmsService := kms.New(session) - keyId := os.Getenv("AWS_KMS_KEY_ID") + keyId := config.Var.AWS_KMS_KEY_ID result, err := kmsService.Encrypt(&kms.EncryptInput{ KeyId: aws.String(keyId), Plaintext: data, }) if err != nil { - return "", StringError(err) + return "", libcommon.StringError(err) } return base64.StdEncoding.EncodeToString(result.CiphertextBlob), nil } @@ -93,7 +33,7 @@ func EncryptBytesToKMS(data []byte) (string, error) { func EncryptStringToKMS(data string) (string, error) { res, err := EncryptBytesToKMS([]byte(data)) if err != nil { - return "", StringError(err) + return "", libcommon.StringError(err) } return res, nil } @@ -101,18 +41,18 @@ func EncryptStringToKMS(data string) (string, error) { func DecryptBlobFromKMS(blob string) (string, error) { bytes, err := base64.StdEncoding.DecodeString(blob) if err != nil { - return "", StringError(err) + return "", libcommon.StringError(err) } session, err := session.NewSessionWithOptions(session.Options{ SharedConfigState: session.SharedConfigEnable, }) if err != nil { - return "", StringError(err) + return "", libcommon.StringError(err) } kmsService := kms.New(session) result, err := kmsService.Decrypt(&kms.DecryptInput{CiphertextBlob: bytes}) if err != nil { - return "", StringError(err) + return "", libcommon.StringError(err) } return string(result.Plaintext), nil } diff --git a/pkg/internal/common/crypt_test.go b/pkg/internal/common/crypt_test.go index 3163e6d1..68319027 100644 --- a/pkg/internal/common/crypt_test.go +++ b/pkg/internal/common/crypt_test.go @@ -4,7 +4,10 @@ import ( "testing" "time" - "github.com/joho/godotenv" + libcommon "github.com/String-xyz/go-lib/v2/common" + env "github.com/String-xyz/go-lib/v2/config" + + "github.com/String-xyz/string-api/config" "github.com/stretchr/testify/assert" ) @@ -39,10 +42,10 @@ func TestEncodeDecodeObject(t *testing.T) { func TestEncryptDecryptString(t *testing.T) { str := "this is a string" - strEncrypted, err := EncryptString(str, "secret_encryption_key_0123456789") + strEncrypted, err := libcommon.EncryptString(str, "secret_encryption_key_0123456789") assert.NoError(t, err) - strDecrypted, err := DecryptString(strEncrypted, "secret_encryption_key_0123456789") + strDecrypted, err := libcommon.DecryptString(strEncrypted, "secret_encryption_key_0123456789") assert.NoError(t, err) assert.Equal(t, str, strDecrypted) @@ -54,10 +57,10 @@ func TestEncryptDecryptObject(t *testing.T) { objEncoded, err := EncodeToBase64(obj) assert.NoError(t, err) - objEncrypted, err := EncryptString(objEncoded, "secret_encryption_key_0123456789") + objEncrypted, err := libcommon.EncryptString(objEncoded, "secret_encryption_key_0123456789") assert.NoError(t, err) - objDecrypted, err := DecryptString(objEncrypted, "secret_encryption_key_0123456789") + objDecrypted, err := libcommon.DecryptString(objEncrypted, "secret_encryption_key_0123456789") assert.NoError(t, err) objDecoded, err := DecodeFromBase64[randomObject1](objDecrypted) @@ -68,18 +71,16 @@ func TestEncryptDecryptObject(t *testing.T) { func TestEncryptDecryptUnencoded(t *testing.T) { obj := randomObject1{Timestamp: time.Now().Unix(), Email: "test@test.com", Address: "0xdecafbabe"} - objEncrypted, err := Encrypt(obj, "secret_encryption_key_0123456789") + objEncrypted, err := libcommon.Encrypt(obj, "secret_encryption_key_0123456789") assert.NoError(t, err) - objDecrypted, err := Decrypt[randomObject1](objEncrypted, "secret_encryption_key_0123456789") + objDecrypted, err := libcommon.Decrypt[randomObject1](objEncrypted, "secret_encryption_key_0123456789") assert.NoError(t, err) assert.Equal(t, obj, objDecrypted) } func TestEncryptDecryptKMS(t *testing.T) { - err := godotenv.Load("../../../.env") - assert.NoError(t, err) - + env.LoadEnv(&config.Var, "../../../.env") obj := "herein lie the secrets of the universe" objEncrypted, err := EncryptStringToKMS(obj) assert.NoError(t, err) diff --git a/pkg/internal/common/error.go b/pkg/internal/common/error.go deleted file mode 100644 index 3e136d0d..00000000 --- a/pkg/internal/common/error.go +++ /dev/null @@ -1,24 +0,0 @@ -package common - -import ( - "github.com/pkg/errors" -) - -func StringError(err error, optionalMsg ...string) error { - if err == nil { - return nil - } - - concat := "" - - for _, msgs := range optionalMsg { - concat += msgs + " " - } - - if errors.Cause(err) == nil || errors.Cause(err) == err { - // fmt.Printf("\nWARNING: Error does not implement StackTracer\n") - return errors.Wrap(errors.New(err.Error()), concat) - } - - return errors.Wrap(err, concat) -} diff --git a/pkg/internal/common/evm.go b/pkg/internal/common/evm.go index c62acc0f..e52a5439 100644 --- a/pkg/internal/common/evm.go +++ b/pkg/internal/common/evm.go @@ -8,64 +8,195 @@ import ( "strconv" "strings" - "github.com/ethereum/go-ethereum/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/params" "github.com/lmittmann/w3" "golang.org/x/crypto/sha3" ) +func addressArray(args []string) (set []ethcommon.Address) { + for _, a := range args { + set = append(set, w3.A(a)) + } + return set +} + +func boolArray(args []string) (set []bool) { + for _, b := range args { + set = append(set, b == "true" || b == "TRUE") + } + return set +} + +func stringArray(args []string) (set []string) { + return args +} + +func bytesArray(args []string) (set [][]byte) { + for _, b := range args { + set = append(set, w3.B(b)) + } + return set +} + +func int8Array(args []string) (set []int8) { + for _, i := range args { + v, err := strconv.ParseInt(i, 0, 8) + if err != nil { + panic(err) + } + set = append(set, int8(v)) + } + return set +} + +func uint8Array(args []string) (set []uint8) { + for _, u := range args { + v, err := strconv.ParseUint(u, 0, 8) + if err != nil { + panic(err) + } + set = append(set, uint8(v)) + } + return set +} + +func uint16Array(args []string) (set []uint16) { + for _, u := range args { + v, err := strconv.ParseUint(u, 0, 16) + if err != nil { + panic(err) + } + set = append(set, uint16(v)) + } + return set +} + +func uint32Array(args []string) (set []uint32) { + for _, u := range args { + v, err := strconv.ParseUint(u, 0, 32) + if err != nil { + panic(err) + } + set = append(set, uint32(v)) + } + return set +} + +func int32Array(args []string) (set []int32) { + for _, i := range args { + v, err := strconv.ParseInt(i, 0, 32) + if err != nil { + panic(err) + } + set = append(set, int32(v)) + } + return set +} + +func uint64Array(args []string) (set []uint64) { + for _, u := range args { + v, err := strconv.ParseUint(u, 0, 64) + if err != nil { + panic(err) + } + set = append(set, v) + } + return set +} + +func uint256Array(args []string) (set []*big.Int) { + for _, u := range args { + set = append(set, w3.I(u)) + } + return set +} + +func int256Array(args []string) (set []*big.Int) { + for _, i := range args { + set = append(set, w3.I(i)) + } + return set +} + func ParseEncoding(function *w3.Func, signature string, params []string) ([]byte, error) { signatureArgs := strings.Split(strings.Split(strings.Split(signature, "(")[1], ")")[0], ",") if len(signatureArgs) != len(params) { - return nil, StringError(errors.New("executor parseParams: mismatched arguments")) + return nil, libcommon.StringError(errors.New("executor parseParams: mismatched arguments")) } args := []interface{}{} for i, s := range signatureArgs { + var subArgs []string + if strings.HasSuffix(s, "[]") { + // set args to an array of s split by commas excluding brackets + subArgs = strings.Split(strings.Trim(params[i], "[]"), ",") + } switch s { case "address": args = append(args, w3.A(params[i])) + case "address[]": + args = append(args, addressArray(subArgs)) case "bool": args = append(args, params[i] == "true" || params[i] == "TRUE") + case "bool[]": + args = append(args, boolArray(subArgs)) case "string": args = append(args, params[i]) + case "string[]": + args = append(args, stringArray(subArgs)) case "bytes": args = append(args, w3.B(params[i])) + case "bytes[]": + args = append(args, bytesArray(subArgs)) case "uint8": v, err := strconv.ParseUint(params[i], 0, 8) if err != nil { - return nil, StringError(err) + return nil, libcommon.StringError(err) } args = append(args, v) + case "uint8[]": + args = append(args, uint8Array(subArgs)) case "uint32": v, err := strconv.ParseUint(params[i], 0, 32) if err != nil { - return nil, StringError(err) + return nil, libcommon.StringError(err) } args = append(args, v) + case "uint32[]": + args = append(args, uint32Array(subArgs)) case "uint256": args = append(args, w3.I(params[i])) + case "uint256[]": + args = append(args, uint256Array(subArgs)) case "int8": v, err := strconv.ParseInt(params[i], 0, 8) if err != nil { - return nil, StringError(err) + return nil, libcommon.StringError(err) } args = append(args, v) + case "int8[]": + args = append(args, int8Array(subArgs)) case "int32": v, err := strconv.ParseInt(params[i], 0, 32) if err != nil { - return nil, StringError(err) + return nil, libcommon.StringError(err) } args = append(args, v) + case "int32[]": + args = append(args, int32Array(subArgs)) case "int256": args = append(args, w3.I(params[i])) + case "int256[]": + args = append(args, int256Array(subArgs)) default: - return nil, StringError(errors.New("executor: parseParams: unsupported type")) + return nil, libcommon.StringError(errors.New("executor: parseParams: unsupported type")) } } result, err := function.EncodeArgs(args...) if err != nil { - return nil, StringError(err) + return nil, libcommon.StringError(err) } return result, nil } @@ -87,12 +218,12 @@ func IsWallet(addr string) bool { RPC := "https://rpc.ankr.com/eth" // temporarily just use ETH mainnet geth, _ := ethclient.Dial(RPC) - if !validAddress(addr) { + if !ValidAddress(addr) { return false } addr = SanitizeChecksum(addr) // Copy correct checksum, although endpoint handlers are doing this already - address := common.HexToAddress(addr) + address := ethcommon.HexToAddress(addr) bytecode, err := geth.CodeAt(context.Background(), address, nil) if err != nil { return false @@ -101,6 +232,10 @@ func IsWallet(addr string) bool { return !isContract } +func IsContract(addr string) bool { + return !IsWallet(addr) +} + func validChecksum(addr string) bool { valid := SanitizeChecksum(addr) return addr == valid @@ -125,7 +260,7 @@ func SanitizeChecksum(addr string) string { return valid } -func validAddress(addr string) bool { +func ValidAddress(addr string) bool { re := regexp.MustCompile("^0x[0-9a-fA-F]{40}$") return re.MatchString(addr) } diff --git a/pkg/internal/common/fingerprint.go b/pkg/internal/common/fingerprint.go index afc345e7..fd61134c 100644 --- a/pkg/internal/common/fingerprint.go +++ b/pkg/internal/common/fingerprint.go @@ -5,10 +5,10 @@ import ( "io" "net/http" "net/url" - "os" "strconv" "time" + "github.com/String-xyz/string-api/config" "github.com/pkg/errors" ) @@ -54,7 +54,7 @@ type FPVisitIpLocation struct { } type FPVisitorVisit struct { - RequestID string `json:"requestId"` + RequestId string `json:"requestId"` Incognito bool `json:"incognito"` LinkedId string `json:"linkedId"` Time string `json:"time"` @@ -66,7 +66,7 @@ type FPVisitorVisit struct { } type FPVisitor struct { - ID string `json:"visitorId"` + Id string `json:"visitorId"` Visits []FPVisitorVisit `json:"visits"` } @@ -80,8 +80,8 @@ type HTTPConfig struct { type FPVisitorOpts struct { Limit int - RequestID string - LinkedID string + RequestId string + LinkedId string } func NewHTTPClient(config HTTPConfig) HTTPClient { @@ -89,10 +89,10 @@ func NewHTTPClient(config HTTPConfig) HTTPClient { } type FingerprintClient interface { - // GetVisitorByID get the fingerprint visitor by its id + // GetVisitorById get the fingerprint visitor by its id // it returns the most up to date information for the visitor // The limit should always be 1 so we can get the latest information - GetVisitorByID(VisitorID string, opts FPVisitorOpts) (FPVisitor, error) + GetVisitorById(VisitorId string, opts FPVisitorOpts) (FPVisitor, error) Request(method, url string, body io.Reader) (*http.Request, error) } @@ -103,14 +103,14 @@ type fingerprint struct { } func NewFingerprint(client HTTPClient) FingerprintClient { - apiKey := os.Getenv("FINGERPRINT_API_KEY") - baseURL := os.Getenv("FINGERPRINT_API_URL") + apiKey := config.Var.FINGERPRINT_API_KEY + baseURL := config.Var.FINGERPRINT_API_URL return &fingerprint{client: client, apiKey: apiKey, baseURL: baseURL} } -func (f fingerprint) GetVisitorByID(visitorID string, opts FPVisitorOpts) (FPVisitor, error) { +func (f fingerprint) GetVisitorById(visitorId string, opts FPVisitorOpts) (FPVisitor, error) { m := FPVisitor{} - r, err := f.Request(http.MethodGet, f.baseURL+"visitors/"+visitorID, nil) + r, err := f.Request(http.MethodGet, f.baseURL+"visitors/"+visitorId, nil) if err != nil { return m, err } @@ -152,11 +152,11 @@ func (f fingerprint) optionsToQuery(opts FPVisitorOpts) url.Values { if opts.Limit != 0 { q.Add("limit", strconv.Itoa(opts.Limit)) } - if opts.RequestID != "" { - q.Add("request_id", opts.RequestID) + if opts.RequestId != "" { + q.Add("request_id", opts.RequestId) } - if opts.LinkedID != "" { - q.Add("linked_id", opts.LinkedID) + if opts.LinkedId != "" { + q.Add("linked_id", opts.LinkedId) } return q } diff --git a/pkg/internal/common/json.go b/pkg/internal/common/json.go index 18c4cf56..813f87c5 100644 --- a/pkg/internal/common/json.go +++ b/pkg/internal/common/json.go @@ -2,11 +2,12 @@ package common import ( "encoding/json" - "io/ioutil" + "io" "net/http" "reflect" "time" + libcommon "github.com/String-xyz/go-lib/v2/common" "github.com/pkg/errors" ) @@ -15,20 +16,20 @@ func GetJson(url string, target interface{}) error { client := &http.Client{Timeout: 10 * time.Second} response, err := client.Get(url) if err != nil { - return StringError(err) + return libcommon.StringError(err) } defer response.Body.Close() - jsonData, err := ioutil.ReadAll(response.Body) + jsonData, err := io.ReadAll(response.Body) if err != nil { - return StringError(err) + return libcommon.StringError(err) } targetType := reflect.TypeOf(target) if len(jsonData) != int(targetType.Size()) { - return StringError(errors.New("Malformed JSON Response")) + return libcommon.StringError(errors.New("Malformed JSON Response")) } err = json.Unmarshal([]byte(jsonData), target) if err != nil { - return StringError(err) + return libcommon.StringError(err) } return nil } @@ -38,20 +39,34 @@ func GetJsonGeneric(url string, target interface{}) error { client := &http.Client{Timeout: 10 * time.Second} response, err := client.Get(url) if err != nil { - return StringError(err) + return libcommon.StringError(err) } defer response.Body.Close() - jsonData, err := ioutil.ReadAll(response.Body) + jsonData, err := io.ReadAll(response.Body) if err != nil { - return StringError(err) + return libcommon.StringError(err) } err = json.Unmarshal([]byte(jsonData), target) if err != nil { - return StringError(err) + return libcommon.StringError(err) } return nil } +func GetJsonDebug(url string) (string, error) { + client := &http.Client{Timeout: 10 * time.Second} + response, err := client.Get(url) + if err != nil { + return "", libcommon.StringError(err) + } + defer response.Body.Close() + jsonData, err := io.ReadAll(response.Body) + if err != nil { + return "", libcommon.StringError(err) + } + return string(jsonData), nil +} + func parseJSON[T any](b []byte) (T, error) { var r T if err := json.Unmarshal(b, &r); err != nil { diff --git a/pkg/internal/common/precision.go b/pkg/internal/common/precision.go new file mode 100644 index 00000000..3b78977a --- /dev/null +++ b/pkg/internal/common/precision.go @@ -0,0 +1,31 @@ +package common + +import ( + "strconv" + + "github.com/String-xyz/string-api/pkg/model" +) + +func EstimateToPrecise(imprecise model.Estimate[float64]) model.Estimate[string] { + res := model.Estimate[string]{ + Timestamp: imprecise.Timestamp, + BaseUSD: strconv.FormatFloat(imprecise.BaseUSD, 'f', 2, 64), + GasUSD: strconv.FormatFloat(imprecise.GasUSD, 'f', 2, 64), + TokenUSD: strconv.FormatFloat(imprecise.TokenUSD, 'f', 2, 64), + ServiceUSD: strconv.FormatFloat(imprecise.ServiceUSD, 'f', 2, 64), + TotalUSD: strconv.FormatFloat(imprecise.TotalUSD, 'f', 2, 64), + } + return res +} + +func EstimateToImprecise(precise model.Estimate[string]) model.Estimate[float64] { + res := model.Estimate[float64]{ + Timestamp: precise.Timestamp, + } + res.BaseUSD, _ = strconv.ParseFloat(precise.BaseUSD, 64) + res.GasUSD, _ = strconv.ParseFloat(precise.GasUSD, 64) + res.TokenUSD, _ = strconv.ParseFloat(precise.TokenUSD, 64) + res.ServiceUSD, _ = strconv.ParseFloat(precise.ServiceUSD, 64) + res.TotalUSD, _ = strconv.ParseFloat(precise.TotalUSD, 64) + return res +} diff --git a/pkg/internal/common/price_test.go b/pkg/internal/common/price_test.go new file mode 100644 index 00000000..edb168c7 --- /dev/null +++ b/pkg/internal/common/price_test.go @@ -0,0 +1,20 @@ +package common + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetCoincapPrices(t *testing.T) { + coins := []string{"matic", "ethereum", "avalanche"} + for _, coin := range coins { + body := make(map[string]interface{}) + err := GetJsonGeneric("https://api.coincap.io/v2/assets?search="+coin, &body) + assert.NoError(t, err) + price := body["data"].([]interface{})[0].(map[string]interface{})["priceUsd"].(string) + assert.NotEqual(t, "", price) + fmt.Printf("\n"+coin+" PRICE = %+v", price) + } +} diff --git a/pkg/internal/common/receipt.go b/pkg/internal/common/receipt.go deleted file mode 100644 index df923da6..00000000 --- a/pkg/internal/common/receipt.go +++ /dev/null @@ -1,71 +0,0 @@ -package common - -import ( - "os" - - "github.com/sendgrid/sendgrid-go" - "github.com/sendgrid/sendgrid-go/helpers/mail" -) - -type emailReceipt struct { - Header string - Body [][2]string - Footer string -} - -type ReceiptGenerationParams struct { - ReceiptType string - CustomerName string - PaymentDescriptor string - TransactionDate string - StringPaymentId string -} - -func StringifyEmailReceipt(e emailReceipt) string { - res := e.Header + "


" - - for index := range e.Body { - res += e.Body[index][0] + ": " + e.Body[index][1] + "
" - } - res += "

" + e.Footer - return res -} - -func GenerateReceipt(params ReceiptGenerationParams, body [][2]string) string { - header := "" + - "" + - "" + - "
Your " + params.ReceiptType + " Details
" + - "
Dear " + params.CustomerName + "," + - "
Thank you for using String. Here is your transaction receipt:" + - "
Transaction Date: " + params.TransactionDate + - "
String Payment ID: " + params.StringPaymentId - - footer := "" + - "
The transaction will appear on your card statement as " + params.PaymentDescriptor + - "
All sales are final. Please see our Terms of Service" + - "
Please reference your String Payment ID " + params.StringPaymentId + - "

Service powered by String" + - "
String XYZ LLC | 490 43rd St, #86, Oakland CA 94609. | NMLS ID: 2400614" + - "
Please visit us at string.xyz. Should you need to reach us, please contact us at support@string.xyz." + - "

Consumer Fraud Warning" + - "
If you feel you have been the victim of a scam you can contact the FTC at 1-877-FTC-HELP (382-4357)" + - "
or online at www.ftc.gov (link is external); or the Consumer Financial Protection Bureau (CFPB) at 1-855-411-CFPB (2372)" + - "
or online at www.consumerfinance.gov" - return StringifyEmailReceipt(emailReceipt{Header: header, Body: body, Footer: footer}) -} - -func EmailReceipt(email string, params ReceiptGenerationParams, body [][2]string) error { - from := mail.NewEmail("String Receipt", "auth@string.xyz") // TODO: create a new sender for receipts - subject := "Your " + params.ReceiptType + " Receipt from String" - to := mail.NewEmail(params.CustomerName, email) - textContent := "" - htmlContent := GenerateReceipt(params, body) - message := mail.NewSingleEmail(from, subject, to, textContent, htmlContent) - client := sendgrid.NewSendClient(os.Getenv("SENDGRID_API_KEY")) - _, err := client.Send(message) - if err != nil { - return StringError(err) - } - return nil -} diff --git a/pkg/internal/common/sign.go b/pkg/internal/common/sign.go index 985ce3c4..4f39a64e 100644 --- a/pkg/internal/common/sign.go +++ b/pkg/internal/common/sign.go @@ -3,26 +3,28 @@ package common import ( "crypto/ecdsa" "errors" - "os" "strconv" - "github.com/ethereum/go-ethereum/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/string-api/config" + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/crypto" ) func EVMSign(buffer []byte, eip131 bool) (string, error) { - privateKey, err := DecryptBlobFromKMS(os.Getenv("EVM_PRIVATE_KEY")) + privateKey, err := DecryptBlobFromKMS(config.Var.EVM_PRIVATE_KEY) if err != nil { - return "", StringError(err) + return "", libcommon.StringError(err) } return EVMSignWithPrivateKey(buffer, privateKey, eip131) } func EVMSignWithPrivateKey(buffer []byte, privateKey string, eip131 bool) (string, error) { - sk, err := crypto.ToECDSA(common.FromHex(privateKey)) + sk, err := crypto.ToECDSA(ethcommon.FromHex(privateKey)) if err != nil { - return "", StringError(err) + return "", libcommon.StringError(err) } if eip131 { @@ -33,25 +35,25 @@ func EVMSignWithPrivateKey(buffer []byte, privateKey string, eip131 bool) (strin hash := crypto.Keccak256Hash(buffer) signature, err := crypto.Sign(hash.Bytes(), sk) if err != nil { - return "", StringError(err) + return "", libcommon.StringError(err) } return hexutil.Encode(signature), nil } func ValidateEVMSignature(signature string, buffer []byte, eip131 bool) (bool, error) { // Get private key - skStr, err := DecryptBlobFromKMS(os.Getenv("EVM_PRIVATE_KEY")) + skStr, err := DecryptBlobFromKMS(config.Var.EVM_PRIVATE_KEY) if err != nil { - return false, StringError(err) + return false, libcommon.StringError(err) } - sk, err := crypto.ToECDSA(common.FromHex(skStr)) + sk, err := crypto.ToECDSA(ethcommon.FromHex(skStr)) if err != nil { - return false, StringError(err) + return false, libcommon.StringError(err) } pk := sk.Public() pkECDSA, ok := pk.(*ecdsa.PublicKey) if !ok { - return false, StringError(errors.New("ValidateSignature: Failed to cast pk to ECDSA")) + return false, libcommon.StringError(errors.New("ValidateSignature: Failed to cast pk to ECDSA")) } pkBytes := crypto.FromECDSAPub(pkECDSA) @@ -65,7 +67,7 @@ func ValidateEVMSignature(signature string, buffer []byte, eip131 bool) (bool, e sigBytes, err := hexutil.Decode(signature) if err != nil { - return false, StringError(err) + return false, libcommon.StringError(err) } // Handle cases where EIP-155 is not implemented, as with most wallets @@ -88,7 +90,7 @@ func ValidateExternalEVMSignature(signature string, address string, buffer []byt sigBytes, err := hexutil.Decode(signature) if err != nil { - return false, StringError(err) + return false, libcommon.StringError(err) } // Handle cases where EIP-155 is not implemented, as with most wallets @@ -98,7 +100,7 @@ func ValidateExternalEVMSignature(signature string, address string, buffer []byt sigPKECDSA, err := crypto.SigToPub(hash.Bytes(), sigBytes) if err != nil { - return false, StringError(err) + return false, libcommon.StringError(err) } sigPKBytes := crypto.FromECDSAPub(sigPKECDSA) diff --git a/pkg/internal/common/sign_test.go b/pkg/internal/common/sign_test.go index 30c8be7d..3c2aebb0 100644 --- a/pkg/internal/common/sign_test.go +++ b/pkg/internal/common/sign_test.go @@ -8,13 +8,10 @@ import ( b64 "encoding/base64" "github.com/String-xyz/string-api/pkg/model" - "github.com/joho/godotenv" "github.com/stretchr/testify/assert" ) func TestSignAndValidateString(t *testing.T) { - err := godotenv.Load("../../../.env") - assert.NoError(t, err) encodedMessage := "Your base64 encoded String Here" @@ -32,9 +29,6 @@ func TestSignAndValidateString(t *testing.T) { } func TestSignAndValidateStruct(t *testing.T) { - err := godotenv.Load("../../../.env") - assert.NoError(t, err) - // Paste the JSON output properties from whatever struct here obj1 := model.WalletSignaturePayload{ Address: "0xPasteYourAddressHere", diff --git a/pkg/internal/common/util.go b/pkg/internal/common/util.go index 15041d88..50cc9e63 100644 --- a/pkg/internal/common/util.go +++ b/pkg/internal/common/util.go @@ -2,29 +2,23 @@ package common import ( "bytes" - "crypto/sha256" - "encoding/hex" "encoding/json" "fmt" - "io/ioutil" - "log" + "io" "math" - "os" - "reflect" + "math/big" "strconv" + "strings" + libcommon "github.com/String-xyz/go-lib/v2/common" "github.com/ethereum/go-ethereum/accounts" - ethcomm "github.com/ethereum/go-ethereum/common" + ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/crypto" + "github.com/rs/zerolog/log" ) -func ToSha256(v string) string { - bs := sha256.Sum256([]byte(v)) - return hex.EncodeToString(bs[:]) -} - -func RecoverAddress(message string, signature string) (ethcomm.Address, error) { +func RecoverAddress(message string, signature string) (ethcommon.Address, error) { sig := hexutil.MustDecode(signature) if sig[crypto.RecoveryIDOffset] == 27 || sig[crypto.RecoveryIDOffset] == 28 { sig[crypto.RecoveryIDOffset] -= 27 @@ -32,7 +26,7 @@ func RecoverAddress(message string, signature string) (ethcomm.Address, error) { msg := accounts.TextHash([]byte(message)) recovered, err := crypto.SigToPub(msg, sig) if err != nil { - return ethcomm.Address{}, StringError(err) + return ethcommon.Address{}, libcommon.StringError(err) } return crypto.PubkeyToAddress(*recovered), nil } @@ -40,78 +34,60 @@ func RecoverAddress(message string, signature string) (ethcomm.Address, error) { func BigNumberToFloat(bigNumber string, decimals uint64) (floatReturn float64, err error) { floatReturn, err = strconv.ParseFloat(bigNumber, 64) if err != nil { - log.Printf("Failed to convert bigNumber to float: %s", err) - err = StringError(err) + log.Err(err).Msg("Failed to convert bigNumber to float") + err = libcommon.StringError(err) return } floatReturn = floatReturn * math.Pow(10, -float64(decimals)) return } -func isNil(i interface{}) bool { - if i == nil { - return true - } - switch reflect.TypeOf(i).Kind() { - case reflect.Ptr, reflect.Map, reflect.Array, reflect.Chan, reflect.Slice: - return reflect.ValueOf(i).IsNil() - } - return false -} - -// keysAndValues is only being used for optional updates -// do not use it for insert or select -func KeysAndValues(item interface{}) ([]string, map[string]interface{}) { - tag := "db" - v := reflect.TypeOf(item) - reflectValue := reflect.ValueOf(item) - reflectValue = reflect.Indirect(reflectValue) - - if v.Kind() == reflect.Ptr { - v = v.Elem() - } - - keyNames := make([]string, 0, v.NumField()) - keyValues := make(map[string]interface{}, v.NumField()) - - for i := 0; i < v.NumField(); i++ { - field := reflectValue.Field(i).Interface() - if !isNil(field) { - t := v.Field(i).Tag.Get(tag) + "=:" + v.Field(i).Tag.Get(tag) - keyNames = append(keyNames, t) - keyValues[v.Field(i).Tag.Get(tag)] = field - } - } - - return keyNames, keyValues -} - -func GetBaseURL() string { - return os.Getenv("BASE_URL") -} - func FloatToUSDString(amount float64) string { return fmt.Sprintf("USD $%.2f", math.Round(amount*100)/100) } -func IsLocalEnv() bool { - return os.Getenv("ENV") == "local" -} - func BetterStringify(jsonBody any) (betterString string, err error) { bodyBytes, err := json.Marshal(jsonBody) if err != nil { - log.Printf("Could not encode %+v to bytes: %s", jsonBody, err) - return betterString, StringError(err) + log.Err(err).Interface("body", jsonBody).Msg("Could not encode to bytes") + return betterString, libcommon.StringError(err) } bodyReader := bytes.NewReader(bodyBytes) - betterBytes, err := ioutil.ReadAll(bodyReader) + betterBytes, err := io.ReadAll(bodyReader) betterString = string(betterBytes) if err != nil { - return betterString, StringError(err) + return betterString, libcommon.StringError(err) } return } + +func SliceContains(elems []string, v string) bool { + for _, s := range elems { + if v == s { + return true + } + } + return false +} + +func StringContainsAny(target string, substrs []string) bool { + // convert target to lowercase + noSpaceLowerCase := strings.ReplaceAll(strings.ToLower(target), " ", "") + for _, substr := range substrs { + if strings.Contains(noSpaceLowerCase, strings.ReplaceAll(strings.ToLower(substr), " ", "")) { + return true + } + } + return false +} + +func StringifyBigIntArray(arr []*big.Int) string { + strArr := make([]string, len(arr)) + for _, elem := range arr { + strArr = append(strArr, elem.String()) + } + return strings.Join(strArr, ",") +} diff --git a/pkg/internal/common/util_test.go b/pkg/internal/common/util_test.go index 76cf9530..2d3788a5 100644 --- a/pkg/internal/common/util_test.go +++ b/pkg/internal/common/util_test.go @@ -3,6 +3,7 @@ package common import ( "testing" + libcommon "github.com/String-xyz/go-lib/v2/common" "github.com/String-xyz/string-api/pkg/model" "github.com/stretchr/testify/assert" ) @@ -14,10 +15,11 @@ func TestRecoverSignature(t *testing.T) { assert.Equal(t, "0x8db97C7cEcE249c2b98bDC0226Cc4C2A57BF52FC", addr.Hex()) } +// TODO: This test should be moved to the go-lib repo func TestKeysAndValues(t *testing.T) { mType := "type" m := model.ContactUpdates{Type: &mType} - names, vals := KeysAndValues(m) + names, vals := libcommon.KeysAndValues(m) assert.Len(t, names, 1) assert.Len(t, vals, 1) } diff --git a/pkg/internal/emailer/emailer.go b/pkg/internal/emailer/emailer.go new file mode 100644 index 00000000..92bc9a75 --- /dev/null +++ b/pkg/internal/emailer/emailer.go @@ -0,0 +1,125 @@ +package emailer + +import ( + "bytes" + "context" + "embed" + "text/template" + + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/string-api/config" + "github.com/sendgrid/sendgrid-go" + "github.com/sendgrid/sendgrid-go/helpers/mail" +) + +type Emailer interface { + SendReceipt(ctx context.Context, email string, params ReceiptGenerationParams) error + SendEmailVerification(ctx context.Context, email string, code string, platformName string) error + SendDeviceVerification(ctx context.Context, email string, link string, textContent string) error +} + +//go:embed templates/* +var templatesFS embed.FS + +type emailer struct { +} + +func New() Emailer { + return &emailer{} +} + +type ReceiptGenerationParams struct { + ReceiptType string + CustomerName string + PaymentDescriptor string + TransactionDate string + StringPaymentId string + TransactionId string + TransactionExplorer string + DestinationAddress string + DestinationExplorer string + PaymentMethod string + Platform string + ItemOrdered string + TokenId string + Subtotal string + NetworkFee string + ProcessingFee string + Total string +} + +func (e emailer) SendEmailVerification(ctx context.Context, email string, code string, platformName string) error { + link := config.Var.BASE_URL + "verification?type=email&token=" + code + + tmpl, err := template.ParseFS(templatesFS, "templates/email_verification.tpl") + if err != nil { + return err + } + + var buf bytes.Buffer + err = tmpl.ExecuteTemplate(&buf, "email_verification.tpl", map[string]interface{}{ + "link": link, + }) + if err != nil { + return err + } + + from := mail.NewEmail(platformName+" via String", config.Var.AUTH_EMAIL_ADDRESS) + subject := "String Email Verification" + to := mail.NewEmail("New String User", email) + textContent := "Click the link below to complete your e-email verification!" + + return sendEmail(ctx, from, subject, to, textContent, buf.String()) +} + +func (e emailer) SendDeviceVerification(ctx context.Context, email string, link string, textContent string) error { + tmpl, err := template.ParseFS(templatesFS, "templates/device_verification.tpl") + if err != nil { + return err + } + + var buf bytes.Buffer + err = tmpl.ExecuteTemplate(&buf, "device_verification.tpl", map[string]interface{}{ + "textContent": textContent, + "link": link, + }) + if err != nil { + return err + } + + from := mail.NewEmail("String XYZ", config.Var.AUTH_EMAIL_ADDRESS) + subject := "New Device Login Verification" + to := mail.NewEmail("New Device Login", email) + + return sendEmail(ctx, from, subject, to, textContent, buf.String()) +} + +func (e emailer) SendReceipt(ctx context.Context, email string, params ReceiptGenerationParams) error { + tmpl, err := template.ParseFS(templatesFS, "templates/receipt.tpl") + if err != nil { + return err + } + + var buf bytes.Buffer + err = tmpl.ExecuteTemplate(&buf, "receipt.tpl", map[string]interface{}{ + "params": params, + }) + if err != nil { + return err + } + + from := mail.NewEmail("String Receipt", config.Var.RECEIPTS_EMAIL_ADDRESS) + subject := "Your " + params.ReceiptType + " Receipt from String" + to := mail.NewEmail(params.CustomerName, email) + return sendEmail(ctx, from, subject, to, "", buf.String()) +} + +func sendEmail(ctx context.Context, from *mail.Email, subject string, to *mail.Email, text string, html string) error { + message := mail.NewSingleEmail(from, subject, to, text, html) + client := sendgrid.NewSendClient(config.Var.SENDGRID_API_KEY) + _, err := client.Send(message) + if err != nil { + return libcommon.StringError(err) + } + return nil +} diff --git a/pkg/internal/emailer/templates/device_verification.tpl b/pkg/internal/emailer/templates/device_verification.tpl new file mode 100644 index 00000000..5714f0ae --- /dev/null +++ b/pkg/internal/emailer/templates/device_verification.tpl @@ -0,0 +1,10 @@ +
+ + {{.textContent}} + +
+ + + Yes + + \ No newline at end of file diff --git a/pkg/internal/emailer/templates/email_verification.tpl b/pkg/internal/emailer/templates/email_verification.tpl new file mode 100644 index 00000000..351a1c1b --- /dev/null +++ b/pkg/internal/emailer/templates/email_verification.tpl @@ -0,0 +1,5 @@ +
+
+ Verify Email Now +
` + diff --git a/pkg/internal/emailer/templates/receipt.tpl b/pkg/internal/emailer/templates/receipt.tpl new file mode 100644 index 00000000..af9d1790 --- /dev/null +++ b/pkg/internal/emailer/templates/receipt.tpl @@ -0,0 +1,30 @@ + + +
Your {{.params.ReceiptType}} Details
+
Dear {{.params.CustomerName}}, +
Thank you for using String. Here is your transaction receipt: +
Transaction Date: {{.params.TransactionDate}} +
String Payment ID: {{.params.StringPaymentId}} + +
Transaction ID: {{.params.TransactionId}} +
Destination Wallet: {{.params.DestinationAddress}} +
Payment Descriptor: {{.params.PaymentDescriptor}} +
Payment Method: {{.params.PaymentMethod}} +
Platform: {{.params.Platform}} +
Item Ordered: {{.params.ItemOrdered}} +
Token ID: {{.params.TokenId}} +
Subtotal: {{.params.Subtotal}} +
Network Fee: {{.params.NetworkFee}} +
Processing Fee: {{.params.ProcessingFee}} +
Total Charge: {{.params.Total}} + +
The transaction will appear on your card statement as {{.params.PaymentDescriptor}} +
All sales are final. Please see our Terms of Service +
Please reference your String Payment ID {{.params.StringPaymentId}} +

Service powered by String +
String XYZ LLC | 490 43rd St, #86, Oakland CA 94609. | NMLS ID: 2400614 +
Please visit us at string.xyz. Should you need to reach us, please contact us at support@string.xyz. +

Consumer Fraud Warning +
If you feel you have been the victim of a scam you can contact the FTC at 1-877-FTC-HELP (382-4357) +
or online at www.ftc.gov (link is external); or the Consumer Financial Protection Bureau (CFPB) at 1-855-411-CFPB (2372) +
or online at www.consumerfinance.gov \ No newline at end of file diff --git a/pkg/internal/persona/account.go b/pkg/internal/persona/account.go new file mode 100644 index 00000000..07c2856f --- /dev/null +++ b/pkg/internal/persona/account.go @@ -0,0 +1,66 @@ +package persona + +import ( + "net/http" + + "github.com/String-xyz/go-lib/v2/common" +) + +/* +The account represents a verified individual and contains one or more inquiries. +The primary use of the account endpoints is to fetch previously submitted information for an individual. +*/ + +type Account struct { + Id string `json:"id"` + Type string `json:"type"` + Attributes AccountAttributes `json:"attributes"` +} + +func (a Account) GetType() string { + return a.Type +} + +type AccountCreate struct { + Attributes CommonFields `json:"attributes"` +} + +type AccountCreateRequest struct { + Data AccountCreate `json:"data"` +} + +type AccountResponse struct { + Data Account `json:"data"` +} + +type ListAccountResponse struct { + Data []Account `json:"data"` + Links Link `json:"links"` +} + +func (c *PersonaClient) CreateAccount(request AccountCreateRequest) (*AccountResponse, error) { + account := &AccountResponse{} + err := c.doRequest(http.MethodPost, "/v1/accounts", request, account) + if err != nil { + return nil, common.StringError(err, "failed to create account") + } + return account, nil +} + +func (c *PersonaClient) GetAccountById(id string) (*AccountResponse, error) { + account := &AccountResponse{} + err := c.doRequest(http.MethodGet, "/v1/accounts/"+id, nil, account) + if err != nil { + return nil, common.StringError(err, "failed to get account") + } + return account, nil +} + +func (c *PersonaClient) ListAccounts() (*ListAccountResponse, error) { + accounts := &ListAccountResponse{} + err := c.doRequest(http.MethodGet, "/v1/accounts", nil, accounts) + if err != nil { + return nil, common.StringError(err, "failed to list accounts") + } + return accounts, nil +} diff --git a/pkg/internal/persona/client.go b/pkg/internal/persona/client.go new file mode 100644 index 00000000..c4b60320 --- /dev/null +++ b/pkg/internal/persona/client.go @@ -0,0 +1,68 @@ +package persona + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type PersonaClient struct { + BaseURL string + APIKey string + Client *http.Client +} + +func New(apiKey string) *PersonaClient { + return &PersonaClient{ + APIKey: apiKey, + BaseURL: "https://withpersona.com/api/", + Client: &http.Client{}, + } +} + +func NewPersonaClient(baseURL, apiKey string) *PersonaClient { + return &PersonaClient{ + BaseURL: baseURL, + APIKey: apiKey, + Client: &http.Client{}, + } +} + +func (c *PersonaClient) doRequest(method, url string, payload, result interface{}) error { + var body io.Reader + if payload != nil { + jsonPayload, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + body = bytes.NewBuffer(jsonPayload) + } + + req, err := http.NewRequest(method, c.BaseURL+url, body) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.APIKey)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Key-Inflection", "camel") + + resp, err := c.Client.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("request failed with status code: %d", resp.StatusCode) + } + + if result != nil { + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + } + return nil +} diff --git a/pkg/internal/persona/event.go b/pkg/internal/persona/event.go new file mode 100644 index 00000000..ae089bc4 --- /dev/null +++ b/pkg/internal/persona/event.go @@ -0,0 +1,74 @@ +package persona + +import ( + "encoding/json" + "time" + + "github.com/String-xyz/go-lib/v2/common" + "github.com/cockroachdb/errors" +) + +type EventType string + +const ( + EventTypeAccountCreated = EventType("account.created") + EventTypeInquiryCreated = EventType("inquiry.created") + EventTypeInquiryStarted = EventType("inquiry.started") + EventTypeInquiryCompleted = EventType("inquiry.completed") + EventTypeVerificationCreated = EventType("verification.created") + EventTypeVerificationPassed = EventType("verification.passed") + EventTypeVerificationFailed = EventType("verification.failed") +) + +type PayloadData interface { + GetType() string +} + +type EventPayload struct { + Data json.RawMessage `json:"data"` +} + +type Event struct { + Id string `json:"id"` + Type string `json:"type"` + Attributes EventAttributes `json:"attributes"` +} + +type EventAttributes struct { + Name EventType `json:"name"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Payload EventPayload `json:"payload"` +} + +func (a EventAttributes) GetType() EventType { + return a.Name +} + +func (a EventAttributes) GetPayloadData() (PayloadData, error) { + switch a.GetType() { + case EventTypeAccountCreated: + var data Account + err := json.Unmarshal(a.Payload.Data, &data) + if err != nil { + return nil, common.StringError(err) + } + return data, nil + case EventTypeInquiryCreated, EventTypeInquiryStarted, EventTypeInquiryCompleted: + var data Inquiry + err := json.Unmarshal(a.Payload.Data, &data) + if err != nil { + return nil, common.StringError(err) + } + return data, nil + case EventTypeVerificationCreated, EventTypeVerificationPassed, EventTypeVerificationFailed: + var data Verification + err := json.Unmarshal(a.Payload.Data, &data) + if err != nil { + return nil, common.StringError(err) + } + return data, nil + default: + return nil, common.StringError(errors.Newf("unknown event type: %s", a.GetType())) + } +} diff --git a/pkg/internal/persona/inquiries.go b/pkg/internal/persona/inquiries.go new file mode 100644 index 00000000..1e58fa0c --- /dev/null +++ b/pkg/internal/persona/inquiries.go @@ -0,0 +1,80 @@ +package persona + +import ( + "net/http" + + "github.com/String-xyz/go-lib/v2/common" +) + +/* +The inquiry represents a single instance of an individual attempting to verify their identity. +The primary use of the inquiry endpoints is to fetch submitted information from the flow. + +Inquiries are created when the individual begins to verify their identity. +Check for the following statuses to determine whether the individual has finished the flow. + +* Created - The individual started the inquiry. +* Pending - The individual submitted a verification within the inquiry. +* Completed - The individual passed all required verifications within the inquiry. + +Approved/Declined (Optional) +These are optional statuses applied by you to execute custom decisioning logic. +* Expired - The individual did not complete the inquiry within 24 hours. +* Failed - The individual exceeded the allowed number of verification attempts on the inquiry and cannot continue. +*/ + +type InquiryCreate struct { + Attributes InquiryCreationAttributes `json:"attributes"` +} + +type InquiryCreateRequest struct { + Data InquiryCreate `json:"data"` +} + +type Inquiry struct { + Id string `json:"id"` + Type string `json:"type"` + Attributes InquiryAttributes `json:"attributes"` + Relationships Relationships `json:"relationships"` +} + +func (i Inquiry) GetType() string { + return i.Type +} + +type InquiryResponse struct { + Data Inquiry `json:"data"` + Included []Included `json:"included"` +} + +type ListInquiryResponse struct { + Data []Inquiry `json:"data"` + Links Link `json:"links"` +} + +func (c *PersonaClient) CreateInquiry(request InquiryCreateRequest) (*InquiryResponse, error) { + inquiry := &InquiryResponse{} + err := c.doRequest(http.MethodPost, "/v1/inquiries", request, inquiry) + if err != nil { + return nil, common.StringError(err, "failed to create inquiry") + } + return inquiry, nil +} + +func (c *PersonaClient) GetInquiryById(id string) (*InquiryResponse, error) { + inquiry := &InquiryResponse{} + err := c.doRequest(http.MethodGet, "/v1/inquiries/"+id, nil, inquiry) + if err != nil { + return nil, common.StringError(err, "failed to get inquiry") + } + return inquiry, nil +} + +func (c *PersonaClient) ListInquiriesByAccount(accountId string) (*ListInquiryResponse, error) { + inquiries := &ListInquiryResponse{} + err := c.doRequest(http.MethodGet, "/v1/inquiries?filter[account-id]="+accountId, nil, inquiries) + if err != nil { + return nil, common.StringError(err, "failed to list inquiries") + } + return inquiries, nil +} diff --git a/pkg/internal/persona/persona_integration_test.go b/pkg/internal/persona/persona_integration_test.go new file mode 100644 index 00000000..5181f146 --- /dev/null +++ b/pkg/internal/persona/persona_integration_test.go @@ -0,0 +1,69 @@ +//go:build integration +// +build integration + +package persona + +import ( + "fmt" + env "github.com/String-xyz/go-lib/v2/config" + "github.com/String-xyz/string-api/config" + "github.com/stretchr/testify/assert" + "testing" +) + +func init() { + err := env.LoadEnv(&config.Var, "../../../.env") + if err != nil { + fmt.Printf("error loading env: %v", err) + } +} + +func client() *PersonaClient { + return New(config.Var.PERSONA_API_KEY) +} + +func TestIntegrationCreateAccount(t *testing.T) { + request := AccountCreateRequest{AccountCreate{Attributes: CommonFields{NameFirst: "Mister", NameLast: "Tester"}}} + account, err := client().CreateAccount(request) + assert.NoError(t, err) + assert.NotNil(t, account) +} + +func TestIntegrationGetAccount(t *testing.T) { + account, err := client().GetAccountById("act_Q1zEPYBZ6Qx8qJKcMrwDXxVA") + assert.NoError(t, err) + assert.NotNil(t, account) +} + +func TestIntegrationListAccounts(t *testing.T) { + accounts, err := client().ListAccounts() + assert.NoError(t, err) + assert.NotNil(t, accounts) + assert.NotEmpty(t, accounts.Data) +} + +func TestIntegrationCreateInquiry(t *testing.T) { + request := InquiryCreateRequest{InquiryCreate{Attributes: InquiryCreationAttributes{AccountId: "act_ndJNqdhWNi44S4Twf4bqzod1", InquityTemplateId: "itmpl_z2so7W2bCFHELp2dhxqqQjGy"}}} + inquiry, err := client().CreateInquiry(request) + assert.NoError(t, err) + assert.NotNil(t, inquiry) +} + +func TestIntegrationGetInquiry(t *testing.T) { + inquiry, err := client().GetInquiryById("inq_kmXCg5pLzWTwg2LuAjiaBsoC") + assert.NoError(t, err) + assert.NotNil(t, inquiry) +} + +func TestIntegrationListInquiriesByAccount(t *testing.T) { + inquiries, err := client().ListInquiriesByAccount("act_ndJNqdhWNi44S4Twf4bqzod1") + assert.NoError(t, err) + assert.NotNil(t, inquiries) + assert.NotEmpty(t, inquiries.Data) +} + +func TestIntegrationGetVerification(t *testing.T) { + verification, err := client().GetVerificationById("ver_ww2rkwtA6c9FiuCG8Jsk1DJt") + assert.NoError(t, err) + assert.NotNil(t, verification) +} diff --git a/pkg/internal/persona/persona_test.go b/pkg/internal/persona/persona_test.go new file mode 100644 index 00000000..ae7da1f2 --- /dev/null +++ b/pkg/internal/persona/persona_test.go @@ -0,0 +1,133 @@ +package persona + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func testServer(handler func(w http.ResponseWriter, r *http.Request)) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(handler)) +} + +func TestNew(t *testing.T) { + c := New("test-key") + if c == nil { + t.Error("Expected persona client to be created") + } +} + +func TestDoRequest(t *testing.T) { + testServer := testServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + res.Write([]byte(`OK`)) + })) + defer func() { testServer.Close() }() + + c := New("test-key") + c.BaseURL = testServer.URL + + err := c.doRequest("GET", "/", nil, nil) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } +} + +func TestCreateAccount(t *testing.T) { + testServer := testServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/accounts", r.URL.String()) + assert.Equal(t, http.MethodPost, r.Method) + + var payload AccountCreateRequest + err := json.NewDecoder(r.Body).Decode(&payload) + assert.NoError(t, err) + assert.Equal(t, "Testable", payload.Data.Attributes.NameFirst) + assert.Equal(t, "Testerson", payload.Data.Attributes.NameLast) + + account := &AccountResponse{Data: Account{ + Id: "test-id", + Type: "account", + }} + + json.NewEncoder(w).Encode(account) + })) + defer func() { testServer.Close() }() + c := New("test-key") + c.BaseURL = testServer.URL + v, err := c.CreateAccount(AccountCreateRequest{Data: AccountCreate{Attributes: CommonFields{NameFirst: "Testable", NameLast: "Testerson"}}}) + assert.NoError(t, err) + assert.NotNil(t, v) + assert.Equal(t, "test-id", v.Data.Id) +} + +func TestGetVerificationById(t *testing.T) { + testServer := testServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + verification := &VerificationResponse{Data: Verification{ + Id: "test-id", + Type: "verification", + }} + + json.NewEncoder(w).Encode(verification) + })) + defer func() { testServer.Close() }() + + c := New("test-key") + c.BaseURL = testServer.URL + + v, err := c.GetVerificationById("test-verification") + + assert.NoError(t, err) + assert.NotNil(t, v) +} + +func TestCreateInquiry(t *testing.T) { + server := testServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/inquiries", r.URL.String()) + assert.Equal(t, http.MethodPost, r.Method) + + var payload InquiryCreateRequest + err := json.NewDecoder(r.Body).Decode(&payload) + assert.NoError(t, err) + assert.Equal(t, "test-account", payload.Data.Attributes.AccountId) + assert.Equal(t, "test-template", payload.Data.Attributes.TemplateId) + + inquiry := &InquiryResponse{Data: Inquiry{ + Id: "test-id", + Type: "inquiry", + }} + + json.NewEncoder(w).Encode(inquiry) + }) + defer server.Close() + + client := NewPersonaClient(server.URL, "test-key") + inquiry, err := client.CreateInquiry(InquiryCreateRequest{ + InquiryCreate{InquiryCreationAttributes{AccountId: "test-account", TemplateId: "test-template"}}}) + assert.NoError(t, err) + assert.Equal(t, "test-id", inquiry.Data.Id) + assert.Equal(t, "inquiry", inquiry.Data.Type) +} + +func TestGetInquiry(t *testing.T) { + server := testServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v1/inquiries/test-id", r.URL.String()) + assert.Equal(t, http.MethodGet, r.Method) + + inquiry := &InquiryResponse{Data: Inquiry{ + Id: "test-id", + Type: "inquiry", + }, + } + + json.NewEncoder(w).Encode(inquiry) + }) + defer server.Close() + + client := NewPersonaClient(server.URL, "test-key") + inquiry, err := client.GetInquiryById("test-id") + assert.NoError(t, err) + assert.Equal(t, "test-id", inquiry.Data.Id) + assert.Equal(t, "inquiry", inquiry.Data.Type) +} diff --git a/pkg/internal/persona/templates.go b/pkg/internal/persona/templates.go new file mode 100644 index 00000000..a73f4a39 --- /dev/null +++ b/pkg/internal/persona/templates.go @@ -0,0 +1,31 @@ +package persona + +import ( + "fmt" + "net/http" +) + +type Template struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + CreatedAt string `json:"created_at"` +} + +func (c *PersonaClient) GetTemplates() ([]Template, error) { + var templates []Template + err := c.doRequest(http.MethodGet, "/v1/templates", nil, &templates) + if err != nil { + return nil, fmt.Errorf("failed to get templates: %w", err) + } + return templates, nil +} + +func (c *PersonaClient) GetTemplate(id string) (*Template, error) { + template := &Template{} + err := c.doRequest(http.MethodGet, fmt.Sprintf("/v1/templates/%s", id), nil, template) + if err != nil { + return nil, fmt.Errorf("failed to get template: %w", err) + } + return template, nil +} diff --git a/pkg/internal/persona/types.go b/pkg/internal/persona/types.go new file mode 100644 index 00000000..575ffa0b --- /dev/null +++ b/pkg/internal/persona/types.go @@ -0,0 +1,244 @@ +package persona + +import ( + "time" +) + +type IdValue struct { + Type string `json:"type"` + Id string `json:"id"` +} + +type HashValue struct { + Type string `json:"type"` + Value map[string]Value `json:"value"` +} + +type StringValue struct { + Type string `json:"type"` + Value *string `json:"value"` +} + +type ArrayValue struct { + Type string `json:"type"` + Value []HashValue `json:"value"` +} + +type Value struct { + Type string `json:"type"` + Value interface{} `json:"value"` +} + +type Link struct { + Prev *string `json:"prev"` + Next *string `json:"next"` +} + +type PhotoURL struct { + Page *string `json:"page"` + Url *string `json:"url"` + fileName *string `json:"fileName"` + NormalizedUrl *string `json:"normalizedUrl"` + OriginalUrls []string `json:"originalUrls"` + ByteSize int `json:"byteSize"` +} + +type Check struct { + Name string `json:"name"` + Status string `json:"status"` + Reason []interface{} `json:"reason"` + Requirement string `json:"requirement"` + Metadata interface{} `json:"metadata"` +} + +type RelationshipId struct { + Data *IdValue `json:"data"` +} + +type RelationshipIds struct { + Data []IdValue `json:"data"` +} + +type Relationships struct { + Account *RelationshipId `json:"account"` + Inquity *RelationshipId `json:"inquiry"` + Template *RelationshipId `json:"template"` + InquityTemplate *RelationshipId `json:"inquiryTemplate"` + InquityTemplateVersion *RelationshipId `json:"inquiryTemplateVersion"` + VerificationTemplate *RelationshipId `json:"verificationTemplate"` + VerificationTemplateVersion *RelationshipId `json:"verificationTemplateVersion"` + Verifications *RelationshipIds `json:"verifications"` + Sessions *RelationshipIds `json:"sessions"` + Documents *RelationshipIds `json:"documents"` + DocumentFiles *RelationshipIds `json:"documentFiles"` + Selfies *RelationshipIds `json:"selfies"` +} + +type Behavior struct { + RequestSpoofAttempts int `json:"requestSpoofAttempts"` + UserAgentSpoofAttempts int `json:"userAgentSpoofAttempts"` + DistractionEvents int `json:"distractionEvents"` + HesitationBaseline int `json:"hesitationBaseline"` + HesitationCount int `json:"hesitationCount"` + HesitationTime int `json:"hesitationTime"` + ShortcutCopies int `json:"shortcutCopies"` + ShortcutPastes int `json:"shortcutPastes"` + AutofillCancels int `json:"autofillCancels"` + AutofillStarts int `json:"autofillStarts"` + DevtoolsOpen bool `json:"devtoolsOpen"` + CompletionTime float64 `json:"completionTime"` + HesitationPercentage float64 `json:"hesitationPercentage"` + BehaviorThreatLevel string `json:"behaviorThreatLevel"` +} + +type AccountFields struct { + Name HashValue `json:"name"` + Address HashValue `json:"address"` + IdentificationNumbers ArrayValue `json:"identificationNumbers"` + Birthdate Value `json:"birthdate"` + PhoneNumber StringValue `json:"phoneNumber"` + EmailAddress StringValue `json:"emailAddress"` + SelfiePhoto Value `json:"selfiePhoto"` +} + +type InquiryFields struct { + AddressStreet1 StringValue `json:"addressStreet1"` + AddressStreet2 StringValue `json:"addressStreet2"` +} + +type Attribute struct { + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` + StartedAt *time.Time `json:"startedAt"` + FailedAt *time.Time `json:"failedAt"` + DecesionedAt *time.Time `json:"decesionedAt"` + MarkForReviewAt *time.Time `json:"markForReviewAt"` + UpdatedAt time.Time `json:"updatedAt"` + RedactedAt *time.Time `json:"redactedAt"` + SubmittedAt *time.Time `json:"submittedAt"` + CompletedAt *time.Time `json:"completedAt"` + ExpiredAt *time.Time `json:"expiredAt"` +} + +type AccountAttributes struct { + Attribute + Fields AccountFields `json:"fields"` +} + +type CommonFields struct { + Attribute + // City of residence address. Not all international addresses use this attribute. + AddresCity string `json:"addressCity,omitempty"` + // Street name of residence address. + AddressStreet1 string `json:"addressStreet1,omitempty"` + // Extension of residence address, usually apartment or suite number. + AddressStreet2 string `json:"addressStreet2,omitempty"` + // State or subdivision of residence address. In the US, + // this should be the unabbreviated name. Not all international addresses use this attribute. + AddressSubdivision string `json:"addressSubdivision,omitempty"` + // Postal code of residence address. Not all international addresses use this attribute. + AddressPostalCode string `json:"addressPostalCode,omitempty"` + // Birthdate, must be in the format "YYYY-MM-DD". + Birthdate string `json:"birthdate,omitempty"` + // ISO 3166-1 alpha 2 country code of the government ID to be verified. This is generally their country of residence as well. + CountryCode string `json:"countryCode,omitempty"` + + EmailAddress string `json:"emailAddress,omitempty"` + // Given or first name. + NameFirst string `json:"nameFirst,omitempty"` + // Family or last name. + NameLast string `json:"nameLast,omitempty"` + + NameMiddle string `json:"nameMiddle,omitempty"` + + PhoneNumber string `json:"phoneNumber,omitempty"` + + SocialSecurityNumber string `json:"socialSecurityNumber,omitempty"` +} + +type CommonAttributes struct { + SelfiePhoto *string `json:"selfiePhoto"` + SelfiePhotoUrl *string `json:"selfiePhotoUrl"` + FrontPhotoUrl *string `json:"frontPhotoUrl"` + BackPhotoUrl *string `json:"backPhotoUrl"` + VideoUrl *string `json:"videoUrl"` + IdClass string `json:"idClass"` + CaptureMethod string `json:"captureMethod"` + EntityConfidenceScore float64 `json:"entityConfidenceScore"` + EntityConfidenceReasons []string `json:"entityConfidenceReasons"` + NameFirst string `json:"nameFirst"` + NameMiddle *string `json:"nameMiddle"` + NameLast string `json:"nameLast"` + NameSuffix *string `json:"nameSuffix"` + Birthdate string `json:"birthdate"` + AddressStreet1 string `json:"addressStreet1"` + AddressStreet2 *string `json:"addressStreet2"` + AddressCity string `json:"addressCity"` + AddressSubdivision string `json:"addressSubdivision"` + AddressPostalCode string `json:"addressPostalCode"` + IssuingAuthority string `json:"issuingAuthority"` + IssuingSubdivision string `json:"issuingSubdivision"` + Nationality *string `json:"nationality"` + DocumentNumber *string `json:"documentNumber"` + VisaStatus *string `json:"visaStatus"` + IssueDate string `json:"issueDate"` + ExpirationDate string `json:"expirationDate"` + Designations *string `json:"designations"` + Birthplace *string `json:"birthplace"` + Endorsements *string `json:"endorsements"` + Height *string `json:"height"` + Sex string `json:"sex"` + Restrictions *string `json:"restrictions"` + VehicleClass *string `json:"vehicleClass"` + IdentificationNumber string `json:"identificationNumber"` +} + +type InquiryCreationAttributes struct { + AccountId string `json:"accountId"` + CountryCode string `json:"countryCode"` + InquityTemplateId string `json:"inquiryTemplateId"` + InquityTemplateVersionId string `json:"inquiryTemplateVersionId"` + // Template ID for flow requirements (use this field if your template ID starts with tmpl_). + // You must pass in either template-id OR inquiry-template-id OR inquiry-template-version-id + TemplateId string `json:"templateId"` + TemplateVersionId string `json:"templateVersionId"` + // for styling + ThemeId string `json:"themeId"` + + Fields *CommonFields `json:"fields"` +} + +type InquiryAttributes struct { + Attribute + ReferenceId *string `json:"referenceId"` + Behaviors Behavior `json:"behaviors"` + Notes *string `json:"notes"` + Tags []interface{} `json:"tags"` + PreviousStepName string `json:"previousStepName"` + NextStepName string `json:"nextStepName"` + Fields InquiryFields `json:"fields"` +} + +type CompletedSteps struct { + Type string `json:"type"` + Status string `json:"status"` +} + +type VerificationAttributes struct { + Attribute + CommonAttributes + CountryCode *string `json:"countryCode"` + LeftPhotoUrl *string `json:"leftPhotoUrl"` + RightPhotoUrl *string `json:"rightPhotoUrl"` + CenterPhotoUrl *string `json:"centerPhotoUrl"` + PhotoUrls []PhotoURL `json:"photoUrls"` + Checks []Check `json:"checks"` + CaptureMethod string `json:"captureMethod"` +} + +type Included struct { + Id string `json:"id"` + Type string `json:"type"` + Atrributes VerificationAttributes `json:"attributes"` + Relationships Relationships `json:"relationships"` +} diff --git a/pkg/internal/persona/verification.go b/pkg/internal/persona/verification.go new file mode 100644 index 00000000..9681a527 --- /dev/null +++ b/pkg/internal/persona/verification.go @@ -0,0 +1,58 @@ +package persona + +import ( + "net/http" + + "github.com/String-xyz/go-lib/v2/common" +) + +/* + +To verify a set of inputs from an individual, a Verification object is created. +A Verification enables an Organization to answer “Is this person who they claim to be?” with a focus on verifying digital transactions. +The verification process is accomplished through the generation and processing of Checks against the information provided by the individual. + +The collective process of mixing and matching verifications to achieve sufficient assurance that +an individual is indeed who they claim to be is often called identity proofing. +The goal of identity proofing is often tying digital identities to physical identities. + +An Inquiry contains one or more verifications. The attributes available for any given verification depends on its type. +Each inquiry’s relationships field lists the IDs of all associated verifications. +To authenticate when fetching photo URLs, pass the same Authorization header. + +Verifications change statuses as the individual progresses through the flow. +Check for the following statuses to monitor progress and find completed results. + +* Initiated - Verification has started, claimed information can now sent and saved to the server for verification +* Confirmed - Verification has been confirmed. This is a status specific to PhoneNumber verifications where they have verified a confirmation code that was entered. +* Submitted - Verification has been submitted, the claimed information is frozen and the server will process the verification +* Passed - Verification has passed. The required checks have passed and the information is verified +* Requires Retry - Verification requires a resubmission. The checks could not be fully processed due to issues with the submitted information +* Failed - Verification has failed. Some or all of the required checks have failed and verification has failed + +*/ + +type Verification struct { + Id string `json:"id"` + Type string `json:"type"` + Attributes VerificationAttributes `json:"attributes"` + Relationships Relationships `json:"relationships"` +} + +func (v Verification) GetType() string { + return v.Type +} + +type VerificationResponse struct { + Data Verification `json:"data"` +} + +func (c *PersonaClient) GetVerificationById(id string) (*VerificationResponse, error) { + verification := &VerificationResponse{} + err := c.doRequest(http.MethodGet, "/v1/verifications/"+id, nil, verification) + if err != nil { + return nil, common.StringError(err, "failed to get verification by id") + } + + return verification, nil +} diff --git a/pkg/internal/unit21/action.go b/pkg/internal/unit21/action.go index e313fe5f..d7338524 100644 --- a/pkg/internal/unit21/action.go +++ b/pkg/internal/unit21/action.go @@ -2,12 +2,13 @@ package unit21 import ( "encoding/json" - "log" - "os" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/internal/common" + "github.com/String-xyz/string-api/pkg/model" - "github.com/String-xyz/string-api/pkg/repository" + "github.com/rs/zerolog/log" ) type Action interface { @@ -17,18 +18,11 @@ type Action interface { eventSubtype string) (unit21Id string, err error) } -type ActionRepo struct { - User repository.User - Device repository.Device - Location repository.Location -} - type action struct { - repo ActionRepo } -func NewAction(r ActionRepo) Action { - return &action{repo: r} +func NewAction() Action { + return &action{} } func (a action) Create( @@ -40,26 +34,26 @@ func (a action) Create( actionData := actionData{ ActionType: instrument.Type, ActionDetails: actionDetails, - EntityId: instrument.UserID, + EntityId: instrument.UserId, EntityType: "user", - InstrumentId: instrument.ID, + InstrumentId: instrument.Id, } - url := "https://" + os.Getenv("UNIT21_ENV") + ".unit21.com/v1/events/create" + url := "https://" + config.Var.UNIT21_ENV + ".unit21.com/v1/events/create" body, err := u21Post(url, mapToUnit21ActionEvent(instrument, actionData, unit21InstrumentId, eventSubtype)) if err != nil { - log.Printf("Unit21 Action create failed: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Unit21 Action create failed") + return "", libcommon.StringError(err) } var u21Response *createEventResponse err = json.Unmarshal(body, &u21Response) if err != nil { - log.Printf("Reading body failed: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Reading body failed") + return "", libcommon.StringError(err) } - log.Printf("Create Action Unit21Id: %s", u21Response.Unit21Id) + log.Info().Str("unit21Id", u21Response.Unit21Id).Msg("Create Action") return u21Response.Unit21Id, nil } @@ -90,10 +84,10 @@ func mapToUnit21ActionEvent(instrument model.Instrument, actionData actionData, actionBody, err := common.BetterStringify(jsonBody) if err != nil { - log.Printf("\nError creating action body\n") + log.Err(err).Msg("Error creating action body") return jsonBody } - log.Printf("\nCreate Action action body: %+v\n", actionBody) + log.Info().Str("body", actionBody).Msg("Create Action action body") return jsonBody } diff --git a/pkg/internal/unit21/base.go b/pkg/internal/unit21/base.go index 1f0e6c89..8f096e01 100644 --- a/pkg/internal/unit21/base.go +++ b/pkg/internal/unit21/base.go @@ -4,31 +4,30 @@ import ( "bytes" "encoding/json" "fmt" - "io/ioutil" - "log" + "io" "net/http" - "os" "time" - "github.com/String-xyz/string-api/pkg/internal/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/string-api/config" + "github.com/rs/zerolog/log" ) func u21Put(url string, jsonBody any) (body []byte, err error) { - apiKey := os.Getenv("UNIT21_API_KEY") + apiKey := config.Var.UNIT21_API_KEY reqBodyBytes, err := json.Marshal(jsonBody) if err != nil { - log.Printf("Could not encode %+v to bytes: %s", jsonBody, err) - return nil, common.StringError(err) + log.Err(err).Msg("Could not encode into bytes") + return nil, libcommon.StringError(err) } - log.Printf("reqBodyBytes: %s", reqBodyBytes) - + log.Info().Str("body", string(reqBodyBytes)).Send() bodyReader := bytes.NewReader(reqBodyBytes) req, err := http.NewRequest(http.MethodPut, url, bodyReader) if err != nil { - log.Printf("Could not create request for %s: %s", url, err) - return nil, common.StringError(err) + log.Err(err).Str("url", url).Msg("Could not create request") + return nil, libcommon.StringError(err) } req.Header.Add("accept", "application/json") @@ -39,21 +38,21 @@ func u21Put(url string, jsonBody any) (body []byte, err error) { res, err := client.Do(req) if err != nil { - log.Printf("Request failed to update %s: %s", url, err) - return nil, common.StringError(err) + log.Err(err).Str("url", url).Msg("Request failed to update") + return nil, libcommon.StringError(err) } defer res.Body.Close() - body, err = ioutil.ReadAll(res.Body) + body, err = io.ReadAll(res.Body) if err != nil { - log.Printf("Error extracting body from %s update request: %s", url, err) - return nil, common.StringError(err) + log.Err(err).Str("url", url).Msg("Error extracting body") + return nil, libcommon.StringError(err) } if res.StatusCode != 200 { - log.Printf("Request failed to update %s: %s", url, fmt.Sprint(res.StatusCode)) - err = common.StringError(fmt.Errorf("request failed with status code %s and return body: %s", fmt.Sprint(res.StatusCode), string(body))) + log.Err(err).Str("url", url).Int("statusCode", res.StatusCode).Msg("Request failed to update") + err = libcommon.StringError(fmt.Errorf("request failed with status code %s and return body: %s", fmt.Sprint(res.StatusCode), string(body))) return } @@ -61,21 +60,20 @@ func u21Put(url string, jsonBody any) (body []byte, err error) { } func u21Post(url string, jsonBody any) (body []byte, err error) { - apiKey := os.Getenv("UNIT21_API_KEY") + apiKey := config.Var.UNIT21_API_KEY reqBodyBytes, err := json.Marshal(jsonBody) - if err != nil { - log.Printf("Could not encode %+v to bytes: %s", jsonBody, err) - return nil, common.StringError(err) + log.Err(err).Msg("Could not encode into bytes") + return nil, libcommon.StringError(err) } bodyReader := bytes.NewReader(reqBodyBytes) req, err := http.NewRequest(http.MethodPost, url, bodyReader) if err != nil { - log.Printf("Could not create request for %s: %s", url, err) - return nil, common.StringError(err) + log.Err(err).Str("url", url).Msg("Could not create request") + return nil, libcommon.StringError(err) } req.Header.Add("accept", "application/json") @@ -86,23 +84,23 @@ func u21Post(url string, jsonBody any) (body []byte, err error) { res, err := client.Do(req) if err != nil { - log.Printf("Request failed to update %s: %s", url, err) - return nil, common.StringError(err) + log.Err(err).Str("url", url).Msg("Request failed to update") + return nil, libcommon.StringError(err) } defer res.Body.Close() - body, err = ioutil.ReadAll(res.Body) + body, err = io.ReadAll(res.Body) if err != nil { - log.Printf("Error extracting body from %s update response: %s", url, err) - return nil, common.StringError(err) + log.Err(err).Str("url", url).Msg("Error extracting body from") + return nil, libcommon.StringError(err) } - log.Printf("String of body from response: %s", string(body)) + log.Info().Str("body", string(body)).Msgf("String of body from response") if res.StatusCode != 200 { - log.Printf("Request failed to update %s: %s", url, fmt.Sprint(res.StatusCode)) - err = common.StringError(fmt.Errorf("request failed with status code %s and return body: %s", fmt.Sprint(res.StatusCode), string(body))) + log.Err(err).Str("url", url).Int("statusCode", res.StatusCode).Msg("Request failed to update") + err = libcommon.StringError(fmt.Errorf("request failed with status code %s and return body: %s", fmt.Sprint(res.StatusCode), string(body))) return } diff --git a/pkg/internal/unit21/entity.go b/pkg/internal/unit21/entity.go index e0c3820a..95348143 100644 --- a/pkg/internal/unit21/entity.go +++ b/pkg/internal/unit21/entity.go @@ -1,25 +1,26 @@ package unit21 import ( + "context" "encoding/json" - "log" - "os" - "github.com/String-xyz/string-api/pkg/internal/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/repository" + "github.com/rs/zerolog/log" ) type Entity interface { - Create(user model.User) (unit21Id string, err error) - Update(user model.User) (unit21Id string, err error) + Create(ctx context.Context, user model.User) (unit21Id string, err error) + Update(ctx context.Context, user model.User) (unit21Id string, err error) AddInstruments(entityId string, instrumentId []string) (err error) } type EntityRepos struct { - Device repository.Device - Contact repository.Contact - UserToPlatform repository.UserToPlatform + Device repository.Device + Contact repository.Contact + User repository.User } type entity struct { @@ -31,119 +32,118 @@ func NewEntity(r EntityRepos) Entity { } // https://docs.unit21.ai/reference/create_entity -func (e entity) Create(user model.User) (unit21Id string, err error) { - +func (e entity) Create(ctx context.Context, user model.User) (unit21Id string, err error) { // ultimately may want a join here. - communications, err := e.getCommunications(user.ID) + communications, err := e.getCommunications(ctx, user.Id) if err != nil { - log.Printf("Failed to gather Unit21 entity communications: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 entity communications") + return "", libcommon.StringError(err) } - digitalData, err := e.getEntityDigitalData(user.ID) + digitalData, err := e.getEntityDigitalData(ctx, user.Id) if err != nil { - log.Printf("Failed to gather Unit21 entity digitalData: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 entity digitalData") + return "", libcommon.StringError(err) } - customData, err := e.getCustomData(user.ID) + customData, err := e.getCustomData(ctx, user.Id) if err != nil { - log.Printf("Failed to gather Unit21 entity customData: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 entity customData") + return "", libcommon.StringError(err) } - url := "https://" + os.Getenv("UNIT21_ENV") + ".unit21.com/v1/entities/create" + url := "https://" + config.Var.UNIT21_ENV + ".unit21.com/v1/entities/create" body, err := u21Post(url, mapUserToEntity(user, communications, digitalData, customData)) if err != nil { - log.Printf("Unit21 Entity create failed: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Unit21 Entity create failed") + return "", libcommon.StringError(err) } var entity *createEntityResponse err = json.Unmarshal(body, &entity) if err != nil { - log.Printf("Reading body failed: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Reading body failed") + return "", libcommon.StringError(err) } - log.Printf("Unit21Id: %s", entity.Unit21Id) + log.Info().Str("Unit21Id", entity.Unit21Id).Send() return entity.Unit21Id, nil } // https://docs.unit21.ai/reference/update_entity -func (e entity) Update(user model.User) (unit21Id string, err error) { +func (e entity) Update(ctx context.Context, user model.User) (unit21Id string, err error) { // ultimately may want a join here. - communications, err := e.getCommunications(user.ID) + communications, err := e.getCommunications(ctx, user.Id) if err != nil { - log.Printf("Failed to gather Unit21 entity communications: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 entity communications") + err = libcommon.StringError(err) return } - digitalData, err := e.getEntityDigitalData(user.ID) + digitalData, err := e.getEntityDigitalData(ctx, user.Id) if err != nil { - log.Printf("Failed to gather Unit21 entity digitalData: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 entity digitalData") + err = libcommon.StringError(err) return } - customData, err := e.getCustomData(user.ID) + customData, err := e.getCustomData(ctx, user.Id) if err != nil { - log.Printf("Failed to gather Unit21 entity customData: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 entity customData") + err = libcommon.StringError(err) return } - orgName := os.Getenv("UNIT21_ORG_NAME") - url := "https://" + os.Getenv("UNIT21_ENV") + ".unit21.com/v1/" + orgName + "/entities/" + user.ID + "/update" + orgName := config.Var.UNIT21_ORG_NAME + url := "https://" + config.Var.UNIT21_ENV + ".unit21.com/v1/" + orgName + "/entities/" + user.Id + "/update" body, err := u21Put(url, mapUserToEntity(user, communications, digitalData, customData)) if err != nil { - log.Printf("Unit21 Entity create failed: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Unit21 Entity create failed") + err = libcommon.StringError(err) return } var entity *updateEntityResponse err = json.Unmarshal(body, &entity) if err != nil { - log.Printf("Reading body failed: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Reading body failed") + err = libcommon.StringError(err) return } - log.Printf("Unit21Id: %s", entity.Unit21Id) + log.Info().Str("Unit21Id", entity.Unit21Id).Send() return entity.Unit21Id, nil } // https://docs.unit21.ai/reference/add_instruments func (e entity) AddInstruments(entityId string, instrumentIds []string) (err error) { - orgName := os.Getenv("UNIT21_ORG_NAME") - url := "https://" + os.Getenv("UNIT21_ENV") + ".unit21.com/v1/" + orgName + "/entities/" + entityId + "/add-instruments" + orgName := config.Var.UNIT21_ORG_NAME + url := "https://" + config.Var.UNIT21_ENV + ".unit21.com/v1/" + orgName + "/entities/" + entityId + "/add-instruments" instruments := make(map[string][]string) instruments["instrument_ids"] = instrumentIds _, err = u21Put(url, instruments) if err != nil { - log.Printf("Unit21 Entity Add Instruments failed: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Unit21 Entity Add Instruments failed") + err = libcommon.StringError(err) return } return } -func (e entity) getCommunications(userId string) (communications entityCommunication, err error) { +func (e entity) getCommunications(ctx context.Context, userId string) (communications entityCommunication, err error) { // Get user contacts - contacts, err := e.repo.Contact.ListByUserId(userId, 100, 0) + contacts, err := e.repo.Contact.ListByUserId(ctx, userId, 100, 0) if err != nil { - log.Printf("Failed to get user contacts: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed to get user contacts") + err = libcommon.StringError(err) return } @@ -158,11 +158,11 @@ func (e entity) getCommunications(userId string) (communications entityCommunica return } -func (e entity) getEntityDigitalData(userId string) (deviceData entityDigitalData, err error) { - devices, err := e.repo.Device.ListByUserId(userId, 100, 0) +func (e entity) getEntityDigitalData(ctx context.Context, userId string) (deviceData entityDigitalData, err error) { + devices, err := e.repo.Device.ListByUserId(ctx, userId, 100, 0) if err != nil { - log.Printf("Failed to get user devices: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed to get user devices") + err = libcommon.StringError(err) return } @@ -173,16 +173,16 @@ func (e entity) getEntityDigitalData(userId string) (deviceData entityDigitalDat return } -func (e entity) getCustomData(userId string) (customData entityCustomData, err error) { - devices, err := e.repo.UserToPlatform.ListByUserId(userId, 100, 0) +func (e entity) getCustomData(ctx context.Context, userId string) (customData entityCustomData, err error) { + platforms, err := e.repo.User.GetPlatforms(ctx, userId, 100, 0) if err != nil { - log.Printf("Failed to get user platforms: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed to get user platforms") + err = libcommon.StringError(err) return } - for _, platform := range devices { - customData.Platforms = append(customData.Platforms, platform.PlatformID) + for _, platform := range platforms { + customData.Platforms = append(customData.Platforms, platform.Id) } return } @@ -197,7 +197,7 @@ func mapUserToEntity(user model.User, communication entityCommunication, digital jsonBody := &u21Entity{ GeneralData: &entityGeneral{ - EntityId: user.ID, + EntityId: user.Id, EntityType: "user", Status: user.Status, RegisteredAt: int(user.CreatedAt.Unix()), diff --git a/pkg/internal/unit21/entity_test.go b/pkg/internal/unit21/entity_test.go index 7e387e9b..df46e0fe 100644 --- a/pkg/internal/unit21/entity_test.go +++ b/pkg/internal/unit21/entity_test.go @@ -1,21 +1,25 @@ package unit21 import ( + "context" "database/sql" "testing" "time" + env "github.com/String-xyz/go-lib/v2/config" + "github.com/DATA-DOG/go-sqlmock" + "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/repository" "github.com/google/uuid" "github.com/jmoiron/sqlx" - "github.com/joho/godotenv" "github.com/lib/pq" "github.com/stretchr/testify/assert" ) func TestCreateEntity(t *testing.T) { + env.LoadEnv(&config.Var, "../../../.env") db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) defer db.Close() @@ -31,6 +35,8 @@ func TestCreateEntity(t *testing.T) { } func TestUpdateEntity(t *testing.T) { + env.LoadEnv(&config.Var, "../../../.env") + ctx := context.Background() db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) defer db.Close() @@ -41,40 +47,39 @@ func TestUpdateEntity(t *testing.T) { assert.Greater(t, len([]rune(u21EntityId)), 0) user := model.User{ - ID: entityId, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - DeactivatedAt: nil, - Type: "User", - Status: "Onboarded", - Tags: nil, - FirstName: "Test", - MiddleName: "A", - LastName: "User", + Id: entityId, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Type: "User", + Status: "Onboarded", + Tags: nil, + FirstName: "Test", + MiddleName: "A", + LastName: "User", } mockedContactRow := sqlmock.NewRows([]string{"id", "user_id", "type", "status", "data"}). AddRow(uuid.NewString(), entityId, "email", "verified", "test@gmail.com").AddRow(uuid.NewString(), entityId, "phone", "verified", "+12345678910") - mock.ExpectQuery("SELECT * FROM contact WHERE user_id = $1 LIMIT $2 OFFSET $3").WithArgs(entityId, 100, 0).WillReturnRows(mockedContactRow) + mock.ExpectQuery("SELECT * FROM contact WHERE user_id = $1 AND deleted_at IS NULL LIMIT $2 OFFSET $3").WithArgs(entityId, 100, 0).WillReturnRows(mockedContactRow) mockedDeviceRow := sqlmock.NewRows([]string{"id", "type", "description", "fingerprint", "ip_addresses", "user_id"}). AddRow(uuid.NewString(), "Mobile", "iPhone 11S", uuid.NewString(), pq.StringArray{"187.25.24.128"}, entityId) - mock.ExpectQuery("SELECT * FROM device WHERE user_id = $1 LIMIT $2 OFFSET $3").WithArgs(entityId, 100, 0).WillReturnRows(mockedDeviceRow) + mock.ExpectQuery("SELECT * FROM device WHERE user_id = $1 AND deleted_at IS NULL LIMIT $2 OFFSET $3").WithArgs(entityId, 100, 0).WillReturnRows(mockedDeviceRow) - mockedUserPlatformRow := sqlmock.NewRows([]string{"user_id", "platform_id"}). - AddRow(entityId, uuid.NewString()) - mock.ExpectQuery("SELECT * FROM user_to_platform WHERE user_id = $1 LIMIT $2 OFFSET $3").WithArgs(entityId, 100, 0).WillReturnRows(mockedUserPlatformRow) + mockedPlatformRows := sqlmock.NewRows([]string{"id", "name", "description", "domains", "ip_addresses", "organization_id"}). + AddRow(uuid.NewString(), "Starcraft III", "Return of the Xel'Naga", nil, nil, uuid.NewString()) + mock.ExpectQuery("SELECT platform.* FROM platform LEFT JOIN user_to_platform ON platform.id = user_to_platform.platform_id WHERE user_to_platform.user_id = $1 AND platform.deleted_at IS NULL LIMIT $2 OFFSET $3").WithArgs(entityId, 100, 0).WillReturnRows(mockedPlatformRows) repos := EntityRepos{ - Device: repository.NewDevice(sqlxDB), - Contact: repository.NewContact(sqlxDB), - UserToPlatform: repository.NewUserToPlatform(sqlxDB), + Device: repository.NewDevice(sqlxDB), + Contact: repository.NewContact(sqlxDB), + User: repository.NewUser(sqlxDB), } u21Entity := NewEntity(repos) // update in u21 - u21EntityId, err = u21Entity.Update(user) + u21EntityId, err = u21Entity.Update(ctx, user) assert.NoError(t, err) assert.Greater(t, len([]rune(u21EntityId)), 0) @@ -84,6 +89,7 @@ func TestUpdateEntity(t *testing.T) { } func TestAddInstruments(t *testing.T) { + env.LoadEnv(&config.Var, "../../../.env") db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) defer db.Close() @@ -102,9 +108,9 @@ func TestAddInstruments(t *testing.T) { } repos := EntityRepos{ - Device: repository.NewDevice(sqlxDB), - Contact: repository.NewContact(sqlxDB), - UserToPlatform: repository.NewUserToPlatform(sqlxDB), + Device: repository.NewDevice(sqlxDB), + Contact: repository.NewContact(sqlxDB), + User: repository.NewUser(sqlxDB), } u21Entity := NewEntity(repos) @@ -117,99 +123,96 @@ func TestAddInstruments(t *testing.T) { } func createMockUser(mock sqlmock.Sqlmock, sqlxDB *sqlx.DB) (entityId string, unit21Id string, err error) { + ctx := context.Background() entityId = uuid.NewString() user := model.User{ - ID: entityId, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - DeactivatedAt: nil, - Type: "User", - Status: "Onboarded", - Tags: model.StringMap{"platform": "Activision Blizzard"}, - FirstName: "Test", - MiddleName: "A", - LastName: "User", + Id: entityId, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Type: "User", + Status: "Onboarded", + Tags: model.StringMap{"platform": "Activision Blizzard"}, + FirstName: "Test", + MiddleName: "A", + LastName: "User", } mockedContactRow := sqlmock.NewRows([]string{"id", "user_id", "type", "status", "data"}). AddRow(uuid.NewString(), entityId, "email", "verified", "test@gmail.com").AddRow(uuid.NewString(), entityId, "phone", "verified", "+12345678910") - mock.ExpectQuery("SELECT * FROM contact WHERE user_id = $1 LIMIT $2 OFFSET $3").WithArgs(entityId, 100, 0).WillReturnRows(mockedContactRow) + mock.ExpectQuery("SELECT * FROM contact WHERE user_id = $1 AND deleted_at IS NULL LIMIT $2 OFFSET $3").WithArgs(entityId, 100, 0).WillReturnRows(mockedContactRow) mockedDeviceRow := sqlmock.NewRows([]string{"id", "type", "description", "fingerprint", "ip_addresses", "user_id"}). AddRow(uuid.NewString(), "Mobile", "iPhone 11S", uuid.NewString(), pq.StringArray{"187.25.24.128"}, entityId) - mock.ExpectQuery("SELECT * FROM device WHERE user_id = $1 LIMIT $2 OFFSET $3").WithArgs(entityId, 100, 0).WillReturnRows(mockedDeviceRow) + mock.ExpectQuery("SELECT * FROM device WHERE user_id = $1 AND deleted_at IS NULL LIMIT $2 OFFSET $3").WithArgs(entityId, 100, 0).WillReturnRows(mockedDeviceRow) - mockedUserPlatformRow := sqlmock.NewRows([]string{"user_id", "platform_id"}). - AddRow(entityId, uuid.NewString()) - mock.ExpectQuery("SELECT * FROM user_to_platform WHERE user_id = $1 LIMIT $2 OFFSET $3").WithArgs(entityId, 100, 0).WillReturnRows(mockedUserPlatformRow) + mockedPlatformRows := sqlmock.NewRows([]string{"id", "name", "description", "domains", "ip_addresses", "organization_id"}). + AddRow(uuid.NewString(), "Starcraft III", "Return of the Xel'Naga", nil, nil, uuid.NewString()) + mock.ExpectQuery("SELECT platform.* FROM platform LEFT JOIN user_to_platform ON platform.id = user_to_platform.platform_id WHERE user_to_platform.user_id = $1 AND platform.deleted_at IS NULL LIMIT $2 OFFSET $3").WithArgs(entityId, 100, 0).WillReturnRows(mockedPlatformRows) repos := EntityRepos{ - Device: repository.NewDevice(sqlxDB), - Contact: repository.NewContact(sqlxDB), - UserToPlatform: repository.NewUserToPlatform(sqlxDB), + Device: repository.NewDevice(sqlxDB), + Contact: repository.NewContact(sqlxDB), + User: repository.NewUser(sqlxDB), } u21Entity := NewEntity(repos) - u21EntityId, err := u21Entity.Create(user) + u21EntityId, err := u21Entity.Create(ctx, user) return entityId, u21EntityId, err } func createMockInstrumentForUser(userId string, mock sqlmock.Sqlmock, sqlxDB *sqlx.DB) (instrument model.Instrument, unit21Id string, err error) { + ctx := context.Background() instrumentId := uuid.NewString() locationId := uuid.NewString() instrument = model.Instrument{ - ID: instrumentId, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - DeactivatedAt: nil, - Type: "Credit Card", - Status: "Verified", - Tags: nil, - Network: "Visa", - PublicKey: "", - Last4: "1234", - UserID: userId, - LocationID: sql.NullString{String: locationId}, + Id: instrumentId, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Type: "Credit Card", + Status: "Verified", + Tags: nil, + Network: "Visa", + PublicKey: "", + Last4: "1234", + UserId: userId, + LocationId: sql.NullString{String: locationId}, } mockedUserRow1 := sqlmock.NewRows([]string{"id", "type", "status", "tags", "first_name", "middle_name", "last_name"}). AddRow(userId, "User", "Onboarded", `{"kyc_level": "1", "platform": "mortal kombat"}`, "Daemon", "", "Targaryan") - mock.ExpectQuery("SELECT * FROM string_user WHERE id = $1 AND deactivated_at IS NULL").WithArgs(userId).WillReturnRows(mockedUserRow1) + mock.ExpectQuery("SELECT * FROM string_user WHERE id = $1 AND deleted_at IS NULL").WithArgs(userId).WillReturnRows(mockedUserRow1) mockedUserRow2 := sqlmock.NewRows([]string{"id", "type", "status", "tags", "first_name", "middle_name", "last_name"}). AddRow(userId, "User", "Onboarded", `{"kyc_level": "1", "platform": "mortal kombat"}`, "Daemon", "", "Targaryan") - mock.ExpectQuery("SELECT * FROM string_user WHERE id = $1 AND deactivated_at IS NULL").WithArgs(userId).WillReturnRows(mockedUserRow2) + mock.ExpectQuery("SELECT * FROM string_user WHERE id = $1 AND deleted_at IS NULL").WithArgs(userId).WillReturnRows(mockedUserRow2) mockedDeviceRow := sqlmock.NewRows([]string{"id", "type", "description", "fingerprint", "ip_addresses", "user_id"}). AddRow(uuid.NewString(), "Mobile", "iPhone 11S", uuid.NewString(), pq.StringArray{"187.25.24.128"}, userId) - mock.ExpectQuery("SELECT * FROM device WHERE user_id = $1 LIMIT $2 OFFSET $3").WithArgs(userId, 100, 0).WillReturnRows(mockedDeviceRow) + mock.ExpectQuery("SELECT * FROM device WHERE user_id = $1 AND deleted_at IS NULL LIMIT $2 OFFSET $3").WithArgs(userId, 100, 0).WillReturnRows(mockedDeviceRow) mockedLocationRow := sqlmock.NewRows([]string{"id", "type", "status", "building_number", "unit_number", "street_name", "city", "state", "postal_code", "country"}). AddRow(locationId, "Home", "Verified", "20181", "411", "Lark Avenue", "Somerville", "MA", "01443", "USA") - mock.ExpectQuery("SELECT * FROM location WHERE id = $1 AND deactivated_at IS NULL").WithArgs(locationId).WillReturnRows(mockedLocationRow) + mock.ExpectQuery("SELECT * FROM location WHERE id = $1 AND deleted_at IS NULL").WithArgs(locationId).WillReturnRows(mockedLocationRow) - repo := InstrumentRepo{ + repos := InstrumentRepos{ User: repository.NewUser(sqlxDB), Device: repository.NewDevice(sqlxDB), Location: repository.NewLocation(sqlxDB), } - u21Instrument := NewInstrument(repo) + action := NewAction() + + u21Instrument := NewInstrument(repos, action) - u21InstrumentId, err := u21Instrument.Create(instrument) + u21InstrumentId, err := u21Instrument.Create(ctx, instrument) return instrument, u21InstrumentId, err } func initializeTest(t *testing.T) (db *sql.DB, mock sqlmock.Sqlmock, sqlxDB *sqlx.DB, err error) { - err = godotenv.Load("../../../.env") - if err != nil { - t.Fatalf("error %s was not expected when loading env", err) - } - db, mock, err = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) sqlxDB = sqlx.NewDb(db, "sqlmock") if err != nil { diff --git a/pkg/internal/unit21/evaluate_test.go b/pkg/internal/unit21/evaluate_test.go index fd108ff5..16fa6987 100644 --- a/pkg/internal/unit21/evaluate_test.go +++ b/pkg/internal/unit21/evaluate_test.go @@ -1,10 +1,13 @@ package unit21 import ( + "context" "fmt" "testing" "time" + env "github.com/String-xyz/go-lib/v2/config" + "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/repository" "github.com/google/uuid" @@ -14,6 +17,8 @@ import ( // This transaction should pass func TestEvaluateTransactionPass(t *testing.T) { + env.LoadEnv(&config.Var, "../../../.env") + ctx := context.Background() db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) defer db.Close() @@ -25,13 +30,15 @@ func TestEvaluateTransactionPass(t *testing.T) { instrumentId1 := uuid.NewString() instrumentId2 := uuid.NewString() mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) - pass, err := evaluateMockTransaction(transaction, sqlxDB) + pass, err := evaluateMockTransaction(ctx, transaction, sqlxDB) assert.NoError(t, err) assert.True(t, pass) } // Entity makes a credit card purchase over $1,500 func TestEvaluateTransactionAbnormalAmounts(t *testing.T) { + env.LoadEnv(&config.Var, "../../../.env") + ctx := context.Background() db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) defer db.Close() @@ -43,58 +50,55 @@ func TestEvaluateTransactionAbnormalAmounts(t *testing.T) { instrumentId1 := uuid.NewString() instrumentId2 := uuid.NewString() mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) - pass, err := evaluateMockTransaction(transaction, sqlxDB) - assert.NoError(t, err) + pass, err := evaluateMockTransaction(ctx, transaction, sqlxDB) + assert.Error(t, err) assert.False(t, pass) } -// User links more than 5 cards to their account in a 1 hour span -// Not currently functioning due to lag in Unit21 data ingestion -func TestEvaluateTransactionManyLinkedCards(t *testing.T) { - db, mock, sqlxDB, err := initializeTest(t) - assert.NoError(t, err) - defer db.Close() - - userId, u21UserId, err := createMockUser(mock, sqlxDB) - assert.NoError(t, err) - assert.Greater(t, len([]rune(u21UserId)), 0) - - // create 6 instruments - for i := 0; i <= 5; i++ { - var u21InstrumentId string - instrument, u21InstrumentId, err := createMockInstrumentForUser(userId, mock, sqlxDB) - assert.NoError(t, err) - assert.Greater(t, len([]rune(u21InstrumentId)), 0) - - // Log create instrument action w/ Unit21 - u21ActionRepo := ActionRepo{ - User: repository.NewUser(sqlxDB), - Device: repository.NewDevice(sqlxDB), - Location: repository.NewLocation(sqlxDB), - } - - u21Action := NewAction(u21ActionRepo) - _, err = u21Action.Create(instrument, "Creation", u21InstrumentId, "Creation") - if err != nil { - fmt.Printf("Error creating a new instrument action in Unit21") - return - } - } - - transaction := createMockTransactionForUser(userId, "1000000", sqlxDB) - assetId1 := uuid.NewString() - assetId2 := uuid.NewString() - instrumentId1 := uuid.NewString() - instrumentId2 := uuid.NewString() - mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) - time.Sleep(10 * time.Second) - pass, err := evaluateMockTransaction(transaction, sqlxDB) - assert.NoError(t, err) - assert.False(t, pass) -} +// // User links more than 5 cards to their account in a 1 hour span +// // Not currently functioning due to lag in Unit21 data ingestion +// func TestEvaluateTransactionManyLinkedCards(t *testing.T) { +// env.LoadEnv(&config.Var, "../../../.env") +// ctx := context.Background() +// db, mock, sqlxDB, err := initializeTest(t) +// assert.NoError(t, err) +// defer db.Close() + +// userId, u21UserId, err := createMockUser(mock, sqlxDB) +// assert.NoError(t, err) +// assert.Greater(t, len([]rune(u21UserId)), 0) + +// // create 6 instruments +// for i := 0; i <= 5; i++ { +// var u21InstrumentId string +// instrument, u21InstrumentId, err := createMockInstrumentForUser(userId, mock, sqlxDB) +// assert.NoError(t, err) +// assert.Greater(t, len([]rune(u21InstrumentId)), 0) + +// u21Action := NewAction() +// _, err = u21Action.Create(instrument, "Creation", u21InstrumentId, "Creation") +// if err != nil { +// t.Log("Error creating a new instrument action in Unit21") +// return +// } +// } + +// transaction := createMockTransactionForUser(userId, "1000000", sqlxDB) +// assetId1 := uuid.NewString() +// assetId2 := uuid.NewString() +// instrumentId1 := uuid.NewString() +// instrumentId2 := uuid.NewString() +// mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) +// time.Sleep(10 * time.Second) +// pass, err := evaluateMockTransaction(ctx, transaction, sqlxDB) +// assert.NoError(t, err) +// assert.False(t, pass) +// } // 10 or more FAILED transactions in a 1 hour span func TestEvaluateTransactionHighFailedTransactionAmount(t *testing.T) { + env.LoadEnv(&config.Var, "../../../.env") + ctx := context.Background() db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) defer db.Close() @@ -110,12 +114,12 @@ func TestEvaluateTransactionHighFailedTransactionAmount(t *testing.T) { instrumentId1 := uuid.NewString() instrumentId2 := uuid.NewString() mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) - pass, err := evaluateMockTransaction(transaction, sqlxDB) - assert.NoError(t, err) + pass, err := evaluateMockTransaction(ctx, transaction, sqlxDB) + assert.Error(t, err) assert.False(t, pass) transaction.Status = "Failed" mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) - u21TransactionId, err := executeMockTransactionForUser(transaction, sqlxDB) + u21TransactionId, err := executeMockTransactionForUser(ctx, transaction, sqlxDB) assert.NoError(t, err) assert.Greater(t, len([]rune(u21TransactionId)), 0) } @@ -128,14 +132,16 @@ func TestEvaluateTransactionHighFailedTransactionAmount(t *testing.T) { instrumentId2 := uuid.NewString() mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) time.Sleep(10 * time.Second) - pass, err := evaluateMockTransaction(transaction, sqlxDB) - assert.NoError(t, err) + pass, err := evaluateMockTransaction(ctx, transaction, sqlxDB) + assert.Error(t, err) assert.False(t, pass) } // User onboarded in the last 48 hours and has // transacted more than 7.5K in the last 90 minutes func TestEvaluateTransactionNewUserHighSpend(t *testing.T) { + env.LoadEnv(&config.Var, "../../../.env") + ctx := context.Background() db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) defer db.Close() @@ -151,11 +157,11 @@ func TestEvaluateTransactionNewUserHighSpend(t *testing.T) { instrumentId1 := uuid.NewString() instrumentId2 := uuid.NewString() mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) - pass, err := evaluateMockTransaction(transaction, sqlxDB) + pass, err := evaluateMockTransaction(ctx, transaction, sqlxDB) assert.NoError(t, err) assert.True(t, pass) mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) - u21TransactionId, err := executeMockTransactionForUser(transaction, sqlxDB) + u21TransactionId, err := executeMockTransactionForUser(ctx, transaction, sqlxDB) assert.NoError(t, err) assert.Greater(t, len([]rune(u21TransactionId)), 0) } @@ -168,21 +174,32 @@ func TestEvaluateTransactionNewUserHighSpend(t *testing.T) { instrumentId2 := uuid.NewString() mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) time.Sleep(10 * time.Second) - pass, err := evaluateMockTransaction(transaction, sqlxDB) - assert.NoError(t, err) + pass, err := evaluateMockTransaction(ctx, transaction, sqlxDB) + assert.Error(t, err) assert.False(t, pass) } -func evaluateMockTransaction(transaction model.Transaction, sqlxDB *sqlx.DB) (pass bool, err error) { - repo := TransactionRepo{ - TxLeg: repository.NewTxLeg((sqlxDB)), - User: repository.NewUser(sqlxDB), - Asset: repository.NewAsset(sqlxDB), +func evaluateMockTransaction(ctx context.Context, transaction model.Transaction, sqlxDB *sqlx.DB) (pass bool, err error) { + repos := TransactionRepos{ + TxLeg: repository.NewTxLeg((sqlxDB)), + User: repository.NewUser(sqlxDB), + Asset: repository.NewAsset(sqlxDB), + Device: repository.NewDevice(sqlxDB), } - u21Transaction := NewTransaction(repo) + u21Transaction := NewTransaction(repos) - pass, err = u21Transaction.Evaluate(transaction) + results, err := u21Transaction.Evaluate(ctx, transaction) + if err != nil { + return false, err + } + if len(results) > 0 { + errorString := "\n" + for _, rule := range results { + errorString += fmt.Sprintln("Rule: ", rule.RuleName, " - ", rule.Status) + } + return false, fmt.Errorf("risk: Transaction Failed Unit21 Real Time Rules Evaluation with results: %+v", errorString) + } - return + return true, err } diff --git a/pkg/internal/unit21/instrument.go b/pkg/internal/unit21/instrument.go index d4b72c7d..2e6668ff 100644 --- a/pkg/internal/unit21/instrument.go +++ b/pkg/internal/unit21/instrument.go @@ -1,134 +1,144 @@ package unit21 import ( + "context" "encoding/json" - "log" - "os" - "github.com/String-xyz/string-api/pkg/internal/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/repository" + "github.com/rs/zerolog/log" ) type Instrument interface { - Create(instrument model.Instrument) (unit21Id string, err error) - Update(instrument model.Instrument) (unit21Id string, err error) + Create(ctx context.Context, instrument model.Instrument) (unit21Id string, err error) + Update(ctx context.Context, instrument model.Instrument) (unit21Id string, err error) } -type InstrumentRepo struct { +type InstrumentRepos struct { User repository.User Device repository.Device Location repository.Location } type instrument struct { - repo InstrumentRepo + action Action + repos InstrumentRepos } -func NewInstrument(r InstrumentRepo) Instrument { - return &instrument{repo: r} +func NewInstrument(r InstrumentRepos, a Action) Instrument { + return &instrument{repos: r, action: a} } -func (i instrument) Create(instrument model.Instrument) (unit21Id string, err error) { +func (i instrument) Create(ctx context.Context, instrument model.Instrument) (unit21Id string, err error) { - source, err := i.getSource(instrument.UserID) + source, err := i.getSource(ctx, instrument.UserId) if err != nil { - log.Printf("Failed to gather Unit21 instrument source: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 instrument source") + return "", libcommon.StringError(err) } - entities, err := i.getEntities(instrument.UserID) + entities, err := i.getEntities(ctx, instrument.UserId) if err != nil { - log.Printf("Failed to gather Unit21 instrument entity: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 instrument entity") + return "", libcommon.StringError(err) } - digitalData, err := i.getInstrumentDigitalData(instrument.UserID) + digitalData, err := i.getInstrumentDigitalData(ctx, instrument.UserId) if err != nil { - log.Printf("Failed to gather Unit21 entity digitalData: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 entity digitalData") + return "", libcommon.StringError(err) } - locationData, err := i.getLocationData(instrument.LocationID.String) + locationData, err := i.getLocationData(ctx, instrument.LocationId.String) if err != nil { - log.Printf("Failed to gather Unit21 instrument location: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 instrument location") + return "", libcommon.StringError(err) } - url := "https://" + os.Getenv("UNIT21_ENV") + ".unit21.com/v1/instruments/create" + url := "https://" + config.Var.UNIT21_ENV + ".unit21.com/v1/instruments/create" body, err := u21Post(url, mapToUnit21Instrument(instrument, source, entities, digitalData, locationData)) if err != nil { - log.Printf("Unit21 Instrument create failed: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Unit21 Instrument create failed") + return "", libcommon.StringError(err) } var u21Response *createInstrumentResponse err = json.Unmarshal(body, &u21Response) if err != nil { - log.Printf("Reading body failed: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Reading body failed") + return "", libcommon.StringError(err) + } + + log.Info().Str("Unit21Id", u21Response.Unit21Id).Send() + + // Log create instrument action w/ Unit21 + _, err = i.action.Create(instrument, "Creation", u21Response.Unit21Id, "Creation") + if err != nil { + log.Err(err).Msg("Error creating a new instrument action in Unit21") + return u21Response.Unit21Id, libcommon.StringError(err) } - log.Printf("Unit21Id: %s", u21Response.Unit21Id) return u21Response.Unit21Id, nil } -func (i instrument) Update(instrument model.Instrument) (unit21Id string, err error) { +func (i instrument) Update(ctx context.Context, instrument model.Instrument) (unit21Id string, err error) { - source, err := i.getSource(instrument.UserID) + source, err := i.getSource(ctx, instrument.UserId) if err != nil { - log.Printf("Failed to gather Unit21 instrument source: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 instrument source") + return "", libcommon.StringError(err) } - entities, err := i.getEntities(instrument.UserID) + entities, err := i.getEntities(ctx, instrument.UserId) if err != nil { - log.Printf("Failed to gather Unit21 instrument entity: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 instrument entity") + return "", libcommon.StringError(err) } - digitalData, err := i.getInstrumentDigitalData(instrument.UserID) + digitalData, err := i.getInstrumentDigitalData(ctx, instrument.UserId) if err != nil { - log.Printf("Failed to gather Unit21 entity digitalData: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 entity digitalData") + return "", libcommon.StringError(err) } - locationData, err := i.getLocationData(instrument.LocationID.String) + locationData, err := i.getLocationData(ctx, instrument.LocationId.String) if err != nil { - log.Printf("Failed to gather Unit21 instrument location: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 instrument location") + return "", libcommon.StringError(err) } - orgName := os.Getenv("UNIT21_ORG_NAME") - url := "https://" + os.Getenv("UNIT21_ENV") + ".unit21.com/v1/" + orgName + "/instruments/" + instrument.ID + "/update" + orgName := config.Var.UNIT21_ORG_NAME + url := "https://" + config.Var.UNIT21_ENV + ".unit21.com/v1/" + orgName + "/instruments/" + instrument.Id + "/update" body, err := u21Put(url, mapToUnit21Instrument(instrument, source, entities, digitalData, locationData)) if err != nil { - log.Printf("Unit21 Instrument create failed: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Unit21 Instrument create failed") + return "", libcommon.StringError(err) } var u21Response *updateInstrumentResponse err = json.Unmarshal(body, &u21Response) if err != nil { - log.Printf("Reading body failed: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Reading body failed") + return "", libcommon.StringError(err) } - log.Printf("Unit21Id: %s", u21Response.Unit21Id) + log.Info().Str("Unit21Id", u21Response.Unit21Id).Send() return u21Response.Unit21Id, nil } -func (i instrument) getSource(userId string) (source string, err error) { +func (i instrument) getSource(ctx context.Context, userId string) (source string, err error) { if userId == "" { - log.Printf("No userId defined") + log.Warn().Msg("No userId defined") return } - user, err := i.repo.User.GetById(userId) + user, err := i.repos.User.GetById(ctx, userId) if err != nil { - log.Printf("Failed go get user contacts: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Failed go get user contacts") + return "", libcommon.StringError(err) } if user.Tags["internal"] == "true" { @@ -137,16 +147,16 @@ func (i instrument) getSource(userId string) (source string, err error) { return "external", nil } -func (i instrument) getEntities(userId string) (entity instrumentEntity, err error) { +func (i instrument) getEntities(ctx context.Context, userId string) (entity instrumentEntity, err error) { if userId == "" { - log.Printf("No userId defined") + log.Warn().Msg("No userId defined") return } - user, err := i.repo.User.GetById(userId) + user, err := i.repos.User.GetById(ctx, userId) if err != nil { - log.Printf("Failed go get user contacts: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed go get user contacts") + err = libcommon.StringError(err) return } @@ -158,16 +168,16 @@ func (i instrument) getEntities(userId string) (entity instrumentEntity, err err return entity, nil } -func (i instrument) getInstrumentDigitalData(userId string) (digitalData instrumentDigitalData, err error) { +func (i instrument) getInstrumentDigitalData(ctx context.Context, userId string) (digitalData instrumentDigitalData, err error) { if userId == "" { - log.Printf("No userId defined") + log.Warn().Msg("No userId defined") return } - devices, err := i.repo.Device.ListByUserId(userId, 100, 0) + devices, err := i.repos.Device.ListByUserId(ctx, userId, 100, 0) if err != nil { - log.Printf("Failed to get user devices: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed to get user devices") + err = libcommon.StringError(err) return } @@ -177,35 +187,36 @@ func (i instrument) getInstrumentDigitalData(userId string) (digitalData instrum return } -func (i instrument) getLocationData(locationId string) (locationData instrumentLocationData, err error) { +func (i instrument) getLocationData(ctx context.Context, locationId string) (locationData *instrumentLocationData, err error) { if locationId == "" { - log.Printf("No locationId defined") + log.Warn().Msg("No locationId defined") return } - location, err := i.repo.Location.GetById(locationId) + location, err := i.repos.Location.GetById(ctx, locationId) if err != nil { - log.Printf("Failed go get instrument location: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed go get instrument location") + err = libcommon.StringError(err) return } - - locationData = instrumentLocationData{ - Type: location.Type, - BuildingNumber: location.BuildingNumber, - UnitNumber: location.UnitNumber, - StreetName: location.StreetName, - City: location.City, - State: location.State, - PostalCode: location.PostalCode, - Country: location.Country, - VerifiedOn: int(location.CreatedAt.Unix()), + if location.CreatedAt.Unix() != 0 { + locationData = &instrumentLocationData{ + Type: location.Type, + BuildingNumber: location.BuildingNumber, + UnitNumber: location.UnitNumber, + StreetName: location.StreetName, + City: location.City, + State: location.State, + PostalCode: location.PostalCode, + Country: location.Country, + VerifiedOn: int(location.CreatedAt.Unix()), + } } return locationData, nil } -func mapToUnit21Instrument(instrument model.Instrument, source string, entityData instrumentEntity, digitalData instrumentDigitalData, locationData instrumentLocationData) *u21Instrument { +func mapToUnit21Instrument(instrument model.Instrument, source string, entityData instrumentEntity, digitalData instrumentDigitalData, locationData *instrumentLocationData) *u21Instrument { var instrumentTagArr []string if instrument.Tags != nil { for key, value := range instrument.Tags { @@ -217,7 +228,7 @@ func mapToUnit21Instrument(instrument model.Instrument, source string, entityDat entityArray = append(entityArray, entityData) jsonBody := &u21Instrument{ - InstrumentId: instrument.ID, + InstrumentId: instrument.Id, InstrumentType: instrument.Type, // InstrumentSubtype: "", // Source: "internal", @@ -225,12 +236,10 @@ func mapToUnit21Instrument(instrument model.Instrument, source string, entityDat RegisteredAt: int(instrument.CreatedAt.Unix()), ParentInstrumentId: "", Entities: entityArray, - CustomData: &instrumentCustomData{ - None: nil, - }, - DigitalData: &digitalData, - LocationData: &locationData, - Tags: instrumentTagArr, + CustomData: nil, //TODO: include platform in customData + DigitalData: &digitalData, + LocationData: locationData, + Tags: instrumentTagArr, // Options: &options, } diff --git a/pkg/internal/unit21/instrument_test.go b/pkg/internal/unit21/instrument_test.go index 73f72062..1e457375 100644 --- a/pkg/internal/unit21/instrument_test.go +++ b/pkg/internal/unit21/instrument_test.go @@ -1,11 +1,15 @@ package unit21 import ( + "context" "database/sql" "testing" "time" + env "github.com/String-xyz/go-lib/v2/config" + "github.com/DATA-DOG/go-sqlmock" + "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/repository" "github.com/google/uuid" @@ -14,6 +18,7 @@ import ( ) func TestCreateInstrument(t *testing.T) { + env.LoadEnv(&config.Var, "../../../.env") db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) defer db.Close() @@ -27,6 +32,8 @@ func TestCreateInstrument(t *testing.T) { } func TestUpdateInstrument(t *testing.T) { + env.LoadEnv(&config.Var, "../../../.env") + ctx := context.Background() db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) defer db.Close() @@ -40,44 +47,46 @@ func TestUpdateInstrument(t *testing.T) { locationId := uuid.NewString() instrument = model.Instrument{ - ID: instrument.ID, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - DeactivatedAt: nil, - Type: "Credit Card", - Status: "Verified", - Tags: nil, - Network: "Visa", - PublicKey: "", - Last4: "1235", - UserID: userId, - LocationID: sql.NullString{String: locationId}, + Id: instrument.Id, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + Type: "Credit Card", + Status: "Verified", + Tags: nil, + Network: "Visa", + PublicKey: "", + Last4: "1235", + UserId: userId, + LocationId: sql.NullString{String: locationId}, } mockedUserRow1 := sqlmock.NewRows([]string{"id", "type", "status", "tags", "first_name", "middle_name", "last_name"}). AddRow(userId, "User", "Onboarded", `{"kyc_level": "1", "platform": "mortal kombat"}`, "Daemon", "", "Targaryan") - mock.ExpectQuery("SELECT * FROM string_user WHERE id = $1 AND deactivated_at IS NULL").WithArgs(userId).WillReturnRows(mockedUserRow1) + mock.ExpectQuery("SELECT * FROM string_user WHERE id = $1 AND deleted_at IS NULL").WithArgs(userId).WillReturnRows(mockedUserRow1) mockedUserRow2 := sqlmock.NewRows([]string{"id", "type", "status", "tags", "first_name", "middle_name", "last_name"}). AddRow(userId, "User", "Onboarded", `{"kyc_level": "1", "platform": "mortal kombat"}`, "Daemon", "", "Targaryan") - mock.ExpectQuery("SELECT * FROM string_user WHERE id = $1 AND deactivated_at IS NULL").WithArgs(userId).WillReturnRows(mockedUserRow2) + mock.ExpectQuery("SELECT * FROM string_user WHERE id = $1 AND deleted_at IS NULL").WithArgs(userId).WillReturnRows(mockedUserRow2) mockedDeviceRow := sqlmock.NewRows([]string{"id", "type", "description", "fingerprint", "ip_addresses", "user_id"}). AddRow(uuid.NewString(), "Mobile", "iPhone 11S", uuid.NewString(), pq.StringArray{"187.25.24.128"}, userId) - mock.ExpectQuery("SELECT * FROM device WHERE user_id = $1 LIMIT $2 OFFSET $3").WithArgs(userId, 100, 0).WillReturnRows(mockedDeviceRow) + mock.ExpectQuery("SELECT * FROM device WHERE user_id = $1 AND deleted_at IS NULL LIMIT $2 OFFSET $3").WithArgs(userId, 100, 0).WillReturnRows(mockedDeviceRow) mockedLocationRow := sqlmock.NewRows([]string{"id", "type", "status", "building_number", "unit_number", "street_name", "city", "state", "postal_code", "country"}). AddRow(locationId, "Home", "Verified", "20181", "411", "Lark Avenue", "Somerville", "MA", "01443", "USA") - mock.ExpectQuery("SELECT * FROM location WHERE id = $1 AND deactivated_at IS NULL").WithArgs(locationId).WillReturnRows(mockedLocationRow) - repo := InstrumentRepo{ + mock.ExpectQuery("SELECT * FROM location WHERE id = $1 AND deleted_at IS NULL").WithArgs(locationId).WillReturnRows(mockedLocationRow) + + repos := InstrumentRepos{ User: repository.NewUser(sqlxDB), Device: repository.NewDevice(sqlxDB), Location: repository.NewLocation(sqlxDB), } - u21Instrument := NewInstrument(repo) + action := NewAction() + + u21Instrument := NewInstrument(repos, action) - u21InstrumentId, err = u21Instrument.Update(instrument) + u21InstrumentId, err = u21Instrument.Update(ctx, instrument) assert.NoError(t, err) assert.Greater(t, len([]rune(u21InstrumentId)), 0) diff --git a/pkg/internal/unit21/transaction.go b/pkg/internal/unit21/transaction.go index 1858ff82..fb6b2823 100644 --- a/pkg/internal/unit21/transaction.go +++ b/pkg/internal/unit21/transaction.go @@ -1,179 +1,202 @@ package unit21 import ( + "context" "encoding/json" - "log" - "os" + "errors" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/internal/common" + "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/repository" + "github.com/rs/zerolog/log" ) type Transaction interface { - Evaluate(transaction model.Transaction) (pass bool, err error) - Create(transaction model.Transaction) (unit21Id string, err error) - Update(transaction model.Transaction) (unit21Id string, err error) + Evaluate(ctx context.Context, transaction model.Transaction) (results []rule, err error) + Create(ctx context.Context, transaction model.Transaction) (unit21Id string, err error) + Update(ctx context.Context, transaction model.Transaction) (unit21Id string, err error) } -type TransactionRepo struct { - TxLeg repository.TxLeg - User repository.User - Asset repository.Asset +type TransactionRepos struct { + User repository.User + TxLeg repository.TxLeg + Asset repository.Asset + Device repository.Device } type transaction struct { - repo TransactionRepo + repos TransactionRepos } -func NewTransaction(r TransactionRepo) Transaction { - return &transaction{repo: r} +func NewTransaction(r TransactionRepos) Transaction { + return &transaction{repos: r} } -func (t transaction) Evaluate(transaction model.Transaction) (pass bool, err error) { - transactionData, err := t.getTransactionData(transaction) +func (t transaction) Evaluate(ctx context.Context, transaction model.Transaction) (results []rule, err error) { + transactionData, err := t.getTransactionData(ctx, transaction) + if err != nil { + log.Err(err).Msg("Failed to gather Unit21 transaction source") + return results, libcommon.StringError(err) + } + + digitalData, err := t.getEventDigitalData(ctx, transaction) if err != nil { - log.Printf("Failed to gather Unit21 transaction source: %s", err) - return false, common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 digital data") + return results, libcommon.StringError(err) } - url := os.Getenv("UNIT21_RTR_URL") + url := config.Var.UNIT21_RTR_URL if url == "" { url = "https://rtr.sandbox2.unit21.com/evaluate" } - body, err := u21Post(url, mapToUnit21TransactionEvent(transaction, transactionData)) + body, err := u21Post(url, mapToUnit21TransactionEvent(transaction, transactionData, digitalData)) if err != nil { - log.Printf("Unit21 Transaction evaluate failed: %s", err) - return false, common.StringError(err) + log.Err(err).Msg("Unit21 Transaction evaluate failed") + return results, libcommon.StringError(err) } // var u21Response *createEventResponse var response evaluateEventResponse err = json.Unmarshal(body, &response) if err != nil { - log.Printf("Reading body failed: %s", err) - return false, common.StringError(err) + log.Err(err).Msg("Reading body failed") + return results, libcommon.StringError(err) } for _, rule := range *response.RuleExecutions { - if rule.Status != "PASS" { - return false, nil + if !common.SliceContains([]string{"PASS", "ERROR"}, rule.Status) { + results = append(results, rule) + } + if rule.Status == "ERROR" { + log.Err(errors.New("Unit21 Transaction evaluate failed for " + rule.RuleName)).Msg("Unit21 Transaction evaluate failed") } } - return true, nil + return results, nil } -func (t transaction) Create(transaction model.Transaction) (unit21Id string, err error) { - transactionData, err := t.getTransactionData(transaction) +func (t transaction) Create(ctx context.Context, transaction model.Transaction) (unit21Id string, err error) { + transactionData, err := t.getTransactionData(ctx, transaction) + if err != nil { + log.Err(err).Msg("Failed to gather Unit21 transaction source") + return "", libcommon.StringError(err) + } + digitalData, err := t.getEventDigitalData(ctx, transaction) if err != nil { - log.Printf("Failed to gather Unit21 transaction source: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 digital data") + return "", libcommon.StringError(err) } - url := "https://" + os.Getenv("UNIT21_ENV") + ".unit21.com/v1/events/create" - body, err := u21Post(url, mapToUnit21TransactionEvent(transaction, transactionData)) + url := "https://" + config.Var.UNIT21_ENV + ".unit21.com/v1/events/create" + body, err := u21Post(url, mapToUnit21TransactionEvent(transaction, transactionData, digitalData)) if err != nil { - log.Printf("Unit21 Transaction create failed: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Unit21 Transaction create failed") + return "", libcommon.StringError(err) } var u21Response *createEventResponse err = json.Unmarshal(body, &u21Response) if err != nil { - log.Printf("Reading body failed: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Reading body failed") + return "", libcommon.StringError(err) } - log.Printf("Unit21Id: %s", u21Response.Unit21Id) - + log.Info().Str("unit21Id", u21Response.Unit21Id).Send() return u21Response.Unit21Id, nil } -func (t transaction) Update(transaction model.Transaction) (unit21Id string, err error) { - transactionData, err := t.getTransactionData(transaction) +func (t transaction) Update(ctx context.Context, transaction model.Transaction) (unit21Id string, err error) { + transactionData, err := t.getTransactionData(ctx, transaction) + if err != nil { + log.Err(err).Msg("Failed to gather Unit21 transaction source") + return "", libcommon.StringError(err) + } + + digitalData, err := t.getEventDigitalData(ctx, transaction) if err != nil { - log.Printf("Failed to gather Unit21 transaction source: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Failed to gather Unit21 digital data") + return "", libcommon.StringError(err) } - orgName := os.Getenv("UNIT21_ORG_NAME") - url := "https://" + os.Getenv("UNIT21_ENV") + ".unit21.com/v1/" + orgName + "/events/" + transaction.ID + "/update" - body, err := u21Put(url, mapToUnit21TransactionEvent(transaction, transactionData)) + orgName := config.Var.UNIT21_ORG_NAME + url := "https://" + config.Var.UNIT21_ENV + ".unit21.com/v1/" + orgName + "/events/" + transaction.Id + "/update" + body, err := u21Put(url, mapToUnit21TransactionEvent(transaction, transactionData, digitalData)) if err != nil { - log.Printf("Unit21 Transaction create failed: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Unit21 Transaction create failed:") + return "", libcommon.StringError(err) } var u21Response *updateEventResponse err = json.Unmarshal(body, &u21Response) if err != nil { - log.Printf("Reading body failed: %s", err) - return "", common.StringError(err) + log.Err(err).Msg("Reading body failed") + return "", libcommon.StringError(err) } - - log.Printf("Unit21Id: %s", u21Response.Unit21Id) + log.Info().Str("unit21Id", u21Response.Unit21Id).Send() return u21Response.Unit21Id, nil } -func (t transaction) getTransactionData(transaction model.Transaction) (txData transactionData, err error) { - senderData, err := t.repo.TxLeg.GetById(transaction.OriginTxLegID) +func (t transaction) getTransactionData(ctx context.Context, transaction model.Transaction) (txData transactionData, err error) { + senderData, err := t.repos.TxLeg.GetById(ctx, transaction.OriginTxLegId) if err != nil { - log.Printf("Failed go get origin transaction leg: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed go get origin transaction leg") + err = libcommon.StringError(err) return } - receiverData, err := t.repo.TxLeg.GetById(transaction.DestinationTxLegID) + receiverData, err := t.repos.TxLeg.GetById(ctx, transaction.DestinationTxLegId) if err != nil { - log.Printf("Failed go get origin transaction leg: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed go get origin transaction leg") + err = libcommon.StringError(err) return } - senderAsset, err := t.repo.Asset.GetById(senderData.AssetID) + senderAsset, err := t.repos.Asset.GetById(ctx, senderData.AssetId) if err != nil { - log.Printf("Failed go get transaction sender asset: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed go get transaction sender asset") + err = libcommon.StringError(err) return } - receiverAsset, err := t.repo.Asset.GetById(receiverData.AssetID) + receiverAsset, err := t.repos.Asset.GetById(ctx, receiverData.AssetId) if err != nil { - log.Printf("Failed go get transaction receiver asset: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed go get transaction receiver asset") + err = libcommon.StringError(err) return } amount, err := common.BigNumberToFloat(senderData.Value, 6) if err != nil { - log.Printf("Failed to convert amount: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed to convert amount") + err = libcommon.StringError(err) return } senderAmount, err := common.BigNumberToFloat(senderData.Amount, senderAsset.Decimals) if err != nil { - log.Printf("Failed to convert senderAmount: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed to convert senderAmount") + err = libcommon.StringError(err) return } receiverAmount, err := common.BigNumberToFloat(receiverData.Amount, receiverAsset.Decimals) if err != nil { - log.Printf("Failed to convert receiverAmount: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed to convert receiverAmount") + err = libcommon.StringError(err) return } var stringFee float64 if transaction.StringFee != "" { stringFee, err = common.BigNumberToFloat(transaction.StringFee, 6) if err != nil { - log.Printf("Failed to convert stringFee: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed to convert stringFee") + err = libcommon.StringError(err) return } } @@ -182,8 +205,8 @@ func (t transaction) getTransactionData(transaction model.Transaction) (txData t if transaction.ProcessingFee != "" { processingFee, err = common.BigNumberToFloat(transaction.ProcessingFee, 6) if err != nil { - log.Printf("Failed to convert processingFee: %s", err) - err = common.StringError(err) + log.Err(err).Msg("Failed to convert processingFee") + err = libcommon.StringError(err) return } } @@ -197,14 +220,14 @@ func (t transaction) getTransactionData(transaction model.Transaction) (txData t Amount: amount, SentAmount: senderAmount, SentCurrency: senderAsset.Name, - SenderEntityId: senderData.UserID, + SenderEntityId: senderData.UserId, SenderEntityType: "user", - SenderInstrumentId: senderData.InstrumentID, + SenderInstrumentId: senderData.InstrumentId, ReceivedAmount: receiverAmount, ReceivedCurrency: receiverAsset.Name, - ReceiverEntityId: receiverData.UserID, + ReceiverEntityId: receiverData.UserId, ReceiverEntityType: "user", - ReceiverInstrumentId: receiverData.InstrumentID, + ReceiverInstrumentId: receiverData.InstrumentId, ExchangeRate: exchangeRate, TransactionHash: transaction.TransactionHash, USDConversionNotes: "", @@ -215,7 +238,26 @@ func (t transaction) getTransactionData(transaction model.Transaction) (txData t return } -func mapToUnit21TransactionEvent(transaction model.Transaction, transactionData transactionData) *u21Event { +func (t transaction) getEventDigitalData(ctx context.Context, transaction model.Transaction) (digitalData eventDigitalData, err error) { + if transaction.DeviceId == "" { + return + } + + device, err := t.repos.Device.GetById(ctx, transaction.DeviceId) + if err != nil { + log.Err(err).Msg("Failed to get transaction device") + err = libcommon.StringError(err) + return + } + + digitalData = eventDigitalData{ + IPAddress: transaction.IPAddress, + ClientFingerprint: device.Fingerprint, + } + return +} + +func mapToUnit21TransactionEvent(transaction model.Transaction, transactionData transactionData, digitalData eventDigitalData) *u21Event { var transactionTagArr []string if transaction.Tags != nil { for key, value := range transaction.Tags { @@ -225,21 +267,19 @@ func mapToUnit21TransactionEvent(transaction model.Transaction, transactionData jsonBody := &u21Event{ GeneralData: &eventGeneral{ - EventId: transaction.ID, //required + EventId: transaction.Id, //required EventType: "transaction", //required EventTime: int(transaction.CreatedAt.Unix()), //required - EventSubtype: "credit_card", //required for RTR + EventSubtype: "Fiat to Crypto", //required for RTR Status: transaction.Status, Parents: nil, Tags: transactionTagArr, }, TransactionData: &transactionData, ActionData: nil, - DigitalData: &eventDigitalData{ - IPAddress: transaction.IPAddress, - }, - LocationData: nil, - CustomData: nil, + DigitalData: &digitalData, + LocationData: nil, + CustomData: nil, } return jsonBody diff --git a/pkg/internal/unit21/transaction_test.go b/pkg/internal/unit21/transaction_test.go index ed4845d9..ebd28b52 100644 --- a/pkg/internal/unit21/transaction_test.go +++ b/pkg/internal/unit21/transaction_test.go @@ -1,11 +1,15 @@ package unit21 import ( + "context" "database/sql" "testing" "time" + env "github.com/String-xyz/go-lib/v2/config" + "github.com/DATA-DOG/go-sqlmock" + "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/repository" "github.com/google/uuid" @@ -15,6 +19,8 @@ import ( ) func TestCreateTransaction(t *testing.T) { + env.LoadEnv(&config.Var, "../../../.env") + ctx := context.Background() db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) defer db.Close() @@ -26,7 +32,7 @@ func TestCreateTransaction(t *testing.T) { instrumentId1 := uuid.NewString() instrumentId2 := uuid.NewString() mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) - u21TransactionId, err := executeMockTransactionForUser(transaction, sqlxDB) + u21TransactionId, err := executeMockTransactionForUser(ctx, transaction, sqlxDB) assert.NoError(t, err) assert.Greater(t, len([]rune(u21TransactionId)), 0) @@ -36,6 +42,8 @@ func TestCreateTransaction(t *testing.T) { } func TestUpdateTransaction(t *testing.T) { + env.LoadEnv(&config.Var, "../../../.env") + ctx := context.Background() db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) defer db.Close() @@ -47,11 +55,11 @@ func TestUpdateTransaction(t *testing.T) { instrumentId1 := uuid.NewString() instrumentId2 := uuid.NewString() mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) - u21TransactionId, err := executeMockTransactionForUser(transaction, sqlxDB) + u21TransactionId, err := executeMockTransactionForUser(ctx, transaction, sqlxDB) assert.NoError(t, err) - OriginTxLegID := uuid.NewString() - DestinationTxLegID := uuid.NewString() + OriginTxLegId := uuid.NewString() + DestinationTxLegId := uuid.NewString() assetId1 = uuid.NewString() assetId2 = uuid.NewString() networkId := uuid.NewString() @@ -59,40 +67,41 @@ func TestUpdateTransaction(t *testing.T) { instrumentId2 = uuid.NewString() transaction = model.Transaction{ - ID: transaction.ID, + Id: transaction.Id, CreatedAt: time.Now(), UpdatedAt: time.Now(), Type: "fiat-to-crypto", Status: "Completed", Tags: map[string]string{}, - DeviceID: uuid.NewString(), + DeviceId: uuid.NewString(), IPAddress: "187.25.24.128", - PlatformID: uuid.NewString(), + PlatformId: uuid.NewString(), TransactionHash: "", - NetworkID: networkId, + NetworkId: networkId, NetworkFee: "100000000", ContractParams: pq.StringArray{}, ContractFunc: "mintTo()", TransactionAmount: "1000000000", - OriginTxLegID: OriginTxLegID, - ReceiptTxLegID: sql.NullString{String: uuid.NewString()}, - ResponseTxLegID: sql.NullString{String: uuid.NewString()}, - DestinationTxLegID: DestinationTxLegID, + OriginTxLegId: OriginTxLegId, + ReceiptTxLegId: sql.NullString{String: uuid.NewString()}, + ResponseTxLegId: sql.NullString{String: uuid.NewString()}, + DestinationTxLegId: DestinationTxLegId, ProcessingFee: "1000000", ProcessingFeeAsset: uuid.NewString(), StringFee: "2000000", } mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) - repo := TransactionRepo{ - TxLeg: repository.NewTxLeg((sqlxDB)), - User: repository.NewUser(sqlxDB), - Asset: repository.NewAsset(sqlxDB), + repos := TransactionRepos{ + TxLeg: repository.NewTxLeg((sqlxDB)), + User: repository.NewUser(sqlxDB), + Asset: repository.NewAsset(sqlxDB), + Device: repository.NewDevice(sqlxDB), } - u21Transaction := NewTransaction(repo) + u21Transaction := NewTransaction(repos) - u21TransactionId, err = u21Transaction.Update(transaction) + u21TransactionId, err = u21Transaction.Update(ctx, transaction) assert.NoError(t, err) assert.Greater(t, len([]rune(u21TransactionId)), 0) @@ -101,46 +110,47 @@ func TestUpdateTransaction(t *testing.T) { // TODO: mock call to client once it's manually tested } -func executeMockTransactionForUser(transaction model.Transaction, sqlxDB *sqlx.DB) (unit21Id string, err error) { - repo := TransactionRepo{ - TxLeg: repository.NewTxLeg(sqlxDB), - User: repository.NewUser(sqlxDB), - Asset: repository.NewAsset(sqlxDB), +func executeMockTransactionForUser(ctx context.Context, transaction model.Transaction, sqlxDB *sqlx.DB) (unit21Id string, err error) { + repos := TransactionRepos{ + TxLeg: repository.NewTxLeg(sqlxDB), + User: repository.NewUser(sqlxDB), + Asset: repository.NewAsset(sqlxDB), + Device: repository.NewDevice(sqlxDB), } - u21Transaction := NewTransaction(repo) + u21Transaction := NewTransaction(repos) - unit21Id, err = u21Transaction.Create(transaction) + unit21Id, err = u21Transaction.Create(ctx, transaction) return } func createMockTransactionForUser(userId string, amount string, sqlxDB *sqlx.DB) (transaction model.Transaction) { transactionId := uuid.NewString() - OriginTxLegID := uuid.NewString() - DestinationTxLegID := uuid.NewString() + OriginTxLegId := uuid.NewString() + DestinationTxLegId := uuid.NewString() networkId := uuid.NewString() transaction = model.Transaction{ - ID: transactionId, + Id: transactionId, CreatedAt: time.Now(), UpdatedAt: time.Now(), Type: "fiat-to-crypto", Status: "Completed", Tags: map[string]string{}, - DeviceID: uuid.NewString(), + DeviceId: uuid.NewString(), IPAddress: "187.25.24.128", - PlatformID: uuid.NewString(), + PlatformId: uuid.NewString(), TransactionHash: "", - NetworkID: networkId, + NetworkId: networkId, NetworkFee: "100000000", ContractParams: pq.StringArray{}, ContractFunc: "mintTo()", TransactionAmount: amount, - OriginTxLegID: OriginTxLegID, - ReceiptTxLegID: sql.NullString{String: uuid.NewString()}, - ResponseTxLegID: sql.NullString{String: uuid.NewString()}, - DestinationTxLegID: DestinationTxLegID, + OriginTxLegId: OriginTxLegId, + ReceiptTxLegId: sql.NullString{String: uuid.NewString()}, + ResponseTxLegId: sql.NullString{String: uuid.NewString()}, + DestinationTxLegId: DestinationTxLegId, ProcessingFee: "1000000", ProcessingFeeAsset: uuid.NewString(), StringFee: "1000000", @@ -151,18 +161,23 @@ func createMockTransactionForUser(userId string, amount string, sqlxDB *sqlx.DB) func mockTransactionRows(mock sqlmock.Sqlmock, transaction model.Transaction, userId string, assetId1 string, assetId2 string, instrumentId1 string, instrumentId2 string) { mockedTxLegRow1 := sqlmock.NewRows([]string{"id", "timestamp", "amount", "value", "asset_id", "user_id", "instrument_id"}). - AddRow(transaction.OriginTxLegID, time.Now(), transaction.TransactionAmount, transaction.TransactionAmount, assetId1, userId, instrumentId1) - mock.ExpectQuery("SELECT * FROM tx_leg WHERE id = $1 AND deactivated_at IS NULL").WithArgs(transaction.OriginTxLegID).WillReturnRows(mockedTxLegRow1) + AddRow(transaction.OriginTxLegId, time.Now(), transaction.TransactionAmount, transaction.TransactionAmount, assetId1, userId, instrumentId1) + mock.ExpectQuery("SELECT * FROM tx_leg WHERE id = $1 AND deleted_at IS NULL").WithArgs(transaction.OriginTxLegId).WillReturnRows(mockedTxLegRow1) mockedTxLegRow2 := sqlmock.NewRows([]string{"id", "timestamp", "amount", "value", "asset_id", "user_id", "instrument_id"}). - AddRow(transaction.DestinationTxLegID, time.Now(), "1", transaction.TransactionAmount, assetId2, userId, instrumentId2) - mock.ExpectQuery("SELECT * FROM tx_leg WHERE id = $1 AND deactivated_at IS NULL").WithArgs(transaction.DestinationTxLegID).WillReturnRows(mockedTxLegRow2) + AddRow(transaction.DestinationTxLegId, time.Now(), "1", transaction.TransactionAmount, assetId2, userId, instrumentId2) + mock.ExpectQuery("SELECT * FROM tx_leg WHERE id = $1 AND deleted_at IS NULL").WithArgs(transaction.DestinationTxLegId).WillReturnRows(mockedTxLegRow2) mockedAssetRow1 := sqlmock.NewRows([]string{"id", "name", "description", "decimals", "is_crypto", "network_id", "value_oracle"}). - AddRow(assetId1, "USD", "fiat USD", 6, false, transaction.NetworkID, "self") - mock.ExpectQuery("SELECT * FROM asset WHERE id = $1 AND deactivated_at IS NULL").WithArgs(assetId1).WillReturnRows(mockedAssetRow1) + AddRow(assetId1, "USD", "fiat USD", 6, false, transaction.NetworkId, "self") + mock.ExpectQuery("SELECT * FROM asset WHERE id = $1 AND deleted_at IS NULL").WithArgs(assetId1).WillReturnRows(mockedAssetRow1) mockedAssetRow2 := sqlmock.NewRows([]string{"id", "name", "description", "decimals", "is_crypto", "network_id", "value_oracle"}). - AddRow(assetId2, "Noose The Goose", "Noose the Goose NFT", 0, true, transaction.NetworkID, "joepegs.com") - mock.ExpectQuery("SELECT * FROM asset WHERE id = $1 AND deactivated_at IS NULL").WithArgs(assetId2).WillReturnRows(mockedAssetRow2) + AddRow(assetId2, "Noose The Goose", "Noose the Goose NFT", 0, true, transaction.NetworkId, "joepegs.com") + mock.ExpectQuery("SELECT * FROM asset WHERE id = $1 AND deleted_at IS NULL").WithArgs(assetId2).WillReturnRows(mockedAssetRow2) + + mockedDeviceRow := sqlmock.NewRows([]string{"id", "type", "description", "fingerprint", "ip_addresses", "user_id"}). + AddRow(transaction.DeviceId, "Mobile", "iPhone 11S", uuid.NewString(), pq.StringArray{"187.25.24.128"}, userId) + mock.ExpectQuery("SELECT * FROM device WHERE id = $1 AND deleted_at IS NULL").WithArgs(transaction.DeviceId).WillReturnRows(mockedDeviceRow) + } diff --git a/pkg/internal/unit21/types.go b/pkg/internal/unit21/types.go index 072e2d9b..7bcf7888 100644 --- a/pkg/internal/unit21/types.go +++ b/pkg/internal/unit21/types.go @@ -170,7 +170,8 @@ type actionData struct { } type eventDigitalData struct { - IPAddress string `json:"ip_address,omitempty"` + IPAddress string `json:"ip_address,omitempty"` + ClientFingerprint string `json:"client_fingerprint,omitempty"` } type eventCustomData struct { diff --git a/pkg/model/auth.go b/pkg/model/auth.go new file mode 100644 index 00000000..9ba373f6 --- /dev/null +++ b/pkg/model/auth.go @@ -0,0 +1,59 @@ +package model + +import ( + "time" + + "github.com/golang-jwt/jwt" +) + +type RefreshTokenResponse struct { + Token string `json:"token"` + ExpAt time.Time `json:"expAt"` +} + +type SignatureRequest struct { + Nonce string `json:"nonce" validate:"required,base64"` +} + +type JWT struct { + ExpAt time.Time `json:"expAt"` + IssuedAt time.Time `json:"issuedAt"` + Token string `json:"token"` + RefreshToken RefreshTokenResponse `json:"refreshToken"` +} + +type JWTClaims struct { + UserId string `json:"userId"` + PlatformId string `json:"platformId"` + DeviceId string `json:"deviceId"` + jwt.StandardClaims +} + +type UserLoginResponse struct { + JWT JWT `json:"authToken"` + User User `json:"user"` +} + +type UserOnboardingStatus struct { + Status string `json:"status"` +} + +type WalletSignaturePayload struct { + Address string `json:"address" validate:"required,eth_addr"` + Timestamp int64 `json:"timestamp" validate:"required"` +} + +type FingerprintPayload struct { + VisitorId string `json:"visitorId"` + RequestId string `json:"requestId"` +} + +type WalletSignaturePayloadSigned struct { + Nonce string `json:"nonce" validate:"required,base64"` + Signature string `json:"signature" validate:"required,base64"` + Fingerprint FingerprintPayload `json:"fingerprint"` +} + +type EmailPreview struct { + Email string `json:"email"` +} diff --git a/pkg/model/common.go b/pkg/model/common.go deleted file mode 100644 index 2ec67411..00000000 --- a/pkg/model/common.go +++ /dev/null @@ -1,12 +0,0 @@ -package model - -type FPVisitor struct { - VisitorID string - Country string - State string - IPAddress string - Timestamp int64 - Confidence float64 - Type string - UserAgent string -} diff --git a/pkg/model/entity.go b/pkg/model/entity.go index 57d460fc..3eecb944 100644 --- a/pkg/model/entity.go +++ b/pkg/model/entity.go @@ -5,108 +5,124 @@ import ( "encoding/json" "time" + ckocommon "github.com/checkout/checkout-sdk-go/common" "github.com/lib/pq" ) -// See STRING_USER in Migrations 0001 type User struct { - ID string `json:"id" db:"id"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - DeactivatedAt *time.Time `json:"deactivatedAt,omitempty" db:"deactivated_at"` - Type string `json:"type" db:"type"` - Status string `json:"status" db:"status"` - Tags StringMap `json:"tags" db:"tags"` - FirstName string `json:"firstName" db:"first_name"` - MiddleName string `json:"middleName" db:"middle_name"` - LastName string `json:"lastName" db:"last_name"` - Email string `json:"email"` -} - -// See PLATFORM in Migrations 0005 + Id string `json:"id" db:"id"` + CheckoutId string `json:"-" db:"checkout_id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` + Type string `json:"type" db:"type"` + Status string `json:"status" db:"status"` + Tags StringMap `json:"tags" db:"tags"` + FirstName string `json:"firstName" db:"first_name"` + MiddleName string `json:"middleName" db:"middle_name"` + LastName string `json:"lastName" db:"last_name"` + Email string `json:"email"` +} + +type Identity struct { + Id string `json:"id,omitempty" db:"id"` + Level int `json:"level" db:"level"` + AccountId string `json:"accountId" db:"account_id"` + UserId string `json:"userId" db:"user_id"` + CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` + EmailVerified *time.Time `json:"emailVerified,omitempty" db:"email_verified"` + PhoneVerified *time.Time `json:"phoneVerified,omitempty" db:"phone_verified"` + SelfieVerified *time.Time `json:"selfieVerified,omitempty" db:"selfie_verified"` + DocumentVerified *time.Time `json:"documentVerified,omitempty" db:"document_verified"` +} + +type UserWithContact struct { + User + Email string `db:"email"` +} + type Platform struct { - ID string `json:"id,omitempty" db:"id"` - CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"` - DeactivatedAt *time.Time `json:"deactivatedAt,omitempty" db:"deactivated_at"` - ActivatedAt *time.Time `json:"activatedAt,omitempty" db:"activated_at"` - Name string `json:"name" db:"name"` - Description string `json:"description" db:"description"` - Domains pq.StringArray `json:"domains" db:"domains"` - IPAddresses pq.StringArray `json:"ipAddresses" db:"ip_addresses"` -} - -// See NETWORK in Migrations 0001 + Id string `json:"id,omitempty" db:"id"` + CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"` + DeactivatedAt *time.Time `json:"deactivatedAt,omitempty" db:"deactivated_at"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + Domains pq.StringArray `json:"domains" db:"domains"` + IPAddresses pq.StringArray `json:"ipAddresses" db:"ip_addresses"` + OrganizationId string `json:"organizationId" db:"organization_id"` +} + type Network struct { - ID string `json:"id" db:"id"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - DeactivatedAt *time.Time `json:"deactivatedAt,omitempty" db:"deactivated_at"` - Name string `json:"name" db:"name"` - NetworkID uint64 `json:"networkId" db:"network_id"` - ChainID uint64 `json:"chainId" db:"chain_id"` - GasTokenID string `json:"gasTokenId" db:"gas_token_id"` - GasOracle string `json:"gasOracle" db:"gas_oracle"` - RPCUrl string `json:"rpcUrl" db:"rpc_url"` - ExplorerUrl string `json:"explorerUrl" db:"explorer_url"` -} - -// See ASSET in Migrations 0001 + Id string `json:"id" db:"id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` + Name string `json:"name" db:"name"` + NetworkId uint64 `json:"networkId" db:"network_id"` + ChainId uint64 `json:"chainId" db:"chain_id"` + GasTokenId string `json:"gasTokenId" db:"gas_token_id"` + GasOracle string `json:"gasOracle" db:"gas_oracle"` + RPCUrl string `json:"rpcUrl" db:"rpc_url"` + ExplorerUrl string `json:"explorerUrl" db:"explorer_url"` +} + type Asset struct { - ID string `json:"id" db:"id"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - DeactivatedAt *time.Time `json:"deactivatedAt,omitempty" db:"deactivated_at"` - Name string `json:"name" db:"name"` - Description string `json:"description" db:"description"` - Decimals uint64 `json:"decimals" db:"decimals"` - IsCrypto bool `json:"isCrypto" db:"is_crypto"` - NetworkID sql.NullString `json:"networkId" db:"network_id"` - ValueOracle sql.NullString `json:"valueOracle" db:"value_oracle"` -} - -// See USER_PLATFORM in Migrations 0002 + Id string `json:"id" db:"id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + Decimals uint64 `json:"decimals" db:"decimals"` + IsCrypto bool `json:"isCrypto" db:"is_crypto"` + NetworkId string `json:"networkId" db:"network_id"` + ValueOracle sql.NullString `json:"valueOracle" db:"value_oracle"` + ValueOracle2 sql.NullString `json:"valueOracle2" db:"value_oracle_2"` + Address sql.NullString `json:"address" db:"address"` +} + type UserToPlatform struct { - UserID string `json:"userId" db:"user_id"` - PlatformID string `json:"platformId" db:"platform_id"` + UserId string `json:"userId" db:"user_id"` + PlatformId string `json:"platformId" db:"platform_id"` } -// See DEVICE in Migrations 0002 type Device struct { - ID string `json:"id" db:"id"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - LastUsedAt time.Time `json:"lastUsedAt" db:"last_used_at"` - ValidatedAt *time.Time `json:"validatedAt" db:"validated_at"` - DeactivatedAt *time.Time `json:"deactivatedAt,omitempty" db:"deactivated_at"` - Type string `json:"type" db:"type"` - Description string `json:"description" db:"description"` - Fingerprint string `json:"fingerprint" db:"fingerprint"` - IpAddresses pq.StringArray `json:"ipAddresses" db:"ip_addresses"` - UserID string `json:"userId" db:"user_id"` -} - -// See CONTACT in Migrations 0002 + Id string `json:"id" db:"id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + LastUsedAt time.Time `json:"lastUsedAt" db:"last_used_at"` + ValidatedAt *time.Time `json:"validatedAt" db:"validated_at"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` + Type string `json:"type" db:"type"` + Description string `json:"description" db:"description"` + Fingerprint string `json:"fingerprint" db:"fingerprint"` + IpAddresses pq.StringArray `json:"ipAddresses,omitempty" db:"ip_addresses"` + UserId string `json:"userId" db:"user_id"` +} + type Contact struct { - ID string `json:"id" db:"id"` - UserID string `json:"userId" db:"user_id"` + Id string `json:"id" db:"id"` + UserId string `json:"userId" db:"user_id"` CreatedAt time.Time `json:"createdAt" db:"created_at"` UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` LastAuthenticatedAt *time.Time `json:"lastAuthenticatedAt" db:"last_authenticated_at"` ValidatedAt *time.Time `json:"validatedAt" db:"validated_at"` - DeactivatedAt *time.Time `json:"deactivatedAt,omitempty" db:"deactivated_at"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` Type string `json:"type" db:"type"` Status string `json:"status" db:"status"` Data string `json:"data" db:"data"` } -// See LOCATION in Migrations 0002 type Location struct { - ID string `json:"id" db:"id"` - UserID string `json:"userId" db:"user_id"` + Id string `json:"id" db:"id"` + UserId string `json:"userId" db:"user_id"` CreatedAt time.Time `json:"createdAt" db:"created_at"` UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - DeactivatedAt *time.Time `json:"deactivatedAt,omitempty" db:"deactivated_at"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` Type string `json:"type" db:"type"` Status string `json:"status" db:"status"` Tags StringMap `json:"tags" db:"tags"` @@ -119,70 +135,77 @@ type Location struct { Country string `json:"country" db:"country"` } -// See INSTRUMENT in Migrations 0002 type Instrument struct { - ID string `json:"id" db:"id"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - DeactivatedAt *time.Time `json:"deactivatedAt,omitempty" db:"deactivated_at"` - Type string `json:"type" db:"type"` - Status string `json:"status" db:"status"` - Tags StringMap `json:"tags" db:"tags"` - Network string `json:"network" db:"network"` - PublicKey string `json:"publicKey" db:"public_key"` - Last4 string `json:"last4" db:"last_4"` - UserID string `json:"userId" db:"user_id"` - LocationID sql.NullString `json:"locationId" db:"location_id"` -} - -// See CONTACT_PLATFORM in Migrations 0003 + Id string `json:"id" db:"id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` + Type string `json:"type" db:"type"` + Status string `json:"status" db:"status"` + Tags StringMap `json:"tags" db:"tags"` + Network string `json:"network" db:"network"` + PublicKey string `json:"publicKey" db:"public_key"` + Last4 string `json:"last4" db:"last_4"` + UserId string `json:"userId" db:"user_id"` + LocationId sql.NullString `json:"locationId" db:"location_id"` + Name string `json:"name" db:"name"` +} + +type CardResponse struct { + Type ckocommon.InstrumentType `json:"type"` + Id string `json:"id"` + Scheme string `json:"scheme"` + Last4 string `json:"last4"` + ExpiryMonth int `json:"expiryMonth"` + ExpiryYear int `json:"expiryYear"` + Expired bool `json:"expired"` + CardType ckocommon.CardType `json:"cardType"` +} + type ContactToPlatform struct { - ContactID string `json:"contactId" db:"contact_id"` - PlatformID string `json:"platformId" db:"platform_id"` + ContactId string `json:"contactId" db:"contact_id"` + PlatformId string `json:"platformId" db:"platform_id"` } -// See DEVICE_INSTRUMENT in Migrations 0003 type DeviceToInstrument struct { - DeviceID string `json:"deviceId" db:"device_id"` - InstrumentID string `json:"instrumentId" db:"instrument_id"` + DeviceId string `json:"deviceId" db:"device_id"` + InstrumentId string `json:"instrumentId" db:"instrument_id"` } -// See Tx_LEG in Migrations 0003 type TxLeg struct { - ID string `json:"id" db:"id"` - CreatedAt time.Time `json:"createdAt" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - DeactivatedAt *time.Time `json:"deactivatedAt,omitempty" db:"deactivated_at"` - Timestamp time.Time `json:"timestamp" db:"timestamp"` - Amount string `json:"amount" db:"amount"` - Value string `json:"value" db:"value"` - AssetID string `json:"assetId" db:"asset_id"` - UserID string `json:"userId" db:"user_id"` - InstrumentID string `json:"instrumentId" db:"instrument_id"` -} - -// See TRANSACTION in Migrations 0003 + Id string `json:"id" db:"id"` + CreatedAt time.Time `json:"createdAt" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` + Timestamp time.Time `json:"timestamp" db:"timestamp"` + Amount string `json:"amount" db:"amount"` + Value string `json:"value" db:"value"` + AssetId string `json:"assetId" db:"asset_id"` + UserId string `json:"userId" db:"user_id"` + InstrumentId string `json:"instrumentId" db:"instrument_id"` +} + type Transaction struct { - ID string `json:"id" db:"id"` + Id string `json:"id" db:"id"` CreatedAt time.Time `json:"createdAt" db:"created_at"` UpdatedAt time.Time `json:"updatedAt" db:"updated_at"` - DeactivatedAt *time.Time `json:"deactivatedAt,omitempty" db:"deactivated_at"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` Type string `json:"type,omitempty" db:"type"` Status string `json:"status,omitempty" db:"status"` Tags StringMap `json:"tags,omitempty" db:"tags"` - DeviceID string `json:"deviceId,omitempty" db:"device_id"` + DeviceId string `json:"deviceId,omitempty" db:"device_id"` IPAddress string `json:"ipAddress,omitempty" db:"ip_address"` - PlatformID string `json:"platformId,omitempty" db:"platform_id"` + PlatformId string `json:"platformId,omitempty" db:"platform_id"` TransactionHash string `json:"transactionHash,omitempty" db:"transaction_hash"` - NetworkID string `json:"networkId,omitempty" db:"network_id"` + NetworkId string `json:"networkId,omitempty" db:"network_id"` NetworkFee string `json:"networkFee,omitempty" db:"network_fee"` ContractParams pq.StringArray `json:"contractParameters,omitempty" db:"contract_params"` ContractFunc string `json:"contractFunc,omitempty" db:"contract_func"` TransactionAmount string `json:"transactionAmount,omitempty" db:"transaction_amount"` - OriginTxLegID string `json:"originTxLegId,omitempty" db:"origin_tx_leg_id"` - ReceiptTxLegID sql.NullString `json:"receiptTxLegId,omitempty" db:"receipt_tx_leg_id"` - ResponseTxLegID sql.NullString `json:"responseTxLegId,omitempty" db:"response_tx_leg_id"` - DestinationTxLegID string `json:"destinationTxLegId,omitempty" db:"destination_tx_leg_id"` + OriginTxLegId string `json:"originTxLegId,omitempty" db:"origin_tx_leg_id"` + ReceiptTxLegId sql.NullString `json:"receiptTxLegId,omitempty" db:"receipt_tx_leg_id"` + ResponseTxLegId sql.NullString `json:"responseTxLegId,omitempty" db:"response_tx_leg_id"` + DestinationTxLegId string `json:"destinationTxLegId,omitempty" db:"destination_tx_leg_id"` ProcessingFee string `json:"processingFee,omitempty" db:"processing_fee"` ProcessingFeeAsset string `json:"processingFeeAsset,omitempty" db:"processing_fee_asset"` StringFee string `json:"stringFee,omitempty" db:"string_fee"` @@ -190,18 +213,46 @@ type Transaction struct { } type AuthStrategy struct { - ID string `json:"id,omitempty" db:"id"` - Status string `json:"status" db:"status"` - EntityID string `json:"entityId,omitempty"` // for redis use only - Type string `json:"authType" db:"type"` - EntityType string `json:"entityType,omitempty"` // for redis use only - ContactData string `json:"contactData,omitempty"` // for redis use only - ContactID NullableString `json:"contactId,omitempty" db:"contact_id"` - Data string `json:"data" data:"data"` - CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` - UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"` - ExpiresAt time.Time `json:"expireAt,omitempty" db:"expire_at"` - DeactivatedAt *time.Time `json:"deactivatedAt,omitempty" db:"deactivated_at"` + Id string `json:"id,omitempty" db:"id"` + Status string `json:"status" db:"status"` + EntityId string `json:"entityId,omitempty"` // for redis use only + Type string `json:"authType" db:"type"` + EntityType string `json:"entityType,omitempty"` // for redis use only + ContactData string `json:"contactData,omitempty"` // for redis use only + ContactId NullableString `json:"contactId,omitempty" db:"contact_id"` + Data string `json:"data" data:"data"` + CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"` + ExpiresAt time.Time `json:"expireAt,omitempty" db:"expire_at"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` +} + +type Apikey struct { + Id string `json:"id,omitempty" db:"id"` + CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` + Type string `json:"type" db:"type"` + Data string `json:"data" db:"data"` + Hint string `json:"hint,omitempty" db:"hint"` + Description *string `json:"description,omitempty" db:"description"` + CreatedBy string `json:"createdBy" db:"created_by"` + PlatformId *string `json:"platformId" db:"platform_id"` + OrganizationId string `json:"organizationId" db:"organization_id"` +} + +type Contract struct { + Id string `json:"id,omitempty" db:"id"` + CreatedAt time.Time `json:"createdAt,omitempty" db:"created_at"` + UpdatedAt time.Time `json:"updatedAt,omitempty" db:"updated_at"` + DeactivatedAt *time.Time `json:"deactivatedAt,omitempty" db:"deactivated_at"` + DeletedAt *time.Time `json:"deletedAt,omitempty" db:"deleted_at"` + Name string `json:"name" db:"name"` + Address string `json:"address" db:"address"` + Functions pq.StringArray `json:"functions" db:"functions" swaggertype:"array,string"` + Type string `json:"type" db:"type"` + NetworkId string `json:"networkId" db:"network_id"` + OrganizationId string `json:"organizationId" db:"organization_id"` } func (a AuthStrategy) MarshalBinary() ([]byte, error) { diff --git a/pkg/model/request.go b/pkg/model/request.go index f4701046..e7877a6d 100644 --- a/pkg/model/request.go +++ b/pkg/model/request.go @@ -15,19 +15,19 @@ type TransactionUpdates struct { Type *string `json:"type" db:"type"` Status *string `json:"status" db:"status"` Tags *types.JSONText `json:"tags" db:"tags"` - DeviceID *string `json:"deviceId" db:"device_id"` + DeviceId *string `json:"deviceId" db:"device_id"` IPAddress *string `json:"ipAddress" db:"ip_address"` - PlatformID *string `json:"platformId" db:"platform_id"` + PlatformId *string `json:"platformId" db:"platform_id"` TransactionHash *string `json:"transactionHash" db:"transaction_hash"` - NetworkID *string `json:"networkId" db:"network_id"` + NetworkId *string `json:"networkId" db:"network_id"` NetworkFee *string `json:"networkFee" db:"network_fee"` ContractParams *pq.StringArray `json:"contractParameters" db:"contract_params"` ContractFunc *string `json:"contractFunc" db:"contract_func"` TransactionAmount *string `json:"transactionAmount" db:"transaction_amount"` - OriginTxLegID *string `json:"originTxLegId" db:"origin_tx_leg_id"` - ReceiptTxLegID *string `json:"receiptTxLegId" db:"receipt_tx_leg_id"` - ResponseTxLegID *string `json:"responseTxLegId" db:"response_tx_leg_id"` - DestinationTxLegID *string `json:"destinationTxLegId" db:"destination_tx_leg_id"` + OriginTxLegId *string `json:"originTxLegId" db:"origin_tx_leg_id"` + ReceiptTxLegId *string `json:"receiptTxLegId" db:"receipt_tx_leg_id"` + ResponseTxLegId *string `json:"responseTxLegId" db:"response_tx_leg_id"` + DestinationTxLegId *string `json:"destinationTxLegId" db:"destination_tx_leg_id"` ProcessingFee *string `json:"processingFee" db:"processing_fee"` ProcessingFeeAsset *string `json:"processingFeeAsset" db:"processing_fee_asset"` StringFee *string `json:"stringFee" db:"string_fee"` @@ -41,17 +41,17 @@ type InstrumentUpdates struct { Network *string `json:"network" db:"network"` PublicKey *string `json:"publicKey" db:"public_key"` Last4 *string `json:"last4" db:"last_4"` - UserID *string `json:"userId" db:"user_id"` - LocationID *sql.NullString `json:"locationId" db:"location_id"` + UserId *string `json:"userId" db:"user_id"` + LocationId *sql.NullString `json:"locationId" db:"location_id"` } type TxLegUpdates struct { Timestamp *time.Time `json:"timestamp" db:"timestamp"` Amount *string `json:"amount" db:"amount"` Value *string `json:"value" db:"value"` - AssetID *string `json:"assetId" db:"asset_id"` - UserID *string `json:"userId" db:"user_id"` - InstrumentID *string `json:"instrumentId" db:"instrument_id"` + AssetId *string `json:"assetId" db:"asset_id"` + UserId *string `json:"userId" db:"user_id"` + InstrumentId *string `json:"instrumentId" db:"instrument_id"` } type UserRegister struct { @@ -68,13 +68,23 @@ type UserEmailLogin struct { } type UserUpdates struct { - DeactivatedAt *time.Time `json:"deactivatedAt" db:"deactivated_at"` - Type *string `json:"type" db:"type"` - Status *string `json:"status" db:"status"` - Tags *types.JSONText `json:"tags" db:"tags"` - FirstNname *string `json:"firstName" db:"first_name"` - MiddleName *string `json:"middleName" db:"middle_name"` - LastName *string `json:"lastName" db:"last_name"` + Type *string `json:"type" db:"type"` + Status *string `json:"status" db:"status"` + CheckoutId *string `json:"checkoutId" db:"checkout_id"` + Tags *types.JSONText `json:"tags" db:"tags"` + FirstName *string `json:"firstName" db:"first_name"` + MiddleName *string `json:"middleName" db:"middle_name"` + LastName *string `json:"lastName" db:"last_name"` +} + +type IdentityUpdates struct { + Level *int `json:"level" db:"level"` + AccountId *string `json:"accountId" db:"account_id"` + UserId *string `json:"userId" db:"user_id"` + EmailVerified *time.Time `json:"emailVerified,omitempty" db:"email_verified"` + PhoneVerified *time.Time `json:"phoneVerified,omitempty" db:"phone_verified"` + SelfieVerified *time.Time `json:"selfieVerified,omitempty" db:"selfie_verified"` + DocumentVerified *time.Time `json:"documentVerified,omitempty" db:"document_verified"` } type UserPKLogin struct { @@ -95,16 +105,15 @@ type UserRequest struct { } type UpdateUserName struct { - FirstName string `json:"firstName" db:"first_name" validate:"required"` - MiddleName string `json:"middleName" db:"middle_name" validate:"required"` - LastName string `json:"lastName" db:"last_name" validate:"required"` + FirstName string `json:"firstName" db:"first_name" validate:"max=255"` + MiddleName string `json:"middleName" db:"middle_name" validate:"max=255"` + LastName string `json:"lastName" db:"last_name" validate:"max=255"` } type ContactUpdates struct { - DeactivatedAt *time.Time `json:"deactivatedAt" db:"deactivated_at"` - Type *string `json:"type" db:"type"` - Status *string `json:"status" db:"status"` - Data *string `json:"data" db:"data"` + Type *string `json:"type" db:"type"` + Status *string `json:"status" db:"status"` + Data *string `json:"data" db:"data"` } type CreatePlatform struct { @@ -112,11 +121,10 @@ type CreatePlatform struct { Authentication AuthType `json:"authentication" db:"authentication"` } -type PlaformContactUpdates struct { - DeactivatedAt *time.Time `json:"deactivatedAt" db:"deactivated_at"` - Type *string `json:"type" db:"type"` - Status *string `json:"status" db:"status"` - Data *string `json:"data" db:"data"` +type PlatformContactUpdates struct { + Type *string `json:"type" db:"type"` + Status *string `json:"status" db:"status"` + Data *string `json:"data" db:"data"` } type UpdateStatus struct { @@ -124,13 +132,18 @@ type UpdateStatus struct { } type NetworkUpdates struct { - GasTokenID *string `json:"gasTokenId" db:"gas_token_id"` + GasTokenId *string `json:"gasTokenId" db:"gas_token_id"` } type DeviceUpdates struct { - ValidatedAt *time.Time `json:"validatedAt" db:"validated_at"` + ValidatedAt *time.Time `json:"validatedAt" db:"validated_at"` + IpAddresses *pq.StringArray `json:"ipAddresses" db:"ip_addresses"` } type RefreshTokenPayload struct { - WalletAddress string `json:"walletAddress" validate:"required"` + WalletAddress string `json:"walletAddress" validate:"required,eth_addr"` +} + +type PreValidateEmail struct { + Email string `json:"email" validate:"required,email"` } diff --git a/pkg/model/transaction.go b/pkg/model/transaction.go index d34b97d8..1da6b788 100644 --- a/pkg/model/transaction.go +++ b/pkg/model/transaction.go @@ -8,35 +8,53 @@ const ( MintERC721 TransactionType = "MintERC721" ) +type Estimate[T string | float64] struct { + Timestamp int64 `json:"timestamp"` + BaseUSD T `json:"baseUSD"` + GasUSD T `json:"gasUSD"` + TokenUSD T `json:"tokenUSD"` + ServiceUSD T `json:"serviceUSD"` + TotalUSD T `json:"totalUSD"` +} + type Quote struct { - Timestamp int64 `json:"timestamp"` - BaseUSD float64 `json:"baseUSD"` - GasUSD float64 `json:"gasUSD"` - TokenUSD float64 `json:"tokenUSD"` - ServiceUSD float64 `json:"serviceUSD"` - TotalUSD float64 `json:"totalUSD"` + TransactionRequest TransactionRequest `json:"request" validate:"required"` + Estimate Estimate[string] `json:"estimate" validate:"required"` + Signature string `json:"signature" validate:"required,base64"` + Level int `json:"level" validate:"required"` } type ExecutionRequest struct { - TransactionRequest - Quote - Signature string `json:"signature"` - CardToken string `json:"cardToken"` + Quote Quote `json:"quote" validate:"required"` + PaymentInfo PaymentInfo `json:"paymentInfo" validate:"required"` +} + +type PaymentInfo struct { + CardToken *string `json:"cardToken"` + CardId *string `json:"cardId"` + CVV *string `json:"cvv"` + SaveCard bool `json:"saveCard" validate:"boolean"` } // User will pass this in for a quote and receive Execution Parameters type TransactionRequest struct { - UserAddress string `json:"userAddress"` // Used to keep track of user ie "0x44A4b9E2A69d86BA382a511f845CbF2E31286770" - ChainID int `json:"chainID"` // Chain ID to execute on ie 80000 - CxAddr string `json:"contractAddress"` // Address of contract ie "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" - CxFunc string `json:"contractFunction"` // Function declaration ie "mintTo(address) payable" - CxReturn string `json:"contractReturn"` // Function return type ie "uint256" - CxParams []string `json:"contractParameters"` // Function parameters ie ["0x000000000000000000BEEF", "32"] - TxValue string `json:"txValue"` // Amount of native token to send ie "0.08 ether" - TxGasLimit string `json:"gasLimit"` // Gwei gas limit ie "210000 gwei" + UserAddress string `json:"userAddress" validate:"required,eth_addr"` // Used to keep track of user ie "0x44A4b9E2A69d86BA382a511f845CbF2E31286770" + AssetName string `json:"assetName" validate:"required,min=3,max=30"` // Used for receipt + ChainId uint64 `json:"chainId" validate:"required,number"` // Chain ID to execute on e.g. 80000. + Actions []TransactionAction `json:"actions" validate:"required"` // Actions to execute +} + +type TransactionAction struct { + CxAddr string `json:"contractAddress" validate:"required,eth_addr"` // Address of contract ie "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2" + CxFunc string `json:"contractFunction" validate:"required"` // Function declaration ie "mintTo(address)" + CxReturn string `json:"contractReturn"` // Function return type ie "uint256" + CxParams []string `json:"contractParameters"` // Function parameters ie ["0x000000000000000000BEEF", "32"] + TxValue string `json:"txValue"` // Amount of native token to send ie "0.08 ether" + TxGasLimit string `json:"gasLimit" validate:"required,number"` // Gwei gas limit ie "210000 gwei" } type TransactionReceipt struct { - TxID string `json:"txID"` - TxURL string `json:"txUrl"` + TxIds []string `json:"txIds"` + TxURLs []string `json:"txUrls"` + TxTimestamp string `json:"txTimestamp"` } diff --git a/pkg/model/user.go b/pkg/model/user.go deleted file mode 100644 index ae622e16..00000000 --- a/pkg/model/user.go +++ /dev/null @@ -1,21 +0,0 @@ -package model - -type UserOnboardingStatus struct { - Status string `json:"status"` -} - -type WalletSignaturePayload struct { - Address string `json:"address" validate:"required,eth_addr"` - Timestamp int64 `json:"timestamp" validate:"required"` -} - -type FingerprintPayload struct { - VisitorID string `json:"visitorId"` - RequestID string `json:"requestId"` -} - -type WalletSignaturePayloadSigned struct { - Nonce string `json:"nonce" validate:"required"` - Signature string `json:"signature" validate:"required"` - Fingerprint FingerprintPayload `json:"fingerprint"` -} diff --git a/pkg/repository/apikey.go b/pkg/repository/apikey.go new file mode 100644 index 00000000..92cc35b7 --- /dev/null +++ b/pkg/repository/apikey.go @@ -0,0 +1,37 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + + "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + strrepo "github.com/String-xyz/go-lib/v2/repository" + serror "github.com/String-xyz/go-lib/v2/stringerror" + "github.com/String-xyz/string-api/pkg/model" +) + +type Apikey interface { + database.Transactable + GetByData(ctx context.Context, data string, keyType string) (model.Apikey, error) +} + +type apikey[T any] struct { + strrepo.Base[T] +} + +func NewApikey(db database.Queryable) Apikey { + return &apikey[model.Apikey]{strrepo.Base[model.Apikey]{Store: db, Table: "apikey"}} +} + +func (p apikey[T]) GetByData(ctx context.Context, data string, keyType string) (model.Apikey, error) { + m := model.Apikey{} + err := p.Store.GetContext(ctx, &m, fmt.Sprintf("SELECT * FROM %s WHERE data = $1 AND type = $2", p.Table), data, keyType) + if err == sql.ErrNoRows { + return m, common.StringError(serror.NOT_FOUND) + } else if err != nil { + return m, common.StringError(err) + } + return m, nil +} diff --git a/pkg/repository/asset.go b/pkg/repository/asset.go index d0f984e1..d9561771 100644 --- a/pkg/repository/asset.go +++ b/pkg/repository/asset.go @@ -1,51 +1,68 @@ package repository import ( + "context" "database/sql" "fmt" - "github.com/String-xyz/string-api/pkg/internal/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + baserepo "github.com/String-xyz/go-lib/v2/repository" + serror "github.com/String-xyz/go-lib/v2/stringerror" "github.com/String-xyz/string-api/pkg/model" - "github.com/jmoiron/sqlx" ) type Asset interface { - Transactable - Create(model.Asset) (model.Asset, error) - GetById(id string) (model.Asset, error) - GetByName(name string) (model.Asset, error) - Update(ID string, updates any) error + database.Transactable + Create(ctx context.Context, m model.Asset) (model.Asset, error) + GetById(ctx context.Context, id string) (model.Asset, error) + GetByName(ctx context.Context, name string) (model.Asset, error) + GetByKey(ctx context.Context, networkId string, address string) (model.Asset, error) + Update(ctx context.Context, Id string, updates any) error } type asset[T any] struct { - base[T] + baserepo.Base[T] } -func NewAsset(db *sqlx.DB) Asset { - return &asset[model.Asset]{base[model.Asset]{store: db, table: "asset"}} +func NewAsset(db database.Queryable) Asset { + return &asset[model.Asset]{baserepo.Base[model.Asset]{Store: db, Table: "asset"}} } -func (a asset[T]) Create(insert model.Asset) (model.Asset, error) { +func (a asset[T]) Create(ctx context.Context, insert model.Asset) (model.Asset, error) { m := model.Asset{} - rows, err := a.store.NamedQuery(` - INSERT INTO asset (name, description, decimals, is_crypto, network_id, value_oracle) - VALUES(:name, :description, :decimals, :is_crypto, :network_id, :value_oracle) RETURNING *`, insert) + + query, args, err := a.Named(` + INSERT INTO asset (name, description, decimals, is_crypto, network_id, value_oracle, value_oracle_2, address) + VALUES(:name, :description, :decimals, :is_crypto, :network_id, :value_oracle, :value_oracle_2, :address) RETURNING *`, insert) + if err != nil { - return m, common.StringError(err) + return m, libcommon.StringError(err) } - for rows.Next() { - err = rows.StructScan(&m) + + // Use QueryRowxContext to execute the query with the provided context + err = a.Store.QueryRowxContext(ctx, query, args...).StructScan(&m) + if err != nil { + return m, libcommon.StringError(err) } - defer rows.Close() - return m, err + return m, nil +} + +func (a asset[T]) GetByName(ctx context.Context, name string) (model.Asset, error) { + m := model.Asset{} + err := a.Store.GetContext(ctx, &m, fmt.Sprintf("SELECT * FROM %s WHERE name = $1", a.Table), name) + if err != nil && err == sql.ErrNoRows { + return m, serror.NOT_FOUND + } + return m, nil } -func (a asset[T]) GetByName(name string) (model.Asset, error) { +func (a asset[T]) GetByKey(ctx context.Context, networkId string, address string) (model.Asset, error) { m := model.Asset{} - err := a.store.Get(&m, fmt.Sprintf("SELECT * FROM %s WHERE name = $1", a.table), name) + err := a.Store.GetContext(ctx, &m, fmt.Sprintf("SELECT * FROM %s WHERE network_id = $1 AND address = $2", a.Table), networkId, address) if err != nil && err == sql.ErrNoRows { - return m, common.StringError(ErrNotFound) + return m, serror.NOT_FOUND } return m, nil } diff --git a/pkg/repository/auth.go b/pkg/repository/auth.go index ee671d2b..752e24a8 100644 --- a/pkg/repository/auth.go +++ b/pkg/repository/auth.go @@ -1,15 +1,14 @@ package repository import ( - "database/sql" "encoding/json" "fmt" "time" - "github.com/String-xyz/string-api/pkg/internal/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + baserepo "github.com/String-xyz/go-lib/v2/repository" "github.com/String-xyz/string-api/pkg/model" - "github.com/String-xyz/string-api/pkg/store" - "github.com/jmoiron/sqlx" "golang.org/x/crypto/bcrypt" ) @@ -27,76 +26,44 @@ const ( ) type AuthStrategy interface { - Create(authType AuthType, m model.AuthStrategy) error CreateAny(key string, val any, expire time.Duration) error - CreateAPIKey(entityID string, authType AuthType, apiKey string, persistOnly bool) (model.AuthStrategy, error) CreateJWTRefresh(key string, val string) (model.AuthStrategy, error) GetUserIdFromRefreshToken(key string) (string, error) Get(string) (model.AuthStrategy, error) GetKeyString(key string) (string, error) - List(limit, offset int) ([]model.AuthStrategy, error) - ListByStatus(limit, offset int, status string) ([]model.AuthStrategy, error) - UpdateStatus(ID, status string) (model.AuthStrategy, error) Delete(key string) error } -type auth struct { - store *sqlx.DB - redis store.RedisStore +type auth[T any] struct { + baserepo.Base[T] + redis database.RedisStore } -func NewAuth(redis store.RedisStore, store *sqlx.DB) AuthStrategy { - return &auth{redis: redis, store: store} +func NewAuth(redis database.RedisStore, db database.Queryable) AuthStrategy { + return &auth[model.AuthStrategy]{baserepo.Base[model.AuthStrategy]{Store: db, Table: "auth_strategy"}, redis} } // Create creates a strategy with user password/email // Ideally this should be move to PG instead of redis -func (a auth) Create(authType AuthType, m model.AuthStrategy) error { +func (a auth[T]) Create(authType AuthType, m model.AuthStrategy) error { hash, err := bcrypt.GenerateFromPassword([]byte(m.Data), 8) if err != nil { - return common.StringError(err) + return libcommon.StringError(err) } strat := &m strat.Data = string(hash) return a.redis.Set(strat.ContactData, strat, 0) } -func (a auth) CreateAny(key string, val any, expire time.Duration) error { +func (a auth[T]) CreateAny(key string, val any, expire time.Duration) error { return a.redis.Set(key, val, expire) } -// CreateAPIKey creates and persists an API Key for a platform -func (a auth) CreateAPIKey(entityID string, authType AuthType, key string, persistOnly bool) (model.AuthStrategy, error) { - // only insert to postgres and skip redis cache - if persistOnly { - rows, err := a.store.Queryx("INSERT INTO auth_strategy(type,data) VALUES($1, $2) RETURNING *", authType, key) - if err == nil { - m := model.AuthStrategy{} - var scanErr error - for rows.Next() { - scanErr = rows.StructScan(&m) - } - return m, scanErr - } - return model.AuthStrategy{}, err - } - - m := model.AuthStrategy{ - EntityID: entityID, - CreatedAt: time.Now(), - Type: string(authType), - EntityType: string(EntityTypePlatform), - Data: key, - } - - return m, a.redis.Set(key, m, 0) -} - // CreateJWTRefresh creates and persists a refresh jwt token -func (a auth) CreateJWTRefresh(key string, userId string) (model.AuthStrategy, error) { +func (a auth[T]) CreateJWTRefresh(key string, userId string) (model.AuthStrategy, error) { expireAt := time.Hour * 24 * 7 // 7 days expiration m := model.AuthStrategy{ - ID: key, + Id: key, CreatedAt: time.Now(), Type: string(AuthTypeJWT), EntityType: string(EntityTypeUser), @@ -107,76 +74,47 @@ func (a auth) CreateJWTRefresh(key string, userId string) (model.AuthStrategy, e return m, a.redis.Set(key, m, expireAt) } -func (a auth) Get(key string) (model.AuthStrategy, error) { +func (a auth[T]) Get(key string) (model.AuthStrategy, error) { m, err := a.redis.Get(key) if err != nil { - return model.AuthStrategy{}, common.StringError(err) + return model.AuthStrategy{}, libcommon.StringError(err) } authStrat := model.AuthStrategy{} err = json.Unmarshal(m, &authStrat) if err != nil { - return model.AuthStrategy{}, common.StringError(err) + return model.AuthStrategy{}, libcommon.StringError(err) } return authStrat, nil } // return the user id from the refresh token or error if token is invalid or expired -func (a auth) GetUserIdFromRefreshToken(refreshToken string) (string, error) { +func (a auth[T]) GetUserIdFromRefreshToken(refreshToken string) (string, error) { authStrat, err := a.Get(refreshToken) if err != nil { - return "", common.StringError(err) + return "", libcommon.StringError(err) } // assert token has not expired if authStrat.ExpiresAt.Before(time.Now()) { - return "", common.StringError(fmt.Errorf("refresh token expired")) + return "", libcommon.StringError(fmt.Errorf("refresh token expired")) } - // assert token has not been deactivated - if authStrat.DeactivatedAt != nil { - return "", common.StringError(fmt.Errorf("refresh token deactivated at %s", authStrat.DeactivatedAt)) + // assert token has not been deleted + if authStrat.DeletedAt != nil { + return "", libcommon.StringError(fmt.Errorf("refresh token deactivated at %s", authStrat.DeletedAt)) } // if all is well, return the user id return authStrat.Data, nil } -func (a auth) GetKeyString(key string) (string, error) { +func (a auth[T]) GetKeyString(key string) (string, error) { m, err := a.redis.Get(key) if err != nil { - return "", common.StringError(err) + return "", libcommon.StringError(err) } return string(m), nil } -// List all the available auth_keys on the postgres db -func (a auth) List(limit, offset int) ([]model.AuthStrategy, error) { - list := []model.AuthStrategy{} - err := a.store.Select(&list, "SELECT * FROM auth_strategy LIMIT $1 OFFSET $2", limit, offset) - if err != nil && err == sql.ErrNoRows { - return list, nil - } - return list, err -} - -// ListByStatus lists all auth_keys with a given status on the postgres db -func (a auth) ListByStatus(limit, offset int, status string) ([]model.AuthStrategy, error) { - list := []model.AuthStrategy{} - err := a.store.Select(&list, "SELECT * FROM auth_strategy WHERE status = $1 LIMIT $2 OFFSET $3", status, limit, offset) - if err != nil && err == sql.ErrNoRows { - return list, nil - } - return list, err -} - -// UpdateStatus updates the status on postgres db and returns the updated row -func (a auth) UpdateStatus(ID, status string) (model.AuthStrategy, error) { - fmt.Println("Status and ID", status, ID) - row := a.store.QueryRowx("UPDATE auth_strategy SET status = $2 WHERE id = $1 RETURNING *", ID, status) - m := model.AuthStrategy{} - err := row.StructScan(&m) - return m, err -} - -func (a auth) Delete(key string) error { +func (a auth[T]) Delete(key string) error { return a.redis.Delete(key) } diff --git a/pkg/repository/base.go b/pkg/repository/base.go deleted file mode 100644 index 8f78d12a..00000000 --- a/pkg/repository/base.go +++ /dev/null @@ -1,182 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strings" - - "github.com/jmoiron/sqlx" - - "github.com/String-xyz/string-api/pkg/internal/common" -) - -var ErrNotFound = errors.New("not found") - -type Repositories struct { - Auth AuthStrategy - User User - Contact Contact - Instrument Instrument - Device Device - UserToPlatform UserToPlatform - Asset Asset - Network Network - Platform Platform - Transaction Transaction - TxLeg TxLeg - Location Location -} - -type Queryable interface { - sqlx.Ext - sqlx.ExecerContext - sqlx.PreparerContext - sqlx.QueryerContext - sqlx.Preparer - - GetContext(context.Context, interface{}, string, ...interface{}) error - SelectContext(context.Context, interface{}, string, ...interface{}) error - Get(interface{}, string, ...interface{}) error - MustExecContext(context.Context, string, ...interface{}) sql.Result - PreparexContext(context.Context, string) (*sqlx.Stmt, error) - QueryRowContext(context.Context, string, ...interface{}) *sql.Row - Select(interface{}, string, ...interface{}) error - QueryRow(string, ...interface{}) *sql.Row - PrepareNamedContext(context.Context, string) (*sqlx.NamedStmt, error) - PrepareNamed(string) (*sqlx.NamedStmt, error) - Preparex(string) (*sqlx.Stmt, error) - NamedExec(string, interface{}) (sql.Result, error) - NamedExecContext(context.Context, string, interface{}) (sql.Result, error) - MustExec(string, ...interface{}) sql.Result - NamedQuery(string, interface{}) (*sqlx.Rows, error) -} - -type Readable interface { - Select(interface{}, string, ...interface{}) error - Get(interface{}, string, ...interface{}) error -} - -type Transactable interface { - // MustBegin panic if Tx cant start - // the underlying store is set to *sqlx.Tx - // You must call rollBack(), Commit() or Reset() to return back from *sqlx.Tx to *sqlx.DB - MustBegin() Queryable - // Rollback rollback the underyling Tx and resets back to *sqlx.DB from *sqlx.Tx - Rollback() - // Commit commits the undelying Tx and resets to back to *sqlx.DB from *sqlx.Tx - Commit() error - // SetTx sets the underying store to be sqlx.Tx so it can be used for transaction across multiple repos - SetTx(t Queryable) - // Reset changes the store back to *sqlx.DB from *sqlx.Tx - // Useful when there are many repos using the same *sqlx.Tx - Reset(b ...Transactable) -} - -type base[T any] struct { - store Queryable - db Queryable - table string -} - -func (b *base[T]) MustBegin() Queryable { - db := b.store.(*sqlx.DB) - b.db = db - t := db.MustBegin() - b.store = t - return t -} - -func (b *base[T]) Rollback() { - t := b.store.(*sqlx.Tx) - t.Rollback() - b.Reset() -} - -func (b *base[T]) Commit() error { - t := b.store.(*sqlx.Tx) - err := t.Commit() - if err != nil { - common.StringError(err) - } - return err -} - -func (b *base[T]) SetTx(t Queryable) { - b.db = b.store - b.store = t -} - -func (b *base[T]) Reset(repos ...Transactable) { - b.store = b.db - for _, v := range repos { - v.Reset() - } -} - -func (b base[T]) List(limit int, offset int) (list []T, err error) { - if limit == 0 { - limit = 20 - } - - err = b.store.Select(&list, fmt.Sprintf("SELECT * FROM %s LIMIT $1 OFFSET $2", b.table), limit, offset) - if err == sql.ErrNoRows { - return list, err - } - return list, err -} - -func (b base[T]) GetById(ID string) (m T, err error) { - err = b.store.Get(&m, fmt.Sprintf("SELECT * FROM %s WHERE id = $1 AND deactivated_at IS NULL", b.table), ID) - if err != nil && err == sql.ErrNoRows { - return m, common.StringError(ErrNotFound) - } - return m, err -} - -// Returns the first match of the user's ID -func (b base[T]) GetByUserId(userID string) (m T, err error) { - err = b.store.Get(&m, fmt.Sprintf("SELECT * FROM %s WHERE user_id = $1 AND deactivated_at IS NULL LIMIT 1", b.table), userID) - if err != nil && err == sql.ErrNoRows { - return m, common.StringError(ErrNotFound) - } - return m, err -} - -func (b base[T]) ListByUserId(userID string, limit int, offset int) ([]T, error) { - list := []T{} - if limit == 0 { - limit = 20 - } - err := b.store.Select(&list, fmt.Sprintf("SELECT * FROM %s WHERE user_id = $1 LIMIT $2 OFFSET $3", b.table), userID, limit, offset) - if err == sql.ErrNoRows { - return list, common.StringError(err) - } - if err != nil { - return list, common.StringError(err) - } - - return list, nil -} - -func (b base[T]) Update(ID string, updates any) error { - names, keyToUpdate := common.KeysAndValues(updates) - if len(names) == 0 { - return common.StringError(errors.New("no fields to update")) - } - query := fmt.Sprintf("UPDATE %s SET %s WHERE id = '%s'", b.table, strings.Join(names, ", "), ID) - _, err := b.store.NamedExec(query, keyToUpdate) - if err != nil { - return common.StringError(err) - } - return err -} - -func (b base[T]) Select(model interface{}, query string, params ...interface{}) error { - return b.store.Select(model, query, params) -} - -func (b base[T]) Get(model interface{}, query string, params ...interface{}) error { - return b.store.Get(model, query, params) -} diff --git a/pkg/repository/base_test.go b/pkg/repository/base_test.go deleted file mode 100644 index e099d8c9..00000000 --- a/pkg/repository/base_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package repository - -import ( - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/String-xyz/string-api/pkg/model" - "github.com/jmoiron/sqlx" -) - -func TestBaseUpdate(t *testing.T) { - db, mock, err := sqlmock.New() - sqlxDB := sqlx.NewDb(db, "sqlmock") - if err != nil { - t.Fatalf("error %s was not expected when opening stub db", err) - } - defer db.Close() - mock.ExpectExec(`UPDATE contact SET`).WithArgs("type") - mType := "type" - m := model.ContactUpdates{Type: &mType} - - NewContact(sqlxDB).Update("ID", m) - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("error '%s' was not expected, while updating a contact", err) - } -} diff --git a/pkg/repository/contact.go b/pkg/repository/contact.go index 621ff979..b9a9457e 100644 --- a/pkg/repository/contact.go +++ b/pkg/repository/contact.go @@ -1,69 +1,104 @@ package repository import ( + "context" "database/sql" "fmt" - "github.com/String-xyz/string-api/pkg/internal/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + "github.com/String-xyz/go-lib/v2/repository" + serror "github.com/String-xyz/go-lib/v2/stringerror" "github.com/String-xyz/string-api/pkg/model" - "github.com/jmoiron/sqlx" ) type Contact interface { - Transactable - Readable - Create(model.Contact) (model.Contact, error) - GetById(ID string) (model.Contact, error) - GetByUserId(userID string) (model.Contact, error) - ListByUserId(userID string, imit int, offset int) ([]model.Contact, error) - List(limit int, offset int) ([]model.Contact, error) - Update(ID string, updates any) error - GetByData(data string) (model.Contact, error) - //GetByUserIdAndStatus gets a contact with the user id and status - GetByUserIdAndStatus(userID string, status string) (model.Contact, error) + database.Transactable + Create(ctx context.Context, m model.Contact) (model.Contact, error) + GetById(ctx context.Context, id string) (model.Contact, error) + GetByUserId(ctx context.Context, userId string) (model.Contact, error) + ListByUserId(ctx context.Context, userId string, imit int, offset int) ([]model.Contact, error) + List(ctx context.Context, limit int, offset int) ([]model.Contact, error) + Update(ctx context.Context, id string, updates any) error + GetByData(ctx context.Context, data string) (model.Contact, error) + GetEmailByUserIdAndPlatformId(ctx context.Context, userId string, platformId string) (model.Contact, error) + GetByUserIdAndType(ctx context.Context, userId string, _type string) (model.Contact, error) + GetByUserIdAndStatus(ctx context.Context, userId string, status string) (model.Contact, error) } type contact[T any] struct { - base[T] + repository.Base[T] } -func NewContact(db *sqlx.DB) Contact { - return &contact[model.Contact]{base: base[model.Contact]{store: db, table: "contact"}} +func NewContact(db database.Queryable) Contact { + return &contact[model.Contact]{repository.Base[model.Contact]{Store: db, Table: "contact"}} } -func (u contact[T]) Create(insert model.Contact) (model.Contact, error) { +func (u contact[T]) Create(ctx context.Context, insert model.Contact) (model.Contact, error) { m := model.Contact{} - rows, err := u.store.NamedQuery(` + + query, args, err := u.Named(` INSERT INTO contact (user_id, data, type, status) VALUES(:user_id, :data, :type, :status) RETURNING *`, insert) + if err != nil { - return m, common.StringError(err) + return m, libcommon.StringError(err) } - for rows.Next() { - err = rows.StructScan(&m) - if err != nil { - return m, common.StringError(err) - } + + // Use QueryRowxContext to execute the query with the provided context + err = u.Store.QueryRowxContext(ctx, query, args...).StructScan(&m) + if err != nil { + return m, libcommon.StringError(err) } - defer rows.Close() return m, nil } -func (u contact[T]) GetByData(data string) (model.Contact, error) { +func (u contact[T]) GetByData(ctx context.Context, data string) (model.Contact, error) { m := model.Contact{} - err := u.store.Get(&m, fmt.Sprintf("SELECT * FROM %s WHERE data = $1", u.table), data) + err := u.Store.GetContext(ctx, &m, fmt.Sprintf("SELECT * FROM %s WHERE data = $1", u.Table), data) if err != nil && err == sql.ErrNoRows { - return m, common.StringError(ErrNotFound) + return m, serror.NOT_FOUND } - return m, nil + return m, libcommon.StringError(err) } -func (u contact[T]) GetByUserIdAndStatus(userID, status string) (model.Contact, error) { +// TODO: replace references to GetByUserIdAndStatus with the following: +func (u contact[T]) GetEmailByUserIdAndPlatformId(ctx context.Context, userId string, platformId string) (model.Contact, error) { m := model.Contact{} - err := u.store.Get(&m, fmt.Sprintf("SELECT * FROM %s WHERE user_id = $1 AND status = $2 LIMIT 1", u.table), userID, status) + err := u.Store.GetContext(ctx, &m, fmt.Sprintf(` + SELECT contact.* + FROM %s + LEFT JOIN contact_to_platform + ON contact.id = contact_to_platform.contact_id + LEFT JOIN platform + ON contact_to_platform.platform_id = platform.id + WHERE contact.type = 'email' + AND contact.user_id = $1 + AND platform.id = $2 + `, u.Table), userId, platformId) if err != nil && err == sql.ErrNoRows { - return m, ErrNotFound + return m, serror.NOT_FOUND } - return m, common.StringError(err) + return m, libcommon.StringError(err) +} + +func (u contact[T]) GetByUserIdAndType(ctx context.Context, userId string, _type string) (model.Contact, error) { + m := model.Contact{} + err := u.Store.GetContext(ctx, &m, fmt.Sprintf("SELECT * FROM %s WHERE user_id = $1 AND type = $2 LIMIT 1", u.Table), userId, _type) + if err != nil && err == sql.ErrNoRows { + return m, serror.NOT_FOUND + } + + return m, libcommon.StringError(err) +} + +func (u contact[T]) GetByUserIdAndStatus(ctx context.Context, userId, status string) (model.Contact, error) { + m := model.Contact{} + err := u.Store.GetContext(ctx, &m, fmt.Sprintf("SELECT * FROM %s WHERE user_id = $1 AND status = $2 LIMIT 1", u.Table), userId, status) + if err != nil && err == sql.ErrNoRows { + return m, serror.NOT_FOUND + } + + return m, libcommon.StringError(err) } diff --git a/pkg/repository/contact_to_platform.go b/pkg/repository/contact_to_platform.go deleted file mode 100644 index ca0d1543..00000000 --- a/pkg/repository/contact_to_platform.go +++ /dev/null @@ -1,42 +0,0 @@ -package repository - -import ( - "github.com/String-xyz/string-api/pkg/internal/common" - "github.com/String-xyz/string-api/pkg/model" - "github.com/jmoiron/sqlx" -) - -type ContactToPlatform interface { - Transactable - Readable - Create(model.ContactToPlatform) (model.ContactToPlatform, error) - GetById(ID string) (model.ContactToPlatform, error) - List(limit int, offset int) ([]model.ContactToPlatform, error) - Update(ID string, updates any) error -} - -type contactToPlatform[T any] struct { - base[T] -} - -func NewContactPlatform(db *sqlx.DB) ContactToPlatform { - return &contactToPlatform[model.ContactToPlatform]{base: base[model.ContactToPlatform]{store: db, table: "contact_to_platform"}} -} - -func (u contactToPlatform[T]) Create(insert model.ContactToPlatform) (model.ContactToPlatform, error) { - m := model.ContactToPlatform{} - rows, err := u.store.NamedQuery(` - INSERT INTO contact_to_platform (contact_id, platform_id) - VALUES(:contact_id, :platform_id) RETURNING *`, insert) - if err != nil { - return m, common.StringError(err) - } - for rows.Next() { - err = rows.StructScan(&m) - if err != nil { - return m, common.StringError(err) - } - } - defer rows.Close() - return m, nil -} diff --git a/pkg/repository/contract.go b/pkg/repository/contract.go new file mode 100644 index 00000000..8919f18f --- /dev/null +++ b/pkg/repository/contract.go @@ -0,0 +1,43 @@ +package repository + +import ( + "context" + "database/sql" + + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + "github.com/String-xyz/go-lib/v2/repository" + serror "github.com/String-xyz/go-lib/v2/stringerror" + + "github.com/String-xyz/string-api/pkg/model" +) + +type Contract interface { + database.Transactable + GetForValidation(ctx context.Context, address string, networkId string, platformId string) (model.Contract, error) +} + +type contract[T any] struct { + repository.Base[T] +} + +func NewContract(db database.Queryable) Contract { + return &contract[model.Contract]{repository.Base[model.Contract]{Store: db, Table: "contract"}} +} + +// GetForValidation returns a contract for validation by address, networkId and platformId +func (u contract[T]) GetForValidation(ctx context.Context, address string, networkId string, platformId string) (model.Contract, error) { + m := model.Contract{} + err := u.Store.GetContext(ctx, &m, ` + SELECT c.* FROM contract c + INNER JOIN contract_to_platform cp + ON c.id = cp.contract_id AND cp.platform_id = $3 + WHERE c.address = $1 AND c.network_id = $2 + AND deactivated_at IS NULL LIMIT 1 + `, + address, networkId, platformId) + if err != nil && err == sql.ErrNoRows { + return m, serror.NOT_FOUND + } + return m, libcommon.StringError(err) +} diff --git a/pkg/repository/device.go b/pkg/repository/device.go index c7195432..c3b5da62 100644 --- a/pkg/repository/device.go +++ b/pkg/repository/device.go @@ -1,59 +1,63 @@ package repository import ( + "context" "database/sql" - "github.com/String-xyz/string-api/pkg/internal/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + baserepo "github.com/String-xyz/go-lib/v2/repository" + serror "github.com/String-xyz/go-lib/v2/stringerror" "github.com/String-xyz/string-api/pkg/model" - "github.com/jmoiron/sqlx" ) type Device interface { - Transactable - Create(model.Device) (model.Device, error) - GetById(id string) (model.Device, error) + database.Transactable + Create(ctx context.Context, m model.Device) (model.Device, error) + GetById(ctx context.Context, id string) (model.Device, error) - // GetByUserIdAndFingerprint gets a device by fingerprint ID and userID, using a compound index + // GetByUserIdAndFingerprint gets a device by fingerprint ID and userId, using a compound index // the visitor might exisit for two users but the uniqueness comes from (userId, fingerprint) - GetByUserIdAndFingerprint(userID string, fingerprint string) (model.Device, error) - GetByUserId(userID string) (model.Device, error) - ListByUserId(userID string, imit int, offset int) ([]model.Device, error) - Update(ID string, updates any) error + GetByUserIdAndFingerprint(ctx context.Context, userId string, fingerprint string) (model.Device, error) + GetByUserId(ctx context.Context, id string) (model.Device, error) + ListByUserId(ctx context.Context, userId string, imit int, offset int) ([]model.Device, error) + Update(ctx context.Context, id string, updates any) error } type device[T any] struct { - base[T] + baserepo.Base[T] } -func NewDevice(db *sqlx.DB) Device { - return &device[model.Device]{base[model.Device]{store: db, table: "device"}} +func NewDevice(db database.Queryable) Device { + return &device[model.Device]{baserepo.Base[model.Device]{Store: db, Table: "device"}} } -func (d device[T]) Create(insert model.Device) (model.Device, error) { +func (d device[T]) Create(ctx context.Context, insert model.Device) (model.Device, error) { m := model.Device{} - rows, err := d.store.NamedQuery(` - INSERT INTO device (last_used_at,validated_at, type, description, user_id, fingerprint, ip_addresses) - VALUES(:last_used_at,:validated_at, :type, :description, :user_id, :fingerprint, :ip_addresses) + + query, args, err := d.Named(` + INSERT INTO device (last_used_at, validated_at, type, description, user_id, fingerprint, ip_addresses) + VALUES(:last_used_at, :validated_at, :type, :description, :user_id, :fingerprint, :ip_addresses) RETURNING *`, insert) + if err != nil { - return m, common.StringError(err) + return m, libcommon.StringError(err) } - for rows.Next() { - err = rows.StructScan(&m) - if err != nil { - return m, common.StringError(err) - } + + // Use QueryRowxContext to execute the query with the provided context + err = d.Store.QueryRowxContext(ctx, query, args...).StructScan(&m) + if err != nil { + return m, libcommon.StringError(err) } - defer rows.Close() return m, nil } -func (d device[T]) GetByUserIdAndFingerprint(userID, fingerprint string) (model.Device, error) { +func (d device[T]) GetByUserIdAndFingerprint(ctx context.Context, userId, fingerprint string) (model.Device, error) { m := model.Device{} - err := d.store.Get(&m, "SELECT * FROM device WHERE user_id = $1 AND fingerprint = $2 LIMIT 1", userID, fingerprint) + err := d.Store.GetContext(ctx, &m, "SELECT * FROM device WHERE user_id = $1 AND fingerprint = $2 LIMIT 1", userId, fingerprint) if err != nil && err == sql.ErrNoRows { - return m, ErrNotFound + return m, serror.NOT_FOUND } return m, err } diff --git a/pkg/repository/identity.go b/pkg/repository/identity.go new file mode 100644 index 00000000..ae731670 --- /dev/null +++ b/pkg/repository/identity.go @@ -0,0 +1,86 @@ +package repository + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + baserepo "github.com/String-xyz/go-lib/v2/repository" + "github.com/String-xyz/string-api/pkg/model" +) + +type Identity interface { + Create(ctx context.Context, insert model.Identity) (identity model.Identity, err error) + GetById(ctx context.Context, id string) (model.Identity, error) + List(ctx context.Context, limit int, offset int) ([]model.Identity, error) + Update(ctx context.Context, id string, updates any) (identity model.Identity, err error) + GetByUserId(ctx context.Context, userId string) (identity model.Identity, err error) + GetByAccountId(ctx context.Context, accountId string) (identity model.Identity, err error) +} + +type identity[T any] struct { + baserepo.Base[T] +} + +func NewIdentity(db database.Queryable) Identity { + return &identity[model.Identity]{baserepo.Base[model.Identity]{Store: db, Table: "identity"}} +} + +func (i identity[T]) Create(ctx context.Context, insert model.Identity) (identity model.Identity, err error) { + query, args, err := i.Named(` + INSERT INTO identity (user_id) + VALUES(:user_id) RETURNING *`, insert) + if err != nil { + return identity, libcommon.StringError(err) + } + + err = i.Store.QueryRowxContext(ctx, query, args...).StructScan(&identity) + if err != nil { + return identity, libcommon.StringError(err) + } + + return identity, nil +} + +func (i identity[T]) Update(ctx context.Context, id string, updates any) (identity model.Identity, err error) { + names, keyToUpdate := libcommon.KeysAndValues(updates) + if len(names) == 0 { + return identity, libcommon.StringError(errors.New("no updates provided")) + } + + keyToUpdate["id"] = id + + query := fmt.Sprintf("UPDATE %s SET %s WHERE id=:id RETURNING *", i.Table, strings.Join(names, ",")) + namedQuery, args, err := i.Named(query, keyToUpdate) + if err != nil { + return identity, libcommon.StringError(err) + } + + err = i.Store.QueryRowxContext(ctx, namedQuery, args...).StructScan(&identity) + if err != nil { + return identity, libcommon.StringError(err) + } + return identity, nil +} + +func (i identity[T]) GetByUserId(ctx context.Context, userId string) (identity model.Identity, err error) { + query := fmt.Sprintf("SELECT * FROM %s WHERE user_id=$1", i.Table) + err = i.Store.QueryRowxContext(ctx, query, userId).StructScan(&identity) + if err != nil && err == sql.ErrNoRows { + return identity, libcommon.StringError(err) + } + return identity, nil +} + +func (i identity[T]) GetByAccountId(ctx context.Context, accountId string) (identity model.Identity, err error) { + query := fmt.Sprintf("SELECT * FROM %s WHERE account_id=$1", i.Table) + err = i.Store.QueryRowxContext(ctx, query, accountId).StructScan(&identity) + if err != nil && err == sql.ErrNoRows { + return identity, libcommon.StringError(err) + } + return identity, nil +} diff --git a/pkg/repository/instrument.go b/pkg/repository/instrument.go index c6e17c30..d391d1a7 100644 --- a/pkg/repository/instrument.go +++ b/pkg/repository/instrument.go @@ -1,101 +1,131 @@ package repository import ( + "context" "database/sql" "fmt" + "strings" - "github.com/String-xyz/string-api/pkg/internal/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + baserepo "github.com/String-xyz/go-lib/v2/repository" + serror "github.com/String-xyz/go-lib/v2/stringerror" "github.com/String-xyz/string-api/pkg/model" "github.com/jmoiron/sqlx" "github.com/pkg/errors" ) type Instrument interface { - Transactable - Create(model.Instrument) (model.Instrument, error) - Update(ID string, updates any) error - GetById(id string) (model.Instrument, error) - GetWalletByAddr(addr string) (model.Instrument, error) - GetCardByFingerprint(fingerprint string) (m model.Instrument, err error) - GetWalletByUserId(userId string) (model.Instrument, error) - GetBankByUserId(userId string) (model.Instrument, error) - WalletAlreadyExists(addr string) (bool, error) + database.Transactable + Create(ctx context.Context, m model.Instrument) (model.Instrument, error) + Update(ctx context.Context, id string, updates any) error + GetById(ctx context.Context, id string) (model.Instrument, error) + GetWalletByAddr(ctx context.Context, addr string) (model.Instrument, error) + GetCardByFingerprint(ctx context.Context, fingerprint string) (m model.Instrument, err error) + GetWalletByUserId(ctx context.Context, userId string) (model.Instrument, error) + GetBankByUserId(ctx context.Context, userId string) (model.Instrument, error) + WalletAlreadyExists(ctx context.Context, addr string) (bool, error) + GetCardsByUserId(ctx context.Context, userId string) ([]model.Instrument, error) } type instrument[T any] struct { - base[T] + baserepo.Base[T] } func NewInstrument(db *sqlx.DB) Instrument { - return &instrument[model.Instrument]{base[model.Instrument]{store: db, table: "instrument"}} + return &instrument[model.Instrument]{baserepo.Base[model.Instrument]{Store: db, Table: "instrument"}} } -func (i instrument[T]) Create(insert model.Instrument) (model.Instrument, error) { +func (i instrument[T]) Create(ctx context.Context, insert model.Instrument) (model.Instrument, error) { m := model.Instrument{} - rows, err := i.store.NamedQuery(` - INSERT INTO instrument (type, status, network, public_key, user_id, last_4) - VALUES(:type, :status, :network, :public_key, :user_id, :last_4) RETURNING *`, insert) + + query, args, err := i.Named(` + INSERT INTO instrument (type, status, network, public_key, user_id, last_4, name) + VALUES(:type, :status, :network, :public_key, :user_id, :last_4, :name) RETURNING *`, insert) + if err != nil { - return m, common.StringError(err) + return m, libcommon.StringError(err) } - for rows.Next() { - err = rows.StructScan(&m) - if err != nil { - return m, common.StringError(err) - } + + // Use QueryRowxContext to execute the query with the provided context + err = i.Store.QueryRowxContext(ctx, query, args...).StructScan(&m) + if err != nil { + return m, libcommon.StringError(err) } - defer rows.Close() return m, nil } -func (i instrument[T]) GetWalletByAddr(addr string) (model.Instrument, error) { +func (i instrument[T]) GetWalletByAddr(ctx context.Context, addr string) (model.Instrument, error) { m := model.Instrument{} - err := i.store.Get(&m, fmt.Sprintf("SELECT * FROM %s WHERE public_key = $1", i.table), addr) - if err != nil && err == sql.ErrNoRows { - return m, common.StringError(ErrNotFound) - } else if err != nil { - return m, common.StringError(err) + query := fmt.Sprintf("SELECT * FROM %s WHERE public_key = $1", i.Table) + err := i.Store.GetContext(ctx, &m, query, addr) + + switch { + case err == nil: + return m, nil + case errors.Is(err, sql.ErrNoRows), + strings.Contains(errors.Cause(err).Error(), "not found"), + strings.Contains(errors.Cause(err).Error(), "no rows in result set"): + return m, serror.NOT_FOUND + default: + return m, libcommon.StringError(err) } - return m, nil } -func (i instrument[T]) GetCardByFingerprint(fingerprint string) (m model.Instrument, err error) { - return i.GetWalletByAddr(fingerprint) +func (i instrument[T]) GetCardByFingerprint(ctx context.Context, fingerprint string) (m model.Instrument, err error) { + return i.GetWalletByAddr(ctx, fingerprint) } -func (i instrument[T]) GetWalletByUserId(userId string) (model.Instrument, error) { +func (i instrument[T]) GetWalletByUserId(ctx context.Context, userId string) (model.Instrument, error) { m := model.Instrument{} - err := i.store.Get(&m, fmt.Sprintf("SELECT * FROM %s WHERE user_id = $1 AND type = 'Crypto Wallet'", i.table), userId) + err := i.Store.GetContext(ctx, &m, fmt.Sprintf("SELECT * FROM %s WHERE user_id = $1 AND type = 'crypto wallet'", i.Table), userId) if err != nil && err == sql.ErrNoRows { - return m, common.StringError(ErrNotFound) + return m, serror.NOT_FOUND } else if err != nil { - return m, common.StringError(err) + return m, libcommon.StringError(err) } return m, nil } -func (i instrument[T]) GetBankByUserId(userId string) (model.Instrument, error) { +func (i instrument[T]) GetBankByUserId(ctx context.Context, userId string) (model.Instrument, error) { m := model.Instrument{} - err := i.store.Get(&m, fmt.Sprintf("SELECT * FROM %s WHERE user_id = $1 AND type = 'Bank Account'", i.table), userId) + err := i.Store.GetContext(ctx, &m, fmt.Sprintf("SELECT * FROM %s WHERE user_id = $1 AND type = 'bank account'", i.Table), userId) if err != nil && err == sql.ErrNoRows { - return m, common.StringError(ErrNotFound) + return m, serror.NOT_FOUND } else if err != nil { - return m, common.StringError(err) + return m, libcommon.StringError(err) } return m, nil } -func (i instrument[T]) WalletAlreadyExists(addr string) (bool, error) { - wallet, err := i.GetWalletByAddr(addr) +func (i instrument[T]) WalletAlreadyExists(ctx context.Context, addr string) (bool, error) { + wallet, err := i.GetWalletByAddr(ctx, addr) + + // not found error means wallet does not exist + if serror.Is(err, serror.NOT_FOUND) { + return false, nil + } - if err != nil && errors.Cause(err).Error() != "not found" { // because we are wrapping error and care about its value - return true, common.StringError(err) - } else if err == nil && wallet.UserID != "" { - return true, common.StringError(errors.New("wallet already associated with user")) - } else if err == nil && wallet.PublicKey == addr { - return true, common.StringError(errors.New("wallet already exists")) + // throw unknown errors + if err != nil { + return false, libcommon.StringError(err) + } + + if wallet.UserId != "" || wallet.PublicKey == addr { + return true, nil } return false, nil } + +func (i instrument[T]) GetCardsByUserId(ctx context.Context, userId string) ([]model.Instrument, error) { + var cards []model.Instrument + err := i.Store.SelectContext(ctx, &cards, fmt.Sprintf("SELECT * FROM %s WHERE user_id = $1 AND type = 'credit card' OR type = 'debit card'", i.Table), userId) + if err != nil && err == sql.ErrNoRows { + return cards, serror.NOT_FOUND + } else if err != nil { + return cards, libcommon.StringError(err) + } + return cards, nil +} diff --git a/pkg/repository/location.go b/pkg/repository/location.go index 737b908e..35437b41 100644 --- a/pkg/repository/location.go +++ b/pkg/repository/location.go @@ -1,41 +1,46 @@ package repository import ( - "github.com/String-xyz/string-api/pkg/internal/common" + "context" + + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + baserepo "github.com/String-xyz/go-lib/v2/repository" "github.com/String-xyz/string-api/pkg/model" "github.com/jmoiron/sqlx" ) type Location interface { - Transactable - Create(model.Location) (model.Location, error) - GetById(id string) (model.Location, error) - Update(ID string, updates any) error + database.Transactable + Create(ctx context.Context, m model.Location) (model.Location, error) + GetById(ctx context.Context, id string) (model.Location, error) + Update(ctx context.Context, id string, updates any) error } type location[T any] struct { - base[T] + baserepo.Base[T] } func NewLocation(db *sqlx.DB) Location { - return &location[model.Location]{base[model.Location]{store: db, table: "location"}} + return &location[model.Location]{baserepo.Base[model.Location]{Store: db, Table: "location"}} } -func (i location[T]) Create(insert model.Location) (model.Location, error) { +func (i location[T]) Create(ctx context.Context, insert model.Location) (model.Location, error) { m := model.Location{} - rows, err := i.store.NamedQuery(` + + query, args, err := i.Named(` INSERT INTO location (name) - VALUES(:name) RETURNING *`, insert) + VALUES(:name) RETURNING *`, insert) + if err != nil { - return m, common.StringError(err) + return m, libcommon.StringError(err) } - for rows.Next() { - err = rows.StructScan(&m) - if err != nil { - return m, common.StringError(err) - } + + // Use QueryRowxContext to execute the query with the provided context + err = i.Store.QueryRowxContext(ctx, query, args...).StructScan(&m) + if err != nil { + return m, libcommon.StringError(err) } - defer rows.Close() return m, nil } diff --git a/pkg/repository/location_test.go b/pkg/repository/location_test.go index b7c4291a..b688d6b4 100644 --- a/pkg/repository/location_test.go +++ b/pkg/repository/location_test.go @@ -1,6 +1,7 @@ package repository import ( + "context" "testing" "time" @@ -11,6 +12,7 @@ import ( ) func TestGetLocation(t *testing.T) { + ctx := context.Background() id := uuid.NewString() db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) sqlxDB := sqlx.NewDb(db, "sqlmock") @@ -24,9 +26,9 @@ func TestGetLocation(t *testing.T) { mock.ExpectQuery("SELECT * FROM location WHERE id = $1 AND deactivated_at IS NULL").WillReturnRows(rows).WithArgs(id) - location, err := NewLocation(sqlxDB).GetById(id) + location, err := NewLocation(sqlxDB).GetById(ctx, id) assert.NoError(t, err) - assert.NotEmpty(t, location.ID) + assert.NotEmpty(t, location.Id) if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("error '%s' was not expected, getting location by id", err) } diff --git a/pkg/repository/network.go b/pkg/repository/network.go index c387c19c..6ff9c555 100644 --- a/pkg/repository/network.go +++ b/pkg/repository/network.go @@ -1,57 +1,60 @@ package repository import ( + "context" "database/sql" "fmt" - "github.com/String-xyz/string-api/pkg/internal/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + baserepo "github.com/String-xyz/go-lib/v2/repository" + serror "github.com/String-xyz/go-lib/v2/stringerror" "github.com/String-xyz/string-api/pkg/model" - "github.com/jmoiron/sqlx" ) type Network interface { - Transactable - Create(model.Network) (model.Network, error) - GetById(id string) (model.Network, error) - GetByChainId(chainId uint64) (model.Network, error) - Update(ID string, updates any) error + database.Transactable + Create(ctx context.Context, m model.Network) (model.Network, error) + GetById(ctx context.Context, id string) (model.Network, error) + GetByChainId(ctx context.Context, chainId uint64) (model.Network, error) + Update(ctx context.Context, id string, updates any) error } type network[T any] struct { - base[T] + baserepo.Base[T] } -func NewNetwork(db *sqlx.DB) Network { - return &network[model.Network]{base[model.Network]{store: db, table: "network"}} +func NewNetwork(db database.Queryable) Network { + return &network[model.Network]{baserepo.Base[model.Network]{Store: db, Table: "network"}} } -func (n network[T]) Create(insert model.Network) (model.Network, error) { +func (n network[T]) Create(ctx context.Context, insert model.Network) (model.Network, error) { m := model.Network{} - rows, err := n.store.NamedQuery(` - INSERT INTO network (name, network_id, chain_id, gas_oracle, rpc_url, explorer_url) - VALUES(:name, :network_id, :chain_id, :gas_oracle, :rpc_url, :explorer_url) RETURNING *`, insert) + + query, args, err := n.Named(` + INSERT INTO network (name, network_id, chain_id, gas_oracle, rpc_url, explorer_url) + VALUES(:name, :network_id, :chain_id, :gas_oracle, :rpc_url, :explorer_url) RETURNING *`, insert) if err != nil { - return m, common.StringError(err) + return m, libcommon.StringError(err) } - defer rows.Close() - - for rows.Next() { - err = rows.StructScan(&m) - if err != nil { - return m, common.StringError(err) - } + // Use QueryRowxContext to execute the query with the provided context + err = n.Store.QueryRowxContext(ctx, query, args...).StructScan(&m) + if err != nil { + return m, libcommon.StringError(err) } return m, nil } -func (n network[T]) GetByChainId(chainId uint64) (model.Network, error) { +func (n network[T]) GetByChainId(ctx context.Context, chainId uint64) (model.Network, error) { m := model.Network{} - err := n.store.Get(&m, fmt.Sprintf("SELECT * FROM %s WHERE chain_id = $1", n.table), chainId) + + err := n.Store.GetContext(ctx, &m, fmt.Sprintf("SELECT * FROM %s WHERE chain_id = $1", n.Table), chainId) + if err != nil && err == sql.ErrNoRows { - return m, common.StringError(ErrNotFound) + return m, serror.NOT_FOUND } return m, nil } diff --git a/pkg/repository/platform.go b/pkg/repository/platform.go index 8d21f93d..878cd4a5 100644 --- a/pkg/repository/platform.go +++ b/pkg/repository/platform.go @@ -1,15 +1,17 @@ package repository import ( + "context" "time" - "github.com/String-xyz/string-api/pkg/internal/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + baserepo "github.com/String-xyz/go-lib/v2/repository" "github.com/String-xyz/string-api/pkg/model" - "github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx/types" ) -type PlaformUpdates struct { +type PlatformUpdates struct { DeactivatedAt *time.Time `json:"deactivatedAt" db:"deactivated_at"` Type *string `json:"type" db:"type"` Status *string `json:"status" db:"status"` @@ -17,38 +19,62 @@ type PlaformUpdates struct { } type Platform interface { - Transactable - Create(model.Platform) (model.Platform, error) - GetById(ID string) (model.Platform, error) - List(limit int, offset int) ([]model.Platform, error) - Update(ID string, updates any) error + database.Transactable + Create(ctx context.Context, m model.Platform) (model.Platform, error) + GetById(ctx context.Context, id string) (model.Platform, error) + List(ctx context.Context, limit int, offset int) ([]model.Platform, error) + Update(ctx context.Context, id string, updates any) error + AssociateUser(ctx context.Context, userId string, platformId string) error + AssociateContact(ctx context.Context, contactId string, platformId string) error } type platform[T any] struct { - base[T] + baserepo.Base[T] } -func NewPlatform(db *sqlx.DB) Platform { - return &platform[model.Platform]{base: base[model.Platform]{store: db, table: "platform"}} +func NewPlatform(db database.Queryable) Platform { + return &platform[model.Platform]{baserepo.Base[model.Platform]{Store: db, Table: "platform"}} } -func (p platform[T]) Create(m model.Platform) (model.Platform, error) { - plat := model.Platform{} - rows, err := p.store.NamedQuery(` +func (p platform[T]) Create(ctx context.Context, insert model.Platform) (model.Platform, error) { + m := model.Platform{} + + query, args, err := p.Named(` INSERT INTO platform (name, description) - VALUES(:name, :description) RETURNING *`, m) + VALUES(:name, :description) RETURNING *`, insert) if err != nil { - return plat, common.StringError(err) + return m, libcommon.StringError(err) } - for rows.Next() { - err := rows.StructScan(&plat) - if err != nil { - return plat, common.StringError(err) - } + // Use QueryRowxContext to execute the query with the provided context + err = p.Store.QueryRowxContext(ctx, query, args...).StructScan(&m) + if err != nil { + return m, libcommon.StringError(err) } - defer rows.Close() - return plat, nil + + return m, nil } +func (p platform[T]) AssociateUser(ctx context.Context, userId string, platformId string) error { + _, err := p.Store.ExecContext(ctx, ` + INSERT INTO user_to_platform (user_id, platform_id) + VALUES($1, $2)`, userId, platformId) + + if err != nil { + return libcommon.StringError(err) + } + + return nil +} + +func (p platform[T]) AssociateContact(ctx context.Context, contactId string, platformId string) error { + _, err := p.Store.ExecContext(ctx, ` + INSERT INTO contact_to_platform (contact_id, platform_id) + VALUES($1, $2)`, contactId, platformId) + if err != nil { + return libcommon.StringError(err) + } + + return nil +} diff --git a/pkg/repository/repository.go b/pkg/repository/repository.go new file mode 100644 index 00000000..ff6a350b --- /dev/null +++ b/pkg/repository/repository.go @@ -0,0 +1,18 @@ +package repository + +type Repositories struct { + Auth AuthStrategy + Apikey Apikey + User User + Contact Contact + Contract Contract + Instrument Instrument + Device Device + Asset Asset + Network Network + Platform Platform + Transaction Transaction + TxLeg TxLeg + Location Location + Identity Identity +} diff --git a/pkg/repository/transaction.go b/pkg/repository/transaction.go index d2c257c8..37b13d46 100644 --- a/pkg/repository/transaction.go +++ b/pkg/repository/transaction.go @@ -1,42 +1,45 @@ package repository import ( - "github.com/String-xyz/string-api/pkg/internal/common" + "context" + + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + baserepo "github.com/String-xyz/go-lib/v2/repository" "github.com/String-xyz/string-api/pkg/model" - "github.com/jmoiron/sqlx" ) type Transaction interface { - Transactable - Create(model.Transaction) (model.Transaction, error) - GetById(id string) (model.Transaction, error) - Update(ID string, updates any) error + database.Transactable + Create(ctx context.Context, m model.Transaction) (model.Transaction, error) + GetById(ctx context.Context, id string) (model.Transaction, error) + Update(ctx context.Context, id string, updates any) error } type transaction[T any] struct { - base[T] + baserepo.Base[T] } -func NewTransaction(db *sqlx.DB) Transaction { - return &transaction[model.Transaction]{base[model.Transaction]{store: db, table: "transaction"}} +func NewTransaction(db database.Queryable) Transaction { + return &transaction[model.Transaction]{baserepo.Base[model.Transaction]{Store: db, Table: "transaction"}} } -func (t transaction[T]) Create(insert model.Transaction) (model.Transaction, error) { +func (t transaction[T]) Create(ctx context.Context, insert model.Transaction) (model.Transaction, error) { m := model.Transaction{} - // TODO: Add platform_id once it becomes available - rows, err := t.store.NamedQuery(` - INSERT INTO transaction (status, network_id, device_id, platform_id) - VALUES(:status, :network_id, :device_id, :platform_id) RETURNING id`, insert) + + query, args, err := t.Named(` + INSERT INTO transaction (status, network_id, device_id, platform_id, ip_address) + VALUES(:status, :network_id, :device_id, :platform_id, :ip_address) RETURNING id`, insert) + if err != nil { - return m, common.StringError(err) + return m, libcommon.StringError(err) } - for rows.Next() { - err = rows.Scan(&m.ID) - if err != nil { - return m, common.StringError(err) - } + + // Use QueryRowxContext to execute the query with the provided context + err = t.Store.QueryRowxContext(ctx, query, args...).Scan(&m.Id) + if err != nil { + return m, libcommon.StringError(err) } - defer rows.Close() return m, nil } diff --git a/pkg/repository/tx_leg.go b/pkg/repository/tx_leg.go index f3657e53..8c60f5b1 100644 --- a/pkg/repository/tx_leg.go +++ b/pkg/repository/tx_leg.go @@ -1,40 +1,45 @@ package repository import ( - "github.com/String-xyz/string-api/pkg/internal/common" + "context" + + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + baserepo "github.com/String-xyz/go-lib/v2/repository" "github.com/String-xyz/string-api/pkg/model" - "github.com/jmoiron/sqlx" ) type TxLeg interface { - Transactable - Create(model.TxLeg) (model.TxLeg, error) - GetById(id string) (model.TxLeg, error) - Update(ID string, updates any) error + database.Transactable + Create(ctx context.Context, m model.TxLeg) (model.TxLeg, error) + GetById(ctx context.Context, id string) (model.TxLeg, error) + Update(ctx context.Context, id string, updates any) error } type txLeg[T any] struct { - base[T] + baserepo.Base[T] } -func NewTxLeg(db *sqlx.DB) TxLeg { - return &txLeg[model.TxLeg]{base[model.TxLeg]{store: db, table: "tx_leg"}} +func NewTxLeg(db database.Queryable) TxLeg { + return &txLeg[model.TxLeg]{baserepo.Base[model.TxLeg]{Store: db, Table: "tx_leg"}} } -func (t txLeg[T]) Create(insert model.TxLeg) (model.TxLeg, error) { +func (t txLeg[T]) Create(ctx context.Context, insert model.TxLeg) (model.TxLeg, error) { m := model.TxLeg{} - rows, err := t.store.NamedQuery(` + + query, args, err := t.Named(` INSERT INTO tx_leg (timestamp, amount, value, asset_id, user_id, instrument_id) - VALUES(:timestamp, :amount, :value, :asset_id, :user_id, :instrument_id) RETURNING *`, insert) + VALUES(:timestamp, :amount, :value, :asset_id, :user_id, :instrument_id) RETURNING *`, insert) + if err != nil { - return m, common.StringError(err) + return m, libcommon.StringError(err) } - for rows.Next() { - err = rows.StructScan(&m) - if err != nil { - return m, common.StringError(err) - } + + // Use QueryRowxContext to execute the query with the provided context + err = t.Store.QueryRowxContext(ctx, query, args...).StructScan(&m) + if err != nil { + return m, libcommon.StringError(err) } - return m, err + return m, nil } diff --git a/pkg/repository/user.go b/pkg/repository/user.go index 4ae2d4ac..e768fa24 100644 --- a/pkg/repository/user.go +++ b/pkg/repository/user.go @@ -1,95 +1,158 @@ package repository import ( + "context" "database/sql" "errors" "fmt" "strings" - "github.com/String-xyz/string-api/pkg/internal/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + baserepo "github.com/String-xyz/go-lib/v2/repository" + serror "github.com/String-xyz/go-lib/v2/stringerror" + "github.com/String-xyz/string-api/pkg/model" - "github.com/jmoiron/sqlx" ) type User interface { - Transactable - Readable - Create(model.User) (model.User, error) - GetById(ID string) (model.User, error) - List(limit int, offset int) ([]model.User, error) - Update(ID string, updates any) (model.User, error) - GetByType(label string) (model.User, error) - UpdateStatus(ID string, status string) (model.User, error) + database.Transactable + Create(ctx context.Context, user model.User) (model.User, error) + GetById(ctx context.Context, id string) (model.User, error) + List(ctx context.Context, limit int, offset int) ([]model.User, error) + Update(ctx context.Context, id string, updates any) (model.User, error) + GetByType(ctx context.Context, label string) (model.User, error) + UpdateStatus(ctx context.Context, id string, status string) (model.User, error) + GetPlatforms(ctx context.Context, id string, limit int, offset int) ([]model.Platform, error) + GetWithContact(ctx context.Context, id string) (model.UserWithContact, error) } type user[T any] struct { - base[T] + baserepo.Base[T] } -func NewUser(db *sqlx.DB) User { - return &user[model.User]{base[model.User]{store: db, table: "string_user"}} +func NewUser(db database.Queryable) User { + return &user[model.User]{baserepo.Base[model.User]{Store: db, Table: "string_user"}} } -func (u user[T]) Create(insert model.User) (model.User, error) { +func (u user[T]) Create(ctx context.Context, insert model.User) (model.User, error) { m := model.User{} - rows, err := u.store.NamedQuery(` + + query, args, err := u.Named(` INSERT INTO string_user (type, status, first_name, middle_name, last_name) - VALUES(:type, :status, :first_name, :middle_name, :last_name) RETURNING *`, insert) + VALUES(:type, :status, :first_name, :middle_name, :last_name) RETURNING *`, insert) + if err != nil { - return m, common.StringError(err) + return m, libcommon.StringError(err) } - defer rows.Close() - for rows.Next() { - err = rows.StructScan(&m) - if err != nil { - return m, common.StringError(err) - } + + // Use QueryRowxContext to execute the query with the provided context + err = u.Store.QueryRowxContext(ctx, query, args...).StructScan(&m) + if err != nil { + return m, libcommon.StringError(err) } return m, nil } -func (u user[T]) Update(ID string, updates any) (model.User, error) { - names, keyToUpdate := common.KeysAndValues(updates) +func (u user[T]) Update(ctx context.Context, id string, updates any) (model.User, error) { + names, keyToUpdate := libcommon.KeysAndValues(updates) var user model.User if len(names) == 0 { - return user, common.StringError(errors.New("no fields to update")) + return user, libcommon.StringError(errors.New("no fields to update")) } - query := fmt.Sprintf("UPDATE %s SET %s WHERE id = '%s' RETURNING *", u.table, strings.Join(names, ", "), ID) - rows, err := u.store.NamedQuery(query, keyToUpdate) - if err != nil { - return user, common.StringError(err) - } - defer rows.Close() + // Add the "id" key to the keyToUpdate map + keyToUpdate["id"] = id - for rows.Next() { - err = rows.StructScan(&user) + query := fmt.Sprintf("UPDATE %s SET %s WHERE id = :id RETURNING *", u.Table, strings.Join(names, ", ")) + namedQuery, args, err := u.Named(query, keyToUpdate) + if err != nil { + return user, libcommon.StringError(err) } + // Use QueryRowxContext to execute the query with the provided context + err = u.Store.QueryRowxContext(ctx, namedQuery, args...).StructScan(&user) if err != nil { - return user, common.StringError(err) + return user, libcommon.StringError(err) } - return user, err + + return user, nil } // update user status -func (u user[T]) UpdateStatus(ID string, status string) (model.User, error) { +func (u user[T]) UpdateStatus(ctx context.Context, id string, status string) (model.User, error) { m := model.User{} - err := u.store.Get(&m, fmt.Sprintf("UPDATE %s SET status = $1 WHERE id = $2 RETURNING *", u.table), status, ID) + + // Prepare the named statement with sqlx.NamedStmt + stmt, err := u.Store.PrepareNamedContext(ctx, fmt.Sprintf("UPDATE %s SET status = :status WHERE id = :id RETURNING *", u.Table)) if err != nil { - return m, common.StringError(err) + return m, libcommon.StringError(err) } + defer stmt.Close() + + // Execute the prepared named statement with context and parameters + err = stmt.GetContext(ctx, &m, map[string]interface{}{ + "id": id, + "status": status, + }) + if err != nil { + return m, libcommon.StringError(err) + } + return m, nil } -func (u user[T]) GetByType(label string) (model.User, error) { +func (u user[T]) GetByType(ctx context.Context, label string) (model.User, error) { m := model.User{} - err := u.store.Get(&m, fmt.Sprintf("SELECT * FROM %s WHERE type = $1 LIMIT 1", u.table), label) + query := fmt.Sprintf("SELECT * FROM %s WHERE type = $1 LIMIT 1", u.Table) + err := u.Store.GetContext(ctx, &m, query, label) + if err != nil && err == sql.ErrNoRows { + return m, serror.NOT_FOUND + } else if err != nil { + return m, libcommon.StringError(err) + } + return m, nil +} + +func (u user[T]) GetPlatforms(ctx context.Context, id string, limit int, offset int) (platforms []model.Platform, err error) { + if limit == 0 { + limit = 20 + } + + query := ` + SELECT platform.* FROM platform + LEFT JOIN user_to_platform + ON platform.id = user_to_platform.platform_id + WHERE user_to_platform.user_id = $1 + AND platform.deleted_at IS NULL + LIMIT $2 OFFSET $3` + + err = u.Store.SelectContext(ctx, &platforms, query, id, limit, offset) // Pass id, limit, and offset as separate parameters + if err != nil && err == sql.ErrNoRows { + return platforms, serror.NOT_FOUND + } else if err != nil { + return platforms, libcommon.StringError(err) + } + return platforms, nil +} + +func (u user[T]) GetWithContact(ctx context.Context, userId string) (model.UserWithContact, error) { + m := model.UserWithContact{} + query := ` + SELECT u.*, c.data as email FROM string_user u + LEFT JOIN contact c + ON u.id = c.user_id + WHERE u.id = $1 + AND c.type = 'email' + AND c.status = 'validated' + LIMIT 1 + ` + err := u.Store.GetContext(ctx, &m, query, userId) if err != nil && err == sql.ErrNoRows { - return m, common.StringError(ErrNotFound) + return m, serror.NOT_FOUND } else if err != nil { - return m, common.StringError(err) + return m, libcommon.StringError(err) } return m, nil } diff --git a/pkg/repository/user_test.go b/pkg/repository/user_test.go index 4625c194..a0c446cd 100644 --- a/pkg/repository/user_test.go +++ b/pkg/repository/user_test.go @@ -1,6 +1,7 @@ package repository import ( + "context" "testing" "time" @@ -26,13 +27,15 @@ func TestCreateUser(t *testing.T) { defer db.Close() mock.ExpectQuery(`INSERT INTO string_user`).WithArgs(m.FirstName, m.LastName, m.Type, m.Status) - NewUser(sqlxDB).Create(m) + ctx := context.Background() + NewUser(sqlxDB).Create(ctx, m) if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("error '%s' was not expected, while inserting a new user", err) } } func TestGetUser(t *testing.T) { + ctx := context.Background() id := uuid.NewString() db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) sqlxDB := sqlx.NewDb(db, "sqlmock") @@ -46,15 +49,16 @@ func TestGetUser(t *testing.T) { mock.ExpectQuery("SELECT * FROM string_user WHERE id = $1 AND deactivated_at IS NULL").WillReturnRows(rows).WithArgs(id) - user, err := NewUser(sqlxDB).GetById(id) + user, err := NewUser(sqlxDB).GetById(ctx, id) assert.NoError(t, err) - assert.Equal(t, id, user.ID) + assert.Equal(t, id, user.Id) if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("error '%s' was not expected, getting user by id", err) } } func TestListUser(t *testing.T) { + ctx := context.Background() id1, id2 := uuid.NewString(), uuid.NewString() db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) sqlxDB := sqlx.NewDb(db, "sqlmock") @@ -69,7 +73,7 @@ func TestListUser(t *testing.T) { mock.ExpectQuery("SELECT * FROM string_user LIMIT $1 OFFSET $2").WillReturnRows(rows).WithArgs(10, 0) - NewUser(sqlxDB).List(10, 0) + NewUser(sqlxDB).List(ctx, 10, 0) if err := mock.ExpectationsWereMet(); err != nil { t.Errorf("error '%s' was not expected, getting the list of users", err) } diff --git a/pkg/repository/user_to_platform.go b/pkg/repository/user_to_platform.go deleted file mode 100644 index c3fc066b..00000000 --- a/pkg/repository/user_to_platform.go +++ /dev/null @@ -1,43 +0,0 @@ -package repository - -import ( - "github.com/String-xyz/string-api/pkg/internal/common" - "github.com/String-xyz/string-api/pkg/model" - "github.com/jmoiron/sqlx" -) - -type UserToPlatform interface { - Transactable - Readable - Create(model.UserToPlatform) (model.UserToPlatform, error) - GetById(ID string) (model.UserToPlatform, error) - List(limit int, offset int) ([]model.UserToPlatform, error) - ListByUserId(userID string, imit int, offset int) ([]model.UserToPlatform, error) - Update(ID string, updates any) error -} - -type userToPlatform[T any] struct { - base[T] -} - -func NewUserToPlatform(db *sqlx.DB) UserToPlatform { - return &userToPlatform[model.UserToPlatform]{base: base[model.UserToPlatform]{store: db, table: "user_to_platform"}} -} - -func (u userToPlatform[T]) Create(insert model.UserToPlatform) (model.UserToPlatform, error) { - m := model.UserToPlatform{} - rows, err := u.store.NamedQuery(` - INSERT INTO user_to_platform (user_id, platform_id) - VALUES(:user_id, :platform_id) RETURNING *`, insert) - if err != nil { - return m, common.StringError(err) - } - for rows.Next() { - err = rows.StructScan(&m) - if err != nil { - return m, common.StringError(err) - } - } - defer rows.Close() - return m, nil -} diff --git a/pkg/service/auth.go b/pkg/service/auth.go index 1eed666a..20c10255 100644 --- a/pkg/service/auth.go +++ b/pkg/service/auth.go @@ -1,13 +1,18 @@ package service import ( - netmail "net/mail" - "os" + "context" "regexp" "strings" "time" + b64 "encoding/base64" + + libcommon "github.com/String-xyz/go-lib/v2/common" + serror "github.com/String-xyz/go-lib/v2/stringerror" + "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/internal/common" + "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/repository" "github.com/golang-jwt/jwt/v4" @@ -23,36 +28,19 @@ var hexRegex *regexp.Regexp = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`) var walletAuthenticationPrefix string = "Thank you for using String! By signing this message you are:\n\n1) Authorizing String to initiate off-chain transactions on your behalf, including your bank account, credit card, or debit card.\n\n2) Confirming that this wallet is owned by you.\n\nThis request will not trigger any blockchain transaction or cost any gas.\n\nNonce: " -type RefreshTokenResponse struct { - Token string `json:"token"` - ExpAt time.Time `json:"expAt"` -} - -type JWT struct { - ExpAt time.Time `json:"expAt"` - IssuedAt time.Time `json:"issuedAt"` - Token string `json:"token"` - RefreshToken RefreshTokenResponse `json:"refreshToken"` -} - -type JWTClaims struct { - UserId string - DeviceId string - jwt.StandardClaims -} - type Auth interface { // PayloadToSign returns a payload to be sign by a wallet // to authenticate an user, the payload expires in 15 minutes - PayloadToSign(walletAdress string) (SignablePayload, error) + PayloadToSign(ctx context.Context, walletAddress string) (signatureRequest model.SignatureRequest, err error) // VerifySignedPayload receives a signed payload from the user and verifies the signature - // if signaure is valid it returns a JWT to authenticate the user - VerifySignedPayload(model.WalletSignaturePayloadSigned) (UserCreateResponse, error) + // if signature is valid it returns a JWT to authenticate the user + VerifySignedPayload(ctx context.Context, signature model.WalletSignaturePayloadSigned, platformId string, bypassDevice bool) (model.UserLoginResponse, error) - GenerateJWT(string, ...model.Device) (JWT, error) - ValidateAPIKey(key string) bool - RefreshToken(token string, walletAddress string) (UserCreateResponse, error) + GenerateJWT(string, string, ...model.Device) (model.JWT, error) + ValidateAPIKeyPublic(ctx context.Context, key string) (string, error) + ValidateAPIKeySecret(ctx context.Context, key string) (string, error) + RefreshToken(ctx context.Context, token string, walletAddress string, platformId string) (model.UserLoginResponse, error) InvalidateRefreshToken(token string) error } @@ -67,104 +55,115 @@ func NewAuth(r repository.Repositories, v Verification, d Device) Auth { return &auth{r, v, d} } -func (a auth) PayloadToSign(walletAddress string) (SignablePayload, error) { +func (a auth) PayloadToSign(ctx context.Context, walletAddress string) (signatureRequest model.SignatureRequest, err error) { + _, finish := Span(ctx, "service.auth.PayloadToSign") + defer finish() + payload := model.WalletSignaturePayload{} - signable := SignablePayload{} if !hexRegex.MatchString(walletAddress) { - return signable, common.StringError(errors.New("missing or invalid address")) + return signatureRequest, libcommon.StringError(errors.New("missing or invalid address")) } payload.Address = walletAddress payload.Timestamp = time.Now().Unix() - key := os.Getenv("STRING_ENCRYPTION_KEY") - encrypted, err := common.Encrypt(payload, key) + key := config.Var.STRING_ENCRYPTION_KEY + encrypted, err := libcommon.Encrypt(payload, key) if err != nil { - return signable, common.StringError(err) + return signatureRequest, libcommon.StringError(err) } - return SignablePayload{walletAuthenticationPrefix + encrypted}, nil + signablePayload := SignablePayload{walletAuthenticationPrefix + encrypted} + encodedNonce := b64.StdEncoding.EncodeToString([]byte(signablePayload.Nonce)) + + return model.SignatureRequest{Nonce: encodedNonce}, nil } -func (a auth) VerifySignedPayload(request model.WalletSignaturePayloadSigned) (UserCreateResponse, error) { - resp := UserCreateResponse{} - key := os.Getenv("STRING_ENCRYPTION_KEY") - payload, err := common.Decrypt[model.WalletSignaturePayload](request.Nonce[len(walletAuthenticationPrefix):], key) - if err != nil { - return resp, common.StringError(err) - } +func (a auth) VerifySignedPayload(ctx context.Context, request model.WalletSignaturePayloadSigned, platformId string, bypassDevice bool) (model.UserLoginResponse, error) { + _, finish := Span(ctx, "service.auth.VerifySignedPayload", SpanTag{"platformId": platformId}) + defer finish() - if err := verifyWalletAuthentication(request); err != nil { - return resp, common.StringError(err) + resp := model.UserLoginResponse{} + + payload, err := verifyWalletAuthentication(request) + if err != nil { + return resp, libcommon.StringError(err) } // Verify user is registered to this wallet address - instrument, err := a.repos.Instrument.GetWalletByAddr(payload.Address) + instrument, err := a.repos.Instrument.GetWalletByAddr(ctx, payload.Address) if err != nil { - return resp, common.StringError(err) + return resp, libcommon.StringError(err) } - user, err := a.repos.User.GetById(instrument.UserID) + + user, err := a.repos.User.GetById(ctx, instrument.UserId) if err != nil { - return resp, common.StringError(err) + return resp, libcommon.StringError(err) } - user.Email = getValidatedEmailOrEmpty(a.repos.Contact, user.ID) + // TODO: remove user.Email and replace with association with contact via user and platform + user.Email = getValidatedEmailOrEmpty(ctx, a.repos.Contact, user.Id) - device, err := a.device.CreateDeviceIfNeeded(user.ID, request.Fingerprint.VisitorID, request.Fingerprint.RequestID) + device, err := a.device.CreateDeviceIfNeeded(ctx, user.Id, request.Fingerprint.VisitorId, request.Fingerprint.RequestId) if err != nil && !strings.Contains(err.Error(), "not found") { - return resp, common.StringError(err) + return resp, libcommon.StringError(err) } // Send verification email if device is unknown and user has a validated email - if user.Email != "" && !isDeviceValidated(device) { - go a.verification.SendDeviceVerification(user.ID, user.Email, device.ID, device.Description) - return resp, common.StringError(errors.New("unknown device")) + // and if verification is not bypassed + if !bypassDevice && user.Email != "" && !isDeviceValidated(device) { + err = a.verification.SendDeviceVerification(ctx, user.Id, user.Email, device.Id, device.Description) + if err != nil { + return resp, libcommon.StringError(err) + } + return resp, libcommon.StringError(serror.UNKNOWN_DEVICE) } // Create the JWT - jwt, err := a.GenerateJWT(user.ID, device) + jwt, err := a.GenerateJWT(user.Id, platformId, device) if err != nil { - return resp, common.StringError(err) + return resp, libcommon.StringError(err) } // Invalidate device if it is unknown and was validated so it cannot be used again - err = a.device.InvalidateUnknownDevice(device) + err = a.device.InvalidateUnknownDevice(ctx, device) if err != nil { - return resp, common.StringError(err) + return resp, libcommon.StringError(err) } - return UserCreateResponse{JWT: jwt, User: user}, nil + return model.UserLoginResponse{JWT: jwt, User: user}, nil } // GenerateJWT generates a jwt token and a refresh token which is saved on redis -func (a auth) GenerateJWT(userId string, m ...model.Device) (JWT, error) { - claims := JWTClaims{} +func (a auth) GenerateJWT(userId string, platformId string, m ...model.Device) (model.JWT, error) { + claims := model.JWTClaims{} refreshToken := uuidWithoutHyphens() - t := &JWT{ + t := &model.JWT{ IssuedAt: time.Now(), ExpAt: time.Now().Add(time.Minute * 15), } // set device id if available if len(m) > 0 { - claims.DeviceId = m[0].ID + claims.DeviceId = m[0].Id } claims.UserId = userId + claims.PlatformId = platformId claims.ExpiresAt = t.ExpAt.Unix() claims.IssuedAt = t.IssuedAt.Unix() // replace this signing method with RSA or something similar token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - signed, err := token.SignedString([]byte(os.Getenv("JWT_SECRET_KEY"))) + signed, err := token.SignedString([]byte(config.Var.JWT_SECRET_KEY)) if err != nil { return *t, err } t.Token = signed // create and save - refreshObj, err := a.repos.Auth.CreateJWTRefresh(common.ToSha256(refreshToken), userId) + refreshObj, err := a.repos.Auth.CreateJWTRefresh(libcommon.ToSha256(refreshToken), userId) if err != nil { return *t, err } - t.RefreshToken = RefreshTokenResponse{ + t.RefreshToken = model.RefreshTokenResponse{ Token: refreshToken, ExpAt: refreshObj.ExpiresAt, } @@ -173,108 +172,153 @@ func (a auth) GenerateJWT(userId string, m ...model.Device) (JWT, error) { } func (a auth) ValidateJWT(token string) (bool, error) { - var claims = &JWTClaims{} + var claims = &model.JWTClaims{} t, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (interface{}, error) { - return []byte(os.Getenv("JWT_SECRET_KEY")), nil + return []byte(config.Var.JWT_SECRET_KEY), nil }) return t.Valid, err } -func (a auth) ValidateAPIKey(key string) bool { - hashed := common.ToSha256(key) - authKey, err := a.repos.Auth.Get(hashed) +func (a auth) ValidateAPIKeyPublic(ctx context.Context, key string) (string, error) { + _, finish := Span(ctx, "service.apikey.ValidateAPIKeyPublic") + defer finish() + + authKey, err := a.repos.Apikey.GetByData(ctx, key, "public") if err != nil { - return false + return "", libcommon.StringError(err) } - return authKey.Data == hashed + + if authKey.Id == "" { + return "", libcommon.StringError(errors.New("invalid api key")) + } + + if authKey.Data != key { + return "", libcommon.StringError(errors.New("invalid api key")) + } + + // platform must be active + platform, err := a.repos.Platform.GetById(ctx, *authKey.PlatformId) + if err != nil { + return "", libcommon.StringError(err) + } + + if platform.DeactivatedAt != nil { + return "", libcommon.StringError(errors.New("invalid api key")) + } + + return *authKey.PlatformId, nil +} + +func (a auth) ValidateAPIKeySecret(ctx context.Context, key string) (string, error) { + _, finish := Span(ctx, "service.akikey.ValidateAPIKeySecret") + defer finish() + + data := libcommon.ToSha256(key) + authKey, err := a.repos.Apikey.GetByData(ctx, data, "secret") + if err != nil { + return "", libcommon.StringError(err) + } + + if authKey.Id == "" { + return "", libcommon.StringError(errors.New("invalid secret key")) + } + + if authKey.Data != data { + return "", libcommon.StringError(errors.New("invalid secret key")) + } + + // platform must be active + platform, err := a.repos.Platform.GetById(ctx, *authKey.PlatformId) + if err != nil { + return "", libcommon.StringError(err) + } + + if platform.DeactivatedAt != nil { + return "", libcommon.StringError(errors.New("invalid api key")) + } + + return *authKey.PlatformId, nil } func (a auth) InvalidateRefreshToken(refreshToken string) error { - return a.repos.Auth.Delete(common.ToSha256(refreshToken)) + return a.repos.Auth.Delete(libcommon.ToSha256(refreshToken)) } -func (a auth) RefreshToken(refreshToken string, walletAddress string) (UserCreateResponse, error) { - resp := UserCreateResponse{} +func (a auth) RefreshToken(ctx context.Context, refreshToken string, walletAddress string, platformId string) (model.UserLoginResponse, error) { + _, finish := Span(ctx, "service.auth.RefreshToken", SpanTag{"platformId": platformId}) + defer finish() + resp := model.UserLoginResponse{} // get user id from refresh token - userId, err := a.repos.Auth.GetUserIdFromRefreshToken(common.ToSha256(refreshToken)) + userId, err := a.repos.Auth.GetUserIdFromRefreshToken(libcommon.ToSha256(refreshToken)) if err != nil { - return resp, common.StringError(err) + return resp, libcommon.StringError(err) } // verify wallet address // Verify user is registered to this wallet address - instrument, err := a.repos.Instrument.GetWalletByAddr(walletAddress) + instrument, err := a.repos.Instrument.GetWalletByAddr(ctx, walletAddress) if err != nil { - if strings.Contains(err.Error(), "not found") { - return resp, common.StringError(errors.New("wallet address not associated with this user: " + walletAddress)) - } - return resp, common.StringError(err) + return resp, libcommon.StringError(err) } - if instrument.UserID != userId { - return resp, common.StringError(errors.New("wallet address not associated with this user: " + walletAddress)) + if instrument.UserId != userId { + return resp, libcommon.StringError(serror.NOT_FOUND) } // get device - device, err := a.repos.Device.GetByUserId(userId) + device, err := a.repos.Device.GetByUserId(ctx, userId) if err != nil { - return resp, common.StringError(err) + return resp, libcommon.StringError(err) } // create new jwt - jwt, err := a.GenerateJWT(userId, device) + jwt, err := a.GenerateJWT(userId, platformId, device) if err != nil { - return resp, common.StringError(err) + return resp, libcommon.StringError(err) } resp.JWT = jwt // delete old refresh token err = a.InvalidateRefreshToken(refreshToken) if err != nil { - return resp, common.StringError(err) + return resp, libcommon.StringError(err) } - user, err := a.repos.User.GetById(instrument.UserID) + user, err := a.repos.User.GetById(ctx, instrument.UserId) if err != nil { - return resp, common.StringError(err) + return resp, libcommon.StringError(err) } // get email - user.Email = getValidatedEmailOrEmpty(a.repos.Contact, user.ID) + user.Email = getValidatedEmailOrEmpty(ctx, a.repos.Contact, user.Id) resp.User = user return resp, nil } -func verifyWalletAuthentication(request model.WalletSignaturePayloadSigned) error { - key := os.Getenv("STRING_ENCRYPTION_KEY") - preSignedPayload, err := common.Decrypt[model.WalletSignaturePayload](request.Nonce[len(walletAuthenticationPrefix):], key) +func verifyWalletAuthentication(request model.WalletSignaturePayloadSigned) (payload model.WalletSignaturePayload, err error) { + key := config.Var.STRING_ENCRYPTION_KEY + payload, err = libcommon.Decrypt[model.WalletSignaturePayload](request.Nonce[len(walletAuthenticationPrefix):], key) if err != nil { - return common.StringError(err) + return payload, libcommon.StringError(err) } // Verify users signature bytes := []byte(request.Nonce) - valid, err := common.ValidateExternalEVMSignature(request.Signature, preSignedPayload.Address, bytes, true) // true: expect eip131 + valid, err := common.ValidateExternalEVMSignature(request.Signature, payload.Address, bytes, true) // true: expect eip131 if err != nil { - return common.StringError(err) + return payload, libcommon.StringError(err) } if !valid { - return common.StringError(errors.New("user signature invalid")) + return payload, libcommon.StringError(errors.New("user signature invalid")) } // Verify timestamp is not expired past 15 minutes - if time.Now().Unix() > preSignedPayload.Timestamp+(15*60) { - return common.StringError(errors.New("login payload expired")) + if time.Now().Unix() > payload.Timestamp+(15*60) { + return payload, libcommon.StringError(serror.EXPIRED) } - return nil -} - -// Use native mail package to check if email a valid email -func validEmail(email string) bool { - _, err := netmail.ParseAddress(email) - return err == nil + return payload, nil } func uuidWithoutHyphens() string { @@ -282,8 +326,8 @@ func uuidWithoutHyphens() string { return strings.Replace(s, "-", "", -1) } -func getValidatedEmailOrEmpty(contactRepo repository.Contact, userId string) string { - contact, err := contactRepo.GetByUserIdAndStatus(userId, "validated") +func getValidatedEmailOrEmpty(ctx context.Context, contactRepo repository.Contact, userId string) string { + contact, err := contactRepo.GetByUserIdAndStatus(ctx, userId, "validated") if err != nil { return "" } diff --git a/pkg/service/auth_key.go b/pkg/service/auth_key.go deleted file mode 100644 index 87dfff86..00000000 --- a/pkg/service/auth_key.go +++ /dev/null @@ -1,55 +0,0 @@ -package service - -import ( - "github.com/String-xyz/string-api/pkg/internal/common" - "github.com/String-xyz/string-api/pkg/model" - "github.com/String-xyz/string-api/pkg/repository" -) - -type APIKeyStrategy interface { - Create() (model.AuthStrategy, error) - List(limit, offset int, status string) ([]model.AuthStrategy, error) - Approve(ID string) error -} - -type aPIKeyStrategy struct { - repo repository.AuthStrategy -} - -func NewAPIKeyStrategy(repo repository.AuthStrategy) APIKeyStrategy { - return aPIKeyStrategy{repo} -} - -func (g aPIKeyStrategy) Create() (model.AuthStrategy, error) { - uuiKey := "str." + uuidWithoutHyphens() - hashed := common.ToSha256(uuiKey) - m, err := g.repo.CreateAPIKey("", repository.AuthTypeAPIKey, hashed, true) - m.Data = uuiKey - m.ContactData = "" - return m, err -} - -func (g aPIKeyStrategy) List(limit, offset int, status string) ([]model.AuthStrategy, error) { - if status != "" { - return g.ListByStatus(limit, offset, status) - } - return g.repo.List(limit, offset) -} - -func (g aPIKeyStrategy) ListByStatus(limit, offset int, status string) ([]model.AuthStrategy, error) { - if limit == 0 { - limit = 100 - } - return g.repo.ListByStatus(limit, offset, status) -} - -// Approve updates the APIKey status and creates an entry on redis -func (g aPIKeyStrategy) Approve(ID string) error { - m, err := g.repo.UpdateStatus(ID, "active") - if err != nil { - return err - } - - _, err = g.repo.CreateAPIKey(m.ID, repository.AuthTypeAPIKey, m.Data, false) - return err -} diff --git a/pkg/service/base.go b/pkg/service/base.go index ee055faf..7550f301 100644 --- a/pkg/service/base.go +++ b/pkg/service/base.go @@ -1,17 +1,19 @@ package service type Services struct { - Auth Auth - ApiKey APIKeyStrategy + Auth Auth // Chain Chain // TODO: Make this service instantiable // Checkout Checkout // TODO: Make this service instantiable Cost Cost Executor Executor Geofencing Geofencing - Platform Platform // Sms Sms // TODO: Make this service instantiable Transaction Transaction User User Verification Verification Device Device + Unit21 Unit21 + Card Card + Webhook Webhook + KYC KYC } diff --git a/pkg/service/card.go b/pkg/service/card.go new file mode 100644 index 00000000..227b5953 --- /dev/null +++ b/pkg/service/card.go @@ -0,0 +1,37 @@ +package service + +import ( + "context" + + libcommon "github.com/String-xyz/go-lib/v2/common" + + "github.com/String-xyz/string-api/pkg/internal/checkout" + "github.com/String-xyz/string-api/pkg/repository" +) + +type Card interface { + ListByUserId(ctx context.Context, userId string, platformId string) (instruments []checkout.CardInstrument, err error) +} + +type card struct { + repos repository.Repositories +} + +func NewCard(repos repository.Repositories) Card { + return &card{repos} +} + +func (c card) ListByUserId(ctx context.Context, userId string, platformId string) (instruments []checkout.CardInstrument, err error) { + _, finish := Span(ctx, "service.card.ListByUserId", SpanTag{"platformId": platformId}) + defer finish() + + user, err := c.repos.User.GetById(ctx, userId) + if err != nil { + return nil, libcommon.StringError(err) + } + + client := checkout.New() + instruments, err = client.Customer.ListInstruments(user.CheckoutId) + + return instruments, libcommon.StringError(err) +} diff --git a/pkg/service/chain.go b/pkg/service/chain.go index fd252a6a..f50029a2 100644 --- a/pkg/service/chain.go +++ b/pkg/service/chain.go @@ -3,19 +3,22 @@ package service import ( - "github.com/String-xyz/string-api/pkg/internal/common" + "context" + + libcommon "github.com/String-xyz/go-lib/v2/common" "github.com/String-xyz/string-api/pkg/repository" ) type Chain struct { - ChainID uint64 + ChainId uint64 RPC string Explorer string + CoincapName string CoingeckoName string OwlracleName string StringFee float64 UUID string - GasTokenID string + GasTokenId string } // TODO: should we store this in a DB or determine it dynamically??? Previously this was defined in the preprocessor in the Chain array @@ -23,18 +26,21 @@ func stringFee(chainId uint64) (float64, error) { return 0.03, nil } -func ChainInfo(chainId uint64, networkRepo repository.Network, assetRepo repository.Asset) (Chain, error) { - network, err := networkRepo.GetByChainId(chainId) +func ChainInfo(ctx context.Context, chainId uint64, networkRepo repository.Network, assetRepo repository.Asset) (Chain, error) { + _, finish := Span(ctx, "service.ChainInfo") + defer finish() + + network, err := networkRepo.GetByChainId(ctx, chainId) if err != nil { - return Chain{}, common.StringError(err) + return Chain{}, libcommon.StringError(err) } - asset, err := assetRepo.GetById(network.GasTokenID) + asset, err := assetRepo.GetById(ctx, network.GasTokenId) if err != nil { - return Chain{}, common.StringError(err) + return Chain{}, libcommon.StringError(err) } fee, err := stringFee(chainId) if err != nil { - return Chain{}, common.StringError(err) + return Chain{}, libcommon.StringError(err) } - return Chain{ChainID: chainId, RPC: network.RPCUrl, Explorer: network.ExplorerUrl, CoingeckoName: asset.ValueOracle.String, OwlracleName: network.GasOracle, StringFee: fee, UUID: network.ID, GasTokenID: network.GasTokenID}, nil + return Chain{ChainId: chainId, RPC: network.RPCUrl, Explorer: network.ExplorerUrl, CoingeckoName: asset.ValueOracle.String, CoincapName: asset.ValueOracle2.String, OwlracleName: network.GasOracle, StringFee: fee, UUID: network.Id, GasTokenId: network.GasTokenId}, nil } diff --git a/pkg/service/checkout.go b/pkg/service/checkout.go index da5fd7de..5462f678 100644 --- a/pkg/service/checkout.go +++ b/pkg/service/checkout.go @@ -3,167 +3,174 @@ package service import ( + "fmt" "math" - "os" + "strings" - "github.com/String-xyz/string-api/pkg/internal/common" - "github.com/checkout/checkout-sdk-go" - checkoutCommon "github.com/checkout/checkout-sdk-go/common" - "github.com/checkout/checkout-sdk-go/payments" - "github.com/checkout/checkout-sdk-go/tokens" -) - -func getConfig() (*checkout.Config, error) { - var sk = os.Getenv("CHECKOUT_SECRET_KEY") - var pk = os.Getenv("CHECKOUT_PUBLIC_KEY") - var env = os.Getenv("CHECKOUT_ENV") - checkoutEnv := checkout.Sandbox + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/cockroachdb/errors" + "github.com/rs/zerolog/log" - if env == "prod" { - checkoutEnv = checkout.Production - } + "github.com/String-xyz/string-api/config" + "github.com/String-xyz/string-api/pkg/internal/checkout" + "github.com/String-xyz/string-api/pkg/model" +) - var config, err = checkout.SdkConfig(&sk, &pk, checkoutEnv) - if err != nil { - return nil, common.StringError(err) - } - return config, err +type AuthorizedCharge struct { + PaymentId string + SourceId string + CheckoutFingerprint string + Last4 string + Issuer string + Approved bool + Status string + Summary string + CardType string + CardholderName string } func convertAmount(amount float64) uint64 { return uint64(math.Round(amount * 100)) } -func CreateToken(card *tokens.Card) (token *tokens.Response, err error) { - config, err := getConfig() - if err != nil { - return nil, common.StringError(err) - } - client := tokens.NewClient(*config) +// Create Customer from the internal use +func createCustomer(user model.UserWithContact, platformId string) (string, error) { + client := checkout.New() + name := fmt.Sprintf("%s %s %s", user.FirstName, user.MiddleName, user.LastName) + fullName := strings.Replace(name, " ", " ", 1) + + resp, err := client.Customer.Create(checkout.CustomerRequest{ + Email: user.Email, + Name: fullName, + Metadata: map[string]interface{}{ + "platformId": platformId, + "internalId": user.Id, + }, + }) - token, err = client.Request(&tokens.Request{Card: card}) if err != nil { - return token, common.StringError(err) + log.Error().Err(err).Msg("Error creating checkout customer from internal user") + return "", err } - return token, nil -} -type AuthorizedCharge struct { - AuthID string - CheckoutFingerprint string - Last4 string - Issuer string - Approved bool - Status string - Summary string - CardType string + return resp.Id, nil } func AuthorizeCharge(p transactionProcessingData) (transactionProcessingData, error) { - auth := AuthorizedCharge{} - config, err := getConfig() + usd := convertAmount(p.floatEstimate.TotalUSD) + source := sourceForRequest(p) + request := checkout.PaymentRequest{ + Amount: int64(usd), + Currency: "USD", + Capture: false, + PaymentIp: p.transactionModel.IPAddress, + } + + var err error + request.Customer, err = customerForRequest(p) if err != nil { - return p, common.StringError(err) + return p, err } - client := payments.NewClient(*config) - - var paymentTokenID string - if common.IsLocalEnv() { - // Generate a payment token ID in case we don't yet have one in the front end - // For testing purposes only - card := tokens.Card{ - Type: checkoutCommon.Card, - Number: "4242424242424242", // Success - // Number: "4273149019799094", // succeed authorize, fail capture - // Number: "4544249167673670", // Declined - Insufficient funds - // Number: "5148447461737269", // Invalid transaction (debit card) - ExpiryMonth: 2, - ExpiryYear: 2024, - Name: "Customer Name", - CVV: "100", - } - paymentToken, err := CreateToken(&card) - if err != nil { - return p, common.StringError(err) - } - paymentTokenID = paymentToken.Created.Token - if p.executionRequest.CardToken != "" { - paymentTokenID = p.executionRequest.CardToken - } - } else { - paymentTokenID = p.executionRequest.CardToken + + client := checkout.New() + resp, err := client.Payment.Authorize(source, request) + if err != nil { + return p, libcommon.StringError(err) } - usd := convertAmount(p.executionRequest.TotalUSD) + p.cardAuthorization, err = hydrateAuthorization(resp) - capture := false - request := &payments.Request{ - Source: payments.TokenSource{ - Type: checkoutCommon.Token.String(), - Token: paymentTokenID, - }, - Amount: usd, - Currency: "USD", - Customer: &payments.Customer{ - Name: p.executionRequest.UserAddress, - }, - Capture: &capture, + return p, err +} + +// CaptureCharge captures the payment for the given payment Id and sets the payment status to p +// If the payment is successful, the payment status will be "captured". +func CaptureCharge(p transactionProcessingData) (transactionProcessingData, error) { + usd := convertAmount(p.floatEstimate.TotalUSD) + client := checkout.New() + captResp, err := client.Payment.Capture(p.cardAuthorization.PaymentId, checkout.CaptureRequest{Amount: int64(usd)}) + if err != nil { + return p, libcommon.StringError(err) } - idempotencyKey := checkout.NewIdempotencyKey() - params := checkout.Params{ - IdempotencyKey: &idempotencyKey, + if captResp.HttpMetadata.StatusCode != 202 { + return p, libcommon.StringError(errors.Newf("capture failed with status code %d", captResp.HttpMetadata.StatusCode)) } - response, err := client.Request(request, ¶ms) + // Lets get the payment status to see if the payment was successful or not. + // If the payment was successful, the payment status will be "captured". + // If status is pending, we need to check the payment status again after a few seconds or use webhooks + // which is not implemented yet. + payResp, err := client.Payment.GetById(p.cardAuthorization.PaymentId) if err != nil { - return p, common.StringError(err) + return p, libcommon.StringError(err) } - // Collect authorization ID and Instrument ID - if response.Processed != nil { - auth.AuthID = response.Processed.ID - auth.Approved = *response.Processed.Approved - auth.Status = string(response.Processed.Status) - auth.Summary = response.Processed.ResponseSummary - auth.CardType = string(response.Processed.Source.CardType) - - if response.Processed.Source.CardSourceResponse != nil { - auth.Last4 = response.Processed.Source.CardSourceResponse.Last4 - auth.Issuer = response.Processed.Source.Issuer - auth.CheckoutFingerprint = response.Processed.Source.CardSourceResponse.Fingerprint - } + if payResp.HttpMetadata.StatusCode != 200 { + return p, libcommon.StringError(errors.Newf("get payment failed with status code %d", payResp.HttpMetadata.StatusCode)) } - p.cardAuthorization = &auth - // TODO: Create entry for authorization in our DB associated with userWallet + + p.PaymentStatus = payResp.Status + p.PaymentId = p.cardAuthorization.PaymentId + return p, nil } -func CaptureCharge(p transactionProcessingData) (transactionProcessingData, error) { - config, err := getConfig() - if err != nil { - return p, common.StringError(err) +// sourceForRequest creates a sourcce from the transaction processing transactionProcessingData +// this source is needed for the checkout payment request, keep in mind that as of now +// only 2 sources are allowed; Id and Token, where Id is an instrument. +// Becase the return type is an interface, check for null when calling this function +// given that a nil value is return it none of the 2 data options is available. +func sourceForRequest(p transactionProcessingData) checkout.Source { + paymentInfo := p.executionRequest.PaymentInfo + if paymentInfo.CardId != nil && *paymentInfo.CardId != "" { + return checkout.IdSource{ + BaseSource: checkout.BaseSource{Type: checkout.SourceTypeId}, + Id: *paymentInfo.CardId, + CVV: *paymentInfo.CVV, + } } - client := payments.NewClient(*config) - - usd := convertAmount(p.executionRequest.Quote.TotalUSD) - idempotencyKey := checkout.NewIdempotencyKey() - params := checkout.Params{ - IdempotencyKey: &idempotencyKey, + if paymentInfo.CardToken != nil && *paymentInfo.CardToken != "" { + return checkout.TokenSource{ + BaseSource: checkout.BaseSource{Type: checkout.SourceTypeToken}, + Token: *paymentInfo.CardToken, + StoreForFutureUse: paymentInfo.SaveCard, + } } - request := payments.CapturesRequest{ - Amount: usd, + + // for local development, we can use a test card token + if paymentInfo.CardToken != nil && *paymentInfo.CardToken == "" && config.Var.ENV == "local" { + return checkout.TokenSource{ + BaseSource: checkout.BaseSource{Type: checkout.SourceTypeToken}, + Token: checkout.DevCardToken(), + StoreForFutureUse: paymentInfo.SaveCard, + } } - capture, err := client.Captures(p.cardAuthorization.AuthID, &request, ¶ms) - if err != nil { - return p, common.StringError(err) + return nil +} + +func customerForRequest(p transactionProcessingData) (*checkout.Customer, error) { + if p.user.CheckoutId == "" { + return nil, libcommon.StringError(errors.New("user checkout id is empty")) } - p.cardCapture = capture + return &checkout.Customer{ + Id: p.user.CheckoutId, + }, nil +} - // TODO: call action, err = client.Actions(capture.Accepted.ActionID) in another service to check on +func hydrateAuthorization(resp *checkout.PaymentResponse) (*AuthorizedCharge, error) { + card := resp.Source.ResponseCardSource + if card == nil { + return nil, libcommon.StringError(errors.New("card source not found in response")) + } - // TODO: Create entry for capture in our DB associated with userWallet - return p, nil + return &AuthorizedCharge{ + PaymentId: resp.Id, + Approved: resp.Approved, + SourceId: card.Id, + Issuer: card.Issuer, + Last4: card.Last4, + }, nil } diff --git a/pkg/service/cost.go b/pkg/service/cost.go index be967b6a..9c3b8790 100644 --- a/pkg/service/cost.go +++ b/pkg/service/cost.go @@ -1,23 +1,32 @@ package service import ( + "context" + "database/sql" + "fmt" + "math" "math/big" - "os" + "strconv" "time" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + serror "github.com/String-xyz/go-lib/v2/stringerror" + "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/internal/common" "github.com/String-xyz/string-api/pkg/model" + "github.com/String-xyz/string-api/pkg/repository" "github.com/String-xyz/string-api/pkg/store" "github.com/pkg/errors" ) type EstimationParams struct { - ChainID uint64 `json:"chainID"` - CostETH big.Int `json:"costETH"` - UseBuffer bool `json:"useBuffer"` - GasUsedWei uint64 `json:"gasUsedWei"` - CostToken big.Int `json:"costToken"` - TokenName string `json:"tokenName"` + ChainId uint64 `json:"chainId"` + CostETH big.Int `json:"costETH"` + UseBuffer bool `json:"useBuffer"` + GasUsedWei uint64 `json:"gasUsedWei"` + CostTokens []big.Int `json:"costToken"` + TokenAddrs []string `json:"tokenName"` } type OwlracleJSON struct { @@ -35,72 +44,270 @@ type OwlracleJSON struct { } `json:"speeds"` } +type CoingeckoPlatform struct { + Id string `json:"id"` + ChainIdentifier uint64 `json:"chain_identifier"` + Name string `json:"name"` + ShortName string `json:"shortname"` +} + +type CoingeckoCoin struct { + ID string `json:"id"` + Symbol string `json:"symbol"` + Name string `json:"name"` + Platforms map[string]string `json:"platforms"` +} + +type CoinKey struct { + ChainId uint64 `json:"chainId"` + Address string `json:"address"` +} + +func (c CoinKey) String() string { + return fmt.Sprintf("%d:%s", c.ChainId, c.Address) +} + +type CoingeckoMapCache struct { + Timestamp int64 `json:"timestamp"` + Value map[string]string `json:"value"` +} + type CostCache struct { Timestamp int64 `json:"timestamp"` Value float64 `json:"value"` } type Cost interface { - EstimateTransaction(p EstimationParams, chain Chain) (model.Quote, error) - LookupUSD(coin string, quantity float64) (float64, error) + EstimateTransaction(p EstimationParams, chain Chain) (estimate model.Estimate[float64], err error) + LookupUSD(quantity float64, coins ...string) (float64, error) + AddCoinToAssetTable(id string, networkId string, address string) error } type cost struct { - redis store.RedisStore // cached token and gas costs + redis database.RedisStore // cached token and gas costs + repos repository.Repositories + subnetTokenProxies map[CoinKey]CoinKey } -func NewCost(redis store.RedisStore) Cost { +func NewCost(redis database.RedisStore, repos repository.Repositories) Cost { + // Temporarily hard-coding this to reduce future cost-of-change with database + subnetTokenProxies := map[CoinKey]CoinKey{ + // USDc DFK Subnet -> USDc Avalanche: + {ChainId: 53935, Address: "0x3AD9DFE640E1A9Cc1D9B0948620820D975c3803a"}: {ChainId: 43114, Address: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E"}, + // String USDc Fuji Testnet -> USDc Avalanche + {ChainId: 43113, Address: "0x671E35F91Cc497385f9f7d0dFCB7192848b1015b"}: {ChainId: 43114, Address: "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E"}, + } return &cost{ - redis: redis, + redis: redis, + repos: repos, + subnetTokenProxies: subnetTokenProxies, } } -func (c cost) EstimateTransaction(p EstimationParams, chain Chain) (model.Quote, error) { +// Get a huge list of data from coingecko to look up token names by address +func GetCoingeckoPlatformMapping() (map[uint64]string, map[string]uint64, error) { + // get list of platform names from coingecko to create mapping to chainid + var platforms []CoingeckoPlatform + err := common.GetJsonGeneric("https://api.coingecko.com/api/v3/asset_platforms", &platforms) + if err != nil { + return map[uint64]string{}, map[string]uint64{}, libcommon.StringError(err) + } + + idToPlatform := make(map[uint64]string) + platformToId := make(map[string]uint64) + for _, p := range platforms { + if p.ChainIdentifier != 0 { + idToPlatform[p.ChainIdentifier] = p.Id + platformToId[p.Id] = p.ChainIdentifier + } + } + return idToPlatform, platformToId, nil +} + +func GetCoingeckoCoinData(id string) (CoingeckoCoin, error) { + var coin CoingeckoCoin + err := common.GetJsonGeneric("https://api.coingecko.com/api/v3/coins/"+id+"?localization=false&tickers=false&market_data=false&community_data=false&developer_data=false&sparkline=false", &coin) + if err != nil { + return coin, libcommon.StringError(err) + } + return coin, nil +} + +func (c cost) AddCoinToAssetTable(id string, networkId string, address string) error { + coinData, err := GetCoingeckoCoinData(id) + if err != nil { + return libcommon.StringError(err) + } + _, err = c.repos.Asset.GetByKey(context.Background(), networkId, address) + if err != nil && err == serror.NOT_FOUND { + // add it to the asset table + _, err := c.repos.Asset.Create(context.Background(), model.Asset{ + Name: coinData.Symbol, // Name in our database is the Symbol + Description: coinData.Name, // Description in our database is the Name, note: coingecko provides an actual description + Decimals: 18, // TODO: Get this from coinData - it's listed per network. Decimals only affects display. + IsCrypto: true, + ValueOracle: sql.NullString{String: id, Valid: true}, + NetworkId: networkId, + Address: sql.NullString{String: address, Valid: true}, + // TODO: Get second oracle data using data from first oracle + }) + if err != nil { + return libcommon.StringError(err) + } + } else if err != nil { + return libcommon.StringError(err) + } + // Check if we are on a new chain + + return nil +} + +func GetCoingeckoCoinMapping() (map[string]string, error) { + _, platformToId, err := GetCoingeckoPlatformMapping() + if err != nil { + return map[string]string{}, libcommon.StringError(err) + } + + // get list of platform names from coingecko to create mapping to chainid + var coins []CoingeckoCoin + err = common.GetJsonGeneric("https://api.coingecko.com/api/v3/coins/list?include_platform=true", &coins) + if err != nil { + return map[string]string{}, libcommon.StringError(err) + } + + coinKeyToId := make(map[string]string) + for _, coin := range coins { + for key, val := range coin.Platforms { + // There's some weird data floating around in here. Ignore it. + if len(val) != 42 || val[:2] != "0x" { + continue + } + newKey := CoinKey{ + ChainId: platformToId[key], + Address: common.SanitizeChecksum(val), + }.String() + coinKeyToId[newKey] = coin.ID + } + } + + return coinKeyToId, nil +} + +// TODO: This logic is being reused, abstract it by templating and refactor +// i.e. LookupCache(cacheName string, rateLimit float, updateMethod func() (T, error)) (T, error) +func (c cost) LookupCoingeckoMapping() (map[string]string, error) { + cacheName := "coingecko_mapping" + cacheObject, err := store.GetObjectFromCache[CoingeckoMapCache](c.redis, cacheName) + if err != nil { + return map[string]string{}, libcommon.StringError(err) + } + if len(cacheObject.Value) == 0 || time.Now().Unix()-cacheObject.Timestamp > c.getExternalAPICallInterval(0.004, 1) { + updatedObject := CoingeckoMapCache{} + updatedObject.Timestamp = time.Now().Unix() + updatedObject.Value, err = GetCoingeckoCoinMapping() + // If update fails, return the old object + if err != nil { + return cacheObject.Value, libcommon.StringError(err) + } + err = store.PutObjectInCache(c.redis, cacheName, updatedObject) + // If store fails, return the new object anyway + if err != nil { + return updatedObject.Value, libcommon.StringError(err) + } + cacheObject = updatedObject + } + + // Return the old or new object + return cacheObject.Value, nil +} + +func (c cost) EstimateTransaction(p EstimationParams, chain Chain) (estimate model.Estimate[float64], err error) { // Get Unix Timestamp and chain info timestamp := time.Now().Unix() // Query cost of native token in USD - nativeCost, err := c.LookupUSD(chain.CoingeckoName, 1) + nativeCost, err := c.LookupUSD(1, chain.CoingeckoName, chain.CoincapName) if err != nil { - return model.Quote{}, common.StringError(err) + return estimate, libcommon.StringError(err) } // Use it to convert transactioncost and apply buffer if p.UseBuffer { - nativeCost *= 1.0 + common.NativeTokenBuffer(chain.ChainID) + nativeCost *= 1.0 + common.NativeTokenBuffer(chain.ChainId) } costEth := common.WeiToEther(&p.CostETH) // transactionCost is for native token transaction cost (tx_value) transactionCost := costEth * nativeCost - // Query owlracle for gas - ethGasFee, err := c.lookupGas(chain.OwlracleName) - if err != nil { - return model.Quote{}, common.StringError(err) + ethGasFee := 0.0 + if chain.OwlracleName == "internal" { + // Use the internal gas rate calculator + // Recommended gas rate with 10% boost and 10% tip respectively + gas, tip, err := GetGasRate(chain, 10, 10) + if err != nil { + return estimate, libcommon.StringError(err) + } + gasWithTip := big.NewInt(0).Add(gas, tip) + ethGasFee = float64(gasWithTip.Int64()) / 1e9 + } else { + // Query owlracle for gas + ethGasFee, err = c.lookupGas(chain.OwlracleName) + if err != nil { + return estimate, libcommon.StringError(err) + } } // Convert it from gwei to eth to USD and apply buffer gasInUSD := ethGasFee * float64(p.GasUsedWei) * nativeCost / float64(1e9) if p.UseBuffer { - gasInUSD *= 1.0 + common.GasBuffer(chain.ChainID) + gasInUSD *= 1.0 + common.GasBuffer(chain.ChainId) } - // Query cost of token in USD if used and apply buffer - costToken := common.WeiToEther(&p.CostToken) - // tokenCost in contract call ERC-20 token costs - // Also for buying tokens directly - tokenCost, err := c.LookupUSD(p.TokenName, costToken) - if err != nil { - return model.Quote{}, common.StringError(err) + // // Query cost of token in USD if used and apply buffer + totalTokenCost := 0.0 + coinMapping, err := c.LookupCoingeckoMapping() + // Coingecko is going down during testing. Comment this out if needed. + if err != nil && len(coinMapping) == 0 { + return estimate, libcommon.StringError(err) + } else if err != nil { + // TODO: Log error and continue + fmt.Printf("LookupCoingeckoMapping failed: %s", err) } - if p.UseBuffer { - tokenCost *= 1.0 + common.TokenBuffer(p.TokenName) + for i, costToken := range p.CostTokens { + // TODO: Get subnetTokenProxies from the database + coinKey := CoinKey{chain.ChainId, p.TokenAddrs[i]} + proxy := c.subnetTokenProxies[CoinKey{chain.ChainId, p.TokenAddrs[i]}] + if proxy.Address != "" { + coinKey = proxy + } + + costTokenEth := common.WeiToEther(&costToken) + + tokenName, ok := coinMapping[coinKey.String()] + if !ok { + return estimate, errors.New("CoinGecko does not list token " + p.TokenAddrs[i]) + } + + // Check if the token is in our database and add it if it's not in there + err = c.AddCoinToAssetTable(tokenName, chain.UUID, p.TokenAddrs[i]) + if err != nil { + return estimate, libcommon.StringError(err) + } + + tokenCost, err := c.LookupUSD(costTokenEth, tokenName) + if err != nil { + return estimate, libcommon.StringError(err) + } + if p.UseBuffer { + tokenCost *= 1.0 + common.TokenBuffer(tokenName) + } + totalTokenCost += tokenCost } // Compute service fee upcharge := chain.StringFee baseCheckoutFee := 0.3 - serviceFee := (transactionCost+gasInUSD+tokenCost)*upcharge + baseCheckoutFee + serviceFee := (transactionCost+gasInUSD+totalTokenCost)*upcharge + baseCheckoutFee // floor if transactionCost < 0.01 { @@ -110,41 +317,76 @@ func (c cost) EstimateTransaction(p EstimationParams, chain Chain) (model.Quote, gasInUSD = 0.01 } - totalUSD := transactionCost + gasInUSD + tokenCost + serviceFee + // Round up to nearest cent + transactionCost = centCeiling(transactionCost) + gasInUSD = centCeiling(gasInUSD) + totalTokenCost = centCeiling(totalTokenCost) + serviceFee = centCeiling(serviceFee) + + // sum total + totalUSD := transactionCost + gasInUSD + totalTokenCost + serviceFee + + // Round that up as well to account for any floating imprecision + totalUSD = centCeiling(totalUSD) // Fill out CostEstimate and return - return model.Quote{ + return model.Estimate[float64]{ Timestamp: timestamp, BaseUSD: transactionCost, GasUSD: gasInUSD, - TokenUSD: tokenCost, + TokenUSD: totalTokenCost, ServiceUSD: serviceFee, TotalUSD: totalUSD, }, nil } +func centCeiling(value float64) float64 { + return math.Ceil(value*100) / 100 +} + func (c cost) getExternalAPICallInterval(rateLimitPerMinute float64, uniqueEntries uint32) int64 { return int64(float64(60*rateLimitPerMinute) / rateLimitPerMinute) } -func (c cost) LookupUSD(coin string, quantity float64) (float64, error) { - cacheName := "usd_value_" + coin +// TODO: Take in an object which contains a list of backup oracle API names +func (c cost) LookupUSD(quantity float64, coins ...string) (float64, error) { + if len(coins) == 0 { + return 0.0, libcommon.StringError(errors.New("no coins provided")) + } + + cacheName := "usd_value_" + coins[0] cacheObject, err := store.GetObjectFromCache[CostCache](c.redis, cacheName) - if err != nil && errors.Cause(err).Error() != "redis: nil" { - return 0.0, common.StringError(err) + if err != nil && serror.Is(err, serror.NOT_FOUND) { + return 0.0, libcommon.StringError(err) } if cacheObject == (CostCache{}) || (err == nil && time.Now().Unix()-cacheObject.Timestamp > c.getExternalAPICallInterval(10, 6)) { - cacheObject.Timestamp = time.Now().Unix() - cacheObject.Value, err = c.coingeckoUSD(coin, 1) - if err != nil { - return 0, common.StringError(err) + // If coingecko is down, use coincap to get the price + var empty interface{} + err = common.GetJsonGeneric(config.Var.COINGECKO_API_URL+"ping", &empty) + if err == nil { + // Only update timestamp if we reacquire the value + cacheObject.Timestamp = time.Now().Unix() + cacheObject.Value, err = c.coingeckoUSD(coins[0]) + if err != nil { + return 0, libcommon.StringError(err) + } + + } else if len(coins) > 1 && coins[1] != "" { + // Only update the timestamp if we reacquire the value + cacheObject.Timestamp = time.Now().Unix() + cacheObject.Value, err = c.coincapUSD(coins[1]) + + if err != nil { + return 0, libcommon.StringError(err) + } } err = store.PutObjectInCache(c.redis, cacheName, cacheObject) if err != nil { - return 0, common.StringError(err) + return 0, libcommon.StringError(err) } } + // If both services are down, use the last value we had return cacheObject.Value * quantity, nil } @@ -152,29 +394,29 @@ func (c cost) lookupGas(network string) (float64, error) { cacheName := "gas_price_" + network cacheObject, err := store.GetObjectFromCache[CostCache](c.redis, cacheName) if err != nil { - return 0, common.StringError(err) + return 0, libcommon.StringError(err) } if cacheObject == (CostCache{}) || time.Now().Unix()-cacheObject.Timestamp > c.getExternalAPICallInterval(1.6, 6) { cacheObject.Timestamp = time.Now().Unix() cacheObject.Value, err = c.owlracle(network) if err != nil { - return 0, common.StringError(err) + return 0, libcommon.StringError(err) } err = store.PutObjectInCache(c.redis, cacheName, cacheObject) if err != nil { - return 0, common.StringError(err) + return 0, libcommon.StringError(err) } } return cacheObject.Value, nil } -func (c cost) coingeckoUSD(coin string, quantity float64) (float64, error) { - requestURL := os.Getenv("COINGECKO_API_URL") + "simple/price?ids=" + coin + "&vs_currencies=usd" +func (c cost) coingeckoUSD(coin string) (float64, error) { + requestURL := config.Var.COINGECKO_API_URL + "simple/price?ids=" + coin + "&vs_currencies=usd" var res map[string]interface{} err := common.GetJsonGeneric(requestURL, &res) if err != nil { - return 0, common.StringError(err) + return 0, libcommon.StringError(err) } prices, found := res[coin] if found { @@ -184,22 +426,41 @@ func (c cost) coingeckoUSD(coin string, quantity float64) (float64, error) { return usd.(float64), nil } } - // return 0, common.StringError(errors.New("Price not found for " + coin)) + // return 0, libcommon.StringError(errors.New("Price not found for " + coin)) // fmt.Printf("\n\nPRICE LOOKUP %+v", coin) // TODO: this is getting hit somewhere, figure out why return 0, nil } +func (c cost) coincapUSD(coin string) (float64, error) { + requestURL := config.Var.COINCAP_API_URL + "assets?search=" + coin + body := make(map[string]interface{}) + err := common.GetJsonGeneric(requestURL, &body) + if err != nil { + return 0, libcommon.StringError(err) + } + res, found := body["data"].([]interface{}) + if found && len(res) > 0 { + price, found := res[0].(map[string]interface{})["priceUsd"] + if found { + usd, _ := strconv.ParseFloat(price.(string), 64) + return usd, nil + } + } + + return 0, nil +} + func (c cost) owlracle(network string) (float64, error) { - requestURL := os.Getenv("OWLRACLE_API_URL") + + requestURL := config.Var.OWLRACLE_API_URL + network + "/gas?apikey=" + - os.Getenv("OWLRACLE_API_KEY") + + config.Var.OWLRACLE_API_KEY + "&accept=100" var res OwlracleJSON err := common.GetJsonGeneric(requestURL, &res) if err != nil { - return 0, common.StringError(err) + return 0, libcommon.StringError(err) } if len(res.Speeds) > 0 { return res.Speeds[0].MaxFeePerGas, nil diff --git a/pkg/service/cost_test.go b/pkg/service/cost_test.go new file mode 100644 index 00000000..46887b57 --- /dev/null +++ b/pkg/service/cost_test.go @@ -0,0 +1,22 @@ +package service + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetTokenPrices(t *testing.T) { + keyMap, err := GetCoingeckoCoinMapping() + assert.NoError(t, err) + name, ok := keyMap[CoinKey{ChainId: 43114, Address: "0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e"}.String()] + assert.True(t, ok) + assert.Equal(t, "usd-coin", name) +} + +func TestGetTokenData(t *testing.T) { + coin, err := GetCoingeckoCoinData("defi-kingdoms") + assert.NoError(t, err) + fmt.Printf("%+v", coin) +} diff --git a/pkg/service/device.go b/pkg/service/device.go index de5ca877..400fa52f 100644 --- a/pkg/service/device.go +++ b/pkg/service/device.go @@ -1,21 +1,26 @@ package service import ( - "os" + "context" "time" + libcommon "github.com/String-xyz/go-lib/v2/common" + serror "github.com/String-xyz/go-lib/v2/stringerror" + "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/internal/common" + "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/repository" + "github.com/lib/pq" - "github.com/pkg/errors" ) type Device interface { - VerifyDevice(encrypted string) error - CreateDeviceIfNeeded(userID, visitorID, requestID string) (model.Device, error) - CreateUnknownDevice(userID string) (model.Device, error) - InvalidateUnknownDevice(device model.Device) error + VerifyDevice(ctx context.Context, encrypted string) error + UpsertDeviceIP(ctx context.Context, deviceId string, Ip string) (err error) + InvalidateUnknownDevice(ctx context.Context, device model.Device) error + CreateDeviceIfNeeded(ctx context.Context, userId, visitorId, requestId string) (model.Device, error) + CreateUnknownDevice(ctx context.Context, userId string) (model.Device, error) } type device struct { @@ -27,34 +32,50 @@ func NewDevice(repos repository.Repositories, f Fingerprint) Device { return &device{repos, f} } -func (d device) createDevice(userID string, visitor model.FPVisitor, description string) (model.Device, error) { - return d.repos.Device.Create(model.Device{ - UserID: userID, - Fingerprint: visitor.VisitorID, - Type: visitor.Type, - IpAddresses: pq.StringArray{visitor.IPAddress}, - Description: description, - LastUsedAt: time.Now(), - }) +func (d device) VerifyDevice(ctx context.Context, encrypted string) error { + key := config.Var.STRING_ENCRYPTION_KEY + _, finish := Span(ctx, "service.device.VerifyDevice") + defer finish() + + received, err := libcommon.Decrypt[DeviceVerification](encrypted, key) + if err != nil { + return libcommon.StringError(err) + } + + now := time.Now() + if now.Unix()-received.Timestamp > (60 * 15) { + return libcommon.StringError(serror.EXPIRED) + } + err = d.repos.Device.Update(ctx, received.DeviceId, model.DeviceUpdates{ValidatedAt: &now}) + return err } -func (d device) CreateUnknownDevice(userID string) (model.Device, error) { - visitor := model.FPVisitor{ - VisitorID: "unknown", - Type: "unknown", - IPAddress: "unknown", - UserAgent: "unknown", +func (d device) UpsertDeviceIP(ctx context.Context, deviceId string, ip string) (err error) { + _, finish := Span(ctx, "service.device.UpsertDeviceIP") + defer finish() + + device, err := d.repos.Device.GetById(ctx, deviceId) + if err != nil { + return } - device, err := d.createDevice(userID, visitor, "an unknown device") - return device, common.StringError(err) + contains := common.SliceContains(device.IpAddresses, ip) + if !contains { + ipAddresses := append(device.IpAddresses, ip) + updates := &model.DeviceUpdates{IpAddresses: &ipAddresses} + err = d.repos.Device.Update(ctx, deviceId, updates) + if err != nil { + return + } + } + return } -func (d device) CreateDeviceIfNeeded(userID, visitorID, requestID string) (model.Device, error) { - if visitorID == "" || requestID == "" { +func (d device) CreateDeviceIfNeeded(ctx context.Context, userId, visitorId, requestId string) (model.Device, error) { + if visitorId == "" || requestId == "" { /* fingerprint is not available, create an unknown device. It should be invalidated on every login */ - device, err := d.getOrCreateUnknownDevice(userID, "unknown") + device, err := d.getOrCreateUnknownDevice(ctx, userId, "unknown") if err != nil { - return device, common.StringError(err) + return device, libcommon.StringError(err) } if !isDeviceValidated(device) { @@ -62,69 +83,83 @@ func (d device) CreateDeviceIfNeeded(userID, visitorID, requestID string) (model return device, nil } - return device, common.StringError(err) + return device, libcommon.StringError(err) } else { /* device recognized, create or get the device */ - device, err := d.repos.Device.GetByUserIdAndFingerprint(userID, visitorID) + device, err := d.repos.Device.GetByUserIdAndFingerprint(ctx, userId, visitorId) if err == nil { return device, err } /* create device only if the error is not found */ - if err == repository.ErrNotFound { - visitor, fpErr := d.fingerprint.GetVisitor(visitorID, requestID) + if serror.Is(err, serror.NOT_FOUND) { + visitor, fpErr := d.fingerprint.GetVisitor(visitorId, requestId) if fpErr != nil { - return model.Device{}, common.StringError(fpErr) + return model.Device{}, libcommon.StringError(fpErr) } - device, dErr := d.createDevice(userID, visitor, "a new device "+visitor.UserAgent+" ") + device, dErr := d.createDevice(ctx, userId, visitor, "a new device "+visitor.UserAgent+" ") return device, dErr } - return device, common.StringError(err) + return device, libcommon.StringError(err) } } -func (d device) VerifyDevice(encrypted string) error { - key := os.Getenv("STRING_ENCRYPTION_KEY") - received, err := common.Decrypt[DeviceVerification](encrypted, key) - if err != nil { - return common.StringError(err) +func (d device) CreateUnknownDevice(ctx context.Context, userId string) (model.Device, error) { + visitor := FPVisitor{ + VisitorId: "unknown", + Type: "unknown", + UserAgent: "unknown", } + device, err := d.createDevice(ctx, userId, visitor, "an unknown device") + return device, libcommon.StringError(err) +} - now := time.Now() - if now.Unix()-received.Timestamp > (60 * 15) { - return common.StringError(errors.New("link expired")) +func (d device) InvalidateUnknownDevice(ctx context.Context, device model.Device) error { + _, finish := Span(ctx, "service.device.InvalidateUnknownDevice") + defer finish() + + if device.Fingerprint != "unknown" { + return nil // only unknown devices can be invalidated } - err = d.repos.Device.Update(received.DeviceID, model.DeviceUpdates{ValidatedAt: &now}) - return err + + device.ValidatedAt = &time.Time{} // Zero time to set it to nil + return d.repos.Device.Update(ctx, device.Id, device) +} + +func (d device) createDevice(ctx context.Context, userId string, visitor FPVisitor, description string) (model.Device, error) { + addresses := pq.StringArray{} + if visitor.IPAddress.String != "" { + addresses = pq.StringArray{visitor.IPAddress.String} + } + + return d.repos.Device.Create(ctx, model.Device{ + UserId: userId, + Fingerprint: visitor.VisitorId, + Type: visitor.Type, + IpAddresses: addresses, + Description: description, + LastUsedAt: time.Now(), + }) } -func (d device) getOrCreateUnknownDevice(userId, visitorId string) (model.Device, error) { +func (d device) getOrCreateUnknownDevice(ctx context.Context, userId, visitorId string) (model.Device, error) { var device model.Device - device, err := d.repos.Device.GetByUserIdAndFingerprint(userId, "unknown") - if err != nil && err != repository.ErrNotFound { - return device, common.StringError(err) + device, err := d.repos.Device.GetByUserIdAndFingerprint(ctx, userId, "unknown") + if err != nil && !serror.Is(err, serror.NOT_FOUND) { + return device, libcommon.StringError(err) } - if device.ID != "" { + if device.Id != "" { return device, nil } // if device is not found, create a new one - device, err = d.CreateUnknownDevice(userId) - return device, common.StringError(err) + device, err = d.CreateUnknownDevice(ctx, userId) + return device, libcommon.StringError(err) } func isDeviceValidated(device model.Device) bool { return device.ValidatedAt != nil && !device.ValidatedAt.IsZero() } - -func (d device) InvalidateUnknownDevice(device model.Device) error { - if device.Fingerprint != "unknown" { - return nil // only unknown devices can be invalidated - } - - device.ValidatedAt = &time.Time{} // Zero time to set it to nil - return d.repos.Device.Update(device.ID, device) -} diff --git a/pkg/service/executor.go b/pkg/service/executor.go index cbc5ea6d..ec7b2047 100644 --- a/pkg/service/executor.go +++ b/pkg/service/executor.go @@ -3,19 +3,21 @@ package service import ( "context" "crypto/ecdsa" - "errors" "math" "math/big" - "os" + "strings" - stringCommon "github.com/String-xyz/string-api/pkg/internal/common" - "github.com/ethereum/go-ethereum/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/string-api/config" + "github.com/String-xyz/string-api/pkg/internal/common" + ethcommon "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" "github.com/lmittmann/w3" "github.com/lmittmann/w3/module/eth" "github.com/lmittmann/w3/w3types" + "github.com/pkg/errors" ) type ContractCall struct { @@ -34,13 +36,19 @@ type CallEstimate struct { } type Executor interface { - Initialize(RPC string) error - Initiate(call ContractCall) (string, *big.Int, error) - Estimate(call ContractCall) (CallEstimate, error) - TxWait(txID string) (uint64, error) + Initialize(network Chain) error + Initiate(calls []ContractCall) ([]string, *big.Int, error) + Estimate(calls []ContractCall) (CallEstimate, error) + TxWait(txIds []string) (uint64, error) Close() error GetByChainId() (uint64, error) GetBalance() (float64, error) + GetTokenIds(txIds []string) ([]string, []string, error) + GetTokenQuantities(txIds []string) ([]string, []*big.Int, error) + GetEventData(txIds []string, eventSignature string) ([]types.Log, error) + ForwardNonFungibleTokens(txIds []string, recipient string) ([]string, []string, error) + ForwardTokens(txIds []string, recipient string) ([]string, []string, []string, error) + GetGasRate(boostPercent int64, tipPercent int64) (*big.Int, *big.Int, error) } type executor struct { @@ -52,16 +60,17 @@ func NewExecutor() Executor { return &executor{} } -func (e *executor) Initialize(RPC string) error { +func (e *executor) Initialize(network Chain) error { + RPC := network.RPC var err error e.client, err = w3.Dial(RPC) if err != nil { - return stringCommon.StringError(err) + return libcommon.StringError(err) } // Do it again for our low-level client - e.geth, err = ethclient.Dial(RPC) + e.geth, err = ethclient.Dial(network.RPC) if err != nil { - return stringCommon.StringError(err) + return libcommon.StringError(err) } return nil } @@ -69,136 +78,238 @@ func (e *executor) Initialize(RPC string) error { func (e *executor) Close() error { err := e.client.Close() if err != nil { - return stringCommon.StringError(err) + return libcommon.StringError(err) } e.geth.Close() return nil } -func (e executor) Estimate(call ContractCall) (CallEstimate, error) { - // Get private key - skStr, err := stringCommon.DecryptBlobFromKMS(os.Getenv("EVM_PRIVATE_KEY")) - if err != nil { - return CallEstimate{}, stringCommon.StringError(err) +func (e executor) Estimate(calls []ContractCall) (CallEstimate, error) { + // Generate blockchain messages + w3calls := []w3types.Caller{} + estimatedGasses := make([]uint64, len(calls)) + totalValue := big.NewInt(0) + includesApprove := false + for i, call := range calls { + // TODO: Further optimize this to take in the calls array + msg, err := e.generateTransactionMessage(call, uint64(i)) + if err != nil { + return CallEstimate{}, libcommon.StringError(err) + } + totalValue = totalValue.Add(totalValue, msg.Value) + + w3calls = append(w3calls, eth.EstimateGas(&msg, nil).Returns(&estimatedGasses[i])) + + // simulation will not track the state of the blockchain after approval + if strings.Contains(strings.ToLower(strings.ReplaceAll(call.CxFunc, " ", "")), "approve") { + includesApprove = true + } } - sk, err := crypto.ToECDSA(common.FromHex(skStr)) - if err != nil { - return CallEstimate{}, stringCommon.StringError(err) + // Estimate gas of messages + err := e.client.Call(w3calls...) + _, ok := err.(w3.CallErrors) + if !includesApprove && (err != nil && ok) { + return CallEstimate{Value: *big.NewInt(0), Gas: 0, Success: false}, libcommon.StringError(err) } - // TODO: avoid panicking so that we get an intelligible error message - to := w3.A(call.CxAddr) - value := w3.I(call.TxValue) - // Get public key - publicKeyECDSA, ok := sk.Public().(*ecdsa.PublicKey) - if !ok { - return CallEstimate{}, stringCommon.StringError(errors.New("Estimate: Error casting public key to ECDSA")) + + // Call(w3calls) should fill out estimatedGasses array + totalGas := uint64(0) + for _, gas := range estimatedGasses { + totalGas += gas } - sender := crypto.PubkeyToAddress(*publicKeyECDSA) + return CallEstimate{Value: *totalValue, Gas: totalGas, Success: true}, nil +} - // Get ChainID from state - var chainId64 uint64 - err = e.client.Call(eth.ChainID().Returns(&chainId64)) - if err != nil { - return CallEstimate{}, stringCommon.StringError(err) +func (e executor) Initiate(calls []ContractCall) ([]string, *big.Int, error) { + w3calls := []w3types.Caller{} + hashes := make([]ethcommon.Hash, len(calls)) + totalValue := big.NewInt(0) + for i, call := range calls { + // TODO: Further optimize this to take in the calls array + tx, err := e.generateTransactionRequest(call, uint64(i)) + if err != nil { + return []string{}, nil, libcommon.StringError(err) + } + totalValue = totalValue.Add(totalValue, tx.Value()) + w3calls = append(w3calls, eth.SendTx(&tx).Returns(&hashes[i])) } - // Get sender nonce - var nonce uint64 - err = e.client.Call(eth.Nonce(sender, nil).Returns(&nonce)) - if err != nil { - return CallEstimate{}, stringCommon.StringError(err) + // Call txs and retrieve hashes + err := e.client.Call(w3calls...) + callErrs, ok := err.(w3.CallErrors) + if err != nil && ok { + catErrs := "" + for _, callErr := range callErrs { + if callErr != nil { + catErrs += callErr.Error() + " " + } + } + return []string{}, nil, libcommon.StringError(errors.New(catErrs)) } - // Get dynamic fee tx gas params - tipCap, _ := e.geth.SuggestGasTipCap(context.Background()) - feeCap, _ := e.geth.SuggestGasPrice(context.Background()) + hashStrings := make([]string, len(hashes)) + for i := range hashes { + hashStrings[i] = hashes[i].String() + } + return hashStrings, totalValue, nil +} - // Get handle to function we wish to call - funcEVM, err := w3.NewFunc(call.CxFunc, call.CxReturn) - if err != nil { - return CallEstimate{}, stringCommon.StringError(err) +func (e executor) TxWait(txIds []string) (uint64, error) { + totalGasUsed := uint64(0) + for _, txId := range txIds { + txHash := ethcommon.HexToHash(txId) + receipt := types.Receipt{} + pending := true + for pending { + pendingReceipt, err := e.geth.TransactionReceipt(context.Background(), txHash) + // TransactionReceipt returns error "not found" while tx is pending + if err != nil && err.Error() != "not found" { + return totalGasUsed, libcommon.StringError(err) + } + if pendingReceipt != nil { + receipt = *pendingReceipt + pending = false + } + // TODO: Sleep for a few ms to keep the cpu cooler + } + if receipt.Status == 0 { + return totalGasUsed, libcommon.StringError(errors.New("transaction failed")) + } + totalGasUsed += receipt.GasUsed } + return totalGasUsed, nil +} - // Encode function parameters - data, err := stringCommon.ParseEncoding(funcEVM, call.CxFunc, call.CxParams) +func (e executor) GetByChainId() (uint64, error) { + // Get ChainId from state + var chainId64 uint64 + err := e.client.Call(eth.ChainID().Returns(&chainId64)) if err != nil { - return CallEstimate{}, stringCommon.StringError(err) + return 0, libcommon.StringError(err) } + return chainId64, nil +} - // Generate blockchain message - msg := w3types.Message{ - From: sender, - To: &to, - GasFeeCap: feeCap, - GasTipCap: tipCap, - Value: value, - Input: data, +func (e executor) getAccount() (ethcommon.Address, error) { + // Get private key + sk, err := e.getSk() + if err != nil { + return ethcommon.Address{}, libcommon.StringError(err) } + // TODO: avoid panicking so that we get an intelligible error message + publicKeyECDSA, ok := sk.Public().(*ecdsa.PublicKey) + if !ok { + return ethcommon.Address{}, libcommon.StringError(errors.New("getAccount: Error casting public key to ECDSA")) + } + return ethcommon.HexToAddress(crypto.PubkeyToAddress(*publicKeyECDSA).String()), nil +} - // Estimate gas of message - var estimatedGas uint64 - err = e.client.Call(eth.EstimateGas(&msg, nil).Returns(&estimatedGas)) +func (e executor) getSk() (ecdsa.PrivateKey, error) { + // Get private key + skStr, err := common.DecryptBlobFromKMS(config.Var.EVM_PRIVATE_KEY) + if err != nil { + return ecdsa.PrivateKey{}, libcommon.StringError(err) + } + sk, err := crypto.ToECDSA(ethcommon.FromHex(skStr)) if err != nil { - // Execution Will Revert! - return CallEstimate{Value: *value, Gas: estimatedGas, Success: false}, stringCommon.StringError(err) + return ecdsa.PrivateKey{}, libcommon.StringError(err) } - return CallEstimate{Value: *value, Gas: estimatedGas, Success: true}, nil + return *sk, nil } -func (e executor) Initiate(call ContractCall) (string, *big.Int, error) { - // Get private key - skStr, err := stringCommon.DecryptBlobFromKMS(os.Getenv("EVM_PRIVATE_KEY")) +func (e executor) GetBalance() (float64, error) { + account, err := e.getAccount() if err != nil { - return "", nil, stringCommon.StringError(err) + return 0, libcommon.StringError(err) } - sk, err := crypto.ToECDSA(common.FromHex(skStr)) + + wei := big.Int{} + err = e.client.Call(eth.Balance(account, nil).Returns(&wei)) if err != nil { - return "", nil, stringCommon.StringError(err) + return 0, libcommon.StringError(err) } - // TODO: avoid panicking so that we get an intelligible error message - to := w3.A(call.CxAddr) - value := w3.I(call.TxValue) - // Get public key - publicKeyECDSA, ok := sk.Public().(*ecdsa.PublicKey) - if !ok { - return "", nil, stringCommon.StringError(errors.New("Estimate: Error casting public key to ECDSA")) + fwei := new(big.Float) + fwei.SetString(wei.String()) + balance := new(big.Float).Quo(fwei, big.NewFloat(math.Pow10(18))) + fbalance, _ := balance.Float64() + return fbalance, nil // We like thinking in floats +} + +func (e executor) generateTransactionMessage(call ContractCall, incrementNonce uint64) (w3types.Message, error) { + sender, err := e.getAccount() + if err != nil { + return w3types.Message{}, libcommon.StringError(err) } - sender := crypto.PubkeyToAddress(*publicKeyECDSA) - // Use provided gas limit - gasLimit := w3.I(call.TxGasLimit) + to := w3.A(call.CxAddr) + value := w3.I(call.TxValue) - // Get chainID from state + // Get ChainId from state var chainId64 uint64 err = e.client.Call(eth.ChainID().Returns(&chainId64)) if err != nil { - return "", nil, stringCommon.StringError(err) + return w3types.Message{}, libcommon.StringError(err) } // Get sender nonce var nonce uint64 err = e.client.Call(eth.Nonce(sender, nil).Returns(&nonce)) if err != nil { - return "", nil, stringCommon.StringError(err) + return w3types.Message{}, libcommon.StringError(err) } - // Get dynamic fee tx gas params - tipCap, _ := e.geth.SuggestGasTipCap(context.Background()) - feeCap, _ := e.geth.SuggestGasPrice(context.Background()) + // TODO: get boost factors from gas analysis engine + gas, tip, err := e.GetGasRate(10, 10) + if err != nil { + return w3types.Message{}, libcommon.StringError(err) + } // Get handle to function we wish to call funcEVM, err := w3.NewFunc(call.CxFunc, call.CxReturn) if err != nil { - return "", nil, stringCommon.StringError(err) + return w3types.Message{}, libcommon.StringError(err) } // Encode function parameters - data, err := stringCommon.ParseEncoding(funcEVM, call.CxFunc, call.CxParams) + data, err := common.ParseEncoding(funcEVM, call.CxFunc, call.CxParams) + if err != nil { + return w3types.Message{}, libcommon.StringError(err) + } + + // Generate blockchain message + return w3types.Message{ + From: sender, + To: &to, + GasFeeCap: gas, + GasTipCap: tip, + Value: value, + Input: data, + Nonce: nonce + incrementNonce, + }, nil +} + +func (e executor) generateTransactionRequest(call ContractCall, incrementNonce uint64) (types.Transaction, error) { + tx := types.Transaction{} + + msg, err := e.generateTransactionMessage(call, 0) + if err != nil { + return tx, libcommon.StringError(err) + } + + // Get chainId from state + var chainId64 uint64 + err = e.client.Call(eth.ChainID().Returns(&chainId64)) + if err != nil { + return tx, libcommon.StringError(err) + } + + // TODO: get boost factors from gas analysis engine + gas, tip, err := e.GetGasRate(10, 10) if err != nil { - return "", nil, stringCommon.StringError(err) + return tx, libcommon.StringError(err) } - // Type conversion for chainID + // Type conversion for chainId chainIdBig := new(big.Int).SetUint64(chainId64) // Get signer type, this is used to encode the tx @@ -207,79 +318,220 @@ func (e executor) Initiate(call ContractCall) (string, *big.Int, error) { // Generate blockchain tx dynamicFeeTx := types.DynamicFeeTx{ ChainID: chainIdBig, - Nonce: nonce, - GasTipCap: tipCap, - GasFeeCap: feeCap, - Gas: gasLimit.Uint64(), - To: &to, - Value: value, - Data: data, + Nonce: msg.Nonce + incrementNonce, + GasTipCap: tip, + GasFeeCap: gas, + Gas: w3.I(call.TxGasLimit).Uint64(), + To: msg.To, + Value: msg.Value, + Data: msg.Input, } - // Sign it - tx := types.MustSignNewTx(sk, signer, &dynamicFeeTx) - // Call tx and retrieve hash - var hash common.Hash - err = e.client.Call(eth.SendTx(tx).Returns(&hash)) + sk, err := e.getSk() if err != nil { - // Execution failed! - return "", nil, stringCommon.StringError(err) + return tx, libcommon.StringError(err) } - return hash.String(), value, nil + // Sign it + tx = *types.MustSignNewTx(&sk, signer, &dynamicFeeTx) + + return tx, nil } -func (e executor) TxWait(txID string) (uint64, error) { - txHash := common.HexToHash(txID) - receipt := types.Receipt{} - for receipt.Status == 0 { - pendingReceipt, err := e.geth.TransactionReceipt(context.Background(), txHash) - // TransactionReceipt returns error "not found" while tx is pending - if err != nil && err.Error() != "not found" { - return 0, stringCommon.StringError(err) +func (e executor) GetEventData(txIds []string, eventSignature string) ([]types.Log, error) { + events := []types.Log{} + for _, txId := range txIds { + receipt, err := e.geth.TransactionReceipt(context.Background(), ethcommon.HexToHash(txId)) + if err != nil { + return []types.Log{}, libcommon.StringError(err) } - if pendingReceipt != nil { - receipt = *pendingReceipt + + event := crypto.Keccak256Hash([]byte(eventSignature)) + + // Iterate through the logs to find the transfer event and extract the token ID. + for _, log := range receipt.Logs { + if log.Topics[0].Hex() == event.Hex() { + events = append(events, *log) + } } - // TODO: Sleep for a few ms to keep the cpu cooler } - return receipt.GasUsed, nil + return events, nil } -func (e executor) GetByChainId() (uint64, error) { - // Get ChainID from state - var chainId64 uint64 - err := e.client.Call(eth.ChainID().Returns(&chainId64)) +// This can be used to check if the recipient of an event such as transfer matches our hot wallet address +func FilterEventData(logs []types.Log, indexes []int, hexValues []string) []types.Log { + matches := []types.Log{} + for _, log := range logs { + for i, index := range indexes { + // Event Address is checksummed, but Event Topics are not + // compare RHS of topic with hexValues query + expected := hexValues[i] + if expected[:2] == "0x" { + expected = expected[2:] + } + RHS := log.Topics[index].Hex()[len(log.Topics[index].Hex())-len(expected):] + if strings.EqualFold(RHS, expected) { + matches = append(matches, log) + } + } + } + return matches +} + +func (e executor) GetTokenIds(txIds []string) ([]string, []string, error) { + logs, err := e.GetEventData(txIds, "Transfer(address,address,uint256)") if err != nil { - return 0, stringCommon.StringError(err) + return []string{}, []string{}, libcommon.StringError(err) } - return chainId64, nil + tokenIds := []string{} + addresses := []string{} + for _, log := range logs { + if len(log.Topics) != 4 { + continue + } + address := log.Address.String() + addresses = append(addresses, address) + tokenId := new(big.Int).SetBytes(log.Topics[3].Bytes()) + tokenIds = append(tokenIds, tokenId.String()) + } + return tokenIds, addresses, nil } -func (e executor) GetBalance() (float64, error) { - // Get private key - skStr, err := stringCommon.DecryptBlobFromKMS(os.Getenv("EVM_PRIVATE_KEY")) +func (e executor) GetTokenQuantities(txIds []string) ([]string, []*big.Int, error) { + logs, err := e.GetEventData(txIds, "Transfer(address,address,uint256)") if err != nil { - return 0, stringCommon.StringError(err) + return []string{}, []*big.Int{}, libcommon.StringError(err) } - sk, err := crypto.ToECDSA(common.FromHex(skStr)) + quantities := []*big.Int{} + addresses := []string{} + for _, log := range logs { + address := log.Address.String() + addresses = append(addresses, address) + quantity := new(big.Int).SetBytes(log.Data) + quantities = append(quantities, quantity) + } + return addresses, quantities, nil +} + +func (e executor) ForwardNonFungibleTokens(txIds []string, recipient string) ([]string, []string, error) { + eventData, err := e.GetEventData(txIds, "Transfer(address,address,uint256)") if err != nil { - return 0, stringCommon.StringError(err) + return []string{}, []string{}, libcommon.StringError(err) } - // Get public key - publicKeyECDSA, ok := sk.Public().(*ecdsa.PublicKey) - if !ok { - return 0, stringCommon.StringError(errors.New("Estimate: Error casting public key to ECDSA")) + hotWallet, err := e.getAccount() + if err != nil { + return []string{}, []string{}, libcommon.StringError(err) + } + // Filter events where recipient is our hot wallet + toForward := FilterEventData(eventData, []int{2}, []string{hotWallet.String()}) + tokenIds := []string{} + calls := []ContractCall{} + for _, log := range toForward { + tokenId := new(big.Int).SetBytes(log.Topics[3].Bytes()).String() + call := ContractCall{ + CxAddr: log.Address.String(), + CxFunc: "transferFrom(address,address,uint256)", + CxParams: []string{ + hotWallet.String(), + recipient, + tokenId, + }, + CxReturn: "", + TxValue: "0", + TxGasLimit: "800000", + } + tokenIds = append(tokenIds, tokenId) + calls = append(calls, call) } - account := crypto.PubkeyToAddress(*publicKeyECDSA) - wei := big.Int{} - err = e.client.Call(eth.Balance(account, nil).Returns(&wei)) + forwardTxIds := []string{} + if len(calls) > 0 { + forwardTxIds, _, err = e.Initiate(calls) + if err != nil { + return txIds, tokenIds, libcommon.StringError(err) + } + } + + return forwardTxIds, tokenIds, nil +} + +func (e executor) ForwardTokens(txIds []string, recipient string) ([]string, []string, []string, error) { + eventData, err := e.GetEventData(txIds, "Transfer(address,address,uint256)") if err != nil { - return 0, stringCommon.StringError(err) + return []string{}, []string{}, []string{}, libcommon.StringError(err) } - fwei := new(big.Float) - fwei.SetString(wei.String()) - balance := new(big.Float).Quo(fwei, big.NewFloat(math.Pow10(18))) - fbalance, _ := balance.Float64() - return fbalance, nil // We like thinking in floats + hotWallet, err := e.getAccount() + if err != nil { + return []string{}, []string{}, []string{}, libcommon.StringError(err) + } + // Filter events where recipient is our hot wallet + toForward := FilterEventData(eventData, []int{2}, []string{hotWallet.String()}) + tokens := []string{} + quantities := []string{} + calls := []ContractCall{} + for _, log := range toForward { + quantity := new(big.Int).SetBytes(log.Data).String() + token := log.Address.String() + call := ContractCall{ + CxAddr: token, + CxFunc: "transfer(address,uint256)", + CxParams: []string{ + recipient, + quantity, + }, + CxReturn: "", + TxValue: "0", + TxGasLimit: "800000", + } + tokens = append(tokens, token) + quantities = append(quantities, quantity) + calls = append(calls, call) + } + + forwardTxIds := []string{} + if len(calls) > 0 { + forwardTxIds, _, err = e.Initiate(calls) + if err != nil { + return txIds, tokens, quantities, libcommon.StringError(err) + } + } + + return forwardTxIds, tokens, quantities, nil +} + +func (e executor) GetGasRate(boostPercent int64, tipPercent int64) (*big.Int, *big.Int, error) { + gasPrice, err := e.geth.SuggestGasPrice(context.Background()) + if err != nil { + return nil, nil, libcommon.StringError(err) + } + + gasBoost := big.NewInt(gasPrice.Int64()) + gasBoost.Mul(gasBoost, big.NewInt(boostPercent)) + gasBoost.Div(gasBoost, big.NewInt(100)) + gasPrice.Add(gasBoost, gasPrice) + tip := big.NewInt(gasPrice.Int64()) + tip.Mul(tip, big.NewInt(tipPercent)) + tip.Div(tip, big.NewInt(100)) + + return gasPrice, tip, nil +} + +func GetGasRate(chain Chain, boostPercent int64, tipPercent int64) (*big.Int, *big.Int, error) { + geth, err := ethclient.Dial(chain.RPC) + if err != nil { + return nil, nil, libcommon.StringError(err) + } + gasPrice, err := geth.SuggestGasPrice(context.Background()) + if err != nil { + return nil, nil, libcommon.StringError(err) + } + + gasBoost := big.NewInt(gasPrice.Int64()) + gasBoost.Mul(gasBoost, big.NewInt(boostPercent)) + gasBoost.Div(gasBoost, big.NewInt(100)) + gasPrice.Add(gasBoost, gasPrice) + tip := big.NewInt(gasPrice.Int64()) + tip.Mul(tip, big.NewInt(tipPercent)) + tip.Div(tip, big.NewInt(100)) + + return gasPrice, tip, nil } diff --git a/pkg/service/executor_test.go b/pkg/service/executor_test.go new file mode 100644 index 00000000..610baac9 --- /dev/null +++ b/pkg/service/executor_test.go @@ -0,0 +1,63 @@ +package service + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func setupTest() (Executor, error) { + // Stub out the RPC + chain := Chain{ + ChainId: 43113, + RPC: "https://api.avax-test.network/ext/bc/C/rpc", + Explorer: "https://testnet.snowtrace.io", + CoincapName: "avalanche", + CoingeckoName: "avalanche-2", + OwlracleName: "avax", + StringFee: 0.03, + UUID: "N/A", + GasTokenId: "N/A", + } + // Dial the executor + executor := NewExecutor() + err := executor.Initialize(chain) + return executor, err +} + +func TestGetEventData(t *testing.T) { + e, err := setupTest() + assert.NoError(t, err) + + eventData, err := e.GetEventData([]string{"0xe27a6a4b4ee6cbd51242faf21044941de70f5ba65ea86673d7abde75eb6c2f56"}, + "Transfer(address,address,uint256)") + assert.NoError(t, err) + assert.Equal(t, "0x00000000000000000000000044a4b9e2a69d86ba382a511f845cbf2e31286770", eventData[0].Topics[2].Hex()) +} + +func TestGetTokensTransferred(t *testing.T) { + e, err := setupTest() + assert.NoError(t, err) + + tokens, err := e.GetTokenIds([]string{"0xe27a6a4b4ee6cbd51242faf21044941de70f5ba65ea86673d7abde75eb6c2f56"}) + assert.NoError(t, err) + + assert.Equal(t, []string{"167"}, tokens) +} + +func TestFilterEventData(t *testing.T) { + e, err := setupTest() + assert.NoError(t, err) + + eventData, err := e.GetEventData([]string{"0xe27a6a4b4ee6cbd51242faf21044941de70f5ba65ea86673d7abde75eb6c2f56"}, + "Transfer(address,address,uint256)") + assert.NoError(t, err) + + filteredEvents := FilterEventData(eventData, []int{2}, []string{"44a4b9e2a69d86ba382a511f845cbf2e31286770"}) + + assert.Equal(t, "0x00000000000000000000000044a4b9e2a69d86ba382a511f845cbf2e31286770", filteredEvents[0].Topics[2].Hex()) + + filteredEvents = FilterEventData(eventData, []int{1}, []string{"44a4b9e2a69d86ba382a511f845cbf2e31286770"}) + + assert.Equal(t, 0, len(filteredEvents)) +} diff --git a/pkg/service/fingerprint.go b/pkg/service/fingerprint.go index 6b90fb59..af929818 100644 --- a/pkg/service/fingerprint.go +++ b/pkg/service/fingerprint.go @@ -1,16 +1,26 @@ package service import ( + "database/sql" "errors" + libcommon "github.com/String-xyz/go-lib/v2/common" "github.com/String-xyz/string-api/pkg/internal/common" - "github.com/String-xyz/string-api/pkg/model" ) type FPClient common.FingerprintClient type HTTPConfig common.HTTPConfig type HTTPClient common.HTTPClient -type FPVisitor = model.FPVisitor +type FPVisitor struct { + VisitorId string + Country string + State string + IPAddress sql.NullString + Timestamp int64 + Confidence float64 + Type string + UserAgent string +} func NewHTTPClient(config HTTPConfig) HTTPClient { return common.NewHTTPClient(common.HTTPConfig(config)) @@ -22,7 +32,7 @@ func NewFingerprintClient(client HTTPClient) FPClient { type Fingerprint interface { //GetVisitor fetches the visitor data by id, it does not validate if the device is the database - GetVisitor(ID string, request string) (FPVisitor, error) + GetVisitor(id string, request string) (FPVisitor, error) } type fingerprint struct { @@ -33,10 +43,10 @@ func NewFingerprint(client FPClient) Fingerprint { return &fingerprint{client} } -func (f fingerprint) GetVisitor(ID, requestID string) (FPVisitor, error) { - visitor, err := f.client.GetVisitorByID(ID, common.FPVisitorOpts{Limit: 1, RequestID: requestID}) +func (f fingerprint) GetVisitor(id, requestId string) (FPVisitor, error) { + visitor, err := f.client.GetVisitorById(id, common.FPVisitorOpts{Limit: 1, RequestId: requestId}) if err != nil { - return FPVisitor{}, common.StringError(err) + return FPVisitor{}, libcommon.StringError(err) } return f.hydrateVisitor(visitor) } @@ -46,7 +56,7 @@ func (f fingerprint) hydrateVisitor(visitor common.FPVisitor) (FPVisitor, error) // of the user, if we at some point want to return all the visit, we will need to create a different // hydration method. if len(visitor.Visits) == 0 || len(visitor.Visits) > 1 { - return FPVisitor{}, common.StringError(errors.New("visitor history does not match")) + return FPVisitor{}, libcommon.StringError(errors.New("visitor history does not match")) } var state string @@ -56,10 +66,10 @@ func (f fingerprint) hydrateVisitor(visitor common.FPVisitor) (FPVisitor, error) } return FPVisitor{ - VisitorID: visitor.ID, + VisitorId: visitor.Id, Country: visit.IPLocation.Coutry.Code, State: state, - IPAddress: visit.IP, + IPAddress: sql.NullString{String: visit.IP}, Timestamp: visit.Timestamp, Confidence: visit.IPLocation.Confidence.Score, Type: visit.BrowserDetails.Device, diff --git a/pkg/service/gas_test.go b/pkg/service/gas_test.go new file mode 100644 index 00000000..f4ae7305 --- /dev/null +++ b/pkg/service/gas_test.go @@ -0,0 +1,17 @@ +package service + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetGasPrices(t *testing.T) { + gas, tip, err := GetGasRate(Chain{RPC: "https://api.avax.network/ext/bc/C/rpc"}, 10, 10) + assert.NoError(t, err) + assert.Greater(t, gas.Int64(), int64(0)) + assert.Greater(t, tip.Int64(), int64(0)) + fmt.Printf("gas: %d, tip: %d", gas.Int64(), tip.Int64()) + +} diff --git a/pkg/service/geofencing.go b/pkg/service/geofencing.go index d5b8f7b0..ef4e280c 100644 --- a/pkg/service/geofencing.go +++ b/pkg/service/geofencing.go @@ -2,12 +2,11 @@ package service import ( "encoding/json" - "io/ioutil" + "io" "net/http" - "os" - "github.com/String-xyz/string-api/pkg/internal/common" - "github.com/String-xyz/string-api/pkg/store" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" "github.com/pkg/errors" ) @@ -20,10 +19,10 @@ type Geofencing interface { } type geofencing struct { - redis store.RedisStore + redis database.RedisStore } -func NewGeofencing(redis store.RedisStore) Geofencing { +func NewGeofencing(redis database.RedisStore) Geofencing { return &geofencing{redis} } @@ -41,12 +40,12 @@ func (g geofencing) IsAllowed(ip string) (bool, error) { // if err != nil { // location, err = getLocationFromAPI(ip) // if err != nil { - // return false, common.StringError(err) + // return false, libcommon.StringError(err) // } // err = g.setLocation(ip, location) // if err != nil { - // return false, common.StringError(err) + // return false, libcommon.StringError(err) // } // } @@ -57,12 +56,12 @@ func (g geofencing) IsAllowed(ip string) (bool, error) { func (c geofencing) setLocation(ip string, location GeoLocation) error { locationStr, err := json.Marshal(location) if err != nil { - return common.StringError(err) + return libcommon.StringError(err) } err = c.redis.Set("location-ip"+ip, locationStr, A_DAY_IN_NANOSEC) if err != nil { - return common.StringError(err) + return libcommon.StringError(err) } return nil } @@ -70,35 +69,35 @@ func (c geofencing) setLocation(ip string, location GeoLocation) error { func (g geofencing) getLocation(ip string) (GeoLocation, error) { cachedData, err := g.redis.Get("location-ip" + ip) if err != nil { - return GeoLocation{}, common.StringError(err) + return GeoLocation{}, libcommon.StringError(err) } location := GeoLocation{} if cachedData == nil { - return location, common.StringError(err) + return location, libcommon.StringError(err) } err = json.Unmarshal(cachedData, &location) if err != nil { - return location, common.StringError(err) + return location, libcommon.StringError(err) } return location, nil } func getLocationFromAPI(ip string) (GeoLocation, error) { - url := "http://api.ipstack.com/" + ip + "?access_key=" + os.Getenv("IPSTACK_API_KEY") + url := "http://api.ipstack.com/" + ip + "?access_key=" res, err := http.Get(url) if err != nil { - return GeoLocation{}, common.StringError(err) + return GeoLocation{}, libcommon.StringError(err) } // read the response body - body, err := ioutil.ReadAll(res.Body) + body, err := io.ReadAll(res.Body) if err != nil { - return GeoLocation{}, common.StringError(err) + return GeoLocation{}, libcommon.StringError(err) } dataObj := GeoLocation{} @@ -106,11 +105,11 @@ func getLocationFromAPI(ip string) (GeoLocation, error) { // unmarshal the json into our struct err = json.Unmarshal(body, &dataObj) if err != nil { - return GeoLocation{}, common.StringError(err) + return GeoLocation{}, libcommon.StringError(err) } if dataObj.Ip != ip || dataObj.CountryCode == "" || dataObj.RegionCode == "" { - return GeoLocation{}, common.StringError(errors.New("The Data returned by the external location service is invalid")) + return GeoLocation{}, libcommon.StringError(errors.New("The Data returned by the external location service is invalid")) } return dataObj, nil diff --git a/pkg/service/kyc.go b/pkg/service/kyc.go new file mode 100644 index 00000000..6e953971 --- /dev/null +++ b/pkg/service/kyc.go @@ -0,0 +1,110 @@ +package service + +import ( + "context" + + "github.com/String-xyz/string-api/pkg/model" + "github.com/String-xyz/string-api/pkg/repository" +) + +type KYC interface { + MeetsRequirements(ctx context.Context, userId string, assetType string, cost float64) (met bool, level KYCLevel, err error) + GetTransactionLevel(assetType string, cost float64) KYCLevel + GetUserLevel(ctx context.Context, userId string) (level KYCLevel, err error) + UpdateUserLevel(ctx context.Context, userId string) (level KYCLevel, err error) +} + +type KYCLevel int + +const ( + Level0 KYCLevel = iota + Level1 + Level2 + Level3 +) + +type kyc struct { + repos repository.Repositories +} + +func NewKYC(repos repository.Repositories) KYC { + return &kyc{repos} +} + +func (k kyc) MeetsRequirements(ctx context.Context, userId string, assetType string, cost float64) (met bool, level KYCLevel, err error) { + transactionLevel := k.GetTransactionLevel(assetType, cost) + + userLevel, err := k.GetUserLevel(ctx, userId) + if err != nil { + return false, transactionLevel, err + } + if userLevel >= transactionLevel { + return true, transactionLevel, nil + } +return false, transactionLevel, nil +} + +func (k kyc) GetTransactionLevel(assetType string, cost float64) KYCLevel { + if assetType == "NFT" { + if cost < 1000.00 { + return Level1 + } else if cost < 5000.00 { + return Level2 + } else { + return Level3 + } + } else { + if cost < 5000.00 { + return Level2 + } else { + return Level3 + } + } +} + +func (k kyc) GetUserLevel(ctx context.Context, userId string) (level KYCLevel, err error) { + level, err = k.UpdateUserLevel(ctx, userId) + if err != nil { + return level, err + } + + return level, nil +} + +func (k kyc) UpdateUserLevel(ctx context.Context, userId string) (level KYCLevel, err error) { + identity, err := k.repos.Identity.GetByUserId(ctx, userId) + if err != nil { + return level, err + } + + points := 0 + if identity.EmailVerified != nil { + points++ + } + if identity.PhoneVerified != nil { + points++ + } + if identity.DocumentVerified != nil { + points++ + } + if identity.SelfieVerified != nil { + points++ + } + + if points >= 4 { + if identity.Level != int(Level2) { + identity.Level = int(Level2) + k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level}) + } + } else if points >= 1 && identity.EmailVerified != nil { + if identity.Level != int(Level1) { + identity.Level = int(Level1) + k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level}) + } + } else if points <= 1 && identity.Level != int(Level0) { + identity.Level = int(Level0) + k.repos.Identity.Update(ctx, userId, model.IdentityUpdates{Level: &identity.Level}) + } + + return KYCLevel(identity.Level), nil +} diff --git a/pkg/service/kyc_test.go b/pkg/service/kyc_test.go new file mode 100644 index 00000000..0c4f712c --- /dev/null +++ b/pkg/service/kyc_test.go @@ -0,0 +1,138 @@ +package service + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + env "github.com/String-xyz/go-lib/v2/config" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/String-xyz/string-api/config" + "github.com/String-xyz/string-api/pkg/model" + "github.com/String-xyz/string-api/pkg/repository" + "github.com/stretchr/testify/assert" +) + +// define a test case struct +type kycCase struct { + CaseName string + User model.User + Identity model.Identity + AssetType string + Cost float64 + Met bool +} + +func getTestCases() (cases []kycCase) { + assetTypes := []string{"NFT", "TOKEN", "NFT_AND_TOKEN"} + assetCosts := []float64{0, 999.99, 1000.00, 4999.99, 5000.00, 100000.00} + kycLevels := []int{0, 1, 2, 3} + now := time.Now() + verifications := [][]*time.Time{ + {nil, nil, nil, nil}, + {&now, nil, nil, nil}, + {&now, &now, nil, nil}, + {&now, &now, &now, nil}, + {&now, &now, &now, &now}, + } + baseUser := model.User{ + Id: uuid.NewString(), + CreatedAt: now, + UpdatedAt: now, + Type: "User", + Status: "Onboarded", + Tags: nil, + FirstName: "Test", + MiddleName: "A", + LastName: "User", + Email: "FakeUser123@nomail.com", + } + + for _, assetType := range assetTypes { + for _, assetCost := range assetCosts { + for _, kycLevel := range kycLevels { + for i, verification := range verifications { + trueLevel := 0 + if i == 4 { + trueLevel = 2 + } else if i >= 1 { + trueLevel = 1 + } + met := (assetType == "NFT" && assetCost < 1000.00 && trueLevel >= 1) || (assetCost < 5000.00 && trueLevel >= 2) + testCase := kycCase{ + CaseName: fmt.Sprintf("AssetType: %s, AssetCost: %f, KYCLevel: %d, TrueLevel: %v", assetType, assetCost, kycLevel, trueLevel), + User: baseUser, + Identity: model.Identity{ + Id: uuid.NewString(), + Level: kycLevel, + AccountId: "", + UserId: "", + CreatedAt: now, + UpdatedAt: now, + DeletedAt: nil, + EmailVerified: verification[0], + PhoneVerified: verification[1], + SelfieVerified: verification[2], + DocumentVerified: verification[3], + }, + AssetType: assetType, + Cost: assetCost, + Met: met, + } + cases = append(cases, testCase) + } + } + } + } + + return cases +} + +func setup(t *testing.T) (kyc KYC, ctx context.Context, mock sqlmock.Sqlmock, db *sql.DB) { + env.LoadEnv(&config.Var, "../../.env") + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + sqlxDB := sqlx.NewDb(db, "sqlmock") + if err != nil { + t.Fatalf("error %s was not expected when opening stub db", err) + } + repos := repository.Repositories{ + Auth: repository.NewAuth(nil, sqlxDB), + Apikey: repository.NewApikey(sqlxDB), + User: repository.NewUser(sqlxDB), + Contact: repository.NewContact(sqlxDB), + Contract: repository.NewContract(sqlxDB), + Instrument: repository.NewInstrument(sqlxDB), + Device: repository.NewDevice(sqlxDB), + Asset: repository.NewAsset(sqlxDB), + Network: repository.NewNetwork(sqlxDB), + Transaction: repository.NewTransaction(sqlxDB), + TxLeg: repository.NewTxLeg(sqlxDB), + Location: repository.NewLocation(sqlxDB), + Platform: repository.NewPlatform(sqlxDB), + Identity: repository.NewIdentity(sqlxDB), + } + kyc = NewKYC(repos) + ctx = context.Background() + return kyc, ctx, mock, db +} + +func TestMeetsRequirements(t *testing.T) { + kyc, ctx, mock, db := setup(t) + defer db.Close() + testCases := getTestCases() + for _, tc := range testCases { + mockedIdentityRow := sqlmock.NewRows([]string{"id", "level", "account_id", "user_id", "email_verified", "phone_verified", "selfie_verified", "document_verified"}). + AddRow(tc.Identity.Id, tc.Identity.Level, tc.Identity.AccountId, tc.Identity.UserId, tc.Identity.EmailVerified, tc.Identity.PhoneVerified, tc.Identity.SelfieVerified, tc.Identity.DocumentVerified) + mock.ExpectQuery("SELECT * FROM identity WHERE user_id=$1").WithArgs(tc.User.Id).WillReturnRows(mockedIdentityRow) + + fmt.Printf("Running test for: %v\n", tc.CaseName) + met, err := kyc.MeetsRequirements(ctx, tc.User.Id, tc.AssetType, tc.Cost) + assert.NoError(t, err) + assert.Equal(t, tc.Met, met) + } +} diff --git a/pkg/service/persona_webhook.go b/pkg/service/persona_webhook.go new file mode 100644 index 00000000..4b13e116 --- /dev/null +++ b/pkg/service/persona_webhook.go @@ -0,0 +1,67 @@ +package service + +import ( + "context" + "encoding/json" + + "github.com/String-xyz/go-lib/v2/common" + "github.com/cockroachdb/errors" + "github.com/rs/zerolog/log" + + "github.com/String-xyz/string-api/pkg/internal/persona" +) + +type personaWebhook struct{} + +func (p personaWebhook) Handle(ctx context.Context, data []byte) error { + event := persona.Event{} + err := json.Unmarshal(data, &event) + if err != nil { + return common.StringError(errors.Newf("error unmarshalling webhook event: %v", err)) + } + return p.processEvent(ctx, event) +} + +func (p personaWebhook) processEvent(ctx context.Context, event persona.Event) error { + payload, err := event.Attributes.GetPayloadData() + if err != nil { + return common.StringError(errors.Newf("error getting payload data: %v", err)) + } + switch event.Attributes.Name { + case persona.EventTypeAccountCreated: + return p.account(ctx, payload, event.Attributes.Name) + case persona.EventTypeInquiryCreated, persona.EventTypeInquiryStarted, persona.EventTypeInquiryCompleted: + return p.inquiry(ctx, payload, event.Attributes.Name) + case persona.EventTypeVerificationCreated, persona.EventTypeVerificationPassed, persona.EventTypeVerificationFailed: + return p.verification(ctx, payload, event.Attributes.Name) + default: + return common.StringError(errors.Newf("unknown event type: %s", event.Attributes.Name)) + } +} + +func (p personaWebhook) account(ctx context.Context, payload persona.PayloadData, eventType persona.EventType) error { + account, ok := payload.(persona.Account) + if !ok { + return common.StringError(errors.New("error casting payload to account")) + } + log.Info().Interface("account", account).Msg("account event") + return nil +} + +func (p personaWebhook) inquiry(ctx context.Context, payload persona.PayloadData, eventType persona.EventType) error { + inquiry, ok := payload.(persona.Inquiry) + if !ok { + return common.StringError(errors.New("error casting payload to inquiry")) + } + log.Info().Interface("inquiry", inquiry).Msg("inquiry event") + return nil +} + +func (p personaWebhook) verification(ctx context.Context, payload persona.PayloadData, eventType persona.EventType) error { + verification, ok := payload.(persona.Verification) + if !ok { + return common.StringError(errors.New("error casting payload to verification")) + } + log.Info().Interface("verification", verification).Msg("verification event") + return nil +} diff --git a/pkg/service/persona_webhook_test.go b/pkg/service/persona_webhook_test.go new file mode 100644 index 00000000..513a9537 --- /dev/null +++ b/pkg/service/persona_webhook_test.go @@ -0,0 +1,40 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/String-xyz/string-api/pkg/test/data" +) + +func TestHandle(t *testing.T) { + webhook := personaWebhook{} + + tests := []struct { + name string + json string + err error + }{ + { + name: "Test Account event", + json: data.PersonAccountJSON, + err: nil, + }, + { + name: "Test Inquiry event", + json: data.PersonInquiryJSON, + err: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + err := webhook.Handle(context.Background(), []byte(tt.json)) + + assert.Equal(t, tt.err, err) + }) + } +} diff --git a/pkg/service/platform.go b/pkg/service/platform.go deleted file mode 100644 index aae5753f..00000000 --- a/pkg/service/platform.go +++ /dev/null @@ -1,40 +0,0 @@ -package service - -import ( - "github.com/String-xyz/string-api/pkg/internal/common" - "github.com/String-xyz/string-api/pkg/model" - "github.com/String-xyz/string-api/pkg/repository" -) - -type CreatePlatform = model.CreatePlatform - -type Platform interface { - Create(CreatePlatform) (model.Platform, error) -} - -type platform struct { - repos repository.Repositories -} - -func NewPlatform(repos repository.Repositories) Platform { - return &platform{repos} -} - -func (a platform) Create(c CreatePlatform) (model.Platform, error) { - uuiKey := "str." + uuidWithoutHyphens() - hashed := common.ToSha256(uuiKey) - m := model.Platform{} - - plat, err := a.repos.Platform.Create(m) - if err != nil { - return model.Platform{}, common.StringError(err) - } - - _, err = a.repos.Auth.CreateAPIKey(plat.ID, c.Authentication, hashed, false) - pt := &plat - if err != nil { - return *pt, common.StringError(err) - } - - return plat, nil -} diff --git a/pkg/service/quote_cache.go b/pkg/service/quote_cache.go new file mode 100644 index 00000000..7931b660 --- /dev/null +++ b/pkg/service/quote_cache.go @@ -0,0 +1,127 @@ +package service + +import ( + "crypto/sha1" + "encoding/hex" + "encoding/json" + "math/big" + "time" + + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + "github.com/String-xyz/string-api/pkg/model" + "github.com/String-xyz/string-api/pkg/store" + "github.com/pkg/errors" +) + +type QuoteCache interface { + CheckUpdateCachedTransactionRequest(request model.TransactionRequest, desiredInterval int64) (recalculate bool, callEstimate CallEstimate, err error) + PutCachedTransactionRequest(request model.TransactionRequest, data CallEstimate) (CallEstimate, error) + UpdateMaxCachedTrueGas(request model.TransactionRequest, gas uint64) error +} + +type quoteCache struct { + redis database.RedisStore +} + +func NewQuoteCache(redis database.RedisStore) QuoteCache { + return "eCache{redis} +} + +type callEstimateCache struct { + Timestamp int64 `json:"timestamp"` + Value string `json:"value" db:"value"` + Gas uint64 `json:"gas" db:"gas"` + Success bool `json:"success" db:"success"` +} + +func (q quoteCache) CheckUpdateCachedTransactionRequest(request model.TransactionRequest, desiredInterval int64) (recalculate bool, callEstimate CallEstimate, err error) { + cacheObject, err := store.GetObjectFromCache[callEstimateCache](q.redis, tokenizeTransactionRequest(sanitizeTransactionRequest(request))) + if cacheObject.Timestamp == 0 || (err == nil && time.Now().Unix()-cacheObject.Timestamp > desiredInterval) { + return true, CallEstimate{}, nil + } else if err != nil { + return false, CallEstimate{}, libcommon.StringError(err) + } else { + value := new(big.Int) + value, ok := value.SetString(cacheObject.Value, 10) + if !ok { + return false, CallEstimate{}, libcommon.StringError(errors.New("Failed to parse value from cache")) + } + return false, CallEstimate{Value: *value, Gas: cacheObject.Gas, Success: cacheObject.Success}, nil + } +} + +func (q quoteCache) UpdateMaxCachedTrueGas(request model.TransactionRequest, gas uint64) error { + key := tokenizeTransactionRequest(sanitizeTransactionRequest(request)) + cacheObject, err := store.GetObjectFromCache[callEstimateCache](q.redis, key) + if cacheObject.Timestamp == 0 || (err == nil && cacheObject.Gas < gas) { + cacheObject.Gas = gas + cacheObject.Timestamp = time.Now().Unix() + err = store.PutObjectInCache(q.redis, key, cacheObject) + } + if err != nil { + return libcommon.StringError(err) + } + return nil +} + +func (q quoteCache) PutCachedTransactionRequest(request model.TransactionRequest, data CallEstimate) (CallEstimate, error) { + key := tokenizeTransactionRequest(sanitizeTransactionRequest(request)) + cacheObject, err := store.GetObjectFromCache[callEstimateCache](q.redis, key) + if err != nil { + return CallEstimate{}, libcommon.StringError(err) + } + // Never lower the known gas value - this can come from estimation or a real transaction + if data.Gas < cacheObject.Gas { + data.Gas = cacheObject.Gas + } + cacheObject = callEstimateCache{ + Timestamp: time.Now().Unix(), + Value: data.Value.String(), + Gas: data.Gas, + Success: data.Success, + } + err = store.PutObjectInCache(q.redis, tokenizeTransactionRequest(sanitizeTransactionRequest(request)), cacheObject) + if err != nil { + return CallEstimate{}, libcommon.StringError(err) + } + value := big.NewInt(0) + value.SetString(cacheObject.Value, 10) + return CallEstimate{Value: *value, Gas: cacheObject.Gas, Success: cacheObject.Success}, nil +} + +func sanitizeTransactionRequest(request model.TransactionRequest) model.TransactionRequest { + actions := []model.TransactionAction{} + for i := range request.Actions { + actions = append(actions, model.TransactionAction{ + CxAddr: request.Actions[i].CxAddr, + CxFunc: request.Actions[i].CxFunc, + CxReturn: request.Actions[i].CxReturn, + CxParams: append([]string{}, request.Actions[i].CxParams...), + TxValue: request.Actions[i].TxValue, + TxGasLimit: request.Actions[i].TxGasLimit, + }) + for j, param := range actions[i].CxParams { + if param == request.UserAddress { + actions[i].CxParams[j] = "*" + } + } + } + sanitized := model.TransactionRequest{ + UserAddress: request.UserAddress, + AssetName: request.AssetName, + ChainId: request.ChainId, + Actions: actions, + } + return sanitized +} + +func tokenizeTransactionRequest(request model.TransactionRequest) string { + bytes, err := json.Marshal(request) + if err != nil { + return "" + } + hash := sha1.New() + hash.Write(bytes) + return hex.EncodeToString(hash.Sum(nil)) +} diff --git a/pkg/service/sms.go b/pkg/service/sms.go index f730a9f5..f433dca9 100644 --- a/pkg/service/sms.go +++ b/pkg/service/sms.go @@ -1,17 +1,17 @@ package service import ( - "os" "strings" - "github.com/String-xyz/string-api/pkg/internal/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/string-api/config" "github.com/pkg/errors" "github.com/twilio/twilio-go" twilioApi "github.com/twilio/twilio-go/rest/api/v2010" ) func SendSMS(message string, recipients []string) error { - var SMS_SID = os.Getenv("TWILIO_SMS_SID") + var SMS_SID = config.Var.TWILIO_SMS_SID client := twilio.NewRestClient() // TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN are loaded from env in constructor params := &twilioApi.CreateMessageParams{} params.SetBody(message) @@ -30,17 +30,17 @@ func SendSMS(message string, recipients []string) error { } } if errs != nil { - return common.StringError(errs) + return libcommon.StringError(errs) } return nil } -func MessageStaff(message string) error { - var devNumbers = os.Getenv("DEV_PHONE_NUMBERS") - recipients := strings.Split(devNumbers, ",") +func MessageTeam(message string) error { + var teamNumbers = config.Var.TEAM_PHONE_NUMBERS + recipients := strings.Split(teamNumbers, ",") err := SendSMS(message, recipients) if err != nil { - return common.StringError(err) + return libcommon.StringError(err) } return nil } diff --git a/pkg/service/sms_test.go b/pkg/service/sms_test.go index 77af3231..4bc81e3b 100644 --- a/pkg/service/sms_test.go +++ b/pkg/service/sms_test.go @@ -5,13 +5,10 @@ package service import ( "testing" - "github.com/joho/godotenv" "github.com/stretchr/testify/assert" ) func TestSendSMS(t *testing.T) { - err := godotenv.Load("../../.env") - assert.NoError(t, err) - err = MessageStaff("This is a test of the String Messaging Service!") + err := MessageTeam("This is a test of the String Messaging Service!") assert.NoError(t, err) } diff --git a/pkg/service/span.go b/pkg/service/span.go new file mode 100644 index 00000000..4db3cf36 --- /dev/null +++ b/pkg/service/span.go @@ -0,0 +1,27 @@ +package service + +import ( + "context" + + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" +) + +type SpanTag map[string]interface{} + +func StartfromContext(ctx context.Context, operationName string) (tracer.Span, context.Context) { + return tracer.StartSpanFromContext(ctx, operationName) +} + +func StartSpan(operationName string) tracer.Span { + return tracer.StartSpan(operationName) +} + +func Span(ctx context.Context, operationName string, tags ...SpanTag) (tracer.Span, func(options ...tracer.FinishOption)) { + sp, _ := StartfromContext(ctx, operationName) + for _, tag := range tags { + for key, value := range tag { + sp.SetTag(key, value) + } + } + return sp, sp.Finish +} diff --git a/pkg/service/string_id.go b/pkg/service/string_id.go index 751cf992..a770b973 100644 --- a/pkg/service/string_id.go +++ b/pkg/service/string_id.go @@ -1,14 +1,11 @@ package service -import ( - "os" -) +import "github.com/String-xyz/string-api/config" func GetStringIdsFromEnv() InternalIds { return InternalIds{ - StringUserId: os.Getenv("STRING_INTERNAL_ID"), - StringBankId: os.Getenv("STRING_BANK_ID"), - StringWalletId: os.Getenv("STRING_WALLET_ID"), - StringPlatformId: os.Getenv("STRING_PLACEHOLDER_PLATFORM_ID"), // This is a temporary placeholder, we will get this from API key + StringUserId: config.Var.STRING_INTERNAL_ID, + StringBankId: config.Var.STRING_BANK_ID, + StringWalletId: config.Var.STRING_WALLET_ID, } } diff --git a/pkg/service/transaction.go b/pkg/service/transaction.go index d24be8ab..82b332da 100644 --- a/pkg/service/transaction.go +++ b/pkg/service/transaction.go @@ -1,29 +1,35 @@ package service import ( + "context" "encoding/json" "fmt" - "log" "math" "math/big" "strconv" "strings" "time" - "github.com/checkout/checkout-sdk-go/payments" - "github.com/pkg/errors" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + serror "github.com/String-xyz/go-lib/v2/stringerror" + "github.com/lmittmann/w3" + "github.com/String-xyz/string-api/pkg/internal/checkout" "github.com/String-xyz/string-api/pkg/internal/common" - "github.com/String-xyz/string-api/pkg/internal/unit21" - "github.com/String-xyz/string-api/pkg/model" - "github.com/String-xyz/string-api/pkg/repository" - "github.com/String-xyz/string-api/pkg/store" + "github.com/String-xyz/string-api/pkg/internal/emailer" + "github.com/lib/pq" + "github.com/pkg/errors" + "github.com/rs/zerolog/log" + + "github.com/String-xyz/string-api/pkg/model" + repository "github.com/String-xyz/string-api/pkg/repository" ) type Transaction interface { - Quote(d model.TransactionRequest) (model.ExecutionRequest, error) - Execute(e model.ExecutionRequest, userId string, deviceId string) (model.TransactionReceipt, error) + Quote(ctx context.Context, d model.TransactionRequest, platformId string) (res model.Quote, err error) + Execute(ctx context.Context, e model.ExecutionRequest, userId string, deviceId string, platformId string, ip string) (res model.TransactionReceipt, err error) } type TransactionRepos struct { @@ -36,6 +42,7 @@ type TransactionRepos struct { Device repository.Device Location repository.Location Contact repository.Contact + Contract repository.Contract } type InternalIds struct { @@ -46,377 +53,615 @@ type InternalIds struct { } type transaction struct { - repos repository.Repositories - redis store.RedisStore - ids InternalIds + repos repository.Repositories + redis database.RedisStore + ids InternalIds + unit21 Unit21 } -type transactionProcessingData struct { - userId *string - deviceId *string - executor *Executor - processingFeeAsset *model.Asset - transactionModel *model.Transaction - chain *Chain - executionRequest *model.ExecutionRequest - cardAuthorization *AuthorizedCharge - cardCapture *payments.CapturesResponse - preBalance *float64 - recipientWalletId *string - txId *string - cumulativeValue *big.Int - trueGas *uint64 +func NewTransaction(repos repository.Repositories, redis database.RedisStore, unit21 Unit21) Transaction { + return &transaction{repos: repos, redis: redis, unit21: unit21} } -func NewTransaction(repos repository.Repositories, redis store.RedisStore) Transaction { - return &transaction{repos: repos, redis: redis} +type transactionProcessingData struct { + userId *string + user *model.User + deviceId *string + ip *string + platformId *string + executor *Executor + processingFeeAsset *model.Asset + transactionModel *model.Transaction + chain *Chain + executionRequest *model.ExecutionRequest + floatEstimate *model.Estimate[float64] + cardAuthorization *AuthorizedCharge + PaymentStatus checkout.PaymentStatus + PaymentId string + recipientWalletId *string + txIds []string + forwardTxIds []string + cumulativeValue *big.Int + trueGas uint64 + tokenIds string + tokenQuantities string + transferredTokens []string + transferredTokenQuantities []*big.Int } -func (t transaction) Quote(d model.TransactionRequest) (model.ExecutionRequest, error) { +func (t transaction) Quote(ctx context.Context, d model.TransactionRequest, platformId string) (res model.Quote, err error) { + _, finish := Span(ctx, "service.transaction.Quote", SpanTag{"platformId": platformId}) + defer finish() + // TODO: use prefab service to parse d and fill out known params - res := model.ExecutionRequest{TransactionRequest: d} - // chain, err := model.ChainInfo(uint64(d.ChainID)) - chain, err := ChainInfo(uint64(d.ChainID), t.repos.Network, t.repos.Asset) + res.TransactionRequest = d + chain, err := ChainInfo(ctx, uint64(d.ChainId), t.repos.Network, t.repos.Asset) if err != nil { - return res, common.StringError(err) + return res, libcommon.StringError(err) } + + allowed, highestType, err := t.isContractAllowed(ctx, platformId, chain.UUID, d) + if err != nil { + return res, libcommon.StringError(err) + } + if !allowed { + return res, libcommon.StringError(serror.CONTRACT_NOT_ALLOWED) + } + executor := NewExecutor() - err = executor.Initialize(chain.RPC) + err = executor.Initialize(chain) if err != nil { - return res, common.StringError(err) + return res, libcommon.StringError(err) } - estimateUSD, _, err := t.testTransaction(executor, d, chain, true) + estimateUSD, _, _, err := t.testTransaction(executor, d, chain, true, true) if err != nil { - return res, common.StringError(err) + return res, libcommon.StringError(err) } - res.Quote = estimateUSD + res.Estimate = common.EstimateToPrecise(estimateUSD) executor.Close() + userWallet, err := t.repos.Instrument.GetWalletByAddr(ctx, d.UserAddress) + if err != nil { + return res, libcommon.StringError(err) + } + userId := userWallet.UserId + kyc := NewKYC(t.repos) + allowed, level, err := kyc.MeetsRequirements(ctx, userId, highestType, estimateUSD.TotalUSD) + if err != nil { + return res, libcommon.StringError(err) + } + res.Level = int(level) + if !allowed { + return res, libcommon.StringError(errors.New("insufficient level")) + } + // Sign entire payload bytes, err := json.Marshal(res) if err != nil { - return res, common.StringError(err) + return res, libcommon.StringError(err) } signature, err := common.EVMSign(bytes, true) if err != nil { - return res, common.StringError(err) + return res, libcommon.StringError(err) } res.Signature = signature return res, nil } -func (t transaction) Execute(e model.ExecutionRequest, userId string, deviceId string) (res model.TransactionReceipt, err error) { - t.getStringInstrumentsAndUserId() +func (t transaction) Execute(ctx context.Context, e model.ExecutionRequest, userId string, deviceId string, platformId string, ip string) (res model.TransactionReceipt, err error) { + _, finish := Span(ctx, "service.transaction.Execute", SpanTag{"platformId": platformId}) + defer finish() - p := transactionProcessingData{executionRequest: &e, userId: &userId, deviceId: &deviceId} + t.getStringInstrumentsAndUserId() + p := transactionProcessingData{executionRequest: &e, userId: &userId, deviceId: &deviceId, ip: &ip, platformId: &platformId} // Pre-flight transaction setup - p, err = t.transactionSetup(p) + p, err = t.transactionSetup(ctx, p) if err != nil { - return res, common.StringError(err) + return res, libcommon.StringError(err) } // Run safety checks - p, err = t.safetyCheck(p) + p, err = t.safetyCheck(ctx, p) if err != nil { - return res, common.StringError(err) + return res, libcommon.StringError(err) } // Send request to the blockchain and update model status, hash, transaction amount - p, err = t.initiateTransaction(p) + p, err = t.initiateTransaction(ctx, p) if err != nil { - return res, common.StringError(err) + return res, libcommon.StringError(err) } // this Executor will not exist in scope of postProcess (*p.executor).Close() - // Send required information to new thread and return txId to the endpoint - go t.postProcess(p) - - return model.TransactionReceipt{TxID: *p.txId, TxURL: p.chain.Explorer + "/tx/" + *p.txId}, nil + // Send required information to new thread and return txId to the endpoint. Create a new context since this will run in background + ctx2 := context.Background() + go t.postProcess(ctx2, p) + + ids := []string{} + urls := []string{} + for i, id := range p.txIds { + // check if lowercase version of p.executionRequest.Quote.TransactionRequest.Actions[i].CxFunc contains the word "approve" + if !strings.Contains(strings.ToLower(p.executionRequest.Quote.TransactionRequest.Actions[i].CxFunc), "approve") { + ids = append(ids, id) + urls = append(urls, p.chain.Explorer+"/tx/"+id) + } + } + return model.TransactionReceipt{TxIds: ids, TxURLs: urls, TxTimestamp: time.Now().Format(time.RFC1123)}, nil } -func (t transaction) postProcess(p transactionProcessingData) { - // Reinitialize Executor - executor := NewExecutor() - p.executor = &executor - err := executor.Initialize(p.chain.RPC) +func (t transaction) transactionSetup(ctx context.Context, p transactionProcessingData) (transactionProcessingData, error) { + _, finish := Span(ctx, "service.transaction.transactionSetup", SpanTag{"platformId": p.platformId}) + defer finish() + + user, err := t.repos.User.GetById(ctx, *p.userId) if err != nil { - log.Printf("Failed to initialized executor in postProcess: %s", common.StringError(err)) - // TODO: Handle error instead of returning it + return p, libcommon.StringError(err) + } + email, err := t.repos.Contact.GetByUserIdAndType(ctx, user.Id, "email") + if err != nil && errors.Cause(err).Error() != "not found" { + return p, libcommon.StringError(err) } + user.Email = email.Data + p.user = &user - // Update TX Status - updateDB := model.TransactionUpdates{} - status := "Post Process RPC Dialed" - updateDB.Status = &status - err = t.repos.Transaction.Update(p.transactionModel.ID, updateDB) + // Pull chain info needed for execution from repository + chain, err := ChainInfo(ctx, p.executionRequest.Quote.TransactionRequest.ChainId, t.repos.Network, t.repos.Asset) if err != nil { - log.Printf("Failed to update transaction repo with status 'Post Process RPC Dialed': %s", common.StringError(err)) - // TODO: Handle error instead of returning it + return p, libcommon.StringError(err) } + p.chain = &chain - // confirm the Tx on the EVM - trueGas, err := confirmTx(executor, *p.txId) - p.trueGas = &trueGas + // Create new Tx in repository, populate it with known info + transactionModel, err := t.repos.Transaction.Create(ctx, model.Transaction{Status: "Created", NetworkId: chain.UUID, DeviceId: *p.deviceId, IPAddress: *p.ip, PlatformId: *p.platformId}) if err != nil { - log.Printf("Failed to confirm transaction: %s", common.StringError(err)) - // TODO: Handle error instead of returning it + return p, libcommon.StringError(err) } + p.transactionModel = &transactionModel - // Update DB status and NetworkFee - status = "Tx Confirmed" - updateDB.Status = &status - networkFee := strconv.FormatUint(trueGas, 10) - updateDB.NetworkFee = &networkFee // geth uses uint64 for gas - err = t.repos.Transaction.Update(p.transactionModel.ID, updateDB) + updateDB := &model.TransactionUpdates{} + processingFeeAsset, err := t.populateInitialTxModelData(ctx, *p.executionRequest, updateDB) + p.processingFeeAsset = &processingFeeAsset if err != nil { - log.Printf("Failed to update transaction repo with status 'Tx Confirmed': %s", common.StringError(err)) - // TODO: Handle error instead of returning it + return p, libcommon.StringError(err) } - - // Get new string wallet balance after executing the transaction - postBalance, err := executor.GetBalance() + err = t.repos.Transaction.Update(ctx, transactionModel.Id, updateDB) if err != nil { - log.Printf("Failed to get executor balance: %s", common.StringError(err)) - // TODO: handle error instead of returning it + log.Err(err).Send() + return p, libcommon.StringError(err) } - // We can close the executor because we aren't using it after this - executor.Close() + // Dial the RPC and update model status + executor := NewExecutor() + p.executor = &executor + err = executor.Initialize(chain) + if err != nil { + return p, libcommon.StringError(err) + } - // If threshold was crossed, notify devs - // TODO: store threshold on a per-network basis in the repo - threshold := 10.0 - if *p.preBalance >= threshold && postBalance < threshold { - msg := fmt.Sprintf("STRING-API: %s balance is < %.2f at %.2f", p.chain.OwlracleName, threshold, postBalance) - err = MessageStaff(msg) - if err != nil { - log.Printf("Failed to send staff with low balance threshold message: %s", common.StringError(err)) - // Not seeing any e - // TODO: handle error instead of returning it - } + err = t.updateTransactionStatus(ctx, "RPC Dialed", transactionModel.Id) + if err != nil { + return p, libcommon.StringError(err) } - // compute profit - // TODO: factor request.processingFeeAsset in the event of crypto-to-usd - profit, err := t.tenderTransaction(p) + return p, err +} + +func (t transaction) safetyCheck(ctx context.Context, p transactionProcessingData) (transactionProcessingData, error) { + _, finish := Span(ctx, "service.transaction.safetyCheck", SpanTag{"platformId": p.platformId}) + defer finish() + + // Test the Tx and update model status + estimateUSD, estimateETH, estimateEVM, err := t.testTransaction(*p.executor, p.executionRequest.Quote.TransactionRequest, *p.chain, false, false) if err != nil { - log.Printf("Failed to tender transaction: %s", common.StringError(err)) - // TODO: Handle error instead of returning it + return p, libcommon.StringError(err) } - stringFee := floatToFixedString(profit, 6) - processingFee := floatToFixedString(profit, 6) // TODO: set processingFee based on payment method, and location - // update db status and processing fees to db - updateDB.StringFee = &stringFee // string fee is always USD with 6 digits - updateDB.ProcessingFee = &processingFee - status = "Profit Tendered" - updateDB.Status = &status - err = t.repos.Transaction.Update(p.transactionModel.ID, updateDB) + err = t.updateTransactionStatus(ctx, "Tested and Estimated", p.transactionModel.Id) if err != nil { - log.Printf("Failed to update transaction repo with status 'Profit Tendered': %s", common.StringError(err)) - // TODO: Handle error instead of returning it + return p, libcommon.StringError(err) } - // charge the users CC - err = t.chargeCard(p) + // Verify the Quote and update model status + _, err = verifyQuote(*p.executionRequest, estimateUSD) if err != nil { - log.Printf("Error, failed to charge card: %+v", common.StringError(err)) - // TODO: Handle error instead of returning it + // Update cache if price is too volatile + if errors.Cause(err).Error() == "verifyQuote: price too volatile" { + quoteCache := NewQuoteCache(t.redis) + _, err = quoteCache.PutCachedTransactionRequest(p.executionRequest.Quote.TransactionRequest, estimateEVM) + if err != nil { + return p, libcommon.StringError(err) + } + } + return p, libcommon.StringError(err) } - // Update status upon success - status = "Card Charged" - updateDB.Status = &status - // TODO: Figure out how much we paid the CC payment processor and deduct it - // and use it to populate processing_fee and processing_fee_asset in the table - err = t.repos.Transaction.Update(p.transactionModel.ID, updateDB) + err = t.updateTransactionStatus(ctx, "Quote Verified", p.transactionModel.Id) if err != nil { - log.Printf("Failed to update transaction repo with status 'Card Charged': %s", common.StringError(err)) - // TODO: Handle error instead of returning it + return p, libcommon.StringError(err) } - // Transaction complete! Update status - status = "Completed" - updateDB.Status = &status - err = t.repos.Transaction.Update(p.transactionModel.ID, updateDB) + floatEstimate := common.EstimateToImprecise(p.executionRequest.Quote.Estimate) + p.floatEstimate = &floatEstimate + + // Get current balance of primary token + balance, err := (*p.executor).GetBalance() if err != nil { - log.Printf("Failed to update transaction repo with status 'Completed': %s", common.StringError(err)) + return p, libcommon.StringError(err) } - // Create Transaction data in Unit21 - err = t.unit21CreateTransaction(p.transactionModel.ID) + // Notify staff if balance is below threshold + threshold := 1.0 + if balance-estimateETH < threshold { + msg := fmt.Sprintf("STRING-API: %s balance is at or below threshold of %.2f before executing %.2f transaction at %.2f", p.chain.OwlracleName, threshold, estimateETH, balance) + go MessageTeam(msg) + } + + // Exit if transaction will fail due to insufficient balance + if balance <= estimateETH { + return p, libcommon.StringError(errors.New("hot wallet ETH balance too low")) + } + + // Authorize quoted cost on end-user CC and update model status + p, err = t.authCard(ctx, p) if err != nil { - log.Printf("Error creating Unit21 transaction: %s", common.StringError(err)) + return p, libcommon.StringError(err) } - // send email receipt - err = t.sendEmailReceipt(p) + // Validate Transaction through Real Time Rules engine + txModel, err := t.repos.Transaction.GetById(ctx, p.transactionModel.Id) if err != nil { - log.Printf("Error sending email receipt to user: %s", common.StringError(err)) + log.Err(err).Msg("error getting tx model in unit21 Tx Evalute") + return p, libcommon.StringError(err) } -} -func (t transaction) transactionSetup(p transactionProcessingData) (transactionProcessingData, error) { - // get user object - _, err := t.repos.User.GetById(*p.userId) + results, err := t.unit21.Transaction.Evaluate(ctx, txModel) if err != nil { - return p, common.StringError(err) + // If Unit21 Evaluate fails, just log, but otherwise continue with the transaction + log.Err(err).Msg("Error evaluating transaction in Unit21") + return p, nil // NOTE: intentionally returning nil here in order to continue the transaction } - // Pull chain info needed for execution from repository - chain, err := ChainInfo(uint64(p.executionRequest.ChainID), t.repos.Network, t.repos.Asset) - p.chain = &chain + if len(results) > 0 { + err = t.updateTransactionStatus(ctx, "Failed", p.transactionModel.Id) + if err != nil { + return p, libcommon.StringError(err) + } + + err = t.unit21CreateTransaction(ctx, p.transactionModel.Id) + if err != nil { + return p, libcommon.StringError(err) + } + errorString := "\n" + for _, rule := range results { + errorString += fmt.Sprintln("Rule: ", rule.RuleName, " - ", rule.Status) + } + return p, libcommon.StringError(errors.New(fmt.Sprintf("risk: Transaction Failed Unit21 Real Time Rules Evaluation with results: %+v", errorString))) + } + + err = t.updateTransactionStatus(ctx, "Unit21 Authorized", p.transactionModel.Id) if err != nil { - return p, common.StringError(err) + return p, libcommon.StringError(err) } - // Create new Tx in repository, populate it with known info - transactionModel, err := t.repos.Transaction.Create(model.Transaction{Status: "Created", NetworkID: chain.UUID, DeviceID: *p.deviceId, PlatformID: t.ids.StringPlatformId}) - p.transactionModel = &transactionModel + return p, nil +} + +func (t transaction) initiateTransaction(ctx context.Context, p transactionProcessingData) (transactionProcessingData, error) { + _, finish := Span(ctx, "service.transaction.initiateTransaction", SpanTag{"platformId": p.platformId}) + defer finish() + + request := p.executionRequest.Quote.TransactionRequest + calls := []ContractCall{} + for _, action := range request.Actions { + + call := ContractCall{ + CxAddr: action.CxAddr, + CxFunc: action.CxFunc, + CxReturn: action.CxReturn, + CxParams: action.CxParams, + TxValue: action.TxValue, + TxGasLimit: action.TxGasLimit, + } + calls = append(calls, call) + } + + txIds, value, err := (*p.executor).Initiate(calls) + p.cumulativeValue = value if err != nil { - return p, common.StringError(err) + return p, libcommon.StringError(err) } + p.txIds = append(p.txIds, txIds...) - updateDB := &model.TransactionUpdates{} - processingFeeAsset, err := t.populateInitialTxModelData(*p.executionRequest, updateDB) - p.processingFeeAsset = &processingFeeAsset + // Create Response Tx leg + eth := common.WeiToEther(value) + wei := floatToFixedString(eth, 18) + usd := floatToFixedString(p.floatEstimate.TotalUSD, int(p.processingFeeAsset.Decimals)) + responseLeg := model.TxLeg{ + Timestamp: time.Now(), + Amount: wei, + Value: usd, + AssetId: p.processingFeeAsset.Id, + UserId: *p.userId, + InstrumentId: t.ids.StringWalletId, + } + responseLeg, err = t.repos.TxLeg.Create(ctx, responseLeg) if err != nil { - return p, common.StringError(err) + return p, libcommon.StringError(err) } - err = t.repos.Transaction.Update(transactionModel.ID, updateDB) + txLeg := model.TransactionUpdates{ResponseTxLegId: &responseLeg.Id} + err = t.repos.Transaction.Update(ctx, p.transactionModel.Id, txLeg) if err != nil { - fmt.Printf("\nERROR = %+v", common.StringError(err)) - return p, common.StringError(err) + return p, libcommon.StringError(err) } - // Dial the RPC and update model status - executor := NewExecutor() - p.executor = &executor - err = executor.Initialize(chain.RPC) - if err != nil { - return p, common.StringError(err) + status := "Transaction Initiated" + txAmount := p.cumulativeValue.String() + + hashesOtherThanApprove := []string{} + for i, txId := range p.txIds { + if !strings.Contains(strings.ToLower(p.executionRequest.Quote.TransactionRequest.Actions[i].CxFunc), "approve") { + hashesOtherThanApprove = append(hashesOtherThanApprove, txId) + } } - err = t.updateTransactionStatus("RPC Dialed", transactionModel.ID) + hashes := strings.Join(hashesOtherThanApprove, ", ") + updateDB := &model.TransactionUpdates{Status: &status, TransactionHash: &hashes, TransactionAmount: &txAmount} + err = t.repos.Transaction.Update(ctx, p.transactionModel.Id, updateDB) if err != nil { - return p, common.StringError(err) + return p, libcommon.StringError(err) } - return p, err + return p, nil } -func (t transaction) safetyCheck(p transactionProcessingData) (transactionProcessingData, error) { - // Test the Tx and update model status - estimateUSD, estimateETH, err := t.testTransaction(*p.executor, p.executionRequest.TransactionRequest, *p.chain, false) +func (t transaction) postProcess(ctx context.Context, p transactionProcessingData) { + _, finish := Span(ctx, "service.transaction.postProcess", SpanTag{"platformId": p.platformId}) + defer finish() + + // Reinitialize Executor + executor := NewExecutor() + p.executor = &executor + err := executor.Initialize(*p.chain) if err != nil { - return p, common.StringError(err) + log.Err(err).Msg("Failed to initialized executor in postProcess") + // TODO: Handle error instead of returning it } - err = t.updateTransactionStatus("Tested and Estimated", p.transactionModel.ID) + + // Update TX Status + updateDB := model.TransactionUpdates{} + status := "Post Process RPC Dialed" + updateDB.Status = &status + err = t.repos.Transaction.Update(ctx, p.transactionModel.Id, updateDB) if err != nil { - return p, common.StringError(err) + log.Err(err).Msg("Failed to update transaction repo with status 'Post Process RPC Dialed'") + // TODO: Handle error instead of returning it } - // Verify the Quote and update model status - _, err = verifyQuote(*p.executionRequest, estimateUSD) + // confirm the Tx on the EVM + trueGas, err := confirmTx(executor, p.txIds) + p.trueGas = trueGas if err != nil { - return p, common.StringError(err) + log.Err(err).Msg("Failed to confirm transaction") + // TODO: Handle error instead of returning it } - err = t.updateTransactionStatus("Quote Verified", p.transactionModel.ID) + + // Update DB status and NetworkFee + status = "Tx Confirmed" + updateDB.Status = &status + networkFee := strconv.FormatUint(trueGas, 10) + updateDB.NetworkFee = &networkFee // geth uses uint64 for gas + err = t.repos.Transaction.Update(ctx, p.transactionModel.Id, updateDB) if err != nil { - return p, common.StringError(err) + log.Err(err).Msg("Failed to update transaction repo with status 'Tx Confirmed'") + // TODO: Handle error instead of returning it } - // Get current balance of primary token - preBalance, err := (*p.executor).GetBalance() - p.preBalance = &preBalance + // Get the Token IDs which were transferred + _ /*nftAddresses*/, tokenIds, err := executor.GetTokenIds(p.txIds) if err != nil { - return p, common.StringError(err) - } - if preBalance < estimateETH { - msg := fmt.Sprintf("STRING-API: %s balance is too low to execute %.2f transaction at %.2f", p.chain.OwlracleName, estimateETH, preBalance) - MessageStaff(msg) - return p, common.StringError(errors.New("hot wallet ETH balance too low")) + log.Err(err).Msg("Failed to get token ids") + // TODO: Handle error instead of returning it } + p.tokenIds = strings.Join(tokenIds, ",") - // Authorize quoted cost on end-user CC and update model status - p, err = t.authCard(p) - if err != nil { - return p, common.StringError(err) + // Forward any non fungible tokens received to the user + // TODO: Use the TX ID/s from this in the receipt + // TODO: Find a way to charge for the gas used in this transaction + if len(tokenIds) > 0 { + forwardTxIds /*forwardTokenIds*/, _, err := executor.ForwardNonFungibleTokens(p.txIds, p.executionRequest.Quote.TransactionRequest.UserAddress) + if err != nil { + log.Err(err).Msg("Failed to forward non fungible tokens") + } + p.forwardTxIds = append(p.forwardTxIds, forwardTxIds...) } - // Validate Transaction through Real Time Rules engine - // RTR is not released for Unit21 Production (slated for Late February 2023) - evaluation, err := t.unit21Evaluate(p.transactionModel.ID) + // Get the Token quantities which were transferred + p.transferredTokens, p.transferredTokenQuantities, err = executor.GetTokenQuantities(p.txIds) if err != nil { - // If Unit21 Evaluate fails, just log, but otherwise continue with the transaction - log.Printf("Error evaluating transaction in Unit21: %s", common.StringError(err)) - return p, nil // NOTE: intentionally returning nil here in order to continue the transaction + log.Err(err).Msg("Failed to get token quantities") + // TODO: Handle error instead of returning it } + p.tokenQuantities = common.StringifyBigIntArray(p.transferredTokenQuantities) - if !evaluation { - err = t.updateTransactionStatus("Failed", p.transactionModel.ID) + if len(p.transferredTokenQuantities) > 0 { + forwardTxIds /*forwardTokenAddresses*/, _ /*tokenQuantities*/, _, err := executor.ForwardTokens(p.txIds, p.executionRequest.Quote.TransactionRequest.UserAddress) if err != nil { - return p, common.StringError(err) + log.Err(err).Msg("Failed to forward tokens") } + p.forwardTxIds = append(p.forwardTxIds, forwardTxIds...) + } - err = t.unit21CreateTransaction(p.transactionModel.ID) - if err != nil { - return p, common.StringError(err) + // Cull any TXIDs from Approve(), the user and Unit21 and the receipt don't need them + for i, action := range p.executionRequest.Quote.TransactionRequest.Actions { + if strings.Contains(strings.ToLower(action.CxFunc), "approve") { + p.txIds = append(p.txIds[:i], p.txIds[i+1:]...) } + } - return p, common.StringError(errors.New("risk: Transaction Failed Unit21 Real Time Rules Evaluation")) + // TODO: Get the final gas total here and cache it to the quote cache. And use it for subsequent quotes. + forwardGas, err := confirmTx(executor, p.forwardTxIds) + if err != nil { + log.Err(err).Msg("Failed to confirm forwarding transactions") } + p.trueGas += forwardGas - err = t.updateTransactionStatus("Unit21 Authorized", p.transactionModel.ID) + // We can close the executor because we aren't using it after this + executor.Close() + + // Check true cost against quote + + // Cache the gas associated with this transaction + qc := NewQuoteCache(t.redis) + err = qc.UpdateMaxCachedTrueGas(p.executionRequest.Quote.TransactionRequest, p.trueGas) if err != nil { - return p, common.StringError(err) + log.Err(err).Msg("Failed to update quote true gas cache") } - return p, nil + // compute profit + // TODO: factor request.processingFeeAsset in the event of crypto-to-usd + profit, err := t.tenderTransaction(ctx, p) + if err != nil { + log.Err(err).Msg("Failed to tender transaction") + // TODO: Handle error instead of returning it + } + stringFee := floatToFixedString(profit, 6) + processingFee := floatToFixedString(profit, 6) // TODO: set processingFee based on payment method, and location + + // update db status and processing fees to db + updateDB.StringFee = &stringFee // string fee is always USD with 6 digits + updateDB.ProcessingFee = &processingFee + status = "Profit Tendered" + updateDB.Status = &status + err = t.repos.Transaction.Update(ctx, p.transactionModel.Id, updateDB) + if err != nil { + log.Err(err).Msg("Failed to update transaction repo with status 'Profit Tendered'") + // TODO: Handle error instead of returning it + } + + // charge the users CC + err = t.chargeCard(ctx, p) + if err != nil { + log.Err(err).Msg("failed to charge card") + // TODO: Handle error instead of returning it + } + + // Update status upon success + status = "Card Charged" + updateDB.Status = &status + // TODO: Figure out how much we paid the CC payment processor and deduct it + // and use it to populate processing_fee and processing_fee_asset in the table + err = t.repos.Transaction.Update(ctx, p.transactionModel.Id, updateDB) + if err != nil { + log.Err(err).Msg("Failed to update transaction repo with status 'Card Charged'") + // TODO: Handle error instead of returning it + } + + // Transaction complete! Update status + status = "Completed" + updateDB.Status = &status + err = t.repos.Transaction.Update(ctx, p.transactionModel.Id, updateDB) + if err != nil { + log.Err(err).Msg("Failed to update transaction repo with status 'Completed'") + } + + // Create Transaction data in Unit21 + err = t.unit21CreateTransaction(ctx, p.transactionModel.Id) + if err != nil { + log.Err(err).Msg("Error creating Unit21 transaction") + } + + // send email receipt + err = t.sendEmailReceipt(ctx, p) + if err != nil { + log.Err(err).Msg("Error sending email receipt to user") + } } -func (t transaction) populateInitialTxModelData(e model.ExecutionRequest, m *model.TransactionUpdates) (model.Asset, error) { +func (t transaction) populateInitialTxModelData(ctx context.Context, e model.ExecutionRequest, m *model.TransactionUpdates) (model.Asset, error) { txType := "fiat-to-crypto" m.Type = &txType // TODO populate transactionModel.Tags with key-val pairs for Unit21 - // TODO populate transactionModel.DeviceID with info from fingerprint + // TODO populate transactionModel.DeviceId with info from fingerprint // TODO populate transactionModel.IPAddress with info from fingerprint - // TODO populate transactionModel.PlatformID with UUID of customer + // TODO populate transactionModel.PlatformId with UUID of customer // bytes, err := json.Marshal() - contractParams := pq.StringArray(e.CxParams) + // For now just concat everything + concatParams := []string{} + concatFuncs := []string{} + for _, action := range e.Quote.TransactionRequest.Actions { + concatParams = append(concatParams, "[") + concatParams = append(concatParams, action.CxParams...) + concatParams = append(concatParams, "]") + concatFuncs = append(concatFuncs, action.CxFunc+action.CxReturn) + } + + contractParams := pq.StringArray(concatParams) m.ContractParams = &contractParams - contractFunc := e.CxFunc + e.CxReturn + contractFunc := strings.Join(concatFuncs, ",") m.ContractFunc = &contractFunc - asset, err := t.repos.Asset.GetByName("USD") + asset, err := t.repos.Asset.GetByName(ctx, "USD") if err != nil { - return model.Asset{}, common.StringError(err) + return model.Asset{}, libcommon.StringError(err) } - m.ProcessingFeeAsset = &asset.ID // Checkout processing asset + m.ProcessingFeeAsset = &asset.Id // Checkout processing asset return asset, nil } -func (t transaction) testTransaction(executor Executor, request model.TransactionRequest, chain Chain, useBuffer bool) (model.Quote, float64, error) { - res := model.Quote{} +func (t transaction) testTransaction(executor Executor, request model.TransactionRequest, chain Chain, useBuffer bool, useCache bool) (model.Estimate[float64], float64, CallEstimate, error) { + res := model.Estimate[float64]{} - call := ContractCall{ - CxAddr: request.CxAddr, - CxFunc: request.CxFunc, - CxReturn: request.CxReturn, - CxParams: request.CxParams, - TxValue: request.TxValue, - TxGasLimit: request.TxGasLimit, + quoteCache := NewQuoteCache(t.redis) + estimateEVM := CallEstimate{} + recalculate := true + var err error + if useBuffer { + recalculate, estimateEVM, err = quoteCache.CheckUpdateCachedTransactionRequest(request, 60*5) // TODO: robust buffer time + if err != nil { + return res, 0, CallEstimate{}, libcommon.StringError(err) + } } - // Estimate value and gas of Tx request - estimateEVM, err := executor.Estimate(call) - if err != nil { - return res, 0, common.StringError(err) + + if recalculate { + calls := []ContractCall{} + for _, action := range request.Actions { + calls = append(calls, ContractCall{ + CxAddr: action.CxAddr, + CxFunc: action.CxFunc, + CxReturn: action.CxReturn, + CxParams: action.CxParams, + TxValue: action.TxValue, + TxGasLimit: action.TxGasLimit, + }) + } + // Estimate value and gas of Tx request + estimateEVM, err = executor.Estimate(calls) + if err != nil { + return res, 0, CallEstimate{}, libcommon.StringError(err) + } + if useCache { + estimateEVM, err = quoteCache.PutCachedTransactionRequest(request, estimateEVM) + if err != nil { + return res, 0, CallEstimate{}, libcommon.StringError(err) + } + } + } + + // Factor in approvals to Token Cost + tokenAddresses := []string{} + tokenAmounts := []big.Int{} + for _, action := range request.Actions { + if common.StringContainsAny(action.CxFunc, []string{"approve", "transfer"}) { + tokenAddresses = append(tokenAddresses, action.CxAddr) + // It should be safe at this point to w3.I without panic + tokenAmounts = append(tokenAmounts, *w3.I(action.CxParams[1])) + } } // Calculate total eth estimate as float64 @@ -425,143 +670,169 @@ func (t transaction) testTransaction(executor Executor, request model.Transactio wei := gas.Add(&estimateEVM.Value, gas) eth := common.WeiToEther(wei) - chainID, err := executor.GetByChainId() + chainId, err := executor.GetByChainId() if err != nil { - return res, eth, common.StringError(err) + return res, eth, CallEstimate{}, libcommon.StringError(err) } - cost := NewCost(t.redis) + cost := NewCost(t.redis, t.repos) estimationParams := EstimationParams{ - ChainID: chainID, + ChainId: chainId, CostETH: estimateEVM.Value, UseBuffer: useBuffer, GasUsedWei: estimateEVM.Gas, - CostToken: *big.NewInt(0), - TokenName: "", + CostTokens: tokenAmounts, + TokenAddrs: tokenAddresses, } // Estimate Cost in USD to execute Tx request estimateUSD, err := cost.EstimateTransaction(estimationParams, chain) if err != nil { - return res, eth, common.StringError(err) + return res, eth, CallEstimate{}, libcommon.StringError(err) } res = estimateUSD - return res, eth, nil + return res, eth, estimateEVM, nil } -func verifyQuote(e model.ExecutionRequest, newEstimate model.Quote) (bool, error) { +func verifyQuote(e model.ExecutionRequest, newEstimate model.Estimate[float64]) (bool, error) { // Null out values which have changed since payload was signed dataToValidate := e - dataToValidate.Signature = "" - dataToValidate.CardToken = "" - bytesToValidate, err := json.Marshal(dataToValidate) + dataToValidate.Quote.Signature = "" + bytesToValidate, err := json.Marshal(dataToValidate.Quote) if err != nil { - return false, common.StringError(err) + return false, libcommon.StringError(err) } - valid, err := common.ValidateEVMSignature(e.Signature, bytesToValidate, true) + valid, err := common.ValidateEVMSignature(e.Quote.Signature, bytesToValidate, true) if err != nil { - return false, common.StringError(err) + return false, libcommon.StringError(err) } if !valid { - return false, common.StringError(errors.New("verifyQuote: invalid signature")) + return false, libcommon.StringError(errors.New("verifyQuote: invalid signature")) + } + if newEstimate.Timestamp-e.Quote.Estimate.Timestamp > 20 { + return false, libcommon.StringError(errors.New("verifyQuote: quote expired")) } - if newEstimate.Timestamp-e.Timestamp > 20 { - return false, common.StringError(errors.New("verifyQuote: quote expired")) + quotedTotal, err := strconv.ParseFloat(e.Quote.Estimate.TotalUSD, 64) + if err != nil { + return false, libcommon.StringError(err) } - if newEstimate.TotalUSD > e.TotalUSD { - return false, common.StringError(errors.New("verifyQuote: price too volatile")) + if newEstimate.TotalUSD > quotedTotal { + return false, libcommon.StringError(errors.New("verifyQuote: price too volatile")) } return true, nil } -func (t transaction) addCardInstrumentIdIfNew(p transactionProcessingData) (string, error) { - instrument, err := t.repos.Instrument.GetCardByFingerprint(p.cardAuthorization.CheckoutFingerprint) +func (t transaction) addCardInstrumentIdIfNew(ctx context.Context, p transactionProcessingData) (string, error) { + _, finish := Span(ctx, "service.transaction.addCardInstrumentIdIfNew", SpanTag{"platformId": p.platformId}) + defer finish() + // Create a new context since there are sub routines that run in background + ctx2 := context.Background() + + instrument, err := t.repos.Instrument.GetCardByFingerprint(ctx, p.cardAuthorization.CheckoutFingerprint) if err != nil && !strings.Contains(err.Error(), "not found") { // because we are wrapping error and care about its value - return "", common.StringError(err) - } else if err == nil && instrument.UserID != "" { - return instrument.ID, nil // instrument already exists + return "", libcommon.StringError(err) + } else if err == nil && instrument.UserId != "" { + go t.unit21.Instrument.Update(ctx2, instrument) // if instrument already exists, update it anyways + return instrument.Id, nil // return if instrument already exists } // We should gather type from the payment processor - instrument_type := "Debit Card" + instrument_type := "debit card" if p.cardAuthorization.CardType == "CREDIT" { - instrument_type = "Credit Card" + instrument_type = "credit card" } // Create a new instrument - instrument = model.Instrument{ // No locationID until fingerprint + instrument = model.Instrument{ // No locationId until fingerprint Type: instrument_type, Status: "created", Last4: p.cardAuthorization.Last4, - UserID: *p.userId, + UserId: *p.userId, PublicKey: p.cardAuthorization.CheckoutFingerprint, + Name: p.cardAuthorization.CardholderName, } - instrument, err = t.repos.Instrument.Create(instrument) + + instrument, err = t.repos.Instrument.Create(ctx, instrument) if err != nil { - return "", common.StringError(err) + return "", libcommon.StringError(err) } - go t.unit21CreateInstrument(instrument) - return instrument.ID, nil + + go t.unit21.Instrument.Create(ctx2, instrument) + + return instrument.Id, nil } -func (t transaction) addWalletInstrumentIdIfNew(address string, id string) (string, error) { - instrument, err := t.repos.Instrument.GetWalletByAddr(address) +func (t transaction) addWalletInstrumentIdIfNew(ctx context.Context, address string, id string) (string, error) { + _, finish := Span(ctx, "service.transaction.addWalletInstrumentIdIfNew") + defer finish() + + // Create a new context since this will run in background + ctx2 := context.Background() + + instrument, err := t.repos.Instrument.GetWalletByAddr(ctx, address) if err != nil && !strings.Contains(err.Error(), "not found") { - return "", common.StringError(err) + return "", libcommon.StringError(err) } else if err == nil && instrument.PublicKey == address { - return instrument.ID, nil + go t.unit21.Instrument.Update(ctx2, instrument) // if instrument already exists, update it anyways + return instrument.Id, nil // return if instrument already exists } // Create a new instrument - instrument = model.Instrument{Type: "CryptoWallet", Status: "external", Network: "ethereum", PublicKey: address, UserID: id} // No locationID or userID because this wallet was not registered with the user and is some other recipient - instrument, err = t.repos.Instrument.Create(instrument) + instrument = model.Instrument{Type: "crypto wallet", Status: "external", Network: "EVM", PublicKey: address, UserId: id} // No locationId or userId because this wallet was not registered with the user and is some other recipient + instrument, err = t.repos.Instrument.Create(ctx, instrument) if err != nil { - return "", common.StringError(err) + return "", libcommon.StringError(err) } - go t.unit21CreateInstrument(instrument) - return instrument.ID, nil + + go t.unit21.Instrument.Create(ctx2, instrument) + + return instrument.Id, nil } -func (t transaction) authCard(p transactionProcessingData) (transactionProcessingData, error) { +func (t transaction) authCard(ctx context.Context, p transactionProcessingData) (transactionProcessingData, error) { + _, finish := Span(ctx, "service.transaction.authCard", SpanTag{"platformId": p.platformId}) + defer finish() + // auth their card p, err := AuthorizeCharge(p) if err != nil { - return p, common.StringError(err) + return p, libcommon.StringError(err) } // Add Checkout Instrument ID to our DB if it's not there already and associate it with the user - instrumentId, err := t.addCardInstrumentIdIfNew(p) + instrumentId, err := t.addCardInstrumentIdIfNew(ctx, p) if err != nil { - return p, common.StringError(err) + return p, libcommon.StringError(err) } // Create Origin Tx leg - usdWei := floatToFixedString(p.executionRequest.TotalUSD, int(p.processingFeeAsset.Decimals)) + usdWei := floatToFixedString(p.floatEstimate.TotalUSD, int(p.processingFeeAsset.Decimals)) origin := model.TxLeg{ Timestamp: time.Now(), Amount: usdWei, Value: usdWei, - AssetID: p.processingFeeAsset.ID, - UserID: *p.userId, - InstrumentID: instrumentId, + AssetId: p.processingFeeAsset.Id, + UserId: *p.userId, + InstrumentId: instrumentId, } - origin, err = t.repos.TxLeg.Create(origin) + origin, err = t.repos.TxLeg.Create(ctx, origin) if err != nil { - return p, common.StringError(err) + return p, libcommon.StringError(err) } - txLegUpdates := model.TransactionUpdates{OriginTxLegID: &origin.ID} - err = t.repos.Transaction.Update(p.transactionModel.ID, txLegUpdates) + + txLegUpdates := model.TransactionUpdates{OriginTxLegId: &origin.Id} + err = t.repos.Transaction.Update(ctx, p.transactionModel.Id, txLegUpdates) if err != nil { - return p, common.StringError(err) + return p, libcommon.StringError(err) } - err = t.updateTransactionStatus("Card "+p.cardAuthorization.Status, p.transactionModel.ID) + err = t.updateTransactionStatus(ctx, "Card "+p.cardAuthorization.Status, p.transactionModel.Id) if err != nil { - return p, common.StringError(err) + return p, libcommon.StringError(err) } - recipientWalletId, err := t.addWalletInstrumentIdIfNew(p.executionRequest.UserAddress, *p.userId) + recipientWalletId, err := t.addWalletInstrumentIdIfNew(ctx, p.executionRequest.Quote.TransactionRequest.UserAddress, *p.userId) p.recipientWalletId = &recipientWalletId if err != nil { - return p, common.StringError(err) + return p, libcommon.StringError(err) } // TODO: Determine the output of the transaction (destination leg) with Tracers @@ -569,115 +840,97 @@ func (t transaction) authCard(p transactionProcessingData) (transactionProcessin Timestamp: time.Now(), // Required by the db. Should be updated when the tx occurs Amount: "0", // Required by Unit21. The amount of the asset received by the user Value: "0", // Default to '0'. The value of the asset received by the user - AssetID: p.chain.GasTokenID, // Required by the db. the asset received by the user - UserID: *p.userId, // the user who received the asset - InstrumentID: recipientWalletId, // Required by the db. the instrument which received the asset (wallet usually) + AssetId: p.chain.GasTokenId, // Required by the db. the asset received by the user + UserId: *p.userId, // the user who received the asset + InstrumentId: recipientWalletId, // Required by the db. the instrument which received the asset (wallet usually) } - destinationLeg, err = t.repos.TxLeg.Create(destinationLeg) + destinationLeg, err = t.repos.TxLeg.Create(ctx, destinationLeg) if err != nil { - return p, common.StringError(err) + return p, libcommon.StringError(err) } - txLegUpdates = model.TransactionUpdates{DestinationTxLegID: &destinationLeg.ID} + txLegUpdates = model.TransactionUpdates{DestinationTxLegId: &destinationLeg.Id} - err = t.repos.Transaction.Update(p.transactionModel.ID, txLegUpdates) + err = t.repos.Transaction.Update(ctx, p.transactionModel.Id, txLegUpdates) if err != nil { - return p, common.StringError(err) + return p, libcommon.StringError(err) } if !p.cardAuthorization.Approved { - err := t.unit21CreateTransaction(p.transactionModel.ID) - if err != nil { - return p, common.StringError(err) - } + go t.unit21CreateTransaction(ctx, p.transactionModel.Id) - return p, common.StringError(errors.New("payment: Authorization Declined by Checkout")) + return p, libcommon.StringError(errors.New("payment: Authorization Declined by Checkout")) } return p, nil } -func (t transaction) initiateTransaction(p transactionProcessingData) (transactionProcessingData, error) { - call := ContractCall{ - CxAddr: p.executionRequest.CxAddr, - CxFunc: p.executionRequest.CxFunc, - CxReturn: p.executionRequest.CxReturn, - CxParams: p.executionRequest.CxParams, - TxValue: p.executionRequest.TxValue, - TxGasLimit: p.executionRequest.TxGasLimit, - } - - txID, value, err := (*p.executor).Initiate(call) - p.cumulativeValue = value +func confirmTx(executor Executor, txIds []string) (uint64, error) { + trueGas, err := executor.TxWait(txIds) if err != nil { - return p, common.StringError(err) + return 0, libcommon.StringError(err) } - p.txId = &txID + return trueGas, nil +} - // Create Response Tx leg - eth := common.WeiToEther(value) - wei := floatToFixedString(eth, 18) - usd := floatToFixedString(p.executionRequest.TotalUSD, int(p.processingFeeAsset.Decimals)) - responseLeg := model.TxLeg{ - Timestamp: time.Now(), - Amount: wei, - Value: usd, - AssetID: p.processingFeeAsset.ID, - UserID: *p.userId, - InstrumentID: t.ids.StringWalletId, +// TODO: rewrite this transaction to reference the asset(s) received by the user, not what we paid +func (t transaction) tenderTransaction(ctx context.Context, p transactionProcessingData) (float64, error) { + _, finish := Span(ctx, "service.transaction.tenderTransaction", SpanTag{"platformId": p.platformId}) + defer finish() + + cost := NewCost(t.redis, t.repos) + trueWei := big.NewInt(0).Add(p.cumulativeValue, big.NewInt(int64(p.trueGas))) + trueEth := common.WeiToEther(trueWei) + + // include true token cost in USD + tokenQuantities := []big.Int{} + for _, quantity := range p.transferredTokenQuantities { + tokenQuantities = append(tokenQuantities, *quantity) } - responseLeg, err = t.repos.TxLeg.Create(responseLeg) - if err != nil { - return p, common.StringError(err) + trueEstimation := EstimationParams{ + ChainId: p.chain.ChainId, + CostETH: *p.cumulativeValue, + UseBuffer: false, + GasUsedWei: p.trueGas, + CostTokens: tokenQuantities, + TokenAddrs: p.transferredTokens, } - txLeg := model.TransactionUpdates{ResponseTxLegID: &responseLeg.ID} - err = t.repos.Transaction.Update(p.transactionModel.ID, txLeg) + presentValue, err := cost.EstimateTransaction(trueEstimation, *p.chain) if err != nil { - return p, common.StringError(err) + return 0, libcommon.StringError(err) } + profit := p.floatEstimate.TotalUSD - presentValue.TotalUSD - status := "Transaction Initiated" - txAmount := p.cumulativeValue.String() - updateDB := &model.TransactionUpdates{Status: &status, TransactionHash: p.txId, TransactionAmount: &txAmount} - err = t.repos.Transaction.Update(p.transactionModel.ID, updateDB) - if err != nil { - return p, common.StringError(err) + assetType := "NFT" + if len(tokenQuantities) > 0 { + assetType = "TOKEN" } - - return p, nil -} - -func confirmTx(executor Executor, txID string) (uint64, error) { - trueGas, err := executor.TxWait(txID) + userWallet, err := t.repos.Instrument.GetWalletByAddr(ctx, p.executionRequest.Quote.TransactionRequest.UserAddress) if err != nil { - return 0, common.StringError(err) + return 0, libcommon.StringError(err) } - return trueGas, nil -} - -// TODO: rewrite this transaction to reference the asset(s) received by the user, not what we paid -func (t transaction) tenderTransaction(p transactionProcessingData) (float64, error) { - cost := NewCost(t.redis) - trueWei := big.NewInt(0).Add(p.cumulativeValue, big.NewInt(int64(*p.trueGas))) - trueEth := common.WeiToEther(trueWei) - trueUSD, err := cost.LookupUSD(p.chain.CoingeckoName, trueEth) + userId := userWallet.UserId + kyc := NewKYC(t.repos) + allowed, level, err := kyc.MeetsRequirements(ctx, userId, assetType, presentValue.TotalUSD) if err != nil { - return 0, common.StringError(err) + return 0, libcommon.StringError(err) + } + if !allowed || int(level) > p.executionRequest.Quote.Level { + MessageTeam("Transaction completed with insufficient KYC: " + p.transactionModel.Id) } - profit := p.executionRequest.Quote.TotalUSD - trueUSD // Create Receive Tx leg - asset, err := t.repos.Asset.GetById(p.chain.GasTokenID) + asset, err := t.repos.Asset.GetById(ctx, p.chain.GasTokenId) if err != nil { - return profit, common.StringError(err) + return profit, libcommon.StringError(err) } wei := floatToFixedString(trueEth, int(asset.Decimals)) - usd := floatToFixedString(p.executionRequest.Quote.TotalUSD, 6) + usd := floatToFixedString(p.floatEstimate.TotalUSD, 6) - txModel, err := t.repos.Transaction.GetById(p.transactionModel.ID) + txModel, err := t.repos.Transaction.GetById(ctx, p.transactionModel.Id) if err != nil { - return profit, common.StringError(err) + return profit, libcommon.StringError(err) } now := time.Now() @@ -685,173 +938,147 @@ func (t transaction) tenderTransaction(p transactionProcessingData) (float64, er Timestamp: &now, // updated based on *when the transaction occured* not time.Now() Amount: &wei, // Should be the amount of the asset received by the user Value: &usd, // The value of the asset received by the user - AssetID: &asset.ID, // the asset received by the user - UserID: p.userId, // the user who received the asset - InstrumentID: p.recipientWalletId, // the instrument which received the asset (wallet usually) + AssetId: &asset.Id, // the asset received by the user + UserId: p.userId, // the user who received the asset + InstrumentId: p.recipientWalletId, // the instrument which received the asset (wallet usually) } // We now update the destination leg instead of creating it - err = t.repos.TxLeg.Update(txModel.DestinationTxLegID, destinationLeg) + err = t.repos.TxLeg.Update(ctx, txModel.DestinationTxLegId, destinationLeg) if err != nil { - return profit, common.StringError(err) + return profit, libcommon.StringError(err) } return profit, nil } -func (t transaction) chargeCard(p transactionProcessingData) error { +func (t transaction) chargeCard(ctx context.Context, p transactionProcessingData) error { + _, finish := Span(ctx, "service.transaction.chargeCard", SpanTag{"platformId": p.platformId}) + defer finish() + p, err := CaptureCharge(p) if err != nil { - return common.StringError(err) + return libcommon.StringError(err) } // Create Receipt Tx leg - usdWei := floatToFixedString(p.executionRequest.Quote.TotalUSD, int(p.processingFeeAsset.Decimals)) + usdWei := floatToFixedString(p.floatEstimate.TotalUSD, int(p.processingFeeAsset.Decimals)) receiptLeg := model.TxLeg{ Timestamp: time.Now(), Amount: usdWei, Value: usdWei, - AssetID: p.processingFeeAsset.ID, - UserID: t.ids.StringUserId, - InstrumentID: t.ids.StringBankId, + AssetId: p.processingFeeAsset.Id, + UserId: t.ids.StringUserId, + InstrumentId: t.ids.StringBankId, } - receiptLeg, err = t.repos.TxLeg.Create(receiptLeg) + receiptLeg, err = t.repos.TxLeg.Create(ctx, receiptLeg) if err != nil { - return common.StringError(err) + return libcommon.StringError(err) } - txLeg := model.TransactionUpdates{ReceiptTxLegID: &receiptLeg.ID, PaymentCode: &p.cardCapture.Accepted.ActionID} - err = t.repos.Transaction.Update(p.transactionModel.ID, txLeg) + txLeg := model.TransactionUpdates{ReceiptTxLegId: &receiptLeg.Id, PaymentCode: &p.PaymentId} + err = t.repos.Transaction.Update(ctx, p.transactionModel.Id, txLeg) if err != nil { - return common.StringError(err) + return libcommon.StringError(err) } return nil } -func (t transaction) sendEmailReceipt(p transactionProcessingData) error { - user, err := t.repos.User.GetById(*p.userId) +func (t transaction) sendEmailReceipt(ctx context.Context, p transactionProcessingData) error { + _, finish := Span(ctx, "service.transaction.sendEmailReceipt", SpanTag{"platformId": p.platformId}) + defer finish() + + user, err := t.repos.User.GetById(ctx, *p.userId) if err != nil { - log.Printf("Error getting user from repo: %s", common.StringError(err)) - return common.StringError(err) + log.Err(err).Msg("Error getting user from repo") + return libcommon.StringError(err) } - contact, err := t.repos.Contact.GetByUserId(user.ID) + + contact, err := t.repos.Contact.GetByUserId(ctx, user.Id) if err != nil { - log.Printf("Error getting user contact from repo: %s", common.StringError(err)) - return common.StringError(err) + log.Err(err).Msg("Error getting user contact from repo") + return libcommon.StringError(err) } + name := user.FirstName // + " " + user.MiddleName + " " + user.LastName if name == "" { name = "User" } - receiptParams := common.ReceiptGenerationParams{ - ReceiptType: "NFT Purchase", // TODO: retrieve dynamically - CustomerName: name, - StringPaymentId: p.transactionModel.ID, - PaymentDescriptor: "String Digital Asset", // TODO: retrieve dynamically - TransactionDate: time.Now().Format(time.RFC1123), - } - receiptBody := [][2]string{ - {"Transaction ID", "" + *p.txId + ""}, - {"Destination Wallet", "" + p.executionRequest.UserAddress + ""}, - {"Payment Descriptor", receiptParams.PaymentDescriptor}, - {"Payment Method", p.cardAuthorization.Issuer + " " + p.cardAuthorization.Last4}, - {"Platform", "String Demo"}, // TODO: retrieve dynamically - {"Item Ordered", "String Fighter NFT"}, // TODO: retrieve dynamically - {"Token ID", "1234"}, // TODO: retrieve dynamically, maybe after building token transfer detection - {"Subtotal", common.FloatToUSDString(p.executionRequest.Quote.BaseUSD + p.executionRequest.Quote.TokenUSD)}, - {"Network Fee:", common.FloatToUSDString(p.executionRequest.Quote.GasUSD)}, - {"Processing Fee", common.FloatToUSDString(p.executionRequest.Quote.ServiceUSD)}, - {"Total Charge", common.FloatToUSDString(p.executionRequest.Quote.TotalUSD)}, - } - err = common.EmailReceipt(contact.Data, receiptParams, receiptBody) - if err != nil { - log.Printf("Error sending email receipt to user: %s", common.StringError(err)) - return common.StringError(err) + + platform, err := t.repos.Platform.GetById(ctx, *p.platformId) + if err != nil { + return libcommon.StringError(err) } - return nil -} -func floatToFixedString(value float64, decimals int) string { - return strconv.FormatUint(uint64(value*(math.Pow10(decimals-1))), 10) -} + transactionRequest := p.executionRequest.Quote.TransactionRequest + estimate := p.floatEstimate -func (t transaction) unit21CreateInstrument(instrument model.Instrument) (err error) { - u21InstrumentRepo := unit21.InstrumentRepo{ - User: t.repos.User, - Device: t.repos.Device, - Location: t.repos.Location, // empty until fingerprint integration + explorers := []string{} + for _, id := range p.txIds { + explorers = append(explorers, p.chain.Explorer+"/tx"+id) } - u21Instrument := unit21.NewInstrument(u21InstrumentRepo) - u21InstrumentId, err := u21Instrument.Create(instrument) - if err != nil { - fmt.Printf("Error creating new instrument in Unit21") - return common.StringError(err) + receiptParams := emailer.ReceiptGenerationParams{ + ReceiptType: "NFT Purchase", // TODO: retrieve dynamically + CustomerName: name, + StringPaymentId: p.transactionModel.Id, + PaymentDescriptor: p.executionRequest.Quote.TransactionRequest.AssetName, + TransactionDate: time.Now().Format(time.RFC1123), + TransactionId: p.txIds[0], // For now assume there were 2 and the approval one was removed + TransactionExplorer: explorers[0], // For now assume there were 2 and the approval one was removed + DestinationAddress: transactionRequest.UserAddress, + DestinationExplorer: p.chain.Explorer + "/address/" + transactionRequest.UserAddress, + PaymentMethod: p.cardAuthorization.Issuer + " " + p.cardAuthorization.Last4, + Platform: platform.Name, + ItemOrdered: p.executionRequest.Quote.TransactionRequest.AssetName, + TokenId: p.tokenIds, + Subtotal: common.FloatToUSDString(estimate.BaseUSD + estimate.TokenUSD), + NetworkFee: common.FloatToUSDString(estimate.GasUSD), + ProcessingFee: common.FloatToUSDString(estimate.ServiceUSD), + Total: common.FloatToUSDString(estimate.TotalUSD), } - // Log create instrument action w/ Unit21 - u21ActionRepo := unit21.ActionRepo{ - User: t.repos.User, - Device: t.repos.Device, - Location: t.repos.Location, // empty until fingerprint integration - } + emailer := emailer.New() - u21Action := unit21.NewAction(u21ActionRepo) - _, err = u21Action.Create(instrument, "Creation", u21InstrumentId, "Creation") + err = emailer.SendReceipt(ctx, contact.Data, receiptParams) if err != nil { - fmt.Printf("Error creating a new instrument action in Unit21") - return common.StringError(err) + log.Err(err).Msg("Error sending email receipt to user") + return libcommon.StringError(err) } + return nil +} - return +func floatToFixedString(value float64, decimals int) string { + return strconv.FormatUint(uint64(value*(math.Pow10(decimals))), 10) } -func (t transaction) unit21CreateTransaction(transactionId string) (err error) { - txModel, err := t.repos.Transaction.GetById(transactionId) - if err != nil { - log.Printf("Error getting tx model in Unit21 in Tx Postprocess: %s", common.StringError(err)) - return common.StringError(err) - } +func (t transaction) unit21CreateTransaction(ctx context.Context, transactionId string) (err error) { + _, finish := Span(ctx, "service.transaction.unit21CreateTransaction", SpanTag{"transactionId": transactionId}) + defer finish() - u21Repo := unit21.TransactionRepo{ - TxLeg: t.repos.TxLeg, - User: t.repos.User, - Asset: t.repos.Asset, + txModel, err := t.repos.Transaction.GetById(ctx, transactionId) + if err != nil { + log.Err(err).Msg("Error getting tx model in Unit21 in Tx Postprocess") + return libcommon.StringError(err) } - u21Tx := unit21.NewTransaction(u21Repo) - _, err = u21Tx.Create(txModel) + _, err = t.unit21.Transaction.Create(ctx, txModel) if err != nil { - log.Printf("Error updating Unit21 in Tx Postprocess: %s", common.StringError(err)) - return common.StringError(err) + log.Err(err).Msg("Error updating unit21 in Tx Postprocess") + return libcommon.StringError(err) } return nil } -func (t transaction) unit21Evaluate(transactionId string) (evaluation bool, err error) { - //Check transaction in Unit21 - txModel, err := t.repos.Transaction.GetById(transactionId) - if err != nil { - log.Printf("Error getting tx model in Unit21 in Tx Evaluate: %s", common.StringError(err)) - return evaluation, common.StringError(err) - } - - u21Repo := unit21.TransactionRepo{ - TxLeg: t.repos.TxLeg, - User: t.repos.User, - Asset: t.repos.Asset, - } +func (t transaction) updateTransactionStatus(ctx context.Context, status string, transactionId string) (err error) { + _, finish := Span(ctx, "service.transaction.updateTransactionStatus", SpanTag{"transactionId": transactionId}) + defer finish() - u21Tx := unit21.NewTransaction(u21Repo) - return u21Tx.Evaluate(txModel) -} - -func (t transaction) updateTransactionStatus(status string, transactionId string) (err error) { updateDB := &model.TransactionUpdates{Status: &status} - err = t.repos.Transaction.Update(transactionId, updateDB) + err = t.repos.Transaction.Update(ctx, transactionId, updateDB) if err != nil { - return common.StringError(err) + return libcommon.StringError(err) } return nil @@ -860,3 +1087,34 @@ func (t transaction) updateTransactionStatus(status string, transactionId string func (t *transaction) getStringInstrumentsAndUserId() { t.ids = GetStringIdsFromEnv() } + +func (t transaction) isContractAllowed(ctx context.Context, platformId string, networkId string, request model.TransactionRequest) (isAllowed bool, highestType string, err error) { + _, finish := Span(ctx, "service.transaction.isContractAllowed", SpanTag{"platformId": platformId}) + defer finish() + highestType = "NFT" + for _, action := range request.Actions { + cxAddr := action.CxAddr + contract, err := t.repos.Contract.GetForValidation(ctx, cxAddr, networkId, platformId) + if err != nil && err == serror.NOT_FOUND { + return false, highestType, libcommon.StringError(serror.CONTRACT_NOT_ALLOWED) + } else if err != nil { + return false, highestType, libcommon.StringError(err) + } + + if contract.Type == "TOKEN" || contract.Type == "NFT_AND_TOKEN" { + highestType = "TOKEN" + } + + if len(contract.Functions) == 0 { + continue + } + + for _, function := range contract.Functions { + if function == action.CxFunc { + continue + } + } + } + + return true, highestType, nil +} diff --git a/pkg/service/unit21.go b/pkg/service/unit21.go new file mode 100644 index 00000000..b72aa138 --- /dev/null +++ b/pkg/service/unit21.go @@ -0,0 +1,29 @@ +package service + +import ( + "github.com/String-xyz/string-api/pkg/internal/unit21" + "github.com/String-xyz/string-api/pkg/repository" +) + +type Unit21 struct { + Action unit21.Action + Entity unit21.Entity + Instrument unit21.Instrument + Transaction unit21.Transaction +} + +func NewUnit21(repos repository.Repositories) Unit21 { + action := unit21.NewAction() + entityRepos := unit21.EntityRepos{Device: repos.Device, Contact: repos.Contact, User: repos.User} + entity := unit21.NewEntity(entityRepos) + instrumentRepos := unit21.InstrumentRepos{User: repos.User, Device: repos.Device, Location: repos.Location} + instrument := unit21.NewInstrument(instrumentRepos, action) + transactionRepos := unit21.TransactionRepos{User: repos.User, TxLeg: repos.TxLeg, Asset: repos.Asset, Device: repos.Device} + transaction := unit21.NewTransaction(transactionRepos) + return Unit21{ + Action: action, + Entity: entity, + Instrument: instrument, + Transaction: transaction, + } +} diff --git a/pkg/service/user.go b/pkg/service/user.go index 303c213e..de4d8107 100644 --- a/pkg/service/user.go +++ b/pkg/service/user.go @@ -1,141 +1,158 @@ package service import ( - "os" + "context" + "strings" "time" + libcommon "github.com/String-xyz/go-lib/v2/common" + serror "github.com/String-xyz/go-lib/v2/stringerror" + + "github.com/String-xyz/string-api/config" "github.com/String-xyz/string-api/pkg/internal/common" - "github.com/String-xyz/string-api/pkg/internal/unit21" + "github.com/String-xyz/string-api/pkg/internal/persona" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/repository" - "github.com/lib/pq" - "github.com/pkg/errors" + "github.com/rs/zerolog/log" ) type UserRequest = model.UserRequest type UserUpdates = model.UpdateUserName -type UserCreateResponse struct { - JWT JWT `json:"authToken"` - User model.User `json:"user"` -} - type User interface { - //GetStatus returns the onboarding status of an user - GetStatus(userID string) (model.UserOnboardingStatus, error) + // GetStatus returns the onboarding status of a user + GetStatus(ctx context.Context, userId string) (model.UserOnboardingStatus, error) // Create creates an user from a wallet signed payload // It associates the wallet to the user and also sets its status as verified // This payload usually comes from a previous requested one using (Auth.PayloadToSign) service - Create(request model.WalletSignaturePayloadSigned) (UserCreateResponse, error) + Create(ctx context.Context, request model.WalletSignaturePayloadSigned, platformId string) (resp model.UserLoginResponse, err error) - //Update updates the user firstname lastname middlename. + // Update updates the user's name fields (firstname, lastname, middlename). // It fetches the user using the walletAddress provided - Update(userID string, request UserUpdates) (model.User, error) + Update(ctx context.Context, userId string, platformId string, request UserUpdates) (model.User, error) + + // GetUserByLoginPayload get the user without actually logging them in + GetUserByLoginPayload(ctx context.Context, request model.WalletSignaturePayloadSigned) (user model.User, err error) + + // PreviewEmail returns a partially obfuscated version of the user's email address + PreviewEmail(ctx context.Context, request model.WalletSignaturePayloadSigned) (email model.EmailPreview, err error) + + // RequestDeviceVerification sends verify device email without needing user id + RequestDeviceVerification(ctx context.Context, request model.WalletSignaturePayloadSigned) (err error) + + // GetDeviceStatus checks the status of the device verification + GetDeviceStatus(ctx context.Context, request model.WalletSignaturePayloadSigned) (model.UserOnboardingStatus, error) + + GetPersonaAccountId(ctx context.Context, userId string) (accountId string, err error) } type user struct { - repos repository.Repositories - auth Auth - fingerprint Fingerprint + repos repository.Repositories + auth Auth + fingerprint Fingerprint + device Device + unit21 Unit21 + verification Verification + persona persona.PersonaClient } -func NewUser(repos repository.Repositories, auth Auth, fprint Fingerprint) User { - return &user{repos, auth, fprint} +func NewUser(repos repository.Repositories, auth Auth, fprint Fingerprint, device Device, unit21 Unit21, verificationSrv Verification) User { + persona := persona.New(config.Var.PERSONA_API_KEY) + return &user{repos, auth, fprint, device, unit21, verificationSrv, *persona} } -func (u user) GetStatus(userID string) (model.UserOnboardingStatus, error) { +func (u user) GetStatus(ctx context.Context, userId string) (model.UserOnboardingStatus, error) { + _, finish := Span(ctx, "service.user.GetStatus") + defer finish() + res := model.UserOnboardingStatus{Status: "not found"} - user, err := u.repos.User.GetById(userID) + user, err := u.repos.User.GetById(ctx, userId) if err != nil { - return res, common.StringError(err) + return res, libcommon.StringError(err) } if user.Status != "" { res.Status = user.Status return res, nil } - return res, common.StringError(errors.New("not found")) + return res, libcommon.StringError(serror.NOT_FOUND) } -func (u user) Create(request model.WalletSignaturePayloadSigned) (UserCreateResponse, error) { - resp := UserCreateResponse{} - key := os.Getenv("STRING_ENCRYPTION_KEY") - payload, err := common.Decrypt[model.WalletSignaturePayload](request.Nonce[len(walletAuthenticationPrefix):], key) +func (u user) Create(ctx context.Context, request model.WalletSignaturePayloadSigned, platformId string) (resp model.UserLoginResponse, err error) { + _, finish := Span(ctx, "service.user.Create", SpanTag{"platformId": platformId}) + defer finish() + + // Verify payload integrity + payload, err := verifyWalletAuthentication(request) if err != nil { - return resp, common.StringError(err) + return resp, libcommon.StringError(err) } + // Make sure address is a wallet and not a smart contract addr := payload.Address - if addr == "" { - return resp, common.StringError(errors.New("no wallet address provided")) + if addr == "" || !common.IsWallet(addr) { + return resp, libcommon.StringError(serror.INVALID_DATA) } // Make sure wallet does not already exist - exists, err := u.repos.Instrument.WalletAlreadyExists(addr) + exists, err := u.repos.Instrument.WalletAlreadyExists(ctx, addr) if err != nil { - return resp, common.StringError(err) + return resp, libcommon.StringError(err) } if exists { - return resp, common.StringError(errors.New("wallet already exists")) + return resp, libcommon.StringError(serror.ALREADY_IN_USE) } - // Make sure address is a wallet and not a smart contract - if !common.IsWallet(addr) { - return resp, common.StringError(errors.New("address provided is not a valid wallet")) + user, err := u.createUserData(ctx, addr) + if err != nil { + return resp, err } - // Verify payload integrity - if err := verifyWalletAuthentication(request); err != nil { - return resp, common.StringError(err) + // Associate user to platform + err = u.repos.Platform.AssociateUser(ctx, user.Id, platformId) + if err != nil { + return resp, libcommon.StringError(err) } - user, err := u.createUserData(addr) - if err != nil { - return resp, err + // create device only if there is a visitor + device, err := u.device.CreateDeviceIfNeeded(ctx, user.Id, request.Fingerprint.VisitorId, request.Fingerprint.RequestId) + if err != nil && serror.Is(err, serror.NOT_FOUND) { + return resp, libcommon.StringError(err) } - var device model.Device + // Create a user identity for KYC + go u.repos.Identity.Create(ctx, model.Identity{UserId: user.Id}) - // create device only if there is a visitor - visitorID := request.Fingerprint.VisitorID - requestID := request.Fingerprint.RequestID - if visitorID != "" && requestID != "" { - visitor, err := u.fingerprint.GetVisitor(visitorID, requestID) + if device.Fingerprint != "" { + // validate that device on user creation + now := time.Now() + err = u.repos.Device.Update(ctx, device.Id, model.DeviceUpdates{ValidatedAt: &now}) if err == nil { - // if fingerprint successfully retrieved, create device, otherwise continue without device - now := time.Now() - - device, err = u.repos.Device.Create(model.Device{ - Fingerprint: visitorID, - UserID: user.ID, - Type: visitor.Type, - IpAddresses: pq.StringArray{visitor.IPAddress}, - Description: visitor.UserAgent, - LastUsedAt: now, - ValidatedAt: &now, - }) - if err != nil { - return resp, common.StringError(err) - } + log.Err(err).Msg("Failed to verify user device") } } - jwt, err := u.auth.GenerateJWT(user.ID, device) + jwt, err := u.auth.GenerateJWT(user.Id, platformId, device) if err != nil { - return resp, common.StringError(err) + return resp, libcommon.StringError(err) } // deviceService.RegisterNewUserDevice() - go u.createUnit21Entity(user) + // Create a new context since this will run in background + ctx2 := context.Background() + go u.unit21.Entity.Create(ctx2, user) - return UserCreateResponse{JWT: jwt, User: user}, nil + return model.UserLoginResponse{JWT: jwt, User: user}, nil } -func (u user) createUserData(addr string) (model.User, error) { +func (u user) createUserData(ctx context.Context, addr string) (model.User, error) { + _, finish := Span(ctx, "service.user.createUserData") + defer finish() + tx := u.repos.User.MustBegin() u.repos.Instrument.SetTx(tx) u.repos.Device.SetTx(tx) @@ -144,64 +161,244 @@ func (u user) createUserData(addr string) (model.User, error) { // Initialize a new user // Validated status pertains to specific instrument user := model.User{Type: "string-user", Status: "unverified"} - user, err := u.repos.User.Create(user) + user, err := u.repos.User.Create(ctx, user) if err != nil { u.repos.User.Rollback() - return user, common.StringError(err) + return user, libcommon.StringError(err) } + // Create a new wallet instrument and associate it with the new user - instrument := model.Instrument{Type: "Crypto Wallet", Status: "verified", Network: "EVM", PublicKey: addr, UserID: user.ID} - instrument, err = u.repos.Instrument.Create(instrument) + instrument := model.Instrument{Type: "crypto wallet", Status: "verified", Network: "EVM", PublicKey: addr, UserId: user.Id} + instrument, err = u.repos.Instrument.Create(ctx, instrument) if err != nil { u.repos.Instrument.Rollback() - return user, common.StringError(err) + return user, libcommon.StringError(err) } - if err := u.repos.User.Commit(); err != nil { - return user, common.StringError(errors.New("error commiting transaction")) + return user, libcommon.StringError(err) } + // Create a new context since this will run in background + ctx2 := context.Background() + go u.unit21.Instrument.Create(ctx2, instrument) + return user, nil } -func (u user) Update(userID string, request UserUpdates) (model.User, error) { +func (u user) Update(ctx context.Context, userId string, platformId string, request UserUpdates) (model.User, error) { + _, finish := Span(ctx, "service.user.Update") + defer finish() + updates := model.UpdateUserName{FirstName: request.FirstName, MiddleName: request.MiddleName, LastName: request.LastName} - user, err := u.repos.User.Update(userID, updates) + user, err := u.repos.User.Update(ctx, userId, updates) + if err != nil { + return user, libcommon.StringError(err) + } + backgroundCtx := context.Background() + // Create customer on checkout so we can use it when processing payments. + // We need to have their email and name + go u.createCheckoutCustomer(backgroundCtx, userId, platformId) + // Create a new context since this will run in background + ctx2 := context.Background() + go u.unit21.Entity.Update(ctx2, user) + + return user, nil +} + +// createCustomer creates a customer on checkout so we can use it when processing payments +// we are not returning error because we don't want to fail the user update and is also an async process +func (u user) createCheckoutCustomer(ctx context.Context, userId string, platformId string) string { + _, finish := Span(ctx, "service.user.createCheckoutCustomer", SpanTag{"platformId": platformId}) + defer finish() + user, err := u.repos.User.GetWithContact(ctx, userId) if err != nil { - return user, common.StringError(err) + log.Err(err).Msg("Failed to get contact") + return "" } - go u.updateUnit21Entity(user) + customerId, err := createCustomer(user, platformId) + if err != nil { + log.Err(err).Msg("Failed to create customer on user update") + return "" + } + _, err = u.repos.User.Update(ctx, userId, model.UserUpdates{CheckoutId: &customerId}) + if err != nil { + log.Err(err).Msg("Failed to update user with checkout customer id") + } + + return customerId +} + +func (u user) GetUserByLoginPayload(ctx context.Context, request model.WalletSignaturePayloadSigned) (user model.User, err error) { + _, finish := Span(ctx, "service.device.GetUserByLoginPayload") + defer finish() + + // Get wallet address from payload + payload, err := verifyWalletAuthentication(request) + if err != nil { + return user, libcommon.StringError(err) + } + + // Verify there is a user registered to this wallet address + instrument, err := u.repos.Instrument.GetWalletByAddr(ctx, payload.Address) + if err != nil { + return user, libcommon.StringError(err) + } + + user, err = u.repos.User.GetById(ctx, instrument.UserId) + if err != nil { + return user, libcommon.StringError(err) + } return user, nil } -func (u user) createUnit21Entity(user model.User) { - // Createing a User Entity in Unit21 - u21Repo := unit21.EntityRepos{ - Device: u.repos.Device, - Contact: u.repos.Contact, - UserToPlatform: u.repos.UserToPlatform, +func (u user) RequestDeviceVerification(ctx context.Context, request model.WalletSignaturePayloadSigned) error { + _, finish := Span(ctx, "service.user.RequestDeviceVerification") + defer finish() + + user, err := u.GetUserByLoginPayload(ctx, request) + if err != nil { + return libcommon.StringError(err) + } + + user.Email = getValidatedEmailOrEmpty(ctx, u.repos.Contact, user.Id) + + if user.Email == "" { + return libcommon.StringError(serror.NOT_FOUND) + } + + device, err := u.device.CreateDeviceIfNeeded(ctx, user.Id, request.Fingerprint.VisitorId, request.Fingerprint.RequestId) + if err != nil && !strings.Contains(err.Error(), "not found") { + return libcommon.StringError(err) + } + + if !isDeviceValidated(device) { + u.verification.SendDeviceVerification(ctx, user.Id, user.Email, device.Id, device.Description) + } + + return nil +} + +func (u user) GetDeviceStatus(ctx context.Context, request model.WalletSignaturePayloadSigned) (model.UserOnboardingStatus, error) { + _, finish := Span(ctx, "service.user.GetDeviceStatus") + defer finish() + + resp := model.UserOnboardingStatus{Status: "unverified"} + + user, err := u.GetUserByLoginPayload(ctx, request) + if err != nil { + return resp, libcommon.StringError(err) + } + + device, err := u.device.CreateDeviceIfNeeded(ctx, user.Id, request.Fingerprint.VisitorId, request.Fingerprint.RequestId) + if err != nil && !strings.Contains(err.Error(), "not found") { + return resp, libcommon.StringError(err) } - u21Entity := unit21.NewEntity(u21Repo) // TODO: Make it an injected dependency - _, err := u21Entity.Create(user) + if !isDeviceValidated(device) { + return resp, nil + } + + resp.Status = "verified" + + return resp, nil +} + +func (u user) PreviewEmail(ctx context.Context, request model.WalletSignaturePayloadSigned) (email model.EmailPreview, err error) { + _, finish := Span(ctx, "service.user.PreviewEmail") + defer finish() + + user, err := u.GetUserByLoginPayload(ctx, request) if err != nil { - log.Err(err).Msg("Error creating Entity in Unit21") + return email, libcommon.StringError(err) + } + + user.Email = getValidatedEmailOrEmpty(ctx, u.repos.Contact, user.Id) + + if user.Email == "" { + return email, libcommon.StringError(serror.NOT_FOUND) } + + // Partially obfuscate email address + // Ex. an****@g***l.com + // This could possibly be done as a regex, but also readability is important + address := strings.Split(user.Email, "@") + + // Allow for .co.uk, .co.jp, etc to be counted in the extension + domain := strings.SplitN(address[1], ".", 2) + + ext := domain[1] + + // Allow for single character addresses, if those exist + first_name_char := "" + if len(address[0]) >= 2 { + first_name_char = address[0][0:2] + } else if len(address[0]) == 1 { + first_name_char = address[0][0:1] + } + + // If the name is 2 characters or less, add two stars minimum + num_name_stars := len(address[0]) - 2 + if num_name_stars <= 0 { + num_name_stars = 2 + } + + name_stars := strings.Repeat("*", num_name_stars) + + num_domain_stars := len(domain[0]) - 2 + if num_domain_stars <= 0 { + num_domain_stars = 2 + } + + domain_stars := strings.Repeat("*", num_domain_stars) + + first_domain_char := string(domain[0][0]) + last_domain_char := string(domain[0][len(domain[0])-1]) + + obfs_domain := first_domain_char + domain_stars + last_domain_char + "." + ext + + email.Email = first_name_char + name_stars + "@" + obfs_domain + + return email, nil } -func (u user) updateUnit21Entity(user model.User) { - // Createing a User Entity in Unit21 - u21Repo := unit21.EntityRepos{ - Device: u.repos.Device, - Contact: u.repos.Contact, - UserToPlatform: u.repos.UserToPlatform, +func (u user) GetPersonaAccountId(ctx context.Context, userId string) (accountId string, err error) { + _, finish := Span(ctx, "service.user.GetPersonaAccountId") + defer finish() + + identity, err := u.repos.Identity.GetByUserId(ctx, userId) + if err != nil { + return accountId, libcommon.StringError(err) + } + if identity.AccountId != "" { + return identity.AccountId, nil + } + + user, err := u.repos.User.GetById(ctx, userId) + if err != nil { + return accountId, libcommon.StringError(err) + } + + request := persona.AccountCreateRequest{ + Data: persona.AccountCreate{ + Attributes: persona.CommonFields{ + EmailAddress: user.Email, + NameFirst: user.FirstName, + NameLast: user.LastName, + NameMiddle: user.MiddleName, + }, + }, + } + account, err := u.persona.CreateAccount(request) + if err != nil { + return accountId, libcommon.StringError(err) } - u21Entity := unit21.NewEntity(u21Repo) - _, err := u21Entity.Update(user) + identity, err = u.repos.Identity.Update(ctx, identity.Id, model.IdentityUpdates{AccountId: &account.Data.Id}) if err != nil { - log.Err(err).Msg("Error updating Entity in Unit21") + return accountId, libcommon.StringError(err) } + + return account.Data.Id, nil } diff --git a/pkg/service/verification.go b/pkg/service/verification.go index e91059dd..816c7aa5 100644 --- a/pkg/service/verification.go +++ b/pkg/service/verification.go @@ -1,165 +1,220 @@ package service import ( - "fmt" + "context" "net/url" - "os" "time" - "github.com/String-xyz/string-api/pkg/internal/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + serror "github.com/String-xyz/go-lib/v2/stringerror" + "github.com/String-xyz/go-lib/v2/validator" + "github.com/String-xyz/string-api/config" + + "github.com/String-xyz/string-api/pkg/internal/emailer" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/repository" - "github.com/pkg/errors" "github.com/rs/zerolog/log" - "github.com/sendgrid/sendgrid-go" - "github.com/sendgrid/sendgrid-go/helpers/mail" ) type EmailVerification struct { - Timestamp int64 - Email string - UserID string + Timestamp int64 + Email string + UserId string + PlatformId string } type DeviceVerification struct { Timestamp int64 - DeviceID string - UserID string + DeviceId string + UserId string } type Verification interface { // SendEmailVerification sends a link to the provided email for verification purpose, link expires in 15 minutes - SendEmailVerification(userID string, email string) error - + SendEmailVerification(ctx context.Context, platformId string, userId string, email string) error + SendDeviceVerification(ctx context.Context, userId string, email string, deviceId string, deviceDescription string) error // VerifyEmail verifies the provided email and creates a contact - VerifyEmail(encrypted string) error - - SendDeviceVerification(userID, email string, deviceID string, deviceDescription string) error + VerifyEmail(ctx context.Context, platformId string, userId string, email string) error + VerifyEmailWithEncryptedToken(ctx context.Context, encrypted string) error + PreValidateEmail(ctx context.Context, platformId, userId, email string) error } type verification struct { - repos repository.Repositories + repos repository.Repositories + unit21 Unit21 } -func NewVerification(repos repository.Repositories) Verification { - return &verification{repos} +func NewVerification(repos repository.Repositories, unit21 Unit21) Verification { + return &verification{repos, unit21} } -func (v verification) SendEmailVerification(userID, email string) error { - if !validEmail(email) { - return common.StringError(errors.New("missing or invalid email")) +func (v verification) SendEmailVerification(ctx context.Context, platformId string, userId string, email string) error { + _, finish := Span(ctx, "service.verification.SendEmailVerification", SpanTag{"platformId": platformId}) + defer finish() + + if !validator.ValidEmail(email) { + return libcommon.StringError(serror.INVALID_DATA) } - user, err := v.repos.User.GetById(userID) - if err != nil || user.ID != userID { - return common.StringError(errors.New("invalid user")) // JWT expiration will not be hit here + user, err := v.repos.User.GetById(ctx, userId) + if err != nil || user.Id != userId { + return libcommon.StringError(serror.INVALID_DATA) // JWT expiration will not be hit here } - contact, _ := v.repos.Contact.GetByData(email) + contact, _ := v.repos.Contact.GetByData(ctx, email) if contact.Status == "validated" { - return common.StringError(errors.New("email already verified")) + return libcommon.StringError(serror.ALREADY_IN_USE) + } + + platform, err := v.repos.Platform.GetById(ctx, platformId) + if err != nil || platform.Id != platformId { + return libcommon.StringError(serror.INVALID_DATA) } // Encrypt required data to Base64 string and insert it in an email hyperlink - key := os.Getenv("STRING_ENCRYPTION_KEY") - code, err := common.Encrypt(EmailVerification{Timestamp: time.Now().Unix(), Email: email, UserID: userID}, key) + key := config.Var.STRING_ENCRYPTION_KEY + code, err := libcommon.Encrypt(EmailVerification{Timestamp: time.Now().Unix(), Email: email, UserId: userId, PlatformId: platformId}, key) if err != nil { - return common.StringError(err) + return libcommon.StringError(err) } code = url.QueryEscape(code) // make sure special characters are browser friendly - baseURL := common.GetBaseURL() - from := mail.NewEmail("String Authentication", "auth@string.xyz") - subject := "String Email Verification" - to := mail.NewEmail("New String User", email) - textContent := "Click the link below to complete your e-email verification!" - htmlContent := `

Verify Email Now
` - - message := mail.NewSingleEmail(from, subject, to, textContent, htmlContent) - client := sendgrid.NewSendClient(os.Getenv("SENDGRID_API_KEY")) - _, err = client.Send(message) - if err != nil { - return common.StringError(err) - } - // Wait for up to 15 minutes, final timeout TBD - now, lastPolled := time.Now().Unix(), time.Now().Unix() - until := now + (60 * 15) - for now < until { - now = time.Now().Unix() - if now-lastPolled < 3 { - continue // throttle following logic in 3 second interval - } - lastPolled = now - contact, err := v.repos.Contact.GetByData(email) - if err != nil && errors.Cause(err).Error() != "not found" { - return common.StringError(err) - } else if err == nil && contact.Data == email { - // success - // update user status - user, err := v.repos.User.UpdateStatus(userID, "email_verified") - if err != nil { - return common.StringError(errors.New("User email verify error - userID: " + user.ID)) - } + emailer := emailer.New() - return nil - } - } - // timed out - return common.StringError(errors.New("link expired")) + return emailer.SendEmailVerification(ctx, email, code, platform.Name) } -func (v verification) SendDeviceVerification(userID, email, deviceID, deviceDescription string) error { +func (v verification) SendDeviceVerification(ctx context.Context, userId string, email string, deviceId string, deviceDescription string) error { log.Info().Str("email", email) - key := os.Getenv("STRING_ENCRYPTION_KEY") - code, err := common.Encrypt(DeviceVerification{Timestamp: time.Now().Unix(), DeviceID: deviceID, UserID: userID}, key) + + key := config.Var.STRING_ENCRYPTION_KEY + + code, err := libcommon.Encrypt(DeviceVerification{Timestamp: time.Now().Unix(), DeviceId: deviceId, UserId: userId}, key) if err != nil { - return common.StringError(err) + return libcommon.StringError(err) } code = url.QueryEscape(code) - baseURL := common.GetBaseURL() - from := mail.NewEmail("String XYZ", "auth@string.xyz") - subject := "New Device Login Verification" - to := mail.NewEmail("New Device Login", email) - link := baseURL + "verification?type=device&token=" + code + link := config.Var.BASE_URL + "verification?type=device&token=" + code textContent := "We noticed that you attempted to log in from " + deviceDescription + " at " + time.Now().Local().Format(time.RFC1123) + ". Is this you?" - htmlContent := fmt.Sprintf(`
%s
- Yes`, - textContent, link) - message := mail.NewSingleEmail(from, subject, to, "", htmlContent) - client := sendgrid.NewSendClient(os.Getenv("SENDGRID_API_KEY")) - _, err = client.Send(message) + emailer := emailer.New() + return emailer.SendDeviceVerification(ctx, email, link, textContent) +} + +func (v verification) VerifyEmail(ctx context.Context, userId string, email string, platformId string) error { + _, finish := Span(ctx, "services.verification.VerifyEmail", SpanTag{"platformId": platformId}) + defer finish() + + now := time.Now() + // 1. Create contact with email + contact := model.Contact{UserId: userId, Type: "email", Status: "validated", Data: email, ValidatedAt: &now} + contact, err := v.repos.Contact.Create(ctx, contact) + if err != nil { + return libcommon.StringError(err) + } + + // 2. Update user status + user, err := v.repos.User.UpdateStatus(ctx, userId, "email_verified") + if err != nil { + // TODO: Log error errors.New("User email verify error - userId: " + user.Id) + return libcommon.StringError(err) + } + + // 3. Associate contact with platform + err = v.repos.Platform.AssociateContact(ctx, contact.Id, platformId) if err != nil { - log.Err(err).Msg("error sending device validation") - return common.StringError(err) + return libcommon.StringError(err) } + // 4. update user in unit21 + ctx2 := context.Background() // Create a new context since this will run in background + go v.unit21.Entity.Update(ctx2, user) + + // 5. Create user in Checkout + go v.createCheckoutCustomer(ctx2, userId, platformId) + + // 6. Update user identity + go v.updateIdentityEmail(ctx2, userId, now) + return nil } -func (v verification) VerifyEmail(encrypted string) error { - key := os.Getenv("STRING_ENCRYPTION_KEY") - received, err := common.Decrypt[EmailVerification](encrypted, key) +func (v verification) VerifyEmailWithEncryptedToken(ctx context.Context, encrypted string) error { + _, finish := Span(ctx, "services.verification.VerifyEmailWithEncryptedToken") + defer finish() + + key := config.Var.STRING_ENCRYPTION_KEY + + received, err := libcommon.Decrypt[EmailVerification](encrypted, key) if err != nil { - return common.StringError(err) + return libcommon.StringError(err) } // Wait for up to 15 minutes, final timeout TBD now := time.Now() if now.Unix()-received.Timestamp > (60 * 15) { - return common.StringError(errors.New("link expired")) + return libcommon.StringError(serror.EXPIRED) } - contact := model.Contact{UserID: received.UserID, Type: "email", Status: "validated", Data: received.Email, ValidatedAt: &now} - contact, err = v.repos.Contact.Create(contact) + + err = v.VerifyEmail(ctx, received.UserId, received.Email, received.PlatformId) if err != nil { - return common.StringError(err) + return libcommon.StringError(err) } - // update user status - user, err := v.repos.User.UpdateStatus(received.UserID, "email_verified") + return nil +} + +func (v verification) PreValidateEmail(ctx context.Context, platformId, userId, email string) error { + _, finish := Span(ctx, "services.verification.PreValidateEmail", SpanTag{"platformId": platformId}) + defer finish() + + return v.VerifyEmail(ctx, userId, email, platformId) +} + +// TODO: REMOVE DUPLICATE AND UPSERT INTELLIGENTLY +// createCustomer creates a customer on checkout so we can use it when processing payments +// we are not returning error because we don't want to fail the user update and is also an async process +func (v verification) createCheckoutCustomer(ctx context.Context, userId string, platformId string) string { + _, finish := Span(ctx, "service.user.createCheckoutCustomer", SpanTag{"platformId": platformId}) + defer finish() + user, err := v.repos.User.GetWithContact(ctx, userId) + if err != nil { + log.Err(err).Msg("Failed to get contact") + return "" + } + + customerId, err := createCustomer(user, platformId) + if err != nil { + log.Err(err).Msg("Failed to create customer on user update") + return "" + } + _, err = v.repos.User.Update(ctx, userId, model.UserUpdates{CheckoutId: &customerId}) + if err != nil { + log.Err(err).Msg("Failed to update user with checkout customer id") + } + + return customerId +} + +func (v verification) updateIdentityEmail(ctx context.Context, userId string, now time.Time) error { + identity, err := v.repos.Identity.GetByUserId(ctx, userId) + if err != nil { + if serror.Is(err, serror.NOT_FOUND) { + identity, err = v.repos.Identity.Create(ctx, model.Identity{UserId: userId}) + if err != nil { + log.Err(err).Msg("Failed to create identity") + return libcommon.StringError(err) + } + } else { + log.Err(err).Msg("Failed to get identity by user id") + return libcommon.StringError(err) + } + } + identity, err = v.repos.Identity.Update(ctx, identity.Id, model.IdentityUpdates{EmailVerified: &now}) if err != nil { - return common.StringError(errors.New("User email verify error - userID: " + user.ID)) + log.Err(err).Msg("Failed to update identity") + return libcommon.StringError(err) } return nil diff --git a/pkg/service/webhook.go b/pkg/service/webhook.go new file mode 100644 index 00000000..f3b1de59 --- /dev/null +++ b/pkg/service/webhook.go @@ -0,0 +1,92 @@ +package service + +import ( + "encoding/json" + + "github.com/String-xyz/go-lib/v2/common" + "github.com/cockroachdb/errors" + "github.com/rs/zerolog/log" + "golang.org/x/net/context" + + "github.com/String-xyz/string-api/pkg/internal/checkout" +) + +type WebhookType string + +const ( + WebhookTypePersona WebhookType = "persona" + WebhookTypeCheckout WebhookType = "checkout" +) + +type Webhook interface { + Handle(ctx context.Context, data []byte, webhook WebhookType) error +} + +type webhook struct { + person personaWebhook +} + +func NewWebhook() Webhook { + return &webhook{personaWebhook{}} +} + +func (w webhook) Handle(ctx context.Context, data []byte, webhook WebhookType) error { + if webhook == WebhookTypePersona { + return w.person.Handle(ctx, data) + } + event := checkout.WebhookEvent{} + err := json.Unmarshal(data, &event) + if err != nil { + return common.StringError(errors.Newf("error unmarshalling webhook event: %v", err)) + } + return w.processEvent(ctx, event) +} + +func (w webhook) processEvent(ctx context.Context, event checkout.WebhookEvent) error { + switch event.Type { + + case checkout.AuthorizationApprovedEvent: + payload := event.Data.(checkout.AuthorizationApproved) + return w.authorizationApproved(ctx, payload) + + case checkout.AuthorizationDeclinedEvent: + payload := event.Data.(checkout.AuthorizationDeclined) + return w.authorizationDeclined(ctx, payload) + + case checkout.PaymentApprovedEvent: + payload := event.Data.(checkout.PaymentApproved) + return w.paymentApproved(ctx, payload) + + case checkout.PaymentCapturedEvent: + payload := event.Data.(checkout.PaymentCaptured) + return w.paymentCaptured(ctx, payload) + + default: + // only the events above are supported for now. + return common.StringError(errors.Newf("unhandled event type: %v", event.Type)) + } +} + +func (w webhook) authorizationApproved(ctx context.Context, data checkout.AuthorizationApproved) error { + log.Info().Msgf("authorization approved: %v", data) + checkout.PostToSlack(checkout.AuthorizationApprovedEvent) + return nil +} + +func (w webhook) authorizationDeclined(ctx context.Context, data checkout.AuthorizationDeclined) error { + log.Info().Msgf("authorization declined: %v", data) + checkout.PostToSlack(checkout.AuthorizationDeclinedEvent) + return nil +} + +func (w webhook) paymentApproved(ctx context.Context, data checkout.PaymentApproved) error { + log.Info().Msgf("payment approved: %v", data) + checkout.PostToSlack(checkout.PaymentApprovedEvent) + return nil +} + +func (w webhook) paymentCaptured(ctx context.Context, data checkout.PaymentCaptured) error { + log.Info().Msgf("payment captured: %v", data) + checkout.PostToSlack(checkout.PaymentCapturedEvent) + return nil +} diff --git a/pkg/service/webhook_test.go b/pkg/service/webhook_test.go new file mode 100644 index 00000000..ef820d76 --- /dev/null +++ b/pkg/service/webhook_test.go @@ -0,0 +1,43 @@ +package service + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/String-xyz/string-api/pkg/test/data" + "github.com/stretchr/testify/assert" +) + +func TestPaymentAuthorized(t *testing.T) { + json := data.AuhorizationApprovedJSON + err := NewWebhook().Handle(context.Background(), []byte(json), WebhookTypeCheckout) + assert.NoError(t, err) +} + +func TestPaymentDeclined(t *testing.T) { + json := data.AuhorizationDeclinedJSON + err := NewWebhook().Handle(context.Background(), []byte(json), WebhookTypeCheckout) + assert.NoError(t, err) +} + +func TestPaymentCaptured(t *testing.T) { + json := data.PaymentCapturedJSON + err := NewWebhook().Handle(context.Background(), []byte(json), WebhookTypeCheckout) + assert.NoError(t, err) +} + +func TestPaymentApproved(t *testing.T) { + json := data.PaymentApprovedJSON + err := NewWebhook().Handle(context.Background(), []byte(json), WebhookTypeCheckout) + assert.NoError(t, err) +} + +// Helper function to compute the MAC of a given payload and secret +func computeMAC(payload []byte, secret string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(payload) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/pkg/store/pg.go b/pkg/store/pg.go index 98697e69..42e0ecbc 100644 --- a/pkg/store/pg.go +++ b/pkg/store/pg.go @@ -2,13 +2,14 @@ package store import ( "fmt" - "os" - "github.com/String-xyz/string-api/pkg/internal/common" + libcommon "github.com/String-xyz/go-lib/v2/common" "github.com/jmoiron/sqlx" "github.com/lib/pq" sqltrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/database/sql" sqlxtrace "gopkg.in/DataDog/dd-trace-go.v1/contrib/jmoiron/sqlx" + + "github.com/String-xyz/string-api/config" ) var pgDB *sqlx.DB @@ -16,16 +17,16 @@ var DBDriver = "postgres" func strConnection() string { var ( - DBUser = os.Getenv("DB_USERNAME") - DBPassword = os.Getenv("DB_PASSWORD") - DBName = os.Getenv("DB_NAME") - DBHost = os.Getenv("DB_HOST") - DBPort = os.Getenv("DB_PORT") + DBUser = config.Var.DB_USERNAME + DBPassword = config.Var.DB_PASSWORD + DBName = config.Var.DB_NAME + DBHost = config.Var.DB_HOST + DBPort = config.Var.DB_PORT ) var SSLMode string - if common.IsLocalEnv() { + if libcommon.IsLocalEnv() { SSLMode = "disable" } else { SSLMode = "require" @@ -47,7 +48,7 @@ func MustNewPG() *sqlx.DB { if pgDB != nil { return pgDB } - sqltrace.Register(DBDriver, &pq.Driver{}, sqltrace.WithServiceName("string-api")) + sqltrace.Register(DBDriver, &pq.Driver{}, sqltrace.WithServiceName("api")) connection, err := sqlxtrace.Open(DBDriver, strConnection()) if err != nil { panic(err) diff --git a/pkg/store/redis.go b/pkg/store/redis.go index eb24569f..301cf5ee 100644 --- a/pkg/store/redis.go +++ b/pkg/store/redis.go @@ -1,156 +1,17 @@ package store import ( - "context" - "crypto/tls" - "log" - "os" - "time" - - "github.com/String-xyz/string-api/pkg/internal/common" - "github.com/go-redis/redis/v8" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + "github.com/String-xyz/string-api/config" ) -type RedisRepresentable interface { - Ping(ctx context.Context) *redis.StatusCmd - Get(ctx context.Context, key string) *redis.StringCmd - Del(ctx context.Context, keys ...string) *redis.IntCmd - Set(ctx context.Context, key string, value interface{}, duration time.Duration) *redis.StatusCmd - HSet(ctx context.Context, key string, values ...interface{}) *redis.IntCmd - HGetAll(ctx context.Context, key string) *redis.StringStringMapCmd - HLen(ctx context.Context, key string) *redis.IntCmd - HDel(ctx context.Context, key string, fields ...string) *redis.IntCmd -} - -type RedisStore interface { - Get(id string) ([]byte, error) - Set(string, any, time.Duration) error - HSet(string, map[string]interface{}) error - HGetAll(string) (map[string]string, error) - HDel(string, string) int64 - HMLen(string) int64 - Delete(string) error -} - -type redisStore struct { - client RedisRepresentable -} - -const REDIS_NOT_FOUND_ERROR = "redis: nil" - -func redisConf() *tls.Config { - var tlsCf *tls.Config - if !common.IsLocalEnv() { - tlsCf = &tls.Config{ - MinVersion: tls.VersionTLS12, - } - } - - return tlsCf -} - -func redisOptions() *redis.Options { - url := os.Getenv("REDIS_HOST") + ":" + os.Getenv("REDIS_PORT") - var tlsCf *tls.Config - if !common.IsLocalEnv() { - tlsCf = &tls.Config{ - MinVersion: tls.VersionTLS12, - } - } - - op := &redis.Options{ - Addr: url, - TLSConfig: tlsCf, - Password: os.Getenv("REDIS_PASSWORD"), - DB: 0, - } - return op -} - -func cluster() *redis.ClusterClient { - url := os.Getenv("REDIS_HOST") + ":" + os.Getenv("REDIS_PORT") - return redis.NewClusterClient(&redis.ClusterOptions{ - Addrs: []string{url}, - Password: os.Getenv("REDIS_PASSWORD"), - PoolSize: 10, - MinIdleConns: 10, - TLSConfig: redisConf(), - ReadOnly: false, - RouteRandomly: false, - RouteByLatency: false, - }) -} - -func NewRedisStore() RedisStore { - ctx := context.Background() - var client RedisRepresentable - if common.IsLocalEnv() { - client = redis.NewClient(redisOptions()) - } else { - client = cluster() - } - _, err := client.Ping(ctx).Result() - if err != nil { - log.Fatalf("Failed to ping Redis: %v", err) - } - - return &redisStore{ - client: client, - } -} - -func (r redisStore) Delete(id string) error { - ctx := context.Background() - _, err := r.client.Del(ctx, id).Result() - if err != nil { - return common.StringError(err) +func NewRedis() database.RedisStore { + opts := database.RedisConfigOptions{ + Host: config.Var.REDIS_HOST, + Port: config.Var.REDIS_PORT, + Password: config.Var.REDIS_PASSWORD, + ClusterMode: !libcommon.IsLocalEnv(), } - return nil -} - -func (r redisStore) Get(id string) ([]byte, error) { - ctx := context.Background() - bytes, err := r.client.Get(ctx, id).Bytes() - if err != nil { - return nil, common.StringError(err) - } - return bytes, nil -} - -func (r redisStore) Set(id string, value any, expire time.Duration) error { - ctx := context.Background() - if err := r.client.Set(ctx, id, value, expire).Err(); err != nil { - return common.StringError(err) - } - return nil -} - -func (r redisStore) HSet(key string, data map[string]interface{}) error { - ctx := context.Background() - if err := r.client.HSet(ctx, key, data).Err(); err != nil { - return common.StringError(err, "failed to save array to redis") - } - - return nil -} - -func (r redisStore) HGetAll(key string) (map[string]string, error) { - ctx := context.Background() - data, err := r.client.HGetAll(ctx, key).Result() - if err != nil { - return data, common.StringError(err) - } - return data, nil -} - -func (r redisStore) HMLen(key string) int64 { - ctx := context.Background() - data := r.client.HLen(ctx, key) - return data.Val() -} - -func (r redisStore) HDel(key, val string) int64 { - ctx := context.Background() - data := r.client.HDel(ctx, key, val) - return data.Val() + return database.NewRedisStore(opts) } diff --git a/pkg/store/redis_helpers.go b/pkg/store/redis_helpers.go index 54c87be9..96a9d44a 100644 --- a/pkg/store/redis_helpers.go +++ b/pkg/store/redis_helpers.go @@ -5,32 +5,34 @@ import ( "reflect" "time" - "github.com/String-xyz/string-api/pkg/internal/common" + libcommon "github.com/String-xyz/go-lib/v2/common" + "github.com/String-xyz/go-lib/v2/database" + serror "github.com/String-xyz/go-lib/v2/stringerror" "github.com/pkg/errors" ) -func GetObjectFromCache[T any](redis RedisStore, key string) (T, error) { +func GetObjectFromCache[T any](redis database.RedisStore, key string) (T, error) { var result *T = new(T) bytes, err := redis.Get(key) - if err != nil && errors.Cause(err).Error() == "redis: nil" && len(bytes) == 0 { + if err != nil && serror.Is(err, serror.NOT_FOUND) && len(bytes) == 0 { return *result, nil // object doesn't exist yet, create it down the stack } else if err != nil { // Work around the way that redis go api scopes error - return *result, common.StringError(errors.New(err.Error())) + return *result, libcommon.StringError(errors.New(err.Error())) } err = json.Unmarshal(bytes, &result) if err != nil { - return *result, common.StringError(err) + return *result, libcommon.StringError(err) } return *result, nil } -func PutObjectInCache(redis RedisStore, key string, object any, optionalTimeout ...time.Duration) error { +func PutObjectInCache(redis database.RedisStore, key string, object any, optionalTimeout ...time.Duration) error { // Safeguard against missing tags val := reflect.ValueOf(object) for i := 0; i < val.Type().NumField(); i++ { if val.Type().Field(i).Tag.Get("json") == "" { - return common.StringError(errors.New("object missing json tags")) + return libcommon.StringError(errors.New("object missing json tags")) } } @@ -41,13 +43,13 @@ func PutObjectInCache(redis RedisStore, key string, object any, optionalTimeout bytes, err := json.Marshal(object) if err != nil { - return common.StringError(err) + return libcommon.StringError(err) } err = redis.Set(key, bytes, timeout) if err != nil { // Work around the way that redis go API scopes error - return common.StringError(errors.New(err.Error())) + return libcommon.StringError(errors.New(err.Error())) } return nil } diff --git a/pkg/test/data/test_data.go b/pkg/test/data/test_data.go new file mode 100644 index 00000000..29a7eb57 --- /dev/null +++ b/pkg/test/data/test_data.go @@ -0,0 +1,507 @@ +package data + +var AuhorizationApprovedJSON = `{ + "id": "evt_az5sblvku4ge3dwpztvyizgcau", + "type": "authorization_approved", + "version": "2.0.0", + "created_on": "2018-04-10T08:13:14Z", + "data": { + "card_id": "crd_fa6psq242dcd6fdn5gifcq1491", + "transaction_id": "trx_y3oqhf46pyzuxjbcn2giaqnb44", + "transaction_type": "purchase", + "transmission_date_time": "2018-04-10T08:12:14Z", + "local_transaction_date_time": "2018-04-10T08:12:14", + "authorization_type": "final_authorization", + "transaction_amount": 1000, + "transaction_currency": "GBP", + "billing_amount": 900, + "billing_currency": "EUR", + "billing_conversion_rate": 0.925643, + "ecb_conversion_rate": 0.9, + "requested_exemption_type": "merchant_initiated_transaction", + "digital_card_information": { + "digital_card_id": "dcr_fa6psq242dcd6fdn5gifcq1491", + "device_type": "phone", + "wallet_type": "apple_pay" + }, + "merchant": { + "merchant_id": "59889", + "name": "Carrefour", + "city": "Paris", + "state": "", + "country": "FRA", + "category_code": "5021" + }, + "transit_information": { + "transaction_type": "authorized_aggregated_split_clearing", + "transportation_mode": "urban_bus" + }, + "point_of_sale_transaction_date": "2023-04-25" + } +}` + +var AuhorizationDeclinedJSON = ` +{ + "id": "evt_az5sblvku4ge3dwpztvyizgcau", + "type": "authorization_declined", + "version": "2.0.0", + "created_on": "2018-04-10T08:13:14Z", + "data": { + "card_id": "crd_fa6psq242dcd6fdn5gifcq1491", + "transaction_id": "trx_y3oqhf46pyzuxjbcn2giaqnb44", + "transaction_type": "purchase", + "transmission_date_time": "2018-04-10T08:12:14Z", + "local_transaction_date_time": "2018-04-10T08:12:14", + "authorization_type": "final_authorization", + "transaction_amount": 1000, + "transaction_currency": "GBP", + "billing_amount": 900, + "billing_currency": "EUR", + "billing_conversion_rate": 0.925643, + "ecb_conversion_rate": 0.9, + "requested_exemption_type": "merchant_initiated_transaction", + "digital_card_information": { + "digital_card_id": "dcr_fa6psq242dcd6fdn5gifcq1491", + "device_type": "phone", + "wallet_type": "apple_pay" + }, + "merchant": { + "merchant_id": "59889", + "name": "Carrefour", + "city": "Paris", + "state": "", + "country": "FRA", + "category_code": "5021" + }, + "decline_reason": "insufficient_funds", + "transit_information": { + "transaction_type": "authorized_aggregated_split_clearing", + "transportation_mode": "urban_bus" + }, + "point_of_sale_transaction_date": "2023-04-25" + } +} +` +var PaymentApprovedJSON = ` +{ + "id": "evt_jyvkfwne6gnenlfm5yuyosi4wa", + "type": "payment_approved", + "version": "1.0.20", + "created_on": "2022-11-14T12:18:54.3211949Z", + "data": { + "id": "pay_m3ncbzghvosu7b2j77hqln4agu", + "action_id": "act_dglxv4ixum3ezijibwc4zaz7ce", + "reference": "test payment", + "amount": 2, + "auth_code": "FPB9A0", + "currency": "GBP", + "payment_type": "Regular", + "processed_on": "2022-11-14T12:18:52.4877348Z", + "processing": { + "acquirer_transaction_id": "123438482318443321234", + "retrieval_reference_number": "123412701234" + }, + "response_code": "10000", + "response_summary": "Approved", + "risk": { + "flagged": false + }, + "3ds": { + "version": "2.2.0", + "challenged": true, + "challenge_indicator": "no_challenge_requested", + "exemption": "none", + "eci": "05", + "cavv": "ABEBASVDkQBBASDCgmMYdQAAAAA=", + "xid": "e6473b22-08ac-492d-a587-2aba9b80df6f", + "downgraded": false, + "enrolled": "Y", + "authentication_response": "Y", + "flow_type": "challenged" + }, + "scheme_id": "482318443327456", + "source": { + "id": "src_ps6ukw5ifuietcr6wthijpzsvy", + "type": "card", + "billing_address": {}, + "expiry_month": 1, + "expiry_year": 2029, + "scheme": "VISA", + "last_4": "1234", + "fingerprint": "71580b426f1d190d29087ff265d8f48df1ad34ede41c27cbff9d23c1a14d1776", + "bin": "123456", + "card_type": "DEBIT", + "card_category": "CONSUMER", + "issuer": "Test Bank", + "issuer_country": "GB", + "product_id": "I", + "product_type": "Visa Infinite", + "avs_check": "I" + }, + "balances": { + "total_authorized": 2, + "total_voided": 0, + "available_to_void": 2, + "total_captured": 0, + "available_to_capture": 2, + "total_refunded": 0, + "available_to_refund": 0 + }, + "event_links": { + "payment": "https://api.checkout.com/payments/pay_m3ncbzghvosu7b2j77hqln4agu", + "payment_actions": "https://api.checkout.com/payments/pay_m3ncbzghvosu7b2j77hqln4agu/actions", + "capture": "https://api.checkout.com/payments/pay_m3ncbzghvosu7b2j77hqln4agu/captures", + "void": "https://api.checkout.com/payments/pay_m3ncbzghvosu7b2j77hqln4agu/voids" + } + }, + "_links": { + "self": { + "href": "https://api.checkout.com/workflows/events/evt_jyvkfwne6gnenlfm5yuyosi4wa" + }, + "subject": { + "href": "https://api.checkout.com/workflows/events/subject/pay_m3ncbzghvosu7b2j77hqln4agu" + }, + "payment": { + "href": "https://api.checkout.com/payments/pay_m3ncbzghvosu7b2j77hqln4agu" + }, + "payment_actions": { + "href": "https://api.checkout.com/payments/pay_m3ncbzghvosu7b2j77hqln4agu/actions" + }, + "capture": { + "href": "https://api.checkout.com/payments/pay_m3ncbzghvosu7b2j77hqln4agu/captures" + }, + "void": { + "href": "https://api.checkout.com/payments/pay_m3ncbzghvosu7b2j77hqln4agu/voids" + } + } +}` + +var PaymentCapturedJSON = `{ + "id": "evt_6aznipgxbuaure3qen5qbzyswy", + "type": "payment_captured", + "version": "1.0.1", + "created_on": "2019-06-07T08:25:22Z", + "data": { + "action_id": "act_gse7gcrhleuedmzhq25n3mhweq", + "response_code": "10000", + "response_summary": "Approved", + "amount": 10000, + "balances": { + "total_authorized": 10000, + "total_voided": 0, + "available_to_void": 0, + "total_captured": 10000, + "available_to_capture": 0, + "total_refunded": 0, + "available_to_refund": 10000 + }, + "metadata": { + "coupon_code": "NY2018", + "partner_id": 123989 + }, + "processing": { + "acquirer_transaction_id": "8137549557", + "acquirer_reference_number": "000220552364" + }, + "id": "pay_waji5li3mqtetnaor77xmow4bq", + "currency": "EUR", + "processed_on": "2019-06-07T08:25:22Z", + "reference": "ORD-5023-4E89" + }, + "_links": { + "self": { + "href": "https://api.checkout.com/events/evt_6aznipgxbuaure3qen5qbzyswy" + }, + "subject": { + "href": "https://api.checkout.com/workflows/events/subject/pay_jlfj2ful7z3u5lbykhy5lzezvm" + } + } +}` + +var PersonAccountJSON = ` +{ + "type": "event", + "id": "evt_Ej9ZbvZGjn11CyudXiHqV7tN", + "attributes": { + "name": "account.created", + "created-at": "2023-07-25T19:59:14.691Z", + "redacted-at": null, + "payload": { + "data": { + "type": "account", + "id": "act_ze7eJgwEHbkx1iiUiu42G2ey", + "attributes": { + "reference-id": null, + "created-at": "2023-07-25T19:59:14.000Z", + "updated-at": "2023-07-25T19:59:14.000Z", + "redacted-at": null, + "fields": { + "name": { + "type": "hash", + "value": { + "first": { + "type": "string", + "value": "Mister" + }, + "middle": { + "type": "string", + "value": null + }, + "last": { + "type": "string", + "value": "Tester" + } + } + }, + "address": { + "type": "hash", + "value": { + "street-1": { + "type": "string", + "value": null + }, + "street-2": { + "type": "string", + "value": null + }, + "subdivision": { + "type": "string", + "value": null + }, + "city": { + "type": "string", + "value": null + }, + "postal-code": { + "type": "string", + "value": null + }, + "country-code": { + "type": "string", + "value": null + } + } + }, + "identification-numbers": { + "type": "array", + "value": [] + }, + "birthdate": { + "type": "date", + "value": null + }, + "phone-number": { + "type": "string", + "value": null + }, + "email-address": { + "type": "string", + "value": null + }, + "selfie-photo": { + "type": "file", + "value": null + } + }, + "name-first": "Mister", + "name-middle": null, + "name-last": "Tester", + "phone-number": null, + "email-address": null, + "address-street-1": null, + "address-street-2": null, + "address-city": null, + "address-subdivision": null, + "address-postal-code": null, + "country-code": null, + "birthdate": null, + "social-security-number": null, + "tags": [], + "identification-numbers": {} + } + } + } + } +} +` +var PersonInquiryJSON = ` +{ + "type": "event", + "id": "evt_gEmTS7n2t3hHe4m2UYykLAym", + "attributes": { + "name": "inquiry.created", + "created-at": "2023-07-25T19:32:25.582Z", + "redacted-at": null, + "payload": { + "data": { + "type": "inquiry", + "id": "inq_bo5P7Ea1grrZc68Rg3mpFi2K", + "attributes": { + "status": "created", + "reference-id": null, + "note": null, + "behaviors": { + "request-spoof-attempts": null, + "user-agent-spoof-attempts": null, + "distraction-events": null, + "hesitation-baseline": null, + "hesitation-count": null, + "hesitation-time": null, + "shortcut-copies": null, + "shortcut-pastes": null, + "autofill-cancels": null, + "autofill-starts": null, + "devtools-open": null, + "completion-time": null, + "hesitation-percentage": null, + "behavior-threat-level": null + }, + "tags": [], + "creator": "API", + "reviewer-comment": null, + "created-at": "2023-07-25T19:32:25.000Z", + "started-at": null, + "completed-at": null, + "failed-at": null, + "marked-for-review-at": null, + "decisioned-at": null, + "expired-at": null, + "redacted-at": null, + "previous-step-name": null, + "next-step-name": "start_biometric_80e902_start", + "name-first": null, + "name-middle": null, + "name-last": null, + "birthdate": null, + "address-street-1": null, + "address-street-2": null, + "address-city": null, + "address-subdivision": null, + "address-subdivision-abbr": null, + "address-postal-code": null, + "address-postal-code-abbr": null, + "social-security-number": null, + "identification-number": null, + "email-address": null, + "phone-number": null, + "fields": { + "phone-number": { + "type": "string", + "value": null + }, + "selected-country-code": { + "type": "string", + "value": "US" + }, + "current-government-id": { + "type": "government_id", + "value": null + }, + "selected-id-class": { + "type": "string", + "value": null + }, + "address-street-1": { + "type": "string", + "value": null + }, + "address-street-2": { + "type": "string", + "value": null + }, + "address-city": { + "type": "string", + "value": null + }, + "address-subdivision": { + "type": "string", + "value": null + }, + "address-postal-code": { + "type": "string", + "value": null + }, + "address-country-code": { + "type": "string", + "value": null + }, + "birthdate": { + "type": "date", + "value": null + }, + "email-address": { + "type": "string", + "value": null + }, + "identification-class": { + "type": "string", + "value": null + }, + "identification-number": { + "type": "string", + "value": null + }, + "name-first": { + "type": "string", + "value": null + }, + "name-middle": { + "type": "string", + "value": null + }, + "name-last": { + "type": "string", + "value": null + }, + "current-selfie": { + "type": "selfie", + "value": null + } + } + }, + "relationships": { + "account": { + "data": { + "type": "account", + "id": "act_ndJNqdhWNi44S4Twf4bqzod1" + } + }, + "template": { + "data": null + }, + "inquiry-template": { + "data": { + "type": "inquiry-template", + "id": "itmpl_z2so7W2bCFHELp2dhxqqQjGy" + } + }, + "inquiry-template-version": { + "data": { + "type": "inquiry-template-version", + "id": "itmplv_CQbgqFNNgwrGe6jAB496VXpd" + } + }, + "reviewer": { + "data": null + }, + "reports": { + "data": [] + }, + "verifications": { + "data": [] + }, + "sessions": { + "data": [] + }, + "documents": { + "data": [] + }, + "selfies": { + "data": [] + } + } + } + } + } +} +` diff --git a/pkg/test/stubs/repository.go b/pkg/test/stubs/repository.go index 2dad2992..bdf06ae2 100644 --- a/pkg/test/stubs/repository.go +++ b/pkg/test/stubs/repository.go @@ -12,26 +12,26 @@ import ( // } // var avax = model.Asset{ -// ID: "1", +// Id: "1", // CreatedAt: time.Now(), // UpdatedAt: time.Now(), // Name: "AVAX", // Description: "Avalanche", // Decimals: 18, // IsCrypto: true, -// NetworkID: sql.NullString{}, +// NetworkId: sql.NullString{}, // ValueOracle: sql.NullString{String: "avalanche-2", Valid: true}, // } // var usd = model.Asset{ -// ID: "2", +// Id: "2", // CreatedAt: time.Now(), // UpdatedAt: time.Now(), // Name: "USD", // Description: "United States Dollar", // Decimals: 6, // IsCrypto: false, -// NetworkID: sql.NullString{}, +// NetworkId: sql.NullString{}, // ValueOracle: sql.NullString{}, // } @@ -59,7 +59,7 @@ import ( // return model.Asset{}, nil // } -// func (Asset) Update(ID string, updates any) error { +// func (Asset) Update(Id string, updates any) error { // return nil // } @@ -70,11 +70,11 @@ func (AuthStrategyRepo) Create(authType repository.AuthType, m model.AuthStrateg return nil } -func (AuthStrategyRepo) CreateAPIKey(entityID string, authType model.AuthType, apiKey string, persistOnly bool) error { +func (AuthStrategyRepo) CreateAPIKey(entityId string, authType model.AuthType, apiKey string, persistOnly bool) error { return nil } -func (AuthStrategyRepo) CreateJWTRefresh(ID string, token string) error { +func (AuthStrategyRepo) CreateJWTRefresh(id string, token string) error { return nil } func (AuthStrategyRepo) Get(string) (model.AuthStrategy, error) { @@ -95,6 +95,6 @@ func (AuthStrategyRepo) List(limit, offset int) ([]model.AuthStrategy, error) { func (AuthStrategyRepo) ListByStatus(limit, offset int, status string) ([]model.AuthStrategy, error) { return []model.AuthStrategy{}, nil } -func (AuthStrategyRepo) UpdateStatus(ID, status string) (model.AuthStrategy, error) { +func (AuthStrategyRepo) UpdateStatus(id, status string) (model.AuthStrategy, error) { return model.AuthStrategy{}, nil } diff --git a/pkg/test/stubs/service.go b/pkg/test/stubs/service.go index 215f1c22..a45abb96 100644 --- a/pkg/test/stubs/service.go +++ b/pkg/test/stubs/service.go @@ -1,6 +1,8 @@ package stubs import ( + "context" + "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/service" ) @@ -14,15 +16,15 @@ func (v *Verification) SetError(e error) { v.Error = e } -func (v Verification) SendEmailVerification(userID string, email string) error { +func (v Verification) SendEmailVerification(ctx context.Context, userId string, email string, platformId string) error { return v.Error } -func (v Verification) VerifyEmail(encrypted string) error { +func (v Verification) VerifyEmail(ctx context.Context, platformId, userId string, deviceId string) error { return v.Error } -func (v Verification) SendDeviceVerification(userID string, deviceID string, deviceDescription string) error { +func (v Verification) SendDeviceVerification(ctx context.Context, userID string, deviceID string, deviceDescription string) error { return v.Error } @@ -30,55 +32,64 @@ func (v Verification) VerifyDevice(encrypted string) error { return v.Error } +func (v Verification) VerifyEmailWithEncryptedToken(ctx context.Context, encrypted string) error { + return v.Error +} + +func (v Verification) PreValidateEmail(ctx context.Context, platformId, userId, email string) error { + return v.Error +} + // User Service Stub type User struct { UserOnboardingStatus model.UserOnboardingStatus - UserCreateResponse service.UserCreateResponse + UserLoginResponse model.UserLoginResponse User model.User Error error } -func (u *User) SetOnboardinStatus(m model.UserOnboardingStatus) { +func (u *User) SetOnboardingStatus(m model.UserOnboardingStatus) { u.UserOnboardingStatus = m } -func (u *User) SetResponse(resp service.UserCreateResponse) { - u.UserCreateResponse = resp +func (u *User) SetResponse(resp model.UserLoginResponse) { + u.UserLoginResponse = resp } func (u *User) SetUser(user model.User) { u.User = user } -func (u User) GetStatus(ID string) (model.UserOnboardingStatus, error) { +func (u User) GetStatus(ctx context.Context, id string) (model.UserOnboardingStatus, error) { return u.UserOnboardingStatus, u.Error } -func (u User) Create(request model.WalletSignaturePayloadSigned) (service.UserCreateResponse, error) { - return u.UserCreateResponse, u.Error +func (u User) Create(ctx context.Context, request model.WalletSignaturePayloadSigned, platformId string) (model.UserLoginResponse, error) { + return u.UserLoginResponse, u.Error } -func (u User) Update(userID string, request service.UserUpdates) (model.User, error) { +func (u User) Update(ctx context.Context, userId string, request service.UserUpdates) (model.User, error) { return u.User, u.Error } // Auth Service Stub type Auth struct { - SignablePayload service.SignablePayload - UserCreateResponse service.UserCreateResponse - JWT service.JWT - Error error + SignablePayload service.SignablePayload + SignatureRequest model.SignatureRequest + UserLoginResponse model.UserLoginResponse + JWT model.JWT + Error error } func (a *Auth) SetWalletSignedPayload(m service.SignablePayload) { a.SignablePayload = m } -func (a *Auth) SetUserCreateResponse(resp service.UserCreateResponse) { - a.UserCreateResponse = resp +func (a *Auth) SetUserCreateResponse(resp model.UserLoginResponse) { + a.UserLoginResponse = resp } -func (a *Auth) SetJWT(jwt service.JWT) { +func (a *Auth) SetJWT(jwt model.JWT) { a.JWT = jwt } @@ -86,22 +97,55 @@ func (a *Auth) SetError(e error) { a.Error = e } -func (a Auth) PayloadToSign(walletAdress string) (service.SignablePayload, error) { - return a.SignablePayload, a.Error +func (a Auth) PayloadToSign(ctx context.Context, walletAdress string) (signatureRequest model.SignatureRequest, err error) { + return a.SignatureRequest, a.Error } -func (a Auth) VerifySignedPayload(model.WalletSignaturePayloadSigned) (service.UserCreateResponse, error) { - return a.UserCreateResponse, a.Error +func (a Auth) VerifySignedPayload(ctx context.Context, signature model.WalletSignaturePayloadSigned, platformId string, bypassDevice bool) (model.UserLoginResponse, error) { + return a.UserLoginResponse, a.Error } -func (a Auth) GenerateJWT(model.Device) (service.JWT, error) { +func (a Auth) GenerateJWT(string, string, ...model.Device) (model.JWT, error) { return a.JWT, a.Error } -func (a Auth) ValidateAPIKey(key string) bool { - return true +func (a Auth) ValidateAPIKeyPublic(ctx context.Context, key string) (string, error) { + return "platform-id", a.Error } -func (a Auth) RefreshToken(token string) (service.JWT, error) { - return a.JWT, a.Error +func (a Auth) ValidateAPIKeySecret(ctx context.Context, key string) (string, error) { + return "platform-id", a.Error +} + +func (a Auth) RefreshToken(ctx context.Context, token string, walletAddress string, platformId string) (model.UserLoginResponse, error) { + return model.UserLoginResponse{}, a.Error +} + +func (a Auth) InvalidateRefreshToken(token string) error { + return a.Error +} + +type Device struct { + Device model.Device + Error error +} + +func (d Device) VerifyDevice(ctx context.Context, encrypted string) error { + return d.Error +} + +func (d Device) UpsertDeviceIP(ctx context.Context, deviceId string, ip string) error { + return d.Error +} + +func (d Device) InvalidateUnknownDevice(ctx context.Context, device model.Device) error { + return d.Error +} + +func (d Device) CreateDeviceIfNeeded(ctx context.Context, userId, visitorId, requestId string) (model.Device, error) { + return d.Device, d.Error +} + +func (d Device) CreateUnknownDevice(ctx context.Context, userId string) (model.Device, error) { + return d.Device, d.Error } diff --git a/script.go b/script.go index a4f8d85c..4d52d996 100644 --- a/script.go +++ b/script.go @@ -12,17 +12,7 @@ func main() { script = os.Args[1] } - if script == "data_seeding" { - dataSeedingArgs := "local" - if len(os.Args) > 2 { - dataSeedingArgs = os.Args[2] - } - if dataSeedingArgs == "local" { - scripts.MockSeeding() - } else { - scripts.DataSeeding() - } - } else if script == "generate_wallet" { + if script == "generate_wallet" { scripts.GenerateWallet() } // etc... } diff --git a/scripts/data_seeding.go b/scripts/data_seeding.go deleted file mode 100644 index 2dd9a52d..00000000 --- a/scripts/data_seeding.go +++ /dev/null @@ -1,400 +0,0 @@ -package scripts - -import ( - "database/sql" - "fmt" - "os" - - "github.com/String-xyz/string-api/api" - "github.com/String-xyz/string-api/pkg/model" - "github.com/String-xyz/string-api/pkg/store" - "github.com/joho/godotenv" - "github.com/rs/zerolog" -) - -// Set this! -const stringPublicAddress = "0x44A4b9E2A69d86BA382a511f845CbF2E31286771" - -func DataSeeding() { - // Initialize repos - godotenv.Load(".env") // removed the err since in cloud this wont be loaded - port := os.Getenv("PORT") - if port == "" { - panic("no port!") - } - - lg := zerolog.New(os.Stdout) - - // Note: This will panic if the env is set to use docker and you run this script from the command line - config := api.APIConfig{ - DB: store.MustNewPG(), - Port: port, - Logger: &lg, - } - - repos := api.NewRepos(config) - // api.Start(config) - - // Write to repos - - // Networks without GasTokenID - networkPolygon, err := repos.Network.Create(model.Network{Name: "Polygon Mainnet", NetworkID: 137, ChainID: 137, GasOracle: "poly", RPCUrl: "https://rpc-mainnet.matic.quiknode.pro", ExplorerUrl: "https://polygonscan.com"}) - if err != nil { - fmt.Printf("%+v", err) - return - } - networkMumbai, err := repos.Network.Create(model.Network{Name: "Mumbai Testnet", NetworkID: 80001, ChainID: 80001, GasOracle: "poly", RPCUrl: "https://matic-mumbai.chainstacklabs.com", ExplorerUrl: "https://mumbai.polygonscan.com"}) - if err != nil { - panic(err) - } - networkGoerli, err := repos.Network.Create(model.Network{Name: "Goerli Testnet", NetworkID: 5, ChainID: 5, GasOracle: "eth", RPCUrl: "https://goerli.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161", ExplorerUrl: "https://goerli.etherscan.io"}) - if err != nil { - panic(err) - } - networkEthereum, err := repos.Network.Create(model.Network{Name: "Ethereum Mainnet", NetworkID: 1, ChainID: 1, GasOracle: "eth", RPCUrl: "https://rpc.ankr.com/eth", ExplorerUrl: "https://etherscan.io"}) - if err != nil { - panic(err) - } - networkFuji, err := repos.Network.Create(model.Network{Name: "Fuji Testnet", NetworkID: 1, ChainID: 43113, GasOracle: "avax", RPCUrl: "https://api.avax-test.network/ext/bc/C/rpc", ExplorerUrl: "https://testnet.snowtrace.io"}) - if err != nil { - panic(err) - } - networkAvalanche, err := repos.Network.Create(model.Network{Name: "Avalanche Mainnet", NetworkID: 1, ChainID: 43114, GasOracle: "avax", RPCUrl: "https://api.avax.network/ext/bc/C/rpc", ExplorerUrl: "https://snowtrace.io"}) - if err != nil { - panic(err) - } - networkNitroGoerli, err := repos.Network.Create(model.Network{Name: "Nitro Goerli Rollup Testnet", NetworkID: 421613, ChainID: 421613, GasOracle: "arb", RPCUrl: "https://goerli-rollup.arbitrum.io/rpc", ExplorerUrl: "https://goerli.arbiscan.io"}) - if err != nil { - panic(err) - } - networkArbitrumNova, err := repos.Network.Create(model.Network{Name: "Arbitrum Nova Mainnet", NetworkID: 42170, ChainID: 42170, GasOracle: "arb", RPCUrl: "https://nova.arbitrum.io/rpc", ExplorerUrl: "https://nova-explorer.arbitrum.io"}) - if err != nil { - panic(err) - } - // Assets - assetAvalanche, err := repos.Asset.Create(model.Asset{Name: "AVAX", Description: "Avalanche", Decimals: 18, IsCrypto: true, NetworkID: nullString(networkAvalanche.ID), ValueOracle: nullString("avalanche-2")}) - if err != nil { - panic(err) - } - assetEthereum, err := repos.Asset.Create(model.Asset{Name: "ETH", Description: "Ethereum", Decimals: 18, IsCrypto: true, NetworkID: nullString(networkEthereum.ID), ValueOracle: nullString("ethereum")}) - if err != nil { - panic(err) - } - assetMatic, err := repos.Asset.Create(model.Asset{Name: "MATIC", Description: "Matic", Decimals: 18, IsCrypto: true, NetworkID: nullString(networkPolygon.ID), ValueOracle: nullString("matic-network")}) - if err != nil { - panic(err) - } - assetGoerliEth, err := repos.Asset.Create(model.Asset{Name: "GOERLIETH", Description: "Goerli Ethereum", Decimals: 18, IsCrypto: true, NetworkID: nullString(networkNitroGoerli.ID), ValueOracle: nullString("ethereum")}) - if err != nil { - panic(err) - } - /*assetUSD*/ - _, err = repos.Asset.Create(model.Asset{Name: "USD", Description: "United States Dollar", Decimals: 6, IsCrypto: false}) - if err != nil { - panic(err) - } - - // Update Networks with GasTokenIDs - err = repos.Network.Update(networkPolygon.ID, model.NetworkUpdates{GasTokenID: &assetMatic.ID}) - if err != nil { - panic(err) - } - err = repos.Network.Update(networkMumbai.ID, model.NetworkUpdates{GasTokenID: &assetMatic.ID}) - if err != nil { - panic(err) - } - err = repos.Network.Update(networkGoerli.ID, model.NetworkUpdates{GasTokenID: &assetEthereum.ID}) - if err != nil { - panic(err) - } - err = repos.Network.Update(networkEthereum.ID, model.NetworkUpdates{GasTokenID: &assetEthereum.ID}) - if err != nil { - panic(err) - } - err = repos.Network.Update(networkFuji.ID, model.NetworkUpdates{GasTokenID: &assetAvalanche.ID}) - if err != nil { - panic(err) - } - err = repos.Network.Update(networkAvalanche.ID, model.NetworkUpdates{GasTokenID: &assetAvalanche.ID}) - if err != nil { - panic(err) - } - err = repos.Network.Update(networkNitroGoerli.ID, model.NetworkUpdates{GasTokenID: &assetGoerliEth.ID}) - if err != nil { - panic(err) - } - err = repos.Network.Update(networkArbitrumNova.ID, model.NetworkUpdates{GasTokenID: &assetEthereum.ID}) - if err != nil { - panic(err) - } - - // String User - userString, err := repos.User.Create(model.User{Type: "Internal", Status: "Internal"}) - if err != nil { - panic(err) - } - - // Set String User ID to what's defined in the ENV - internalId := os.Getenv("STRING_INTERNAL_ID") - if internalId == "" { - panic("STRING_INTERNAL_ID is not set in ENV!") - } - - type UpdateID struct { - ID string `json:"id" db:"id"` - } - - updateId := UpdateID{ID: internalId} - userString, err = repos.User.Update(userString.ID, updateId) - if err != nil { - panic(err) - } - // Instruments, used in TX Legs - /*instrumentDeveloperCard*/ - bankString, err := repos.Instrument.Create(model.Instrument{Type: "Bank Account", Status: "Live", Network: "bankprov", PublicKey: "420481286", UserID: userString.ID}) - if err != nil { - panic(err) - } - - bankId := os.Getenv("STRING_BANK_ID") - if bankId == "" { - panic("STRING_BANK_ID is not set in ENV!") - } - - updateId = UpdateID{ID: bankId} - err = repos.Instrument.Update(bankString.ID, updateId) - if err != nil { - panic(err) - } - - /*instrumentDeveloperWallet*/ - walletString, err := repos.Instrument.Create(model.Instrument{Type: "Crypto Wallet", Status: "Internal", Network: "EVM", PublicKey: stringPublicAddress, UserID: userString.ID}) - if err != nil { - panic(err) - } - - walletId := os.Getenv("STRING_WALLET_ID") - if bankId == "" { - panic("STRING_WALLET_ID is not set in ENV!") - } - - updateId = UpdateID{ID: walletId} - err = repos.Instrument.Update(walletString.ID, updateId) - if err != nil { - panic(err) - } - - // Platforms, placeholder - /*platformDeveloper*/ - placeholderPlatform, err := repos.Platform.Create(model.Platform{Name: "Nintendo", Description: "Fun"}) - - if err != nil { - panic(err) - } - - platformId := os.Getenv("STRING_PLACEHOLDER_PLATFORM_ID") - if bankId == "" { - panic("STRING_PLACEHOLDER_PLATFORM_ID is not set in ENV!") - } - - updateId = UpdateID{ID: platformId} - err = repos.Platform.Update(placeholderPlatform.ID, updateId) - if err != nil { - panic(err) - } -} - -func MockSeeding() { - // Initialize repos - godotenv.Load(".env") // removed the err since in cloud this wont be loaded - port := os.Getenv("PORT") - if port == "" { - panic("no port!") - } - lg := zerolog.New(os.Stdout) - - // Note: This will panic if the env is set to use docker and you run this script from the command line - config := api.APIConfig{ - DB: store.MustNewPG(), - Port: port, - Logger: &lg, - } - - repos := api.NewRepos(config) - // api.Start(config) - - // Write to repos - - // Networks without GasTokenID - networkPolygon, err := repos.Network.Create(model.Network{Name: "Polygon Mainnet", NetworkID: 137, ChainID: 137, GasOracle: "poly", RPCUrl: "https://rpc-mainnet.matic.quiknode.pro", ExplorerUrl: "https://polygonscan.com"}) - if err != nil { - fmt.Printf("%+v", err) - return - } - networkMumbai, err := repos.Network.Create(model.Network{Name: "Mumbai Testnet", NetworkID: 80001, ChainID: 80001, GasOracle: "poly", RPCUrl: "https://matic-mumbai.chainstacklabs.com", ExplorerUrl: "https://mumbai.polygonscan.com"}) - if err != nil { - panic(err) - } - networkGoerli, err := repos.Network.Create(model.Network{Name: "Goerli Testnet", NetworkID: 5, ChainID: 5, GasOracle: "eth", RPCUrl: "https://goerli.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161", ExplorerUrl: "https://goerli.etherscan.io"}) - if err != nil { - panic(err) - } - networkEthereum, err := repos.Network.Create(model.Network{Name: "Ethereum Mainnet", NetworkID: 1, ChainID: 1, GasOracle: "eth", RPCUrl: "https://rpc.ankr.com/eth", ExplorerUrl: "https://etherscan.io"}) - if err != nil { - panic(err) - } - networkFuji, err := repos.Network.Create(model.Network{Name: "Fuji Testnet", NetworkID: 1, ChainID: 43113, GasOracle: "avax", RPCUrl: "https://api.avax-test.network/ext/bc/C/rpc", ExplorerUrl: "https://testnet.snowtrace.io"}) - if err != nil { - panic(err) - } - networkAvalanche, err := repos.Network.Create(model.Network{Name: "Avalanche Mainnet", NetworkID: 1, ChainID: 43114, GasOracle: "avax", RPCUrl: "https://api.avax.network/ext/bc/C/rpc", ExplorerUrl: "https://snowtrace.io"}) - if err != nil { - panic(err) - } - networkNitroGoerli, err := repos.Network.Create(model.Network{Name: "Nitro Goerli Rollup Testnet", NetworkID: 421613, ChainID: 421613, GasOracle: "arb", RPCUrl: "https://goerli-rollup.arbitrum.io/rpc", ExplorerUrl: "https://goerli.arbiscan.io"}) - if err != nil { - panic(err) - } - networkArbitrumNova, err := repos.Network.Create(model.Network{Name: "Arbitrum Nova Mainnet", NetworkID: 42170, ChainID: 42170, GasOracle: "arb", RPCUrl: "https://nova.arbitrum.io/rpc", ExplorerUrl: "https://nova-explorer.arbitrum.io"}) - if err != nil { - panic(err) - } - // Assets - assetAvalanche, err := repos.Asset.Create(model.Asset{Name: "AVAX", Description: "Avalanche", Decimals: 18, IsCrypto: true, NetworkID: nullString(networkAvalanche.ID), ValueOracle: nullString("avalanche-2")}) - if err != nil { - panic(err) - } - assetEthereum, err := repos.Asset.Create(model.Asset{Name: "ETH", Description: "Ethereum", Decimals: 18, IsCrypto: true, NetworkID: nullString(networkEthereum.ID), ValueOracle: nullString("ethereum")}) - if err != nil { - panic(err) - } - assetMatic, err := repos.Asset.Create(model.Asset{Name: "MATIC", Description: "Matic", Decimals: 18, IsCrypto: true, NetworkID: nullString(networkPolygon.ID), ValueOracle: nullString("matic-network")}) - if err != nil { - panic(err) - } - assetGoerliEth, err := repos.Asset.Create(model.Asset{Name: "GOERLIETH", Description: "Goerli Ethereum", Decimals: 18, IsCrypto: true, NetworkID: nullString(networkNitroGoerli.ID), ValueOracle: nullString("ethereum")}) - if err != nil { - panic(err) - } - /*assetUSD*/ - _, err = repos.Asset.Create(model.Asset{Name: "USD", Description: "United States Dollar", Decimals: 6, IsCrypto: false}) - if err != nil { - panic(err) - } - - // Update Networks with GasTokenIDs - err = repos.Network.Update(networkPolygon.ID, model.NetworkUpdates{GasTokenID: &assetMatic.ID}) - if err != nil { - panic(err) - } - err = repos.Network.Update(networkMumbai.ID, model.NetworkUpdates{GasTokenID: &assetMatic.ID}) - if err != nil { - panic(err) - } - err = repos.Network.Update(networkGoerli.ID, model.NetworkUpdates{GasTokenID: &assetEthereum.ID}) - if err != nil { - panic(err) - } - err = repos.Network.Update(networkEthereum.ID, model.NetworkUpdates{GasTokenID: &assetEthereum.ID}) - if err != nil { - panic(err) - } - err = repos.Network.Update(networkFuji.ID, model.NetworkUpdates{GasTokenID: &assetAvalanche.ID}) - if err != nil { - panic(err) - } - err = repos.Network.Update(networkAvalanche.ID, model.NetworkUpdates{GasTokenID: &assetAvalanche.ID}) - if err != nil { - panic(err) - } - err = repos.Network.Update(networkNitroGoerli.ID, model.NetworkUpdates{GasTokenID: &assetGoerliEth.ID}) - if err != nil { - panic(err) - } - err = repos.Network.Update(networkArbitrumNova.ID, model.NetworkUpdates{GasTokenID: &assetEthereum.ID}) - if err != nil { - panic(err) - } - - // String User - userString, err := repos.User.Create(model.User{Type: "Internal", Status: "Internal"}) - if err != nil { - panic(err) - } - - // Set String User ID to what's defined in the ENV - internalId := os.Getenv("STRING_INTERNAL_ID") - if internalId == "" { - panic("STRING_INTERNAL_ID is not set in ENV!") - } - - type UpdateID struct { - ID string `json:"id" db:"id"` - } - - updateId := UpdateID{ID: internalId} - userString, err = repos.User.Update(userString.ID, updateId) - if err != nil { - panic(err) - } - - // Devices, this is used in TX LEG - /*deviceDeveloper*/ - - // Instruments, used in TX Legs - /*instrumentDeveloperCard*/ - bankString, err := repos.Instrument.Create(model.Instrument{Type: "Bank Account", Status: "Live", Network: "bankprov", PublicKey: "420481286", UserID: userString.ID}) - if err != nil { - panic(err) - } - - bankId := os.Getenv("STRING_BANK_ID") - if bankId == "" { - panic("STRING_BANK_ID is not set in ENV!") - } - - updateId = UpdateID{ID: bankId} - err = repos.Instrument.Update(bankString.ID, updateId) - if err != nil { - panic(err) - } - - /*instrumentDeveloperWallet*/ - walletString, err := repos.Instrument.Create(model.Instrument{Type: "Crypto Wallet", Status: "Internal", Network: "EVM", PublicKey: stringPublicAddress, UserID: userString.ID}) - if err != nil { - panic(err) - } - - walletId := os.Getenv("STRING_WALLET_ID") - if bankId == "" { - panic("STRING_WALLET_ID is not set in ENV!") - } - - updateId = UpdateID{ID: walletId} - err = repos.Instrument.Update(walletString.ID, updateId) - if err != nil { - panic(err) - } - - // Platforms, placeholder - /*platformDeveloper*/ - placeholderPlatform, err := repos.Platform.Create(model.Platform{Name: "Nintendo", Description: "Fun"}) - if err != nil { - panic(err) - } - - platformId := os.Getenv("STRING_PLACEHOLDER_PLATFORM_ID") - if bankId == "" { - panic("STRING_PLACEHOLDER_PLATFORM_ID is not set in ENV!") - } - - updateId = UpdateID{ID: platformId} - err = repos.Platform.Update(placeholderPlatform.ID, updateId) - if err != nil { - panic(err) - } -} - -func nullString(str string) sql.NullString { - return sql.NullString{String: str, Valid: true} -} diff --git a/scripts/generate_wallet.go b/scripts/generate_wallet.go index 190c3b00..237851f3 100644 --- a/scripts/generate_wallet.go +++ b/scripts/generate_wallet.go @@ -5,8 +5,8 @@ import ( "crypto/ecdsa" "encoding/base64" "fmt" - "os" + env "github.com/String-xyz/string-api/config" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/aws/aws-sdk-go-v2/service/ssm/types" @@ -15,10 +15,10 @@ import ( "github.com/aws/aws-sdk-go/service/kms" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/crypto" - "github.com/joho/godotenv" "github.com/pkg/errors" ) +// TODO: We could use the go=lib here func StringError(err error, optionalMsg ...string) error { if err == nil { return nil @@ -81,7 +81,7 @@ func PutSSM(name string, value string, overwrite bool) error { return StringError(err) } ssmClient := ssm.NewFromConfig(cfg) - keyId := os.Getenv("AWS_KMS_KEY_ID") + keyId := env.Var.AWS_KMS_KEY_ID input := &ssm.PutParameterInput{ Name: &name, Value: &value, @@ -118,8 +118,6 @@ func GetSSM(name string) (string, error) { } func GenerateWallet() error { - godotenv.Load(".env") // removed the err since in cloud this wont be loaded - preExistingWallet, _ := GetAddress() if preExistingWallet != "" { fmt.Printf("\n WARNING: WALLET CREDENTIALS FOR %+v ARE ALREADY BEING STORED IN SSM. THIS SCRIPT WILL EXIT.", preExistingWallet) @@ -177,7 +175,7 @@ func GetAddress() (string, error) { } func EncryptBytesToKMS(data []byte) (string, error) { - region := os.Getenv("AWS_REGION") + region := env.Var.AWS_REGION session, err := session.NewSession(&aws.Config{ Region: aws.String(region), }) @@ -185,7 +183,7 @@ func EncryptBytesToKMS(data []byte) (string, error) { return "", StringError(err) } kmsService := kms.New(session) - keyId := os.Getenv("AWS_KMS_KEY_ID") + keyId := env.Var.AWS_KMS_KEY_ID result, err := kmsService.Encrypt(&kms.EncryptInput{ KeyId: aws.String(keyId), Plaintext: data,