diff --git a/.env.example b/.env.example index b6905fe4..6e9a8bd3 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,7 @@ JWT_SECRET_KEY=hskdhfjfkdhsgafgwterurorhfh UNIT21_API_KEY= UNIT21_ENV=sandbox2-api UNIT21_ORG_NAME=string +UNIT21_RTR_URL=https://rtr.sandbox2.unit21.com/evaluate TWILIO_ACCOUNT_SID=AC034879a536d54325687e48544403cb4d TWILIO_AUTH_TOKEN= TWILIO_SMS_SID=MG367a4f51ea6f67a28db4d126eefc734f @@ -38,4 +39,5 @@ 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 \ No newline at end of file +STRING_PLACEHOLDER_PLATFORM_ID=00000000-0000-0000-0000-000000000003 +SERVICE_NAME=string_api \ No newline at end of file diff --git a/.github/workflows/dev-deploy.yml b/.github/workflows/dev-deploy.yml new file mode 100644 index 00000000..9665865f --- /dev/null +++ b/.github/workflows/dev-deploy.yml @@ -0,0 +1,58 @@ +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/test.yml b/.github/workflows/test.yml index 122513f9..7c9b380f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,6 @@ on: pull_request: branches: - develop - name: run tests jobs: lint: @@ -17,7 +16,7 @@ jobs: cache: true cache-dependency-path: go.sum - name: Install golangci-lint - run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.47.3 + run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.50.1 - name: Run golangci-lint run: golangci-lint run --version --verbose --out-format=github-actions @@ -56,5 +55,5 @@ jobs: - name: Coveralls uses: coverallsapp/github-action@v1.1.2 with: - github-token: ${{ secrets.github_token }} - path-to-lcov: coverage.lcov \ No newline at end of file + github-token: ${{ secrets.GITHUB_TOKEN }} + path-to-lcov: coverage.lcov diff --git a/README.md b/README.md index 3929e2ce..19f0392b 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,16 @@ -### For Live Reloading: ### -1. install [Air|https://github.com/cosmtrek/air]: `go install github.com/cosmtrek/air@latest` -2. run `air` -3. if you get `zsh: command not found: air` you need to add to PATH: `PATH=$PATH:$(go env GOPATH)/bin` +### To run the APIS: ### +1. Ensure you have the infra repo where the docker compose file is now located +2. run `docker compose -f ../infra/local/docker-compose.yml up` +3. You can also run with the -d flag to keep the process in the background, ie `docker compose -f -d ../infra/local/docker-compose.yml up` -### For migrations: ### -1. install [Goose|https://pressly.github.io/] `brew install goose` -2. Note, this binary is separate from the go package. -3. `goose postgres "host=localhost dbname=string_db user=string_db password=string_password sslmode=disable" down-to 0` +### To get a live terminal output from any repository being run by the infra docker compose: ### +1. Run `docker logs [docker container name] -f` +2. ie `docker logs platform-admin-api -f` +3. You can get the container names using `docker ps` +4. If you don't want the output in real time, you can omit the `-f` flag -### Postgres & Redis - Docker Compose: ***local dev only*** ### -1. To build and start the docker containers for the first time: `docker-compose up --build` -2. To shutdown the docker containers press `ctl + c` +### For migrations: ### +1. This is now handled by the docker compose file ### Docker Issues? 1. If docker is giving you an error when you try to `docker-compose up --build` try the following commands in order: @@ -25,8 +25,10 @@ run `go install` to get dependencies installed ### For local testing: ### 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` ### 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 +When setting up the production env variables, their URL will be: https://api.unit21.com/v1 \ No newline at end of file diff --git a/api/api.go b/api/api.go index 468f346a..8ec6217a 100644 --- a/api/api.go +++ b/api/api.go @@ -101,7 +101,7 @@ func loginRoute(services service.Services, e *echo.Echo) { } func verificationRoute(services service.Services, e *echo.Echo) { - handler := handler.NewVerification(e, services.Verification) + handler := handler.NewVerification(e, services.Verification, services.Device) handler.RegisterRoutes(e.Group("/verification")) } diff --git a/api/config.go b/api/config.go index a783a3fd..9a0d8273 100644 --- a/api/config.go +++ b/api/config.go @@ -10,18 +10,18 @@ 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), - UserPlatform: repository.NewUserPlatform(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), + 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), } } @@ -38,7 +38,11 @@ func NewServices(config APIConfig, repos repository.Repositories) service.Servic verificationRepos := repository.Repositories{Contact: repos.Contact, User: repos.User, Device: repos.Device} verification := service.NewVerification(verificationRepos) - auth := service.NewAuth(repos, fingerprint, verification) + // 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) executor := service.NewExecutor() @@ -61,5 +65,6 @@ func NewServices(config APIConfig, repos repository.Repositories) service.Servic Transaction: transaction, User: user, Verification: verification, + Device: device, } } diff --git a/api/handler/http_error.go b/api/handler/http_error.go index f3fddc95..044aa47d 100644 --- a/api/handler/http_error.go +++ b/api/handler/http_error.go @@ -83,3 +83,17 @@ func Conflict(c echo.Context, message ...string) error { } 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 be08c07a..17098f97 100644 --- a/api/handler/login.go +++ b/api/handler/login.go @@ -1,6 +1,7 @@ package handler import ( + b64 "encoding/base64" "net/http" "strings" @@ -41,7 +42,8 @@ func (l login) NoncePayload(c echo.Context) error { return InternalError(c) } - return c.JSON(http.StatusOK, payload) + encodedNonce := b64.StdEncoding.EncodeToString([]byte(payload.Nonce)) + return c.JSON(http.StatusOK, map[string]string{"nonce": encodedNonce}) } func (l login) VerifySignature(c echo.Context) error { @@ -56,11 +58,23 @@ func (l login) VerifySignature(c echo.Context) error { return InvalidPayloadError(c, err) } - resp, err := l.Service.VerifySignedPayload(body) - if err != nil && strings.Contains(err.Error(), "unknown device") { - return Unprocessable(c) + // base64 decode nonce + decodedNonce, _ := b64.URLEncoding.DecodeString(body.Nonce) + if err != nil { + LogStringError(c, err, "login: verify signature decode nonce") + return BadRequestError(c) } + body.Nonce = string(decodedNonce) + + resp, err := l.Service.VerifySignedPayload(body) if err != nil { + if strings.Contains(err.Error(), "unknown device") { + return Unprocessable(c) + } + if strings.Contains(err.Error(), "invalid email") { + return InvalidEmail(c) + } + LogStringError(c, err, "login: verify signature") return BadRequestError(c, "Invalid Payload") } @@ -93,7 +107,7 @@ func (l login) RefreshToken(c echo.Context) error { return Unauthorized(c) } - jwt, err := l.Service.RefreshToken(cookie.Value, body.WalletAddress) + resp, err := l.Service.RefreshToken(cookie.Value, body.WalletAddress) 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") @@ -102,14 +116,15 @@ func (l login) RefreshToken(c echo.Context) error { LogStringError(c, err, "login: refresh token") return BadRequestError(c, "Invalid or expired token") } + // set auth in cookies - err = SetAuthCookies(c, jwt) + err = SetAuthCookies(c, resp.JWT) if err != nil { LogStringError(c, err, "RefreshToken: unable to set auth cookies") return InternalError(c) } - return c.JSON(http.StatusOK, jwt) + return c.JSON(http.StatusOK, resp) } // logout diff --git a/api/handler/transact.go b/api/handler/transact.go index 0178fb3d..2a4c56c5 100644 --- a/api/handler/transact.go +++ b/api/handler/transact.go @@ -2,6 +2,7 @@ package handler import ( "net/http" + "strings" "github.com/String-xyz/string-api/pkg/model" "github.com/String-xyz/string-api/pkg/service" @@ -29,6 +30,7 @@ func (t transaction) Transact(c echo.Context) error { LogStringError(c, err, "transact: execute bind") return BadRequestError(c) } + SanitizeChecksums(&body.CxAddr, &body.UserAddress) // Sanitize Checksum for body.CxParams? It might look like this: for i := range body.CxParams { @@ -37,10 +39,15 @@ func (t transaction) Transact(c echo.Context) error { 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) + } if err != nil { LogStringError(c, err, "transact: execute") return InternalError(c) } + return c.JSON(http.StatusOK, res) } diff --git a/api/handler/user.go b/api/handler/user.go index ac76b117..aa7f25ab 100644 --- a/api/handler/user.go +++ b/api/handler/user.go @@ -1,6 +1,7 @@ package handler import ( + b64 "encoding/base64" "net/http" "strings" @@ -43,6 +44,14 @@ func (u user) Create(c echo.Context) error { return InvalidPayloadError(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) + } + body.Nonce = string(decodedNonce) + resp, err := u.userService.Create(body) if err != nil { if strings.Contains(err.Error(), "wallet already associated with user") { @@ -108,6 +117,10 @@ func (u user) VerifyEmail(c echo.Context) error { return Conflict(c) } + if strings.Contains(err.Error(), "link expired") { + return LinkExpired(c, "Link expired, please request a new one") + } + LogStringError(c, err, "user: email verification") return InternalError(c, "Unable to send email verification") } diff --git a/api/handler/verification.go b/api/handler/verification.go index 1dc092a5..02f2df34 100644 --- a/api/handler/verification.go +++ b/api/handler/verification.go @@ -19,12 +19,13 @@ type Verification interface { } type verification struct { - service service.Verification - group *echo.Group + service service.Verification + deviceService service.Device + group *echo.Group } -func NewVerification(route *echo.Echo, service service.Verification) Verification { - return &verification{service, nil} +func NewVerification(route *echo.Echo, service service.Verification, deviceService service.Device) Verification { + return &verification{service, deviceService, nil} } func (v verification) VerifyEmail(c echo.Context) error { @@ -39,7 +40,7 @@ func (v verification) VerifyEmail(c echo.Context) error { func (v verification) VerifyDevice(c echo.Context) error { token := c.QueryParam("token") - err := v.service.VerifyDevice(token) + err := v.deviceService.VerifyDevice(token) if err != nil { LogStringError(c, err, "verification: device verification") return BadRequestError(c) diff --git a/dev.Dockerfile b/dev.Dockerfile index 87be470f..77154435 100644 --- a/dev.Dockerfile +++ b/dev.Dockerfile @@ -12,8 +12,7 @@ ADD go.mod go.sum /string_api/ RUN go mod download # install the air tool -RUN curl -fLo install.sh https://raw.githubusercontent.com/cosmtrek/air/master/install.sh \ - && chmod +x install.sh && sh install.sh && cp ./bin/air /bin/air +RUN go install github.com/cosmtrek/air@latest # install goose for db migrations RUN go install github.com/pressly/goose/v3/cmd/goose@latest diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 49edbe1b..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,44 +0,0 @@ -version: '3.8' -services: - string-api: - build: - context: . - dockerfile: dev.Dockerfile - container_name: string-api - ports: - - 5555:5555 - depends_on: - - db - - redis - volumes: - - ./:/string_api - db: - image: postgres:14.1-alpine - restart: always - environment: - - POSTGRES_USER=string_db - - POSTGRES_PASSWORD=string_password - ports: - - '5432:5432' - volumes: - - db:/var/lib/postgresql/data - redis: - image: redis:7.0-alpine - restart: always - ports: - - '6379:6379' - command: redis-server --save 20 1 --loglevel warning --requirepass eYVX7EwVmmxKPCDmwMtyKVge8oLd2t81 - ## We want the Redis data not to persist. - ## Commenting out the lines below is equivalent to run the command FLUSHALL after each restart - ## Uncomment the following lines to persist data - # volumes: - # - redis:/data - -volumes: - db: - driver: local - ## We want the Redis data not to persist. - ## Commenting out the lines below is equivalent to run the command FLUSHALL after each restart - ## Uncomment the following lines to persist data - # redis: - # driver: local diff --git a/entrypoint.sh b/entrypoint.sh index 72b1a1fb..c630ff82 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -6,12 +6,9 @@ export $(grep -v '^#' .env | xargs) # run db migrations echo "----- Running migrations..." cd migrations -cd mocks DB_CONFIG="host=$DB_HOST user=$DB_USERNAME dbname=$DB_NAME sslmode=disable password=$DB_PASSWORD" goose postgres "$DB_CONFIG" reset -cd .. -goose postgres "$DB_CONFIG" reset goose postgres "$DB_CONFIG" up cd .. echo "----- ...Migrations done" diff --git a/infra/dev/alb.tf b/infra/dev/alb.tf index 28cc53b0..be7f4443 100644 --- a/infra/dev/alb.tf +++ b/infra/dev/alb.tf @@ -23,6 +23,18 @@ resource "aws_alb" "alb" { create_before_destroy = true } } + + resource "aws_ssm_parameter" "alb" { + name = "${local.service_name}-alb-arn" + value = aws_alb.alb.arn + type = "String" + } + + resource "aws_ssm_parameter" "alb_dns" { + name = "${local.service_name}-alb-dns" + value = aws_alb.alb.dns_name + type = "String" + } resource "aws_alb_target_group" "ecs_task_target_group" { name = "${local.service_name}-tg" @@ -67,6 +79,12 @@ resource "aws_alb_listener" "alb_https_listener" { } } + resource "aws_ssm_parameter" "alb_listerner" { + name = "${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 diff --git a/migrations/0001_string-user_platform_asset_network.sql b/migrations/0001_string-user_platform_asset_network.sql index 06590b6d..d816e3c3 100644 --- a/migrations/0001_string-user_platform_asset_network.sql +++ b/migrations/0001_string-user_platform_asset_network.sql @@ -10,11 +10,11 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; ------------------------------------------------------------------------- -- +goose StatementBegin CREATE OR REPLACE FUNCTION update_updated_at_column() - RETURNS TRIGGER AS + RETURNS TRIGGER AS $$ BEGIN - NEW.updated_at = now(); - RETURN NEW; + NEW.updated_at = now(); + RETURN NEW; END; $$ language 'plpgsql'; -- +goose StatementEnd @@ -36,9 +36,9 @@ CREATE TABLE string_user ( ); CREATE OR REPLACE TRIGGER update_string_user_updated_at - BEFORE UPDATE - ON string_user - FOR EACH ROW + BEFORE UPDATE + ON string_user + FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); ------------------------------------------------------------------------- @@ -55,9 +55,9 @@ CREATE TABLE platform ( authentication TEXT DEFAULT '' --enum [email, phone, wallet] ); CREATE OR REPLACE TRIGGER update_platform_updated_at - BEFORE UPDATE - ON platform - FOR EACH ROW + BEFORE UPDATE + ON platform + FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); ------------------------------------------------------------------------- @@ -76,9 +76,9 @@ CREATE TABLE network ( 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 + BEFORE UPDATE + ON network + FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); ------------------------------------------------------------------------- @@ -97,9 +97,9 @@ CREATE TABLE asset ( -- We will write sql commands to add/update these in bulk. ); CREATE OR REPLACE TRIGGER update_asset_updated_at - BEFORE UPDATE - ON asset - FOR EACH ROW + 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); @@ -121,7 +121,7 @@ DROP TABLE IF EXISTS network; ------------------------------------------------------------------------- -- PLATFORM ------------------------------------------------------------- -DROP TRIGGER IF EXISTS update_platform_updated_at ON platfom; +DROP TRIGGER IF EXISTS update_platform_updated_at ON platform; DROP TABLE IF EXISTS platform; ------------------------------------------------------------------------- diff --git a/migrations/0002_user-platform_device_contact_location_instrument.sql b/migrations/0002_user-to-platform_device_contact_location_instrument.sql similarity index 96% rename from migrations/0002_user-platform_device_contact_location_instrument.sql rename to migrations/0002_user-to-platform_device_contact_location_instrument.sql index 8d7e009b..4315b060 100644 --- a/migrations/0002_user-platform_device_contact_location_instrument.sql +++ b/migrations/0002_user-to-platform_device_contact_location_instrument.sql @@ -27,9 +27,9 @@ CREATE TABLE device ( ); CREATE OR REPLACE TRIGGER update_device_updated_at - BEFORE UPDATE - ON device - FOR EACH ROW + 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); @@ -49,9 +49,9 @@ CREATE TABLE contact ( ); CREATE OR REPLACE TRIGGER update_contact_updated_at - BEFORE UPDATE - ON contact - FOR EACH ROW + BEFORE UPDATE + ON contact + FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); ------------------------------------------------------------------------- @@ -74,9 +74,9 @@ CREATE TABLE location ( ); CREATE OR REPLACE TRIGGER update_location_updated_at - BEFORE UPDATE - ON location - FOR EACH ROW + BEFORE UPDATE + ON location + FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); ------------------------------------------------------------------------- @@ -97,9 +97,9 @@ CREATE TABLE instrument ( ); CREATE OR REPLACE TRIGGER update_instrument_updated_at - BEFORE UPDATE - ON instrument - FOR EACH ROW + BEFORE UPDATE + ON instrument + FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); ------------------------------------------------------------------------- diff --git a/migrations/0003_contact-platform_device-instrument_tx-leg_transaction.sql b/migrations/0003_contact-to-platform_device-to-instrument_tx-leg_transaction.sql similarity index 98% rename from migrations/0003_contact-platform_device-instrument_tx-leg_transaction.sql rename to migrations/0003_contact-to-platform_device-to-instrument_tx-leg_transaction.sql index a981ef0b..99d8a319 100644 --- a/migrations/0003_contact-platform_device-instrument_tx-leg_transaction.sql +++ b/migrations/0003_contact-to-platform_device-to-instrument_tx-leg_transaction.sql @@ -50,9 +50,9 @@ CREATE TABLE tx_leg ( ); CREATE OR REPLACE TRIGGER update_tx_leg_updated_at - BEFORE UPDATE - ON tx_leg - FOR EACH ROW + BEFORE UPDATE + ON tx_leg + FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); ------------------------------------------------------------------------- @@ -84,9 +84,9 @@ CREATE TABLE transaction ( ); CREATE OR REPLACE TRIGGER update_transaction_updated_at - BEFORE UPDATE - ON transaction - FOR EACH ROW + BEFORE UPDATE + ON transaction + FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); ------------------------------------------------------------------------- diff --git a/migrations/0004_auth_key.sql b/migrations/0004_auth_key.sql index 40bfa569..9295d939 100644 --- a/migrations/0004_auth_key.sql +++ b/migrations/0004_auth_key.sql @@ -11,9 +11,9 @@ CREATE TABLE auth_strategy ( ); CREATE OR REPLACE TRIGGER update_auth_strategy_updated_at - BEFORE UPDATE - ON auth_strategy - FOR EACH ROW + BEFORE UPDATE + ON auth_strategy + FOR EACH ROW EXECUTE PROCEDURE update_updated_at_column(); CREATE INDEX auth_strategy_status_idx ON auth_strategy(status); 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 new file mode 100644 index 00000000..cc1036fa --- /dev/null +++ b/migrations/0005_user-to-platform_contact-to-platform_device-to-instrument_platform.sql @@ -0,0 +1,103 @@ +------------------------------------------------------------------------- +-- +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, + ADD COLUMN description TEXT DEFAULT '', + ADD COLUMN domains TEXT[] DEFAULT NULL, -- define which domains can make calls to API (web-to-API) + ADD COLUMN ip_addresses TEXT[] DEFAULT NULL; -- 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 new file mode 100644 index 00000000..8cc24d81 --- /dev/null +++ b/migrations/0006_platform-member_member-to-platform_member-role_member-to-role_member-invite_apikey.sql @@ -0,0 +1,103 @@ +------------------------------------------------------------------------- +-- +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 NULL, + 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/migrations/mocks/0005_mocks.sql b/migrations/mocks/0005_mocks.sql deleted file mode 100644 index f8f199ca..00000000 --- a/migrations/mocks/0005_mocks.sql +++ /dev/null @@ -1,46 +0,0 @@ -------------------------------------------------------------------------- --- +goose Up -------------------------------------------------------------------------- --- STRING_USER ---------------------------------------------------------- -INSERT INTO string_user (id, created_at, updated_at, type, status, tags, first_name, last_name) -VALUES ('0e837b73-55cf-43ff-9b1e-0d8258eec978', '2022-10-19 00:17:01.837572+00', '2022-10-19 00:17:01.837572+00', 'Developer', 'Developing', '{}', 'Deve', 'Loper'); - -------------------------------------------------------------------------- --- DEVICE --------------------------------------------------------------- -INSERT INTO device (id, created_at, updated_at, last_used_at, validated_at, description, user_id) -VALUES ('073f5a88-9223-4554-a7ce-11d358123a21', '2022-10-19 00:23:10.405595+00', '2022-10-19 00:23:10.405595+00', '2022-10-19 00:17:01.837572+00', '2022-10-19 00:17:01.837572+00', 'Developer Laptop', '0e837b73-55cf-43ff-9b1e-0d8258eec978'); - -------------------------------------------------------------------------- --- INSTRUMENT ----------------------------------------------------------- -INSERT INTO instrument (id, created_at, updated_at, type, status, tags, network, public_key, last_4, user_id) -VALUES ('13438963-f5e7-47c4-a790-ebca3e3bf915', '2022-10-19 00:53:36.538289+00', '2022-10-19 00:53:36.538289+00', 'Credit Card', 'Ephemeral', '{}', 'Mastercard', '', '4242', '0e837b73-55cf-43ff-9b1e-0d8258eec978'), -('ab6a2d66-ad4c-43f4-adf9-c0cd3282492c', '2022-10-19 00:55:26.166175+00', '2022-10-19 00:55:26.166175+00', 'Crypto Wallet', 'Ephemeral', '{}', 'Ethereum', '0x44A4b9E2A69d86BA382a511f845CbF2E31286770', '', '0e837b73-55cf-43ff-9b1e-0d8258eec978'); - -------------------------------------------------------------------------- --- NETWORK -------------------------------------------------------------- -INSERT INTO network (id, created_at, updated_at, name, network_id, chain_id, gas_token_id, gas_oracle, rpc_url, explorer_url) -VALUES ('ea34e526-ec6e-4f2b-89b4-acc08db80d63', '2022-10-14 20:18:09.555645+00', '2022-10-14 20:18:09.555645+00', 'Fuji Testnet', '43113', '43113', '19611d0e-a42f-4cee-a35a-b34eb5c08a7f', 'avax', 'https://api.avax-test.network/ext/bc/C/rpc', 'https://testnet.snowtrace.io'), -('b21d6cd6-5d8a-49a6-bac6-e6323316dc01', '2022-10-14 20:41:39.962327+00', '2022-10-14 20:41:39.962327+00', 'Goerli Testnet', '5', '5', '3ef72571-c2e1-4ca3-991c-0df17cef7535', 'eth', 'https://goerli.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161', 'https://goerli.etherscan.io'), -('6cea71b3-b287-4680-ad9d-e631d0bc84ba', '2022-10-14 20:41:39.962327+00', '2022-10-14 20:41:39.962327+00', 'Polygon Mainnet', '137', '137', 'c06986d8-cc2c-4cdc-9728-16a45698b3e7', 'poly', 'https://rpc-mainnet.matic.quiknode.pro', 'https://polygonscan.com'), -('cd42c066-554c-42ad-994b-48fed371931c', '2022-10-14 20:41:39.962327+00', '2022-10-14 20:41:39.962327+00', 'Avalanche Mainnet', '43114', '43114', '19611d0e-a42f-4cee-a35a-b34eb5c08a7f', 'avax', 'https://api.avax.network/ext/bc/C/rpc', 'https://snowtrace.io'), -('491d46e2-18e0-45ec-8209-faf0ec5d278c', '2022-10-14 20:41:39.962327+00', '2022-10-14 20:41:39.962327+00', 'Mumbai Testnet', '80001', '80001', 'c06986d8-cc2c-4cdc-9728-16a45698b3e7', 'poly', 'https://matic-mumbai.chainstacklabs.com', 'https://mumbai.polygonscan.com/'), -('60a02818-4e7d-4b84-b673-e2376fdbfbf9', '2022-10-14 20:41:39.962327+00', '2022-10-30 01:07:37.237054+00', 'Ethereum Mainnet', '1', '1', '3ef72571-c2e1-4ca3-991c-0df17cef7535', 'eth', 'https://rpc.ankr.com/eth', 'https://etherscan.io/'); - -------------------------------------------------------------------------- --- PLATFORM ------------------------------------------------------------- -INSERT INTO platform (id, created_at, updated_at, type, status, name, api_key, authentication) -VALUES ('54a7e062-4cec-44f3-9d89-99498d0eb6ef', '2022-10-19 00:37:16.965408+00', '2022-10-19 00:37:16.965408+00', 'Game', 'Verified', 'Nintendo', 'developer', 'email'); - -------------------------------------------------------------------------- --- ASSET ------------------------------------------------------------- -INSERT INTO asset (id, created_at, updated_at, name, description, decimals, is_crypto, network_id, value_oracle) -VALUES ('19611d0e-a42f-4cee-a35a-b34eb5c08a7f', '2022-10-14 20:17:06.460812+00', '2022-10-15 02:41:02.270712+00', 'AVAX', 'Avalanche', 18, TRUE, 'cd42c066-554c-42ad-994b-48fed371931c', 'avalanche-2'), -('c06986d8-cc2c-4cdc-9728-16a45698b3e7', '2022-10-14 20:17:06.460812+00', '2022-10-15 02:41:02.270712+00', 'MATIC', 'Matic', 18, TRUE, '6cea71b3-b287-4680-ad9d-e631d0bc84ba', 'matic-network'), -('3ef72571-c2e1-4ca3-991c-0df17cef7535', '2022-10-14 20:17:06.460812+00', '2022-10-15 02:41:02.270712+00', 'ETH', 'Ethereum', 18, TRUE, '60a02818-4e7d-4b84-b673-e2376fdbfbf9', 'ethereum'), -('bc376c3a-6481-49d0-83ef-34ba80937ba8', '2022-10-18 03:59:05.042924+00', '2022-10-18 03:59:05.042924+00', 'USD', 'United States Dollar', 6, FALSE, null, null); - - -------------------------------------------------------------------------- --- +goose Down - --- Can't delete rows due to foreign key constraint \ No newline at end of file diff --git a/pkg/internal/common/sign_test.go b/pkg/internal/common/sign_test.go index 85af266a..30c8be7d 100644 --- a/pkg/internal/common/sign_test.go +++ b/pkg/internal/common/sign_test.go @@ -5,6 +5,8 @@ import ( "fmt" "testing" + b64 "encoding/base64" + "github.com/String-xyz/string-api/pkg/model" "github.com/joho/godotenv" "github.com/stretchr/testify/assert" @@ -14,12 +16,17 @@ func TestSignAndValidateString(t *testing.T) { err := godotenv.Load("../../../.env") assert.NoError(t, err) - obj1 := []byte("Your String Here") + encodedMessage := "Your base64 encoded String Here" + + // decode + decoded, err := b64.URLEncoding.DecodeString(encodedMessage) + assert.NoError(t, err) - obj1Signed, err := EVMSign(obj1, true) + // sign + obj1Signed, err := EVMSign(decoded, true) assert.NoError(t, err) fmt.Printf("\nString Signature: %+v\n", obj1Signed) - valid, err := ValidateEVMSignature(obj1Signed, obj1, true) + valid, err := ValidateEVMSignature(obj1Signed, decoded, true) assert.NoError(t, err) assert.Equal(t, true, valid) } diff --git a/pkg/internal/common/util.go b/pkg/internal/common/util.go index 91b71606..15041d88 100644 --- a/pkg/internal/common/util.go +++ b/pkg/internal/common/util.go @@ -1,9 +1,12 @@ package common import ( + "bytes" "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" + "io/ioutil" "log" "math" "os" @@ -94,3 +97,21 @@ func FloatToUSDString(amount float64) string { 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) + } + + bodyReader := bytes.NewReader(bodyBytes) + + betterBytes, err := ioutil.ReadAll(bodyReader) + betterString = string(betterBytes) + if err != nil { + return betterString, StringError(err) + } + + return +} diff --git a/pkg/internal/unit21/action.go b/pkg/internal/unit21/action.go new file mode 100644 index 00000000..e313fe5f --- /dev/null +++ b/pkg/internal/unit21/action.go @@ -0,0 +1,99 @@ +package unit21 + +import ( + "encoding/json" + "log" + "os" + + "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 Action interface { + Create(instrument model.Instrument, + actionDetails string, + unit21InstrumentId string, + 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 (a action) Create( + instrument model.Instrument, + actionDetails string, + unit21InstrumentId string, + eventSubtype string) (unit21Id string, err error) { + + actionData := actionData{ + ActionType: instrument.Type, + ActionDetails: actionDetails, + EntityId: instrument.UserID, + EntityType: "user", + InstrumentId: instrument.ID, + } + + url := "https://" + os.Getenv("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) + } + + var u21Response *createEventResponse + err = json.Unmarshal(body, &u21Response) + if err != nil { + log.Printf("Reading body failed: %s", err) + return "", common.StringError(err) + } + + log.Printf("Create Action Unit21Id: %s", u21Response.Unit21Id) + + return u21Response.Unit21Id, nil +} + +func mapToUnit21ActionEvent(instrument model.Instrument, actionData actionData, unit21Id string, eventSubtype string) *u21Event { + var instrumentTagArr []string + if instrument.Tags != nil { + for key, value := range instrument.Tags { + instrumentTagArr = append(instrumentTagArr, key+":"+value) + } + } + + jsonBody := &u21Event{ + GeneralData: &eventGeneral{ + EventId: unit21Id, //required + EventType: "action", //required + EventTime: int(instrument.CreatedAt.Unix()), //required + EventSubtype: eventSubtype, //required for RTR + Status: instrument.Status, + Parents: nil, + Tags: instrumentTagArr, + }, + ActionData: &actionData, + DigitalData: nil, + LocationData: nil, + CustomData: nil, + } + + actionBody, err := common.BetterStringify(jsonBody) + if err != nil { + log.Printf("\nError creating action body\n") + return jsonBody + } + log.Printf("\nCreate Action action body: %+v\n", actionBody) + + return jsonBody +} diff --git a/pkg/internal/unit21/base.go b/pkg/internal/unit21/base.go index 35c5663c..1f0e6c89 100644 --- a/pkg/internal/unit21/base.go +++ b/pkg/internal/unit21/base.go @@ -51,8 +51,6 @@ func u21Put(url string, jsonBody any) (body []byte, err error) { return nil, common.StringError(err) } - log.Printf("String of body from response: %s", string(body)) - 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))) @@ -64,8 +62,9 @@ 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") - log.Printf("jsonBody: %+v", jsonBody) + reqBodyBytes, err := json.Marshal(jsonBody) + if err != nil { log.Printf("Could not encode %+v to bytes: %s", jsonBody, err) return nil, common.StringError(err) @@ -95,7 +94,7 @@ func u21Post(url string, jsonBody any) (body []byte, err error) { body, err = ioutil.ReadAll(res.Body) if err != nil { - log.Printf("Error extracting body from %s update request: %s", url, err) + log.Printf("Error extracting body from %s update response: %s", url, err) return nil, common.StringError(err) } diff --git a/pkg/internal/unit21/entity.go b/pkg/internal/unit21/entity.go index 958ba091..e0c3820a 100644 --- a/pkg/internal/unit21/entity.go +++ b/pkg/internal/unit21/entity.go @@ -17,9 +17,9 @@ type Entity interface { } type EntityRepos struct { - Device repository.Device - Contact repository.Contact - UserPlatform repository.UserPlatform + Device repository.Device + Contact repository.Contact + UserToPlatform repository.UserToPlatform } type entity struct { @@ -128,15 +128,13 @@ func (e entity) AddInstruments(entityId string, instrumentIds []string) (err err instruments := make(map[string][]string) instruments["instrument_ids"] = instrumentIds - body, err := u21Put(url, instruments) + _, err = u21Put(url, instruments) if err != nil { log.Printf("Unit21 Entity Add Instruments failed: %s", err) err = common.StringError(err) return } - log.Printf("String of body from response: %s", string(body)) - return } @@ -176,7 +174,7 @@ func (e entity) getEntityDigitalData(userId string) (deviceData entityDigitalDat } func (e entity) getCustomData(userId string) (customData entityCustomData, err error) { - devices, err := e.repo.UserPlatform.ListByUserId(userId, 100, 0) + devices, err := e.repo.UserToPlatform.ListByUserId(userId, 100, 0) if err != nil { log.Printf("Failed to get user platforms: %s", err) err = common.StringError(err) @@ -186,7 +184,6 @@ func (e entity) getCustomData(userId string) (customData entityCustomData, err e for _, platform := range devices { customData.Platforms = append(customData.Platforms, platform.PlatformID) } - log.Printf("deviceData: %s", customData) return } diff --git a/pkg/internal/unit21/entity_test.go b/pkg/internal/unit21/entity_test.go index 8aef5517..7e387e9b 100644 --- a/pkg/internal/unit21/entity_test.go +++ b/pkg/internal/unit21/entity_test.go @@ -1,6 +1,7 @@ package unit21 import ( + "database/sql" "testing" "time" @@ -15,17 +16,30 @@ import ( ) func TestCreateEntity(t *testing.T) { - err := godotenv.Load("../../../.env") + db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) + defer db.Close() - 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) - } + _, u21EntityId, err := createMockUser(mock, sqlxDB) + + assert.NoError(t, err) + assert.Greater(t, len([]rune(u21EntityId)), 0) + + //validate response from Unit21 + //check Unit21 dashboard for new entity added + // TODO: mock call to client once it's manually tested +} + +func TestUpdateEntity(t *testing.T) { + db, mock, sqlxDB, err := initializeTest(t) + assert.NoError(t, err) defer db.Close() - entityId := uuid.NewString() + // create entity to modify + entityId, u21EntityId, err := createMockUser(mock, sqlxDB) + assert.NoError(t, err) + assert.Greater(t, len([]rune(u21EntityId)), 0) + user := model.User{ ID: entityId, CreatedAt: time.Now(), @@ -33,7 +47,7 @@ func TestCreateEntity(t *testing.T) { DeactivatedAt: nil, Type: "User", Status: "Onboarded", - Tags: model.StringMap{"platform": "Activision Blizzard"}, + Tags: nil, FirstName: "Test", MiddleName: "A", LastName: "User", @@ -49,17 +63,18 @@ func TestCreateEntity(t *testing.T) { mockedUserPlatformRow := sqlmock.NewRows([]string{"user_id", "platform_id"}). AddRow(entityId, uuid.NewString()) - mock.ExpectQuery("SELECT * FROM user_platform WHERE user_id = $1 LIMIT $2 OFFSET $3").WithArgs(entityId, 100, 0).WillReturnRows(mockedUserPlatformRow) + mock.ExpectQuery("SELECT * FROM user_to_platform WHERE user_id = $1 LIMIT $2 OFFSET $3").WithArgs(entityId, 100, 0).WillReturnRows(mockedUserPlatformRow) repos := EntityRepos{ - Device: repository.NewDevice(sqlxDB), - Contact: repository.NewContact(sqlxDB), - UserPlatform: repository.NewUserPlatform(sqlxDB), + Device: repository.NewDevice(sqlxDB), + Contact: repository.NewContact(sqlxDB), + UserToPlatform: repository.NewUserToPlatform(sqlxDB), } u21Entity := NewEntity(repos) - u21EntityId, err := u21Entity.Create(user) + // update in u21 + u21EntityId, err = u21Entity.Update(user) assert.NoError(t, err) assert.Greater(t, len([]rune(u21EntityId)), 0) @@ -68,19 +83,41 @@ func TestCreateEntity(t *testing.T) { // TODO: mock call to client once it's manually tested } -func TestUpdateEntity(t *testing.T) { - err := godotenv.Load("../../../.env") +func TestAddInstruments(t *testing.T) { + db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) + defer db.Close() - 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) + // create entity to modify + entityId, u21EntityId, err := createMockUser(mock, sqlxDB) + + assert.NoError(t, err) + assert.Greater(t, len([]rune(u21EntityId)), 0) + + var instrumentIds []string + + // mock new instrumentIds + for i := 1; i <= 10; i++ { + instrumentIds = append(instrumentIds, uuid.NewString()) } - defer db.Close() - // choose an older entity so we can update it - entityId := "d8451ddd-6116-4b62-9072-0e8b63a843f1" + repos := EntityRepos{ + Device: repository.NewDevice(sqlxDB), + Contact: repository.NewContact(sqlxDB), + UserToPlatform: repository.NewUserToPlatform(sqlxDB), + } + + u21Entity := NewEntity(repos) + err = u21Entity.AddInstruments(entityId, instrumentIds) + assert.NoError(t, err) + + //validate response from Unit21 + //check Unit21 dashboard for new entity added + // TODO: mock call to client once it's manually tested +} + +func createMockUser(mock sqlmock.Sqlmock, sqlxDB *sqlx.DB) (entityId string, unit21Id string, err error) { + entityId = uuid.NewString() user := model.User{ ID: entityId, CreatedAt: time.Now(), @@ -88,7 +125,7 @@ func TestUpdateEntity(t *testing.T) { DeactivatedAt: nil, Type: "User", Status: "Onboarded", - Tags: nil, + Tags: model.StringMap{"platform": "Activision Blizzard"}, FirstName: "Test", MiddleName: "A", LastName: "User", @@ -104,57 +141,79 @@ func TestUpdateEntity(t *testing.T) { mockedUserPlatformRow := sqlmock.NewRows([]string{"user_id", "platform_id"}). AddRow(entityId, uuid.NewString()) - mock.ExpectQuery("SELECT * FROM user_platform WHERE user_id = $1 LIMIT $2 OFFSET $3").WithArgs(entityId, 100, 0).WillReturnRows(mockedUserPlatformRow) + mock.ExpectQuery("SELECT * FROM user_to_platform WHERE user_id = $1 LIMIT $2 OFFSET $3").WithArgs(entityId, 100, 0).WillReturnRows(mockedUserPlatformRow) repos := EntityRepos{ - Device: repository.NewDevice(sqlxDB), - Contact: repository.NewContact(sqlxDB), - UserPlatform: repository.NewUserPlatform(sqlxDB), + Device: repository.NewDevice(sqlxDB), + Contact: repository.NewContact(sqlxDB), + UserToPlatform: repository.NewUserToPlatform(sqlxDB), } u21Entity := NewEntity(repos) - // update in u21 - u21EntityId, err := u21Entity.Update(user) - assert.NoError(t, err) - assert.Greater(t, len([]rune(u21EntityId)), 0) + u21EntityId, err := u21Entity.Create(user) - //validate response from Unit21 - //check Unit21 dashboard for new entity added - // TODO: mock call to client once it's manually tested + return entityId, u21EntityId, err } -func TestAddInstruments(t *testing.T) { - err := godotenv.Load("../../../.env") - assert.NoError(t, err) +func createMockInstrumentForUser(userId string, mock sqlmock.Sqlmock, sqlxDB *sqlx.DB) (instrument model.Instrument, unit21Id string, err error) { + instrumentId := uuid.NewString() + locationId := uuid.NewString() - db, _, 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) + 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}, } - defer db.Close() - entityId := "44142758-f015-4f79-a004-e554b0641480" //previous created test user - var instrumentIds []string + 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 new instrumentIds - for i := 1; i <= 10; i++ { - instrumentIds = append(instrumentIds, uuid.NewString()) - } + 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) - repos := EntityRepos{ - Device: repository.NewDevice(sqlxDB), - Contact: repository.NewContact(sqlxDB), - UserPlatform: repository.NewUserPlatform(sqlxDB), + 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) + + 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{ + User: repository.NewUser(sqlxDB), + Device: repository.NewDevice(sqlxDB), + Location: repository.NewLocation(sqlxDB), } - u21Entity := NewEntity(repos) + u21Instrument := NewInstrument(repo) - err = u21Entity.AddInstruments(entityId, instrumentIds) - assert.NoError(t, err) + u21InstrumentId, err := u21Instrument.Create(instrument) - //validate response from Unit21 - //check Unit21 dashboard for new entity added - // TODO: mock call to client once it's manually tested + 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 { + t.Fatalf("error %s was not expected when opening stub db", err) + } + return } diff --git a/pkg/internal/unit21/evaluate_test.go b/pkg/internal/unit21/evaluate_test.go new file mode 100644 index 00000000..fd108ff5 --- /dev/null +++ b/pkg/internal/unit21/evaluate_test.go @@ -0,0 +1,188 @@ +package unit21 + +import ( + "fmt" + "testing" + "time" + + "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/stretchr/testify/assert" +) + +// This transaction should pass +func TestEvaluateTransactionPass(t *testing.T) { + db, mock, sqlxDB, err := initializeTest(t) + assert.NoError(t, err) + defer db.Close() + + userId := uuid.NewString() + 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) + pass, err := evaluateMockTransaction(transaction, sqlxDB) + assert.NoError(t, err) + assert.True(t, pass) +} + +// Entity makes a credit card purchase over $1,500 +func TestEvaluateTransactionAbnormalAmounts(t *testing.T) { + db, mock, sqlxDB, err := initializeTest(t) + assert.NoError(t, err) + defer db.Close() + + userId := uuid.NewString() + transaction := createMockTransactionForUser(userId, "2000000000", sqlxDB) + assetId1 := uuid.NewString() + assetId2 := uuid.NewString() + instrumentId1 := uuid.NewString() + instrumentId2 := uuid.NewString() + mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) + 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) { + 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) +} + +// 10 or more FAILED transactions in a 1 hour span +func TestEvaluateTransactionHighFailedTransactionAmount(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, evaluate, and execute 10 failed transactions + for i := 0; i < 10; i++ { + transaction := createMockTransactionForUser(userId, "2000000000", sqlxDB) + assetId1 := uuid.NewString() + assetId2 := uuid.NewString() + instrumentId1 := uuid.NewString() + instrumentId2 := uuid.NewString() + mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) + pass, err := evaluateMockTransaction(transaction, sqlxDB) + assert.NoError(t, err) + assert.False(t, pass) + transaction.Status = "Failed" + mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) + u21TransactionId, err := executeMockTransactionForUser(transaction, sqlxDB) + assert.NoError(t, err) + assert.Greater(t, len([]rune(u21TransactionId)), 0) + } + + // create and evaluate a transaction that would otherwise pass + 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 onboarded in the last 48 hours and has +// transacted more than 7.5K in the last 90 minutes +func TestEvaluateTransactionNewUserHighSpend(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, evaluate, and execute 6 successful transactions for $1500 + for i := 0; i < 6; i++ { + transaction := createMockTransactionForUser(userId, "1500000000", sqlxDB) + assetId1 := uuid.NewString() + assetId2 := uuid.NewString() + instrumentId1 := uuid.NewString() + instrumentId2 := uuid.NewString() + mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) + pass, err := evaluateMockTransaction(transaction, sqlxDB) + assert.NoError(t, err) + assert.True(t, pass) + mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) + u21TransactionId, err := executeMockTransactionForUser(transaction, sqlxDB) + assert.NoError(t, err) + assert.Greater(t, len([]rune(u21TransactionId)), 0) + } + + // create and evaluate a transaction that would otherwise pass + 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) +} + +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), + } + + u21Transaction := NewTransaction(repo) + + pass, err = u21Transaction.Evaluate(transaction) + + return +} diff --git a/pkg/internal/unit21/instrument.go b/pkg/internal/unit21/instrument.go index ae8cef1f..d4b72c7d 100644 --- a/pkg/internal/unit21/instrument.go +++ b/pkg/internal/unit21/instrument.go @@ -174,7 +174,6 @@ func (i instrument) getInstrumentDigitalData(userId string) (digitalData instrum for _, device := range devices { digitalData.IpAddresses = append(digitalData.IpAddresses, device.IpAddresses...) } - log.Printf("deviceData: %s", digitalData) return } @@ -235,7 +234,5 @@ func mapToUnit21Instrument(instrument model.Instrument, source string, entityDat // Options: &options, } - log.Printf("%+v\n", jsonBody) - return jsonBody } diff --git a/pkg/internal/unit21/instrument_test.go b/pkg/internal/unit21/instrument_test.go index 0367a8a6..73f72062 100644 --- a/pkg/internal/unit21/instrument_test.go +++ b/pkg/internal/unit21/instrument_test.go @@ -9,92 +9,38 @@ import ( "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 TestCreateInstrument(t *testing.T) { - err := godotenv.Load("../../../.env") + db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) - - 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) - } defer db.Close() - instrumentId := uuid.NewString() userId := 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}, - } - - 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 NOT 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 NOT 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) - - 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 NOT NULL").WithArgs(locationId).WillReturnRows(mockedLocationRow) - - repo := InstrumentRepo{ - User: repository.NewUser(sqlxDB), - Device: repository.NewDevice(sqlxDB), - Location: repository.NewLocation(sqlxDB), - } - - u21Instrument := NewInstrument(repo) - - u21InstrumentId, err := u21Instrument.Create(instrument) + _, u21InstrumentId, err := createMockInstrumentForUser(userId, mock, sqlxDB) assert.NoError(t, err) assert.Greater(t, len([]rune(u21InstrumentId)), 0) - //validate response from Unit21 - //check Unit21 dashboard for new instrument added - // TODO: mock call to client once it's manually tested } func TestUpdateInstrument(t *testing.T) { - err := godotenv.Load("../../../.env") + db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) - - 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) - } defer db.Close() - instrumentId := "4c2b64dd-3471-4869-9099-3882882b47cb" userId := uuid.NewString() + + instrument, u21InstrumentId, err := createMockInstrumentForUser(userId, mock, sqlxDB) + assert.NoError(t, err) + assert.Greater(t, len([]rune(u21InstrumentId)), 0) + locationId := uuid.NewString() - instrument := model.Instrument{ - ID: instrumentId, + instrument = model.Instrument{ + ID: instrument.ID, CreatedAt: time.Now(), UpdatedAt: time.Now(), DeactivatedAt: nil, @@ -110,11 +56,11 @@ func TestUpdateInstrument(t *testing.T) { 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 NOT NULL").WithArgs(userId).WillReturnRows(mockedUserRow1) + mock.ExpectQuery("SELECT * FROM string_user WHERE id = $1 AND deactivated_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 NOT NULL").WithArgs(userId).WillReturnRows(mockedUserRow2) + mock.ExpectQuery("SELECT * FROM string_user WHERE id = $1 AND deactivated_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) @@ -122,7 +68,7 @@ func TestUpdateInstrument(t *testing.T) { 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 NOT NULL").WithArgs(locationId).WillReturnRows(mockedLocationRow) + mock.ExpectQuery("SELECT * FROM location WHERE id = $1 AND deactivated_at IS NULL").WithArgs(locationId).WillReturnRows(mockedLocationRow) repo := InstrumentRepo{ User: repository.NewUser(sqlxDB), Device: repository.NewDevice(sqlxDB), @@ -131,7 +77,7 @@ func TestUpdateInstrument(t *testing.T) { u21Instrument := NewInstrument(repo) - u21InstrumentId, err := u21Instrument.Update(instrument) + u21InstrumentId, err = u21Instrument.Update(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 2125a0bc..1858ff82 100644 --- a/pkg/internal/unit21/transaction.go +++ b/pkg/internal/unit21/transaction.go @@ -2,7 +2,6 @@ package unit21 import ( "encoding/json" - "fmt" "log" "os" @@ -37,24 +36,31 @@ func (t transaction) Evaluate(transaction model.Transaction) (pass bool, err err log.Printf("Failed to gather Unit21 transaction source: %s", err) return false, common.StringError(err) } - url := "https://rtr.sandbox2.unit21.com/evaluate" // will need to be hardcoded for production - body, err := u21Post(url, mapToUnit21Event(transaction, transactionData)) + url := os.Getenv("UNIT21_RTR_URL") + if url == "" { + url = "https://rtr.sandbox2.unit21.com/evaluate" + } + + body, err := u21Post(url, mapToUnit21TransactionEvent(transaction, transactionData)) if err != nil { log.Printf("Unit21 Transaction evaluate failed: %s", err) return false, common.StringError(err) } // var u21Response *createEventResponse - var response any + var response evaluateEventResponse err = json.Unmarshal(body, &response) if err != nil { log.Printf("Reading body failed: %s", err) return false, common.StringError(err) } - log.Printf("unit21 evaluate response: %+v", response) - // log.Printf("Unit21Id: %s") + for _, rule := range *response.RuleExecutions { + if rule.Status != "PASS" { + return false, nil + } + } return true, nil } @@ -68,7 +74,7 @@ func (t transaction) Create(transaction model.Transaction) (unit21Id string, err } url := "https://" + os.Getenv("UNIT21_ENV") + ".unit21.com/v1/events/create" - body, err := u21Post(url, mapToUnit21Event(transaction, transactionData)) + body, err := u21Post(url, mapToUnit21TransactionEvent(transaction, transactionData)) if err != nil { log.Printf("Unit21 Transaction create failed: %s", err) return "", common.StringError(err) @@ -95,7 +101,7 @@ func (t transaction) Update(transaction model.Transaction) (unit21Id string, 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, mapToUnit21Event(transaction, transactionData)) + body, err := u21Put(url, mapToUnit21TransactionEvent(transaction, transactionData)) if err != nil { log.Printf("Unit21 Transaction create failed: %s", err) @@ -162,24 +168,30 @@ func (t transaction) getTransactionData(transaction model.Transaction) (txData t err = common.StringError(err) return } - - stringFee, err := common.BigNumberToFloat(transaction.StringFee, 6) - if err != nil { - log.Printf("Failed to convert stringFee: %s", err) - err = common.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) + return + } } - processingFee, err := common.BigNumberToFloat(transaction.ProcessingFee, 6) - if err != nil { - log.Printf("Failed to convert processingFee: %s", err) - err = common.StringError(err) - return + var processingFee float64 + 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) + return + } } - fmt.Printf("senderAsset: %+v\n", senderAsset) - log.Printf("senderAsset.Name: %s", senderAsset.Name) - log.Printf("receiverAsset.Name: %s", receiverAsset.Name) + var exchangeRate float64 + if receiverAmount > 0 { + exchangeRate = senderAmount / receiverAmount + } txData = transactionData{ Amount: amount, @@ -193,7 +205,7 @@ func (t transaction) getTransactionData(transaction model.Transaction) (txData t ReceiverEntityId: receiverData.UserID, ReceiverEntityType: "user", ReceiverInstrumentId: receiverData.InstrumentID, - ExchangeRate: senderAmount / receiverAmount, + ExchangeRate: exchangeRate, TransactionHash: transaction.TransactionHash, USDConversionNotes: "", InternalFee: stringFee, @@ -203,7 +215,7 @@ func (t transaction) getTransactionData(transaction model.Transaction) (txData t return } -func mapToUnit21Event(transaction model.Transaction, transactionData transactionData) *u21Event { +func mapToUnit21TransactionEvent(transaction model.Transaction, transactionData transactionData) *u21Event { var transactionTagArr []string if transaction.Tags != nil { for key, value := range transaction.Tags { @@ -213,10 +225,10 @@ func mapToUnit21Event(transaction model.Transaction, transactionData transaction jsonBody := &u21Event{ GeneralData: &eventGeneral{ - EventId: transaction.ID, - EventType: "transaction", - EventTime: int(transaction.CreatedAt.Unix()), - EventSubtype: "", + EventId: transaction.ID, //required + EventType: "transaction", //required + EventTime: int(transaction.CreatedAt.Unix()), //required + EventSubtype: "credit_card", //required for RTR Status: transaction.Status, Parents: nil, Tags: transactionTagArr, diff --git a/pkg/internal/unit21/transaction_test.go b/pkg/internal/unit21/transaction_test.go index 2563b62f..ed4845d9 100644 --- a/pkg/internal/unit21/transaction_test.go +++ b/pkg/internal/unit21/transaction_test.go @@ -1,6 +1,7 @@ package unit21 import ( + "database/sql" "testing" "time" @@ -9,115 +10,56 @@ import ( "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 TestEvaluateTransaction(t *testing.T) { - err := godotenv.Load("../../../.env") +func TestCreateTransaction(t *testing.T) { + db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) - - 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) - } defer db.Close() - transactionId := uuid.NewString() - OriginTxLegID := uuid.NewString() - DestinationTxLegID := uuid.NewString() - userId1 := uuid.NewString() - userId2 := uuid.NewString() + userId := uuid.NewString() + transaction := createMockTransactionForUser(userId, "1000000", sqlxDB) assetId1 := uuid.NewString() assetId2 := uuid.NewString() - networkId := uuid.NewString() instrumentId1 := uuid.NewString() instrumentId2 := uuid.NewString() - - transaction := model.Transaction{ - ID: transactionId, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Type: "fiat-to-crypto", - Status: "Completed", - Tags: map[string]string{}, - DeviceID: uuid.NewString(), - IPAddress: "187.25.24.128", - PlatformID: uuid.NewString(), - TransactionHash: "", - NetworkID: networkId, - NetworkFee: "100000000", - ContractParams: pq.StringArray{}, - ContractFunc: "mintTo()", - TransactionAmount: "1000000000", - OriginTxLegID: OriginTxLegID, - ReceiptTxLegID: uuid.NewString(), - ResponseTxLegID: uuid.NewString(), - DestinationTxLegID: DestinationTxLegID, - ProcessingFee: "1000000", - ProcessingFeeAsset: uuid.NewString(), - StringFee: "1000000", - } - - mockedTxLegRow1 := sqlmock.NewRows([]string{"id", "timestamp", "amount", "value", "asset_id", "user_id", "instrument_id"}). - AddRow(OriginTxLegID, time.Now(), "1000000", "1000000", assetId1, userId1, instrumentId1) - mock.ExpectQuery("SELECT * FROM tx_leg WHERE id = $1 AND 'deactivated_at' IS NOT NULL").WithArgs(OriginTxLegID).WillReturnRows(mockedTxLegRow1) - - mockedTxLegRow2 := sqlmock.NewRows([]string{"id", "timestamp", "amount", "value", "asset_id", "user_id", "instrument_id"}). - AddRow(DestinationTxLegID, time.Now(), "1", "10000000", assetId2, userId2, instrumentId2) - mock.ExpectQuery("SELECT * FROM tx_leg WHERE id = $1 AND 'deactivated_at' IS NOT NULL").WithArgs(DestinationTxLegID).WillReturnRows(mockedTxLegRow2) - - mockedAssetRow1 := sqlmock.NewRows([]string{"id", "name", "description", "decimals", "is_crypto", "network_id", "value_oracle"}). - AddRow(assetId1, "USD", "fiat USD", 6, false, networkId, "self") - mock.ExpectQuery("SELECT * FROM asset WHERE id = $1 AND 'deactivated_at' IS NOT 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, networkId, "joepegs.com") - mock.ExpectQuery("SELECT * FROM asset WHERE id = $1 AND 'deactivated_at' IS NOT NULL").WithArgs(assetId2).WillReturnRows(mockedAssetRow2) - - repo := TransactionRepo{ - TxLeg: repository.NewTxLeg((sqlxDB)), - User: repository.NewUser(sqlxDB), - Asset: repository.NewAsset(sqlxDB), - } - - u21Transaction := NewTransaction(repo) - - pass, err := u21Transaction.Evaluate(transaction) + mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) + u21TransactionId, err := executeMockTransactionForUser(transaction, sqlxDB) assert.NoError(t, err) - assert.True(t, pass) + assert.Greater(t, len([]rune(u21TransactionId)), 0) //validate response from Unit21 //check Unit21 dashboard for new transaction added // TODO: mock call to client once it's manually tested } -func TestCreateTransaction(t *testing.T) { - err := godotenv.Load("../../../.env") +func TestUpdateTransaction(t *testing.T) { + db, mock, sqlxDB, err := initializeTest(t) assert.NoError(t, err) - - 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) - } defer db.Close() - transactionId := uuid.NewString() - OriginTxLegID := uuid.NewString() - DestinationTxLegID := uuid.NewString() - userId1 := uuid.NewString() - userId2 := uuid.NewString() + userId := uuid.NewString() + transaction := createMockTransactionForUser(userId, "1000000", sqlxDB) assetId1 := uuid.NewString() assetId2 := uuid.NewString() - networkId := uuid.NewString() instrumentId1 := uuid.NewString() instrumentId2 := uuid.NewString() + mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) + u21TransactionId, err := executeMockTransactionForUser(transaction, sqlxDB) + assert.NoError(t, err) - transaction := model.Transaction{ - ID: transactionId, + OriginTxLegID := uuid.NewString() + DestinationTxLegID := uuid.NewString() + assetId1 = uuid.NewString() + assetId2 = uuid.NewString() + networkId := uuid.NewString() + instrumentId1 = uuid.NewString() + instrumentId2 = uuid.NewString() + + transaction = model.Transaction{ + ID: transaction.ID, CreatedAt: time.Now(), UpdatedAt: time.Now(), Type: "fiat-to-crypto", @@ -133,39 +75,24 @@ func TestCreateTransaction(t *testing.T) { ContractFunc: "mintTo()", TransactionAmount: "1000000000", OriginTxLegID: OriginTxLegID, - ReceiptTxLegID: uuid.NewString(), - ResponseTxLegID: uuid.NewString(), + ReceiptTxLegID: sql.NullString{String: uuid.NewString()}, + ResponseTxLegID: sql.NullString{String: uuid.NewString()}, DestinationTxLegID: DestinationTxLegID, ProcessingFee: "1000000", ProcessingFeeAsset: uuid.NewString(), - StringFee: "1000000", + StringFee: "2000000", } - - mockedTxLegRow1 := sqlmock.NewRows([]string{"id", "timestamp", "amount", "value", "asset_id", "user_id", "instrument_id"}). - AddRow(OriginTxLegID, time.Now(), "1000000", "1000000", assetId1, userId1, instrumentId1) - mock.ExpectQuery("SELECT * FROM tx_leg WHERE id = $1 AND 'deactivated_at' IS NOT NULL").WithArgs(OriginTxLegID).WillReturnRows(mockedTxLegRow1) - - mockedTxLegRow2 := sqlmock.NewRows([]string{"id", "timestamp", "amount", "value", "asset_id", "user_id", "instrument_id"}). - AddRow(DestinationTxLegID, time.Now(), "1", "10000000", assetId2, userId2, instrumentId2) - mock.ExpectQuery("SELECT * FROM tx_leg WHERE id = $1 AND 'deactivated_at' IS NOT NULL").WithArgs(DestinationTxLegID).WillReturnRows(mockedTxLegRow2) - - mockedAssetRow1 := sqlmock.NewRows([]string{"id", "name", "description", "decimals", "is_crypto", "network_id", "value_oracle"}). - AddRow(assetId1, "USD", "fiat USD", 6, false, networkId, "self") - mock.ExpectQuery("SELECT * FROM asset WHERE id = $1 AND 'deactivated_at' IS NOT 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, networkId, "joepegs.com") - mock.ExpectQuery("SELECT * FROM asset WHERE id = $1 AND 'deactivated_at' IS NOT NULL").WithArgs(assetId2).WillReturnRows(mockedAssetRow2) + mockTransactionRows(mock, transaction, userId, assetId1, assetId2, instrumentId1, instrumentId2) repo := TransactionRepo{ - TxLeg: repository.NewTxLeg(sqlxDB), + TxLeg: repository.NewTxLeg((sqlxDB)), User: repository.NewUser(sqlxDB), Asset: repository.NewAsset(sqlxDB), } u21Transaction := NewTransaction(repo) - u21TransactionId, err := u21Transaction.Create(transaction) + u21TransactionId, err = u21Transaction.Update(transaction) assert.NoError(t, err) assert.Greater(t, len([]rune(u21TransactionId)), 0) @@ -174,29 +101,27 @@ func TestCreateTransaction(t *testing.T) { // TODO: mock call to client once it's manually tested } -func TestUpdateTransaction(t *testing.T) { - err := godotenv.Load("../../../.env") - assert.NoError(t, err) - - 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) +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), } - defer db.Close() - transactionId := "866c32ae-9fda-409c-a0be-28b830022e93" + u21Transaction := NewTransaction(repo) + + unit21Id, err = u21Transaction.Create(transaction) + + return +} + +func createMockTransactionForUser(userId string, amount string, sqlxDB *sqlx.DB) (transaction model.Transaction) { + transactionId := uuid.NewString() OriginTxLegID := uuid.NewString() DestinationTxLegID := uuid.NewString() - userId1 := uuid.NewString() - userId2 := uuid.NewString() - assetId1 := uuid.NewString() - assetId2 := uuid.NewString() networkId := uuid.NewString() - instrumentId1 := uuid.NewString() - instrumentId2 := uuid.NewString() - transaction := model.Transaction{ + transaction = model.Transaction{ ID: transactionId, CreatedAt: time.Now(), UpdatedAt: time.Now(), @@ -211,45 +136,33 @@ func TestUpdateTransaction(t *testing.T) { NetworkFee: "100000000", ContractParams: pq.StringArray{}, ContractFunc: "mintTo()", - TransactionAmount: "1000000000", + TransactionAmount: amount, OriginTxLegID: OriginTxLegID, - ReceiptTxLegID: uuid.NewString(), - ResponseTxLegID: uuid.NewString(), + ReceiptTxLegID: sql.NullString{String: uuid.NewString()}, + ResponseTxLegID: sql.NullString{String: uuid.NewString()}, DestinationTxLegID: DestinationTxLegID, ProcessingFee: "1000000", ProcessingFeeAsset: uuid.NewString(), - StringFee: "2000000", + StringFee: "1000000", } + return +} + +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(OriginTxLegID, time.Now(), "1000000", "1000000", assetId1, userId1, instrumentId1) - mock.ExpectQuery("SELECT * FROM tx_leg WHERE id = $1 AND 'deactivated_at' IS NOT NULL").WithArgs(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 deactivated_at IS NULL").WithArgs(transaction.OriginTxLegID).WillReturnRows(mockedTxLegRow1) mockedTxLegRow2 := sqlmock.NewRows([]string{"id", "timestamp", "amount", "value", "asset_id", "user_id", "instrument_id"}). - AddRow(DestinationTxLegID, time.Now(), "1", "10000000", assetId2, userId2, instrumentId2) - mock.ExpectQuery("SELECT * FROM tx_leg WHERE id = $1 AND 'deactivated_at' IS NOT NULL").WithArgs(DestinationTxLegID).WillReturnRows(mockedTxLegRow2) + 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) mockedAssetRow1 := sqlmock.NewRows([]string{"id", "name", "description", "decimals", "is_crypto", "network_id", "value_oracle"}). - AddRow(assetId1, "USD", "fiat USD", 6, false, networkId, "self") - mock.ExpectQuery("SELECT * FROM asset WHERE id = $1 AND 'deactivated_at' IS NOT NULL").WithArgs(assetId1).WillReturnRows(mockedAssetRow1) + 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) 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, networkId, "joepegs.com") - mock.ExpectQuery("SELECT * FROM asset WHERE id = $1 AND 'deactivated_at' IS NOT NULL").WithArgs(assetId2).WillReturnRows(mockedAssetRow2) - - repo := TransactionRepo{ - TxLeg: repository.NewTxLeg((sqlxDB)), - User: repository.NewUser(sqlxDB), - Asset: repository.NewAsset(sqlxDB), - } - - u21Transaction := NewTransaction(repo) - - u21TransactionId, err := u21Transaction.Update(transaction) - assert.NoError(t, err) - assert.Greater(t, len([]rune(u21TransactionId)), 0) - - //validate response from Unit21 - //check Unit21 dashboard for new transaction added - // TODO: mock call to client once it's manually tested + 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) } diff --git a/pkg/internal/unit21/types.go b/pkg/internal/unit21/types.go index 5878d731..072e2d9b 100644 --- a/pkg/internal/unit21/types.go +++ b/pkg/internal/unit21/types.go @@ -125,7 +125,7 @@ type updateInstrumentResponse struct { type u21Event struct { GeneralData *eventGeneral `json:"general_data"` - TransactionData *transactionData `json:"transaction_data"` + TransactionData *transactionData `json:"transaction_data,omitempty"` ActionData *actionData `json:"action_data,omitempty"` DigitalData *eventDigitalData `json:"digital_data,omitempty"` LocationData *instrumentLocationData `json:"location_data,omitempty"` @@ -194,3 +194,19 @@ type updateEventResponse struct { EventId string `json:"event_id"` Unit21Id string `json:"unit21_id"` } + +type evaluateEventResponse struct { + Endpoint string `json:"endpoint"` + EvaluationId string `json:"evaluation_id"` + EventId string `json:"event_id"` + OrgId int `json:"org_id"` + RuleExecutions *ruleExecutions `json:"rule_executions"` + Timestamp float64 `json:"timestamp"` +} + +type ruleExecutions map[string]rule + +type rule struct { + RuleName string `json:"rule_name"` + Status string `json:"status"` +} diff --git a/pkg/model/entity.go b/pkg/model/entity.go index 0908e048..57d460fc 100644 --- a/pkg/model/entity.go +++ b/pkg/model/entity.go @@ -20,19 +20,20 @@ type User struct { 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 0001 +// See PLATFORM in Migrations 0005 type Platform 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"` - Name string `json:"name" db:"name"` - ApiKey string `json:"apiKey" db:"api_key"` - Authentication AuthType `json:"authentication" db:"authentication"` + 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 @@ -65,7 +66,7 @@ type Asset struct { } // See USER_PLATFORM in Migrations 0002 -type UserPlatform struct { +type UserToPlatform struct { UserID string `json:"userId" db:"user_id"` PlatformID string `json:"platformId" db:"platform_id"` } @@ -135,13 +136,13 @@ type Instrument struct { } // See CONTACT_PLATFORM in Migrations 0003 -type ContactPlatform struct { +type ContactToPlatform struct { ContactID string `json:"contactId" db:"contact_id"` PlatformID string `json:"platformId" db:"platform_id"` } // See DEVICE_INSTRUMENT in Migrations 0003 -type DeviceInstrument struct { +type DeviceToInstrument struct { DeviceID string `json:"deviceId" db:"device_id"` InstrumentID string `json:"instrumentId" db:"instrument_id"` } @@ -166,25 +167,26 @@ type Transaction struct { 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"` - DeviceID string `json:"deviceId" db:"device_id"` - IPAddress string `json:"ipAddress" db:"ip_address"` - PlatformID string `json:"platformId" db:"platform_id"` - TransactionHash string `json:"transactionHash" db:"transaction_hash"` - 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"` - ProcessingFee string `json:"processingFee" db:"processing_fee"` - ProcessingFeeAsset string `json:"processingFeeAsset" db:"processing_fee_asset"` - StringFee string `json:"stringFee" db:"string_fee"` + 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"` + IPAddress string `json:"ipAddress,omitempty" db:"ip_address"` + PlatformID string `json:"platformId,omitempty" db:"platform_id"` + TransactionHash string `json:"transactionHash,omitempty" db:"transaction_hash"` + 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"` + 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"` + PaymentCode string `json:"paymentCode,omitempty" db:"payment_code"` } type AuthStrategy struct { diff --git a/pkg/model/request.go b/pkg/model/request.go index 080b2d10..f4701046 100644 --- a/pkg/model/request.go +++ b/pkg/model/request.go @@ -1,6 +1,7 @@ package model import ( + "database/sql" "time" "github.com/jmoiron/sqlx/types" @@ -30,6 +31,27 @@ type TransactionUpdates struct { ProcessingFee *string `json:"processingFee" db:"processing_fee"` ProcessingFeeAsset *string `json:"processingFeeAsset" db:"processing_fee_asset"` StringFee *string `json:"stringFee" db:"string_fee"` + PaymentCode *string `json:"paymentCode" db:"payment_code"` +} + +type InstrumentUpdates struct { + 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"` +} + +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"` } type UserRegister struct { diff --git a/pkg/model/user.go b/pkg/model/user.go index c9e16855..ae622e16 100644 --- a/pkg/model/user.go +++ b/pkg/model/user.go @@ -10,12 +10,12 @@ type WalletSignaturePayload struct { } type FingerprintPayload struct { - VisitorID string `json:"visitorId" validate:"required"` - RequestID string `json:"requestId" validate:"required"` + 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" validate:"required"` + Fingerprint FingerprintPayload `json:"fingerprint"` } diff --git a/pkg/repository/asset.go b/pkg/repository/asset.go index e34e10ef..d0f984e1 100644 --- a/pkg/repository/asset.go +++ b/pkg/repository/asset.go @@ -13,7 +13,7 @@ type Asset interface { Transactable Create(model.Asset) (model.Asset, error) GetById(id string) (model.Asset, error) - GetName(name string) (model.Asset, error) + GetByName(name string) (model.Asset, error) Update(ID string, updates any) error } @@ -41,7 +41,7 @@ func (a asset[T]) Create(insert model.Asset) (model.Asset, error) { return m, err } -func (a asset[T]) GetName(name string) (model.Asset, error) { +func (a asset[T]) GetByName(name string) (model.Asset, error) { m := model.Asset{} err := a.store.Get(&m, fmt.Sprintf("SELECT * FROM %s WHERE name = $1", a.table), name) if err != nil && err == sql.ErrNoRows { diff --git a/pkg/repository/base.go b/pkg/repository/base.go index 87b5ccb9..8f78d12a 100644 --- a/pkg/repository/base.go +++ b/pkg/repository/base.go @@ -15,18 +15,18 @@ import ( var ErrNotFound = errors.New("not found") type Repositories struct { - Auth AuthStrategy - User User - Contact Contact - Instrument Instrument - Device Device - UserPlatform UserPlatform - Asset Asset - Network Network - Platform Platform - Transaction Transaction - TxLeg TxLeg - Location Location + 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 { diff --git a/pkg/repository/contact_platform.go b/pkg/repository/contact_platform.go deleted file mode 100644 index 8f4a29d0..00000000 --- a/pkg/repository/contact_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 ContactPlatform interface { - Transactable - Readable - Create(model.ContactPlatform) (model.ContactPlatform, error) - GetById(ID string) (model.ContactPlatform, error) - List(limit int, offset int) ([]model.ContactPlatform, error) - Update(ID string, updates any) error -} - -type contactPlatform[T any] struct { - base[T] -} - -func NewContactPlatform(db *sqlx.DB) ContactPlatform { - return &contactPlatform[model.ContactPlatform]{base: base[model.ContactPlatform]{store: db, table: "contact_platform"}} -} - -func (u contactPlatform[T]) Create(insert model.ContactPlatform) (model.ContactPlatform, error) { - m := model.ContactPlatform{} - rows, err := u.store.NamedQuery(` - INSERT INTO contact_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/contact_to_platform.go b/pkg/repository/contact_to_platform.go new file mode 100644 index 00000000..ca0d1543 --- /dev/null +++ b/pkg/repository/contact_to_platform.go @@ -0,0 +1,42 @@ +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/instrument.go b/pkg/repository/instrument.go index 6a816bc6..c6e17c30 100644 --- a/pkg/repository/instrument.go +++ b/pkg/repository/instrument.go @@ -13,9 +13,10 @@ import ( type Instrument interface { Transactable Create(model.Instrument) (model.Instrument, error) - GetById(id string) (model.Instrument, error) - GetWallet(addr string) (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) @@ -48,7 +49,7 @@ func (i instrument[T]) Create(insert model.Instrument) (model.Instrument, error) return m, nil } -func (i instrument[T]) GetWallet(addr string) (model.Instrument, error) { +func (i instrument[T]) GetWalletByAddr(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 { @@ -59,6 +60,10 @@ func (i instrument[T]) GetWallet(addr string) (model.Instrument, error) { return m, nil } +func (i instrument[T]) GetCardByFingerprint(fingerprint string) (m model.Instrument, err error) { + return i.GetWalletByAddr(fingerprint) +} + func (i instrument[T]) GetWalletByUserId(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) @@ -82,7 +87,7 @@ func (i instrument[T]) GetBankByUserId(userId string) (model.Instrument, error) } func (i instrument[T]) WalletAlreadyExists(addr string) (bool, error) { - wallet, err := i.GetWallet(addr) + wallet, err := i.GetWalletByAddr(addr) if err != nil && errors.Cause(err).Error() != "not found" { // because we are wrapping error and care about its value return true, common.StringError(err) diff --git a/pkg/repository/location_test.go b/pkg/repository/location_test.go index d9ae37de..b7c4291a 100644 --- a/pkg/repository/location_test.go +++ b/pkg/repository/location_test.go @@ -22,7 +22,7 @@ func TestGetLocation(t *testing.T) { rows := sqlmock.NewRows([]string{"id", "created_at", "updated_at", "type", "status", "building_number", "unit_number", "street_name", "city", "state", "postal_code", "country"}). AddRow(id, time.Now(), time.Now(), "Home", "Verified", "20181", "411", "Lark Avenue", "Somerville", "MA", "01443", "USA") - mock.ExpectQuery("SELECT * FROM location WHERE id = $1 AND 'deactivated_at' IS NOT NULL").WillReturnRows(rows).WithArgs(id) + mock.ExpectQuery("SELECT * FROM location WHERE id = $1 AND deactivated_at IS NULL").WillReturnRows(rows).WithArgs(id) location, err := NewLocation(sqlxDB).GetById(id) assert.NoError(t, err) diff --git a/pkg/repository/network.go b/pkg/repository/network.go index d41faaca..c387c19c 100644 --- a/pkg/repository/network.go +++ b/pkg/repository/network.go @@ -13,7 +13,7 @@ type Network interface { Transactable Create(model.Network) (model.Network, error) GetById(id string) (model.Network, error) - GetChainID(chainId uint64) (model.Network, error) + GetByChainId(chainId uint64) (model.Network, error) Update(ID string, updates any) error } @@ -47,7 +47,7 @@ func (n network[T]) Create(insert model.Network) (model.Network, error) { return m, nil } -func (n network[T]) GetChainID(chainId uint64) (model.Network, error) { +func (n network[T]) GetByChainId(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) if err != nil && err == sql.ErrNoRows { diff --git a/pkg/repository/platform.go b/pkg/repository/platform.go index 8313cbee..8d21f93d 100644 --- a/pkg/repository/platform.go +++ b/pkg/repository/platform.go @@ -1,8 +1,6 @@ package repository import ( - "database/sql" - "fmt" "time" "github.com/String-xyz/string-api/pkg/internal/common" @@ -24,7 +22,6 @@ type Platform interface { GetById(ID string) (model.Platform, error) List(limit int, offset int) ([]model.Platform, error) Update(ID string, updates any) error - GetByApiKey(key string) (model.Platform, error) } type platform[T any] struct { @@ -38,8 +35,8 @@ func NewPlatform(db *sqlx.DB) Platform { func (p platform[T]) Create(m model.Platform) (model.Platform, error) { plat := model.Platform{} rows, err := p.store.NamedQuery(` - INSERT INTO platform (type, authentication, api_key, status) - VALUES(:type, :authentication, :api_key, :status) RETURNING *`, m) + INSERT INTO platform (name, description) + VALUES(:name, :description) RETURNING *`, m) if err != nil { return plat, common.StringError(err) @@ -55,13 +52,3 @@ func (p platform[T]) Create(m model.Platform) (model.Platform, error) { return plat, nil } -func (p platform[T]) GetByApiKey(key string) (model.Platform, error) { - m := model.Platform{} - err := p.store.Get(&m, fmt.Sprintf("SELECT * FROM %s WHERE api_key = $1", p.table), key) - if err != nil && err == sql.ErrNoRows { - return m, common.StringError(ErrNotFound) - } else if err != nil { - return m, common.StringError(err) - } - return m, nil -} diff --git a/pkg/repository/user_platform.go b/pkg/repository/user_platform.go deleted file mode 100644 index b098150d..00000000 --- a/pkg/repository/user_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 UserPlatform interface { - Transactable - Readable - Create(model.UserPlatform) (model.UserPlatform, error) - GetById(ID string) (model.UserPlatform, error) - List(limit int, offset int) ([]model.UserPlatform, error) - ListByUserId(userID string, imit int, offset int) ([]model.UserPlatform, error) - Update(ID string, updates any) error -} - -type userPlatform[T any] struct { - base[T] -} - -func NewUserPlatform(db *sqlx.DB) UserPlatform { - return &userPlatform[model.UserPlatform]{base: base[model.UserPlatform]{store: db, table: "user_platform"}} -} - -func (u userPlatform[T]) Create(insert model.UserPlatform) (model.UserPlatform, error) { - m := model.UserPlatform{} - rows, err := u.store.NamedQuery(` - INSERT INTO user_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/repository/user_test.go b/pkg/repository/user_test.go index 578f48c8..4625c194 100644 --- a/pkg/repository/user_test.go +++ b/pkg/repository/user_test.go @@ -44,7 +44,7 @@ func TestGetUser(t *testing.T) { rows := sqlmock.NewRows([]string{"id", "first_name", "last_name", "created_at", "updated_at"}). AddRow(id, "Mocking", "Jay", time.Now(), time.Now()) - mock.ExpectQuery("SELECT * FROM string_user WHERE id = $1 AND 'deactivated_at' IS NOT NULL").WillReturnRows(rows).WithArgs(id) + mock.ExpectQuery("SELECT * FROM string_user WHERE id = $1 AND deactivated_at IS NULL").WillReturnRows(rows).WithArgs(id) user, err := NewUser(sqlxDB).GetById(id) assert.NoError(t, err) diff --git a/pkg/repository/user_to_platform.go b/pkg/repository/user_to_platform.go new file mode 100644 index 00000000..c3fc066b --- /dev/null +++ b/pkg/repository/user_to_platform.go @@ -0,0 +1,43 @@ +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 48f256e3..1eed666a 100644 --- a/pkg/service/auth.go +++ b/pkg/service/auth.go @@ -12,7 +12,6 @@ import ( "github.com/String-xyz/string-api/pkg/repository" "github.com/golang-jwt/jwt/v4" "github.com/google/uuid" - "github.com/lib/pq" "github.com/pkg/errors" ) @@ -22,8 +21,6 @@ type SignablePayload struct { var hexRegex *regexp.Regexp = regexp.MustCompile(`^0x[a-fA-F0-9]{40}$`) -// var walletAuthenticationPrefix string = "" // For testing locally - 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 { @@ -53,21 +50,21 @@ type Auth interface { // if signaure is valid it returns a JWT to authenticate the user VerifySignedPayload(model.WalletSignaturePayloadSigned) (UserCreateResponse, error) - GenerateJWT(model.Device) (JWT, error) + GenerateJWT(string, ...model.Device) (JWT, error) ValidateAPIKey(key string) bool - RefreshToken(token string, walletAddress string) (JWT, error) + RefreshToken(token string, walletAddress string) (UserCreateResponse, error) InvalidateRefreshToken(token string) error } type auth struct { repos repository.Repositories - fingerprint Fingerprint verification Verification + device Device } // reusing UserRepos here -func NewAuth(r repository.Repositories, f Fingerprint, v Verification) Auth { - return &auth{r, f, v} +func NewAuth(r repository.Repositories, v Verification, d Device) Auth { + return &auth{r, v, d} } func (a auth) PayloadToSign(walletAddress string) (SignablePayload, error) { @@ -100,7 +97,7 @@ func (a auth) VerifySignedPayload(request model.WalletSignaturePayloadSigned) (U } // Verify user is registered to this wallet address - instrument, err := a.repos.Instrument.GetWallet(payload.Address) + instrument, err := a.repos.Instrument.GetWalletByAddr(payload.Address) if err != nil { return resp, common.StringError(err) } @@ -109,56 +106,36 @@ func (a auth) VerifySignedPayload(request model.WalletSignaturePayloadSigned) (U return resp, common.StringError(err) } - created, device, err := a.createDeviceIfNeeded(user.ID, request.Fingerprint.VisitorID, request.Fingerprint.RequestID) - if err != nil { + user.Email = getValidatedEmailOrEmpty(a.repos.Contact, user.ID) + + device, err := a.device.CreateDeviceIfNeeded(user.ID, request.Fingerprint.VisitorID, request.Fingerprint.RequestID) + if err != nil && !strings.Contains(err.Error(), "not found") { return resp, common.StringError(err) } - if created || device.ValidatedAt == nil { - go a.verification.SendDeviceVerification(user.ID, device.ID, device.Description) + // 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")) } // Create the JWT - jwt, err := a.GenerateJWT(device) + jwt, err := a.GenerateJWT(user.ID, device) if err != nil { return resp, common.StringError(err) } - return UserCreateResponse{JWT: jwt, User: user}, nil -} -func (a auth) createDeviceIfNeeded(userID, visitorID, requestID string) (bool, model.Device, error) { - device, err := a.repos.Device.GetByUserIdAndFingerprint(userID, visitorID) - if err == nil { - return false, device, nil - } - // create device only if the error is not found - if err != nil && err == repository.ErrNotFound { - visitor, fpErr := a.fingerprint.GetVisitor(visitorID, requestID) - if fpErr != nil { - return false, model.Device{}, common.StringError(fpErr) - } - device, dErr := a.createDevice(userID, visitor) - return dErr == nil, device, dErr + // Invalidate device if it is unknown and was validated so it cannot be used again + err = a.device.InvalidateUnknownDevice(device) + if err != nil { + return resp, common.StringError(err) } - return false, device, common.StringError(err) -} - -func (a auth) createDevice(userID string, visitor model.FPVisitor) (model.Device, error) { - return a.repos.Device.Create(model.Device{ - UserID: userID, - Fingerprint: visitor.VisitorID, - Type: visitor.Type, - IpAddresses: pq.StringArray{visitor.IPAddress}, - Description: visitor.UserAgent, - LastUsedAt: time.Now(), - ValidatedAt: nil, - }) + return UserCreateResponse{JWT: jwt, User: user}, nil } // GenerateJWT generates a jwt token and a refresh token which is saved on redis -func (a auth) GenerateJWT(m model.Device) (JWT, error) { +func (a auth) GenerateJWT(userId string, m ...model.Device) (JWT, error) { claims := JWTClaims{} refreshToken := uuidWithoutHyphens() t := &JWT{ @@ -166,8 +143,12 @@ func (a auth) GenerateJWT(m model.Device) (JWT, error) { ExpAt: time.Now().Add(time.Minute * 15), } - claims.DeviceId = m.ID - claims.UserId = m.UserID + // set device id if available + if len(m) > 0 { + claims.DeviceId = m[0].ID + } + + claims.UserId = userId claims.ExpiresAt = t.ExpAt.Unix() claims.IssuedAt = t.IssuedAt.Unix() // replace this signing method with RSA or something similar @@ -179,7 +160,7 @@ func (a auth) GenerateJWT(m model.Device) (JWT, error) { t.Token = signed // create and save - refreshObj, err := a.repos.Auth.CreateJWTRefresh(common.ToSha256(refreshToken), m.UserID) + refreshObj, err := a.repos.Auth.CreateJWTRefresh(common.ToSha256(refreshToken), userId) if err != nil { return *t, err } @@ -212,46 +193,58 @@ func (a auth) InvalidateRefreshToken(refreshToken string) error { return a.repos.Auth.Delete(common.ToSha256(refreshToken)) } -func (a auth) RefreshToken(refreshToken string, walletAddress string) (JWT, error) { +func (a auth) RefreshToken(refreshToken string, walletAddress string) (UserCreateResponse, error) { + resp := UserCreateResponse{} + // get user id from refresh token userId, err := a.repos.Auth.GetUserIdFromRefreshToken(common.ToSha256(refreshToken)) if err != nil { - return JWT{}, common.StringError(err) + return resp, common.StringError(err) } // verify wallet address // Verify user is registered to this wallet address - instrument, err := a.repos.Instrument.GetWallet(walletAddress) + instrument, err := a.repos.Instrument.GetWalletByAddr(walletAddress) if err != nil { if strings.Contains(err.Error(), "not found") { - return JWT{}, common.StringError(errors.New("wallet address not associated with this user: " + walletAddress)) + return resp, common.StringError(errors.New("wallet address not associated with this user: " + walletAddress)) } - return JWT{}, common.StringError(err) + return resp, common.StringError(err) } if instrument.UserID != userId { - return JWT{}, common.StringError(errors.New("wallet address not associated with this user: " + walletAddress)) + return resp, common.StringError(errors.New("wallet address not associated with this user: " + walletAddress)) } // get device device, err := a.repos.Device.GetByUserId(userId) if err != nil { - return JWT{}, common.StringError(err) + return resp, common.StringError(err) } // create new jwt - jwt, err := a.GenerateJWT(device) + jwt, err := a.GenerateJWT(userId, device) if err != nil { - return JWT{}, common.StringError(err) + return resp, common.StringError(err) } + resp.JWT = jwt // delete old refresh token err = a.InvalidateRefreshToken(refreshToken) if err != nil { - return JWT{}, common.StringError(err) + return resp, common.StringError(err) + } + + user, err := a.repos.User.GetById(instrument.UserID) + if err != nil { + return resp, common.StringError(err) } - return jwt, nil + // get email + user.Email = getValidatedEmailOrEmpty(a.repos.Contact, user.ID) + resp.User = user + + return resp, nil } func verifyWalletAuthentication(request model.WalletSignaturePayloadSigned) error { @@ -288,3 +281,12 @@ func uuidWithoutHyphens() string { s := uuid.New().String() return strings.Replace(s, "-", "", -1) } + +func getValidatedEmailOrEmpty(contactRepo repository.Contact, userId string) string { + contact, err := contactRepo.GetByUserIdAndStatus(userId, "validated") + if err != nil { + return "" + } + + return contact.Data +} diff --git a/pkg/service/base.go b/pkg/service/base.go index c532551c..ee055faf 100644 --- a/pkg/service/base.go +++ b/pkg/service/base.go @@ -13,4 +13,5 @@ type Services struct { Transaction Transaction User User Verification Verification + Device Device } diff --git a/pkg/service/chain.go b/pkg/service/chain.go index fa1f720b..fd252a6a 100644 --- a/pkg/service/chain.go +++ b/pkg/service/chain.go @@ -15,6 +15,7 @@ type Chain struct { OwlracleName string StringFee float64 UUID 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,7 +24,7 @@ func stringFee(chainId uint64) (float64, error) { } func ChainInfo(chainId uint64, networkRepo repository.Network, assetRepo repository.Asset) (Chain, error) { - network, err := networkRepo.GetChainID(chainId) + network, err := networkRepo.GetByChainId(chainId) if err != nil { return Chain{}, common.StringError(err) } @@ -35,5 +36,5 @@ func ChainInfo(chainId uint64, networkRepo repository.Network, assetRepo reposit if err != nil { return Chain{}, common.StringError(err) } - return Chain{ChainID: chainId, RPC: network.RPCUrl, Explorer: network.ExplorerUrl, CoingeckoName: asset.ValueOracle.String, OwlracleName: network.GasOracle, StringFee: fee, UUID: network.ID}, nil + 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 } diff --git a/pkg/service/checkout.go b/pkg/service/checkout.go index 24cf9834..da5fd7de 100644 --- a/pkg/service/checkout.go +++ b/pkg/service/checkout.go @@ -53,12 +53,17 @@ type AuthorizedCharge struct { CheckoutFingerprint string Last4 string Issuer string + Approved bool + Status string + Summary string + CardType string } -func AuthorizeCharge(amount float64, userWallet string, tokenId string) (auth AuthorizedCharge, err error) { +func AuthorizeCharge(p transactionProcessingData) (transactionProcessingData, error) { + auth := AuthorizedCharge{} config, err := getConfig() if err != nil { - return auth, common.StringError(err) + return p, common.StringError(err) } client := payments.NewClient(*config) @@ -67,8 +72,11 @@ func AuthorizeCharge(amount float64, userWallet string, tokenId string) (auth Au // 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", + 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", @@ -76,17 +84,17 @@ func AuthorizeCharge(amount float64, userWallet string, tokenId string) (auth Au } paymentToken, err := CreateToken(&card) if err != nil { - return auth, common.StringError(err) + return p, common.StringError(err) } paymentTokenID = paymentToken.Created.Token - if tokenId != "" { - paymentTokenID = tokenId + if p.executionRequest.CardToken != "" { + paymentTokenID = p.executionRequest.CardToken } } else { - paymentTokenID = tokenId + paymentTokenID = p.executionRequest.CardToken } - usd := convertAmount(amount) + usd := convertAmount(p.executionRequest.TotalUSD) capture := false request := &payments.Request{ @@ -97,7 +105,7 @@ func AuthorizeCharge(amount float64, userWallet string, tokenId string) (auth Au Amount: usd, Currency: "USD", Customer: &payments.Customer{ - Name: userWallet, + Name: p.executionRequest.UserAddress, }, Capture: &capture, } @@ -107,30 +115,37 @@ func AuthorizeCharge(amount float64, userWallet string, tokenId string) (auth Au IdempotencyKey: &idempotencyKey, } response, err := client.Request(request, ¶ms) - if err != nil { - return auth, common.StringError(err) + return p, common.StringError(err) } // Collect authorization ID and Instrument ID - auth.AuthID = response.Processed.ID - 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 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 + } } + p.cardAuthorization = &auth // TODO: Create entry for authorization in our DB associated with userWallet - return auth, nil + return p, nil } -func CaptureCharge(amount float64, userWallet string, authorizationID string) (capture *payments.CapturesResponse, err error) { +func CaptureCharge(p transactionProcessingData) (transactionProcessingData, error) { config, err := getConfig() if err != nil { - return nil, common.StringError(err) + return p, common.StringError(err) } client := payments.NewClient(*config) - usd := convertAmount(amount) + usd := convertAmount(p.executionRequest.Quote.TotalUSD) idempotencyKey := checkout.NewIdempotencyKey() params := checkout.Params{ @@ -139,11 +154,16 @@ func CaptureCharge(amount float64, userWallet string, authorizationID string) (c request := payments.CapturesRequest{ Amount: usd, } - capture, err = client.Captures(authorizationID, &request, ¶ms) + + capture, err := client.Captures(p.cardAuthorization.AuthID, &request, ¶ms) if err != nil { - return nil, common.StringError(err) + return p, common.StringError(err) } + p.cardCapture = capture + + // TODO: call action, err = client.Actions(capture.Accepted.ActionID) in another service to check on + // TODO: Create entry for capture in our DB associated with userWallet - return capture, nil + return p, nil } diff --git a/pkg/service/device.go b/pkg/service/device.go new file mode 100644 index 00000000..de5ca877 --- /dev/null +++ b/pkg/service/device.go @@ -0,0 +1,130 @@ +package service + +import ( + "os" + "time" + + "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 +} + +type device struct { + repos repository.Repositories + fingerprint Fingerprint +} + +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) CreateUnknownDevice(userID string) (model.Device, error) { + visitor := model.FPVisitor{ + VisitorID: "unknown", + Type: "unknown", + IPAddress: "unknown", + UserAgent: "unknown", + } + device, err := d.createDevice(userID, visitor, "an unknown device") + return device, common.StringError(err) +} + +func (d device) CreateDeviceIfNeeded(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") + if err != nil { + return device, common.StringError(err) + } + + if !isDeviceValidated(device) { + device.ValidatedAt = nil + return device, nil + } + + return device, common.StringError(err) + } else { + /* device recognized, create or get the device */ + device, err := d.repos.Device.GetByUserIdAndFingerprint(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 fpErr != nil { + return model.Device{}, common.StringError(fpErr) + } + device, dErr := d.createDevice(userID, visitor, "a new device "+visitor.UserAgent+" ") + return device, dErr + } + + return device, common.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) + } + + now := time.Now() + if now.Unix()-received.Timestamp > (60 * 15) { + return common.StringError(errors.New("link expired")) + } + err = d.repos.Device.Update(received.DeviceID, model.DeviceUpdates{ValidatedAt: &now}) + return err +} + +func (d device) getOrCreateUnknownDevice(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) + } + + 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) +} + +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 51d9b0b5..cbc5ea6d 100644 --- a/pkg/service/executor.go +++ b/pkg/service/executor.go @@ -39,7 +39,7 @@ type Executor interface { Estimate(call ContractCall) (CallEstimate, error) TxWait(txID string) (uint64, error) Close() error - GetChainID() (uint64, error) + GetByChainId() (uint64, error) GetBalance() (float64, error) } @@ -245,7 +245,7 @@ func (e executor) TxWait(txID string) (uint64, error) { return receipt.GasUsed, nil } -func (e executor) GetChainID() (uint64, error) { +func (e executor) GetByChainId() (uint64, error) { // Get ChainID from state var chainId64 uint64 err := e.client.Call(eth.ChainID().Returns(&chainId64)) diff --git a/pkg/service/platform.go b/pkg/service/platform.go index 71999271..aae5753f 100644 --- a/pkg/service/platform.go +++ b/pkg/service/platform.go @@ -23,12 +23,7 @@ func NewPlatform(repos repository.Repositories) Platform { func (a platform) Create(c CreatePlatform) (model.Platform, error) { uuiKey := "str." + uuidWithoutHyphens() hashed := common.ToSha256(uuiKey) - m := model.Platform{ - Type: c.Type, - Authentication: c.Authentication, - ApiKey: hashed, - Status: "pending", - } + m := model.Platform{} plat, err := a.repos.Platform.Create(m) if err != nil { @@ -37,7 +32,6 @@ func (a platform) Create(c CreatePlatform) (model.Platform, error) { _, err = a.repos.Auth.CreateAPIKey(plat.ID, c.Authentication, hashed, false) pt := &plat - pt.ApiKey = uuiKey if err != nil { return *pt, common.StringError(err) } diff --git a/pkg/service/transaction.go b/pkg/service/transaction.go index 76ab6e7d..d4152ee1 100644 --- a/pkg/service/transaction.go +++ b/pkg/service/transaction.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/checkout/checkout-sdk-go/payments" "github.com/pkg/errors" "github.com/String-xyz/string-api/pkg/internal/common" @@ -50,6 +51,23 @@ type transaction struct { ids InternalIds } +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 store.RedisStore) Transaction { return &transaction{repos: repos, redis: redis} } @@ -89,159 +107,264 @@ func (t transaction) Quote(d model.TransactionRequest) (model.ExecutionRequest, return res, nil } -func (t transaction) Execute(e model.ExecutionRequest, userId string, deviceId string) (model.TransactionReceipt, error) { - res := model.TransactionReceipt{} +func (t transaction) Execute(e model.ExecutionRequest, userId string, deviceId string) (res model.TransactionReceipt, err error) { t.getStringInstrumentsAndUserId() - user, err := t.repos.User.GetById(userId) + p := transactionProcessingData{executionRequest: &e, userId: &userId, deviceId: &deviceId} + + // Pre-flight transaction setup + p, err = t.transactionSetup(p) if err != nil { return res, common.StringError(err) } - if user.ID != userId { - return res, common.StringError(errors.New("not logged in")) - } - // Pull chain info needed for execution from repository - chain, err := ChainInfo(uint64(e.ChainID), t.repos.Network, t.repos.Asset) + // Run safety checks + p, err = t.safetyCheck(p) if err != nil { return res, common.StringError(err) } - // Create new Tx in repository, populate it with known info - db, err := t.repos.Transaction.Create(model.Transaction{Status: "Created", NetworkID: chain.UUID, DeviceID: deviceId, PlatformID: t.ids.StringPlatformId}) + // Send request to the blockchain and update model status, hash, transaction amount + p, err = t.initiateTransaction(p) if err != nil { return res, common.StringError(err) } - updateDB := &model.TransactionUpdates{} - processingFeeAsset, err := t.populateInitialTxModelData(e, updateDB) + // 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 +} + +func (t transaction) postProcess(p transactionProcessingData) { + // Reinitialize Executor + executor := NewExecutor() + p.executor = &executor + err := executor.Initialize(p.chain.RPC) if err != nil { - return res, common.StringError(err) + log.Printf("Failed to initialized executor in postProcess: %s", common.StringError(err)) + // TODO: Handle error instead of returning it } - err = t.repos.Transaction.Update(db.ID, updateDB) + + // Update TX Status + updateDB := model.TransactionUpdates{} + status := "Post Process RPC Dialed" + updateDB.Status = &status + err = t.repos.Transaction.Update(p.transactionModel.ID, updateDB) if err != nil { - fmt.Printf("\nERROR = %+v", err) - return res, common.StringError(err) + log.Printf("Failed to update transaction repo with status 'Post Process RPC Dialed': %s", common.StringError(err)) + // TODO: Handle error instead of returning it } - // Dial the RPC and update model status - executor := NewExecutor() - err = executor.Initialize(chain.RPC) + // confirm the Tx on the EVM + trueGas, err := confirmTx(executor, *p.txId) + p.trueGas = &trueGas if err != nil { - return res, common.StringError(err) + log.Printf("Failed to confirm transaction: %s", common.StringError(err)) + // TODO: Handle error instead of returning it } - status := "RPC Dialed" + + // Update DB status and NetworkFee + status = "Tx Confirmed" updateDB.Status = &status - err = t.repos.Transaction.Update(db.ID, updateDB) + networkFee := strconv.FormatUint(trueGas, 10) + updateDB.NetworkFee = &networkFee // geth uses uint64 for gas + err = t.repos.Transaction.Update(p.transactionModel.ID, updateDB) if err != nil { - return res, common.StringError(err) + log.Printf("Failed to update transaction repo with status 'Tx Confirmed': %s", common.StringError(err)) + // TODO: Handle error instead of returning it } - // Test the Tx and update model status - estimateUSD, estimateETH, err := t.testTransaction(executor, e.TransactionRequest, chain, false) + // Get new string wallet balance after executing the transaction + postBalance, err := executor.GetBalance() if err != nil { - return res, common.StringError(err) + log.Printf("Failed to get executor balance: %s", common.StringError(err)) + // TODO: handle error instead of returning it + } + + // We can close the executor because we aren't using it after this + executor.Close() + + // 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 + } } - status = "Tested and Estimated" + + // compute profit + // TODO: factor request.processingFeeAsset in the event of crypto-to-usd + profit, err := t.tenderTransaction(p) + if err != nil { + log.Printf("Failed to tender transaction: %s", common.StringError(err)) + // 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(db.ID, updateDB) + err = t.repos.Transaction.Update(p.transactionModel.ID, updateDB) if err != nil { - return res, common.StringError(err) + log.Printf("Failed to update transaction repo with status 'Profit Tendered': %s", common.StringError(err)) + // TODO: Handle error instead of returning it } - // Verify the Quote and update model status - _, err = verifyQuote(e, estimateUSD) + // charge the users CC + err = t.chargeCard(p) if err != nil { - return res, common.StringError(err) + log.Printf("Error, failed to charge card: %+v", common.StringError(err)) + // TODO: Handle error instead of returning it } - status = "Quote Verified" + + // Update status upon success + status = "Card Charged" updateDB.Status = &status - err = t.repos.Transaction.Update(db.ID, updateDB) + // 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) if err != nil { - return res, common.StringError(err) + log.Printf("Failed to update transaction repo with status 'Card Charged': %s", common.StringError(err)) + // TODO: Handle error instead of returning it } - // Get current balance of primary token - preBalance, err := executor.GetBalance() + // Transaction complete! Update status + status = "Completed" + updateDB.Status = &status + err = t.repos.Transaction.Update(p.transactionModel.ID, updateDB) if err != nil { - return res, common.StringError(err) + log.Printf("Failed to update transaction repo with status 'Completed': %s", common.StringError(err)) } - if preBalance < estimateETH { - msg := fmt.Sprintf("STRING-API: %s balance is too low to execute %.2f transaction at %.2f", chain.OwlracleName, estimateETH, preBalance) - MessageStaff(msg) - return res, common.StringError(errors.New("hot wallet ETH balance too low")) + + // Create Transaction data in Unit21 + err = t.unit21CreateTransaction(p.transactionModel.ID) + if err != nil { + log.Printf("Error creating Unit21 transaction: %s", common.StringError(err)) } - // Authorize quoted cost on end-user CC and update model status - cardAuthorization, err := t.authCard(e.UserAddress, e.CardToken, e.TotalUSD, processingFeeAsset, db.ID, userId) + // send email receipt + err = t.sendEmailReceipt(p) if err != nil { - return res, common.StringError(err) + log.Printf("Error sending email receipt to user: %s", common.StringError(err)) } - status = "Card Authorized" - updateDB.Status = &status - err = t.repos.Transaction.Update(db.ID, updateDB) +} + +func (t transaction) transactionSetup(p transactionProcessingData) (transactionProcessingData, error) { + // get user object + _, err := t.repos.User.GetById(*p.userId) if err != nil { - return res, common.StringError(err) + return p, common.StringError(err) } - // // Turning off until we can determine the Destination Leg prior to execution - // u21auth, err := t.unit21Evaluate(db.ID) - // if err != nil { - // return res, common.StringError(err) - // } + // 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 err != nil { + return p, common.StringError(err) + } - // if !u21auth { - // err = fmt.Errorf("Transaction Unauthorized in Unit21") - // return res, common.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 + if err != nil { + return p, common.StringError(err) + } - // Send request to the blockchain and update model status, hash, transaction amount - txID, value, err := t.initiateTransaction(executor, e, processingFeeAsset, db.ID, userId) + updateDB := &model.TransactionUpdates{} + processingFeeAsset, err := t.populateInitialTxModelData(*p.executionRequest, updateDB) + p.processingFeeAsset = &processingFeeAsset if err != nil { - return res, common.StringError(err) + return p, common.StringError(err) } - status = "Transaction Initiated" - updateDB.Status = &status - updateDB.TransactionHash = &txID - txAmount := value.String() - updateDB.TransactionAmount = &txAmount - err = t.repos.Transaction.Update(db.ID, updateDB) + err = t.repos.Transaction.Update(transactionModel.ID, updateDB) if err != nil { - return res, common.StringError(err) + fmt.Printf("\nERROR = %+v", common.StringError(err)) + return p, common.StringError(err) } - // this Executor will not exist in scope of postProcess - executor.Close() + // 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) + } - // Send required information to new thread and return TxID to the endpoint - post := postProcessRequest{ - TxID: txID, - Chain: chain, - Authorization: cardAuthorization, - UserAddress: e.UserAddress, - CumulativeValue: value, - Quote: e.Quote, - TxDBID: db.ID, - processingFeeAsset: processingFeeAsset, - preBalance: preBalance, - userId: userId, - } - go t.postProcess(post) - - return model.TransactionReceipt{TxID: txID, TxURL: chain.Explorer + "/tx/" + txID}, nil + err = t.updateTransactionStatus("RPC Dialed", transactionModel.ID) + if err != nil { + return p, common.StringError(err) + } + + return p, err } -func (t *transaction) getStringInstrumentsAndUserId() { - t.ids = GetStringIdsFromEnv() +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) + if err != nil { + return p, common.StringError(err) + } + err = t.updateTransactionStatus("Tested and Estimated", p.transactionModel.ID) + if err != nil { + return p, common.StringError(err) + } + + // Verify the Quote and update model status + _, err = verifyQuote(*p.executionRequest, estimateUSD) + if err != nil { + return p, common.StringError(err) + } + err = t.updateTransactionStatus("Quote Verified", p.transactionModel.ID) + if err != nil { + return p, common.StringError(err) + } + + // Get current balance of primary token + preBalance, err := (*p.executor).GetBalance() + p.preBalance = &preBalance + 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")) + } + + // Authorize quoted cost on end-user CC and update model status + p, err = t.authCard(p) + if err != nil { + return p, common.StringError(err) + } + + // Validate Transaction through Real Time Rules engine + err = t.unit21Evaluate(p.transactionModel.ID) + if err != nil { + return p, common.StringError(err) + } + + return p, nil } func (t transaction) populateInitialTxModelData(e model.ExecutionRequest, m *model.TransactionUpdates) (model.Asset, error) { txType := "fiat-to-crypto" m.Type = &txType - // TODO populate db.Tags with key-val pairs for Unit21 - // TODO populate db.DeviceID with info from fingerprint - // TODO populate db.IPAddress with info from fingerprint - // TODO populate db.PlatformID with UUID of customer + // TODO populate transactionModel.Tags with key-val pairs for Unit21 + // TODO populate transactionModel.DeviceID with info from fingerprint + // TODO populate transactionModel.IPAddress with info from fingerprint + // TODO populate transactionModel.PlatformID with UUID of customer // bytes, err := json.Marshal() contractParams := pq.StringArray(e.CxParams) @@ -249,7 +372,7 @@ func (t transaction) populateInitialTxModelData(e model.ExecutionRequest, m *mod contractFunc := e.CxFunc + e.CxReturn m.ContractFunc = &contractFunc - asset, err := t.repos.Asset.GetName("USD") + asset, err := t.repos.Asset.GetByName("USD") if err != nil { return model.Asset{}, common.StringError(err) } @@ -280,7 +403,7 @@ func (t transaction) testTransaction(executor Executor, request model.Transactio wei := gas.Add(&estimateEVM.Value, gas) eth := common.WeiToEther(wei) - chainID, err := executor.GetChainID() + chainID, err := executor.GetByChainId() if err != nil { return res, eth, common.StringError(err) } @@ -294,26 +417,6 @@ func (t transaction) testTransaction(executor Executor, request model.Transactio TokenName: "", } - // // TODO: Determine the output of the transaction! - // // We need to determine the DestinationTXLeg here - // destinationLeg := model.TxLeg{ - // Timestamp: time.Now(), // null? Should be updated when the tx occurs - // 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: recipientId, // the user who received the asset - // InstrumentID: userWalletId, // the instrument which received the asset (wallet usually) - // } - // destinationLeg, err = t.repos.TxLeg.Create(destinationLeg) - // if err != nil { - // return res, eth, common.StringError(err) - // } - // txLeg := model.TransactionUpdates{DestinationTxLegID: &destinationLeg.ID} - // err = t.repos.Transaction.Update(txUUID, txLeg) - // if err != nil { - // return res, eth, common.StringError(err) - // } - // Estimate Cost in USD to execute Tx request estimateUSD, err := cost.EstimateTransaction(estimationParams, chain) if err != nil { @@ -348,25 +451,37 @@ func verifyQuote(e model.ExecutionRequest, newEstimate model.Quote) (bool, error return true, nil } -func (t transaction) addCardInstrumentIdIfNew(fingerprint string, userID string, last4 string) (string, error) { - instrument, err := t.repos.Instrument.GetWallet(fingerprint) // temporarily using get wallet and storing it there +func (t transaction) addCardInstrumentIdIfNew(p transactionProcessingData) (string, error) { + instrument, err := t.repos.Instrument.GetCardByFingerprint(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 } + // We should gather type from the payment processor + instrument_type := "Debit Card" + if p.cardAuthorization.CardType == "CREDIT" { + instrument_type = "Credit Card" + } // Create a new instrument - instrument = model.Instrument{Type: "card", Status: "authorized", Last4: last4, UserID: userID, PublicKey: fingerprint} // No locationID until fingerprint + instrument = model.Instrument{ // No locationID until fingerprint + Type: instrument_type, + Status: "created", + Last4: p.cardAuthorization.Last4, + UserID: *p.userId, + PublicKey: p.cardAuthorization.CheckoutFingerprint, + } instrument, err = t.repos.Instrument.Create(instrument) if err != nil { return "", common.StringError(err) } + go t.unit21CreateInstrument(instrument) return instrument.ID, nil } func (t transaction) addWalletInstrumentIdIfNew(address string, id string) (string, error) { - instrument, err := t.repos.Instrument.GetWallet(address) + instrument, err := t.repos.Instrument.GetWalletByAddr(address) if err != nil && !strings.Contains(err.Error(), "not found") { return "", common.StringError(err) } else if err == nil && instrument.PublicKey == address { @@ -374,89 +489,141 @@ func (t transaction) addWalletInstrumentIdIfNew(address string, id string) (stri } // Create a new instrument - instrument = model.Instrument{Type: "crypto-wallet", 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 = 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) if err != nil { return "", common.StringError(err) } + go t.unit21CreateInstrument(instrument) return instrument.ID, nil } -func (t transaction) authCard(userWallet string, cardToken string, usd float64, chargeAsset model.Asset, dbID string, userId string) (AuthorizedCharge, error) { +func (t transaction) authCard(p transactionProcessingData) (transactionProcessingData, error) { // auth their card - auth, err := AuthorizeCharge(usd, userWallet, cardToken) + p, err := AuthorizeCharge(p) if err != nil { - return auth, common.StringError(err) + return p, common.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(auth.CheckoutFingerprint, userId, auth.Last4) + instrumentId, err := t.addCardInstrumentIdIfNew(p) if err != nil { - return auth, common.StringError(err) + return p, common.StringError(err) } // Create Origin Tx leg - usdWei := floatToFixedString(usd, int(chargeAsset.Decimals)) + usdWei := floatToFixedString(p.executionRequest.TotalUSD, int(p.processingFeeAsset.Decimals)) origin := model.TxLeg{ Timestamp: time.Now(), Amount: usdWei, Value: usdWei, - AssetID: chargeAsset.ID, - UserID: userId, + AssetID: p.processingFeeAsset.ID, + UserID: *p.userId, InstrumentID: instrumentId, } origin, err = t.repos.TxLeg.Create(origin) if err != nil { - return auth, common.StringError(err) + return p, common.StringError(err) + } + txLegUpdates := model.TransactionUpdates{OriginTxLegID: &origin.ID} + err = t.repos.Transaction.Update(p.transactionModel.ID, txLegUpdates) + if err != nil { + return p, common.StringError(err) + } + + err = t.updateTransactionStatus("Card "+p.cardAuthorization.Status, p.transactionModel.ID) + if err != nil { + return p, common.StringError(err) + } + + recipientWalletId, err := t.addWalletInstrumentIdIfNew(p.executionRequest.UserAddress, *p.userId) + p.recipientWalletId = &recipientWalletId + if err != nil { + return p, common.StringError(err) + } + + // TODO: Determine the output of the transaction (destination leg) with Tracers + destinationLeg := model.TxLeg{ + 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) + } + + destinationLeg, err = t.repos.TxLeg.Create(destinationLeg) + if err != nil { + return p, common.StringError(err) } - txLeg := model.TransactionUpdates{OriginTxLegID: &origin.ID} - err = t.repos.Transaction.Update(dbID, txLeg) + + txLegUpdates = model.TransactionUpdates{DestinationTxLegID: &destinationLeg.ID} + + err = t.repos.Transaction.Update(p.transactionModel.ID, txLegUpdates) if err != nil { - return auth, common.StringError(err) + return p, common.StringError(err) } - go t.unit21CreateInstrument(origin.InstrumentID) + if !p.cardAuthorization.Approved { + err := t.unit21CreateTransaction(p.transactionModel.ID) + if err != nil { + return p, common.StringError(err) + } - return auth, nil + return p, common.StringError(errors.New("payment: Authorization Declined by Checkout")) + } + + return p, nil } -func (t transaction) initiateTransaction(executor Executor, e model.ExecutionRequest, chargeAsset model.Asset, txUUID string, userId string) (string, *big.Int, error) { +func (t transaction) initiateTransaction(p transactionProcessingData) (transactionProcessingData, error) { call := ContractCall{ - CxAddr: e.CxAddr, - CxFunc: e.CxFunc, - CxReturn: e.CxReturn, - CxParams: e.CxParams, - TxValue: e.TxValue, - TxGasLimit: e.TxGasLimit, + 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 := executor.Initiate(call) + + txID, value, err := (*p.executor).Initiate(call) + p.cumulativeValue = value if err != nil { - return "", nil, common.StringError(err) + return p, common.StringError(err) } + p.txId = &txID // Create Response Tx leg eth := common.WeiToEther(value) wei := floatToFixedString(eth, 18) - usd := floatToFixedString(e.TotalUSD, int(chargeAsset.Decimals)) + usd := floatToFixedString(p.executionRequest.TotalUSD, int(p.processingFeeAsset.Decimals)) responseLeg := model.TxLeg{ Timestamp: time.Now(), Amount: wei, Value: usd, - AssetID: chargeAsset.ID, - UserID: userId, + AssetID: p.processingFeeAsset.ID, + UserID: *p.userId, InstrumentID: t.ids.StringWalletId, } responseLeg, err = t.repos.TxLeg.Create(responseLeg) if err != nil { - return txID, value, common.StringError(err) + return p, common.StringError(err) } txLeg := model.TransactionUpdates{ResponseTxLegID: &responseLeg.ID} - err = t.repos.Transaction.Update(txUUID, txLeg) + err = t.repos.Transaction.Update(p.transactionModel.ID, txLeg) + if err != nil { + return p, common.StringError(err) + } + + 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 txID, value, common.StringError(err) + return p, common.StringError(err) } - return txID, value, nil + return p, nil } func confirmTx(executor Executor, txID string) (uint64, error) { @@ -467,211 +634,88 @@ func confirmTx(executor Executor, txID string) (uint64, error) { return trueGas, nil } -func (t transaction) chargeCard(userWallet string, authorizationID string, usd float64, chargeAsset model.Asset, txUUID string, userId string) error { - _, err := CaptureCharge(usd, userWallet, authorizationID) - if err != nil { - return common.StringError(err) - } - - // Create Receipt Tx leg - usdWei := floatToFixedString(usd, int(chargeAsset.Decimals)) - receiptLeg := model.TxLeg{ - Timestamp: time.Now(), - Amount: usdWei, - Value: usdWei, - AssetID: chargeAsset.ID, - UserID: t.ids.StringUserId, - InstrumentID: t.ids.StringBankId, - } - receiptLeg, err = t.repos.TxLeg.Create(receiptLeg) - if err != nil { - return common.StringError(err) - } - txLeg := model.TransactionUpdates{ReceiptTxLegID: &receiptLeg.ID} - err = t.repos.Transaction.Update(txUUID, txLeg) - if err != nil { - return common.StringError(err) - } - - return nil -} - // TODO: rewrite this transaction to reference the asset(s) received by the user, not what we paid -func (t transaction) tenderTransaction(cumulativeValue *big.Int, cumulativeGas uint64, quotedTotal float64, chain Chain, txUUID string, recipientId string, userWalletId string) (float64, error) { +func (t transaction) tenderTransaction(p transactionProcessingData) (float64, error) { cost := NewCost(t.redis) - trueWei := big.NewInt(0).Add(cumulativeValue, big.NewInt(int64(cumulativeGas))) + trueWei := big.NewInt(0).Add(p.cumulativeValue, big.NewInt(int64(*p.trueGas))) trueEth := common.WeiToEther(trueWei) - trueUSD, err := cost.LookupUSD(chain.CoingeckoName, trueEth) + trueUSD, err := cost.LookupUSD(p.chain.CoingeckoName, trueEth) if err != nil { return 0, common.StringError(err) } - profit := quotedTotal - trueUSD + profit := p.executionRequest.Quote.TotalUSD - trueUSD // Create Receive Tx leg - asset, err := t.repos.Asset.GetName("ETH") + asset, err := t.repos.Asset.GetById(p.chain.GasTokenID) if err != nil { return profit, common.StringError(err) } wei := floatToFixedString(trueEth, int(asset.Decimals)) - usd := floatToFixedString(quotedTotal, 6) - destinationLeg := model.TxLeg{ - Timestamp: time.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: recipientId, // the user who received the asset - InstrumentID: userWalletId, // the instrument which received the asset (wallet usually) - } - destinationLeg, err = t.repos.TxLeg.Create(destinationLeg) - if err != nil { - return profit, common.StringError(err) - } - txLeg := model.TransactionUpdates{DestinationTxLegID: &destinationLeg.ID} - err = t.repos.Transaction.Update(txUUID, txLeg) + usd := floatToFixedString(p.executionRequest.Quote.TotalUSD, 6) + + txModel, err := t.repos.Transaction.GetById(p.transactionModel.ID) if err != nil { return profit, common.StringError(err) } - return profit, nil -} - -type postProcessRequest struct { - TxID string - Chain Chain - Authorization AuthorizedCharge - UserAddress string - CumulativeGas uint64 - CumulativeValue *big.Int - Quote model.Quote - TxDBID string - processingFeeAsset model.Asset - preBalance float64 - userId string -} - -func (t transaction) postProcess(request postProcessRequest) { - executor := NewExecutor() - err := executor.Initialize(request.Chain.RPC) - if err != nil { - // TODO: Handle error instead of returning it - } - updateDB := model.TransactionUpdates{} - status := "Post Process RPC Dialed" - updateDB.Status = &status - err = t.repos.Transaction.Update(request.TxDBID, updateDB) - if err != nil { - // TODO: Handle error instead of returning it + now := time.Now() + destinationLeg := model.TxLegUpdates{ + 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) } - // confirm the Tx on the EVM, update db status and NetworkFee - trueGas, err := confirmTx(executor, request.TxID) - if err != nil { - // TODO: Handle error instead of returning it - } - status = "Tx Confirmed" - updateDB.Status = &status - networkFee := strconv.FormatUint(trueGas, 10) - updateDB.NetworkFee = &networkFee // geth uses uint64 for gas - err = t.repos.Transaction.Update(request.TxDBID, updateDB) + // We now update the destination leg instead of creating it + err = t.repos.TxLeg.Update(txModel.DestinationTxLegID, destinationLeg) if err != nil { - // TODO: Handle error instead of returning it - } - - // Check and see if balance threshold was crossed - postBalance, err := executor.GetBalance() - if err != nil { - // TODO: handle error instead of returning it - } - // TODO: store threshold on a per-network basis in the repo - threshold := 10.0 - if request.preBalance >= threshold && postBalance < threshold { - msg := fmt.Sprintf("STRING-API: %s balance is < %.2f at %.2f", request.Chain.OwlracleName, threshold, postBalance) - MessageStaff(msg) - if err != nil { - // TODO: handle error instead of returning it - } + return profit, common.StringError(err) } - // compute profit, update db status and processing fees to db - // TODO: factor request.processingFeeAsset in the event of crypto-to-usd - recipientWalletId, err := t.addWalletInstrumentIdIfNew(request.UserAddress, request.userId) - if err != nil { - // TODO: handle error instead of returning it - } - profit, err := t.tenderTransaction(request.CumulativeValue, trueGas, request.Quote.TotalUSD, request.Chain, request.TxDBID, request.userId, recipientWalletId) - if err != nil { - // TODO: Handle error instead of returning it - } - fmt.Printf("PROFIT=%+v", profit) - status = "Profit Tendered" - updateDB.Status = &status - stringFee := floatToFixedString(profit, 6) - processingFee := floatToFixedString(profit, 6) // TODO: set processingFee based on payment method, and location - updateDB.StringFee = &stringFee // string fee is always USD with 6 digits - updateDB.ProcessingFee = &processingFee - err = t.repos.Transaction.Update(request.TxDBID, updateDB) - if err != nil { - // TODO: Handle error instead of returning it - } + return profit, nil +} - // charge the users CC - err = t.chargeCard(request.UserAddress, request.Authorization.AuthID, request.Quote.TotalUSD, request.processingFeeAsset, request.TxDBID, request.userId) +func (t transaction) chargeCard(p transactionProcessingData) error { + p, err := CaptureCharge(p) if err != nil { - // TODO: Handle error instead of returning it - } - 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(request.TxDBID, updateDB) - if err != nil { - // TODO: Handle error instead of returning it + return common.StringError(err) } - status = "Completed" - updateDB.Status = &status - err = t.repos.Transaction.Update(request.TxDBID, updateDB) - if err != nil { - // TODO: Handle error instead of returning it + // Create Receipt Tx leg + usdWei := floatToFixedString(p.executionRequest.Quote.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, } - executor.Close() - // Create Transaction data in Unit21 - txModel, err := t.repos.Transaction.GetById(request.TxDBID) + receiptLeg, err = t.repos.TxLeg.Create(receiptLeg) if err != nil { - log.Printf("Error getting tx model in Unit21 in Tx Postprocess: %s", err) - // return res, common.StringError(err) - } - - u21Repo := unit21.TransactionRepo{ - TxLeg: t.repos.TxLeg, - User: t.repos.User, - Asset: t.repos.Asset, + return common.StringError(err) } - - u21Tx := unit21.NewTransaction(u21Repo) - _, err = u21Tx.Create(txModel) + txLeg := model.TransactionUpdates{ReceiptTxLegID: &receiptLeg.ID, PaymentCode: &p.cardCapture.Accepted.ActionID} + err = t.repos.Transaction.Update(p.transactionModel.ID, txLeg) if err != nil { - log.Printf("Error updating Unit21 in Tx Postprocess: %s", err) - // return res, common.StringError(err) + return common.StringError(err) } - // send email receipt - err = t.sendEmailReceipt(request) - if err != nil { - log.Printf("Error sending email receipt to user: %s", err) - } + return nil } -func (t transaction) sendEmailReceipt(request postProcessRequest) error { - user, err := t.repos.User.GetById(request.userId) +func (t transaction) sendEmailReceipt(p transactionProcessingData) error { + user, err := t.repos.User.GetById(*p.userId) if err != nil { - log.Printf("Error getting user from repo: %s", err) - return err + log.Printf("Error getting user from repo: %s", common.StringError(err)) + return common.StringError(err) } - contact, err := t.repos.Contact.GetByUserId(request.userId) + contact, err := t.repos.Contact.GetByUserId(user.ID) if err != nil { - log.Printf("Error getting user contact from repo: %s", err) - return err + log.Printf("Error getting user contact from repo: %s", common.StringError(err)) + return common.StringError(err) } name := user.FirstName // + " " + user.MiddleName + " " + user.LastName if name == "" { @@ -680,27 +724,27 @@ func (t transaction) sendEmailReceipt(request postProcessRequest) error { receiptParams := common.ReceiptGenerationParams{ ReceiptType: "NFT Purchase", // TODO: retrieve dynamically CustomerName: name, - StringPaymentId: request.TxDBID, + StringPaymentId: p.transactionModel.ID, PaymentDescriptor: "String Digital Asset", // TODO: retrieve dynamically TransactionDate: time.Now().Format(time.RFC1123), } receiptBody := [][2]string{ - {"Transaction ID", "" + request.TxID + ""}, - {"Destination Wallet", "" + request.UserAddress + ""}, + {"Transaction ID", "" + *p.txId + ""}, + {"Destination Wallet", "" + p.executionRequest.UserAddress + ""}, {"Payment Descriptor", receiptParams.PaymentDescriptor}, - {"Payment Method", request.Authorization.Issuer + " " + request.Authorization.Last4}, + {"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(request.Quote.BaseUSD + request.Quote.TokenUSD)}, - {"Network Fee:", common.FloatToUSDString(request.Quote.GasUSD)}, - {"Processing Fee", common.FloatToUSDString(request.Quote.ServiceUSD)}, - {"Total Charge", common.FloatToUSDString(request.Quote.TotalUSD)}, + {"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", err) - return err + log.Printf("Error sending email receipt to user: %s", common.StringError(err)) + return common.StringError(err) } return nil } @@ -709,33 +753,66 @@ func floatToFixedString(value float64, decimals int) string { return strconv.FormatUint(uint64(value*(math.Pow10(decimals-1))), 10) } -func (t transaction) unit21CreateInstrument(instrumentId string) { - // Send Instrument Data to Unit21 - instrument, err := t.repos.Instrument.GetById(instrumentId) +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 + } + + u21Instrument := unit21.NewInstrument(u21InstrumentRepo) + u21InstrumentId, err := u21Instrument.Create(instrument) if err != nil { - fmt.Printf("Error creating new instrument in Unit21 -- can't get instrument model") - return + fmt.Printf("Error creating new instrument in Unit21") + return common.StringError(err) } - u21Repo := unit21.InstrumentRepo{ + // 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 } - u21Tx := unit21.NewInstrument(u21Repo) - _, err = u21Tx.Create(instrument) + u21Action := unit21.NewAction(u21ActionRepo) + _, err = u21Action.Create(instrument, "Creation", u21InstrumentId, "Creation") if err != nil { - fmt.Printf("Error creating new instrument in Unit21") + fmt.Printf("Error creating a new instrument action in Unit21") + return common.StringError(err) } + + return } -func (t transaction) unit21Evaluate(transactionId string) (evaluation bool, err error) { +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) + } + + u21Repo := unit21.TransactionRepo{ + TxLeg: t.repos.TxLeg, + User: t.repos.User, + Asset: t.repos.Asset, + } + + u21Tx := unit21.NewTransaction(u21Repo) + _, err = u21Tx.Create(txModel) + if err != nil { + log.Printf("Error updating Unit21 in Tx Postprocess: %s", common.StringError(err)) + return common.StringError(err) + } + + return nil +} + +func (t transaction) unit21Evaluate(transactionId string) (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", err) - return + log.Printf("Error getting tx model in Unit21 in Tx Evaluate: %s", common.StringError(err)) + return common.StringError(err) } u21Repo := unit21.TransactionRepo{ @@ -745,12 +822,43 @@ func (t transaction) unit21Evaluate(transactionId string) (evaluation bool, err } u21Tx := unit21.NewTransaction(u21Repo) - evaluation, err = u21Tx.Evaluate(txModel) + evaluation, err := u21Tx.Evaluate(txModel) if err != nil { - log.Printf("Error evaluating transaction in Unit21: %s", err) - return + log.Printf("Error evaluating transaction in Unit21: %s", common.StringError(err)) + return common.StringError(err) } - return + if !evaluation { + err = t.updateTransactionStatus("Failed", transactionId) + if err != nil { + return common.StringError(err) + } + + err = t.unit21CreateTransaction(transactionId) + if err != nil { + return common.StringError(err) + } + return common.StringError(errors.New("risk: Transaction Failed Unit21 Real Time Rules Evaluation")) + } + err = t.updateTransactionStatus("Unit21 Authorized", transactionId) + if err != nil { + return common.StringError(err) + } + + return nil +} + +func (t transaction) updateTransactionStatus(status string, transactionId string) (err error) { + updateDB := &model.TransactionUpdates{Status: &status} + err = t.repos.Transaction.Update(transactionId, updateDB) + if err != nil { + return common.StringError(err) + } + + return nil +} + +func (t *transaction) getStringInstrumentsAndUserId() { + t.ids = GetStringIdsFromEnv() } diff --git a/pkg/service/user.go b/pkg/service/user.go index 13d485f9..303c213e 100644 --- a/pkg/service/user.go +++ b/pkg/service/user.go @@ -93,12 +93,38 @@ func (u user) Create(request model.WalletSignaturePayloadSigned) (UserCreateResp return resp, common.StringError(err) } - user, device, err := u.createUserData(addr, request.Fingerprint.VisitorID, request.Fingerprint.RequestID) + user, err := u.createUserData(addr) if err != nil { return resp, err } - jwt, err := u.auth.GenerateJWT(device) + var device model.Device + + // 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 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) + } + } + } + + jwt, err := u.auth.GenerateJWT(user.ID, device) if err != nil { return resp, common.StringError(err) } @@ -109,7 +135,7 @@ func (u user) Create(request model.WalletSignaturePayloadSigned) (UserCreateResp return UserCreateResponse{JWT: jwt, User: user}, nil } -func (u user) createUserData(addr, visitorID, requestID string) (model.User, model.Device, error) { +func (u user) createUserData(addr string) (model.User, error) { tx := u.repos.User.MustBegin() u.repos.Instrument.SetTx(tx) u.repos.Device.SetTx(tx) @@ -121,41 +147,21 @@ func (u user) createUserData(addr, visitorID, requestID string) (model.User, mod user, err := u.repos.User.Create(user) if err != nil { u.repos.User.Rollback() - return user, model.Device{}, common.StringError(err) + return user, common.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 := model.Instrument{Type: "Crypto Wallet", Status: "verified", Network: "EVM", PublicKey: addr, UserID: user.ID} instrument, err = u.repos.Instrument.Create(instrument) if err != nil { u.repos.Instrument.Rollback() - return user, model.Device{}, common.StringError(err) - } - - visitor, err := u.fingerprint.GetVisitor(visitorID, requestID) - if err != nil { - u.repos.Instrument.Rollback() - return user, model.Device{}, err // is this intentionally not common.StringError? - } - 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 { - u.repos.Device.Rollback() - return user, model.Device{}, err // is this intentionally not common.StringError? + return user, common.StringError(err) } if err := u.repos.User.Commit(); err != nil { - return user, model.Device{}, common.StringError(errors.New("error commiting transaction")) + return user, common.StringError(errors.New("error commiting transaction")) } - return user, device, nil + return user, nil } func (u user) Update(userID string, request UserUpdates) (model.User, error) { @@ -173,9 +179,9 @@ func (u user) Update(userID string, request UserUpdates) (model.User, error) { func (u user) createUnit21Entity(user model.User) { // Createing a User Entity in Unit21 u21Repo := unit21.EntityRepos{ - Device: u.repos.Device, - Contact: u.repos.Contact, - UserPlatform: u.repos.UserPlatform, + Device: u.repos.Device, + Contact: u.repos.Contact, + UserToPlatform: u.repos.UserToPlatform, } u21Entity := unit21.NewEntity(u21Repo) // TODO: Make it an injected dependency @@ -188,9 +194,9 @@ func (u user) createUnit21Entity(user model.User) { func (u user) updateUnit21Entity(user model.User) { // Createing a User Entity in Unit21 u21Repo := unit21.EntityRepos{ - Device: u.repos.Device, - Contact: u.repos.Contact, - UserPlatform: u.repos.UserPlatform, + Device: u.repos.Device, + Contact: u.repos.Contact, + UserToPlatform: u.repos.UserToPlatform, } u21Entity := unit21.NewEntity(u21Repo) diff --git a/pkg/service/verification.go b/pkg/service/verification.go index c9751ed1..e91059dd 100644 --- a/pkg/service/verification.go +++ b/pkg/service/verification.go @@ -34,8 +34,7 @@ type Verification interface { // VerifyEmail verifies the provided email and creates a contact VerifyEmail(encrypted string) error - SendDeviceVerification(userID string, deviceID string, deviceDescription string) error - VerifyDevice(encrypted string) error + SendDeviceVerification(userID, email string, deviceID string, deviceDescription string) error } type verification struct { @@ -109,13 +108,8 @@ func (v verification) SendEmailVerification(userID, email string) error { return common.StringError(errors.New("link expired")) } -func (v verification) SendDeviceVerification(userID, deviceID, deviceDescription string) error { - email, err := v.repos.Contact.GetByUserIdAndStatus(userID, "validated") - if err != nil { - log.Err(err).Msg("Error getting a valid email") - return err - } - log.Info().Str("email", email.Data) +func (v verification) SendDeviceVerification(userID, email, deviceID, 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) if err != nil { @@ -126,11 +120,13 @@ func (v verification) SendDeviceVerification(userID, deviceID, deviceDescription baseURL := common.GetBaseURL() from := mail.NewEmail("String XYZ", "auth@string.xyz") subject := "New Device Login Verification" - to := mail.NewEmail("New Device Login", email.Data) + to := mail.NewEmail("New Device Login", email) link := baseURL + "verification?type=device&token=" + code - htmlContent := fmt.Sprintf(`
We noticed that you attempted to log in from a new device %s. Is this you?
+ + 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`, - deviceDescription, link) + textContent, link) message := mail.NewSingleEmail(from, subject, to, "", htmlContent) client := sendgrid.NewSendClient(os.Getenv("SENDGRID_API_KEY")) @@ -168,17 +164,3 @@ func (v verification) VerifyEmail(encrypted string) error { return nil } - -func (v verification) VerifyDevice(encrypted string) error { - key := os.Getenv("STRING_ENCRYPTION_KEY") - received, err := common.Decrypt[DeviceVerification](encrypted, key) - if err != nil { - return common.StringError(err) - } - now := time.Now() - if now.Unix()-received.Timestamp > (60 * 15) { - return common.StringError(errors.New("link expired")) - } - err = v.repos.Device.Update(received.DeviceID, model.DeviceUpdates{ValidatedAt: &now}) - return err -} diff --git a/pkg/test/stubs/repository.go b/pkg/test/stubs/repository.go index a132be47..2dad2992 100644 --- a/pkg/test/stubs/repository.go +++ b/pkg/test/stubs/repository.go @@ -49,7 +49,7 @@ import ( // return model.Asset{}, nil // } -// func (Asset) GetName(name string) (model.Asset, error) { +// func (Asset) GetByName(name string) (model.Asset, error) { // if name == "AVAX" { // return avax, nil // } diff --git a/scripts/data_seeding.go b/scripts/data_seeding.go index 805bfdf5..2dd9a52d 100644 --- a/scripts/data_seeding.go +++ b/scripts/data_seeding.go @@ -186,7 +186,8 @@ func DataSeeding() { // Platforms, placeholder /*platformDeveloper*/ - placeholderPlatform, err := repos.Platform.Create(model.Platform{Type: "Game", Status: "Verified", Name: "Nintendo", ApiKey: "Internal", Authentication: "Email"}) + placeholderPlatform, err := repos.Platform.Create(model.Platform{Name: "Nintendo", Description: "Fun"}) + if err != nil { panic(err) } @@ -377,7 +378,7 @@ func MockSeeding() { // Platforms, placeholder /*platformDeveloper*/ - placeholderPlatform, err := repos.Platform.Create(model.Platform{Type: "Game", Status: "Verified", Name: "Nintendo", ApiKey: "Internal", Authentication: "Email"}) + placeholderPlatform, err := repos.Platform.Create(model.Platform{Name: "Nintendo", Description: "Fun"}) if err != nil { panic(err) }