diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..13237ca61 --- /dev/null +++ b/.env.example @@ -0,0 +1,49 @@ +# Generate a new keypair for the distribution account +SEP10_SIGNING_PUBLIC_KEY= +SEP10_SIGNING_PRIVATE_KEY= + +# Generate a new keypair for the distribution account +DISTRIBUTION_PUBLIC_KEY= +DISTRIBUTION_SEED= +DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE=${DISTRIBUTION_SEED} +CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE=${DISTRIBUTION_SEED} + +# Anchor platform configuration +ANCHOR_PLATFORM_BASE_PLATFORM_URL=http://localhost:8085 +ANCHOR_PLATFORM_BASE_SEP_URL=http://localhost:8080 +ANCHOR_PLATFORM_BASE_URL=http://localhost:8090 + +CORS_ALLOWED_ORIGINS=* +BASE_URL=http://stellar.local:8000 +SDP_UI_BASE_URL=http://stellar.local:3000 +DATABASE_URL="postgres://postgres@localhost:5432/sdp_mtn?sslmode=disable" + +LOG_LEVEL=info +EC256_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEJ3HNphPAEKHvtRjsl5Kjwc9tTMqS\n2pmYNybrLsxZ6cuQvg2yiEoXZixP2cJ77csHClXC6cb1wQp/BNGDvGKoPg==\n-----END PUBLIC KEY-----" +EC256_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgdo6o+tdFkF94B7z8\nnoybH6/zO3PryLLjLbj54/zOi4WhRANCAAQncc2mE8AQoe+1GOyXkqPBz21MypLa\nmZg3JusuzFnpy5C+DbKIShdmLE/ZwnvtywcKVcLpxvXBCn8E0YO8Yqg+\n-----END PRIVATE KEY-----" +EMAIL_SENDER_TYPE=DRY_RUN +SMS_SENDER_TYPE=DRY_RUN +DISABLE_MFA=true +DISABLE_RECAPTCHA=true +RECAPTCHA_SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI +RECAPTCHA_SITE_SECRET_KEY=6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe +SEP24_JWT_SECRET='jwt_secret_1234567890' +ANCHOR_PLATFORM_OUTGOING_JWT_SECRET=mySdpToAnchorPlatformSecret + +# multi-tenant +INSTANCE_NAME="SDP Testnet on Docker" +ADMIN_PORT="8003" +TENANT_XLM_BOOTSTRAP_AMOUNT=5 +ADMIN_ACCOUNT=SDP-admin +ADMIN_API_KEY=api_key_1234567890 + +# Event broker configuration +EVENT_BROKER_TYPE=kafka +BROKER_URLS=localhost:9094 +KAFKA_SECURITY_PROTOCOL=PLAINTEXT +CONSUMER_GROUP_ID=group-id + +# scheduler options +ENABLE_SCHEDULER=false +SCHEDULER_RECEIVER_INVITATION_JOB_SECONDS="10" +SCHEDULER_PAYMENT_JOB_SECONDS="10" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..4083e612a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,41 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "sunday" + time: "02:00" + open-pull-requests-limit: 2 + groups: + minor-and-patch: + applies-to: version-updates + update-types: + - "patch" + - "minor" + major: + applies-to: version-updates + update-types: + - "major" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "sunday" + time: "02:00" + open-pull-requests-limit: 2 + groups: + all-actions: + applies-to: version-updates + patterns: [ "*" ] + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "sunday" + time: "02:00" + open-pull-requests-limit: 2 + groups: + all-docker: + applies-to: version-updates + patterns: [ "*" ] \ No newline at end of file diff --git a/.github/workflows/anchor_platform_integration_check.yml b/.github/workflows/anchor_platform_integration_check.yml index eb95a70fa..4de40c209 100644 --- a/.github/workflows/anchor_platform_integration_check.yml +++ b/.github/workflows/anchor_platform_integration_check.yml @@ -19,6 +19,7 @@ jobs: DISTRIBUTION_PUBLIC_KEY: ${{ vars.DISTRIBUTION_PUBLIC_KEY }} DISTRIBUTION_SEED: ${{ vars.DISTRIBUTION_SEED }} CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE: ${{ vars.DISTRIBUTION_SEED }} + DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE: ${{ vars.DISTRIBUTION_SEED }} SEP10_SIGNING_PUBLIC_KEY: ${{ vars.SEP10_SIGNING_PUBLIC_KEY }} SEP10_SIGNING_PRIVATE_KEY: ${{ vars.SEP10_SIGNING_PRIVATE_KEY }} steps: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fe5b8607..898a498f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,10 +36,11 @@ jobs: - name: Run ./gomod.sh run: ./gomod.sh - - name: Install github.com/nishanths/exhaustive@v0.12.0 and golang.org/x/tools/cmd/deadcode@v0.18.0 + - name: Install nishanths/exhaustive@v0.12.0, deadcode@v0.18.0 and goimports@v0.22.0 run: | go install github.com/nishanths/exhaustive/cmd/exhaustive@v0.12.0 go install golang.org/x/tools/cmd/deadcode@v0.18.0 + go install golang.org/x/tools/cmd/goimports@v0.22.0 - name: Run `exhaustive` run: exhaustive -default-signifies-exhaustive ./... @@ -55,6 +56,19 @@ jobs: echo "✅ No deadcode found" fi + - name: Run `goimports` + run: | + # Find all .go files excluding paths containing 'mock' and run goimports + non_compliant_files=$(find . -type f -name "*.go" ! -path "*mock*" | xargs goimports -local "github.com/stellar/stellar-disbursement-platform-backend" -l) + + if [ -n "$non_compliant_files" ]; then + echo "🚨 The following files are not compliant with goimports:" + echo "$non_compliant_files" + exit 1 + else + echo "✅ All files are compliant with goimports." + fi + check-helm-readme: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/e2e_integration_test.yml b/.github/workflows/e2e_integration_test.yml index 42c3589fe..585a02ba0 100644 --- a/.github/workflows/e2e_integration_test.yml +++ b/.github/workflows/e2e_integration_test.yml @@ -1,4 +1,4 @@ -name: E2E integration test +name: Integration Tests on: push: @@ -14,54 +14,89 @@ on: env: USER_EMAIL: "sdp_user@stellar.org" USER_PASSWORD: "mockPassword123!" + DISTRIBUTION_PUBLIC_KEY: ${{ vars.DISTRIBUTION_PUBLIC_KEY }} + DISTRIBUTION_SEED: ${{ vars.DISTRIBUTION_SEED }} + CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE: ${{ vars.DISTRIBUTION_SEED }} + SEP10_SIGNING_PUBLIC_KEY: ${{ vars.SEP10_SIGNING_PUBLIC_KEY }} + SEP10_SIGNING_PRIVATE_KEY: ${{ vars.SEP10_SIGNING_PRIVATE_KEY }} + CIRCLE_API_KEY: ${{ vars.CIRCLE_API_KEY }} + CIRCLE_USDC_WALLET_ID: ${{ vars.CIRCLE_USDC_WALLET_ID }} jobs: - e2e-integration-test: + e2e: runs-on: ubuntu-latest - environment: "Receiver Registration - E2E Integration Tests" + strategy: + matrix: + platform: + - "Stellar" + - "Circle" + include: + - platform: "Stellar" + environment: "Receiver Registration - E2E Integration Tests (Stellar)" + DISTRIBUTION_ACCOUNT_TYPE: "DISTRIBUTION_ACCOUNT.STELLAR.ENV" + - platform: "Circle" + environment: "Receiver Registration - E2E Integration Tests (Circle)" + DISTRIBUTION_ACCOUNT_TYPE: "DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT" + environment: ${{ matrix.environment }} env: - DISTRIBUTION_PUBLIC_KEY: ${{ vars.DISTRIBUTION_PUBLIC_KEY }} - DISTRIBUTION_SEED: ${{ vars.DISTRIBUTION_SEED }} - CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE: ${{ vars.DISTRIBUTION_SEED }} - SEP10_SIGNING_PUBLIC_KEY: ${{ vars.SEP10_SIGNING_PUBLIC_KEY }} - SEP10_SIGNING_PRIVATE_KEY: ${{ vars.SEP10_SIGNING_PRIVATE_KEY }} + DISTRIBUTION_ACCOUNT_TYPE: ${{ matrix.DISTRIBUTION_ACCOUNT_TYPE }} steps: - name: Checkout uses: actions/checkout@v4 - name: Cleanup data - working-directory: internal/integrationtests + working-directory: internal/integrationtests/docker run: docker-compose -f docker-compose-e2e-tests.yml down -v + shell: bash - name: Run Docker Compose for SDP, Anchor Platform and TSS - working-directory: internal/integrationtests + working-directory: internal/integrationtests/docker run: docker-compose -f docker-compose-e2e-tests.yml up --build -V -d + shell: bash - name: Install curl run: sudo apt-get update && sudo apt-get install -y curl + shell: bash - name: Create integration test data run: | docker exec e2e-sdp-api bash -c "./stellar-disbursement-platform integration-tests create-data" + shell: bash - - name: Restart anchor platform + - name: Restart Anchor Platform run: | docker restart e2e-anchor-platform + shell: bash - - name: Wait for anchor platform localhost:8080/health - timeout-minutes: 5 + - name: Wait for Anchor Platform at both localhost:8080/health and localhost:8085/health run: | - until curl --output /dev/null --silent --head --fail http://localhost:8080/health; do - echo 'Waiting for anchor-platform to be up and running...' - sleep 15 - done - echo 'Anchor-platform is up and running.' + wait_for_server() { + local endpoint=$1 + local max_wait_time=$2 + + SECONDS=0 + while ! curl -s $endpoint > /dev/null; do + echo "Waiting for server at $endpoint to be up... $SECONDS seconds elapsed" + sleep 4 + if [ $SECONDS -ge $max_wait_time ]; then + echo "Server at $endpoint is not up after $max_wait_time seconds." + exit 1 + fi + done + echo "Server at $endpoint is up." + } + + wait_for_server http://localhost:8080/health 120 + wait_for_server http://localhost:8085/health 120 + shell: bash - name: Start integration test command run: | docker exec e2e-sdp-api bash -c "./stellar-disbursement-platform integration-tests start" + shell: bash - name: Docker logs if: always() - working-directory: internal/integrationtests + working-directory: internal/integrationtests/docker run: docker-compose -f docker-compose-e2e-tests.yml logs && docker-compose -f docker-compose-e2e-tests.yml down + shell: bash diff --git a/.github/workflows/singletenant_to_multitenant_db_migration_test.yml b/.github/workflows/singletenant_to_multitenant_db_migration_test.yml new file mode 100644 index 000000000..3c0d78de3 --- /dev/null +++ b/.github/workflows/singletenant_to_multitenant_db_migration_test.yml @@ -0,0 +1,145 @@ +name: Single-tenant to Multi-tenant + +on: + push: + branches: + - main + - develop + - "release/**" + - "releases/**" + - "hotfix/**" + pull_request: + +env: + USER_EMAIL: "sdp_user@stellar.org" + USER_PASSWORD: "mockPassword123!" + DATABASE_URL: "postgres://postgres@db:5432/e2e-sdp?sslmode=disable" + DISTRIBUTION_ACCOUNT_TYPE: "DISTRIBUTION_ACCOUNT.STELLAR.ENV" + DISTRIBUTION_PUBLIC_KEY: ${{ vars.DISTRIBUTION_PUBLIC_KEY }} + DISTRIBUTION_SEED: ${{ vars.DISTRIBUTION_SEED }} + CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE: ${{ vars.DISTRIBUTION_SEED }} + SEP10_SIGNING_PUBLIC_KEY: ${{ vars.SEP10_SIGNING_PUBLIC_KEY }} + SEP10_SIGNING_PRIVATE_KEY: ${{ vars.SEP10_SIGNING_PRIVATE_KEY }} + +jobs: + db-migration: + runs-on: ubuntu-latest + environment: "Receiver Registration - E2E Integration Tests (Stellar)" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cleanup data + working-directory: internal/integrationtests/docker + run: docker-compose -f docker-compose-e2e-tests.yml down -v + shell: bash + + - name: Run Docker Compose for SDP, Anchor Platform and TSS + working-directory: internal/integrationtests/docker + run: docker-compose -f docker-compose-e2e-tests.yml up --build -V -d + shell: bash + + - name: Install curl + run: sudo apt-get update && sudo apt-get install -y curl + shell: bash + + - name: Copy DB Dump to Container and Restore + run: | + docker cp internal/integrationtests/resources/single_tenant_dump.sql e2e-sdp-v2-database:/tmp/single_tenant_dump.sql + docker exec e2e-sdp-v2-database bash -c "psql -d $DATABASE_URL -f /tmp/single_tenant_dump.sql" + + - name: Provision New Tenant + run: | + adminAccount="SDP-admin" + adminApiKey="api_key_1234567890" + encodedCredentials=$(echo -n "$adminAccount:$adminApiKey" | base64) + AuthHeader="Authorization: Basic $encodedCredentials" + tenant="migrated-tenant" + baseURL="http://$tenant.stellar.local:8000" + sdpUIBaseURL="http://$tenant.stellar.local:3000" + ownerEmail="init_owner@$tenant.local" + AdminTenantURL="http://localhost:8003/tenants" + response=$(curl -s -w "\n%{http_code}" -X POST $AdminTenantURL \ + -H "Content-Type: application/json" \ + -H "$AuthHeader" \ + -d '{ + "name": "'"$tenant"'", + "organization_name": "'"$tenant"'", + "base_url": "'"$baseURL"'", + "sdp_ui_base_url": "'"$sdpUIBaseURL"'", + "owner_email": "'"$ownerEmail"'", + "owner_first_name": "jane", + "owner_last_name": "doe", + "distribution_account_type": "DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT" + }') + + http_code=$(echo "$response" | tail -n1) + response_body=$(echo "$response" | sed '$d') + + if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then + echo "✅ Tenant $tenant created successfully." + echo "🔗 You can now reset the password for the owner $ownerEmail on $sdpUIBaseURL/forgot-password" + echo "Response body: $response_body" + else + echo "❌ Failed to create tenant $tenant. HTTP status code: $http_code" + echo "Server response: $response_body" + exit 1 + fi + + - name: Run Migration + run: | + docker exec e2e-sdp-v2-database bash -c "psql -d $DATABASE_URL -c \"SELECT admin.migrate_tenant_data_from_v1_to_v2('migrated-tenant');\"" + + - name: Verify Row Counts + run: | + submitter_public_count=$(docker exec e2e-sdp-v2-database bash -c "psql -d $DATABASE_URL -t -c 'SELECT COUNT(*) FROM public.submitter_transactions;'") + submitter_tss_count=$(docker exec e2e-sdp-v2-database bash -c "psql -d $DATABASE_URL -t -c 'SELECT COUNT(*) FROM tss.submitter_transactions;'") + receiver_public_count=$(docker exec e2e-sdp-v2-database bash -c "psql -d $DATABASE_URL -t -c 'SELECT COUNT(*) FROM public.receivers;'") + receiver_migrated_count=$(docker exec e2e-sdp-v2-database bash -c "psql -d $DATABASE_URL -t -c 'SELECT COUNT(*) FROM \"sdp_migrated-tenant\".receivers;'") + + if [ "$submitter_public_count" -eq "$submitter_tss_count" ] && [ "$submitter_public_count" -gt 0 ]; then + echo "✅ submitter_transactions row counts match and are greater than zero." + else + echo "❌ submitter_transactions row counts do not match or are not greater than zero." + exit 1 + fi + + if [ "$receiver_public_count" -eq "$receiver_migrated_count" ] && [ "$receiver_public_count" -gt 0 ]; then + echo "✅ receivers row counts match and are greater than zero." + else + echo "❌ receivers row counts do not match or are not greater than zero." + exit 1 + fi + + - name: Exclude Deprecated Tables + run: | + docker exec e2e-sdp-v2-database bash -c "psql -d $DATABASE_URL -c \" + BEGIN TRANSACTION; + DROP TABLE public.messages CASCADE; + DROP TABLE public.payments CASCADE; + DROP TABLE public.disbursements CASCADE; + DROP TABLE public.receiver_verifications CASCADE; + DROP TABLE public.receiver_wallets CASCADE; + DROP TABLE public.auth_user_password_reset CASCADE; + DROP TABLE public.auth_user_mfa_codes CASCADE; + DROP TABLE public.receivers CASCADE; + DROP TABLE public.auth_users CASCADE; + DROP TABLE public.wallets_assets CASCADE; + DROP TABLE public.assets CASCADE; + DROP TABLE public.wallets CASCADE; + DROP TABLE public.organizations CASCADE; + DROP TABLE public.gorp_migrations CASCADE; + DROP TABLE public.auth_migrations CASCADE; + DROP TABLE public.countries CASCADE; + DROP TABLE public.submitter_transactions CASCADE; + DROP TABLE public.channel_accounts CASCADE; + COMMIT; + \"" + + - name: Docker logs + if: always() + working-directory: internal/integrationtests/docker + run: | + docker-compose -f docker-compose-e2e-tests.yml logs + docker-compose -f docker-compose-e2e-tests.yml down -v + shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index f6897fef1..aea432dc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,67 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). None -## [2.0.0](https://github.com/stellar/stellar-disbursement-platform-backend/releases/tag/2.0.0) +## [2.1.0](https://github.com/stellar/stellar-disbursement-platform-backend/releases/tag/2.1.0) ([diff](https://github.com/stellar/stellar-disbursement-platform-backend/compare/2.0.0...2.1.0)) + +Release of the Stellar Disbursement Platform v2.1.0. This release introduces +the option to set different distribution account signers per tenant, as well +as Circle support, so the tenant can choose to run their payments through the +Circle API rather than directly on the Stellar network. + +This version is only compatible with the [stellar/stellar-disbursement-platform-frontend] version `2.1.0`. + +### Changed + +- Update the name of the account types used for Distribution Accounts to be more descriptive. [#285](https://github.com/stellar/stellar-disbursement-platform-backend/pull/285), [#311](https://github.com/stellar/stellar-disbursement-platform-backend/pull/311) +- When provisioning a tenant, indicate the Distribution account signer type [#319](https://github.com/stellar/stellar-disbursement-platform-backend/pull/319) +- The DistributionAccountResolver now surfaces the tenant's CircleWalletID for Circle-using tenants [#328](https://github.com/stellar/stellar-disbursement-platform-backend/pull/328) +- Disable asset management calls when the tenant is using Circle [#322](https://github.com/stellar/stellar-disbursement-platform-backend/pull/322) +- Bump version of [github.com/stellar/go](https://github.com/stellar/go) to become compatible with Protocol 21. + +### Added + +- Add a new verification type called `YEAR_MONTH` [#369](https://github.com/stellar/stellar-disbursement-platform-backend/pull/369) +- Add the ability to use different signature types per tenant, allowing for more flexibility in the signature service. [#289](https://github.com/stellar/stellar-disbursement-platform-backend/pull/289) +- Add support to provision tenants with `accountType=DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT` [#330](https://github.com/stellar/stellar-disbursement-platform-backend/pull/330) +- Circle SDK integration for the backend. [#321](https://github.com/stellar/stellar-disbursement-platform-backend/pull/321) +- Implement CircleService on top of the CircleClient, in order to automatically route the calls through the correct tenant based on the tenant value saved in the context [#331](https://github.com/stellar/stellar-disbursement-platform-backend/pull/331) +- Add support for Circle-using tenants when validating the tenant available balance upon disbursement start [#309](https://github.com/stellar/stellar-disbursement-platform-backend/pull/309), [#336](https://github.com/stellar/stellar-disbursement-platform-backend/pull/336) +- Implement [joho/godotenv](https://github.com/joho/godotenv) loader [#324](https://github.com/stellar/stellar-disbursement-platform-backend/pull/324) +- Add support for Circle-using tenants to the `db setup-for-network` CLI command [#327](https://github.com/stellar/stellar-disbursement-platform-backend/pull/327) +- Implement the `GET /balances` endpoint that returns the Circle balance when the tenant is using Circle [#325](https://github.com/stellar/stellar-disbursement-platform-backend/pull/325), [#329](https://github.com/stellar/stellar-disbursement-platform-backend/pull/329) +- Implement the `PATCH /organization/circle-config` endpoint that allows Circle configuration to be updated for Circle-using tenants [#326](https://github.com/stellar/stellar-disbursement-platform-backend/pull/326), [#332](https://github.com/stellar/stellar-disbursement-platform-backend/pull/332), [#334](https://github.com/stellar/stellar-disbursement-platform-backend/pull/334) +- Send Stellar payments through Circle when the tenant uses a CIRCLE distribution account [#333](https://github.com/stellar/stellar-disbursement-platform-backend/pull/333) +- Implement Circle reconciliation through polling [#339](https://github.com/stellar/stellar-disbursement-platform-backend/pull/339), [#347](https://github.com/stellar/stellar-disbursement-platform-backend/pull/347) +- Add Circle transfer ID to GET /payments endpoints [#346](https://github.com/stellar/stellar-disbursement-platform-backend/pull/346) +- Add function to migrate data from a single-tenant to a multi-tenant instance [#351](https://github.com/stellar/stellar-disbursement-platform-backend/pull/351) +- Invalidate Circle Distribution Account Status upon receiving auth error [#350](https://github.com/stellar/stellar-disbursement-platform-backend/pull/350), [359](https://github.com/stellar/stellar-disbursement-platform-backend/pull/359) +- Add retry for circle 429 requests [#362](https://github.com/stellar/stellar-disbursement-platform-backend/pull/362) +- Separate Stellar and Circle payment jobs [#366](https://github.com/stellar/stellar-disbursement-platform-backend/pull/366), [#374](https://github.com/stellar/stellar-disbursement-platform-backend/pull/374) +- Misc + - Reformat the imports using goimports and enforce it through a GH Action [#337](https://github.com/stellar/stellar-disbursement-platform-backend/pull/337) + - Added dependabot extra features [#349](https://github.com/stellar/stellar-disbursement-platform-backend/pull/349) + - Add CI for e2e integration test for Circle [#342](https://github.com/stellar/stellar-disbursement-platform-backend/pull/342), [#357](https://github.com/stellar/stellar-disbursement-platform-backend/pull/357) + - Add CI to validate single-tenant to multi-tenant migration [#356](https://github.com/stellar/stellar-disbursement-platform-backend/pull/356) + +### Fixed + +- Fix SDP helm charts [#323](https://github.com/stellar/stellar-disbursement-platform-backend/pull/323), [#375](https://github.com/stellar/stellar-disbursement-platform-backend/pull/375) +- Fix CF 429 response and anchor patch transaction job for circle accounts [#361](https://github.com/stellar/stellar-disbursement-platform-backend/pull/361) +- Select the correct error object used in a crash-reporter alert [#365](https://github.com/stellar/stellar-disbursement-platform-backend/pull/365) +- Fixes post python migration [#367](https://github.com/stellar/stellar-disbursement-platform-backend/pull/367) +- Make `PATCH /receivers/:id` validation consistent [#371](https://github.com/stellar/stellar-disbursement-platform-backend/pull/371) + +### Security + +- Security patch for gorilla/schema and rs/cors [#345](https://github.com/stellar/stellar-disbursement-platform-backend/pull/345) +- Bump versions of getsentry/sentry-go and gofiber/fiber [#352](https://github.com/stellar/stellar-disbursement-platform-backend/pull/352) + +### Deprecated + +- Deprecated the use of `DISTRIBUTION_SIGNER_TYPE`, since this information is now provided when provisioning a tenant [#319](https://github.com/stellar/stellar-disbursement-platform-backend/pull/319). +- Remove Freedom Wallet from the list of pubnet wallets [#372](https://github.com/stellar/stellar-disbursement-platform-backend/pull/372) + +## [2.0.0](https://github.com/stellar/stellar-disbursement-platform-backend/releases/tag/2.0.0) ([diff](https://github.com/stellar/stellar-disbursement-platform-backend/compare/1.1.7...2.0.0)) Release of the Stellar Disbursement Platform v2.0.0. This release introduces multi-tenancy support, allowing multiple tenants @@ -16,9 +76,10 @@ release introduces multi-tenancy support, allowing multiple tenants Each organization has its own set of users, receivers, disbursements, etc. -This version is only compatible with the [stellar/stellar-disbursement-platform-frontend] version 2.x.x. +This version is only compatible with the [stellar/stellar-disbursement-platform-frontend] version `2.0.0`. ### Changed + - Support multi-tenant CLI - Make `add-user` CLI support multi-tenancy [#228](https://github.com/stellar/stellar-disbursement-platform-backend/pull/228) - Change migrations CLI to run for all tenants [#89](https://github.com/stellar/stellar-disbursement-platform-backend/pull/89) @@ -27,10 +88,11 @@ This version is only compatible with the [stellar/stellar-disbursement-platform- - Tag log entries with tenant metadata [#192](https://github.com/stellar/stellar-disbursement-platform-backend/pull/192) - Use `DistributionAccountResolver` instead of passing around distribution public key [#212](https://github.com/stellar/stellar-disbursement-platform-backend/pull/212) - Make provision new tenant an atomic operation [#233](https://github.com/stellar/stellar-disbursement-platform-backend/pull/233) -- Make `ready_payments_cancellation` job multi-tenant [#223] (https://github.com/stellar/stellar-disbursement-platform-backend/pull/223) +- Make `ready_payments_cancellation` job multi-tenant [#223](https://github.com/stellar/stellar-disbursement-platform-backend/pull/223) ### Added + - Tenant Provisioning & Onboarding [#84](https://github.com/stellar/stellar-disbursement-platform-backend/pull/84) - Tenant Authentication Middleware [#92](https://github.com/stellar/stellar-disbursement-platform-backend/pull/92) - Multi-tenancy connection pool & data source manager [#86](https://github.com/stellar/stellar-disbursement-platform-backend/pull/86) @@ -50,8 +112,7 @@ This version is only compatible with the [stellar/stellar-disbursement-platform- - Patch incoming TSS events to Anchor platform [#134](https://github.com/stellar/stellar-disbursement-platform-backend/pull/134) - Update DB structure so that TSS resources can be shared by multiple SDP tenants - Move all TSS related tables to TSS schema [#141](https://github.com/stellar/stellar-disbursement-platform-backend/pull/141) - - Create TSS schema and migrations CLI command [#136](https://github.com/ - stellar/stellar-disbursement-platform-backend/pull/136) + - Create TSS schema and migrations CLI command [#136](https://github.com/stellar/stellar-disbursement-platform-backend/pull/136) - Refactor migrations commands to support TSS migrations [#123](https://github.com/stellar/stellar-disbursement-platform-backend/pull/123) - Add host distribution account awareness [#172](https://github.com/stellar/stellar-disbursement-platform-backend/pull/172) - Wire distribution account to tenant admin table during user provisioning [#198](https://github.com/stellar/stellar-disbursement-platform-backend/pull/198) @@ -59,7 +120,7 @@ This version is only compatible with the [stellar/stellar-disbursement-platform- - Kafka message broker support - Migrate SMS invitation to use message broker from scheduled jobs [#133](https://github.com/stellar/stellar-disbursement-platform-backend/pull/133) - Publish receiver wallet invitation events at disbursement start [#182](https://github.com/stellar/stellar-disbursement-platform-backend/pull/182) - - Produce payment events to sync back to SDP [#149] (https://github.com/stellar/stellar-disbursement-platform-backend/pull/149) + - Produce payment events to sync back to SDP [#149](https://github.com/stellar/stellar-disbursement-platform-backend/pull/149) - Produce payment events from SDP to TSS [#159](https://github.com/stellar/stellar-disbursement-platform-backend/pull/159) - Implement `DistributionAccountDBSignatureClient` [#197](https://github.com/stellar/stellar-disbursement-platform-backend/pull/197) - Create tenant distribution account during provisioning [#224](https://github.com/stellar/stellar-disbursement-platform-backend/pull/224) @@ -68,6 +129,7 @@ This version is only compatible with the [stellar/stellar-disbursement-platform- - Add script to migrate SDP v1.1.6 to V2.x.x [#267](https://github.com/stellar/stellar-disbursement-platform-backend/pull/267) ### Security + - Admin API authentication/authorization [#201](https://github.com/stellar/stellar-disbursement-platform-backend/pull/201) - Enable security protocols for Kafka - SASL auth [#162](https://github.com/stellar/stellar-disbursement-platform-backend/pull/162) diff --git a/README.md b/README.md index f6a2c5f80..8f6430b55 100644 --- a/README.md +++ b/README.md @@ -222,11 +222,13 @@ We recommend Kafka for organizations that require high throughput and low latenc * `events.receiver-wallets.new_invitation`: This topic is used to send disbursement invites to recipients. *[Producer: Core, Consumer: Core]* * `events.payment.ready_to_pay`: This topic is used to submit payments from the Core to the TSS. *[Producer: Core, Consumer: TSS]* +* `events.payment.circle_ready_to_pay`: This topic is used to submit Circle payments. *[Producer: Core, Consumer: Core]* * `events.payment.payment_completed`: This topic is used to notify the Core that a payment has been completed. *[Producer: TSS, Consumer: Core]* For each of the topics above, there is a dead letter topic that is used to store messages that could not be processed. The dead letter topics are named as follows: * `events.receiver-wallets.new_invitation.dlq` * `events.payment.ready_to_pay.dlq` +* `events.payment.circle_ready_to_pay.dlq` * `events.payment.payment_completed.dlq` diff --git a/cmd/channel_accounts_test.go b/cmd/channel_accounts_test.go index 4ead82c3b..c181155da 100644 --- a/cmd/channel_accounts_test.go +++ b/cmd/channel_accounts_test.go @@ -38,8 +38,8 @@ func Test_ChannelAccountsCommand_CreateCommand(t *testing.T) { "create", "2", "--distribution-seed", distributionKP.Seed(), "--distribution-public-key", distributionKP.Address(), - "--distribution-signer-type", "DISTRIBUTION_ACCOUNT_ENV", "--channel-account-encryption-passphrase", keypair.MustRandom().Seed(), + "--distribution-account-encryption-passphrase", keypair.MustRandom().Seed(), "--database-url", dbt.DSN, }) @@ -103,7 +103,7 @@ func Test_ChannelAccountsCommand_VerifyCommand(t *testing.T) { "verify", "--distribution-seed", distributionKP.Seed(), "--distribution-public-key", distributionKP.Address(), - "--distribution-signer-type", "DISTRIBUTION_ACCOUNT_ENV", + "--distribution-account-encryption-passphrase", keypair.MustRandom().Seed(), "--channel-account-encryption-passphrase", keypair.MustRandom().Seed(), "--database-url", dbt.DSN, }) @@ -171,7 +171,7 @@ func Test_ChannelAccountsCommand_EnsureCommand(t *testing.T) { "ensure", "2", "--distribution-seed", distributionKP.Seed(), "--distribution-public-key", distributionKP.Address(), - "--distribution-signer-type", "DISTRIBUTION_ACCOUNT_ENV", + "--distribution-account-encryption-passphrase", keypair.MustRandom().Seed(), "--channel-account-encryption-passphrase", keypair.MustRandom().Seed(), "--database-url", dbt.DSN, }) @@ -237,7 +237,7 @@ func Test_ChannelAccountsCommand_DeleteCommand(t *testing.T) { "delete", "--distribution-seed", distributionKP.Seed(), "--distribution-public-key", distributionKP.Address(), - "--distribution-signer-type", "DISTRIBUTION_ACCOUNT_ENV", + "--distribution-account-encryption-passphrase", keypair.MustRandom().Seed(), "--channel-account-encryption-passphrase", keypair.MustRandom().Seed(), "--channel-account-id", "acc-id", "--database-url", dbt.DSN, diff --git a/cmd/db/db.go b/cmd/db/db.go index fe7c420f4..63fd9ba98 100644 --- a/cmd/db/db.go +++ b/cmd/db/db.go @@ -16,6 +16,7 @@ import ( di "github.com/stellar/stellar-disbursement-platform-backend/internal/dependencyinjection" "github.com/stellar/stellar-disbursement-platform-backend/internal/services" sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) const DBConfigOptionFlagName = "database-url" @@ -80,20 +81,36 @@ func (c *DatabaseCommand) setupForNetworkCmd(globalOptions *utils.GlobalOptionsT log.Ctx(ctx).Fatal(err.Error()) } - tenantIDToDSNMap, err := getTenantIDToDSNMapping(ctx, c.adminDBConnectionPool) + dsnByTenantID, err := getTenantIDToDSNMapping(ctx, c.adminDBConnectionPool) if err != nil { log.Ctx(ctx).Fatalf("getting tenants schemas: %s", err.Error()) } + m := tenant.NewManager(tenant.WithDatabase(c.adminDBConnectionPool)) + tenants, err := m.GetAllTenants(ctx, nil) + if err != nil { + log.Ctx(ctx).Fatalf("getting all tenants: %v", err) + } + tenantsByID := make(map[string]tenant.Tenant, len(tenants)) + for _, tnt := range tenants { + tenantsByID[tnt.ID] = tnt + } + if opts.TenantID != "" { - if dsn, ok := tenantIDToDSNMap[opts.TenantID]; ok { - tenantIDToDSNMap = map[string]string{opts.TenantID: dsn} + if dsn, ok := dsnByTenantID[opts.TenantID]; ok { + dsnByTenantID = map[string]string{opts.TenantID: dsn} } else { log.Ctx(ctx).Fatalf("tenant ID %s does not exist", opts.TenantID) } } - for tenantID, dsn := range tenantIDToDSNMap { + for tenantID, dsn := range dsnByTenantID { + networkType, err := sdpUtils.GetNetworkTypeFromNetworkPassphrase(globalOptions.NetworkPassphrase) + if err != nil { + log.Ctx(ctx).Fatalf("error getting network type: %s", err.Error()) + } + tnt := tenantsByID[tenantID] + log.Ctx(ctx).Infof("running for tenant ID %s", tenantID) tenantDBConnectionPool, err := db.OpenDBConnectionPool(dsn) if err != nil { @@ -101,12 +118,7 @@ func (c *DatabaseCommand) setupForNetworkCmd(globalOptions *utils.GlobalOptionsT } defer tenantDBConnectionPool.Close() - networkType, err := sdpUtils.GetNetworkTypeFromNetworkPassphrase(globalOptions.NetworkPassphrase) - if err != nil { - log.Ctx(ctx).Fatalf("error getting network type: %s", err.Error()) - } - - if err := services.SetupAssetsForProperNetwork(ctx, tenantDBConnectionPool, networkType, services.DefaultAssetsNetworkMap); err != nil { + if err := services.SetupAssetsForProperNetwork(ctx, tenantDBConnectionPool, networkType, tnt.DistributionAccountType.Platform()); err != nil { log.Ctx(ctx).Fatalf("error upserting assets for proper network: %s", err.Error()) } diff --git a/cmd/db_test.go b/cmd/db_test.go index df8cf4084..078c4a781 100644 --- a/cmd/db_test.go +++ b/cmd/db_test.go @@ -485,7 +485,7 @@ func Test_DatabaseCommand_db_setup_for_network(t *testing.T) { require.NoError(t, outerErr) defer tenant2SchemaConnectionPool.Close() - testnetUSDCIssuer := keypair.MustRandom().Address() + randomUSDCIssuer := keypair.MustRandom().Address() clearTenantTables := func(t *testing.T, ctx context.Context, tenantSchemaConnectionPool db.DBConnectionPool) { models, mErr := data.NewModels(tenantSchemaConnectionPool) require.NoError(t, mErr) @@ -494,14 +494,14 @@ func Test_DatabaseCommand_db_setup_for_network(t *testing.T) { data.DeleteAllWalletFixtures(t, ctx, tenantSchemaConnectionPool) data.DeleteAllAssetFixtures(t, ctx, tenantSchemaConnectionPool) - data.CreateAssetFixture(t, ctx, tenantSchemaConnectionPool, "USDC", testnetUSDCIssuer) + data.CreateAssetFixture(t, ctx, tenantSchemaConnectionPool, "USDC", randomUSDCIssuer) assets, aErr := models.Assets.GetAll(ctx) require.NoError(t, aErr) assert.Len(t, assets, 1) assert.Equal(t, "USDC", assets[0].Code) - assert.Equal(t, testnetUSDCIssuer, assets[0].Issuer) + assert.Equal(t, randomUSDCIssuer, assets[0].Issuer) // Wallets data.CreateWalletFixture(t, ctx, tenantSchemaConnectionPool, "Vibrant Assist", "https://vibrantapp.com", "api-dev.vibrantapp.com", "https://vibrantapp.com/sdp-dev") @@ -547,12 +547,13 @@ func Test_DatabaseCommand_db_setup_for_network(t *testing.T) { actualAssets, aErr := models.Assets.GetAll(ctx) require.NoError(t, aErr) - assert.Len(t, actualAssets, 2) - assert.Equal(t, "USDC", actualAssets[0].Code) - assert.NotEqual(t, testnetUSDCIssuer, actualAssets[0].Issuer) - assert.Equal(t, assets.USDCAssetIssuerPubnet, actualAssets[0].Issuer) - assert.Equal(t, "XLM", actualAssets[1].Code) - assert.Empty(t, actualAssets[1].Issuer) + assert.Len(t, actualAssets, 3) + assert.Equal(t, assets.EURCAssetCode, actualAssets[0].Code) + assert.Equal(t, assets.EURCAssetIssuerPubnet, actualAssets[0].Issuer) + assert.Equal(t, assets.USDCAssetCode, actualAssets[1].Code) + assert.Equal(t, assets.USDCAssetIssuerPubnet, actualAssets[1].Issuer) + assert.Equal(t, assets.XLMAssetCode, actualAssets[2].Code) + assert.Empty(t, actualAssets[2].Issuer) // Validating wallets wallets, wErr := models.Wallets.GetAll(ctx) @@ -581,10 +582,9 @@ func Test_DatabaseCommand_db_setup_for_network(t *testing.T) { expectedLogs := []string{ fmt.Sprintf("running for tenant ID %s", tnt1.ID), "updating/inserting assets for the 'pubnet' network", - "Code: USDC", - fmt.Sprintf("Issuer: %s", assets.USDCAssetIssuerPubnet), - "Code: XLM", - "Issuer: ", + fmt.Sprintf("* %s - %s", assets.USDCAssetCode, assets.USDCAssetIssuerPubnet), + fmt.Sprintf("* %s - %s", assets.EURCAssetCode, assets.EURCAssetIssuerPubnet), + fmt.Sprintf("* %s - %s", assets.XLMAssetCode, ""), "updating/inserting wallets for the 'pubnet' network", "Name: Vibrant Assist", "Homepage: https://vibrantapp.com/vibrant-assist", @@ -605,11 +605,13 @@ func Test_DatabaseCommand_db_setup_for_network(t *testing.T) { actualAssets, err = models.Assets.GetAll(ctx) require.NoError(t, err) - require.Len(t, actualAssets, 2) - require.Equal(t, assets.USDCAssetPubnet.Code, actualAssets[0].Code) - require.Equal(t, assets.USDCAssetPubnet.Issuer, actualAssets[0].Issuer) - require.Equal(t, assets.XLMAsset.Code, actualAssets[1].Code) - require.Empty(t, assets.XLMAsset.Issuer) + require.Len(t, actualAssets, 3) + assert.Equal(t, assets.EURCAssetCode, actualAssets[0].Code) + assert.Equal(t, assets.EURCAssetIssuerPubnet, actualAssets[0].Issuer) + assert.Equal(t, assets.USDCAssetCode, actualAssets[1].Code) + assert.Equal(t, assets.USDCAssetIssuerPubnet, actualAssets[1].Issuer) + assert.Equal(t, assets.XLMAssetCode, actualAssets[2].Code) + assert.Empty(t, actualAssets[2].Issuer) // Validating wallets wallets, err = models.Wallets.GetAll(ctx) @@ -641,10 +643,9 @@ func Test_DatabaseCommand_db_setup_for_network(t *testing.T) { expectedLogs = []string{ fmt.Sprintf("running for tenant ID %s", tnt2.ID), "updating/inserting assets for the 'pubnet' network", - "Code: USDC", - fmt.Sprintf("Issuer: %s", assets.USDCAssetIssuerPubnet), - "Code: XLM", - "Issuer: ", + fmt.Sprintf("* %s - %s", assets.USDCAssetCode, assets.USDCAssetIssuerPubnet), + fmt.Sprintf("* %s - %s", assets.EURCAssetCode, assets.EURCAssetIssuerPubnet), + fmt.Sprintf("* %s - %s", assets.XLMAssetCode, ""), "updating/inserting wallets for the 'pubnet' network", "Name: Vibrant Assist", "Homepage: https://vibrantapp.com/vibrant-assist", @@ -686,8 +687,8 @@ func Test_DatabaseCommand_db_setup_for_network(t *testing.T) { require.NoError(t, err) assert.Len(t, currentAssets, 1) - assert.Equal(t, "USDC", currentAssets[0].Code) - assert.Equal(t, testnetUSDCIssuer, currentAssets[0].Issuer) + assert.Equal(t, assets.USDCAssetCode, currentAssets[0].Code) + assert.Equal(t, randomUSDCIssuer, currentAssets[0].Issuer) // Validating wallets wallets, err := models.Wallets.GetAll(ctx) @@ -710,13 +711,13 @@ func Test_DatabaseCommand_db_setup_for_network(t *testing.T) { actualAssets, err := models.Assets.GetAll(ctx) require.NoError(t, err) - assert.Len(t, actualAssets, 2) - assert.Equal(t, "USDC", actualAssets[0].Code) - assert.NotEqual(t, testnetUSDCIssuer, actualAssets[0].Issuer) - assert.Equal(t, assets.USDCAssetIssuerPubnet, actualAssets[0].Issuer) - assert.Equal(t, "XLM", actualAssets[1].Code) - assert.Empty(t, actualAssets[1].Issuer) - + assert.Len(t, actualAssets, 3) + assert.Equal(t, assets.EURCAssetCode, actualAssets[0].Code) + assert.Equal(t, assets.EURCAssetIssuerPubnet, actualAssets[0].Issuer) + assert.Equal(t, assets.USDCAssetCode, actualAssets[1].Code) + assert.Equal(t, assets.USDCAssetIssuerPubnet, actualAssets[1].Issuer) + assert.Equal(t, assets.XLMAssetCode, actualAssets[2].Code) + assert.Empty(t, actualAssets[2].Issuer) // Validating wallets wallets, err = models.Wallets.GetAll(ctx) require.NoError(t, err) @@ -743,10 +744,9 @@ func Test_DatabaseCommand_db_setup_for_network(t *testing.T) { expectedLogs := []string{ fmt.Sprintf("running for tenant ID %s", tnt2.ID), "updating/inserting assets for the 'pubnet' network", - "Code: USDC", - fmt.Sprintf("Issuer: %s", assets.USDCAssetPubnet.Issuer), - "Code: XLM", - "Issuer: ", + fmt.Sprintf("* %s - %s", assets.USDCAssetCode, assets.USDCAssetIssuerPubnet), + fmt.Sprintf("* %s - %s", assets.EURCAssetCode, assets.EURCAssetIssuerPubnet), + fmt.Sprintf("* %s - %s", assets.XLMAssetCode, ""), "updating/inserting wallets for the 'pubnet' network", "Name: Vibrant Assist", "Homepage: https://vibrantapp.com/vibrant-assist", diff --git a/cmd/integration_tests.go b/cmd/integration_tests.go index cdc39453d..b4db44045 100644 --- a/cmd/integration_tests.go +++ b/cmd/integration_tests.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/stellar/go/support/config" "github.com/stellar/go/support/log" + cmdUtils "github.com/stellar/stellar-disbursement-platform-backend/cmd/utils" "github.com/stellar/stellar-disbursement-platform-backend/internal/integrationtests" ) @@ -40,6 +41,13 @@ func (c *IntegrationTestsCommand) Command() *cobra.Command { FlagDefault: "disbursement_integration_tests", Required: true, }, + { + Name: "distribution-account-type", + Usage: "The account type of the distribution account", + OptType: types.String, + ConfigKey: &integrationTestsOpts.DistributionAccountType, + Required: true, + }, { Name: "wallet-name", Usage: "Wallet name to be used in integration tests", @@ -90,6 +98,13 @@ func (c *IntegrationTestsCommand) Command() *cobra.Command { ConfigKey: &integrationTestsOpts.UserPassword, Required: true, }, + { + Name: "server-api-base-url", + Usage: "The Base URL of the server API of the SDP.", + OptType: types.String, + ConfigKey: &integrationTestsOpts.ServerApiBaseURL, + Required: true, + }, } integrationTestsCmd := &cobra.Command{ Use: "integration-tests", @@ -173,13 +188,6 @@ func (c *IntegrationTestsCommand) StartIntegrationTestsCommand(integrationTestsO ConfigKey: &integrationTestsOpts.DisbursementCSVFilePath, Required: true, }, - { - Name: "server-api-base-url", - Usage: "The Base URL of the server API of the SDP.", - OptType: types.String, - ConfigKey: &integrationTestsOpts.ServerApiBaseURL, - Required: true, - }, { Name: "anchor-platform-base-sep-url", Usage: "The Base URL of the sep server of the anchor platform. This is the base URL where the Anchor Platform " + @@ -245,9 +253,23 @@ func (c *IntegrationTestsCommand) CreateIntegrationTestsDataCommand(integrationT Usage: "Wallet deeplink to be used in integration tests", OptType: types.String, ConfigKey: &integrationTestsOpts.WalletDeepLink, - FlagDefault: "test_wallet://", + FlagDefault: "test-wallet://sdp", Required: true, }, + { + Name: "circle-usdc-wallet-id", + Usage: "The wallet id for a distribution account that is using Circle as the platform", + OptType: types.String, + ConfigKey: &integrationTestsOpts.CircleUSDCWalletID, + Required: false, + }, + { + Name: "circle-api-key", + Usage: "The api key for a distribution account that is using Circle as the platform", + OptType: types.String, + ConfigKey: &integrationTestsOpts.CircleAPIKey, + Required: false, + }, } createIntegrationTestsDataCmd := &cobra.Command{ diff --git a/cmd/integration_tests_test.go b/cmd/integration_tests_test.go index 3b0bc6bf6..b36802629 100644 --- a/cmd/integration_tests_test.go +++ b/cmd/integration_tests_test.go @@ -9,10 +9,11 @@ import ( "testing" "github.com/spf13/cobra" - "github.com/stellar/stellar-disbursement-platform-backend/internal/integrationtests" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/integrationtests" ) type mockIntegrationTests struct { @@ -131,7 +132,7 @@ func Test_IntegrationTestsCommand_CreateIntegrationTestsDataCommand(t *testing.T DisbursetAssetIssuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV", WalletName: "walletTest", WalletHomepage: "https://www.test_wallet.com", - WalletDeepLink: "test_wallet://", + WalletDeepLink: "test-wallet://sdp", } cmd := command.CreateIntegrationTestsDataCommand(integrationTestsOpts) @@ -142,7 +143,7 @@ func Test_IntegrationTestsCommand_CreateIntegrationTestsDataCommand(t *testing.T t.Setenv("DISBURSED_ASSET_ISSUER", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") t.Setenv("WALLET_NAME", "walletTest") t.Setenv("WALLET_HOMEPAGE", "https://www.test_wallet.com") - t.Setenv("WALLET_DEEPLINK", "test_wallet://") + t.Setenv("WALLET_DEEPLINK", "test-wallet://sdp") parentCmdMock.SetArgs([]string{ "create-data", diff --git a/cmd/message.go b/cmd/message.go index 20dc21e86..f68108f02 100644 --- a/cmd/message.go +++ b/cmd/message.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/stellar/go/support/config" "github.com/stellar/go/support/log" + cmdUtils "github.com/stellar/stellar-disbursement-platform-backend/cmd/utils" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" ) diff --git a/cmd/message_test.go b/cmd/message_test.go index 26be82abe..b5f827958 100644 --- a/cmd/message_test.go +++ b/cmd/message_test.go @@ -5,11 +5,12 @@ import ( "testing" "github.com/spf13/cobra" - cmdUtils "github.com/stellar/stellar-disbursement-platform-backend/cmd/utils" - "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + cmdUtils "github.com/stellar/stellar-disbursement-platform-backend/cmd/utils" + "github.com/stellar/stellar-disbursement-platform-backend/internal/message" ) type mockMessengerService struct { diff --git a/cmd/mocks/ch_acc_cmd_service_interface.go b/cmd/mocks/ch_acc_cmd_service_interface.go index 53b43ff5a..bae7885c1 100644 --- a/cmd/mocks/ch_acc_cmd_service_interface.go +++ b/cmd/mocks/ch_acc_cmd_service_interface.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.27.1. DO NOT EDIT. +// Code generated by mockery v2.40.1. DO NOT EDIT. package mocks @@ -20,6 +20,10 @@ type MockChAccCmdServiceInterface struct { func (_m *MockChAccCmdServiceInterface) CreateChannelAccounts(ctx context.Context, chAccService services.ChannelAccountsService, count int) error { ret := _m.Called(ctx, chAccService, count) + if len(ret) == 0 { + panic("no return value specified for CreateChannelAccounts") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, services.ChannelAccountsService, int) error); ok { r0 = rf(ctx, chAccService, count) @@ -34,6 +38,10 @@ func (_m *MockChAccCmdServiceInterface) CreateChannelAccounts(ctx context.Contex func (_m *MockChAccCmdServiceInterface) DeleteChannelAccount(ctx context.Context, chAccService services.ChannelAccountsService, opts services.DeleteChannelAccountsOptions) error { ret := _m.Called(ctx, chAccService, opts) + if len(ret) == 0 { + panic("no return value specified for DeleteChannelAccount") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, services.ChannelAccountsService, services.DeleteChannelAccountsOptions) error); ok { r0 = rf(ctx, chAccService, opts) @@ -48,6 +56,10 @@ func (_m *MockChAccCmdServiceInterface) DeleteChannelAccount(ctx context.Context func (_m *MockChAccCmdServiceInterface) EnsureChannelAccountsCount(ctx context.Context, chAccService services.ChannelAccountsService, count int) error { ret := _m.Called(ctx, chAccService, count) + if len(ret) == 0 { + panic("no return value specified for EnsureChannelAccountsCount") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, services.ChannelAccountsService, int) error); ok { r0 = rf(ctx, chAccService, count) @@ -62,6 +74,10 @@ func (_m *MockChAccCmdServiceInterface) EnsureChannelAccountsCount(ctx context.C func (_m *MockChAccCmdServiceInterface) VerifyChannelAccounts(ctx context.Context, chAccService services.ChannelAccountsService, deleteInvalidAccounts bool) error { ret := _m.Called(ctx, chAccService, deleteInvalidAccounts) + if len(ret) == 0 { + panic("no return value specified for VerifyChannelAccounts") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, services.ChannelAccountsService, bool) error); ok { r0 = rf(ctx, chAccService, deleteInvalidAccounts) @@ -76,6 +92,10 @@ func (_m *MockChAccCmdServiceInterface) VerifyChannelAccounts(ctx context.Contex func (_m *MockChAccCmdServiceInterface) ViewChannelAccounts(ctx context.Context, dbConnectionPool db.DBConnectionPool) error { ret := _m.Called(ctx, dbConnectionPool) + if len(ret) == 0 { + panic("no return value specified for ViewChannelAccounts") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, db.DBConnectionPool) error); ok { r0 = rf(ctx, dbConnectionPool) @@ -86,13 +106,12 @@ func (_m *MockChAccCmdServiceInterface) ViewChannelAccounts(ctx context.Context, return r0 } -type mockConstructorTestingTNewMockChAccCmdServiceInterface interface { +// NewMockChAccCmdServiceInterface creates a new instance of MockChAccCmdServiceInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockChAccCmdServiceInterface(t interface { mock.TestingT Cleanup(func()) -} - -// NewMockChAccCmdServiceInterface creates a new instance of MockChAccCmdServiceInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewMockChAccCmdServiceInterface(t mockConstructorTestingTNewMockChAccCmdServiceInterface) *MockChAccCmdServiceInterface { +}) *MockChAccCmdServiceInterface { mock := &MockChAccCmdServiceInterface{} mock.Mock.Test(t) diff --git a/cmd/root.go b/cmd/root.go index 834a68dce..39ea9d101 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/stellar/go/support/config" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/cmd/db" cmdUtils "github.com/stellar/stellar-disbursement-platform-backend/cmd/utils" "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor" diff --git a/cmd/serve.go b/cmd/serve.go index 4592f631a..6d8f7230c 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -12,6 +12,7 @@ import ( cmdUtils "github.com/stellar/stellar-disbursement-platform-backend/cmd/utils" "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/anchorplatform" + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" di "github.com/stellar/stellar-disbursement-platform-backend/internal/dependencyinjection" @@ -22,8 +23,11 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/scheduler" "github.com/stellar/stellar-disbursement-platform-backend/internal/scheduler/jobs" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" serveadmin "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/serve" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) type ServeCommand struct{} @@ -81,6 +85,11 @@ func (s *ServerService) GetSchedulerJobRegistrars( sj := []scheduler.SchedulerJobRegisterOption{ scheduler.WithAPAuthEnforcementJob(apAPIService, serveOpts.MonitorService, serveOpts.CrashTrackerClient.Clone()), scheduler.WithReadyPaymentsCancellationJobOption(models), + scheduler.WithCircleReconciliationJobOption(jobs.CircleReconciliationJobOptions{ + Models: models, + DistAccountResolver: serveOpts.SubmitterEngine.DistributionAccountResolver, + CircleService: serveOpts.CircleService, + }), } if serveOpts.EnableScheduler { @@ -93,7 +102,18 @@ func (s *ServerService) GetSchedulerJobRegistrars( } sj = append(sj, - scheduler.WithPaymentToSubmitterJobOption(schedulerOptions.PaymentJobIntervalSeconds, models, tssDBConnectionPool), + scheduler.WithCirclePaymentToSubmitterJobOption(jobs.CirclePaymentToSubmitterJobOptions{ + JobIntervalSeconds: schedulerOptions.PaymentJobIntervalSeconds, + Models: models, + DistAccountResolver: serveOpts.SubmitterEngine.DistributionAccountResolver, + CircleService: serveOpts.CircleService, + }), + scheduler.WithStellarPaymentToSubmitterJobOption(jobs.StellarPaymentToSubmitterJobOptions{ + JobIntervalSeconds: schedulerOptions.PaymentJobIntervalSeconds, + Models: models, + TSSDBConnectionPool: tssDBConnectionPool, + DistAccountResolver: serveOpts.SubmitterEngine.DistributionAccountResolver, + }), scheduler.WithPaymentFromSubmitterJobOption(schedulerOptions.PaymentJobIntervalSeconds, models, tssDBConnectionPool), scheduler.WithPatchAnchorPlatformTransactionsCompletionJobOption(schedulerOptions.PaymentJobIntervalSeconds, apAPIService, models), scheduler.WithSendReceiverWalletsSMSInvitationJobOption(jobs.SendReceiverWalletsSMSInvitationJobOptions{ @@ -155,14 +175,32 @@ func (s *ServerService) SetupConsumers(ctx context.Context, o SetupConsumersOpti return fmt.Errorf("creating Payment Completed Kafka Consumer: %w", err) } - paymentReadyToPayConsumer, err := events.NewKafkaConsumer( + // Stellar and Circle have their dedicated paymentReadyToPay consumer that reads from their dedicated topics. + // This is to avoid the noisy neighbor problem where slow circle payments can block stellar payments and vice versa. + stellarPaymentReadyToPayConsumer, err := events.NewKafkaConsumer( kafkaConfig, events.PaymentReadyToPayTopic, o.EventBrokerOptions.ConsumerGroupID, - eventhandlers.NewPaymentToSubmitterEventHandler(eventhandlers.PaymentToSubmitterEventHandlerOptions{ + eventhandlers.NewStellarPaymentToSubmitterEventHandler(eventhandlers.StellarPaymentToSubmitterEventHandlerOptions{ AdminDBConnectionPool: o.ServeOpts.AdminDBConnectionPool, MtnDBConnectionPool: o.ServeOpts.MtnDBConnectionPool, TSSDBConnectionPool: o.TSSDBConnectionPool, + DistAccountResolver: o.ServeOpts.SubmitterEngine.DistributionAccountResolver, + }), + ) + if err != nil { + return fmt.Errorf("creating Payment Ready to Pay Kafka Consumer: %w", err) + } + + circlePaymentReadyToPayConsumer, err := events.NewKafkaConsumer( + kafkaConfig, + events.CirclePaymentReadyToPayTopic, + o.EventBrokerOptions.ConsumerGroupID, + eventhandlers.NewCirclePaymentToSubmitterEventHandler(eventhandlers.CirclePaymentToSubmitterEventHandlerOptions{ + AdminDBConnectionPool: o.ServeOpts.AdminDBConnectionPool, + MtnDBConnectionPool: o.ServeOpts.MtnDBConnectionPool, + DistAccountResolver: o.ServeOpts.SubmitterEngine.DistributionAccountResolver, + CircleService: o.ServeOpts.CircleService, }), ) if err != nil { @@ -176,7 +214,8 @@ func (s *ServerService) SetupConsumers(ctx context.Context, o SetupConsumersOpti go events.NewEventConsumer(smsInvitationConsumer, producer, o.ServeOpts.CrashTrackerClient.Clone()).Consume(ctx) go events.NewEventConsumer(paymentCompletedConsumer, producer, o.ServeOpts.CrashTrackerClient.Clone()).Consume(ctx) - go events.NewEventConsumer(paymentReadyToPayConsumer, producer, o.ServeOpts.CrashTrackerClient.Clone()).Consume(ctx) + go events.NewEventConsumer(stellarPaymentReadyToPayConsumer, producer, o.ServeOpts.CrashTrackerClient.Clone()).Consume(ctx) + go events.NewEventConsumer(circlePaymentReadyToPayConsumer, producer, o.ServeOpts.CrashTrackerClient.Clone()).Consume(ctx) return nil } @@ -478,6 +517,7 @@ func (c *ServeCommand) Command(serverService ServerServiceInterface, monitorServ serveOpts.MonitorService = monitorService serveOpts.BaseURL = globalOptions.BaseURL serveOpts.NetworkPassphrase = globalOptions.NetworkPassphrase + serveOpts.DistAccEncryptionPassphrase = txSubmitterOpts.SignatureServiceOptions.DistAccEncryptionPassphrase // Inject metrics server dependencies metricsServeOpts.MonitorService = monitorService @@ -557,6 +597,7 @@ func (c *ServeCommand) Command(serverService ServerServiceInterface, monitorServ // Setup Distribution Account Resolver distAccResolverOpts.AdminDBConnectionPool = adminDBConnectionPool + distAccResolverOpts.MTNDBConnectionPool = mtnDBConnectionPool distAccResolver, err := di.NewDistributionAccountResolver(ctx, distAccResolverOpts) if err != nil { log.Ctx(ctx).Fatalf("error creating distribution account resolver: %v", err) @@ -573,6 +614,38 @@ func (c *ServeCommand) Command(serverService ServerServiceInterface, monitorServ serveOpts.SubmitterEngine = submitterEngine adminServeOpts.SubmitterEngine = submitterEngine + // Setup NetworkType + serveOpts.NetworkType, err = utils.GetNetworkTypeFromNetworkPassphrase(serveOpts.NetworkPassphrase) + if err != nil { + log.Ctx(ctx).Fatalf("error parsing network type: %v", err) + } + + // Inject Circle Service dependencies + circleService, err := di.NewCircleService(ctx, circle.ServiceOptions{ + ClientFactory: circle.NewClient, + ClientConfigModel: circle.NewClientConfigModel(serveOpts.MtnDBConnectionPool), + NetworkType: serveOpts.NetworkType, + EncryptionPassphrase: serveOpts.DistAccEncryptionPassphrase, + TenantManager: tenant.NewManager(tenant.WithDatabase(serveOpts.AdminDBConnectionPool)), + }) + if err != nil { + log.Ctx(ctx).Fatalf("error creating Circle service: %v", err) + } + serveOpts.CircleService = circleService + + // Setup Distribution Account Service + distributionAccountServiceOptions := services.DistributionAccountServiceOptions{ + NetworkType: serveOpts.NetworkType, + HorizonClient: submitterEngine.HorizonClient, + CircleService: circleService, + } + distributionAccountService, err := di.NewDistributionAccountService(ctx, distributionAccountServiceOptions) + if err != nil { + log.Ctx(ctx).Fatalf("error creating distribution account service: %v", err) + } + serveOpts.DistributionAccountService = distributionAccountService + adminServeOpts.DistributionAccountService = distributionAccountService + // Validate the Event Broker Type and Scheduler Jobs if eventBrokerOptions.EventBrokerType == events.NoneEventBrokerType && !serveOpts.EnableScheduler { log.Ctx(ctx).Fatalf("Both Event Brokers and Scheduler are disabled. Please enable one.") diff --git a/cmd/serve_test.go b/cmd/serve_test.go index 1ac9587ea..23626a2e2 100644 --- a/cmd/serve_test.go +++ b/cmd/serve_test.go @@ -19,6 +19,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/anchorplatform" + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" di "github.com/stellar/stellar-disbursement-platform-backend/internal/dependencyinjection" "github.com/stellar/stellar-disbursement-platform-backend/internal/events" @@ -28,9 +29,11 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/scheduler" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient" + svcMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/services/mocks" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine" preconditionsMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/preconditions/mocks" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" serveadmin "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/serve" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) @@ -107,7 +110,8 @@ func Test_serve(t *testing.T) { dbt.Close() cmdUtils.ClearTestEnvironment(t) - distributionSeed := "SBHQEYSACD5DOK5I656NKLAMOHC6VT64ATOWWM2VJ3URGDGMVGNPG4ON" + distributionAccKP := keypair.MustRandom() + distributionAccPrivKey := distributionAccKP.Seed() // Populate dependency injection: di.SetInstance(di.TSSDBConnectionPoolInstanceName, dbConnectionPool) @@ -120,7 +124,7 @@ func Test_serve(t *testing.T) { mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) di.SetInstance(di.LedgerNumberTrackerInstanceName, mLedgerNumberTracker) - sigService, _, _, _, _ := signing.NewMockSignatureService(t) + sigService, _, _ := signing.NewMockSignatureService(t) submitterEngine := engine.SubmitterEngine{ HorizonClient: mHorizonClient, @@ -130,11 +134,18 @@ func Test_serve(t *testing.T) { } di.SetInstance(di.TxSubmitterEngineInstanceName, submitterEngine) + mDistAccService := svcMocks.NewMockDistributionAccountService(t) + di.SetInstance(di.DistributionAccountServiceInstanceName, mDistAccService) + ctx := context.Background() // mock metric service mMonitorService := monitorMocks.NewMockMonitorService(t) + // mock circle service + mCircleService := circle.NewMockService(t) + di.SetInstance(di.CircleServiceInstanceName, mCircleService) + serveOpts := serve.ServeOptions{ Environment: "test", GitCommit: "1234567890abcdef", @@ -151,6 +162,7 @@ func Test_serve(t *testing.T) { BaseURL: "https://sdp-backend.stellar.org", ResetTokenExpirationHours: 24, NetworkPassphrase: network.TestNetworkPassphrase, + NetworkType: utils.TestnetNetworkType, Sep10SigningPublicKey: "GAX46JJZ3NPUM2EUBTTGFM6ITDF7IGAFNBSVWDONPYZJREHFPP2I5U7S", Sep10SigningPrivateKey: "SBUSPEKAZKLZSWHRSJ2HWDZUK6I3IVDUWA7JJZSGBLZ2WZIUJI7FPNB5", AnchorPlatformBaseSepURL: "localhost:8080", @@ -162,7 +174,10 @@ func Test_serve(t *testing.T) { DisableReCAPTCHA: false, EnableScheduler: false, SubmitterEngine: submitterEngine, + DistributionAccountService: mDistAccService, MaxInvitationSMSResendAttempts: 3, + DistAccEncryptionPassphrase: distributionAccPrivKey, + CircleService: mCircleService, } serveOpts.AnchorPlatformAPIService, err = anchorplatform.NewAnchorPlatformAPIService(httpclient.DefaultClient(), serveOpts.AnchorPlatformBasePlatformURL, serveOpts.AnchorPlatformOutgoingJWTSecret) require.NoError(t, err) @@ -214,6 +229,7 @@ func Test_serve(t *testing.T) { Port: 8003, Version: "x.y.z", SubmitterEngine: submitterEngine, + DistributionAccountService: mDistAccService, TenantAccountNativeAssetBootstrapAmount: tenant.MinTenantDistributionAccountAmount, AdminAccount: "admin-account", AdminApiKey: "admin-api-key", @@ -279,7 +295,8 @@ func Test_serve(t *testing.T) { t.Setenv("DISTRIBUTION_PUBLIC_KEY", "GBC2HVWFIFN7WJHFORVBCDKJORG6LWTW3O2QBHOURL3KHZPM4KMWTUSA") t.Setenv("DISABLE_MFA", fmt.Sprintf("%t", serveOpts.DisableMFA)) t.Setenv("DISABLE_RECAPTCHA", fmt.Sprintf("%t", serveOpts.DisableMFA)) - t.Setenv("DISTRIBUTION_SEED", distributionSeed) + t.Setenv("DISTRIBUTION_SEED", distributionAccPrivKey) + t.Setenv("DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE", distributionAccPrivKey) t.Setenv("BASE_URL", serveOpts.BaseURL) t.Setenv("SDP_UI_BASE_URL", serveTenantOpts.SDPUIBaseURL) t.Setenv("RECAPTCHA_SITE_KEY", serveOpts.ReCAPTCHASiteKey) diff --git a/cmd/transaction_submitter_test.go b/cmd/transaction_submitter_test.go index 68d3b29f2..b122cad99 100644 --- a/cmd/transaction_submitter_test.go +++ b/cmd/transaction_submitter_test.go @@ -93,6 +93,7 @@ func Test_tss(t *testing.T) { "--distribution-public-key", "GAXCC3VMCWRFZAFWK7JXI6M7XQ3WPVUUEL2SEFODWJY6N2QIFFGXSL6M", "--distribution-seed", "SBQ3ZNC2SE3FV43HZ2KW3FCXQMMIQ33LZB745KTMCHDS6PNQOVXMV5NC", "--channel-account-encryption-passphrase", "SDA3C7OW5HU4MMEEYTPXX43F4OU2MJBGF5WMJALL7CTILTI2GOVK2YFA", + "--distribution-account-encryption-passphrase", "SDA3C7OW5HU4MMEEYTPXX43F4OU2MJBGF5WMJALL7CTILTI2GOVK2YFA", "--horizon-url", "https://horizon-testnet.stellar.org", "--network-passphrase", "Test SDF Network ; September 2015", "--broker-urls", "kafka:9092", diff --git a/cmd/utils/custom_set_value.go b/cmd/utils/custom_set_value.go index 11547249d..a7ed5753f 100644 --- a/cmd/utils/custom_set_value.go +++ b/cmd/utils/custom_set_value.go @@ -16,7 +16,6 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/events" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor" - "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) @@ -56,18 +55,6 @@ func SetConfigOptionCrashTrackerType(co *config.ConfigOption) error { return nil } -func SetConfigOptionDistributionSignerType(co *config.ConfigOption) error { - ssType := viper.GetString(co.Name) - - ssTypeParsed, err := signing.ParseSignatureClientDistributionType(ssType) - if err != nil { - return fmt.Errorf("couldn't parse signature client distribution type in %s: %w", co.Name, err) - } - - *(co.ConfigKey.(*signing.SignatureClientType)) = ssTypeParsed - return nil -} - func SetConfigOptionLogLevel(co *config.ConfigOption) error { // parse string to logLevel object logLevelStr := viper.GetString(co.Name) diff --git a/cmd/utils/custom_set_value_test.go b/cmd/utils/custom_set_value_test.go index 2489427b2..4c2bb5bd7 100644 --- a/cmd/utils/custom_set_value_test.go +++ b/cmd/utils/custom_set_value_test.go @@ -9,14 +9,14 @@ import ( "github.com/spf13/cobra" "github.com/stellar/go/support/config" "github.com/stellar/go/support/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stellar/stellar-disbursement-platform-backend/internal/events" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor" - "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) // customSetterTestCase is a test case to test a custom_set_value function. @@ -277,57 +277,6 @@ func Test_SetConfigOptionCrashTrackerType(t *testing.T) { } } -func Test_SetConfigOptionDistributionSignerType(t *testing.T) { - opts := struct{ sigServiceType signing.SignatureClientType }{} - - co := config.ConfigOption{ - Name: "distribution-signer-type", - OptType: types.String, - CustomSetValue: SetConfigOptionDistributionSignerType, - ConfigKey: &opts.sigServiceType, - } - - testCases := []customSetterTestCase[signing.SignatureClientType]{ - { - name: "returns an error if the value is empty", - args: []string{}, - wantErrContains: `couldn't parse signature client distribution type in distribution-signer-type: invalid signature client distribution type ""`, - }, - { - name: "returns an error if the value is not supported", - args: []string{"--distribution-signer-type", "test"}, - wantErrContains: `couldn't parse signature client distribution type in distribution-signer-type: invalid signature client distribution type "TEST"`, - }, - { - name: "🎉 handles signature service type (through CLI args): DISTRIBUTION_ACCOUNT_ENV", - args: []string{"--distribution-signer-type", string(signing.DistributionAccountEnvSignatureClientType)}, - wantResult: signing.DistributionAccountEnvSignatureClientType, - }, - { - name: "🎉 handles signature service type (through ENV vars): DISTRIBUTION_ACCOUNT_ENV", - envValue: string(signing.DistributionAccountEnvSignatureClientType), - wantResult: signing.DistributionAccountEnvSignatureClientType, - }, - { - name: "🎉 handles signature service type (through CLI args): DISTRIBUTION_ACCOUNT_DB", - args: []string{"--distribution-signer-type", string(signing.DistributionAccountDBSignatureClientType)}, - wantResult: signing.DistributionAccountDBSignatureClientType, - }, - { - name: "🎉 handles signature service type (through ENV vars): DISTRIBUTION_ACCOUNT_DB", - envValue: string(signing.DistributionAccountDBSignatureClientType), - wantResult: signing.DistributionAccountDBSignatureClientType, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - opts.sigServiceType = "" - customSetterTester[signing.SignatureClientType](t, tc, co) - }) - } -} - func Test_SetConfigOptionEC256PublicKey(t *testing.T) { opts := struct{ ec256PublicKey string }{} diff --git a/cmd/utils/global_options_test.go b/cmd/utils/global_options_test.go index 13fc059ed..f5f2c335a 100644 --- a/cmd/utils/global_options_test.go +++ b/cmd/utils/global_options_test.go @@ -3,8 +3,9 @@ package utils import ( "testing" - "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stretchr/testify/assert" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" ) func Test_globalOptions_PopulateCrashTrackerOptions(t *testing.T) { diff --git a/cmd/utils/shared_config_options.go b/cmd/utils/shared_config_options.go index 5dea21fb2..c4bddebb6 100644 --- a/cmd/utils/shared_config_options.go +++ b/cmd/utils/shared_config_options.go @@ -244,29 +244,20 @@ func BaseDistributionAccountSignatureClientConfigOptions(opts *signing.Signature return []*config.ConfigOption{ { Name: "distribution-account-encryption-passphrase", - Usage: "A Stellar-compliant ed25519 private key used to encrypt/decrypt the in-memory distribution accounts' private keys. It's mandatory when the distribution-signer-type is set to DISTRIBUTION_ACCOUNT_DB.", + Usage: "A Stellar-compliant ed25519 private key used to encrypt and decrypt the private keys of tenants' distribution accounts.", OptType: types.String, CustomSetValue: SetConfigOptionStellarPrivateKey, ConfigKey: &opts.DistAccEncryptionPassphrase, - Required: false, + Required: true, }, { Name: "distribution-seed", - Usage: "The private key of the HOST's Stellar distribution account, used to create channel accounts", // TODO: this will eventually be used for sponsoring tenant accounts. + Usage: "The private key of the HOST's Stellar distribution account, used to create channel accounts", OptType: types.String, CustomSetValue: SetConfigOptionStellarPrivateKey, ConfigKey: &opts.DistributionPrivateKey, Required: true, }, - { - Name: "distribution-signer-type", - Usage: fmt.Sprintf("The type of signer used to sign Stellar transactions for the tenants' distribution accounts. Options: %s", signing.DistSigClientsDescriptionStr()), - OptType: types.String, - CustomSetValue: SetConfigOptionDistributionSignerType, - ConfigKey: &opts.DistributionSignerType, - FlagDefault: string(signing.DistributionAccountDBSignatureClientType), - Required: true, - }, } } diff --git a/db/db.go b/db/db.go index c7153ee52..0bde1884e 100644 --- a/db/db.go +++ b/db/db.go @@ -9,6 +9,7 @@ import ( "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor" ) diff --git a/db/db_connection_pool_with_router_test.go b/db/db_connection_pool_with_router_test.go index 183a895f6..d5650604a 100644 --- a/db/db_connection_pool_with_router_test.go +++ b/db/db_connection_pool_with_router_test.go @@ -6,9 +6,10 @@ import ( "testing" "github.com/jmoiron/sqlx" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) func TestConnectionPoolWithRouter_BeginTxx(t *testing.T) { diff --git a/db/dbtest/dbtest.go b/db/dbtest/dbtest.go index 9e6f28308..261f81b4d 100644 --- a/db/dbtest/dbtest.go +++ b/db/dbtest/dbtest.go @@ -11,11 +11,15 @@ import ( ) func OpenWithoutMigrations(t *testing.T) *dbtest.DB { + t.Helper() + db := dbtest.Postgres(t) return db } func openWithMigrations(t *testing.T, configs ...migrations.MigrationRouter) *dbtest.DB { + t.Helper() + db := OpenWithoutMigrations(t) conn := db.Open() @@ -34,6 +38,8 @@ func openWithMigrations(t *testing.T, configs ...migrations.MigrationRouter) *db } func Open(t *testing.T) *dbtest.DB { + t.Helper() + return openWithMigrations(t, migrations.AdminMigrationRouter, migrations.SDPMigrationRouter, @@ -43,9 +49,13 @@ func Open(t *testing.T) *dbtest.DB { } func OpenWithAdminMigrationsOnly(t *testing.T) *dbtest.DB { + t.Helper() + return openWithMigrations(t, migrations.AdminMigrationRouter) } func OpenWithTSSMigrationsOnly(t *testing.T) *dbtest.DB { + t.Helper() + return openWithMigrations(t, migrations.TSSMigrationRouter) } diff --git a/db/migrations/admin-migrations/2024-06-06.0-update-enum-values-in-distribution-account-type.sql b/db/migrations/admin-migrations/2024-06-06.0-update-enum-values-in-distribution-account-type.sql new file mode 100644 index 000000000..777aa4256 --- /dev/null +++ b/db/migrations/admin-migrations/2024-06-06.0-update-enum-values-in-distribution-account-type.sql @@ -0,0 +1,54 @@ +-- +migrate Up + +-- Remove default value, and change type to text +ALTER TABLE tenants + ALTER COLUMN distribution_account_type DROP DEFAULT, + ALTER COLUMN distribution_account_type TYPE text; + +-- Drop enum +DROP TYPE distribution_account_type; + +-- Update values of the text column +UPDATE tenants SET distribution_account_type = 'DISTRIBUTION_ACCOUNT.STELLAR.ENV' WHERE distribution_account_type = 'ENV_STELLAR'; +UPDATE tenants SET distribution_account_type = 'DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT' WHERE distribution_account_type = 'DB_VAULT_STELLAR'; +UPDATE tenants SET distribution_account_type = 'DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT' WHERE distribution_account_type = 'DB_VAULT_CIRCLE'; + +-- Create a new enum +CREATE TYPE distribution_account_type AS ENUM ( + 'DISTRIBUTION_ACCOUNT.STELLAR.ENV', + 'DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT', + 'DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT' +); + +-- Update column to new enum type, and set default value +ALTER TABLE tenants + ALTER COLUMN distribution_account_type TYPE distribution_account_type USING distribution_account_type::text::distribution_account_type, + ALTER COLUMN distribution_account_type SET DEFAULT 'DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT'; + + +-- +migrate Down + +-- Remove default value, and change type to text +ALTER TABLE tenants + ALTER COLUMN distribution_account_type DROP DEFAULT, + ALTER COLUMN distribution_account_type TYPE text; + +-- Drop enum +DROP TYPE distribution_account_type; + +-- Update values of the text column +UPDATE tenants SET distribution_account_type = 'ENV_STELLAR' WHERE distribution_account_type = 'DISTRIBUTION_ACCOUNT.STELLAR.ENV'; +UPDATE tenants SET distribution_account_type = 'DB_VAULT_STELLAR' WHERE distribution_account_type = 'DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT'; +UPDATE tenants SET distribution_account_type = 'DB_VAULT_CIRCLE' WHERE distribution_account_type = 'DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT'; + +-- Create a new enum +CREATE TYPE distribution_account_type AS ENUM ( + 'ENV_STELLAR', + 'DB_VAULT_STELLAR', + 'DB_VAULT_CIRCLE' +); + +-- Update column to new enum type, and set default value +ALTER TABLE tenants + ALTER COLUMN distribution_account_type TYPE distribution_account_type USING distribution_account_type::text::distribution_account_type, + ALTER COLUMN distribution_account_type SET DEFAULT 'DB_VAULT_STELLAR'; diff --git a/db/migrations/admin-migrations/2024-07-15.0-migrate-data-v1-to-v2-function.sql b/db/migrations/admin-migrations/2024-07-15.0-migrate-data-v1-to-v2-function.sql new file mode 100644 index 000000000..c60c945ed --- /dev/null +++ b/db/migrations/admin-migrations/2024-07-15.0-migrate-data-v1-to-v2-function.sql @@ -0,0 +1,105 @@ +-- +migrate Up + +-- +migrate StatementBegin +-- This function migrates data from the public schema (V1 version) to the tenant's specific schema (V2 version). +-- It copies relevant data from the public schema to the tenant's schema, and then imports TSS data. +CREATE OR REPLACE FUNCTION migrate_tenant_data_from_v1_to_v2(tenant_name TEXT) RETURNS void AS $$ +DECLARE + schema_name TEXT := 'sdp_' || tenant_name; +BEGIN + + -- Step 1: Delete existing data in the tenant's schema + -- auth_users: delete the owner created during tenant provisioning + -- wallets, assets and wallets_assets: delete data created by the `setup-for-network` cmd if any. + EXECUTE format(' + DELETE FROM %I.auth_user_mfa_codes; + DELETE FROM %I.auth_user_password_reset; + DELETE FROM %I.auth_users; + DELETE FROM %I.wallets_assets; + DELETE FROM %I.assets; + DELETE FROM %I.wallets; + ', schema_name, schema_name, schema_name, schema_name, schema_name, schema_name); + + -- Step 2: Insert new data into tenant's schema. + EXECUTE format(' + -- These tables can be copied without changing any types in the source table columns: + INSERT INTO %I.wallets SELECT * FROM public.wallets; + INSERT INTO %I.assets SELECT * FROM public.assets; + INSERT INTO %I.wallets_assets SELECT * FROM public.wallets_assets; + INSERT INTO %I.auth_users SELECT * FROM public.auth_users; + INSERT INTO %I.receivers SELECT * FROM public.receivers; + INSERT INTO %I.auth_user_mfa_codes SELECT * FROM public.auth_user_mfa_codes; + INSERT INTO %I.auth_user_password_reset SELECT * FROM public.auth_user_password_reset; + + -- These tables need to have the type of some columns changed, we do that with the `ALTER TABLE` directives: + -- NOTE: we''re not reverting the types back to the original ones, as the source tables should be dropped after the migration. + ALTER TABLE public.receiver_wallets + ALTER COLUMN status DROP DEFAULT, + ALTER COLUMN status TYPE %I.receiver_wallet_status + USING status::text::%I.receiver_wallet_status; + INSERT INTO %I.receiver_wallets SELECT * FROM public.receiver_wallets; + + ALTER TABLE public.receiver_verifications + ALTER COLUMN verification_field TYPE %I.verification_type + USING verification_field::text::%I.verification_type; + INSERT INTO %I.receiver_verifications SELECT * FROM public.receiver_verifications; + + ALTER TABLE public.disbursements + ALTER COLUMN status DROP DEFAULT, + ALTER COLUMN status TYPE %I.disbursement_status + USING status::text::%I.disbursement_status, + ALTER COLUMN verification_field DROP DEFAULT, + ALTER COLUMN verification_field TYPE %I.verification_type + USING verification_field::text::%I.verification_type; + INSERT INTO %I.disbursements SELECT * FROM public.disbursements; + + ALTER TABLE public.payments + ALTER COLUMN status DROP DEFAULT, + ALTER COLUMN status TYPE %I.payment_status + USING status::text::%I.payment_status; + INSERT INTO %I.payments SELECT * FROM public.payments; + + ALTER TABLE public.messages + ALTER COLUMN status DROP DEFAULT, + ALTER COLUMN status TYPE %I.message_status + USING status::text::%I.message_status, + ALTER COLUMN type TYPE %I.message_type + USING type::text::%I.message_type; + INSERT INTO %I.messages SELECT * FROM public.messages; + ', schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, + schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, + schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, + schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, + schema_name); + + + -- Step 3: Import TSS data + + -- Add new columns to the transaction_submitter table and populate them + ALTER TABLE public.submitter_transactions + ADD COLUMN IF NOT EXISTS tenant_id VARCHAR(36), + ADD COLUMN IF NOT EXISTS distribution_account_address VARCHAR(56); + + WITH SelectedTenant AS ( + SELECT id AS tenant_id, distribution_account_address + FROM admin.tenants + LIMIT 1 + ) + UPDATE public.submitter_transactions SET tenant_id = (SELECT tenant_id FROM SelectedTenant), distribution_account_address = (SELECT distribution_account_address FROM SelectedTenant); + + -- Copy values to the new table + INSERT INTO tss.submitter_transactions + SELECT + id, external_id, + status::text::tss.transaction_status AS status, + status_history, status_message, asset_code, asset_issuer, amount, destination, created_at, updated_at, locked_at, started_at, sent_at, completed_at, synced_at, locked_until_ledger_number, stellar_transaction_hash, attempts_count, xdr_sent, xdr_received, tenant_id, distribution_account_address + FROM public.submitter_transactions; + +END; +$$ LANGUAGE plpgsql; +-- +migrate StatementEnd + +COMMENT ON FUNCTION migrate_tenant_data_from_v1_to_v2(TEXT) IS 'Migrate data from v1 to v2 for a given tenant'; + +-- +migrate Down +DROP FUNCTION migrate_tenant_data_from_v1_to_v2(TEXT); \ No newline at end of file diff --git a/db/migrations/sdp-migrations/2024-06-12.0-add-circle-client-config-table.sql b/db/migrations/sdp-migrations/2024-06-12.0-add-circle-client-config-table.sql new file mode 100644 index 000000000..e86fdcd67 --- /dev/null +++ b/db/migrations/sdp-migrations/2024-06-12.0-add-circle-client-config-table.sql @@ -0,0 +1,47 @@ +-- +migrate Up +CREATE TABLE circle_client_config ( + wallet_id VARCHAR(64) NOT NULL, + encrypted_api_key VARCHAR(256) NOT NULL, + encrypter_public_key VARCHAR(256) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- +migrate StatementBegin +CREATE OR REPLACE FUNCTION enforce_single_row_for_circle_client_config() + RETURNS TRIGGER AS $$ +BEGIN + IF (SELECT COUNT(*) FROM circle_client_config) != 0 THEN + RAISE EXCEPTION 'circle_client_config must contain exactly one row'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +-- +migrate StatementEnd + +CREATE TRIGGER enforce_single_row_for_circle_client_config_insert_trigger + BEFORE INSERT ON circle_client_config + FOR EACH ROW +EXECUTE FUNCTION enforce_single_row_for_circle_client_config(); + +CREATE TRIGGER enforce_single_row_for_circle_client_config_delete_trigger + BEFORE DELETE ON circle_client_config + FOR EACH ROW +EXECUTE FUNCTION enforce_single_row_for_circle_client_config(); + + +-- TRIGGER: updated_at +CREATE TRIGGER refresh_circle_client_config_updated_at BEFORE UPDATE ON circle_client_config FOR EACH ROW EXECUTE PROCEDURE update_at_refresh(); + + +-- +migrate Down + +DROP TRIGGER enforce_single_row_for_circle_client_config_delete_trigger ON circle_client_config; + +DROP TRIGGER enforce_single_row_for_circle_client_config_insert_trigger ON circle_client_config; + +DROP FUNCTION enforce_single_row_for_circle_client_config; + +DROP TRIGGER refresh_circle_client_config_updated_at ON circle_client_config; + +DROP TABLE circle_client_config; \ No newline at end of file diff --git a/db/migrations/sdp-migrations/2024-06-24.0-add-circle-transfers-table.sql b/db/migrations/sdp-migrations/2024-06-24.0-add-circle-transfers-table.sql new file mode 100644 index 000000000..e9fdb9d50 --- /dev/null +++ b/db/migrations/sdp-migrations/2024-06-24.0-add-circle-transfers-table.sql @@ -0,0 +1,26 @@ +-- +migrate Up +CREATE TYPE circle_transfer_status AS ENUM ('pending', 'complete', 'failed'); + +CREATE TABLE circle_transfer_requests ( + idempotency_key VARCHAR(36) PRIMARY KEY DEFAULT public.uuid_generate_v4(), + payment_id VARCHAR(36) NOT NULL, + circle_transfer_id VARCHAR(36), + status circle_transfer_status, + response_body JSONB, + source_wallet_id VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_payment_id ON circle_transfer_requests (payment_id) WHERE (status IS DISTINCT FROM 'failed'); + +-- TRIGGER: updated_at +CREATE TRIGGER refresh_circle_transfer_requests_updated_at BEFORE UPDATE ON circle_transfer_requests FOR EACH ROW EXECUTE PROCEDURE update_at_refresh(); + +-- +migrate Down +DROP TRIGGER refresh_circle_transfer_requests_updated_at ON circle_transfer_requests; + +DROP TABLE circle_transfer_requests; + +DROP TYPE circle_transfer_status; diff --git a/db/migrations/sdp-migrations/2024-06-27.0-alter-circle-transfers-add-reconciliation-tracking.sql b/db/migrations/sdp-migrations/2024-06-27.0-alter-circle-transfers-add-reconciliation-tracking.sql new file mode 100644 index 000000000..8216ab068 --- /dev/null +++ b/db/migrations/sdp-migrations/2024-06-27.0-alter-circle-transfers-add-reconciliation-tracking.sql @@ -0,0 +1,9 @@ +-- +migrate Up +ALTER TABLE circle_transfer_requests + ADD COLUMN sync_attempts INT NOT NULL DEFAULT 0, + ADD COLUMN last_sync_attempt_at TIMESTAMPTZ; + +-- +migrate Down +ALTER TABLE circle_transfer_requests + DROP COLUMN sync_attempts, + DROP COLUMN last_sync_attempt_at; diff --git a/db/migrations/sdp-migrations/2024-07-29.0-add-verification-type-year-month.sql b/db/migrations/sdp-migrations/2024-07-29.0-add-verification-type-year-month.sql new file mode 100644 index 000000000..521e8ea55 --- /dev/null +++ b/db/migrations/sdp-migrations/2024-07-29.0-add-verification-type-year-month.sql @@ -0,0 +1,37 @@ +-- This migration updates the verification_field column in the receiver_verifications table to include a new verification type called 'YEAR_MONTH'. +-- +migrate Up +ALTER TYPE verification_type ADD VALUE 'YEAR_MONTH'; + +-- +migrate Down +-- Create new type +CREATE TYPE verification_type_new AS ENUM ('DATE_OF_BIRTH', 'PIN', 'NATIONAL_ID_NUMBER'); + +-- Add a new column with the new type (receiver_verifications & disbursements) +ALTER TABLE receiver_verifications ADD COLUMN verification_field_new verification_type_new; +ALTER TABLE disbursements ADD COLUMN verification_field_new verification_type_new; + +-- Copy & transform data (receiver_verifications & disbursements) +UPDATE receiver_verifications SET verification_field_new = +CASE + WHEN verification_field = 'YEAR_MONTH' THEN 'DATE_OF_BIRTH'::verification_type_new + ELSE verification_field::text::verification_type_new +END; +UPDATE disbursements SET verification_field_new = +CASE + WHEN verification_field = 'YEAR_MONTH' THEN 'DATE_OF_BIRTH'::verification_type_new + ELSE verification_field::text::verification_type_new +END; + +-- Drop the old column (receiver_verifications & disbursements) +ALTER TABLE receiver_verifications DROP COLUMN verification_field; +ALTER TABLE disbursements DROP COLUMN verification_field; + +-- Rename the new column (receiver_verifications & disbursements) +ALTER TABLE receiver_verifications RENAME COLUMN verification_field_new TO verification_field; +ALTER TABLE disbursements RENAME COLUMN verification_field_new TO verification_field; + +-- Drop old type +DROP TYPE verification_type; + +-- Rename new type +ALTER TYPE verification_type_new RENAME TO verification_type; diff --git a/db/router/router_test.go b/db/router/router_test.go index 5c544d285..f3e64bd98 100644 --- a/db/router/router_test.go +++ b/db/router/router_test.go @@ -4,8 +4,9 @@ import ( "strings" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) func Test_GetDSNForAdmin(t *testing.T) { diff --git a/db/sql_exec_with_metrics.go b/db/sql_exec_with_metrics.go index b277cacbe..9e7363419 100644 --- a/db/sql_exec_with_metrics.go +++ b/db/sql_exec_with_metrics.go @@ -9,6 +9,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor" ) diff --git a/db/sql_exec_with_router_test.go b/db/sql_exec_with_router_test.go index 072b8ed76..8675c5caa 100644 --- a/db/sql_exec_with_router_test.go +++ b/db/sql_exec_with_router_test.go @@ -5,10 +5,11 @@ import ( "fmt" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) type MockDataSourceRouter struct { diff --git a/dev/.env.example b/dev/.env.example index ae8b42852..35f1ecd6e 100644 --- a/dev/.env.example +++ b/dev/.env.example @@ -10,5 +10,4 @@ DISTRIBUTION_SEED= CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE=${DISTRIBUTION_SEED} # Distribution signer -DISTRIBUTION_SIGNER_TYPE=DISTRIBUTION_ACCOUNT_ENV DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE=${DISTRIBUTION_SEED} diff --git a/dev/README.md b/dev/README.md index 9f3303707..eca0359b4 100644 --- a/dev/README.md +++ b/dev/README.md @@ -7,7 +7,7 @@ - [Quick Setup and Deployment](#quick-setup-and-deployment) - [Docker](#docker) - [Clone the repository:](#clone-the-repository) - - [Update local dns](#update-local-dns) + - [Update local DNS](#update-local-dns) - [Automated Stellar Account Creation and .env Configuration](#automated-stellar-account-creation-and-env-configuration) - [Install Multi-tenant SDP Locally](#install-multi-tenant-sdp-locally) - [Login to the SDP and send a Disbursement](#login-to-the-sdp-and-send-a-disbursement) @@ -66,7 +66,7 @@ scripts/make_env.sh The script will generate new keypairs with a USDC funded distribution account and create the .env file with the following configuration values. Example: -``` +```bash # Generate a new keypair for SEP-10 signing SEP10_SIGNING_PUBLIC_KEY=GCRSCJEVHB5JFXNZH3KYQRHSKDX3ZRFMMPKDPNX7AL3JSXJSILTV7DEW SEP10_SIGNING_PRIVATE_KEY=SBEZHHWE2QPBIKNMVHPE5QD2JUUN2PLYNEHYQZZPQ7GYPYWULDTJ5RZU @@ -79,7 +79,6 @@ DISTRIBUTION_SEED=SDDWY3N3DSTR6SNCZTECOW6PNUIPOHDTMLKVWDQUTHLRNIKMAUIT46M6 CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE=SDDWY3N3DSTR6SNCZTECOW6PNUIPOHDTMLKVWDQUTHLRNIKMAUIT46M6 # Distribution signer -DISTRIBUTION_SIGNER_TYPE=DISTRIBUTION_ACCOUNT_ENV DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE=SDDWY3N3DSTR6SNCZTECOW6PNUIPOHDTMLKVWDQUTHLRNIKMAUIT46M6 ``` diff --git a/dev/docker-compose-sdp-anchor.yml b/dev/docker-compose-sdp-anchor.yml index 751840be3..dc5ac650a 100644 --- a/dev/docker-compose-sdp-anchor.yml +++ b/dev/docker-compose-sdp-anchor.yml @@ -48,7 +48,6 @@ services: ADMIN_PORT: "8003" INSTANCE_NAME: "SDP Testnet on Docker" TENANT_XLM_BOOTSTRAP_AMOUNT: 5 - DISTRIBUTION_SIGNER_TYPE: ${DISTRIBUTION_SIGNER_TYPE:-DISTRIBUTION_ACCOUNT_ENV} SINGLE_TENANT_MODE: "false" # scheduler options @@ -215,10 +214,12 @@ services: kafka-topics.sh --create --if-not-exists --topic events.receiver-wallets.new_invitation --bootstrap-server kafka:9092 kafka-topics.sh --create --if-not-exists --topic events.payment.payment_completed --bootstrap-server kafka:9092 kafka-topics.sh --create --if-not-exists --topic events.payment.ready_to_pay --bootstrap-server kafka:9092 + kafka-topics.sh --create --if-not-exists --topic events.payment.circle_ready_to_pay --bootstrap-server kafka:9092 kafka-topics.sh --create --if-not-exists --topic events.receiver-wallets.new_invitation.dlq --bootstrap-server kafka:9092 kafka-topics.sh --create --if-not-exists --topic events.payment.payment_completed.dlq --bootstrap-server kafka:9092 kafka-topics.sh --create --if-not-exists --topic events.payment.ready_to_pay.dlq --bootstrap-server kafka:9092 + kafka-topics.sh --create --if-not-exists --topic events.payment.circle_ready_to_pay.dlq --bootstrap-server kafka:9092 " demo-wallet: build: diff --git a/dev/docker-compose-tss.yml b/dev/docker-compose-tss.yml index f2f0b8dac..3c101024e 100644 --- a/dev/docker-compose-tss.yml +++ b/dev/docker-compose-tss.yml @@ -18,7 +18,6 @@ services: TSS_METRICS_TYPE: "TSS_PROMETHEUS" DISTRIBUTION_PUBLIC_KEY: ${DISTRIBUTION_PUBLIC_KEY} DISTRIBUTION_SEED: ${DISTRIBUTION_SEED} - DISTRIBUTION_SIGNER_TYPE: ${DISTRIBUTION_SIGNER_TYPE:-DISTRIBUTION_ACCOUNT_ENV} DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE: ${DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE} CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE: ${CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE} diff --git a/dev/main.sh b/dev/main.sh index 36de98e21..15d6699ce 100755 --- a/dev/main.sh +++ b/dev/main.sh @@ -122,7 +122,8 @@ for tenant in "${tenants[@]}"; do "sdp_ui_base_url": "'"$sdpUIBaseURL"'", "owner_email": "'"$ownerEmail"'", "owner_first_name": "jane", - "owner_last_name": "doe" + "owner_last_name": "doe", + "distribution_account_type": "DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT" }') http_code=$(echo "$response" | tail -n1) diff --git a/dev/scripts/make_env.sh b/dev/scripts/make_env.sh index ddbb9b7dd..ca81c3944 100755 --- a/dev/scripts/make_env.sh +++ b/dev/scripts/make_env.sh @@ -30,7 +30,6 @@ DISTRIBUTION_SEED=$distribution_private CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE=$distribution_private # Distribution signer -DISTRIBUTION_SIGNER_TYPE=DISTRIBUTION_ACCOUNT_ENV DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE=$distribution_private EOF diff --git a/go.list b/go.list index ee5b74da7..d24230bd5 100644 --- a/go.list +++ b/go.list @@ -1,11 +1,14 @@ github.com/stellar/stellar-disbursement-platform-backend -cloud.google.com/go v0.112.1 +cloud.google.com/go v0.112.2 +cloud.google.com/go/auth v0.3.0 +cloud.google.com/go/auth/oauth2adapt v0.2.2 cloud.google.com/go/compute v1.24.0 -cloud.google.com/go/compute/metadata v0.2.3 +cloud.google.com/go/compute/metadata v0.3.0 cloud.google.com/go/firestore v1.15.0 -cloud.google.com/go/iam v1.1.5 -cloud.google.com/go/longrunning v0.5.5 -cloud.google.com/go/storage v1.35.1 +cloud.google.com/go/iam v1.1.8 +cloud.google.com/go/longrunning v0.5.6 +cloud.google.com/go/pubsub v1.37.0 +cloud.google.com/go/storage v1.40.0 firebase.google.com/go v3.12.0+incompatible github.com/2opremio/pretty v0.2.2-0.20230601220618-e1d5758b2a95 github.com/BurntSushi/toml v1.3.2 @@ -33,6 +36,7 @@ github.com/beevik/etree v1.1.0 github.com/beorn7/perks v1.0.1 github.com/bgentry/speakeasy v0.1.0 github.com/buger/goreplay v1.3.2 +github.com/cenkalti/backoff/v4 v4.2.1 github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d github.com/cespare/xxhash/v2 v2.2.0 github.com/chzyer/logex v1.2.1 @@ -47,6 +51,7 @@ github.com/creachadair/mds v0.0.1 github.com/creack/pty v1.1.9 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/denisenkom/go-mssqldb v0.9.0 +github.com/djherbis/fscache v0.10.1 github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 github.com/elazarl/go-bindata-assetfs v1.0.1 github.com/fatih/color v1.14.1 @@ -55,9 +60,10 @@ github.com/felixge/httpsnoop v1.0.4 github.com/flosch/pongo2/v4 v4.0.2 github.com/frankban/quicktest v1.14.6 github.com/fsnotify/fsnotify v1.7.0 +github.com/fsouza/fake-gcs-server v1.49.0 github.com/gavv/monotime v0.0.0-20161010190848-47d58efa6955 github.com/getsentry/raven-go v0.2.0 -github.com/getsentry/sentry-go v0.28.0 +github.com/getsentry/sentry-go v0.28.1 github.com/gin-contrib/sse v0.1.0 github.com/gin-gonic/gin v1.8.1 => github.com/gin-gonic/gin v1.9.1 github.com/go-chi/chi v4.1.2+incompatible @@ -80,25 +86,28 @@ github.com/gobuffalo/packr/v2 v2.8.3 github.com/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d github.com/goccy/go-json v0.9.11 github.com/godror/godror v0.24.2 -github.com/gofiber/fiber/v2 v2.52.2 +github.com/gofiber/fiber/v2 v2.52.2 => github.com/gofiber/fiber/v2 v2.52.5 github.com/gogo/protobuf v1.3.2 github.com/golang-jwt/jwt v3.2.2+incompatible github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da github.com/golang/mock v1.6.0 -github.com/golang/protobuf v1.5.3 +github.com/golang/protobuf v1.5.4 github.com/golang/snappy v0.0.4 github.com/google/go-cmp v0.6.0 github.com/google/go-querystring v1.1.0 github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 +github.com/google/renameio/v2 v2.0.0 github.com/google/s2a-go v0.1.7 github.com/google/uuid v1.6.0 github.com/googleapis/enterprise-certificate-proxy v0.3.2 github.com/googleapis/gax-go/v2 v2.12.3 github.com/googleapis/google-cloud-go-testing v0.0.0-20210719221736-1c9a4c676720 github.com/gorilla/css v1.0.0 -github.com/gorilla/schema v1.2.0 +github.com/gorilla/handlers v1.5.2 +github.com/gorilla/mux v1.8.1 +github.com/gorilla/schema v1.4.1 github.com/gorilla/websocket v1.5.0 github.com/graph-gophers/graphql-go v1.3.0 github.com/guregu/null v4.0.0+incompatible @@ -124,6 +133,7 @@ github.com/jarcoal/httpmock v0.0.0-20161210151336-4442edb3db31 github.com/jmespath/go-jmespath v0.4.0 github.com/jmespath/go-jmespath/internal/testify v1.5.1 github.com/jmoiron/sqlx v1.3.5 +github.com/joho/godotenv v1.5.1 github.com/josharian/intern v1.0.0 github.com/jpillora/backoff v1.0.0 github.com/json-iterator/go v1.1.12 @@ -189,6 +199,7 @@ github.com/pierrec/lz4/v4 v4.1.18 github.com/pingcap/errors v0.11.4 github.com/pkg/errors v0.9.1 github.com/pkg/sftp v1.13.6 +github.com/pkg/xattr v0.4.9 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/posener/complete v1.2.3 github.com/poy/onpar v1.1.2 @@ -198,7 +209,7 @@ github.com/prometheus/common v0.44.0 github.com/prometheus/procfs v0.12.0 github.com/rivo/uniseg v0.2.0 github.com/rogpeppe/go-internal v1.11.0 -github.com/rs/cors v1.10.1 +github.com/rs/cors v1.11.0 github.com/rubenv/sql-migrate v1.5.2 github.com/russross/blackfriday/v2 v2.1.0 github.com/sagikazarmark/crypt v0.19.0 @@ -218,7 +229,7 @@ github.com/spf13/cast v1.6.0 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.19.0 -github.com/stellar/go v0.0.0-20231212225359-bc7173e667a6 +github.com/stellar/go v0.0.0-20240617183518-100dc4fa6043 github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 github.com/stellar/throttled v2.2.3-0.20190823235211-89d75816f59d+incompatible github.com/stretchr/objx v0.5.2 @@ -263,26 +274,28 @@ go.opentelemetry.io/otel/trace v1.24.0 go.uber.org/atomic v1.9.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.21.0 -golang.org/x/crypto v0.21.0 +golang.org/x/crypto v0.22.0 golang.org/x/exp v0.0.0-20231006140011-7918f672742d golang.org/x/mod v0.13.0 -golang.org/x/net v0.23.0 -golang.org/x/oauth2 v0.18.0 -golang.org/x/sync v0.6.0 -golang.org/x/sys v0.18.0 -golang.org/x/term v0.18.0 +golang.org/x/net v0.24.0 +golang.org/x/oauth2 v0.20.0 +golang.org/x/sync v0.7.0 +golang.org/x/sys v0.19.0 +golang.org/x/term v0.19.0 golang.org/x/text v0.14.0 golang.org/x/time v0.5.0 golang.org/x/tools v0.14.0 golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 -google.golang.org/api v0.171.0 +google.golang.org/api v0.177.0 google.golang.org/appengine v1.6.8 -google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 -google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2 -google.golang.org/genproto/googleapis/rpc v0.0.0-20240314234333-6e1732d8331c -google.golang.org/grpc v1.62.1 -google.golang.org/protobuf v1.33.0 +google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda +google.golang.org/genproto/googleapis/api v0.0.0-20240429193739-8cf5692501f6 +google.golang.org/genproto/googleapis/rpc v0.0.0-20240429193739-8cf5692501f6 +google.golang.org/grpc v1.63.2 +google.golang.org/protobuf v1.34.1 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c +gopkg.in/djherbis/atime.v1 v1.0.0 +gopkg.in/djherbis/stream.v1 v1.3.1 gopkg.in/gavv/httpexpect.v1 v1.0.0-20170111145843-40724cf1e4a0 gopkg.in/ini.v1 v1.67.0 gopkg.in/square/go-jose.v2 v2.4.1 => gopkg.in/go-jose/go-jose.v2 v2.6.3 diff --git a/go.mod b/go.mod index e6e83520a..f1fc6478d 100644 --- a/go.mod +++ b/go.mod @@ -6,29 +6,30 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 github.com/avast/retry-go v3.0.0+incompatible github.com/aws/aws-sdk-go v1.45.26 - github.com/getsentry/sentry-go v0.28.0 + github.com/getsentry/sentry-go v0.28.1 github.com/go-chi/chi v4.1.2+incompatible github.com/go-chi/chi/v5 v5.0.10 github.com/go-chi/httprate v0.8.0 github.com/gocarina/gocsv v0.0.0-20230616125104-99d496ca653d github.com/golang-jwt/jwt/v4 v4.5.0 github.com/google/uuid v1.6.0 - github.com/gorilla/schema v1.2.0 + github.com/gorilla/schema v1.4.1 github.com/jmoiron/sqlx v1.3.5 + github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 github.com/manifoldco/promptui v0.9.0 github.com/nyaruka/phonenumbers v1.1.8 github.com/prometheus/client_golang v1.17.0 - github.com/rs/cors v1.10.1 + github.com/rs/cors v1.11.0 github.com/rubenv/sql-migrate v1.5.2 github.com/segmentio/kafka-go v0.4.46 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.7.0 github.com/spf13/viper v1.19.0 - github.com/stellar/go v0.0.0-20231212225359-bc7173e667a6 + github.com/stellar/go v0.0.0-20240617183518-100dc4fa6043 github.com/stretchr/testify v1.9.0 github.com/twilio/twilio-go v1.11.0 - golang.org/x/crypto v0.21.0 + golang.org/x/crypto v0.22.0 golang.org/x/exp v0.0.0-20231006140011-7918f672742d ) @@ -42,8 +43,7 @@ require ( github.com/go-errors/errors v1.5.1 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/golang/mock v1.6.0 // indirect - github.com/golang/protobuf v1.5.3 // indirect - github.com/google/go-cmp v0.6.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect @@ -72,9 +72,9 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/sys v0.18.0 // indirect + golang.org/x/sys v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect - google.golang.org/protobuf v1.33.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/tylerb/graceful.v1 v1.2.15 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect @@ -83,3 +83,5 @@ require ( replace github.com/gin-gonic/gin v1.8.1 => github.com/gin-gonic/gin v1.9.1 replace gopkg.in/square/go-jose.v2 v2.4.1 => gopkg.in/go-jose/go-jose.v2 v2.6.3 + +replace github.com/gofiber/fiber/v2 v2.52.2 => github.com/gofiber/fiber/v2 v2.52.5 diff --git a/go.sum b/go.sum index a526a19c4..3a64e19f0 100644 --- a/go.sum +++ b/go.sum @@ -38,8 +38,8 @@ github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nos github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gavv/monotime v0.0.0-20161010190848-47d58efa6955 h1:gmtGRvSexPU4B1T/yYo0sLOKzER1YT+b4kPxPpm0Ty4= github.com/gavv/monotime v0.0.0-20161010190848-47d58efa6955/go.mod h1:vmp8DIyckQMXOPl0AQVHt+7n5h7Gb7hS6CUydiV8QeA= -github.com/getsentry/sentry-go v0.28.0 h1:7Rqx9M3ythTKy2J6uZLHmc8Sz9OGgIlseuO1iBX/s0M= -github.com/getsentry/sentry-go v0.28.0/go.mod h1:1fQZ+7l7eeJ3wYi82q5Hg8GqAPgefRq+FP/QhafYVgg= +github.com/getsentry/sentry-go v0.28.1 h1:zzaSm/vHmGllRM6Tpx1492r0YDzauArdBfkJRtY6P5k= +github.com/getsentry/sentry-go v0.28.1/go.mod h1:1fQZ+7l7eeJ3wYi82q5Hg8GqAPgefRq+FP/QhafYVgg= github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= @@ -66,18 +66,16 @@ github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= -github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= +github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= +github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= @@ -92,6 +90,8 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGw github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw= github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= @@ -163,8 +163,8 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= -github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po= +github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rubenv/sql-migrate v1.5.2 h1:bMDqOnrJVV/6JQgQ/MxOpU+AdO8uzYYA/TxFUBzFtS0= github.com/rubenv/sql-migrate v1.5.2/go.mod h1:H38GW8Vqf8F0Su5XignRyaRcbXbJunSWxs+kmzlg0Is= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -192,8 +192,8 @@ github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= -github.com/stellar/go v0.0.0-20231212225359-bc7173e667a6 h1:LcQ01nwgxVoCmzAthjGSbxun9z/mxuqDy4uYETw4jEQ= -github.com/stellar/go v0.0.0-20231212225359-bc7173e667a6/go.mod h1:PAWie4LYyDzJXqDVG4Qcj1Nt+uNk7sjzgSCXndQYsBA= +github.com/stellar/go v0.0.0-20240617183518-100dc4fa6043 h1:5UQzsvt9VtD3ijpzPtdW0/lXWCNgDs6GzmLUE8ZuWfk= +github.com/stellar/go v0.0.0-20240617183518-100dc4fa6043/go.mod h1:TuXKLL7WViqwrvpWno2I4UYGn2Ny9KZld1jUIN6fnK8= github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 h1:OzCVd0SV5qE3ZcDeSFCmOWLZfEWZ3Oe8KtmSOYKEVWE= github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2/go.mod h1:yoxyU/M8nl9LKeWIoBrbDPQ7Cy+4jxRcWcOayZ4BMps= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -244,8 +244,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= @@ -260,8 +260,8 @@ golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -282,16 +282,16 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -309,12 +309,9 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/helmchart/sdp/Chart.yaml b/helmchart/sdp/Chart.yaml index 9554057b2..463527fd2 100644 --- a/helmchart/sdp/Chart.yaml +++ b/helmchart/sdp/Chart.yaml @@ -1,12 +1,12 @@ apiVersion: v2 name: stellar-disbursement-platform description: A Helm chart for the Stellar Disbursement Platform Backend (A.K.A. `sdp`) -version: 2.0.0 -appVersion: "2.0.0" +version: "2.1.0" +appVersion: "2.1.0" type: application maintainers: - name: Stellar Development Foundation sources: - https://github.com/stellar/stellar-disbursement-platform-backend - https://github.com/stellar/stellar-disbursement-platform-frontend - - https://github.com/stellar/helm-charts \ No newline at end of file + - https://github.com/stellar/helm-charts diff --git a/helmchart/sdp/README.md b/helmchart/sdp/README.md index 56715e96b..85e57c306 100644 --- a/helmchart/sdp/README.md +++ b/helmchart/sdp/README.md @@ -93,86 +93,84 @@ Configuration parameters for the SDP Core Service which is the core backend serv - Messaging Service: a recurring process that sends text messages to users prompting them to download the wallet selected for a particular disbursement and verify their phone with an OTP - Wallet Registration UI: a web application that collects and verifies the recipient’s OTP code and verification information via Stellar’s SEP-24: Hosted Deposit and Withdrawal protocol -| Name | Description | Value | -| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | -| `sdp.route` | Configuration related to the routing of the SDP service. | | -| `sdp.route.schema` | Protocol scheme used for the service. Can be "http" or "https". | `https` | -| `sdp.route.domain` | Public domain/address of the SDP service. If using localhost, consider including the port as part of the domain. | `nil` | -| `sdp.route.mtnDomain` | Public domain/address of the multi-tenant SDP service. This is a wild-card domain used for multi-tenant setups e.g. "*.sdp.localhost.com". | `nil` | -| `sdp.route.port` | Primary port on which the SDP service listens. | `8000` | -| `sdp.route.metricsPort` | Port dedicated to metrics collection for the SDP service. | `8002` | -| `sdp.route.adminPort` | Port dedicated to serve the SDP admin endpoints, used to manage new or existing tenants. | `8003` | -| `sdp.image` | Configuration related to the Docker image used by the SDP service. | | -| `sdp.image.repository` | Docker image repository for the SDP backend service. | `stellar/stellar-disbursement-platform-backend` | -| `sdp.image.pullPolicy` | Image pull policy for the SDP service. For locally built images, consider using "Never" or "IfNotPresent". | `Always` | -| `sdp.image.tag` | Docker image tag for the SDP service. If set, this overrides the default value from `.Chart.AppVersion`. | `latest` | -| `sdp.deployment` | Configuration related to the deployment of the SDP service. | | -| `sdp.deployment.annotations` | Annotations to be added to the deployment. | `nil` | -| `sdp.deployment.podAnnotations` | Annotations specific to the pods. | `{}` | -| `sdp.deployment.podSecurityContext` | Security settings for the pods. | `{}` | -| `sdp.deployment.securityContext` | Security settings for the container within the pod. | `{}` | -| `sdp.deployment.strategy` | Configuration related to the deployment strategy, ensuring smooth updates and minimal downtime. | `{}` | -| `sdp.deployment.nodeSelector` | Node selector to determine which nodes should run the pods. | `{}` | -| `sdp.deployment.tolerations` | Tolerations to ensure pods aren't scheduled on unsuitable nodes. | `[]` | -| `sdp.deployment.affinity` | Affinity rules to determine where pods get scheduled based on node conditions. | `{}` | -| `sdp.configMap` | Configuration for the ConfigMap used by the SDP service. | | -| `sdp.configMap.annotations` | Annotations to be added to the ConfigMap. | `nil` | -| `sdp.configMap.data` | Used to inject non-sensitive environment variables into the SDP deployment; for the latest variables, consult the application's CLI `-h` command. | | -| `sdp.configMap.data.BASE_URL` | The base URL of the SDP backend. | `http://localhost:8000` | -| `sdp.configMap.data.CRASH_TRACKER_TYPE` | Determines the type of crash tracker in use. Options: "DRY_RUN", "SENTRY". | `DRY_RUN` | -| `sdp.configMap.data.EC256_PUBLIC_KEY` | The EC256 public key used for authentication purposes. This EC key needs to be at least as strong as prime256v1 (P-256). | `""` | -| `sdp.configMap.data.ENVIRONMENT` | Specifies the environment SDP is running in (e.g. "localhost"). | `dev` | -| `sdp.configMap.data.LOG_LEVEL` | Determines the verbosity level of logs. Options: "TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL", "PANIC" | `INFO` | -| `sdp.configMap.data.SEP10_SIGNING_PUBLIC_KEY` | Anchor platform SEP10 signing public key. | `nil` | -| `sdp.configMap.data.DISTRIBUTION_PUBLIC_KEY` | The public key of the HOST's Stellar distribution account, used to create channel accounts. | `nil` | -| `sdp.configMap.data.METRICS_TYPE` | Defines the type of metrics system in use. Options: "PROMETHEUS". | `PROMETHEUS` | -| `sdp.configMap.data.EMAIL_SENDER_TYPE` | The messenger type used to send invitations to new dashboard users. Options: "DRY_RUN", "AWS_EMAIL". | `DRY_RUN` | -| `sdp.configMap.data.SMS_SENDER_TYPE` | The messenger type used to send text messages to recipients. Options: "DRY_RUN", "TWILIO_SMS". | `DRY_RUN` | -| `sdp.configMap.data.RECAPTCHA_SITE_KEY` | Site key for ReCaptcha. Required if using ReCaptcha. | `nil` | -| `sdp.configMap.data.CORS_ALLOWED_ORIGINS` | Specifies the domains allowed to make cross-origin requests. "*" means all domains are allowed. | `*` | -| `sdp.configMap.data.DISABLE_RECAPTCHA` | Determines if ReCaptcha should be disabled for login ("true" or "false"). | `false` | -| `sdp.configMap.data.DISABLE_MFA` | Determines if email-based MFA should be disabled during login ("true" or "false"). | `false` | -| `sdp.configMap.data.SDP_UI_BASE_URL` | The base URL of the SDP UI/dashboard. | `nil` | -| `sdp.configMap.data.INSTANCE_NAME` | The name of the SDP instance. Example: "SDP Testnet". | `nil` | -| `sdp.configMap.data.ENABLE_SCHEDULER` | Whether the scheduled jobs are enabled in this instance ("true" or "false"). Default "false". | `false` | -| `sdp.configMap.data.SCHEDULER_PAYMENT_JOB_SECONDS` | The interval in seconds for the payment job that syncs payments between the SDP and the TSS. | `3600` | -| `sdp.configMap.data.SCHEDULER_RECEIVER_INVITATION_JOB_SECONDS` | The interval in seconds for the receiver invitation job that sends invitations to new receivers. 0 or negative values disable the job. | `3600` | -| `sdp.configMap.data.MAX_INVITATION_SMS_RESEND_ATTEMPTS` | The maximum number of times an invitation SMS can be resent. 0 or negative values disable the job. | `3` | -| `sdp.configMap.data.TENANT_XLM_BOOTSTRAP_AMOUNT` | The amount of XLM to be sent to a newly created tenant distribution account. | `5` | -| `sdp.kubeSecrets` | Kubernetes secrets are used to manage sensitive information, such as API keys and private keys. It's crucial that these details are kept private. | | -| `sdp.kubeSecrets.secretName` | The name of the Kubernetes secret object. Only use this if create is false. | `sdp-backend-secret-name` | -| `sdp.kubeSecrets.create` | If true, the secret will be created. If false, it is assumed the secret already exists. | `false` | -| `sdp.kubeSecrets.annotations` | Annotations to be added to the secret. | `nil` | -| `sdp.kubeSecrets.data.AWS_ACCESS_KEY_ID` | AWS IAM user's access key ID for authenticating to AWS services. | `MY_AWS_ACCESS_KEY_ID` | -| `sdp.kubeSecrets.data.AWS_REGION` | AWS region where services (like SES for email sending) are provisioned. | `MY_AWS_REGION` | -| `sdp.kubeSecrets.data.AWS_SECRET_ACCESS_KEY` | AWS IAM user's secret access key for authenticating to AWS services. | `MY_AWS_SECRET_ACCESS_KEY` | -| `sdp.kubeSecrets.data.AWS_SES_SENDER_ID` | Identifier for the AWS SES service used for sending emails. | `MY_AWS_SES_SENDER_ID` | -| `sdp.kubeSecrets.data.AWS_SNS_SENDER_ID` | Identifier for the AWS SNS service used for sending text messages. | `MY_AWS_SNS_SENDER_ID` | -| `sdp.kubeSecrets.data.TWILIO_ACCOUNT_SID` | Account SID for authenticating to the Twilio service, used for sending text messages. | `MY_TWILIO_ACCOUNT_SID` | -| `sdp.kubeSecrets.data.TWILIO_AUTH_TOKEN` | Authentication token for the Twilio service. | `MY_TWILIO_AUTH_TOKEN` | -| `sdp.kubeSecrets.data.TWILIO_SERVICE_SID` | Service SID for the specific Twilio service being utilized. | `MY_TWILIO_SERVICE_SID` | -| `sdp.kubeSecrets.data.EC256_PRIVATE_KEY` | The EC256 Private Key. This key is used to sign the authentication token. This EC key needs to be at least as strong as prime256v1 (P-256). | `""` | -| `sdp.kubeSecrets.data.SEP10_SIGNING_PRIVATE_KEY` | The public key of the Stellar account that signs the SEP-10 transactions. It's also used to sign URLs. | `nil` | -| `sdp.kubeSecrets.data.SEP24_JWT_SECRET` | The JWT secret that's used by the Anchor Platform to sign the SEP-24 JWT token. Must be the same as Anchor Platform's SECRET_SEP24_INTERACTIVE_URL_JWT_SECRET. | `nil` | -| `sdp.kubeSecrets.data.RECAPTCHA_SITE_SECRET_KEY` | Secret key for Google reCAPTCHA service to verify user's non-robotic behavior. | `nil` | -| `sdp.kubeSecrets.data.ANCHOR_PLATFORM_OUTGOING_JWT_SECRET` | The JWT secret used to create a JWT token used to send requests to the anchor platform. | `nil` | -| `sdp.kubeSecrets.data.DATABASE_URL` | URL of the database used by the SDP. | `nil` | -| `sdp.kubeSecrets.data.DISTRIBUTION_SEED` | The HOST's Stellar distribution account, used to create channel accounts. This is needed for the init container. | `nil` | -| `sdp.kubeSecrets.data.CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE` | The private key used to encrypt the channel accounts secrets in the database. | `nil` | -| `sdp.kubeSecrets.data.DISTRIBUTION_SIGNER_TYPE` | The type of signer used to sign Stellar transactions for the tenants' distribution accounts. Options: DISTRIBUTION_ACCOUNT_ENV: uses the same distribution account for all tenants, as well as for the HOST, through the secret configured in DISTRIBUTION_SEED. DISTRIBUTION_ACCOUNT_DB: uses the one different distribution account private key per tenant, and stores them in the database, encrypted with the DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE. | `nil` | -| `sdp.kubeSecrets.data.DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE` | The private key used to encrypt the distribution accounts secrets in the database, mandatory when DISTRIBUTION_SIGNER_TYPE is set to DISTRIBUTION_ACCOUNT_DB. | `nil` | -| `sdp.kubeSecrets.data.SENTRY_DSN` | The DSN for the Sentry service. it must be set if CRASH_TRACKER_TYPE is set to "SENTRY". | `nil` | -| `sdp.kubeSecrets.data.KAFKA_SASL_USERNAME` | The username for SASL authentication to the Kafka broker. Required if KAFKA_SECURITY_PROTOCOL is set to "SASL_SSL" or "SASL_PLAINTEXT". | `nil` | -| `sdp.kubeSecrets.data.KAFKA_SASL_PASSWORD` | The password for SASL authentication to the Kafka broker. Required if KAFKA_SECURITY_PROTOCOL is set to "SASL_SSL" or "SASL_PLAINTEXT". | `nil` | -| `sdp.kubeSecrets.data.KAFKA_SSL_ACCESS_KEY` | Access key (keystore) in PEM format. Required if KAFKA_SECURITY_PROTOCOL is set to "SSL". | `nil` | -| `sdp.kubeSecrets.data.KAFKA_SSL_ACCESS_CERTIFICATE` | Certificate in PEM format that matches with the Kafka Access Key. Required if KAFKA_SECURITY_PROTOCOL is set to "SSL". | `nil` | -| `sdp.kubeSecrets.data.ADMIN_ACCOUNT` | The ID of the admin account. To use, add to the request header as 'Authorization', formatted as Base64-encoded 'ADMIN_ACCOUNT:ADMIN_API_KEY'.", | `nil` | -| `sdp.kubeSecrets.data.ADMIN_API_KEY` | The API key for the admin account. To use, add to the request header as 'Authorization', formatted as Base64-encoded 'ADMIN_ACCOUNT:ADMIN_API_KEY'.", | `nil` | -| `sdp.ingress` | Configuration for the ingress controller for the SDP service. | | -| `sdp.ingress.enabled` | If true, an ingress controller will be created for the SDP service. | `true` | -| `sdp.ingress.className` | Name of the IngressClass to be used for the ingress controller. | `nginx` | -| `sdp.ingress.tls[0].hosts` | List of hosts covered by the TLS certificate. | `["{{ include \"sdp.domain\" . }}"]` | -| `sdp.ingress.tls[0].secretName` | The name of the Kubernetes TLS secret. You need to create this secret manually. | `backend-tls-cert-name` | +| Name | Description | Value | +| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| `sdp.route` | Configuration related to the routing of the SDP service. | | +| `sdp.route.schema` | Protocol scheme used for the service. Can be "http" or "https". | `https` | +| `sdp.route.domain` | Public domain/address of the SDP service. If using localhost, consider including the port as part of the domain. | `nil` | +| `sdp.route.mtnDomain` | Public domain/address of the multi-tenant SDP service. This is a wild-card domain used for multi-tenant setups e.g. "*.sdp.localhost.com". | `nil` | +| `sdp.route.port` | Primary port on which the SDP service listens. | `8000` | +| `sdp.route.metricsPort` | Port dedicated to metrics collection for the SDP service. | `8002` | +| `sdp.route.adminPort` | Port dedicated to serve the SDP admin endpoints, used to manage new or existing tenants. | `8003` | +| `sdp.image` | Configuration related to the Docker image used by the SDP service. | | +| `sdp.image.repository` | Docker image repository for the SDP backend service. | `stellar/stellar-disbursement-platform-backend` | +| `sdp.image.pullPolicy` | Image pull policy for the SDP service. For locally built images, consider using "Never" or "IfNotPresent". | `Always` | +| `sdp.image.tag` | Docker image tag for the SDP service. If set, this overrides the default value from `.Chart.AppVersion`. | `latest` | +| `sdp.deployment` | Configuration related to the deployment of the SDP service. | | +| `sdp.deployment.annotations` | Annotations to be added to the deployment. | `nil` | +| `sdp.deployment.podAnnotations` | Annotations specific to the pods. | `{}` | +| `sdp.deployment.podSecurityContext` | Security settings for the pods. | `{}` | +| `sdp.deployment.securityContext` | Security settings for the container within the pod. | `{}` | +| `sdp.deployment.strategy` | Configuration related to the deployment strategy, ensuring smooth updates and minimal downtime. | `{}` | +| `sdp.deployment.nodeSelector` | Node selector to determine which nodes should run the pods. | `{}` | +| `sdp.deployment.tolerations` | Tolerations to ensure pods aren't scheduled on unsuitable nodes. | `[]` | +| `sdp.deployment.affinity` | Affinity rules to determine where pods get scheduled based on node conditions. | `{}` | +| `sdp.configMap` | Configuration for the ConfigMap used by the SDP service. | | +| `sdp.configMap.annotations` | Annotations to be added to the ConfigMap. | `nil` | +| `sdp.configMap.data` | Used to inject non-sensitive environment variables into the SDP deployment; for the latest variables, consult the application's CLI `-h` command. | | +| `sdp.configMap.data.CRASH_TRACKER_TYPE` | Determines the type of crash tracker in use. Options: "DRY_RUN", "SENTRY". | `DRY_RUN` | +| `sdp.configMap.data.EC256_PUBLIC_KEY` | The EC256 public key used for authentication purposes. This EC key needs to be at least as strong as prime256v1 (P-256). | `""` | +| `sdp.configMap.data.ENVIRONMENT` | Specifies the environment SDP is running in (e.g. "localhost"). | `dev` | +| `sdp.configMap.data.LOG_LEVEL` | Determines the verbosity level of logs. Options: "TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL", "PANIC" | `INFO` | +| `sdp.configMap.data.SEP10_SIGNING_PUBLIC_KEY` | Anchor platform SEP10 signing public key. | `nil` | +| `sdp.configMap.data.DISTRIBUTION_PUBLIC_KEY` | The public key of the HOST's Stellar distribution account, used to create channel accounts. | `nil` | +| `sdp.configMap.data.METRICS_TYPE` | Defines the type of metrics system in use. Options: "PROMETHEUS". | `PROMETHEUS` | +| `sdp.configMap.data.EMAIL_SENDER_TYPE` | The messenger type used to send invitations to new dashboard users. Options: "DRY_RUN", "AWS_EMAIL". | `DRY_RUN` | +| `sdp.configMap.data.SMS_SENDER_TYPE` | The messenger type used to send text messages to recipients. Options: "DRY_RUN", "TWILIO_SMS". | `DRY_RUN` | +| `sdp.configMap.data.RECAPTCHA_SITE_KEY` | Site key for ReCaptcha. Required if using ReCaptcha. | `nil` | +| `sdp.configMap.data.CORS_ALLOWED_ORIGINS` | Specifies the domains allowed to make cross-origin requests. "*" means all domains are allowed. | `*` | +| `sdp.configMap.data.DISABLE_RECAPTCHA` | Determines if ReCaptcha should be disabled for login ("true" or "false"). | `false` | +| `sdp.configMap.data.DISABLE_MFA` | Determines if email-based MFA should be disabled during login ("true" or "false"). | `false` | +| `sdp.configMap.data.SDP_UI_BASE_URL` | The base URL of the SDP UI/dashboard. | `nil` | +| `sdp.configMap.data.INSTANCE_NAME` | The name of the SDP instance. Example: "SDP Testnet". | `nil` | +| `sdp.configMap.data.ENABLE_SCHEDULER` | Whether the scheduled jobs are enabled in this instance ("true" or "false"). Default "false". | `false` | +| `sdp.configMap.data.SCHEDULER_PAYMENT_JOB_SECONDS` | The interval in seconds for the payment job that syncs payments between the SDP and the TSS. | `3600` | +| `sdp.configMap.data.SCHEDULER_RECEIVER_INVITATION_JOB_SECONDS` | The interval in seconds for the receiver invitation job that sends invitations to new receivers. 0 or negative values disable the job. | `3600` | +| `sdp.configMap.data.MAX_INVITATION_SMS_RESEND_ATTEMPTS` | The maximum number of times an invitation SMS can be resent. 0 or negative values disable the job. | `3` | +| `sdp.configMap.data.TENANT_XLM_BOOTSTRAP_AMOUNT` | The amount of XLM to be sent to a newly created tenant distribution account. | `5` | +| `sdp.kubeSecrets` | Kubernetes secrets are used to manage sensitive information, such as API keys and private keys. It's crucial that these details are kept private. | | +| `sdp.kubeSecrets.secretName` | The name of the Kubernetes secret object. Only use this if create is false. | `sdp-backend-secret-name` | +| `sdp.kubeSecrets.create` | If true, the secret will be created. If false, it is assumed the secret already exists. | `false` | +| `sdp.kubeSecrets.annotations` | Annotations to be added to the secret. | `nil` | +| `sdp.kubeSecrets.data.AWS_ACCESS_KEY_ID` | AWS IAM user's access key ID for authenticating to AWS services. | `MY_AWS_ACCESS_KEY_ID` | +| `sdp.kubeSecrets.data.AWS_REGION` | AWS region where services (like SES for email sending) are provisioned. | `MY_AWS_REGION` | +| `sdp.kubeSecrets.data.AWS_SECRET_ACCESS_KEY` | AWS IAM user's secret access key for authenticating to AWS services. | `MY_AWS_SECRET_ACCESS_KEY` | +| `sdp.kubeSecrets.data.AWS_SES_SENDER_ID` | Identifier for the AWS SES service used for sending emails. | `MY_AWS_SES_SENDER_ID` | +| `sdp.kubeSecrets.data.AWS_SNS_SENDER_ID` | Identifier for the AWS SNS service used for sending text messages. | `MY_AWS_SNS_SENDER_ID` | +| `sdp.kubeSecrets.data.TWILIO_ACCOUNT_SID` | Account SID for authenticating to the Twilio service, used for sending text messages. | `MY_TWILIO_ACCOUNT_SID` | +| `sdp.kubeSecrets.data.TWILIO_AUTH_TOKEN` | Authentication token for the Twilio service. | `MY_TWILIO_AUTH_TOKEN` | +| `sdp.kubeSecrets.data.TWILIO_SERVICE_SID` | Service SID for the specific Twilio service being utilized. | `MY_TWILIO_SERVICE_SID` | +| `sdp.kubeSecrets.data.EC256_PRIVATE_KEY` | The EC256 Private Key. This key is used to sign the authentication token. This EC key needs to be at least as strong as prime256v1 (P-256). | `""` | +| `sdp.kubeSecrets.data.SEP10_SIGNING_PRIVATE_KEY` | The public key of the Stellar account that signs the SEP-10 transactions. It's also used to sign URLs. | `nil` | +| `sdp.kubeSecrets.data.SEP24_JWT_SECRET` | The JWT secret that's used by the Anchor Platform to sign the SEP-24 JWT token. Must be the same as Anchor Platform's SECRET_SEP24_INTERACTIVE_URL_JWT_SECRET. | `nil` | +| `sdp.kubeSecrets.data.RECAPTCHA_SITE_SECRET_KEY` | Secret key for Google reCAPTCHA service to verify user's non-robotic behavior. | `nil` | +| `sdp.kubeSecrets.data.ANCHOR_PLATFORM_OUTGOING_JWT_SECRET` | The JWT secret used to create a JWT token used to send requests to the anchor platform. | `nil` | +| `sdp.kubeSecrets.data.DATABASE_URL` | URL of the database used by the SDP. | `nil` | +| `sdp.kubeSecrets.data.DISTRIBUTION_SEED` | The HOST's Stellar distribution account, used to create channel accounts. This is needed for the init container. | `nil` | +| `sdp.kubeSecrets.data.CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE` | The private key used to encrypt the channel accounts secrets in the database. | `nil` | +| `sdp.kubeSecrets.data.DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE` | A Stellar-compliant ed25519 private key used to encrypt and decrypt the private keys of tenants’ distribution accounts. | `nil` | +| `sdp.kubeSecrets.data.SENTRY_DSN` | The DSN for the Sentry service. it must be set if CRASH_TRACKER_TYPE is set to "SENTRY". | `nil` | +| `sdp.kubeSecrets.data.KAFKA_SASL_USERNAME` | The username for SASL authentication to the Kafka broker. Required if KAFKA_SECURITY_PROTOCOL is set to "SASL_SSL" or "SASL_PLAINTEXT". | `nil` | +| `sdp.kubeSecrets.data.KAFKA_SASL_PASSWORD` | The password for SASL authentication to the Kafka broker. Required if KAFKA_SECURITY_PROTOCOL is set to "SASL_SSL" or "SASL_PLAINTEXT". | `nil` | +| `sdp.kubeSecrets.data.KAFKA_SSL_ACCESS_KEY` | Access key (keystore) in PEM format. Required if KAFKA_SECURITY_PROTOCOL is set to "SSL". | `nil` | +| `sdp.kubeSecrets.data.KAFKA_SSL_ACCESS_CERTIFICATE` | Certificate in PEM format that matches with the Kafka Access Key. Required if KAFKA_SECURITY_PROTOCOL is set to "SSL". | `nil` | +| `sdp.kubeSecrets.data.ADMIN_ACCOUNT` | The ID of the admin account. To use, add to the request header as 'Authorization', formatted as Base64-encoded 'ADMIN_ACCOUNT:ADMIN_API_KEY'.", | `nil` | +| `sdp.kubeSecrets.data.ADMIN_API_KEY` | The API key for the admin account. To use, add to the request header as 'Authorization', formatted as Base64-encoded 'ADMIN_ACCOUNT:ADMIN_API_KEY'.", | `nil` | +| `sdp.ingress` | Configuration for the ingress controller for the SDP service. | | +| `sdp.ingress.enabled` | If true, an ingress controller will be created for the SDP service. | `true` | +| `sdp.ingress.className` | Name of the IngressClass to be used for the ingress controller. | `nginx` | +| `sdp.ingress.tls[0].hosts` | List of hosts covered by the TLS certificate. | `["{{ include \"sdp.domain\" . }}"]` | +| `sdp.ingress.tls[0].secretName` | The name of the Kubernetes TLS secret. You need to create this secret manually. | `backend-tls-cert-name` | ### Anchor Platform @@ -235,48 +233,47 @@ the recipient’s registration process through the SEP-24 deposit flow. Configuration parameters for the Transaction Submission Service. This is the service that submits all payment transactions to the Stellar network. This service is designed to maximize payment throughput, handle queuing, and graceful resubmission/error handling -| Name | Description | Value | -| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | -| `tss.enabled` | If true, the tss will be deployed. | `true` | -| `tss.route` | Configuration related to the routing of the TSS. | | -| `tss.route.schema` | Protocol scheme used for the service. Can be "http" or "https". | `https` | -| `tss.route.port` | Primary port on which the TSS listens. | `9000` | -| `tss.route.metricsPort` | Port dedicated to metrics collection for the TSS. | `9002` | -| `tss.deployment` | Configuration related to the deployment of the TSS. | | -| `tss.deployment.annotations` | Annotations to be added to the deployment. | `nil` | -| `tss.deployment.podAnnotations` | Annotations specific to the pods. | `{}` | -| `tss.deployment.strategy` | Configuration related to the deployment strategy, ensuring smooth updates and minimal downtime. | `{}` | -| `tss.deployment.podSecurityContext` | Security settings for the pods. | `{}` | -| `tss.deployment.securityContext` | Security settings for the container within the pod. | `{}` | -| `tss.deployment.resources` | Resource limits and requests for the application pods. | `{}` | -| `tss.deployment.nodeSelector` | Node selector to determine which nodes should run the pods. | `{}` | -| `tss.deployment.tolerations` | Tolerations to ensure pods aren't scheduled on unsuitable nodes. | `[]` | -| `tss.deployment.affinity` | Affinity rules to determine where pods get scheduled based on node conditions. | `{}` | -| `tss.configMap` | Configuration settings for the Transaction Submission Service (TSS) ConfigMap. | | -| `tss.configMap.annotations` | Annotations to be added to the ConfigMap. | `nil` | -| `tss.configMap.data` | Used to inject non-sensitive environment variables into the TSS deployment; for the latest variables, consult the application's CLI `-h` command. | | -| `tss.configMap.data.CRASH_TRACKER_TYPE` | Determines the type of crash tracker in use. Options: "DRY_RUN", "SENTRY". | `DRY_RUN` | -| `tss.configMap.data.DISTRIBUTION_PUBLIC_KEY` | The public key of the HOST's Stellar distribution account, used to create channel accounts. | `nil` | -| `tss.configMap.data.NUM_CHANNEL_ACCOUNTS` | The number of channel accounts the TSS will create/use. Channel accounts provide a method for submitting transactions to the network at a high rate. | `1` | -| `tss.configMap.data.MAX_BASE_FEE` | Specifies the maximum base fee (in stroops) the TSS is willing to pay per transaction. This helps to control costs and ensures transactions are economically feasible. | `100000` | -| `tss.configMap.data.TSS_METRICS_TYPE` | Defines the type of metrics system that the TSS should use. Options: "TSS_PROMETHEUS". | `TSS_PROMETHEUS` | -| `tss.configMap.data.QUEUE_POLLING_INTERVAL` | Specifies the interval (in seconds) at which the TSS should poll the queue. | `6` | -| `tss.configMap.data.ENVIRONMENT` | Specifies the environment TSS is running in (e.g. "localhost"). | `development` | -| `tss.configMap.data.LOG_LEVEL` | Determines the verbosity level of logs. Options: "TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL", "PANIC" | `INFO` | -| `tss.kubeSecrets` | Kubernetes secrets are used to manage sensitive information, such as API keys and private keys. It's crucial that these details are kept private. | | -| `tss.kubeSecrets.secretName` | The name of the Kubernetes secret object. Only use this if create is false. | `tss-secret-name` | -| `tss.kubeSecrets.create` | If true, the secret will be created. If false, it is assumed the secret already exists. | `false` | -| `tss.kubeSecrets.annotations` | Annotations to be added to the secret. | `nil` | -| `tss.kubeSecrets.data.DATABASE_URL` | URL of the database used by the TSS. | `nil` | -| `tss.kubeSecrets.data.DISTRIBUTION_SEED` | The HOST's Stellar distribution account, used to create channel accounts. | `nil` | -| `tss.kubeSecrets.data.CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE` | The private key used to encrypt the channel accounts secrets in the database. | `nil` | -| `tss.kubeSecrets.data.DISTRIBUTION_SIGNER_TYPE` | The type of signer used to sign Stellar transactions for the tenants' distribution accounts. Options: DISTRIBUTION_ACCOUNT_ENV: uses the the same distribution account for all tenants, as well as for the HOST, through the secret configured in DISTRIBUTION_SEED. DISTRIBUTION_ACCOUNT_DB: uses the one different distribution account private key per tenant, and stores them in the database, encrypted with the DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE. | `nil` | -| `tss.kubeSecrets.data.DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE` | The private key used to encrypt the distribution accounts secrets in the database, mandatory when DISTRIBUTION_SIGNER_TYPE is set to DISTRIBUTION_ACCOUNT_DB. | `nil` | -| `tss.kubeSecrets.data.SENTRY_DSN` | The DSN for the Sentry service. it must be set if CRASH_TRACKER_TYPE is set to "SENTRY". | `nil` | -| `tss.kubeSecrets.data.KAFKA_SASL_USERNAME` | The username for SASL authentication to the Kafka broker. Required if KAFKA_SECURITY_PROTOCOL is set to "SASL_SSL" or "SASL_PLAINTEXT". | `nil` | -| `tss.kubeSecrets.data.KAFKA_SASL_PASSWORD` | The password for SASL authentication to the Kafka broker. Required if KAFKA_SECURITY_PROTOCOL is set to "SASL_SSL" or "SASL_PLAINTEXT". | `nil` | -| `tss.kubeSecrets.data.KAFKA_SSL_ACCESS_KEY` | Access key (keystore) in PEM format. Required if KAFKA_SECURITY_PROTOCOL is set to "SSL". | `nil` | -| `tss.kubeSecrets.data.KAFKA_SSL_ACCESS_CERTIFICATE` | Certificate in PEM format that matches with the Kafka Access Key. Required if KAFKA_SECURITY_PROTOCOL is set to "SSL". | `nil` | +| Name | Description | Value | +| ----------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | +| `tss.enabled` | If true, the tss will be deployed. | `true` | +| `tss.route` | Configuration related to the routing of the TSS. | | +| `tss.route.schema` | Protocol scheme used for the service. Can be "http" or "https". | `https` | +| `tss.route.port` | Primary port on which the TSS listens. | `9000` | +| `tss.route.metricsPort` | Port dedicated to metrics collection for the TSS. | `9002` | +| `tss.deployment` | Configuration related to the deployment of the TSS. | | +| `tss.deployment.annotations` | Annotations to be added to the deployment. | `nil` | +| `tss.deployment.podAnnotations` | Annotations specific to the pods. | `{}` | +| `tss.deployment.strategy` | Configuration related to the deployment strategy, ensuring smooth updates and minimal downtime. | `{}` | +| `tss.deployment.podSecurityContext` | Security settings for the pods. | `{}` | +| `tss.deployment.securityContext` | Security settings for the container within the pod. | `{}` | +| `tss.deployment.resources` | Resource limits and requests for the application pods. | `{}` | +| `tss.deployment.nodeSelector` | Node selector to determine which nodes should run the pods. | `{}` | +| `tss.deployment.tolerations` | Tolerations to ensure pods aren't scheduled on unsuitable nodes. | `[]` | +| `tss.deployment.affinity` | Affinity rules to determine where pods get scheduled based on node conditions. | `{}` | +| `tss.configMap` | Configuration settings for the Transaction Submission Service (TSS) ConfigMap. | | +| `tss.configMap.annotations` | Annotations to be added to the ConfigMap. | `nil` | +| `tss.configMap.data` | Used to inject non-sensitive environment variables into the TSS deployment; for the latest variables, consult the application's CLI `-h` command. | | +| `tss.configMap.data.CRASH_TRACKER_TYPE` | Determines the type of crash tracker in use. Options: "DRY_RUN", "SENTRY". | `DRY_RUN` | +| `tss.configMap.data.DISTRIBUTION_PUBLIC_KEY` | The public key of the HOST's Stellar distribution account, used to create channel accounts. | `nil` | +| `tss.configMap.data.NUM_CHANNEL_ACCOUNTS` | The number of channel accounts the TSS will create/use. Channel accounts provide a method for submitting transactions to the network at a high rate. | `1` | +| `tss.configMap.data.MAX_BASE_FEE` | Specifies the maximum base fee (in stroops) the TSS is willing to pay per transaction. This helps to control costs and ensures transactions are economically feasible. | `100000` | +| `tss.configMap.data.TSS_METRICS_TYPE` | Defines the type of metrics system that the TSS should use. Options: "TSS_PROMETHEUS". | `TSS_PROMETHEUS` | +| `tss.configMap.data.QUEUE_POLLING_INTERVAL` | Specifies the interval (in seconds) at which the TSS should poll the queue. | `6` | +| `tss.configMap.data.ENVIRONMENT` | Specifies the environment TSS is running in (e.g. "localhost"). | `development` | +| `tss.configMap.data.LOG_LEVEL` | Determines the verbosity level of logs. Options: "TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL", "PANIC" | `INFO` | +| `tss.kubeSecrets` | Kubernetes secrets are used to manage sensitive information, such as API keys and private keys. It's crucial that these details are kept private. | | +| `tss.kubeSecrets.secretName` | The name of the Kubernetes secret object. Only use this if create is false. | `tss-secret-name` | +| `tss.kubeSecrets.create` | If true, the secret will be created. If false, it is assumed the secret already exists. | `false` | +| `tss.kubeSecrets.annotations` | Annotations to be added to the secret. | `nil` | +| `tss.kubeSecrets.data.DATABASE_URL` | URL of the database used by the TSS. | `nil` | +| `tss.kubeSecrets.data.DISTRIBUTION_SEED` | The HOST's Stellar distribution account, used to create channel accounts. | `nil` | +| `tss.kubeSecrets.data.CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE` | The private key used to encrypt the channel accounts secrets in the database. | `nil` | +| `tss.kubeSecrets.data.DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE` | A Stellar-compliant ed25519 private key used to encrypt and decrypt the private keys of tenants’ distribution accounts. | `nil` | +| `tss.kubeSecrets.data.SENTRY_DSN` | The DSN for the Sentry service. it must be set if CRASH_TRACKER_TYPE is set to "SENTRY". | `nil` | +| `tss.kubeSecrets.data.KAFKA_SASL_USERNAME` | The username for SASL authentication to the Kafka broker. Required if KAFKA_SECURITY_PROTOCOL is set to "SASL_SSL" or "SASL_PLAINTEXT". | `nil` | +| `tss.kubeSecrets.data.KAFKA_SASL_PASSWORD` | The password for SASL authentication to the Kafka broker. Required if KAFKA_SECURITY_PROTOCOL is set to "SASL_SSL" or "SASL_PLAINTEXT". | `nil` | +| `tss.kubeSecrets.data.KAFKA_SSL_ACCESS_KEY` | Access key (keystore) in PEM format. Required if KAFKA_SECURITY_PROTOCOL is set to "SSL". | `nil` | +| `tss.kubeSecrets.data.KAFKA_SSL_ACCESS_CERTIFICATE` | Certificate in PEM format that matches with the Kafka Access Key. Required if KAFKA_SECURITY_PROTOCOL is set to "SSL". | `nil` | ### Dashboard diff --git a/helmchart/sdp/minimal-values.yaml b/helmchart/sdp/minimal-values.yaml index e28bb396a..3ad106b07 100644 --- a/helmchart/sdp/minimal-values.yaml +++ b/helmchart/sdp/minimal-values.yaml @@ -10,14 +10,7 @@ global: ## @param global.eventBroker.urls A comma-separated list of broker URLs for the event broker. ## @param global.eventBroker.consumerGroupId The consumer group ID for the event broker. eventBroker: - type: "KAFKA" - urls: #required - consumerGroupId: #required - - ## @extra global.eventBroker.kafka Configuration related to the Kafka event broker. - ## @param global.eventBroker.kafka.securityProtocol The security protocol to be used for the Kafka broker. Options: "PLAINTEXT", "SASL_SSL", "SASL_PLAINTEXT", "SSL". - kafka: - securityProtocol: #required + type: "NONE" sdp: @@ -27,6 +20,7 @@ sdp: domain: #required mtnDomain: #required + ## @param sdp.configMap.data.ENABLE_SCHEDULER Whether the scheduled jobs are enabled in this instance ("true" or "false"). Setting to "true" because broker type is `NONE`. ## @param sdp.configMap.data.EC256_PUBLIC_KEY [string] The EC256 public key used for authentication purposes. ## @param sdp.configMap.data.SEP10_SIGNING_PUBLIC_KEY Anchor platform SEP10 signing public key. ## @param sdp.configMap.data.DISTRIBUTION_PUBLIC_KEY The public key of the Stellar distribution account that sends the Stellar payments. @@ -35,6 +29,7 @@ sdp: configMap: annotations: data: + ENABLE_SCHEDULER: "true" EC256_PUBLIC_KEY: #required SEP10_SIGNING_PUBLIC_KEY: #required DISTRIBUTION_PUBLIC_KEY: #required @@ -48,10 +43,12 @@ sdp: ## @param sdp.kubeSecrets.data.ANCHOR_PLATFORM_OUTGOING_JWT_SECRET The JWT secret used to create a JWT token used to send requests to the anchor platform. ## @param sdp.kubeSecrets.data.DATABASE_URL URL of the database used by the SDP. ## @param sdp.kubeSecrets.data.DISTRIBUTION_SEED The private key of the Stellar account used to disburse funds. This is needed for the init container + ## @param sdp.kubeSecrets.data.DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE A Stellar-compliant ed25519 private key used to encrypt and decrypt the private keys of tenants’ distribution accounts. ## @param sdp.kubeSecrets.data.CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE The private key used to encrypt the channel account secrets in the database. ## @param sdp.kubeSecrets.data.ADMIN_ACCOUNT The ID of the admin account. To use, add to the request header as 'Authorization', formatted as Base64-encoded 'ADMIN_ACCOUNT:ADMIN_API_KEY'.", ## @param sdp.kubeSecrets.data.ADMIN_API_KEY The API key for the admin account. To use, add to the request header as 'Authorization', formatted as Base64-encoded 'ADMIN_ACCOUNT:ADMIN_API_KEY'.", kubeSecrets: + secretName: sdp create: true data: EC256_PRIVATE_KEY: #required @@ -61,6 +58,7 @@ sdp: ANCHOR_PLATFORM_OUTGOING_JWT_SECRET: #required for mySdpToAnchorPlatformSecret DATABASE_URL: #required DISTRIBUTION_SEED: #required + DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE: #required CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE: #required ADMIN_ACCOUNT: #required ADMIN_API_KEY: #required @@ -88,6 +86,7 @@ anchorPlatform: ## @param anchorPlatform.kubeSecrets.data.SECRET_SEP24_INTERACTIVE_URL_JWT_SECRET The JWT secret used by the Anchor Platform to sign SEP-24 interactive URLs. These URLs typically initiate user-interactive processes like deposits and withdrawals. ## @param anchorPlatform.kubeSecrets.data.SECRET_SEP24_MORE_INFO_URL_JWT_SECRET The JWT secret used by the Anchor Platform to sign SEP-24 'More Info' URLs. These URLs provide users with additional details or steps related to their transactions. kubeSecrets: + secretName: sdp-ap create: true data: SECRET_DATA_PASSWORD: #required @@ -111,12 +110,15 @@ tss: ## @param tss.kubeSecrets.data.DATABASE_URL URL of the database used by the TSS. ## @param tss.kubeSecrets.data.DISTRIBUTION_SEED The private key of the Stellar account used to disburse funds. + ## @param tss.kubeSecrets.data.DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE A Stellar-compliant ed25519 private key used to encrypt and decrypt the private keys of tenants’ distribution accounts. ## @param tss.kubeSecrets.data.CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE The private key used to encrypt the channel account secrets in the database. kubeSecrets: + secretName: sdp-tss create: true data: DATABASE_URL: #required DISTRIBUTION_SEED: #required + DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE: #required CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE: #required dashboard: diff --git a/helmchart/sdp/templates/01.2-configmap-ap.yaml b/helmchart/sdp/templates/01.2-configmap-ap.yaml index 0284b12cc..c6d83257a 100644 --- a/helmchart/sdp/templates/01.2-configmap-ap.yaml +++ b/helmchart/sdp/templates/01.2-configmap-ap.yaml @@ -16,7 +16,7 @@ metadata: data: # if {{ include "isPubnet" . }} is true, then the network is set to PUBNET, else it's all TESTNET {{- if eq (include "isPubnet" .) "true" }} - STELLAR_NETWORK_NETWORK: "PUBNET" + STELLAR_NETWORK_NETWORK: "PUBLIC" STELLAR_NETWORK_NETWORK_PASSPHRASE: "Public Global Stellar Network ; September 2015" STELLAR_NETWORK_HORIZON_URL: "https://horizon.stellar.org" {{- else }} diff --git a/helmchart/sdp/templates/05.1-secrets-sdp.yaml b/helmchart/sdp/templates/05.1-secrets-sdp.yaml index f0edfc6f9..75b52ccac 100644 --- a/helmchart/sdp/templates/05.1-secrets-sdp.yaml +++ b/helmchart/sdp/templates/05.1-secrets-sdp.yaml @@ -16,7 +16,9 @@ metadata: {{- if .Values.sdp.kubeSecrets.data }} data: {{- range $key, $value := .Values.sdp.kubeSecrets.data }} - {{ $key }}: {{ $value | b64enc | quote }} + {{- if $value }} + {{ $key }}: {{ $value | b64enc | quote }} + {{- end }} {{- end }} {{- end }} {{- end }} \ No newline at end of file diff --git a/helmchart/sdp/templates/05.2-secrets-ap.yaml b/helmchart/sdp/templates/05.2-secrets-ap.yaml index 9517f8974..5601bbff5 100644 --- a/helmchart/sdp/templates/05.2-secrets-ap.yaml +++ b/helmchart/sdp/templates/05.2-secrets-ap.yaml @@ -13,10 +13,12 @@ metadata: {{- toYaml .Values.anchorPlatform.kubeSecrets.annotations | nindent 4 }} {{- end }} -{{- if .Values.anchorPlatform.configMap.data }} +{{- if .Values.anchorPlatform.kubeSecrets.data }} data: - {{- range $key, $value := .Values.anchorPlatform.configMap.data }} - {{ $key }}: {{ $value | b64enc | quote }} + {{- range $key, $value := .Values.anchorPlatform.kubeSecrets.data }} + {{- if $value }} + {{ $key }}: {{ $value | b64enc | quote }} + {{- end }} {{- end }} {{- end }} {{- end }} \ No newline at end of file diff --git a/helmchart/sdp/templates/05.3-secrets-tss.yaml b/helmchart/sdp/templates/05.3-secrets-tss.yaml index 4a8d143b8..650b695ff 100644 --- a/helmchart/sdp/templates/05.3-secrets-tss.yaml +++ b/helmchart/sdp/templates/05.3-secrets-tss.yaml @@ -16,7 +16,9 @@ metadata: {{- if .Values.tss.kubeSecrets.data }} data: {{- range $key, $value := .Values.tss.kubeSecrets.data }} - {{ $key }}: {{ $value | b64enc | quote }} + {{- if $value }} + {{ $key }}: {{ $value | b64enc | quote }} + {{- end }} {{- end }} {{- end }} {{- end }} \ No newline at end of file diff --git a/helmchart/sdp/values.yaml b/helmchart/sdp/values.yaml index dc99a45ac..46b64fc8d 100644 --- a/helmchart/sdp/values.yaml +++ b/helmchart/sdp/values.yaml @@ -132,7 +132,6 @@ sdp: ## @extra sdp.configMap Configuration for the ConfigMap used by the SDP service. ## @param sdp.configMap.annotations Annotations to be added to the ConfigMap. ## @extra sdp.configMap.data Used to inject non-sensitive environment variables into the SDP deployment; for the latest variables, consult the application's CLI `-h` command. - ## @param sdp.configMap.data.BASE_URL The base URL of the SDP backend. ## @param sdp.configMap.data.CRASH_TRACKER_TYPE Determines the type of crash tracker in use. Options: "DRY_RUN", "SENTRY". ## @param sdp.configMap.data.EC256_PUBLIC_KEY [string] The EC256 public key used for authentication purposes. This EC key needs to be at least as strong as prime256v1 (P-256). ## @param sdp.configMap.data.ENVIRONMENT Specifies the environment SDP is running in (e.g. "localhost"). @@ -156,7 +155,6 @@ sdp: configMap: annotations: data: - BASE_URL: "http://localhost:8000" CRASH_TRACKER_TYPE: "DRY_RUN" EC256_PUBLIC_KEY: #required ENVIRONMENT: "dev" @@ -198,8 +196,7 @@ sdp: ## @param sdp.kubeSecrets.data.DATABASE_URL URL of the database used by the SDP. ## @param sdp.kubeSecrets.data.DISTRIBUTION_SEED The HOST's Stellar distribution account, used to create channel accounts. This is needed for the init container. ## @param sdp.kubeSecrets.data.CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE The private key used to encrypt the channel accounts secrets in the database. - ## @param sdp.kubeSecrets.data.DISTRIBUTION_SIGNER_TYPE The type of signer used to sign Stellar transactions for the tenants' distribution accounts. Options: DISTRIBUTION_ACCOUNT_ENV: uses the same distribution account for all tenants, as well as for the HOST, through the secret configured in DISTRIBUTION_SEED. DISTRIBUTION_ACCOUNT_DB: uses the one different distribution account private key per tenant, and stores them in the database, encrypted with the DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE. - ## @param sdp.kubeSecrets.data.DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE The private key used to encrypt the distribution accounts secrets in the database, mandatory when DISTRIBUTION_SIGNER_TYPE is set to DISTRIBUTION_ACCOUNT_DB. + ## @param sdp.kubeSecrets.data.DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE A Stellar-compliant ed25519 private key used to encrypt and decrypt the private keys of tenants’ distribution accounts. ## @param sdp.kubeSecrets.data.SENTRY_DSN The DSN for the Sentry service. it must be set if CRASH_TRACKER_TYPE is set to "SENTRY". ## @param sdp.kubeSecrets.data.KAFKA_SASL_USERNAME The username for SASL authentication to the Kafka broker. Required if KAFKA_SECURITY_PROTOCOL is set to "SASL_SSL" or "SASL_PLAINTEXT". ## @param sdp.kubeSecrets.data.KAFKA_SASL_PASSWORD The password for SASL authentication to the Kafka broker. Required if KAFKA_SECURITY_PROTOCOL is set to "SASL_SSL" or "SASL_PLAINTEXT". @@ -228,8 +225,7 @@ sdp: ANCHOR_PLATFORM_OUTGOING_JWT_SECRET: #required for mySdpToAnchorPlatformSecret DATABASE_URL: #required DISTRIBUTION_SEED: #required - DISTRIBUTION_SIGNER_TYPE: #optional - DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE: #required when DISTRIBUTION_SIGNER_TYPE=DISTRIBUTION_ACCOUNT_DB + DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE: #required CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE: #required KAFKA_SASL_USERNAME: #optional, depends on value of KAFKA_SECURITY_PROTOCOL KAFKA_SASL_PASSWORD: #optional, depends on value of KAFKA_SECURITY_PROTOCOL @@ -484,8 +480,7 @@ tss: ## @param tss.kubeSecrets.data.DATABASE_URL URL of the database used by the TSS. ## @param tss.kubeSecrets.data.DISTRIBUTION_SEED The HOST's Stellar distribution account, used to create channel accounts. ## @param tss.kubeSecrets.data.CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE The private key used to encrypt the channel accounts secrets in the database. - ## @param tss.kubeSecrets.data.DISTRIBUTION_SIGNER_TYPE The type of signer used to sign Stellar transactions for the tenants' distribution accounts. Options: DISTRIBUTION_ACCOUNT_ENV: uses the the same distribution account for all tenants, as well as for the HOST, through the secret configured in DISTRIBUTION_SEED. DISTRIBUTION_ACCOUNT_DB: uses the one different distribution account private key per tenant, and stores them in the database, encrypted with the DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE. - ## @param tss.kubeSecrets.data.DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE The private key used to encrypt the distribution accounts secrets in the database, mandatory when DISTRIBUTION_SIGNER_TYPE is set to DISTRIBUTION_ACCOUNT_DB. + ## @param tss.kubeSecrets.data.DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE A Stellar-compliant ed25519 private key used to encrypt and decrypt the private keys of tenants’ distribution accounts. ## @param tss.kubeSecrets.data.SENTRY_DSN The DSN for the Sentry service. it must be set if CRASH_TRACKER_TYPE is set to "SENTRY". ## @param tss.kubeSecrets.data.KAFKA_SASL_USERNAME The username for SASL authentication to the Kafka broker. Required if KAFKA_SECURITY_PROTOCOL is set to "SASL_SSL" or "SASL_PLAINTEXT". ## @param tss.kubeSecrets.data.KAFKA_SASL_PASSWORD The password for SASL authentication to the Kafka broker. Required if KAFKA_SECURITY_PROTOCOL is set to "SASL_SSL" or "SASL_PLAINTEXT". @@ -499,8 +494,7 @@ tss: DATABASE_URL: #required DISTRIBUTION_SEED: #required CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE: #required - DISTRIBUTION_SIGNER_TYPE: #optional - DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE: #required when DISTRIBUTION_SIGNER_TYPE=DISTRIBUTION_ACCOUNT_DB + DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE: #required SENTRY_DSN: #optional KAFKA_SASL_USERNAME: #optional, depends on value of KAFKA_SECURITY_PROTOCOL KAFKA_SASL_PASSWORD: #optional, depends on value of KAFKA_SECURITY_PROTOCOL diff --git a/internal/anchorplatform/platform_api_service.go b/internal/anchorplatform/platform_api_service.go index 49421c50b..d9561081c 100644 --- a/internal/anchorplatform/platform_api_service.go +++ b/internal/anchorplatform/platform_api_service.go @@ -11,8 +11,8 @@ import ( "strings" "github.com/gorilla/schema" - "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) diff --git a/internal/anchorplatform/sep24_auth_middleware.go b/internal/anchorplatform/sep24_auth_middleware.go index 65283f6b1..166e0ad8b 100644 --- a/internal/anchorplatform/sep24_auth_middleware.go +++ b/internal/anchorplatform/sep24_auth_middleware.go @@ -6,13 +6,13 @@ import ( "net/http" "strings" - "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" - "github.com/stellar/go/network" "github.com/stellar/go/support/http/httpdecode" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) type ContextType string diff --git a/internal/anchorplatform/sep24_jwt_claims.go b/internal/anchorplatform/sep24_jwt_claims.go index abbd5431d..205cff809 100644 --- a/internal/anchorplatform/sep24_jwt_claims.go +++ b/internal/anchorplatform/sep24_jwt_claims.go @@ -7,6 +7,7 @@ import ( "github.com/golang-jwt/jwt/v4" "github.com/stellar/go/keypair" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) diff --git a/internal/circle/balance.go b/internal/circle/balance.go new file mode 100644 index 000000000..dd7062313 --- /dev/null +++ b/internal/circle/balance.go @@ -0,0 +1,54 @@ +package circle + +import ( + "errors" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" +) + +var ( + ErrUnsupportedCurrency = errors.New("unsupported Circle currency code") + ErrUnsupportedCurrencyForNetwork = errors.New("unsupported Circle currency code for this network type") +) + +// Balance represents the amount and currency of a balance or transfer. +type Balance struct { + Amount string `json:"amount"` + Currency string `json:"currency"` +} + +// AllowedAssetsMap is a map of Circle currency codes to Stellar assets, for each network type. +var AllowedAssetsMap = map[string]map[utils.NetworkType]data.Asset{ + "USD": { + utils.PubnetNetworkType: assets.USDCAssetPubnet, + utils.TestnetNetworkType: assets.USDCAssetTestnet, + }, + "EUR": { + utils.PubnetNetworkType: assets.EURCAssetPubnet, + utils.TestnetNetworkType: assets.EURCAssetTestnet, + }, +} + +// ParseStellarAsset returns the Stellar asset for the given Circle currency code, or an error if the currency is not +// supported in the SDP. +func ParseStellarAsset(circleCurrency string, networkType utils.NetworkType) (data.Asset, error) { + return ParseStellarAssetFromAllowlist(circleCurrency, networkType, AllowedAssetsMap) +} + +// ParseStellarAssetFromAllowlist returns the Stellar asset for the given Circle currency code, or an error if the +// currency is not supported in the SDP. This function allows for the use of a custom asset allowlist. +func ParseStellarAssetFromAllowlist(circleCurrency string, networkType utils.NetworkType, allowedAssetsMap map[string]map[utils.NetworkType]data.Asset) (data.Asset, error) { + assetByNetworkType, ok := allowedAssetsMap[circleCurrency] + if !ok { + return data.Asset{}, ErrUnsupportedCurrency + } + + asset, ok := assetByNetworkType[networkType] + if !ok { + return data.Asset{}, ErrUnsupportedCurrencyForNetwork + } + + return asset, nil +} diff --git a/internal/circle/balance_test.go b/internal/circle/balance_test.go new file mode 100644 index 000000000..279b3ac39 --- /dev/null +++ b/internal/circle/balance_test.go @@ -0,0 +1,100 @@ +package circle + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" +) + +func Test_ParseStellarAsset(t *testing.T) { + testCases := []struct { + name string + circleCurrency string + networkType utils.NetworkType + allowedAssetsMap map[string]map[utils.NetworkType]data.Asset + expectedAsset data.Asset + expectedError error + }{ + { + name: "[Pubnet] USDC", + circleCurrency: "USD", + networkType: utils.PubnetNetworkType, + allowedAssetsMap: AllowedAssetsMap, + expectedAsset: assets.USDCAssetPubnet, + expectedError: nil, + }, + { + name: "[Testnet] USDC", + circleCurrency: "USD", + networkType: utils.TestnetNetworkType, + allowedAssetsMap: AllowedAssetsMap, + expectedAsset: assets.USDCAssetTestnet, + expectedError: nil, + }, + { + name: "[Pubnet] EUR", + circleCurrency: "EUR", + networkType: utils.PubnetNetworkType, + allowedAssetsMap: AllowedAssetsMap, + expectedAsset: assets.EURCAssetPubnet, + expectedError: nil, + }, + { + name: "[Testnet] EUR", + circleCurrency: "EUR", + networkType: utils.TestnetNetworkType, + allowedAssetsMap: AllowedAssetsMap, + expectedAsset: assets.EURCAssetTestnet, + expectedError: nil, + }, + { + name: "Unsupported currency", + circleCurrency: "JPY", + networkType: utils.PubnetNetworkType, + allowedAssetsMap: AllowedAssetsMap, + expectedAsset: data.Asset{}, + expectedError: ErrUnsupportedCurrency, + }, + { + name: "Unsupported currency for network type", + circleCurrency: "JPY", + networkType: utils.PubnetNetworkType, + allowedAssetsMap: map[string]map[utils.NetworkType]data.Asset{ + "JPY": {}, + }, + expectedAsset: data.Asset{}, + expectedError: ErrUnsupportedCurrencyForNetwork, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if !assert.ObjectsAreEqual(tc.allowedAssetsMap, AllowedAssetsMap) { + return + } + asset, err := ParseStellarAsset(tc.circleCurrency, tc.networkType) + + if tc.expectedError == nil { + assert.NoError(t, err) + assert.Equal(t, tc.expectedAsset, asset) + } else { + assert.Equal(t, tc.expectedError, err) + } + }) + + t.Run("FromAllowlist/"+tc.name, func(t *testing.T) { + asset, err := ParseStellarAssetFromAllowlist(tc.circleCurrency, tc.networkType, tc.allowedAssetsMap) + + if tc.expectedError == nil { + assert.NoError(t, err) + assert.Equal(t, tc.expectedAsset, asset) + } else { + assert.Equal(t, tc.expectedError, err) + } + }) + } +} diff --git a/internal/circle/client.go b/internal/circle/client.go new file mode 100644 index 000000000..0eace1e58 --- /dev/null +++ b/internal/circle/client.go @@ -0,0 +1,288 @@ +package circle + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "slices" + "strconv" + "time" + + "github.com/avast/retry-go" + "github.com/stellar/go/support/log" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" +) + +const ( + pingPath = "/ping" + transferPath = "/v1/transfers" + walletPath = "/v1/wallets" +) + +var authErrorStatusCodes = []int{http.StatusUnauthorized, http.StatusForbidden} + +// ClientInterface defines the interface for interacting with the Circle API. +// +//go:generate mockery --name=ClientInterface --case=underscore --structname=MockClient --filename=client_mock.go --inpackage +type ClientInterface interface { + Ping(ctx context.Context) (bool, error) + PostTransfer(ctx context.Context, transferRequest TransferRequest) (*Transfer, error) + GetTransferByID(ctx context.Context, id string) (*Transfer, error) + GetWalletByID(ctx context.Context, id string) (*Wallet, error) +} + +// Client provides methods to interact with the Circle API. +type Client struct { + BasePath string + APIKey string + httpClient httpclient.HttpClientInterface + tenantManager tenant.ManagerInterface +} + +// ClientFactory is a function that creates a ClientInterface. +type ClientFactory func(networkType utils.NetworkType, apiKey string, tntManager tenant.ManagerInterface) ClientInterface + +var _ ClientFactory = NewClient + +// NewClient creates a new instance of Circle Client. +func NewClient(networkType utils.NetworkType, apiKey string, tntManager tenant.ManagerInterface) ClientInterface { + circleEnv := Sandbox + if networkType == utils.PubnetNetworkType { + circleEnv = Production + } + + return &Client{ + BasePath: string(circleEnv), + APIKey: apiKey, + httpClient: httpclient.DefaultClient(), + tenantManager: tntManager, + } +} + +// Ping checks that the service is running. +// +// Circle API documentation: https://developers.circle.com/circle-mint/reference/ping. +func (client *Client) Ping(ctx context.Context) (bool, error) { + u, err := url.JoinPath(client.BasePath, pingPath) + if err != nil { + return false, fmt.Errorf("building path: %w", err) + } + + resp, err := client.request(ctx, u, http.MethodGet, false, nil) + if err != nil { + return false, fmt.Errorf("making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + var pingResp struct { + Message string `json:"message"` + } + if err = json.NewDecoder(resp.Body).Decode(&pingResp); err != nil { + return false, err + } + + if pingResp.Message == "pong" { + return true, nil + } + + return false, fmt.Errorf("unexpected response message: %s", pingResp.Message) +} + +// PostTransfer creates a new transfer. +// +// Circle API documentation: https://developers.circle.com/circle-mint/reference/createtransfer. +func (client *Client) PostTransfer(ctx context.Context, transferReq TransferRequest) (*Transfer, error) { + err := transferReq.validate() + if err != nil { + return nil, fmt.Errorf("validating transfer request: %w", err) + } + + u, err := url.JoinPath(client.BasePath, transferPath) + if err != nil { + return nil, fmt.Errorf("building path: %w", err) + } + + transferData, err := json.Marshal(transferReq) + if err != nil { + return nil, err + } + + resp, err := client.request(ctx, u, http.MethodPost, true, transferData) + if err != nil { + return nil, fmt.Errorf("making request: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + handleErr := client.handleError(ctx, resp) + if handleErr != nil { + return nil, fmt.Errorf("handling API response error: %w", handleErr) + } + } + + return parseTransferResponse(resp) +} + +// GetTransferByID retrieves a transfer by its ID. +// +// Circle API documentation: https://developers.circle.com/circle-mint/reference/gettransfer. +func (client *Client) GetTransferByID(ctx context.Context, id string) (*Transfer, error) { + u, err := url.JoinPath(client.BasePath, transferPath, id) + if err != nil { + return nil, fmt.Errorf("building path: %w", err) + } + + resp, err := client.request(ctx, u, http.MethodGet, true, nil) + if err != nil { + return nil, fmt.Errorf("making request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + handleErr := client.handleError(ctx, resp) + if handleErr != nil { + return nil, fmt.Errorf("handling API response error: %w", handleErr) + } + } + + return parseTransferResponse(resp) +} + +// GetWalletByID retrieves a wallet by its ID. +// +// Circle API documentation: https://developers.circle.com/circle-mint/reference/getwallet. +func (client *Client) GetWalletByID(ctx context.Context, id string) (*Wallet, error) { + url, err := url.JoinPath(client.BasePath, walletPath, id) + if err != nil { + return nil, fmt.Errorf("building path: %w", err) + } + + resp, err := client.request(ctx, url, http.MethodGet, true, nil) + if err != nil { + return nil, fmt.Errorf("making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + handleErr := client.handleError(ctx, resp) + if handleErr != nil { + return nil, fmt.Errorf("handling API response error: %w", handleErr) + } + } + + return parseWalletResponse(resp) +} + +type RetryableError struct { + err error + retryAfter time.Duration +} + +func (re RetryableError) Error() string { + retryableErr := fmt.Errorf("retryable error: %w", re.err) + return retryableErr.Error() +} + +// request makes an HTTP request to the Circle API. +func (client *Client) request(ctx context.Context, u string, method string, isAuthed bool, bodyBytes []byte) (*http.Response, error) { + var resp *http.Response + err := retry.Do( + func() error { + bodyReader := bytes.NewReader(bodyBytes) + req, err := http.NewRequestWithContext(ctx, method, u, bodyReader) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + if isAuthed { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.APIKey)) + } + + if bodyReader != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err = client.httpClient.Do(req) + if err != nil { + return fmt.Errorf("submitting request to %s: %w", u, err) + } + + if resp.StatusCode == http.StatusTooManyRequests { + retryAfter := parseRetryAfter(resp.Header.Get("Retry-After")) + log.Ctx(ctx).Warnf("CircleClient - Request to %s is rate limited, retry after: %s", u, retryAfter) + return RetryableError{ + err: fmt.Errorf("rate limited, retry after: %s", retryAfter), + retryAfter: retryAfter, + } + } + return nil + }, + retry.DelayType(func(n uint, err error, config *retry.Config) time.Duration { + // if err is RetryableError, return retryAfter + var retryableErr RetryableError + ok := errors.As(err, &retryableErr) + if ok { + return retryableErr.retryAfter + } + // default is back-off delay + return retry.BackOffDelay(n, err, config) + }), + retry.Attempts(4), + retry.MaxDelay(time.Second*600), + retry.RetryIf(func(err error) bool { + return errors.As(err, &RetryableError{}) + }), + retry.OnRetry(func(n uint, err error) { + log.Ctx(ctx).Warnf("CircleClient - Request to %s is rate limited, Retry number %d due to: %s", u, n, err) + }), + retry.LastErrorOnly(true), + ) + if err != nil { + return nil, err + } + + return resp, nil +} + +func parseRetryAfter(retryAfter string) time.Duration { + if retryAfter == "" { + return 0 + } + seconds, err := strconv.Atoi(retryAfter) + if err != nil { + return 0 + } + return time.Duration(seconds) * time.Second +} + +func (client *Client) handleError(ctx context.Context, resp *http.Response) error { + if slices.Contains(authErrorStatusCodes, resp.StatusCode) { + tnt, getCtxTntErr := tenant.GetTenantFromContext(ctx) + if getCtxTntErr != nil { + return fmt.Errorf("getting tenant from context: %w", getCtxTntErr) + } + + deactivateTntErr := client.tenantManager.DeactivateTenantDistributionAccount(ctx, tnt.ID) + if deactivateTntErr != nil { + return fmt.Errorf("deactivating tenant distribution account: %w", deactivateTntErr) + } + } + + apiError, err := parseAPIError(resp) + if err != nil { + return fmt.Errorf("parsing API error: %w", err) + } + + return fmt.Errorf("Circle API error: %w", apiError) //nolint:golint,unused +} + +var _ ClientInterface = (*Client)(nil) diff --git a/internal/circle/client_config.go b/internal/circle/client_config.go new file mode 100644 index 000000000..138032c59 --- /dev/null +++ b/internal/circle/client_config.go @@ -0,0 +1,187 @@ +package circle + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" +) + +type ClientConfig struct { + EncryptedAPIKey *string `db:"encrypted_api_key"` + WalletID *string `db:"wallet_id"` + EncrypterPublicKey *string `db:"encrypter_public_key"` + UpdatedAt time.Time `db:"updated_at"` + CreatedAt time.Time `db:"created_at"` +} + +//go:generate mockery --name=ClientConfigModelInterface --case=underscore --structname=MockClientConfigModel --filename=client_config_mock.go --inpackage +type ClientConfigModelInterface interface { + Upsert(ctx context.Context, configUpdate ClientConfigUpdate) error + GetDecryptedAPIKey(ctx context.Context, passphrase string) (string, error) + Get(ctx context.Context) (*ClientConfig, error) +} + +type ClientConfigModel struct { + DBConnectionPool db.DBConnectionPool + Encrypter utils.PrivateKeyEncrypter +} + +func NewClientConfigModel(dbConnectionPool db.DBConnectionPool) *ClientConfigModel { + return &ClientConfigModel{ + DBConnectionPool: dbConnectionPool, + Encrypter: &utils.DefaultPrivateKeyEncrypter{}, + } +} + +// Upsert insert or update the client configuration for Circle into the database. +func (m *ClientConfigModel) Upsert(ctx context.Context, configUpdate ClientConfigUpdate) error { + if m.DBConnectionPool == nil { + return fmt.Errorf("DBConnectionPool is nil") + } + err := db.RunInTransaction(ctx, m.DBConnectionPool, nil, func(tx db.DBTransaction) error { + existingConfig, err := m.get(ctx, tx) + if err != nil { + return fmt.Errorf("getting existing circle config: %w", err) + } + + if existingConfig == nil { + err = m.insert(ctx, tx, configUpdate) + if err != nil { + return fmt.Errorf("inserting new circle config: %w", err) + } + } else { + err = m.update(ctx, tx, configUpdate) + if err != nil { + return fmt.Errorf("updating existing circle config: %w", err) + } + } + + return nil + }) + if err != nil { + return fmt.Errorf("running transaction: %w", err) + } + + return nil +} + +// GetDecryptedAPIKey retrieves the decrypted API key from the database. +func (m *ClientConfigModel) GetDecryptedAPIKey(ctx context.Context, passphrase string) (string, error) { + config, err := m.Get(ctx) + if err != nil { + return "", fmt.Errorf("getting circle config: %w", err) + } + + apiKey, err := m.Encrypter.Decrypt(*config.EncryptedAPIKey, passphrase) + if err != nil { + return "", fmt.Errorf("decrypting circle API key: %w", err) + } + + return apiKey, nil +} + +// Get retrieves the circle client config from the database if it exists. +func (m *ClientConfigModel) Get(ctx context.Context) (*ClientConfig, error) { + if m.DBConnectionPool == nil { + return nil, fmt.Errorf("DBConnectionPool is nil") + } + return m.get(ctx, m.DBConnectionPool) +} + +// get retrieves the circle client config from the database if it exists. +func (m *ClientConfigModel) get(ctx context.Context, sqlExec db.SQLExecuter) (*ClientConfig, error) { + const q = `SELECT * FROM circle_client_config` + var config ClientConfig + err := sqlExec.GetContext(ctx, &config, q) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, fmt.Errorf("getting circle config: %w", err) + } + return &config, nil +} + +// insert inserts the circle client config into the database. +func (m *ClientConfigModel) insert(ctx context.Context, sqlExec db.SQLExecuter, config ClientConfigUpdate) error { + if err := config.validateForInsert(); err != nil { + return fmt.Errorf("invalid circle config for insert: %w", err) + } + const q = ` + INSERT INTO circle_client_config (encrypted_api_key, wallet_id, encrypter_public_key) + VALUES ($1, $2, $3) + ` + _, err := sqlExec.ExecContext(ctx, q, config.EncryptedAPIKey, config.WalletID, config.EncrypterPublicKey) + if err != nil { + return fmt.Errorf("inserting circle config: %w", err) + } + return nil +} + +// update updates the circle client config in the database. +func (m *ClientConfigModel) update(ctx context.Context, sqlExec db.SQLExecuter, config ClientConfigUpdate) error { + if err := config.validate(); err != nil { + return fmt.Errorf("invalid circle config for update: %w", err) + } + + query := ` + UPDATE + circle_client_config + SET + %s + ` + + args := []interface{}{} + fields := []string{} + if config.WalletID != nil { + fields = append(fields, "wallet_id = ?") + args = append(args, config.WalletID) + } + + if config.EncryptedAPIKey != nil { + fields = append(fields, "encrypted_api_key = ?", "encrypter_public_key = ?") + args = append(args, config.EncryptedAPIKey, config.EncrypterPublicKey) + } + + query = m.DBConnectionPool.Rebind(fmt.Sprintf(query, strings.Join(fields, ", "))) + + _, err := sqlExec.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("error updating client config: %w", err) + } + + return nil +} + +var _ ClientConfigModelInterface = (*ClientConfigModel)(nil) + +type ClientConfigUpdate struct { + EncryptedAPIKey *string `db:"encrypted_api_key"` + WalletID *string `db:"wallet_id"` + EncrypterPublicKey *string `db:"encrypter_public_key"` +} + +func (c ClientConfigUpdate) validate() error { + if c.WalletID == nil && c.EncryptedAPIKey == nil { + return fmt.Errorf("wallet_id or encrypted_api_key must be provided") + } + + if c.EncryptedAPIKey != nil && c.EncrypterPublicKey == nil { + return fmt.Errorf("encrypter_public_key must be provided if encrypted_api_key is provided") + } + + return nil +} + +func (c ClientConfigUpdate) validateForInsert() error { + if c.WalletID == nil || c.EncryptedAPIKey == nil || c.EncrypterPublicKey == nil { + return fmt.Errorf("wallet_id, encrypted_api_key, and encrypter_public_key must be provided") + } + return nil +} diff --git a/internal/circle/client_config_mock.go b/internal/circle/client_config_mock.go new file mode 100644 index 000000000..c09e52429 --- /dev/null +++ b/internal/circle/client_config_mock.go @@ -0,0 +1,104 @@ +// Code generated by mockery v2.40.1. DO NOT EDIT. + +package circle + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockClientConfigModel is an autogenerated mock type for the ClientConfigModelInterface type +type MockClientConfigModel struct { + mock.Mock +} + +// Get provides a mock function with given fields: ctx +func (_m *MockClientConfigModel) Get(ctx context.Context) (*ClientConfig, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Get") + } + + var r0 *ClientConfig + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (*ClientConfig, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) *ClientConfig); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*ClientConfig) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetDecryptedAPIKey provides a mock function with given fields: ctx, passphrase +func (_m *MockClientConfigModel) GetDecryptedAPIKey(ctx context.Context, passphrase string) (string, error) { + ret := _m.Called(ctx, passphrase) + + if len(ret) == 0 { + panic("no return value specified for GetDecryptedAPIKey") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (string, error)); ok { + return rf(ctx, passphrase) + } + if rf, ok := ret.Get(0).(func(context.Context, string) string); ok { + r0 = rf(ctx, passphrase) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, passphrase) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Upsert provides a mock function with given fields: ctx, configUpdate +func (_m *MockClientConfigModel) Upsert(ctx context.Context, configUpdate ClientConfigUpdate) error { + ret := _m.Called(ctx, configUpdate) + + if len(ret) == 0 { + panic("no return value specified for Upsert") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, ClientConfigUpdate) error); ok { + r0 = rf(ctx, configUpdate) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewMockClientConfigModel creates a new instance of MockClientConfigModel. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockClientConfigModel(t interface { + mock.TestingT + Cleanup(func()) +}) *MockClientConfigModel { + mock := &MockClientConfigModel{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/circle/client_config_test.go b/internal/circle/client_config_test.go new file mode 100644 index 000000000..03ef688a0 --- /dev/null +++ b/internal/circle/client_config_test.go @@ -0,0 +1,399 @@ +package circle + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/testutils" +) + +func Test_ClientConfigModel_Upsert_Update(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + ccm := &ClientConfigModel{DBConnectionPool: dbConnectionPool} + + walletID := "the_wallet_id" + encryptedAPIKey := "the_encrypted_api_key" + encrypterPublicKey := "the_encrypter_public_key" + + updatedWalletID := "another_wallet_id" + updatedEncryptedAPIKey := "another_encrypted_api_key" + updatedEncrypterPublicKey := "another_encrypter_public_key" + + outerErr = ccm.insert(ctx, dbConnectionPool, ClientConfigUpdate{ + WalletID: &walletID, + EncryptedAPIKey: &encryptedAPIKey, + EncrypterPublicKey: &encrypterPublicKey, + }) + require.NoError(t, outerErr) + + t.Run("update existing config", func(t *testing.T) { + // Ensure there is an existing config + cc, err := ccm.Get(ctx) + require.NoError(t, err) + + // Verify the existing config + assert.Equal(t, walletID, *cc.WalletID) + assert.Equal(t, encryptedAPIKey, *cc.EncryptedAPIKey) + assert.Equal(t, encrypterPublicKey, *cc.EncrypterPublicKey) + + err = ccm.Upsert(ctx, ClientConfigUpdate{ + WalletID: &updatedWalletID, + EncryptedAPIKey: &updatedEncryptedAPIKey, + EncrypterPublicKey: &updatedEncrypterPublicKey, + }) + assert.NoError(t, err) + + cc, err = ccm.Get(ctx) + require.NoError(t, err) + require.NotNil(t, cc) + assert.Equal(t, updatedWalletID, *cc.WalletID) + assert.Equal(t, updatedEncryptedAPIKey, *cc.EncryptedAPIKey) + assert.Equal(t, updatedEncrypterPublicKey, *cc.EncrypterPublicKey) + }) + + t.Run("return error on validation failure", func(t *testing.T) { + err := ccm.Upsert(ctx, ClientConfigUpdate{ + WalletID: nil, + EncryptedAPIKey: nil, + EncrypterPublicKey: nil, + }) + assert.Error(t, err) + assert.ErrorContains(t, err, "invalid circle config for update: wallet_id or encrypted_api_key must be provided") + }) +} + +func Test_ClientConfigModel_Upsert_Insert(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + ccm := &ClientConfigModel{DBConnectionPool: dbConnectionPool} + + walletID := "the_wallet_id" + encryptedAPIKey := "the_encrypted_api_key" + encrypterPublicKey := "the_encrypter_public_key" + + t.Run("return error on validation failure for no values", func(t *testing.T) { + err := ccm.Upsert(ctx, ClientConfigUpdate{ + WalletID: nil, + EncryptedAPIKey: nil, + EncrypterPublicKey: nil, + }) + assert.Error(t, err) + assert.ErrorContains(t, err, "invalid circle config for insert: wallet_id, encrypted_api_key, and encrypter_public_key must be provided") + }) + + t.Run("return error on validation failure for partial values", func(t *testing.T) { + err := ccm.Upsert(ctx, ClientConfigUpdate{ + WalletID: &walletID, + EncryptedAPIKey: nil, + EncrypterPublicKey: nil, + }) + assert.Error(t, err) + assert.ErrorContains(t, err, "invalid circle config for insert: wallet_id, encrypted_api_key, and encrypter_public_key must be provided") + }) + + t.Run("insert new config", func(t *testing.T) { + // Ensure there is an existing config + cc, err := ccm.Get(ctx) + assert.NoError(t, err) + assert.Nil(t, cc) + + err = ccm.Upsert(ctx, ClientConfigUpdate{ + WalletID: &walletID, + EncryptedAPIKey: &encryptedAPIKey, + EncrypterPublicKey: &encrypterPublicKey, + }) + assert.NoError(t, err) + + cc, err = ccm.Get(ctx) + require.NoError(t, err) + require.NotNil(t, cc) + assert.Equal(t, walletID, *cc.WalletID) + assert.Equal(t, encryptedAPIKey, *cc.EncryptedAPIKey) + assert.Equal(t, encrypterPublicKey, *cc.EncrypterPublicKey) + }) +} + +func Test_ClientConfigModel_get(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + ccm := &ClientConfigModel{DBConnectionPool: dbConnectionPool} + + walletID := "the_wallet_id" + encryptedAPIKey := "the_encrypted_api_key" + encrypterPublicKey := "the_encrypter_public_key" + + t.Run("retrieve existing config successfully", func(t *testing.T) { + tx := testutils.BeginTxWithRollback(t, ctx, dbConnectionPool) + + // Insert a record to retrieve + insertErr := ccm.insert(ctx, tx, ClientConfigUpdate{ + WalletID: &walletID, + EncryptedAPIKey: &encryptedAPIKey, + EncrypterPublicKey: &encrypterPublicKey, + }) + require.NoError(t, insertErr) + + config, err := ccm.get(ctx, tx) + require.NoError(t, err) + require.NotNil(t, config) + assert.Equal(t, walletID, *config.WalletID) + assert.Equal(t, encryptedAPIKey, *config.EncryptedAPIKey) + assert.Equal(t, encrypterPublicKey, *config.EncrypterPublicKey) + }) + + t.Run("return nil if no config exists", func(t *testing.T) { + tx := testutils.BeginTxWithRollback(t, ctx, dbConnectionPool) + + config, err := ccm.get(ctx, tx) + require.NoError(t, err) + assert.Nil(t, config) + }) +} + +func Test_ClientConfigModel_update(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + + ccm := &ClientConfigModel{DBConnectionPool: dbConnectionPool} + + walletID := "the_wallet_id" + encryptedAPIKey := "the_encrypted_api_key" + encrypterPublicKey := "the_encrypter_public_key" + + updatedWalletID := "another_wallet_id" + updatedEncryptedAPIKey := "another_encrypted_api_key" + updatedEncrypterPublicKey := "another_encrypter_public_key" + + // Insert a record to update + insertErr := ccm.insert(ctx, dbConnectionPool, ClientConfigUpdate{ + WalletID: &walletID, + EncryptedAPIKey: &encryptedAPIKey, + EncrypterPublicKey: &encrypterPublicKey, + }) + require.NoError(t, insertErr) + + t.Run("return error if no fields are provided", func(t *testing.T) { + config := ClientConfigUpdate{} + err := ccm.update(ctx, dbConnectionPool, config) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid circle config for update: wallet_id or encrypted_api_key must be provided") + }) + + t.Run("update wallet_id successfully", func(t *testing.T) { + tx := testutils.BeginTxWithRollback(t, ctx, dbConnectionPool) + + config := ClientConfigUpdate{WalletID: &updatedWalletID} + + err := ccm.update(ctx, tx, config) + require.NoError(t, err) + + cc, err := ccm.get(ctx, tx) + assert.NoError(t, err) + assert.Equal(t, updatedWalletID, *cc.WalletID) + assert.Equal(t, encryptedAPIKey, *cc.EncryptedAPIKey) + assert.Equal(t, encrypterPublicKey, *cc.EncrypterPublicKey) + }) + + t.Run("updates encrypted_api_key and encrypter_public_key successfully", func(t *testing.T) { + tx := testutils.BeginTxWithRollback(t, ctx, dbConnectionPool) + + err := ccm.update(ctx, dbConnectionPool, ClientConfigUpdate{ + EncryptedAPIKey: &updatedEncryptedAPIKey, + EncrypterPublicKey: &updatedEncrypterPublicKey, + }) + require.NoError(t, err) + + cc, err := ccm.get(ctx, tx) + assert.NoError(t, err) + assert.Equal(t, walletID, *cc.WalletID) + assert.Equal(t, updatedEncryptedAPIKey, *cc.EncryptedAPIKey) + assert.Equal(t, updatedEncrypterPublicKey, *cc.EncrypterPublicKey) + }) + + t.Run("updates both wallet_id and encrypted_api_key with encrypter_public_key successfully", func(t *testing.T) { + tx := testutils.BeginTxWithRollback(t, ctx, dbConnectionPool) + + err := ccm.update(ctx, dbConnectionPool, ClientConfigUpdate{ + WalletID: &updatedWalletID, + EncryptedAPIKey: &updatedEncryptedAPIKey, + EncrypterPublicKey: &updatedEncrypterPublicKey, + }) + require.NoError(t, err) + + cc, err := ccm.get(ctx, tx) + assert.NoError(t, err) + assert.Equal(t, updatedWalletID, *cc.WalletID) + assert.Equal(t, updatedEncryptedAPIKey, *cc.EncryptedAPIKey) + assert.Equal(t, updatedEncrypterPublicKey, *cc.EncrypterPublicKey) + }) +} + +func Test_ClientConfigModel_insert(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + ccm := &ClientConfigModel{DBConnectionPool: dbConnectionPool} + + walletID := "the_wallet_id" + encryptedAPIKey := "the_encrypted_api_key" + encrypterPublicKey := "the_encrypter_public_key" + + t.Run("insert successfully", func(t *testing.T) { + tx := testutils.BeginTxWithRollback(t, ctx, dbConnectionPool) + + config := ClientConfigUpdate{ + WalletID: &walletID, + EncryptedAPIKey: &encryptedAPIKey, + EncrypterPublicKey: &encrypterPublicKey, + } + + err := ccm.insert(ctx, tx, config) + require.NoError(t, err) + + cc, err := ccm.get(ctx, tx) + assert.NoError(t, err) + assert.Equal(t, walletID, *cc.WalletID) + assert.Equal(t, encryptedAPIKey, *cc.EncryptedAPIKey) + assert.Equal(t, encrypterPublicKey, *cc.EncrypterPublicKey) + }) + + t.Run("insert fails with missing encrypted_api_key", func(t *testing.T) { + tx := testutils.BeginTxWithRollback(t, ctx, dbConnectionPool) + + config := ClientConfigUpdate{ + WalletID: &walletID, + EncryptedAPIKey: nil, + EncrypterPublicKey: &encrypterPublicKey, + } + + err := ccm.insert(ctx, tx, config) + assert.Error(t, err) + assert.Contains(t, + err.Error(), + "invalid circle config for insert: wallet_id, encrypted_api_key, and encrypter_public_key must be provided") + }) + + t.Run("insert fails with missing wallet_id", func(t *testing.T) { + tx := testutils.BeginTxWithRollback(t, ctx, dbConnectionPool) + + config := ClientConfigUpdate{ + WalletID: nil, + EncryptedAPIKey: &encryptedAPIKey, + EncrypterPublicKey: &encrypterPublicKey, + } + + err := ccm.insert(ctx, tx, config) + assert.Error(t, err) + assert.Contains(t, + err.Error(), + "invalid circle config for insert: wallet_id, encrypted_api_key, and encrypter_public_key must be provided") + }) + + t.Run("insert fails with missing encrypter_public_key", func(t *testing.T) { + tx := testutils.BeginTxWithRollback(t, ctx, dbConnectionPool) + + config := ClientConfigUpdate{ + WalletID: &walletID, + EncryptedAPIKey: &encryptedAPIKey, + EncrypterPublicKey: nil, + } + + err := ccm.insert(ctx, tx, config) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid circle config for insert: wallet_id, encrypted_api_key, and encrypter_public_key must be provided") + }) + + t.Run("insert fails when inserting a second record", func(t *testing.T) { + tx := testutils.BeginTxWithRollback(t, ctx, dbConnectionPool) + + config := ClientConfigUpdate{ + WalletID: &walletID, + EncryptedAPIKey: &encryptedAPIKey, + EncrypterPublicKey: &encrypterPublicKey, + } + + err := ccm.insert(ctx, tx, config) + require.NoError(t, err) + + err = ccm.insert(ctx, tx, config) + assert.EqualError(t, err, "inserting circle config: pq: circle_client_config must contain exactly one row") + }) +} + +func Test_ClientConfigUpdate_Validate(t *testing.T) { + walletID := "wallet_id" + encryptedAPIKey := "encrypted_api_key" + encrypterPublicKey := "encrypter_public_key" + + tests := []struct { + name string + input ClientConfigUpdate + wantErr error + }{ + { + name: "both wallet_id and encrypted_api_key are nil", + input: ClientConfigUpdate{}, + wantErr: errors.New("wallet_id or encrypted_api_key must be provided"), + }, + { + name: "encrypted_api_key is provided without encrypter_public_key", + input: ClientConfigUpdate{EncryptedAPIKey: &encryptedAPIKey}, + wantErr: errors.New("encrypter_public_key must be provided if encrypted_api_key is provided"), + }, + { + name: "wallet_id is provided without encrypted_api_key", + input: ClientConfigUpdate{WalletID: &walletID}, + }, + { + name: "both wallet_id and encrypted_api_key are provided with encrypter_public_key", + input: ClientConfigUpdate{WalletID: &walletID, EncryptedAPIKey: &encryptedAPIKey, EncrypterPublicKey: &encrypterPublicKey}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.input.validate() + if tt.wantErr != nil { + assert.Equal(t, tt.wantErr.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/circle/client_mock.go b/internal/circle/client_mock.go new file mode 100644 index 000000000..418dcb81b --- /dev/null +++ b/internal/circle/client_mock.go @@ -0,0 +1,146 @@ +// Code generated by mockery v2.40.1. DO NOT EDIT. + +package circle + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockClient is an autogenerated mock type for the ClientInterface type +type MockClient struct { + mock.Mock +} + +// GetTransferByID provides a mock function with given fields: ctx, id +func (_m *MockClient) GetTransferByID(ctx context.Context, id string) (*Transfer, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetTransferByID") + } + + var r0 *Transfer + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*Transfer, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *Transfer); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Transfer) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetWalletByID provides a mock function with given fields: ctx, id +func (_m *MockClient) GetWalletByID(ctx context.Context, id string) (*Wallet, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetWalletByID") + } + + var r0 *Wallet + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*Wallet, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *Wallet); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Wallet) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Ping provides a mock function with given fields: ctx +func (_m *MockClient) Ping(ctx context.Context) (bool, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Ping") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (bool, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) bool); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// PostTransfer provides a mock function with given fields: ctx, transferRequest +func (_m *MockClient) PostTransfer(ctx context.Context, transferRequest TransferRequest) (*Transfer, error) { + ret := _m.Called(ctx, transferRequest) + + if len(ret) == 0 { + panic("no return value specified for PostTransfer") + } + + var r0 *Transfer + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, TransferRequest) (*Transfer, error)); ok { + return rf(ctx, transferRequest) + } + if rf, ok := ret.Get(0).(func(context.Context, TransferRequest) *Transfer); ok { + r0 = rf(ctx, transferRequest) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Transfer) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, TransferRequest) error); ok { + r1 = rf(ctx, transferRequest) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockClient(t interface { + mock.TestingT + Cleanup(func()) +}) *MockClient { + mock := &MockClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/circle/client_test.go b/internal/circle/client_test.go new file mode 100644 index 000000000..f6959fa34 --- /dev/null +++ b/internal/circle/client_test.go @@ -0,0 +1,483 @@ +package circle + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + httpclientMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" +) + +func Test_NewClient(t *testing.T) { + mockTntManager := &tenant.TenantManagerMock{} + t.Run("production environment", func(t *testing.T) { + clientInterface := NewClient(utils.PubnetNetworkType, "test-key", mockTntManager) + cc, ok := clientInterface.(*Client) + assert.True(t, ok) + assert.Equal(t, string(Production), cc.BasePath) + assert.Equal(t, "test-key", cc.APIKey) + }) + + t.Run("sandbox environment", func(t *testing.T) { + clientInterface := NewClient(utils.TestnetNetworkType, "test-key", mockTntManager) + cc, ok := clientInterface.(*Client) + assert.True(t, ok) + assert.Equal(t, string(Sandbox), cc.BasePath) + assert.Equal(t, "test-key", cc.APIKey) + }) +} + +func Test_Client_Ping(t *testing.T) { + ctx := context.Background() + + t.Run("ping error", func(t *testing.T) { + cc, httpClientMock, _ := newClientWithMocks(t) + testError := errors.New("test error") + httpClientMock. + On("Do", mock.Anything). + Return(nil, testError). + Once() + + ok, err := cc.Ping(ctx) + assert.EqualError(t, err, fmt.Errorf("making request: submitting request to http://localhost:8080/ping: %w", testError).Error()) + assert.False(t, ok) + }) + + t.Run("ping successful", func(t *testing.T) { + cc, httpClientMock, _ := newClientWithMocks(t) + httpClientMock. + On("Do", mock.Anything). + Return(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"message": "pong"}`)), + }, nil). + Run(func(args mock.Arguments) { + req, ok := args.Get(0).(*http.Request) + assert.True(t, ok) + + assert.Equal(t, "http://localhost:8080/ping", req.URL.String()) + assert.Equal(t, http.MethodGet, req.Method) + assert.Empty(t, req.Header.Get("Authorization")) + }). + Once() + + ok, err := cc.Ping(ctx) + assert.NoError(t, err) + assert.True(t, ok) + }) +} + +func Test_Client_PostTransfer(t *testing.T) { + ctx := context.Background() + validTransferReq := TransferRequest{ + Source: TransferAccount{Type: TransferAccountTypeWallet, ID: "source-id"}, + Destination: TransferAccount{Type: TransferAccountTypeBlockchain, Chain: "XLM", Address: "GBG2DFASN2E5ZZSOYH7SJ7HWBKR4M5LYQ5Q5ZVBWS3RI46GDSYTEA6YF", AddressTag: "txmemo2"}, + Amount: Balance{Amount: "100.00", Currency: "USD"}, + IdempotencyKey: uuid.NewString(), + } + + t.Run("post transfer error", func(t *testing.T) { + cc, httpClientMock, _ := newClientWithMocks(t) + testError := errors.New("test error") + httpClientMock. + On("Do", mock.Anything). + Return(nil, testError). + Once() + + transfer, err := cc.PostTransfer(ctx, validTransferReq) + assert.EqualError(t, err, fmt.Errorf("making request: submitting request to http://localhost:8080/v1/transfers: %w", testError).Error()) + assert.Nil(t, transfer) + }) + + t.Run("post transfer fails to validate request", func(t *testing.T) { + cc, _, _ := newClientWithMocks(t) + transfer, err := cc.PostTransfer(ctx, TransferRequest{}) + assert.EqualError(t, err, fmt.Errorf("validating transfer request: %w", errors.New("source type must be provided")).Error()) + assert.Nil(t, transfer) + }) + + t.Run("post transfer fails auth", func(t *testing.T) { + unauthorizedResponse := `{"code": 401, "message": "Malformed key. Does it contain three parts?"}` + cc, httpClientMock, tntManagerMock := newClientWithMocks(t) + tnt := &tenant.Tenant{ID: "test-id"} + ctx = tenant.SaveTenantInContext(ctx, tnt) + + httpClientMock. + On("Do", mock.Anything). + Return(&http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(bytes.NewBufferString(unauthorizedResponse)), + }, nil). + Once() + tntManagerMock. + On("DeactivateTenantDistributionAccount", mock.Anything, tnt.ID). + Return(nil).Once() + + transfer, err := cc.PostTransfer(ctx, validTransferReq) + assert.EqualError(t, err, "handling API response error: Circle API error: APIError: Code=401, Message=Malformed key. Does it contain three parts?, Errors=[], StatusCode=401") + assert.Nil(t, transfer) + }) + + t.Run("post transfer successful", func(t *testing.T) { + cc, httpClientMock, _ := newClientWithMocks(t) + httpClientMock. + On("Do", mock.Anything). + Return(&http.Response{ + StatusCode: http.StatusCreated, + Body: io.NopCloser(bytes.NewBufferString(`{"data": {"id": "test-id"}}`)), + }, nil). + Run(func(args mock.Arguments) { + req, ok := args.Get(0).(*http.Request) + assert.True(t, ok) + + assert.Equal(t, "http://localhost:8080/v1/transfers", req.URL.String()) + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, "Bearer test-key", req.Header.Get("Authorization")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + }). + Once() + + transfer, err := cc.PostTransfer(ctx, validTransferReq) + assert.NoError(t, err) + assert.Equal(t, "test-id", transfer.ID) + }) +} + +func Test_Client_GetTransferByID(t *testing.T) { + ctx := context.Background() + t.Run("get transfer by id error", func(t *testing.T) { + cc, httpClientMock, _ := newClientWithMocks(t) + testError := errors.New("test error") + httpClientMock. + On("Do", mock.Anything). + Return(nil, testError). + Once() + + transfer, err := cc.GetTransferByID(ctx, "test-id") + assert.EqualError(t, err, fmt.Errorf("making request: submitting request to http://localhost:8080/v1/transfers/test-id: %w", testError).Error()) + assert.Nil(t, transfer) + }) + + t.Run("get transfer by id fails auth", func(t *testing.T) { + unauthorizedResponse := `{"code": 401, "message": "Malformed key. Does it contain three parts?"}` + cc, httpClientMock, tntManagerMock := newClientWithMocks(t) + tnt := &tenant.Tenant{ID: "test-id"} + ctx = tenant.SaveTenantInContext(ctx, tnt) + + httpClientMock. + On("Do", mock.Anything). + Return(&http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(bytes.NewBufferString(unauthorizedResponse)), + }, nil). + Once() + tntManagerMock. + On("DeactivateTenantDistributionAccount", mock.Anything, tnt.ID). + Return(nil).Once() + + transfer, err := cc.GetTransferByID(ctx, "test-id") + assert.EqualError(t, err, "handling API response error: Circle API error: APIError: Code=401, Message=Malformed key. Does it contain three parts?, Errors=[], StatusCode=401") + assert.Nil(t, transfer) + }) + + t.Run("get transfer by id successful", func(t *testing.T) { + cc, httpClientMock, _ := newClientWithMocks(t) + httpClientMock. + On("Do", mock.Anything). + Return(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"data": {"id": "test-id"}}`)), + }, nil). + Run(func(args mock.Arguments) { + req, ok := args.Get(0).(*http.Request) + assert.True(t, ok) + + assert.Equal(t, "http://localhost:8080/v1/transfers/test-id", req.URL.String()) + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, "Bearer test-key", req.Header.Get("Authorization")) + }). + Once() + + transfer, err := cc.GetTransferByID(ctx, "test-id") + assert.NoError(t, err) + assert.Equal(t, "test-id", transfer.ID) + }) +} + +func Test_Client_GetWalletByID(t *testing.T) { + ctx := context.Background() + t.Run("get wallet by id error", func(t *testing.T) { + cc, httpClientMock, _ := newClientWithMocks(t) + testError := errors.New("test error") + httpClientMock. + On("Do", mock.Anything). + Run(func(args mock.Arguments) { + req, ok := args.Get(0).(*http.Request) + assert.True(t, ok) + + assert.Equal(t, "http://localhost:8080/v1/wallets/test-id", req.URL.String()) + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, "Bearer test-key", req.Header.Get("Authorization")) + }). + Return(nil, testError). + Once() + + wallet, err := cc.GetWalletByID(ctx, "test-id") + assert.EqualError(t, err, fmt.Errorf("making request: submitting request to http://localhost:8080/v1/wallets/test-id: %w", testError).Error()) + assert.Nil(t, wallet) + }) + + t.Run("get wallet by id fails auth", func(t *testing.T) { + const unauthorizedResponse = `{ + "code": 401, + "message": "Malformed key. Does it contain three parts?" + }` + cc, httpClientMock, tntManagerMock := newClientWithMocks(t) + tnt := &tenant.Tenant{ID: "test-id"} + ctx = tenant.SaveTenantInContext(ctx, tnt) + + httpClientMock. + On("Do", mock.Anything). + Return(&http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(bytes.NewBufferString(unauthorizedResponse)), + }, nil). + Once() + tntManagerMock. + On("DeactivateTenantDistributionAccount", mock.Anything, tnt.ID). + Return(nil).Once() + + transfer, err := cc.GetWalletByID(ctx, "test-id") + assert.EqualError(t, err, "handling API response error: Circle API error: APIError: Code=401, Message=Malformed key. Does it contain three parts?, Errors=[], StatusCode=401") + assert.Nil(t, transfer) + }) + + t.Run("get wallet by id successful", func(t *testing.T) { + const getWalletResponseJSON = `{ + "data": { + "walletId": "test-id", + "entityId": "2f47c999-9022-4939-acea-dc3afa9ccbaf", + "type": "end_user_wallet", + "description": "Treasury Wallet", + "balances": [ + { + "amount": "4790.00", + "currency": "USD" + } + ] + } + }` + cc, httpClientMock, _ := newClientWithMocks(t) + httpClientMock. + On("Do", mock.Anything). + Return(&http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(getWalletResponseJSON)), + }, nil). + Run(func(args mock.Arguments) { + req, ok := args.Get(0).(*http.Request) + assert.True(t, ok) + + assert.Equal(t, "http://localhost:8080/v1/wallets/test-id", req.URL.String()) + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, "Bearer test-key", req.Header.Get("Authorization")) + }). + Once() + + wallet, err := cc.GetWalletByID(ctx, "test-id") + assert.NoError(t, err) + wantWallet := &Wallet{ + WalletID: "test-id", + EntityID: "2f47c999-9022-4939-acea-dc3afa9ccbaf", + Type: "end_user_wallet", + Description: "Treasury Wallet", + Balances: []Balance{ + {Amount: "4790.00", Currency: "USD"}, + }, + } + assert.Equal(t, wantWallet, wallet) + }) +} + +func Test_Client_handleError(t *testing.T) { + ctx := context.Background() + tnt := &tenant.Tenant{ID: "test-id"} + ctx = tenant.SaveTenantInContext(ctx, tnt) + + cc, _, tntManagerMock := newClientWithMocks(t) + + t.Run("deactivate tenant distribution account error", func(t *testing.T) { + testError := errors.New("foo") + tntManagerMock. + On("DeactivateTenantDistributionAccount", mock.Anything, tnt.ID). + Return(testError).Once() + + err := cc.handleError(ctx, &http.Response{StatusCode: http.StatusUnauthorized}) + assert.EqualError(t, err, fmt.Errorf("deactivating tenant distribution account: %w", testError).Error()) + }) + + t.Run("deactivates tenant distribution account if Circle error response is unauthorized", func(t *testing.T) { + unauthorizedResponse := &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(bytes.NewBufferString(`{"code": 401, "message": "Unauthorized"}`)), + } + tntManagerMock. + On("DeactivateTenantDistributionAccount", mock.Anything, tnt.ID). + Return(nil).Once() + + err := cc.handleError(ctx, unauthorizedResponse) + assert.EqualError(t, fmt.Errorf("Circle API error: %w", errors.New("APIError: Code=401, Message=Unauthorized, Errors=[], StatusCode=401")), err.Error()) + }) + + t.Run("deactivates tenant distribution account if Circle error response is forbidden", func(t *testing.T) { + unauthorizedResponse := &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(bytes.NewBufferString(`{"code": 403, "message": "Forbidden"}`)), + } + tntManagerMock. + On("DeactivateTenantDistributionAccount", mock.Anything, tnt.ID). + Return(nil).Once() + + err := cc.handleError(ctx, unauthorizedResponse) + assert.EqualError(t, fmt.Errorf("Circle API error: %w", errors.New("APIError: Code=403, Message=Forbidden, Errors=[], StatusCode=403")), err.Error()) + }) + + t.Run("does not deactivate tenant distribution account if Circle error response is not unauthorized or forbidden", func(t *testing.T) { + unauthorizedResponse := &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(bytes.NewBufferString(`{"code": 400, "message": "Bad Request"}`)), + } + + err := cc.handleError(ctx, unauthorizedResponse) + assert.EqualError(t, fmt.Errorf("Circle API error: %w", errors.New("APIError: Code=400, Message=Bad Request, Errors=[], StatusCode=400")), err.Error()) + }) + + t.Run("records error correctly when not proper json", func(t *testing.T) { + unauthorizedResponse := &http.Response{ + StatusCode: http.StatusTooManyRequests, + Body: io.NopCloser(bytes.NewBufferString(`error code: 1015`)), + } + + err := cc.handleError(ctx, unauthorizedResponse) + assert.EqualError(t, fmt.Errorf("Circle API error: %w", errors.New("APIError: Code=0, Message=error code: 1015, Errors=[], StatusCode=429")), err.Error()) + }) + + tntManagerMock.AssertExpectations(t) +} + +func Test_Client_request(t *testing.T) { + tests := []struct { + name string + responses []http.Response + expectedAttempts int + expectedStatusCode int + expectedError string + }{ + { + name: "Success on first attempt", + responses: []http.Response{ + {StatusCode: http.StatusOK}, + }, + expectedAttempts: 1, + expectedStatusCode: http.StatusOK, + }, + { + name: "Success after rate limit", + responses: []http.Response{ + { + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"1"}}, + }, + {StatusCode: http.StatusOK}, + }, + expectedAttempts: 2, + expectedStatusCode: http.StatusOK, + }, + { + name: "Fail after max retries", + responses: []http.Response{ + { + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"1"}}, + }, + { + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"1"}}, + }, + { + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"1"}}, + }, + { + StatusCode: http.StatusTooManyRequests, + Header: http.Header{"Retry-After": []string{"1"}}, + }, + }, + expectedAttempts: 4, + expectedError: "rate limited, retry after: 1s", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cc, httpClientMock, _ := newClientWithMocks(t) + + ctx := context.Background() + u := "https://api-sandbox.circle.com/test" + method := http.MethodGet + isAuthed := true + body := []byte("test-body") + + for _, resp := range tt.responses { + httpClientMock. + On("Do", mock.Anything). + Return(&resp, nil).Once() + } + + resp, err := cc.request(ctx, u, method, isAuthed, body) + + if tt.expectedError != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedStatusCode, resp.StatusCode) + } + + httpClientMock.AssertNumberOfCalls(t, "Do", tt.expectedAttempts) + + // Check if the request was properly formed + lastCall := httpClientMock.Calls[len(httpClientMock.Calls)-1] + lastReq := lastCall.Arguments[0].(*http.Request) + assert.Equal(t, method, lastReq.Method) + assert.Equal(t, u, lastReq.URL.String()) + assert.Equal(t, "Bearer test-key", lastReq.Header.Get("Authorization")) + assert.Equal(t, "application/json", lastReq.Header.Get("Content-Type")) + }) + } +} + +func newClientWithMocks(t *testing.T) (Client, *httpclientMocks.HttpClientMock, *tenant.TenantManagerMock) { + httpClientMock := httpclientMocks.NewHttpClientMock(t) + tntManagerMock := tenant.NewTenantManagerMock(t) + + return Client{ + BasePath: "http://localhost:8080", + APIKey: "test-key", + httpClient: httpClientMock, + tenantManager: tntManagerMock, + }, httpClientMock, tntManagerMock +} diff --git a/internal/circle/environments.go b/internal/circle/environments.go new file mode 100644 index 000000000..41f1cf5ac --- /dev/null +++ b/internal/circle/environments.go @@ -0,0 +1,9 @@ +package circle + +// Environment holds the possible environments for the Circle API. +type Environment string + +const ( + Production Environment = "https://api.circle.com" + Sandbox Environment = "https://api-sandbox.circle.com" +) diff --git a/internal/circle/errors.go b/internal/circle/errors.go new file mode 100644 index 000000000..8d2767bf4 --- /dev/null +++ b/internal/circle/errors.go @@ -0,0 +1,50 @@ +package circle + +import ( + "encoding/json" + "fmt" + "io" + "net/http" +) + +// APIError represents the error response from Circle APIs. +type APIError struct { + // Code is the Circle API error code. + Code int `json:"code"` + Message string `json:"message"` + Errors []APIErrorDetail `json:"errors,omitempty"` + // StatusCode is the HTTP status code. + StatusCode int `json:"status_code,omitempty"` +} + +// APIErrorDetail represents the detailed error information. +type APIErrorDetail struct { + Error string `json:"error"` + Message string `json:"message"` + Location string `json:"location"` + InvalidValue interface{} `json:"invalidValue,omitempty"` + Constraints map[string]interface{} `json:"constraints,omitempty"` +} + +// Error implements the error interface for APIError. +func (e APIError) Error() string { + return fmt.Sprintf("APIError: Code=%d, Message=%s, Errors=%v, StatusCode=%d", e.Code, e.Message, e.Errors, e.StatusCode) +} + +// parseAPIError parses the error response from Circle APIs. +// https://developers.circle.com/circle-mint/docs/circle-apis-api-errors. +func parseAPIError(resp *http.Response) (*APIError, error) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading error response body: %w", err) + } + defer resp.Body.Close() + + var apiErr APIError + if err = json.Unmarshal(body, &apiErr); err != nil { + apiErr.Message = string(body) + } + apiErr.StatusCode = resp.StatusCode + + return &apiErr, nil +} diff --git a/internal/circle/payment_request.go b/internal/circle/payment_request.go new file mode 100644 index 000000000..a64b981a1 --- /dev/null +++ b/internal/circle/payment_request.go @@ -0,0 +1,55 @@ +package circle + +import ( + "fmt" + + "github.com/google/uuid" + "github.com/stellar/go/strkey" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" +) + +type PaymentRequest struct { + SourceWalletID string + DestinationStellarAddress string + Amount string + StellarAssetCode string + IdempotencyKey string +} + +// GetCircleAssetCode converts the request's Stellar asset code to a Circle's asset code. +func (p PaymentRequest) GetCircleAssetCode() (string, error) { + switch p.StellarAssetCode { + case assets.USDCAssetCode: + return "USD", nil + case assets.EURCAssetCode: + return "EUR", nil + default: + return "", fmt.Errorf("unsupported asset code for CIRCLE: %s", p.StellarAssetCode) + } +} + +func (p PaymentRequest) Validate() error { + if p.SourceWalletID == "" { + return fmt.Errorf("source wallet ID is required") + } + + if !strkey.IsValidEd25519PublicKey(p.DestinationStellarAddress) { + return fmt.Errorf("destination stellar address is not a valid public key") + } + + if err := utils.ValidateAmount(p.Amount); err != nil { + return fmt.Errorf("amount is not valid: %w", err) + } + + if p.StellarAssetCode == "" { + return fmt.Errorf("stellar asset code is required") + } + + if err := uuid.Validate(p.IdempotencyKey); err != nil { + return fmt.Errorf("idempotency key is not valid: %w", err) + } + + return nil +} diff --git a/internal/circle/payment_request_test.go b/internal/circle/payment_request_test.go new file mode 100644 index 000000000..359e91120 --- /dev/null +++ b/internal/circle/payment_request_test.go @@ -0,0 +1,145 @@ +package circle + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stellar/go/keypair" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_PaymentRequest_GetCircleAssetCode(t *testing.T) { + tests := []struct { + name string + stellarAssetCode string + expectedAssetCode string + wantErr string + }{ + { + name: "USDC asset code", + stellarAssetCode: "USDC", + expectedAssetCode: "USD", + wantErr: "", + }, + { + name: "EURC asset code", + stellarAssetCode: "EURC", + expectedAssetCode: "EUR", + wantErr: "", + }, + { + name: "unsupported asset code for CIRCLE", + stellarAssetCode: "XYZ", + expectedAssetCode: "", + wantErr: "unsupported asset code for CIRCLE: XYZ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := PaymentRequest{ + StellarAssetCode: tt.stellarAssetCode, + } + + actualAssetCode, actualErr := p.GetCircleAssetCode() + + if tt.wantErr != "" { + assert.ErrorContains(t, actualErr, tt.wantErr) + } else { + assert.NoError(t, actualErr) + } + + assert.Equal(t, tt.expectedAssetCode, actualAssetCode) + }) + } +} + +func Test_PaymentRequest_Validate(t *testing.T) { + validDestinationAddress := keypair.MustRandom().Address() + + tests := []struct { + name string + paymentReq PaymentRequest + wantErr string + }{ + { + name: "missing source wallet ID", + paymentReq: PaymentRequest{ + SourceWalletID: "", + DestinationStellarAddress: validDestinationAddress, + Amount: "100.00", + StellarAssetCode: "USDC", + IdempotencyKey: uuid.New().String(), + }, + wantErr: "source wallet ID is required", + }, + { + name: "invalid destination stellar address", + paymentReq: PaymentRequest{ + SourceWalletID: "source_wallet_123", + DestinationStellarAddress: "invalid_address", + Amount: "100.00", + StellarAssetCode: "USDC", + IdempotencyKey: uuid.New().String(), + }, + wantErr: "destination stellar address is not a valid public key", + }, + { + name: "invalid amount", + paymentReq: PaymentRequest{ + SourceWalletID: "source_wallet_123", + DestinationStellarAddress: validDestinationAddress, + Amount: "invalid_amount", + StellarAssetCode: "USDC", + IdempotencyKey: uuid.New().String(), + }, + wantErr: "amount is not valid", + }, + { + name: "missing stellar asset code", + paymentReq: PaymentRequest{ + SourceWalletID: "source_wallet_123", + DestinationStellarAddress: validDestinationAddress, + Amount: "100.00", + StellarAssetCode: "", + IdempotencyKey: uuid.New().String(), + }, + wantErr: "stellar asset code is required", + }, + { + name: "invalid idempotency key", + paymentReq: PaymentRequest{ + SourceWalletID: "source_wallet_123", + DestinationStellarAddress: validDestinationAddress, + Amount: "100.00", + StellarAssetCode: "USDC", + IdempotencyKey: "invalid_uuid", + }, + wantErr: "idempotency key is not valid", + }, + { + name: "valid payment request", + paymentReq: PaymentRequest{ + SourceWalletID: "source_wallet_123", + DestinationStellarAddress: validDestinationAddress, + Amount: "100.00", + StellarAssetCode: "USDC", + IdempotencyKey: uuid.New().String(), + }, + wantErr: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.paymentReq.Validate() + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/circle/service.go b/internal/circle/service.go new file mode 100644 index 000000000..71ac39318 --- /dev/null +++ b/internal/circle/service.go @@ -0,0 +1,147 @@ +package circle + +import ( + "context" + "fmt" + + "github.com/stellar/go/strkey" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" +) + +type Service struct { + ClientFactory ClientFactory + ClientConfigModel ClientConfigModelInterface + NetworkType utils.NetworkType + EncryptionPassphrase string + TenantManager tenant.ManagerInterface +} + +const StellarChainCode = "XLM" + +// ServiceInterface defines the interface for Circle related SDP operations. +// +//go:generate mockery --name=ServiceInterface --case=underscore --structname=MockService --output=. --filename=service_mock.go --inpackage +type ServiceInterface interface { + ClientInterface + SendPayment(ctx context.Context, paymentRequest PaymentRequest) (*Transfer, error) +} + +var _ ServiceInterface = (*Service)(nil) + +type ServiceOptions struct { + ClientFactory ClientFactory + ClientConfigModel ClientConfigModelInterface + TenantManager tenant.ManagerInterface + NetworkType utils.NetworkType + EncryptionPassphrase string +} + +func (o ServiceOptions) Validate() error { + if o.ClientFactory == nil { + return fmt.Errorf("ClientFactory is required") + } + + if o.ClientConfigModel == nil { + return fmt.Errorf("ClientConfigModel is required") + } + + if o.TenantManager == nil { + return fmt.Errorf("TenantManager is required") + } + + err := o.NetworkType.Validate() + if err != nil { + return fmt.Errorf("validating NetworkType: %w", err) + } + + if !strkey.IsValidEd25519SecretSeed(o.EncryptionPassphrase) { + return fmt.Errorf("EncryptionPassphrase is invalid") + } + + return nil +} + +func NewService(opts ServiceOptions) (*Service, error) { + err := opts.Validate() + if err != nil { + return nil, fmt.Errorf("validating circle.Service options: %w", err) + } + + return &Service{ + ClientFactory: opts.ClientFactory, + ClientConfigModel: opts.ClientConfigModel, + NetworkType: opts.NetworkType, + EncryptionPassphrase: opts.EncryptionPassphrase, + TenantManager: opts.TenantManager, + }, nil +} + +func (s *Service) SendPayment(ctx context.Context, paymentRequest PaymentRequest) (*Transfer, error) { + if err := paymentRequest.Validate(); err != nil { + return nil, fmt.Errorf("validating payment request: %w", err) + } + + circleAssetCode, err := paymentRequest.GetCircleAssetCode() + if err != nil { + return nil, fmt.Errorf("getting Circle asset code: %w", err) + } + + return s.PostTransfer(ctx, TransferRequest{ + IdempotencyKey: paymentRequest.IdempotencyKey, + Amount: Balance{ + Amount: paymentRequest.Amount, + Currency: circleAssetCode, + }, + Source: TransferAccount{ + Type: TransferAccountTypeWallet, + ID: paymentRequest.SourceWalletID, + }, + Destination: TransferAccount{ + Type: TransferAccountTypeBlockchain, + Chain: StellarChainCode, + Address: paymentRequest.DestinationStellarAddress, + }, + }) +} + +func (s *Service) getClientForTenantInContext(ctx context.Context) (ClientInterface, error) { + apiKey, err := s.ClientConfigModel.GetDecryptedAPIKey(ctx, s.EncryptionPassphrase) + if err != nil { + return nil, fmt.Errorf("retrieving decrypted Circle API key: %w", err) + } + return s.ClientFactory(s.NetworkType, apiKey, s.TenantManager), nil +} + +func (s *Service) Ping(ctx context.Context) (bool, error) { + client, err := s.getClientForTenantInContext(ctx) + if err != nil { + return false, fmt.Errorf("cannot get Circle client: %w", err) + } + return client.Ping(ctx) +} + +func (s *Service) PostTransfer(ctx context.Context, transferRequest TransferRequest) (*Transfer, error) { + client, err := s.getClientForTenantInContext(ctx) + if err != nil { + return nil, fmt.Errorf("cannot get Circle client: %w", err) + } + return client.PostTransfer(ctx, transferRequest) +} + +func (s *Service) GetTransferByID(ctx context.Context, transferID string) (*Transfer, error) { + client, err := s.getClientForTenantInContext(ctx) + if err != nil { + return nil, fmt.Errorf("cannot get Circle client: %w", err) + } + return client.GetTransferByID(ctx, transferID) +} + +func (s *Service) GetWalletByID(ctx context.Context, walletID string) (*Wallet, error) { + client, err := s.getClientForTenantInContext(ctx) + if err != nil { + return nil, fmt.Errorf("cannot get Circle client: %w", err) + } + return client.GetWalletByID(ctx, walletID) +} diff --git a/internal/circle/service_mock.go b/internal/circle/service_mock.go new file mode 100644 index 000000000..e67346fdc --- /dev/null +++ b/internal/circle/service_mock.go @@ -0,0 +1,176 @@ +// Code generated by mockery v2.43.2. DO NOT EDIT. + +package circle + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockService is an autogenerated mock type for the ServiceInterface type +type MockService struct { + mock.Mock +} + +// GetTransferByID provides a mock function with given fields: ctx, id +func (_m *MockService) GetTransferByID(ctx context.Context, id string) (*Transfer, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetTransferByID") + } + + var r0 *Transfer + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*Transfer, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *Transfer); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Transfer) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetWalletByID provides a mock function with given fields: ctx, id +func (_m *MockService) GetWalletByID(ctx context.Context, id string) (*Wallet, error) { + ret := _m.Called(ctx, id) + + if len(ret) == 0 { + panic("no return value specified for GetWalletByID") + } + + var r0 *Wallet + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*Wallet, error)); ok { + return rf(ctx, id) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *Wallet); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Wallet) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Ping provides a mock function with given fields: ctx +func (_m *MockService) Ping(ctx context.Context) (bool, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Ping") + } + + var r0 bool + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (bool, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) bool); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(bool) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// PostTransfer provides a mock function with given fields: ctx, transferRequest +func (_m *MockService) PostTransfer(ctx context.Context, transferRequest TransferRequest) (*Transfer, error) { + ret := _m.Called(ctx, transferRequest) + + if len(ret) == 0 { + panic("no return value specified for PostTransfer") + } + + var r0 *Transfer + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, TransferRequest) (*Transfer, error)); ok { + return rf(ctx, transferRequest) + } + if rf, ok := ret.Get(0).(func(context.Context, TransferRequest) *Transfer); ok { + r0 = rf(ctx, transferRequest) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Transfer) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, TransferRequest) error); ok { + r1 = rf(ctx, transferRequest) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SendPayment provides a mock function with given fields: ctx, paymentRequest +func (_m *MockService) SendPayment(ctx context.Context, paymentRequest PaymentRequest) (*Transfer, error) { + ret := _m.Called(ctx, paymentRequest) + + if len(ret) == 0 { + panic("no return value specified for SendPayment") + } + + var r0 *Transfer + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, PaymentRequest) (*Transfer, error)); ok { + return rf(ctx, paymentRequest) + } + if rf, ok := ret.Get(0).(func(context.Context, PaymentRequest) *Transfer); ok { + r0 = rf(ctx, paymentRequest) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*Transfer) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, PaymentRequest) error); ok { + r1 = rf(ctx, paymentRequest) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockService(t interface { + mock.TestingT + Cleanup(func()) +}) *MockService { + mock := &MockService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/circle/service_test.go b/internal/circle/service_test.go new file mode 100644 index 000000000..02a27160a --- /dev/null +++ b/internal/circle/service_test.go @@ -0,0 +1,274 @@ +package circle + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" +) + +func Test_ServiceOptions_Validate(t *testing.T) { + var clientFactory ClientFactory = func(networkType utils.NetworkType, apiKey string, tntManager tenant.ManagerInterface) ClientInterface { + return nil + } + circleClientConfigModel := &ClientConfigModel{} + mockTenantManager := &tenant.TenantManagerMock{} + + testCases := []struct { + name string + opts ServiceOptions + expectedErrContains string + }{ + { + name: "ClientFactory validation fails", + opts: ServiceOptions{}, + expectedErrContains: "ClientFactory is required", + }, + { + name: "ClientConfigModel validation fails", + opts: ServiceOptions{ClientFactory: clientFactory}, + expectedErrContains: "ClientConfigModel is required", + }, + { + name: "TenantManager validation fails", + opts: ServiceOptions{ClientFactory: clientFactory, ClientConfigModel: circleClientConfigModel}, + expectedErrContains: "TenantManager is required", + }, + { + name: "NetworkType validation fails", + opts: ServiceOptions{ + ClientFactory: clientFactory, + ClientConfigModel: circleClientConfigModel, + TenantManager: mockTenantManager, + NetworkType: utils.NetworkType("FOOBAR"), + }, + expectedErrContains: `validating NetworkType: invalid network type "FOOBAR"`, + }, + { + name: "EncryptionPassphrase validation fails", + opts: ServiceOptions{ + ClientFactory: clientFactory, + ClientConfigModel: circleClientConfigModel, + TenantManager: mockTenantManager, + NetworkType: utils.TestnetNetworkType, + EncryptionPassphrase: "FOO BAR", + }, + expectedErrContains: "EncryptionPassphrase is invalid", + }, + { + name: "🎉 successfully validates options", + opts: ServiceOptions{ + ClientFactory: clientFactory, + ClientConfigModel: circleClientConfigModel, + TenantManager: mockTenantManager, + NetworkType: utils.TestnetNetworkType, + EncryptionPassphrase: "SCW5I426WV3IDTLSTLQEHC6BMXWI2Z6C4DXAOC4ZA2EIHTAZQ6VD3JI6", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.opts.Validate() + if tc.expectedErrContains != "" { + assert.Contains(t, err.Error(), tc.expectedErrContains) + } else { + assert.NoError(t, err) + } + }) + } +} + +func Test_NewService(t *testing.T) { + t.Run("handle constructor error", func(t *testing.T) { + svc, err := NewService(ServiceOptions{}) + assert.Empty(t, svc) + assert.ErrorContains(t, err, "validating circle.Service options: ClientFactory is required") + }) + + t.Run("🎉 successfully creates a new Service", func(t *testing.T) { + clientFactory := func(networkType utils.NetworkType, apiKey string, tntManager tenant.ManagerInterface) ClientInterface { + return nil + } + clientConfigModel := &ClientConfigModel{} + mockTntManager := &tenant.TenantManagerMock{} + networkType := utils.TestnetNetworkType + encryptionPassphrase := "SCW5I426WV3IDTLSTLQEHC6BMXWI2Z6C4DXAOC4ZA2EIHTAZQ6VD3JI6" + + svc, err := NewService(ServiceOptions{ + ClientFactory: clientFactory, + ClientConfigModel: clientConfigModel, + TenantManager: mockTntManager, + NetworkType: networkType, + EncryptionPassphrase: encryptionPassphrase, + }) + assert.NoError(t, err) + + wantService := &Service{ + ClientFactory: clientFactory, + ClientConfigModel: clientConfigModel, + TenantManager: mockTntManager, + NetworkType: networkType, + EncryptionPassphrase: encryptionPassphrase, + } + assert.Equal(t, wantService.ClientFactory(networkType, "FOO BAR", mockTntManager), svc.ClientFactory(networkType, "FOO BAR", mockTntManager)) + assert.Equal(t, wantService.ClientConfigModel, svc.ClientConfigModel) + assert.Equal(t, wantService.NetworkType, svc.NetworkType) + assert.Equal(t, wantService.EncryptionPassphrase, svc.EncryptionPassphrase) + }) +} + +func Test_Service_getClient(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + + pubKey := "GBFL6FHGHTOSNCAR3GE2MX53Y6BZ3QBCYSTBOCJBSFOWZ35EG2F6T4LG" + encryptionPassphrase := "SCW5I426WV3IDTLSTLQEHC6BMXWI2Z6C4DXAOC4ZA2EIHTAZQ6VD3JI6" + apiKey := "api-key" + encryptedAPIKey := "72TARC5aoKJOEUIMTR9nlITP6+MbugQtS+2faBKSQbCrXic=" // <--- "api-key" encrypted with the encryptionPassphrase. + networkType := utils.TestnetNetworkType + clientConfigModel := NewClientConfigModel(dbConnectionPool) + mockTntManager := &tenant.TenantManagerMock{} + + // Add a client config to the database. + err = clientConfigModel.Upsert(ctx, ClientConfigUpdate{ + WalletID: utils.StringPtr("the_wallet_id"), + EncryptedAPIKey: utils.StringPtr(encryptedAPIKey), + EncrypterPublicKey: utils.StringPtr(pubKey), + }) + require.NoError(t, err) + + // Create a service. + svc, err := NewService(ServiceOptions{ + ClientFactory: NewClient, + ClientConfigModel: clientConfigModel, + TenantManager: mockTntManager, + NetworkType: networkType, + EncryptionPassphrase: encryptionPassphrase, + }) + assert.NoError(t, err) + + circleClient, err := svc.getClientForTenantInContext(ctx) + assert.NoError(t, err) + wantCircleClient := NewClient(networkType, apiKey, &tenant.TenantManagerMock{}) + assert.Equal(t, wantCircleClient, circleClient) +} + +func Test_Service_allMethods(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + + pubKey := "GBFL6FHGHTOSNCAR3GE2MX53Y6BZ3QBCYSTBOCJBSFOWZ35EG2F6T4LG" + encryptionPassphrase := "SCW5I426WV3IDTLSTLQEHC6BMXWI2Z6C4DXAOC4ZA2EIHTAZQ6VD3JI6" + encryptedAPIKey := "72TARC5aoKJOEUIMTR9nlITP6+MbugQtS+2faBKSQbCrXic=" // <--- "api-key" encrypted with the encryptionPassphrase. + networkType := utils.TestnetNetworkType + clientConfigModel := NewClientConfigModel(dbConnectionPool) + mockTntManager := &tenant.TenantManagerMock{} + + // Add a client config to the database. + err = clientConfigModel.Upsert(ctx, ClientConfigUpdate{ + WalletID: utils.StringPtr("the_wallet_id"), + EncryptedAPIKey: utils.StringPtr(encryptedAPIKey), + EncrypterPublicKey: utils.StringPtr(pubKey), + }) + require.NoError(t, err) + + // Method used to spin up a service with a mock client. + createService := func(t *testing.T, mCircleClient *MockClient) *Service { + svc, err := NewService(ServiceOptions{ + ClientFactory: func(networkType utils.NetworkType, apiKey string, tntManager tenant.ManagerInterface) ClientInterface { + return mCircleClient + }, + ClientConfigModel: clientConfigModel, + TenantManager: mockTntManager, + NetworkType: networkType, + EncryptionPassphrase: encryptionPassphrase, + }) + require.NoError(t, err) + return svc + } + + t.Run("Ping", func(t *testing.T) { + mCircleClient := NewMockClient(t) + mCircleClient. + On("Ping", ctx). + Return(true, nil). + Once() + svc := createService(t, mCircleClient) + + res, err := svc.Ping(ctx) + assert.NoError(t, err) + assert.True(t, res) + }) + + t.Run("PostTransfer", func(t *testing.T) { + mCircleClient := NewMockClient(t) + transferRequest := TransferRequest{ + Source: TransferAccount{ + Type: TransferAccountTypeWallet, + ID: "wallet-id", + }, + Destination: TransferAccount{ + Type: TransferAccountTypeWallet, + Chain: "XLM", + Address: pubKey, + }, + Amount: Balance{ + Amount: "123.45", + Currency: "USD", + }, + IdempotencyKey: "idempotency-key", + } + mCircleClient. + On("PostTransfer", ctx, transferRequest). + Return(&Transfer{ID: "transfer-id"}, nil). + Once() + svc := createService(t, mCircleClient) + + res, err := svc.PostTransfer(ctx, transferRequest) + assert.NoError(t, err) + assert.Equal(t, &Transfer{ID: "transfer-id"}, res) + }) + + t.Run("GetTransferByID", func(t *testing.T) { + mCircleClient := NewMockClient(t) + mCircleClient. + On("GetTransferByID", ctx, "transfer-id"). + Return(&Transfer{ID: "transfer-id"}, nil). + Once() + svc := createService(t, mCircleClient) + + res, err := svc.GetTransferByID(ctx, "transfer-id") + assert.NoError(t, err) + assert.Equal(t, &Transfer{ID: "transfer-id"}, res) + }) + + t.Run("GetWalletByID", func(t *testing.T) { + mCircleClient := NewMockClient(t) + mCircleClient. + On("GetWalletByID", ctx, "wallet-id"). + Return(&Wallet{WalletID: "wallet-id"}, nil). + Once() + svc := createService(t, mCircleClient) + + res, err := svc.GetWalletByID(ctx, "wallet-id") + assert.NoError(t, err) + assert.Equal(t, &Wallet{WalletID: "wallet-id"}, res) + }) +} diff --git a/internal/circle/transfer.go b/internal/circle/transfer.go new file mode 100644 index 000000000..09adef2f8 --- /dev/null +++ b/internal/circle/transfer.go @@ -0,0 +1,139 @@ +package circle + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" +) + +// Transfer represents a transfer of funds from a Circle Endpoint to another. A circle endpoint can be a wallet, card, wire, or blockchain address. +type Transfer struct { + ID string `json:"id"` + Source TransferAccount `json:"source"` + Destination TransferAccount `json:"destination"` + Amount Balance `json:"amount"` + TransactionHash string `json:"transactionHash,omitempty"` + Status TransferStatus `json:"status"` + ErrorCode TransferErrorCode `json:"errorCode,omitempty"` + CreateDate time.Time `json:"createDate"` +} + +type TransferStatus string + +const ( + TransferStatusPending TransferStatus = "pending" + TransferStatusComplete TransferStatus = "complete" // means success + TransferStatusFailed TransferStatus = "failed" +) + +func (s TransferStatus) ToPaymentStatus() (data.PaymentStatus, error) { + switch s { + case TransferStatusPending: + return data.PendingPaymentStatus, nil + case TransferStatusComplete: + return data.SuccessPaymentStatus, nil + case TransferStatusFailed: + return data.FailedPaymentStatus, nil + default: + return "", fmt.Errorf("unknown transfer status %q", s) + } +} + +type TransferErrorCode string + +const ( + TransferErrorCodeInsufficientFunds TransferErrorCode = "insufficient_funds" + TransferErrorCodeBlockchainError TransferErrorCode = "blockchain_error" + TransferErrorCodeTransferDenied TransferErrorCode = "transfer_denied" + TransferErrorCodeTransferFailed TransferErrorCode = "transfer_failed" +) + +// TransferAccountType represents the type of the source or destination of the transfer. +type TransferAccountType string + +const ( + TransferAccountTypeCard TransferAccountType = "card" + TransferAccountTypeWire TransferAccountType = "wire" + TransferAccountTypeBlockchain TransferAccountType = "blockchain" + TransferAccountTypeWallet TransferAccountType = "wallet" +) + +// TransferAccount represents the source or destination of the transfer. +type TransferAccount struct { + Type TransferAccountType `json:"type"` + ID string `json:"id,omitempty"` + Chain string `json:"chain,omitempty"` + Address string `json:"address,omitempty"` + AddressTag string `json:"addressTag,omitempty"` +} + +// TransferResponse represents the response from the Circle APIs. +type TransferResponse struct { + Data Transfer `json:"data"` +} + +// TransferRequest represents the request to create a new transfer. +type TransferRequest struct { + Source TransferAccount `json:"source"` + Destination TransferAccount `json:"destination"` + Amount Balance `json:"amount"` + IdempotencyKey string `json:"idempotencyKey"` +} + +func (tr TransferRequest) validate() error { + if tr.Source.Type == "" { + return fmt.Errorf("source type must be provided") + } + + if tr.Source.Type != TransferAccountTypeWallet { + return fmt.Errorf("source type must be wallet") + } + + if tr.Source.ID == "" { + return fmt.Errorf("source ID must be provided for wallet transfers") + } + + if tr.Destination.Type != TransferAccountTypeBlockchain { + return fmt.Errorf("destination type must be blockchain") + } + + if tr.Destination.Chain != "XLM" { + return fmt.Errorf("destination chain must be Stellar (XLM)") + } + + if tr.Destination.Address == "" { + return fmt.Errorf("destination address must be provided") + } + + if tr.Amount.Currency == "" { + return fmt.Errorf("currency must be provided") + } + + if tr.Amount.Amount == "" { + return fmt.Errorf("amount must be provided") + } + + if tr.IdempotencyKey == "" { + return fmt.Errorf("idempotency key must be provided") + } + + if _, err := strconv.ParseFloat(tr.Amount.Amount, 64); err != nil { + return fmt.Errorf("amount must be a valid number") + } + + return nil +} + +// parseTransferResponse parses the response from the Circle APIs +func parseTransferResponse(resp *http.Response) (*Transfer, error) { + var transferResponse TransferResponse + if err := json.NewDecoder(resp.Body).Decode(&transferResponse); err != nil { + return nil, fmt.Errorf("decoding transfer response: %w", err) + } + + return &transferResponse.Data, nil +} diff --git a/internal/circle/transfer_test.go b/internal/circle/transfer_test.go new file mode 100644 index 000000000..accb5c381 --- /dev/null +++ b/internal/circle/transfer_test.go @@ -0,0 +1,154 @@ +package circle + +import ( + "errors" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" +) + +func Test_TransferRequest_validate(t *testing.T) { + tests := []struct { + name string + tr TransferRequest + wantErr error + }{ + { + name: "source type is not provided", + tr: TransferRequest{}, + wantErr: errors.New("source type must be provided"), + }, + { + name: "source type is not wallet", + tr: TransferRequest{Source: TransferAccount{Type: TransferAccountTypeBlockchain}}, + wantErr: errors.New("source type must be wallet"), + }, + { + name: "source ID is not provided", + tr: TransferRequest{ + Source: TransferAccount{Type: TransferAccountTypeWallet}, + }, + wantErr: errors.New("source ID must be provided for wallet transfers"), + }, + { + name: "destination type is not blockchain", + tr: TransferRequest{ + Source: TransferAccount{Type: TransferAccountTypeWallet, ID: "1014442536"}, + Destination: TransferAccount{Type: TransferAccountTypeWallet}, + }, + wantErr: errors.New("destination type must be blockchain"), + }, + { + name: "destination chain is not XLM", + tr: TransferRequest{ + Source: TransferAccount{Type: TransferAccountTypeWallet, ID: "1014442536"}, + Destination: TransferAccount{Type: TransferAccountTypeBlockchain}, + }, + wantErr: errors.New("destination chain must be Stellar (XLM)"), + }, + { + name: "destination address is not provided", + tr: TransferRequest{ + Source: TransferAccount{Type: TransferAccountTypeWallet, ID: "1014442536"}, + Destination: TransferAccount{Type: TransferAccountTypeBlockchain, Chain: "XLM"}, + }, + wantErr: errors.New("destination address must be provided"), + }, + { + name: "currency is not provided", + tr: TransferRequest{ + Source: TransferAccount{Type: TransferAccountTypeWallet, ID: "1014442536"}, + Destination: TransferAccount{Type: TransferAccountTypeBlockchain, Chain: "XLM", Address: "GBG2DFASN2E5ZZSOYH7SJ7HWBKR4M5LYQ5Q5ZVBWS3RI46GDSYTEA6YF"}, + }, + wantErr: errors.New("currency must be provided"), + }, + { + name: "amount is not a valid number", + tr: TransferRequest{ + Source: TransferAccount{Type: TransferAccountTypeWallet, ID: "1014442536"}, + Destination: TransferAccount{Type: TransferAccountTypeBlockchain, Chain: "XLM", Address: "GBG2DFASN2E5ZZSOYH7SJ7HWBKR4M5LYQ5Q5ZVBWS3RI46GDSYTEA6YF"}, + Amount: Balance{Amount: "invalid", Currency: "USD"}, + IdempotencyKey: uuid.NewString(), + }, + wantErr: errors.New("amount must be a valid number"), + }, + { + name: "idempotency key is not provided", + tr: TransferRequest{ + Source: TransferAccount{Type: TransferAccountTypeWallet, ID: "1014442536"}, + Destination: TransferAccount{Type: TransferAccountTypeBlockchain, Chain: "XLM", Address: "GBG2DFASN2E5ZZSOYH7SJ7HWBKR4M5LYQ5Q5ZVBWS3RI46GDSYTEA6YF"}, + Amount: Balance{Amount: "0.25", Currency: "USD"}, + }, + wantErr: nil, + }, + { + name: "valid transfer request", + tr: TransferRequest{ + IdempotencyKey: uuid.NewString(), + Source: TransferAccount{Type: TransferAccountTypeWallet, ID: "1014442536"}, + Destination: TransferAccount{Type: TransferAccountTypeBlockchain, Chain: "XLM", Address: "GBG2DFASN2E5ZZSOYH7SJ7HWBKR4M5LYQ5Q5ZVBWS3RI46GDSYTEA6YF"}, + Amount: Balance{Amount: "0.25", Currency: "USD"}, + }, + wantErr: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.tr.validate() + if tt.wantErr != nil { + assert.EqualError(t, err, tt.wantErr.Error()) + } + }) + } +} + +func Test_TransferStatus_ToPaymentStatus(t *testing.T) { + tests := []struct { + name string + transferStatus TransferStatus + expectedStatus data.PaymentStatus + expectedErr string + }{ + { + name: "pending status", + transferStatus: TransferStatusPending, + expectedStatus: data.PendingPaymentStatus, + expectedErr: "", + }, + { + name: "complete status", + transferStatus: TransferStatusComplete, + expectedStatus: data.SuccessPaymentStatus, + expectedErr: "", + }, + { + name: "failed status", + transferStatus: TransferStatusFailed, + expectedStatus: data.FailedPaymentStatus, + expectedErr: "", + }, + { + name: "unknown status", + transferStatus: "wrong-status", + expectedStatus: "", + expectedErr: `unknown transfer status "wrong-status"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actualStatus, actualErr := tt.transferStatus.ToPaymentStatus() + + if tt.expectedErr != "" { + assert.ErrorContains(t, actualErr, tt.expectedErr) + } else { + assert.NoError(t, actualErr) + } + + assert.Equal(t, tt.expectedStatus, actualStatus) + }) + } +} diff --git a/internal/circle/wallet.go b/internal/circle/wallet.go new file mode 100644 index 000000000..7b2284d0f --- /dev/null +++ b/internal/circle/wallet.go @@ -0,0 +1,29 @@ +package circle + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type WalletResponse struct { + Data Wallet `json:"data"` +} + +type Wallet struct { + WalletID string `json:"walletId"` + EntityID string `json:"entityId"` + Type string `json:"type"` + Description string `json:"description"` + Balances []Balance `json:"balances"` +} + +// parseWalletResponse parses the response from the Circle API into a Wallet struct. +func parseWalletResponse(resp *http.Response) (*Wallet, error) { + var walletResponse WalletResponse + if err := json.NewDecoder(resp.Body).Decode(&walletResponse); err != nil { + return nil, fmt.Errorf("unmarshalling Circle HTTP response: %w", err) + } + + return &walletResponse.Data, nil +} diff --git a/internal/data/assets.go b/internal/data/assets.go index bdb06431a..723f80df5 100644 --- a/internal/data/assets.go +++ b/internal/data/assets.go @@ -10,8 +10,8 @@ import ( "time" "github.com/lib/pq" - "github.com/stellar/go/protocols/horizon/base" + "github.com/stellar/stellar-disbursement-platform-backend/db" ) diff --git a/internal/data/assets_test.go b/internal/data/assets_test.go index 53e99c92c..fd4b9fd07 100644 --- a/internal/data/assets_test.go +++ b/internal/data/assets_test.go @@ -7,11 +7,12 @@ import ( "time" "github.com/stellar/go/protocols/horizon/base" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_Asset_IsNative(t *testing.T) { diff --git a/internal/data/circle_transfer_requests.go b/internal/data/circle_transfer_requests.go new file mode 100644 index 000000000..6a8ea308e --- /dev/null +++ b/internal/data/circle_transfer_requests.go @@ -0,0 +1,286 @@ +package data + +import ( + "context" + "database/sql" + "errors" + "fmt" + "slices" + "time" + + "github.com/lib/pq" + + "github.com/stellar/stellar-disbursement-platform-backend/db" +) + +type CircleTransferRequest struct { + IdempotencyKey string `db:"idempotency_key"` + PaymentID string `db:"payment_id"` + CircleTransferID *string `db:"circle_transfer_id"` + Status *CircleTransferStatus `db:"status"` + ResponseBody []byte `db:"response_body"` + SourceWalletID *string `db:"source_wallet_id"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` + CompletedAt *time.Time `db:"completed_at"` + LastSyncAttemptAt *time.Time `db:"last_sync_attempt_at"` + SyncAttempts int `db:"sync_attempts"` +} + +type CircleTransferStatus string + +const ( + CircleTransferStatusPending CircleTransferStatus = "pending" + CircleTransferStatusSuccess CircleTransferStatus = "complete" // means success + CircleTransferStatusFailed CircleTransferStatus = "failed" +) + +func CompletedCircleStatuses() []CircleTransferStatus { + return []CircleTransferStatus{CircleTransferStatusSuccess, CircleTransferStatusFailed} +} + +func (s CircleTransferStatus) IsCompleted() bool { + return slices.Contains(CompletedCircleStatuses(), s) +} + +type CircleTransferRequestUpdate struct { + CircleTransferID string `db:"circle_transfer_id"` + Status CircleTransferStatus `db:"status"` + ResponseBody []byte `db:"response_body"` + SourceWalletID string `db:"source_wallet_id"` + CompletedAt *time.Time `db:"completed_at"` + LastSyncAttemptAt *time.Time `db:"last_sync_attempt_at"` + SyncAttempts int `db:"sync_attempts"` +} + +type CircleTransferRequestModel struct { + dbConnectionPool db.DBConnectionPool +} + +func (m CircleTransferRequestModel) GetOrInsert(ctx context.Context, paymentID string) (*CircleTransferRequest, error) { + if paymentID == "" { + return nil, fmt.Errorf("paymentID is required") + } + + return db.RunInTransactionWithResult(ctx, m.dbConnectionPool, nil, func(dbTx db.DBTransaction) (*CircleTransferRequest, error) { + // validate that the payment ID exists + var paymentIDExists bool + err := dbTx.GetContext(ctx, &paymentIDExists, "SELECT EXISTS(SELECT 1 FROM payments WHERE id = $1)", paymentID) + if err != nil { + return nil, fmt.Errorf("getting payment by ID: %w", err) + } + if !paymentIDExists { + return nil, fmt.Errorf("payment with ID %s does not exist: %w", paymentID, ErrRecordNotFound) + } + + circleTransferRequest, err := m.GetIncompleteByPaymentID(ctx, m.dbConnectionPool, paymentID) + if err != nil && !errors.Is(err, ErrRecordNotFound) { + return nil, fmt.Errorf("finding incomplete circle transfer by payment ID: %w", err) + } + + if circleTransferRequest != nil { + return circleTransferRequest, nil + } + + return m.Insert(ctx, paymentID) + }) +} + +func (m CircleTransferRequestModel) Insert(ctx context.Context, paymentID string) (*CircleTransferRequest, error) { + if paymentID == "" { + return nil, fmt.Errorf("paymentID is required") + } + + query := ` + INSERT INTO circle_transfer_requests + (payment_id) + VALUES + ($1) + RETURNING + * + ` + + var circleTransferRequest CircleTransferRequest + err := m.dbConnectionPool.GetContext(ctx, &circleTransferRequest, query, paymentID) + if err != nil { + return nil, fmt.Errorf("inserting circle transfer request: %w", err) + } + + return &circleTransferRequest, nil +} + +func (m CircleTransferRequestModel) GetIncompleteByPaymentID(ctx context.Context, sqlExec db.SQLExecuter, paymentID string) (*CircleTransferRequest, error) { + queryParams := QueryParams{ + Filters: map[FilterKey]interface{}{ + FilterKeyPaymentID: paymentID, + IsNull(FilterKeyCompletedAt): true, + }, + SortBy: "created_at", + SortOrder: SortOrderDESC, + } + return m.Get(ctx, m.dbConnectionPool, queryParams) +} + +const ( + maxSyncAttempts = 10 + batchSize = 10 +) + +// GetPendingReconciliation returns the pending Circle transfer requests that are in `pending` status and have not +// reached the maximum sync attempts. +func (m CircleTransferRequestModel) GetPendingReconciliation(ctx context.Context, sqlExec db.SQLExecuter) ([]*CircleTransferRequest, error) { + queryParams := QueryParams{ + Filters: map[FilterKey]interface{}{ + FilterKeyStatus: []CircleTransferStatus{CircleTransferStatusPending}, + LowerThan(FilterKeySyncAttempts): maxSyncAttempts, + }, + SortBy: "last_sync_attempt_at", + SortOrder: SortOrderASC, + Page: 1, + PageLimit: batchSize, + ForUpdateSkipLocked: true, + } + return m.GetAll(ctx, sqlExec, queryParams) +} + +const baseCircleQuery = ` + SELECT + * + FROM + circle_transfer_requests c +` + +func (m CircleTransferRequestModel) GetAll(ctx context.Context, sqlExec db.SQLExecuter, queryParams QueryParams) ([]*CircleTransferRequest, error) { + query, params := buildCircleTransferRequestQuery(baseCircleQuery, queryParams, sqlExec) + + var circleTransferRequests []*CircleTransferRequest + err := sqlExec.SelectContext(ctx, &circleTransferRequests, query, params...) + if err != nil { + return nil, fmt.Errorf("getting circle transfer requests: %w", err) + } + + return circleTransferRequests, nil +} + +func (m CircleTransferRequestModel) Get(ctx context.Context, sqlExec db.SQLExecuter, queryParams QueryParams) (*CircleTransferRequest, error) { + query, params := buildCircleTransferRequestQuery(baseCircleQuery, queryParams, sqlExec) + + var circleTransferRequests CircleTransferRequest + err := sqlExec.GetContext(ctx, &circleTransferRequests, query, params...) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrRecordNotFound + } + return nil, fmt.Errorf("getting circle transfer request: %w", err) + } + + return &circleTransferRequests, nil +} + +func (m CircleTransferRequestModel) GetCurrentTransfersForPaymentIDs(ctx context.Context, sqlExec db.SQLExecuter, paymentIDs []string) (map[string]*CircleTransferRequest, error) { + if len(paymentIDs) == 0 { + return nil, fmt.Errorf("paymentIDs is required") + } + + query := ` + SELECT DISTINCT ON (payment_id) + * + FROM + circle_transfer_requests + WHERE + payment_id = ANY($1) + ORDER BY + payment_id, created_at DESC; + ` + + var circleTransferRequests []*CircleTransferRequest + err := sqlExec.SelectContext(ctx, &circleTransferRequests, query, pq.Array(paymentIDs)) + if err != nil { + return nil, fmt.Errorf("getting circle transfer requests: %w", err) + } + + circleTransferRequestsByPaymentID := make(map[string]*CircleTransferRequest) + if len(circleTransferRequests) == 0 { + return circleTransferRequestsByPaymentID, nil + } + + for _, circleTransferRequest := range circleTransferRequests { + circleTransferRequestsByPaymentID[circleTransferRequest.PaymentID] = circleTransferRequest + } + + return circleTransferRequestsByPaymentID, nil +} + +func (m CircleTransferRequestModel) Update(ctx context.Context, sqlExec db.SQLExecuter, idempotencyKey string, update CircleTransferRequestUpdate) (*CircleTransferRequest, error) { + if idempotencyKey == "" { + return nil, fmt.Errorf("idempotencyKey is required") + } + + setClause, params := BuildSetClause(update) + if setClause == "" { + return nil, fmt.Errorf("no fields to update") + } + + query := fmt.Sprintf(` + UPDATE + circle_transfer_requests + SET + %s + WHERE + idempotency_key = ? + RETURNING + * + `, setClause) + params = append(params, idempotencyKey) + query = sqlExec.Rebind(query) + + var circleTransferRequest CircleTransferRequest + err := sqlExec.GetContext(ctx, &circleTransferRequest, query, params...) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("circle transfer request with idempotency key %s not found: %w", idempotencyKey, ErrRecordNotFound) + } + return nil, fmt.Errorf("updating circle transfer request: %w", err) + } + + return &circleTransferRequest, nil +} + +func buildCircleTransferRequestQuery(baseQuery string, queryParams QueryParams, sqlExec db.SQLExecuter) (string, []interface{}) { + qb := NewQueryBuilder(baseQuery) + + if queryParams.Filters[FilterKeyStatus] != nil { + if statusSlice, ok := queryParams.Filters[FilterKeyStatus].([]CircleTransferStatus); ok { + if len(statusSlice) > 0 { + qb.AddCondition("c.status = ANY(?)", pq.Array(statusSlice)) + } + } else { + qb.AddCondition("c.status = ?", queryParams.Filters[FilterKeyStatus]) + } + } + + if paymentID := queryParams.Filters[FilterKeyPaymentID]; paymentID != nil { + qb.AddCondition("c.payment_id = ?", paymentID) + } + + if queryParams.Filters[IsNull(FilterKeyCompletedAt)] != nil { + qb.AddCondition("c." + string(IsNull(FilterKeyCompletedAt))) + } + + if queryParams.Filters[LowerThan(FilterKeySyncAttempts)] != nil { + qb.AddCondition("c.sync_attempts < ?", queryParams.Filters[LowerThan(FilterKeySyncAttempts)]) + } + + if queryParams.SortBy != "" && queryParams.SortOrder != "" { + qb.AddSorting(queryParams.SortBy, queryParams.SortOrder, "c") + } + + if queryParams.PageLimit > 0 && queryParams.Page > 0 { + qb.AddPagination(queryParams.Page, queryParams.PageLimit) + } + + qb.forUpdateSkipLocked = queryParams.ForUpdateSkipLocked + + query, params := qb.Build() + return sqlExec.Rebind(query), params +} diff --git a/internal/data/circle_transfer_requests_test.go b/internal/data/circle_transfer_requests_test.go new file mode 100644 index 000000000..5067b6745 --- /dev/null +++ b/internal/data/circle_transfer_requests_test.go @@ -0,0 +1,686 @@ +package data + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/testutils" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" +) + +func Test_CircleTransferRequestModel_Insert(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + + m := CircleTransferRequestModel{dbConnectionPool: dbConnectionPool} + + t.Run("returns error if paymentID is empty", func(t *testing.T) { + circleEntry, err := m.Insert(ctx, "") + require.EqualError(t, err, "paymentID is required") + require.Nil(t, circleEntry) + }) + + t.Run("🎉 successfully inserts a circle transfer request", func(t *testing.T) { + paymentID := "payment-id" + circleEntry, err := m.Insert(ctx, paymentID) + require.NoError(t, err) + require.NotNil(t, circleEntry) + + assert.Equal(t, paymentID, circleEntry.PaymentID) + assert.NotEmpty(t, circleEntry.UpdatedAt) + assert.NotEmpty(t, circleEntry.CreatedAt) + assert.Nil(t, circleEntry.CompletedAt) + assert.NoError(t, uuid.Validate(circleEntry.IdempotencyKey), "idempotency key should be a valid UUID") + }) + + t.Run("database constraint that prevents repeated rows with the same paymentID and status!=failed", func(t *testing.T) { + paymentID := "payment-id-2" + circleEntry, err := m.Insert(ctx, paymentID) + require.NoError(t, err) + + _, err = m.Insert(ctx, paymentID) + require.Error(t, err) + require.ErrorContains(t, err, "duplicate key value violates unique constraint") + + // it works again when we update the status of the existing entry to failed + _, err = m.Update(ctx, dbConnectionPool, circleEntry.IdempotencyKey, CircleTransferRequestUpdate{ + Status: CircleTransferStatusFailed, + CircleTransferID: "circle-transfer-id", + ResponseBody: []byte(`{"foo":"bar"}`), + SourceWalletID: "source-wallet-id", + }) + require.NoError(t, err) + + _, err = m.Insert(ctx, paymentID) + require.NoError(t, err) + }) +} + +func Test_CircleTransferRequestModel_Update(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + m := CircleTransferRequestModel{dbConnectionPool: dbConnectionPool} + + updateRequest := CircleTransferRequestUpdate{ + CircleTransferID: "circle-transfer-id", + Status: CircleTransferStatusPending, + ResponseBody: []byte(`{"foo":"bar"}`), + SourceWalletID: "source-wallet-id", + } + + t.Run("return an error if the idempotencyKey is empty", func(t *testing.T) { + circleEntry, err := m.Update(ctx, dbConnectionPool, "", CircleTransferRequestUpdate{}) + require.Error(t, err) + require.ErrorContains(t, err, "idempotencyKey is required") + require.Nil(t, circleEntry) + }) + + t.Run("return an error if the circle transfer request does not exist", func(t *testing.T) { + circleEntry, err := m.Update(ctx, dbConnectionPool, "test-key", updateRequest) + require.Error(t, err) + require.ErrorContains(t, err, "circle transfer request with idempotency key test-key not found") + require.ErrorIs(t, err, ErrRecordNotFound) + require.Nil(t, circleEntry) + }) + + t.Run("🎉 successfully updates a circle transfer request (completedAt==nil)", func(t *testing.T) { + paymentID := "payment-id" + circleEntry, err := m.Insert(ctx, paymentID) + require.NoError(t, err) + + updatedCircleEntry, err := m.Update(ctx, dbConnectionPool, circleEntry.IdempotencyKey, updateRequest) + require.NoError(t, err) + require.NotNil(t, updatedCircleEntry) + + assert.Equal(t, circleEntry.IdempotencyKey, updatedCircleEntry.IdempotencyKey) + assert.Equal(t, updateRequest.CircleTransferID, *updatedCircleEntry.CircleTransferID) + assert.Equal(t, updateRequest.Status, *updatedCircleEntry.Status) + assert.JSONEq(t, string(updateRequest.ResponseBody), string(updatedCircleEntry.ResponseBody)) + assert.Equal(t, updateRequest.SourceWalletID, *updatedCircleEntry.SourceWalletID) + assert.NotEmpty(t, updatedCircleEntry.UpdatedAt) + assert.Nil(t, updatedCircleEntry.CompletedAt) + }) + + t.Run("🎉 successfully updates a circle transfer request(completedAt!=nil)", func(t *testing.T) { + paymentID := "payment-id2" + circleEntry, err := m.Insert(ctx, paymentID) + require.NoError(t, err) + + updateRequest2 := updateRequest + updateRequest2.CompletedAt = utils.TimePtr(time.Now()) + updatedCircleEntry, err := m.Update(ctx, dbConnectionPool, circleEntry.IdempotencyKey, updateRequest2) + require.NoError(t, err) + require.NotNil(t, updatedCircleEntry) + + assert.Equal(t, circleEntry.IdempotencyKey, updatedCircleEntry.IdempotencyKey) + assert.Equal(t, updateRequest2.CircleTransferID, *updatedCircleEntry.CircleTransferID) + assert.Equal(t, updateRequest2.Status, *updatedCircleEntry.Status) + assert.JSONEq(t, string(updateRequest2.ResponseBody), string(updatedCircleEntry.ResponseBody)) + assert.Equal(t, updateRequest2.SourceWalletID, *updatedCircleEntry.SourceWalletID) + assert.NotEmpty(t, updatedCircleEntry.UpdatedAt) + assert.NotNil(t, updatedCircleEntry.CompletedAt) + }) +} + +func Test_CircleTransferRequestModel_Get_and_GetAll(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + now := time.Now() + m := CircleTransferRequestModel{dbConnectionPool: dbConnectionPool} + + circleEntry1, outerErr := m.Insert(ctx, "payment-id-1") + require.NoError(t, outerErr) + circleEntry1, outerErr = m.Update(ctx, dbConnectionPool, circleEntry1.IdempotencyKey, CircleTransferRequestUpdate{ + CircleTransferID: "circle-transfer-id-1", + Status: CircleTransferStatusSuccess, + SyncAttempts: 10, + }) + require.NoError(t, outerErr) + circleEntry2, outerErr := m.Insert(ctx, "payment-id-2") + require.NoError(t, outerErr) + circleEntry2, outerErr = m.Update(ctx, dbConnectionPool, circleEntry2.IdempotencyKey, CircleTransferRequestUpdate{ + CircleTransferID: "circle-transfer-id-2", + Status: CircleTransferStatusFailed, + SyncAttempts: 1, + CompletedAt: &now, + }) + require.NoError(t, outerErr) + + t.Run("Get", func(t *testing.T) { + testCases := []struct { + name string + queryParams QueryParams + expectedCircleRequestID string + expectedErrContains string + }{ + { + name: "get by paymentID", + queryParams: QueryParams{Filters: map[FilterKey]interface{}{FilterKeyPaymentID: "payment-id-1"}}, + expectedCircleRequestID: circleEntry1.IdempotencyKey, + }, + { + name: "get by status", + queryParams: QueryParams{Filters: map[FilterKey]interface{}{FilterKeyStatus: CircleTransferStatusFailed}}, + expectedCircleRequestID: circleEntry2.IdempotencyKey, + }, + { + name: "get by completed_at IS NULL", + queryParams: QueryParams{Filters: map[FilterKey]interface{}{IsNull(FilterKeyCompletedAt): true}}, + expectedCircleRequestID: circleEntry1.IdempotencyKey, + }, + { + name: "get by sync_attempts < 10", + queryParams: QueryParams{Filters: map[FilterKey]interface{}{LowerThan(FilterKeySyncAttempts): 10}}, + expectedCircleRequestID: circleEntry2.IdempotencyKey, + }, + { + name: "return an error if the record is not found", + queryParams: QueryParams{Filters: map[FilterKey]interface{}{FilterKeyPaymentID: "payment-id-3"}}, + expectedErrContains: ErrRecordNotFound.Error(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + circleEntry, err := m.Get(ctx, dbConnectionPool, tc.queryParams) + if tc.expectedErrContains != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.expectedErrContains) + require.Nil(t, circleEntry) + } else { + require.NoError(t, err) + require.NotNil(t, circleEntry) + require.Equal(t, tc.expectedCircleRequestID, circleEntry.IdempotencyKey) + } + }) + } + }) + + t.Run("GetAll", func(t *testing.T) { + testCases := []struct { + name string + queryParams QueryParams + expectedCircleRequestIDs []string + expectedErrContains string + }{ + { + name: "get by paymentID", + queryParams: QueryParams{Filters: map[FilterKey]interface{}{FilterKeyPaymentID: "payment-id-1"}}, + expectedCircleRequestIDs: []string{circleEntry1.IdempotencyKey}, + }, + { + name: "get by status", + queryParams: QueryParams{Filters: map[FilterKey]interface{}{FilterKeyStatus: CircleTransferStatusFailed}}, + expectedCircleRequestIDs: []string{circleEntry2.IdempotencyKey}, + }, + { + name: "get by completed_at IS NULL", + queryParams: QueryParams{Filters: map[FilterKey]interface{}{IsNull(FilterKeyCompletedAt): true}}, + expectedCircleRequestIDs: []string{circleEntry1.IdempotencyKey}, + }, + { + name: "get by sync_attempts < 10", + queryParams: QueryParams{Filters: map[FilterKey]interface{}{LowerThan(FilterKeySyncAttempts): 10}}, + expectedCircleRequestIDs: []string{circleEntry2.IdempotencyKey}, + }, + { + name: "return empty if the record is not found", + queryParams: QueryParams{Filters: map[FilterKey]interface{}{FilterKeyPaymentID: "payment-id-3"}}, + expectedCircleRequestIDs: []string{}, + }, + { + name: "return an error if more than one record is not found", + queryParams: QueryParams{Filters: map[FilterKey]interface{}{FilterKeyStatus: []CircleTransferStatus{ + CircleTransferStatusSuccess, + CircleTransferStatusFailed, + }}}, + expectedCircleRequestIDs: []string{ + circleEntry1.IdempotencyKey, + circleEntry2.IdempotencyKey, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + circleEntries, err := m.GetAll(ctx, dbConnectionPool, tc.queryParams) + if tc.expectedErrContains != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.expectedErrContains) + require.Nil(t, circleEntries) + } else { + require.NoError(t, err) + require.Len(t, circleEntries, len(tc.expectedCircleRequestIDs)) + gotIDs := make([]string, len(circleEntries)) + for i, circleEntry := range circleEntries { + gotIDs[i] = circleEntry.IdempotencyKey + } + require.ElementsMatch(t, tc.expectedCircleRequestIDs, gotIDs) + } + }) + } + }) +} + +func Test_CircleTransferRequestModel_GetIncompleteByPaymentID(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + m := CircleTransferRequestModel{dbConnectionPool: dbConnectionPool} + + t.Run("return nil if no circle transfer request is found", func(t *testing.T) { + circleEntry, err := m.GetIncompleteByPaymentID(ctx, dbConnectionPool, "payment-id") + require.ErrorIs(t, err, ErrRecordNotFound) + require.Nil(t, circleEntry) + }) + + t.Run("return nil if the existing circle transfer is in completed_at state", func(t *testing.T) { + paymentID := "payment-id" + circleEntry, err := m.Insert(ctx, paymentID) + require.NoError(t, err) + + _, err = m.Update(ctx, dbConnectionPool, circleEntry.IdempotencyKey, CircleTransferRequestUpdate{ + CircleTransferID: "circle-transfer-id", + Status: CircleTransferStatusFailed, + ResponseBody: []byte(`{"foo":"bar"}`), + SourceWalletID: "source-wallet-id", + CompletedAt: utils.TimePtr(time.Now()), + }) + require.NoError(t, err) + + circleEntry, err = m.GetIncompleteByPaymentID(ctx, dbConnectionPool, paymentID) + require.ErrorIs(t, err, ErrRecordNotFound) + require.Nil(t, circleEntry) + }) + + t.Run("🎉 successfully finds an incomplete circle transfer request", func(t *testing.T) { + paymentID := "payment-id" + _, err := m.Insert(ctx, paymentID) + require.NoError(t, err) + + circleEntry, err := m.GetIncompleteByPaymentID(ctx, dbConnectionPool, paymentID) + require.NoError(t, err) + require.NotNil(t, circleEntry) + assert.Equal(t, paymentID, circleEntry.PaymentID) + }) +} + +func Test_CircleTransferRequestModel_GetOrInsert(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + m := CircleTransferRequestModel{dbConnectionPool: dbConnectionPool} + + // Create fixtures + models, err := NewModels(dbConnectionPool) + require.NoError(t, err) + asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") + country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") + wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") + disbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ + Country: country, + Wallet: wallet, + Status: ReadyDisbursementStatus, + Asset: asset, + }) + receiverReady := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) + rwReady := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverReady.ID, wallet.ID, ReadyReceiversWalletStatus) + payment1 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursement, + Asset: *asset, + Amount: "100", + Status: DraftPaymentStatus, + }) + payment2 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursement, + Asset: *asset, + Amount: "100", + Status: DraftPaymentStatus, + }) + + t.Run("return an error if paymentID is empty", func(t *testing.T) { + circleEntry, err := m.GetOrInsert(ctx, "") + require.Error(t, err) + require.ErrorContains(t, err, "paymentID is required") + require.Nil(t, circleEntry) + }) + + t.Run("🎉 successfully finds an existing circle transfer request", func(t *testing.T) { + insertedEntry, err := m.Insert(ctx, payment1.ID) + require.NoError(t, err) + + gotEntry, err := m.GetOrInsert(ctx, payment1.ID) + require.NoError(t, err) + require.NotNil(t, gotEntry) + assert.Equal(t, insertedEntry, gotEntry) + }) + + t.Run("🎉 successfully insert a new circle transfer request", func(t *testing.T) { + query := "SELECT COUNT(*) FROM circle_transfer_requests" + var count int + err := dbConnectionPool.GetContext(ctx, &count, query) + require.NoError(t, err) + require.Equal(t, 1, count) + + gotEntry, err := m.GetOrInsert(ctx, payment2.ID) + require.NoError(t, err) + require.NotNil(t, gotEntry) + assert.Equal(t, payment2.ID, gotEntry.PaymentID) + + err = dbConnectionPool.GetContext(ctx, &count, query) + require.NoError(t, err) + require.Equal(t, 2, count) // <- new row inserted + }) +} + +func Test_buildCircleTransferRequestQuery(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + baseQuery := "SELECT * FROM circle_transfer_requests c" + + testCases := []struct { + name string + queryParams QueryParams + expectedQuery string + expectedParams []interface{} + }{ + { + name: "build query without params", + queryParams: QueryParams{}, + expectedQuery: "SELECT * FROM circle_transfer_requests c", + expectedParams: []interface{}{}, + }, + { + name: "build query with status filter (value)", + queryParams: QueryParams{ + Filters: map[FilterKey]interface{}{ + FilterKeyStatus: "pending", + }, + }, + expectedQuery: "SELECT * FROM circle_transfer_requests c WHERE 1=1 AND c.status = $1", + expectedParams: []interface{}{"pending"}, + }, + { + name: "build query with status filter (slice)", + queryParams: QueryParams{ + Filters: map[FilterKey]interface{}{ + FilterKeyStatus: []CircleTransferStatus{CircleTransferStatusSuccess, CircleTransferStatusFailed}, + }, + }, + expectedQuery: "SELECT * FROM circle_transfer_requests c WHERE 1=1 AND c.status = ANY($1)", + expectedParams: []interface{}{pq.Array([]CircleTransferStatus{CircleTransferStatusSuccess, CircleTransferStatusFailed})}, + }, + { + name: "build query with payment_id filter", + queryParams: QueryParams{ + Filters: map[FilterKey]interface{}{ + FilterKeyPaymentID: "test-payment-id", + }, + }, + expectedQuery: "SELECT * FROM circle_transfer_requests c WHERE 1=1 AND c.payment_id = $1", + expectedParams: []interface{}{"test-payment-id"}, + }, + { + name: "build query with IsNull(completed_at) filter", + queryParams: QueryParams{ + Filters: map[FilterKey]interface{}{ + IsNull(FilterKeyCompletedAt): true, + }, + }, + expectedQuery: "SELECT * FROM circle_transfer_requests c WHERE 1=1 AND c.completed_at IS NULL", + expectedParams: []interface{}{}, + }, + { + name: "build query with LowerThan(sync_attempts) filter", + queryParams: QueryParams{ + Filters: map[FilterKey]interface{}{ + LowerThan(FilterKeySyncAttempts): 7, + }, + }, + expectedQuery: "SELECT * FROM circle_transfer_requests c WHERE 1=1 AND c.sync_attempts < $1", + expectedParams: []interface{}{7}, + }, + { + name: "build query with sort by", + queryParams: QueryParams{ + SortBy: "created_at", + SortOrder: "ASC", + }, + expectedQuery: "SELECT * FROM circle_transfer_requests c ORDER BY c.created_at ASC", + expectedParams: []interface{}{}, + }, + { + name: "build query with pagination", + queryParams: QueryParams{ + Page: 1, + PageLimit: 20, + }, + expectedQuery: "SELECT * FROM circle_transfer_requests c LIMIT $1 OFFSET $2", + expectedParams: []interface{}{20, 0}, + }, + { + name: "build query with FOR UPDATE SKIP LOCKED", + queryParams: QueryParams{ + ForUpdateSkipLocked: true, + }, + expectedQuery: "SELECT * FROM circle_transfer_requests c FOR UPDATE SKIP LOCKED", + expectedParams: []interface{}{}, + }, + { + name: "build query with all filters, and pagination, and FOR UPDATE SKIP LOCKED", + queryParams: QueryParams{ + Page: 1, + PageLimit: 20, + SortBy: "created_at", + SortOrder: "ASC", + Filters: map[FilterKey]interface{}{ + FilterKeyStatus: "pending", + FilterKeyPaymentID: "test-payment-id", + IsNull(FilterKeyCompletedAt): true, + LowerThan(FilterKeySyncAttempts): 7, + }, + ForUpdateSkipLocked: true, + }, + expectedQuery: "SELECT * FROM circle_transfer_requests c WHERE 1=1 AND c.status = $1 AND c.payment_id = $2 AND c.completed_at IS NULL AND c.sync_attempts < $3 ORDER BY c.created_at ASC LIMIT $4 OFFSET $5 FOR UPDATE SKIP LOCKED", + expectedParams: []interface{}{"pending", "test-payment-id", 7, 20, 0}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + query, params := buildCircleTransferRequestQuery(baseQuery, tc.queryParams, dbConnectionPool) + + assert.Equal(t, tc.expectedQuery, query) + assert.Equal(t, tc.expectedParams, params) + }) + } +} + +func Test_CircleTransferRequestModel_GetCurrentTransfersForPaymentIDs(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + m := CircleTransferRequestModel{dbConnectionPool: dbConnectionPool} + + // Create fixtures + models, outerErr := NewModels(dbConnectionPool) + require.NoError(t, outerErr) + asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") + country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") + wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") + disbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ + Country: country, + Wallet: wallet, + Status: ReadyDisbursementStatus, + Asset: asset, + }) + receiverReady := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) + rwReady := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverReady.ID, wallet.ID, ReadyReceiversWalletStatus) + payment1 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursement, + Asset: *asset, + Amount: "100", + Status: DraftPaymentStatus, + }) + payment2 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursement, + Asset: *asset, + Amount: "200", + Status: DraftPaymentStatus, + }) + payment3 := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursement, + Asset: *asset, + Amount: "300", + Status: DraftPaymentStatus, + }) + + testCases := []struct { + name string + paymentIDs []string + initFn func(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter) + expectedResult map[string]*CircleTransferRequest + expectedErr string + }{ + { + name: "return an error if paymentIDs is empty", + paymentIDs: []string{}, + expectedResult: nil, + expectedErr: "paymentIDs is required", + }, + { + name: "🎉 successfully finds circle current transfer request", + paymentIDs: []string{payment3.ID}, + initFn: func(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter) { + // insert a transfer for payment 3 + tr, err := m.Insert(ctx, payment3.ID) + require.NoError(t, err) + + _, err = m.Update(ctx, dbConnectionPool, tr.IdempotencyKey, CircleTransferRequestUpdate{ + CircleTransferID: "circle-transfer-id-3", + Status: CircleTransferStatusFailed, + }) + require.NoError(t, err) + + // insert another transfer for payment 3 + tr2, err := m.Insert(ctx, payment3.ID) + require.NoError(t, err) + + _, err = m.Update(ctx, sqlExec, tr2.IdempotencyKey, CircleTransferRequestUpdate{ + CircleTransferID: "circle-transfer-id-3-NEW", + Status: CircleTransferStatusSuccess, + }) + require.NoError(t, err) + }, + expectedResult: map[string]*CircleTransferRequest{ + payment3.ID: { + PaymentID: payment3.ID, + CircleTransferID: utils.StringPtr("circle-transfer-id-3-NEW"), + }, + }, + }, + + { + name: "🎉 successfully finds circle transfer requests for multiple payments", + paymentIDs: []string{payment1.ID, payment2.ID}, + initFn: func(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter) { + transfer1, err := m.Insert(ctx, payment1.ID) + require.NoError(t, err) + + _, err = m.Update(ctx, dbConnectionPool, transfer1.IdempotencyKey, CircleTransferRequestUpdate{ + CircleTransferID: "circle-transfer-id-1", + Status: CircleTransferStatusFailed, + }) + require.NoError(t, err) + + transfer2, err := m.Insert(ctx, payment2.ID) + require.NoError(t, err) + + _, err = m.Update(ctx, dbConnectionPool, transfer2.IdempotencyKey, CircleTransferRequestUpdate{ + CircleTransferID: "circle-transfer-id-2", + Status: CircleTransferStatusPending, + }) + require.NoError(t, err) + }, + expectedResult: map[string]*CircleTransferRequest{ + payment1.ID: { + PaymentID: payment1.ID, + CircleTransferID: utils.StringPtr("circle-transfer-id-1"), + }, + payment2.ID: { + PaymentID: payment2.ID, + CircleTransferID: utils.StringPtr("circle-transfer-id-2"), + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tx := testutils.BeginTxWithRollback(t, ctx, dbConnectionPool) + + if tc.initFn != nil { + tc.initFn(t, ctx, tx) + } + + result, err := m.GetCurrentTransfersForPaymentIDs(ctx, tx, tc.paymentIDs) + if tc.expectedErr != "" { + require.ErrorContains(t, err, tc.expectedErr) + require.Nil(t, result) + } else { + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, len(tc.expectedResult), len(result)) + for expectedPaymentID, expectedResult := range tc.expectedResult { + assert.NotNil(t, result[expectedPaymentID]) + assert.Equal(t, expectedResult.CircleTransferID, result[expectedPaymentID].CircleTransferID) + assert.Equal(t, expectedResult.PaymentID, result[expectedPaymentID].PaymentID) + } + } + }) + } +} diff --git a/internal/data/countries_test.go b/internal/data/countries_test.go index d9c3208c5..ad3a2ee8f 100644 --- a/internal/data/countries_test.go +++ b/internal/data/countries_test.go @@ -4,10 +4,11 @@ import ( "context" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) func Test_CountryModelGet(t *testing.T) { diff --git a/internal/data/disbursement_instructions_test.go b/internal/data/disbursement_instructions_test.go index a0a879f9e..84082614e 100644 --- a/internal/data/disbursement_instructions_test.go +++ b/internal/data/disbursement_instructions_test.go @@ -5,10 +5,11 @@ import ( "database/sql" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) func Test_DisbursementInstructionModel_ProcessAll(t *testing.T) { diff --git a/internal/data/disbursement_receivers_test.go b/internal/data/disbursement_receivers_test.go index ee3c830b5..9d964f914 100644 --- a/internal/data/disbursement_receivers_test.go +++ b/internal/data/disbursement_receivers_test.go @@ -4,9 +4,10 @@ import ( "context" "testing" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" - "github.com/stretchr/testify/require" ) func Test_DisbursementReceiverModel_Count(t *testing.T) { diff --git a/internal/data/disbursements.go b/internal/data/disbursements.go index 1964cae0e..8010d27f4 100644 --- a/internal/data/disbursements.go +++ b/internal/data/disbursements.go @@ -12,6 +12,7 @@ import ( "github.com/lib/pq" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/db" ) @@ -55,6 +56,7 @@ type VerificationField string const ( VerificationFieldDateOfBirth VerificationField = "DATE_OF_BIRTH" + VerificationFieldYearMonth VerificationField = "YEAR_MONTH" VerificationFieldPin VerificationField = "PIN" VerificationFieldNationalID VerificationField = "NATIONAL_ID_NUMBER" ) @@ -63,6 +65,7 @@ const ( func GetAllVerificationFields() []VerificationField { return []VerificationField{ VerificationFieldDateOfBirth, + VerificationFieldYearMonth, VerificationFieldPin, VerificationFieldNationalID, } diff --git a/internal/data/disbursements_test.go b/internal/data/disbursements_test.go index ca9620c0d..e764d12a8 100644 --- a/internal/data/disbursements_test.go +++ b/internal/data/disbursements_test.go @@ -5,10 +5,11 @@ import ( "testing" "time" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) func Test_DisbursementModelInsert(t *testing.T) { diff --git a/internal/data/fixtures.go b/internal/data/fixtures.go index 50eba6fb2..c0353bf80 100644 --- a/internal/data/fixtures.go +++ b/internal/data/fixtures.go @@ -363,6 +363,31 @@ func CreateReceiverVerificationFixture(t *testing.T, ctx context.Context, sqlExe return &verification } +func CreateCircleTransferRequestFixture(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter, insert CircleTransferRequest) *CircleTransferRequest { + const query = ` + INSERT INTO circle_transfer_requests + (payment_id, circle_transfer_id, status, source_wallet_id, completed_at, last_sync_attempt_at, sync_attempts) + VALUES + ($1, $2, $3, $4, $5, $6, $7) + RETURNING + * + ` + + var circleTransferRequest CircleTransferRequest + err := sqlExec.GetContext(ctx, &circleTransferRequest, query, + insert.PaymentID, + insert.CircleTransferID, + insert.Status, + insert.SourceWalletID, + insert.CompletedAt, + insert.LastSyncAttemptAt, + insert.SyncAttempts, + ) + require.NoError(t, err) + + return &circleTransferRequest +} + func DeleteAllReceiverVerificationFixtures(t *testing.T, ctx context.Context, sqlExec db.SQLExecuter) { const query = "DELETE FROM receiver_verifications" _, err := sqlExec.ExecContext(ctx, query) diff --git a/internal/data/fixtures_test.go b/internal/data/fixtures_test.go index 2e02a01c7..f04913adb 100644 --- a/internal/data/fixtures_test.go +++ b/internal/data/fixtures_test.go @@ -5,9 +5,10 @@ import ( "strings" "testing" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" - "github.com/stretchr/testify/require" ) func Test_CreateReceiverFixture(t *testing.T) { diff --git a/internal/data/messages.go b/internal/data/messages.go index 4c1a384ea..b45b4ab66 100644 --- a/internal/data/messages.go +++ b/internal/data/messages.go @@ -9,6 +9,7 @@ import ( "time" "github.com/lib/pq" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" ) diff --git a/internal/data/messages_test.go b/internal/data/messages_test.go index edd6fc33b..44f003215 100644 --- a/internal/data/messages_test.go +++ b/internal/data/messages_test.go @@ -5,11 +5,12 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_MessageModel_Insert(t *testing.T) { diff --git a/internal/data/models.go b/internal/data/models.go index b51120de8..939d41c08 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -26,6 +26,7 @@ type Models struct { ReceiverWallet *ReceiverWalletModel DisbursementReceivers *DisbursementReceiverModel Message *MessageModel + CircleTransferRequests *CircleTransferRequestModel DBConnectionPool db.DBConnectionPool } @@ -46,6 +47,7 @@ func NewModels(dbConnectionPool db.DBConnectionPool) (*Models, error) { ReceiverWallet: &ReceiverWalletModel{dbConnectionPool: dbConnectionPool}, DisbursementReceivers: &DisbursementReceiverModel{dbConnectionPool: dbConnectionPool}, Message: &MessageModel{dbConnectionPool: dbConnectionPool}, + CircleTransferRequests: &CircleTransferRequestModel{dbConnectionPool: dbConnectionPool}, DBConnectionPool: dbConnectionPool, }, nil } diff --git a/internal/data/models_test.go b/internal/data/models_test.go index f5570771c..9554d5582 100644 --- a/internal/data/models_test.go +++ b/internal/data/models_test.go @@ -3,9 +3,10 @@ package data import ( "testing" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" - "github.com/stretchr/testify/require" ) func Test_NewModels(t *testing.T) { diff --git a/internal/data/organizations_test.go b/internal/data/organizations_test.go index c628ecab3..b6c41f6be 100644 --- a/internal/data/organizations_test.go +++ b/internal/data/organizations_test.go @@ -9,10 +9,11 @@ import ( "image/png" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) func Test_Organizations_DatabaseTriggers(t *testing.T) { diff --git a/internal/data/payments.go b/internal/data/payments.go index d0a3e61f6..fbdd34640 100644 --- a/internal/data/payments.go +++ b/internal/data/payments.go @@ -10,9 +10,9 @@ import ( "strings" "time" + "github.com/lib/pq" "github.com/stellar/go/support/log" - "github.com/lib/pq" "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) @@ -22,15 +22,16 @@ type Payment struct { Amount string `json:"amount" db:"amount"` StellarTransactionID string `json:"stellar_transaction_id" db:"stellar_transaction_id"` // TODO: evaluate if we will keep or remove StellarOperationID - StellarOperationID string `json:"stellar_operation_id" db:"stellar_operation_id"` - Status PaymentStatus `json:"status" db:"status"` - StatusHistory PaymentStatusHistory `json:"status_history,omitempty" db:"status_history"` - Disbursement *Disbursement `json:"disbursement,omitempty" db:"disbursement"` - Asset Asset `json:"asset"` - ReceiverWallet *ReceiverWallet `json:"receiver_wallet,omitempty" db:"receiver_wallet"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - ExternalPaymentID string `json:"external_payment_id,omitempty" db:"external_payment_id"` + StellarOperationID string `json:"stellar_operation_id" db:"stellar_operation_id"` + Status PaymentStatus `json:"status" db:"status"` + StatusHistory PaymentStatusHistory `json:"status_history,omitempty" db:"status_history"` + Disbursement *Disbursement `json:"disbursement,omitempty" db:"disbursement"` + Asset Asset `json:"asset"` + ReceiverWallet *ReceiverWallet `json:"receiver_wallet,omitempty" db:"receiver_wallet"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ExternalPaymentID string `json:"external_payment_id,omitempty" db:"external_payment_id"` + CircleTransferRequestID *string `json:"circle_transfer_request_id,omitempty"` } type PaymentStatusHistoryEntry struct { @@ -139,12 +140,52 @@ func (p *PaymentUpdate) Validate() error { return nil } +const basePaymentQuery = ` +SELECT + p.id, + p.amount, + COALESCE(p.stellar_transaction_id, '') as stellar_transaction_id, + COALESCE(p.stellar_operation_id, '') as stellar_operation_id, + p.status, + p.status_history, + p.created_at, + p.updated_at, + COALESCE(p.external_payment_id, '') as external_payment_id, + d.id as "disbursement.id", + d.name as "disbursement.name", + d.status as "disbursement.status", + d.created_at as "disbursement.created_at", + d.updated_at as "disbursement.updated_at", + a.id as "asset.id", + a.code as "asset.code", + a.issuer as "asset.issuer", + rw.id as "receiver_wallet.id", + COALESCE(rw.stellar_address, '') as "receiver_wallet.stellar_address", + COALESCE(rw.stellar_memo, '') as "receiver_wallet.stellar_memo", + COALESCE(rw.stellar_memo_type, '') as "receiver_wallet.stellar_memo_type", + rw.status as "receiver_wallet.status", + rw.created_at as "receiver_wallet.created_at", + rw.updated_at as "receiver_wallet.updated_at", + rw.receiver_id as "receiver_wallet.receiver.id", + COALESCE(rw.anchor_platform_transaction_id, '') AS "receiver_wallet.anchor_platform_transaction_id", + rw.anchor_platform_transaction_synced_at as "receiver_wallet.anchor_platform_transaction_synced_at", + w.id as "receiver_wallet.wallet.id", + w.name as "receiver_wallet.wallet.name", + w.enabled as "receiver_wallet.wallet.enabled" +FROM + payments p +JOIN disbursements d ON p.disbursement_id = d.id +JOIN assets a ON p.asset_id = a.id +JOIN wallets w on d.wallet_id = w.id +JOIN receiver_wallets rw on rw.receiver_id = p.receiver_id AND rw.wallet_id = w.id +` + func (p *PaymentModel) GetAllReadyToPatchCompletionAnchorTransactions(ctx context.Context, sqlExec db.SQLExecuter) ([]Payment, error) { const query = ` SELECT p.id, p.amount, - p.stellar_transaction_id, + COALESCE(p.stellar_transaction_id, '') as "stellar_transaction_id", p.status, p.status_history, p.updated_at, @@ -154,7 +195,7 @@ func (p *PaymentModel) GetAllReadyToPatchCompletionAnchorTransactions(ctx contex rw.id AS "receiver_wallet.id", COALESCE(rw.stellar_memo, '') AS "receiver_wallet.stellar_memo", COALESCE(rw.stellar_memo_type, '') AS "receiver_wallet.stellar_memo_type", - rw.anchor_platform_transaction_id AS "receiver_wallet.anchor_platform_transaction_id", + COALESCE(rw.anchor_platform_transaction_id, '') AS "receiver_wallet.anchor_platform_transaction_id", rw.anchor_platform_transaction_synced_at AS "receiver_wallet.anchor_platform_transaction_synced_at" FROM payments p @@ -165,6 +206,7 @@ func (p *PaymentModel) GetAllReadyToPatchCompletionAnchorTransactions(ctx contex WHERE p.status = ANY($1) -- ARRAY['SUCCESS', 'FAILURE']::payment_status[] AND rw.status = $2 -- 'REGISTERED'::receiver_wallet_status + AND rw.anchor_platform_transaction_id IS NOT NULL AND rw.anchor_platform_transaction_synced_at IS NULL ORDER BY p.created_at @@ -183,46 +225,7 @@ func (p *PaymentModel) GetAllReadyToPatchCompletionAnchorTransactions(ctx contex func (p *PaymentModel) Get(ctx context.Context, id string, sqlExec db.SQLExecuter) (*Payment, error) { payment := Payment{} - query := ` - SELECT - p.id, - p.amount, - COALESCE(p.stellar_transaction_id, '') as stellar_transaction_id, - COALESCE(p.stellar_operation_id, '') as stellar_operation_id, - p.status, - p.status_history, - p.created_at, - p.updated_at, - COALESCE(p.external_payment_id, '') as external_payment_id, - d.id as "disbursement.id", - d.name as "disbursement.name", - d.status as "disbursement.status", - d.created_at as "disbursement.created_at", - d.updated_at as "disbursement.updated_at", - a.id as "asset.id", - a.code as "asset.code", - a.issuer as "asset.issuer", - rw.id as "receiver_wallet.id", - COALESCE(rw.stellar_address, '') as "receiver_wallet.stellar_address", - COALESCE(rw.stellar_memo, '') as "receiver_wallet.stellar_memo", - COALESCE(rw.stellar_memo_type, '') as "receiver_wallet.stellar_memo_type", - rw.status as "receiver_wallet.status", - rw.created_at as "receiver_wallet.created_at", - rw.updated_at as "receiver_wallet.updated_at", - rw.receiver_id as "receiver_wallet.receiver.id", - COALESCE(rw.anchor_platform_transaction_id, '') as "receiver_wallet.anchor_platform_transaction_id", - rw.anchor_platform_transaction_synced_at as "receiver_wallet.anchor_platform_transaction_synced_at", - w.id as "receiver_wallet.wallet.id", - w.name as "receiver_wallet.wallet.name", - w.enabled as "receiver_wallet.wallet.enabled" - FROM - payments p - JOIN disbursements d ON p.disbursement_id = d.id - JOIN assets a ON p.asset_id = a.id - JOIN receiver_wallets rw ON rw.receiver_id = p.receiver_id AND rw.wallet_id = d.wallet_id - JOIN wallets w ON rw.wallet_id = w.id - WHERE p.id = $1 - ` + query := fmt.Sprintf(`%s WHERE p.id = $1`, basePaymentQuery) err := sqlExec.GetContext(ctx, &payment, query, id) if err != nil { @@ -309,47 +312,7 @@ func (p *PaymentModel) Count(ctx context.Context, queryParams *QueryParams, sqlE func (p *PaymentModel) GetAll(ctx context.Context, queryParams *QueryParams, sqlExec db.SQLExecuter) ([]Payment, error) { payments := []Payment{} - query := ` - SELECT - p.id, - p.amount, - COALESCE(p.stellar_transaction_id, '') as stellar_transaction_id, - COALESCE(p.stellar_operation_id, '') as stellar_operation_id, - p.status, - p.status_history, - p.created_at, - p.updated_at, - COALESCE(p.external_payment_id, '') as external_payment_id, - d.id as "disbursement.id", - d.name as "disbursement.name", - d.status as "disbursement.status", - d.created_at as "disbursement.created_at", - d.updated_at as "disbursement.updated_at", - a.id as "asset.id", - a.code as "asset.code", - a.issuer as "asset.issuer", - rw.id as "receiver_wallet.id", - COALESCE(rw.stellar_address, '') as "receiver_wallet.stellar_address", - COALESCE(rw.stellar_memo, '') as "receiver_wallet.stellar_memo", - COALESCE(rw.stellar_memo_type, '') as "receiver_wallet.stellar_memo_type", - rw.status as "receiver_wallet.status", - rw.created_at as "receiver_wallet.created_at", - rw.updated_at as "receiver_wallet.updated_at", - rw.receiver_id as "receiver_wallet.receiver.id", - COALESCE(rw.anchor_platform_transaction_id, '') as "receiver_wallet.anchor_platform_transaction_id", - rw.anchor_platform_transaction_synced_at as "receiver_wallet.anchor_platform_transaction_synced_at", - w.id as "receiver_wallet.wallet.id", - w.name as "receiver_wallet.wallet.name", - w.enabled as "receiver_wallet.wallet.enabled" - FROM - payments p - JOIN disbursements d on p.disbursement_id = d.id - JOIN assets a on p.asset_id = a.id - JOIN wallets w on d.wallet_id = w.id - JOIN receiver_wallets rw on rw.receiver_id = p.receiver_id AND rw.wallet_id = w.id - ` - - query, params := newPaymentQuery(query, queryParams, true, sqlExec) + query, params := newPaymentQuery(basePaymentQuery, queryParams, true, sqlExec) err := sqlExec.SelectContext(ctx, &payments, query, params...) if err != nil { @@ -761,3 +724,55 @@ func (p *PaymentModel) CancelPaymentsWithinPeriodDays(ctx context.Context, sqlEx return nil } + +// UpdateStatus updates the status of a payment. +func (p *PaymentModel) UpdateStatus( + ctx context.Context, + sqlExec db.SQLExecuter, + paymentID string, + status PaymentStatus, + statusMsg *string, + stellarTransactionHash string, +) error { + if paymentID == "" { + return fmt.Errorf("paymentID is required") + } + + err := status.Validate() + if err != nil { + return fmt.Errorf("status is invalid: %w", err) + } + + args := []interface{}{status, statusMsg, paymentID} + query := ` + UPDATE + payments + SET + status = $1::payment_status, + status_history = array_append(status_history, create_payment_status_history(NOW(), $1, $2)) + %s + WHERE + id = $3 + ` + var optionalQuerySet string + if stellarTransactionHash != "" { + args = append(args, stellarTransactionHash) + optionalQuerySet = fmt.Sprintf(", stellar_transaction_id = $%d", len(args)) + } + query = fmt.Sprintf(query, optionalQuerySet) + + result, err := sqlExec.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("marking payment as %s: %w", status, err) + } + + numRowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("getting number of rows affected: %w", err) + } + if numRowsAffected == 0 { + return fmt.Errorf("payment with ID %s was not found: %w", paymentID, ErrRecordNotFound) + } + + return nil +} diff --git a/internal/data/payments_state_machine.go b/internal/data/payments_state_machine.go index 49137f9af..9fa47732a 100644 --- a/internal/data/payments_state_machine.go +++ b/internal/data/payments_state_machine.go @@ -47,6 +47,7 @@ func PaymentStateMachineWithInitialState(initialState PaymentStatus) *StateMachi {From: ReadyPaymentStatus.State(), To: PendingPaymentStatus.State()}, // payment gets submitted if user is ready {From: ReadyPaymentStatus.State(), To: PausedPaymentStatus.State()}, // payment paused (when disbursement paused) {From: ReadyPaymentStatus.State(), To: CanceledPaymentStatus.State()}, // automatic cancellation of ready payments + {From: ReadyPaymentStatus.State(), To: FailedPaymentStatus.State()}, // payment fails before it's submitted {From: PausedPaymentStatus.State(), To: ReadyPaymentStatus.State()}, // payment resumed (when disbursement resumed) {From: PendingPaymentStatus.State(), To: FailedPaymentStatus.State()}, // payment fails {From: FailedPaymentStatus.State(), To: PendingPaymentStatus.State()}, // payment retried diff --git a/internal/data/payments_state_machine_test.go b/internal/data/payments_state_machine_test.go index f74abe7de..ce4cb04bf 100644 --- a/internal/data/payments_state_machine_test.go +++ b/internal/data/payments_state_machine_test.go @@ -41,7 +41,7 @@ func Test_PaymentStatus_SourceStatuses(t *testing.T) { { name: "Failure", targetStatus: FailedPaymentStatus, - expectedSourceStatuses: []PaymentStatus{PendingPaymentStatus}, + expectedSourceStatuses: []PaymentStatus{ReadyPaymentStatus, PendingPaymentStatus}, }, { name: "Canceled", diff --git a/internal/data/payments_test.go b/internal/data/payments_test.go index 55d8bf511..dc4200aba 100644 --- a/internal/data/payments_test.go +++ b/internal/data/payments_test.go @@ -7,11 +7,12 @@ import ( "github.com/lib/pq" "github.com/stellar/go/support/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_PaymentsModelGet(t *testing.T) { @@ -1830,3 +1831,78 @@ func Test_PaymentModel_GetBatchForUpdate(t *testing.T) { assert.EqualError(t, err, "pq: canceling statement due to lock timeout") }) } + +func Test_PaymentModel_UpdateStatus(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + + models, outerErr := NewModels(dbConnectionPool) + require.NoError(t, outerErr) + + t.Run("return an error if paymentID is empty", func(t *testing.T) { + err := models.Payment.UpdateStatus(ctx, dbConnectionPool, "", SuccessPaymentStatus, nil, "") + assert.ErrorContains(t, err, "paymentID is required") + }) + + t.Run("return an error if status is invalid", func(t *testing.T) { + err := models.Payment.UpdateStatus(ctx, dbConnectionPool, "payment-id", PaymentStatus("INVALID"), nil, "") + assert.ErrorContains(t, err, "status is invalid") + }) + + t.Run("return an error if payment doesn't exist", func(t *testing.T) { + err := models.Payment.UpdateStatus(ctx, dbConnectionPool, "payment-id", SuccessPaymentStatus, nil, "") + assert.ErrorContains(t, err, "payment with ID payment-id was not found") + assert.ErrorIs(t, err, ErrRecordNotFound) + }) + + t.Run("🎉 successfully updates status", func(t *testing.T) { + // Create fixtures + models, err := NewModels(dbConnectionPool) + require.NoError(t, err) + asset := CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") + country := CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") + wallet := CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") + disbursement := CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &Disbursement{ + Country: country, + Wallet: wallet, + Status: ReadyDisbursementStatus, + Asset: asset, + }) + receiverReady := CreateReceiverFixture(t, ctx, dbConnectionPool, &Receiver{}) + rwReady := CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverReady.ID, wallet.ID, ReadyReceiversWalletStatus) + payment := CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursement, + Asset: *asset, + Amount: "100", + Status: DraftPaymentStatus, + }) + + // 1. Update status WITHOUT Stellar trabnsaction ID + statusMsg := "transfer is in CIRCLE" + err = models.Payment.UpdateStatus(ctx, dbConnectionPool, payment.ID, PendingPaymentStatus, &statusMsg, "") + require.NoError(t, err) + + paymentDB, err := models.Payment.Get(ctx, payment.ID, dbConnectionPool) + require.NoError(t, err) + assert.Equal(t, PendingPaymentStatus, paymentDB.Status) + assert.Equal(t, len(payment.StatusHistory)+1, len(paymentDB.StatusHistory), "a new status history should have been created") + assert.Empty(t, paymentDB.StellarTransactionID) + + // 2. Update status WITH Stellar transaction ID + stellarTransactionID := "stellar-transaction-id" + err = models.Payment.UpdateStatus(ctx, dbConnectionPool, payment.ID, SuccessPaymentStatus, &statusMsg, stellarTransactionID) + require.NoError(t, err) + + paymentDB, err = models.Payment.Get(ctx, payment.ID, dbConnectionPool) + require.NoError(t, err) + assert.Equal(t, SuccessPaymentStatus, paymentDB.Status) + assert.Equal(t, len(payment.StatusHistory)+2, len(paymentDB.StatusHistory), "a new status history should have been created") + assert.Equal(t, stellarTransactionID, paymentDB.StellarTransactionID) + }) +} diff --git a/internal/data/query_builder.go b/internal/data/query_builder.go index c20ffc160..53f6d7f4f 100644 --- a/internal/data/query_builder.go +++ b/internal/data/query_builder.go @@ -2,16 +2,19 @@ package data import ( "fmt" + "reflect" + "strings" ) // QueryBuilder is a helper struct for building SQL queries type QueryBuilder struct { - baseQuery string - whereClause string - whereParams []interface{} - sortClause string - paginationClause string - paginationParams []interface{} + baseQuery string + whereClause string + whereParams []interface{} + sortClause string + paginationClause string + paginationParams []interface{} + forUpdateSkipLocked bool } // NewQueryBuilder creates a new QueryBuilder @@ -79,5 +82,49 @@ func (qb *QueryBuilder) Build() (string, []interface{}) { query = fmt.Sprintf("%s %s", query, qb.paginationClause) params = append(params, qb.paginationParams...) } + if qb.forUpdateSkipLocked { + query = fmt.Sprintf("%s FOR UPDATE SKIP LOCKED", query) + } return query, params } + +// BuildSetClause builds a SET clause for an UPDATE query based on the provided struct and its "db" tags. For instance, +// given the following struct: +// +// type User struct { +// ID int64 `db:"id"` +// Name string `db:"name"` +// } +// +// The function will return the following string and slice when called with an instance of `User{ID: 1, Name: "John"}`: +// "id = ?, name = ?", []interface{}{1, "John"} +func BuildSetClause(u interface{}) (string, []interface{}) { + v := reflect.ValueOf(u) + t := reflect.TypeOf(u) + + // Check if the provided argument is a struct + if t.Kind() != reflect.Struct { + return "", nil + } + + var setClauses []string + var params []interface{} + + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + fieldType := t.Field(i) + dbTag := fieldType.Tag.Get("db") + dbTag = strings.Split(dbTag, ",")[0] + if dbTag == "" { + continue + } + + // Check if the field is not zero-value + if !field.IsZero() { + setClauses = append(setClauses, fmt.Sprintf("%s = ?", dbTag)) + params = append(params, field.Interface()) + } + } + + return strings.Join(setClauses, ", "), params +} diff --git a/internal/data/query_builder_test.go b/internal/data/query_builder_test.go index 43125de0d..78e6042aa 100644 --- a/internal/data/query_builder_test.go +++ b/internal/data/query_builder_test.go @@ -88,3 +88,56 @@ func Test_QueryBuilder(t *testing.T) { assert.Equal(t, []interface{}{"Disbursement 1", 20, 20}, params) }) } + +func Test_BuildSetClause(t *testing.T) { + testCases := []struct { + name string + input interface{} + expectedQuery string + expectedArgs []interface{} + }{ + { + name: "non-struct generates empty output", + input: "non-struct", + }, + { + name: "struct without the \"db\" tag generates empty output", + input: struct{ Name string }{Name: "John"}, + }, + { + name: "struct without \"db\" tag generates the expected output, only included non-empty fields", + input: struct { + Name string `db:"name"` + LastName string `db:"last_name"` + }{Name: "John"}, + expectedQuery: "name = ?", + expectedArgs: []interface{}{"John"}, + }, + { + name: "struct without \"db\" tag generates the expected output, only included non-empty fields", + input: struct { + Name string `db:"name"` + LastName string `db:"last_name"` + }{Name: "John"}, + expectedQuery: "name = ?", + expectedArgs: []interface{}{"John"}, + }, + { + name: "struct without \"db,qualifier\" tag generates the expected output, not including the qualifier", + input: struct { + Name string `db:"name,qualifier"` + LastName string `db:"last_name,omitempty"` + }{Name: "John", LastName: "Doe"}, + expectedQuery: "name = ?, last_name = ?", + expectedArgs: []interface{}{"John", "Doe"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + query, args := BuildSetClause(tc.input) + assert.Equal(t, tc.expectedQuery, query) + assert.Equal(t, tc.expectedArgs, args) + }) + } +} diff --git a/internal/data/query_params.go b/internal/data/query_params.go index bb91a9403..6f56fba23 100644 --- a/internal/data/query_params.go +++ b/internal/data/query_params.go @@ -1,12 +1,15 @@ package data +import "fmt" + type QueryParams struct { - Query string - Page int - PageLimit int - SortBy SortField - SortOrder SortOrder - Filters map[FilterKey]interface{} + Query string + Page int + PageLimit int + SortBy SortField + SortOrder SortOrder + Filters map[FilterKey]interface{} + ForUpdateSkipLocked bool } type SortOrder string @@ -31,6 +34,27 @@ type FilterKey string const ( FilterKeyStatus FilterKey = "status" FilterKeyReceiverID FilterKey = "receiver_id" + FilterKeyPaymentID FilterKey = "payment_id" + FilterKeyCompletedAt FilterKey = "completed_at" FilterKeyCreatedAtAfter FilterKey = "created_at_after" FilterKeyCreatedAtBefore FilterKey = "created_at_before" + FilterKeySyncAttempts FilterKey = "sync_attempts" ) + +func (fk FilterKey) Equals() string { + return fmt.Sprintf("%s = ?", fk) +} + +func (fk FilterKey) LowerThan() string { + return fmt.Sprintf("%s < ?", fk) +} + +// IsNull returns `{filterKey} IS NULL`. +func IsNull(filterKey FilterKey) FilterKey { + return FilterKey(fmt.Sprintf("%s IS NULL", filterKey)) +} + +// LowerThan returns `{filterKey} < ?`. +func LowerThan(filterKey FilterKey) FilterKey { + return FilterKey(fmt.Sprintf("%s < ?", filterKey)) +} diff --git a/internal/data/receiver_verification_test.go b/internal/data/receiver_verification_test.go index cb1dcaba1..86b98648c 100644 --- a/internal/data/receiver_verification_test.go +++ b/internal/data/receiver_verification_test.go @@ -6,10 +6,11 @@ import ( "testing" "time" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) func Test_ReceiverVerificationModel_GetByReceiverIDsAndVerificationField(t *testing.T) { @@ -85,11 +86,16 @@ func Test_ReceiverVerificationModel_GetAllByReceiverId(t *testing.T) { VerificationValue: "1990-01-01", }) verification2 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ + ReceiverID: receiver.ID, + VerificationField: VerificationFieldYearMonth, + VerificationValue: "1990-01", + }) + verification3 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver.ID, VerificationField: VerificationFieldPin, VerificationValue: "1234", }) - verification3 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ + verification4 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver.ID, VerificationField: VerificationFieldNationalID, VerificationValue: "5678", @@ -98,7 +104,7 @@ func Test_ReceiverVerificationModel_GetAllByReceiverId(t *testing.T) { receiverVerificationModel := ReceiverVerificationModel{} actualVerifications, err := receiverVerificationModel.GetAllByReceiverId(ctx, dbConnectionPool, receiver.ID) require.NoError(t, err) - assert.Len(t, actualVerifications, 3) + assert.Len(t, actualVerifications, 4) assert.Equal(t, []ReceiverVerification{ { @@ -110,18 +116,25 @@ func Test_ReceiverVerificationModel_GetAllByReceiverId(t *testing.T) { }, { ReceiverID: receiver.ID, - VerificationField: VerificationFieldPin, + VerificationField: VerificationFieldYearMonth, HashedValue: verification2.HashedValue, CreatedAt: verification2.CreatedAt, UpdatedAt: verification2.UpdatedAt, }, { ReceiverID: receiver.ID, - VerificationField: VerificationFieldNationalID, + VerificationField: VerificationFieldPin, HashedValue: verification3.HashedValue, CreatedAt: verification3.CreatedAt, UpdatedAt: verification3.UpdatedAt, }, + { + ReceiverID: receiver.ID, + VerificationField: VerificationFieldNationalID, + HashedValue: verification4.HashedValue, + CreatedAt: verification4.CreatedAt, + UpdatedAt: verification4.UpdatedAt, + }, }, actualVerifications) }) } @@ -157,12 +170,19 @@ func Test_ReceiverVerificationModel_GetReceiverVerificationByReceiverId(t *testi verification2 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver.ID, - VerificationField: VerificationFieldPin, - VerificationValue: "1234", + VerificationField: VerificationFieldYearMonth, + VerificationValue: "1990-01", }) verification2.UpdatedAt = earlierTime verification3 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ + ReceiverID: receiver.ID, + VerificationField: VerificationFieldPin, + VerificationValue: "1234", + }) + verification3.UpdatedAt = earlierTime + + verification4 := CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, ReceiverVerificationInsert{ ReceiverID: receiver.ID, VerificationField: VerificationFieldNationalID, VerificationValue: "5678", @@ -176,9 +196,9 @@ func Test_ReceiverVerificationModel_GetReceiverVerificationByReceiverId(t *testi ReceiverVerification{ ReceiverID: receiver.ID, VerificationField: VerificationFieldNationalID, - HashedValue: verification3.HashedValue, - CreatedAt: verification3.CreatedAt, - UpdatedAt: verification3.UpdatedAt, + HashedValue: verification4.HashedValue, + CreatedAt: verification4.CreatedAt, + UpdatedAt: verification4.UpdatedAt, }, *actualVerification) }) } diff --git a/internal/data/receivers.go b/internal/data/receivers.go index c87965c5e..9beb4a3ac 100644 --- a/internal/data/receivers.go +++ b/internal/data/receivers.go @@ -426,6 +426,7 @@ func (r *ReceiverModel) DeleteByPhoneNumber(ctx context.Context, dbConnectionPoo queries := []QueryWithParams{ {"DELETE FROM messages WHERE receiver_id = $1", []interface{}{receiverID}}, {"DELETE FROM receiver_verifications WHERE receiver_id = $1", []interface{}{receiverID}}, + {"DELETE FROM circle_transfer_requests WHERE payment_id IN (SELECT id FROM payments WHERE receiver_id = $1)", []interface{}{receiverID}}, {"DELETE FROM payments WHERE receiver_id = $1", []interface{}{receiverID}}, {"DELETE FROM receiver_wallets WHERE receiver_id = $1", []interface{}{receiverID}}, {"DELETE FROM receivers WHERE id = $1", []interface{}{receiverID}}, diff --git a/internal/data/receivers_test.go b/internal/data/receivers_test.go index 3d3788559..9467807c2 100644 --- a/internal/data/receivers_test.go +++ b/internal/data/receivers_test.go @@ -8,12 +8,13 @@ import ( "time" "github.com/lib/pq" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_ReceiversModelGet(t *testing.T) { diff --git a/internal/data/receivers_wallet.go b/internal/data/receivers_wallet.go index 9c4299ac4..555e699f2 100644 --- a/internal/data/receivers_wallet.go +++ b/internal/data/receivers_wallet.go @@ -10,9 +10,9 @@ import ( "time" "github.com/lib/pq" - "github.com/stellar/go/network" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/db" ) diff --git a/internal/data/receivers_wallet_test.go b/internal/data/receivers_wallet_test.go index b40f8d819..ba229a8a6 100644 --- a/internal/data/receivers_wallet_test.go +++ b/internal/data/receivers_wallet_test.go @@ -7,12 +7,13 @@ import ( "time" "github.com/stellar/go/network" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_ReceiversWalletModelGetWithReceiverId(t *testing.T) { diff --git a/internal/data/wallets.go b/internal/data/wallets.go index e05a656a2..002a04653 100644 --- a/internal/data/wallets.go +++ b/internal/data/wallets.go @@ -9,6 +9,7 @@ import ( "time" "github.com/lib/pq" + "github.com/stellar/stellar-disbursement-platform-backend/db" ) diff --git a/internal/data/wallets_test.go b/internal/data/wallets_test.go index b41bd2528..7ffc34360 100644 --- a/internal/data/wallets_test.go +++ b/internal/data/wallets_test.go @@ -4,10 +4,11 @@ import ( "context" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) func Test_WalletModelGet(t *testing.T) { @@ -205,7 +206,7 @@ func Test_WalletModelInsert(t *testing.T) { name := "test_wallet" homepage := "https://www.test_wallet.com" - deep_link_schema := "test_wallet://" + deep_link_schema := "test-wallet://sdp" sep_10_client_domain := "www.test_wallet.com" assets := []string{xlm.ID, usdc.ID} @@ -255,7 +256,7 @@ func Test_WalletModelInsert(t *testing.T) { name := "test_wallet" homepage := "https://www.test_wallet.com" - deep_link_schema := "test_wallet://" + deep_link_schema := "test-wallet://sdp" sep_10_client_domain := "www.test_wallet.com" assets := []string{xlm.ID, xlm.ID, usdc.ID, usdc.ID} @@ -304,7 +305,7 @@ func Test_WalletModelInsert(t *testing.T) { name := "test_wallet" homepage := "https://www.test_wallet.com" - deep_link_schema := "test_wallet://" + deep_link_schema := "test-wallet://sdp" sep_10_client_domain := "www.test_wallet.com" assets := []string{xlm.ID, usdc.ID} @@ -404,7 +405,7 @@ func Test_WalletModelGetOrCreate(t *testing.T) { name := "test_wallet" homepage := "https://www.test_wallet.com" - deep_link_schema := "test_wallet://" + deep_link_schema := "test-wallet://sdp" sep_10_client_domain := "www.test_wallet.com" wallet, err := walletModel.GetOrCreate(ctx, name, homepage, deep_link_schema, sep_10_client_domain) @@ -416,14 +417,14 @@ func Test_WalletModelGetOrCreate(t *testing.T) { DeleteAllWalletFixtures(t, ctx, dbConnectionPool) name := "test_wallet" homepage := "https://www.test_wallet.com" - deep_link_schema := "test_wallet://" + deep_link_schema := "test-wallet://sdp" sep_10_client_domain := "www.test_wallet.com" wallet, err := walletModel.GetOrCreate(ctx, name, homepage, deep_link_schema, sep_10_client_domain) require.NoError(t, err) assert.Equal(t, "test_wallet", wallet.Name) assert.Equal(t, "https://www.test_wallet.com", wallet.Homepage) - assert.Equal(t, "test_wallet://", wallet.DeepLinkSchema) + assert.Equal(t, "test-wallet://sdp", wallet.DeepLinkSchema) assert.Equal(t, "www.test_wallet.com", wallet.SEP10ClientDomain) }) @@ -433,11 +434,11 @@ func Test_WalletModelGetOrCreate(t *testing.T) { "test_wallet", "https://www.test_wallet.com", "www.test_wallet.com", - "test_wallet://") + "test-wallet://sdp") name := "test_wallet" homepage := "https://www.test_wallet.com" - deep_link_schema := "test_wallet://" + deep_link_schema := "test-wallet://sdp" sep_10_client_domain := "www.test_wallet.com" wallet, err := walletModel.GetOrCreate(ctx, name, homepage, deep_link_schema, sep_10_client_domain) diff --git a/internal/dependencyinjection/anchor_platform_service.go b/internal/dependencyinjection/anchor_platform_service.go index 1a7c098c7..4b87472f6 100644 --- a/internal/dependencyinjection/anchor_platform_service.go +++ b/internal/dependencyinjection/anchor_platform_service.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/anchorplatform" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient" ) diff --git a/internal/dependencyinjection/anchor_platform_service_test.go b/internal/dependencyinjection/anchor_platform_service_test.go index 27b21c6e4..0763b539f 100644 --- a/internal/dependencyinjection/anchor_platform_service_test.go +++ b/internal/dependencyinjection/anchor_platform_service_test.go @@ -3,9 +3,10 @@ package dependencyinjection import ( "testing" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/internal/anchorplatform" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient" - "github.com/stretchr/testify/require" ) func Test_NewAnchorPlatformAPIService(t *testing.T) { diff --git a/internal/dependencyinjection/circle_service.go b/internal/dependencyinjection/circle_service.go new file mode 100644 index 000000000..2c650bcbd --- /dev/null +++ b/internal/dependencyinjection/circle_service.go @@ -0,0 +1,35 @@ +package dependencyinjection + +import ( + "context" + "fmt" + + "github.com/stellar/go/support/log" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" +) + +const CircleServiceInstanceName = "circle_service_instance" + +// NewCircleService creates a new circle service instance, or retrieves an instance that was previously created. +func NewCircleService(ctx context.Context, opts circle.ServiceOptions) (circle.ServiceInterface, error) { + instanceName := CircleServiceInstanceName + + // Already initialized + if instance, ok := GetInstance(instanceName); ok { + if circleServiceInstance, ok2 := instance.(circle.ServiceInterface); ok2 { + return circleServiceInstance, nil + } + return nil, fmt.Errorf("trying to cast an existing circle service instance") + } + + log.Ctx(ctx).Info("⚙️ Setting up Circle Service") + newInstance, err := circle.NewService(opts) + if err != nil { + return nil, fmt.Errorf("creating a new circle service instance: %w", err) + } + + SetInstance(instanceName, newInstance) + + return newInstance, nil +} diff --git a/internal/dependencyinjection/circle_service_test.go b/internal/dependencyinjection/circle_service_test.go new file mode 100644 index 000000000..e020b609c --- /dev/null +++ b/internal/dependencyinjection/circle_service_test.go @@ -0,0 +1,48 @@ +package dependencyinjection + +import ( + "context" + "testing" + + "github.com/stellar/go/keypair" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" +) + +func Test_NewCircleService(t *testing.T) { + ctx := context.Background() + opts := circle.ServiceOptions{ + ClientFactory: circle.NewClient, + ClientConfigModel: &circle.ClientConfigModel{}, + TenantManager: &tenant.TenantManagerMock{}, + NetworkType: utils.TestnetNetworkType, + EncryptionPassphrase: keypair.MustRandom().Seed(), + } + + t.Run("should create and return the same instance on the second call", func(t *testing.T) { + defer ClearInstancesTestHelper(t) + + gotDependency, err := NewCircleService(ctx, opts) + require.NoError(t, err) + + gotDependencyDuplicate, err := NewCircleService(ctx, opts) + require.NoError(t, err) + + assert.Equal(t, &gotDependency, &gotDependencyDuplicate) + }) + + t.Run("should return an error if there's an invalid instance pre-stored", func(t *testing.T) { + ClearInstancesTestHelper(t) + + instanceName := CircleServiceInstanceName + SetInstance(instanceName, false) + + gotDependency, err := NewCircleService(ctx, opts) + assert.Empty(t, gotDependency) + assert.EqualError(t, err, "trying to cast an existing circle service instance") + }) +} diff --git a/internal/dependencyinjection/container_test.go b/internal/dependencyinjection/container_test.go index 9ae557e90..d421f3bcd 100644 --- a/internal/dependencyinjection/container_test.go +++ b/internal/dependencyinjection/container_test.go @@ -4,10 +4,11 @@ import ( "context" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) func Test_SetInstance(t *testing.T) { diff --git a/internal/dependencyinjection/crash_tracker_test.go b/internal/dependencyinjection/crash_tracker_test.go index 5b0077f30..638126aa7 100644 --- a/internal/dependencyinjection/crash_tracker_test.go +++ b/internal/dependencyinjection/crash_tracker_test.go @@ -4,9 +4,10 @@ import ( "context" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" ) func Test_dependencyinjection_buildCrashTrackerInstanceName(t *testing.T) { diff --git a/internal/dependencyinjection/distribution_account_resolver_test.go b/internal/dependencyinjection/distribution_account_resolver_test.go index 03f4b9c31..46a87c960 100644 --- a/internal/dependencyinjection/distribution_account_resolver_test.go +++ b/internal/dependencyinjection/distribution_account_resolver_test.go @@ -29,6 +29,7 @@ func Test_dependencyinjection_NewDistributionAccountResolver(t *testing.T) { opts := signing.DistributionAccountResolverOptions{ AdminDBConnectionPool: dbConnectionPool, HostDistributionAccountPublicKey: hostDistAccPublicKey, + MTNDBConnectionPool: dbConnectionPool, } gotDependency, err := NewDistributionAccountResolver(ctx, opts) diff --git a/internal/dependencyinjection/distribution_account_service.go b/internal/dependencyinjection/distribution_account_service.go new file mode 100644 index 000000000..dfa9cc93e --- /dev/null +++ b/internal/dependencyinjection/distribution_account_service.go @@ -0,0 +1,33 @@ +package dependencyinjection + +import ( + "context" + "fmt" + + "github.com/stellar/go/support/log" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/services" +) + +const DistributionAccountServiceInstanceName = "distribution_account_service_instance" + +func NewDistributionAccountService(ctx context.Context, opts services.DistributionAccountServiceOptions) (services.DistributionAccountServiceInterface, error) { + instanceName := DistributionAccountServiceInstanceName + + // Already initialized + if instance, ok := GetInstance(instanceName); ok { + if distributionAccountServiceInstance, ok2 := instance.(services.DistributionAccountServiceInterface); ok2 { + return distributionAccountServiceInstance, nil + } + return nil, fmt.Errorf("trying to cast a new distribution account service instance") + } + + log.Ctx(ctx).Info("⚙️ Setting up Distribution Account Service") + newInstance, err := services.NewDistributionAccountService(opts) + if err != nil { + return nil, fmt.Errorf("initializing new distribution account service: %w", err) + } + SetInstance(instanceName, newInstance) + + return newInstance, nil +} diff --git a/internal/dependencyinjection/distribution_account_service_test.go b/internal/dependencyinjection/distribution_account_service_test.go new file mode 100644 index 000000000..72b430a87 --- /dev/null +++ b/internal/dependencyinjection/distribution_account_service_test.go @@ -0,0 +1,45 @@ +package dependencyinjection + +import ( + "context" + "testing" + + "github.com/stellar/go/clients/horizonclient" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" +) + +func Test_dependencyinjection_NewDistributionAccountService(t *testing.T) { + ctx := context.Background() + mockHorizonClient := &horizonclient.MockClient{} + svcOpts := services.DistributionAccountServiceOptions{ + HorizonClient: mockHorizonClient, + CircleService: &circle.Service{}, + NetworkType: utils.TestnetNetworkType, + } + + t.Run("should create and return the same instance on the second call", func(t *testing.T) { + defer ClearInstancesTestHelper(t) + + gotDependency, err := NewDistributionAccountService(ctx, svcOpts) + require.NoError(t, err) + + gotDependencyDuplicate, err := NewDistributionAccountService(ctx, svcOpts) + require.NoError(t, err) + assert.Equal(t, &gotDependency, &gotDependencyDuplicate) + }) + + t.Run("should return an error if there's an invalid instance pre-stored", func(t *testing.T) { + ClearInstancesTestHelper(t) + + SetInstance(DistributionAccountServiceInstanceName, false) + + gotDependency, err := NewDistributionAccountService(ctx, svcOpts) + assert.Empty(t, gotDependency) + assert.EqualError(t, err, "trying to cast a new distribution account service instance") + }) +} diff --git a/internal/dependencyinjection/email_client.go b/internal/dependencyinjection/email_client.go index 3bf343965..71ec2c024 100644 --- a/internal/dependencyinjection/email_client.go +++ b/internal/dependencyinjection/email_client.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/message" ) diff --git a/internal/dependencyinjection/email_client_test.go b/internal/dependencyinjection/email_client_test.go index a42768791..35cc1eee5 100644 --- a/internal/dependencyinjection/email_client_test.go +++ b/internal/dependencyinjection/email_client_test.go @@ -3,9 +3,10 @@ package dependencyinjection import ( "testing" - "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/message" ) func Test_NewEmailClient(t *testing.T) { diff --git a/internal/dependencyinjection/horizon_client.go b/internal/dependencyinjection/horizon_client.go index d0c2b6ca0..5159a33b4 100644 --- a/internal/dependencyinjection/horizon_client.go +++ b/internal/dependencyinjection/horizon_client.go @@ -6,6 +6,7 @@ import ( "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient" ) diff --git a/internal/dependencyinjection/horizon_client_test.go b/internal/dependencyinjection/horizon_client_test.go index 146cabdeb..5cd5750cb 100644 --- a/internal/dependencyinjection/horizon_client_test.go +++ b/internal/dependencyinjection/horizon_client_test.go @@ -4,9 +4,10 @@ import ( "context" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) func Test_dependencyinjection_NewHorizonClient(t *testing.T) { diff --git a/internal/dependencyinjection/ledger_number_tracker.go b/internal/dependencyinjection/ledger_number_tracker.go index 425795eab..c9c80f1be 100644 --- a/internal/dependencyinjection/ledger_number_tracker.go +++ b/internal/dependencyinjection/ledger_number_tracker.go @@ -6,6 +6,7 @@ import ( "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/preconditions" ) diff --git a/internal/dependencyinjection/ledger_number_tracker_test.go b/internal/dependencyinjection/ledger_number_tracker_test.go index 815334087..b914e721c 100644 --- a/internal/dependencyinjection/ledger_number_tracker_test.go +++ b/internal/dependencyinjection/ledger_number_tracker_test.go @@ -5,9 +5,10 @@ import ( "testing" "github.com/stellar/go/clients/horizonclient" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) func Test_dependencyinjection_NewLedgerNumberTracker(t *testing.T) { diff --git a/internal/dependencyinjection/mtn_db_connection_pool.go b/internal/dependencyinjection/mtn_db_connection_pool.go index c3df7adfe..0a7930c63 100644 --- a/internal/dependencyinjection/mtn_db_connection_pool.go +++ b/internal/dependencyinjection/mtn_db_connection_pool.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) diff --git a/internal/dependencyinjection/mtn_db_connection_pool_test.go b/internal/dependencyinjection/mtn_db_connection_pool_test.go index 704de0f3f..d7745ed66 100644 --- a/internal/dependencyinjection/mtn_db_connection_pool_test.go +++ b/internal/dependencyinjection/mtn_db_connection_pool_test.go @@ -4,11 +4,12 @@ import ( "context" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_dependencyinjection_NewMtnDBConnectionPool(t *testing.T) { diff --git a/internal/dependencyinjection/signature_service.go b/internal/dependencyinjection/signature_service.go index e3152f875..3dac76d4b 100644 --- a/internal/dependencyinjection/signature_service.go +++ b/internal/dependencyinjection/signature_service.go @@ -5,21 +5,16 @@ import ( "fmt" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" ) const SignatureServiceInstanceName = "signature_service_instance" -// buildSignatureServiceInstanceName returns the name of the signature service instance, based on the signature type -// provided. -func buildSignatureServiceInstanceName(sigType signing.SignatureClientType) string { - return fmt.Sprintf("%s-%s", SignatureServiceInstanceName, string(sigType)) -} - // NewSignatureService creates a new signature service instance, or retrieves an instance that was already // created before. func NewSignatureService(ctx context.Context, opts signing.SignatureServiceOptions) (signing.SignatureService, error) { - instanceName := buildSignatureServiceInstanceName(opts.DistributionSignerType) + instanceName := SignatureServiceInstanceName // Already initialized if instance, ok := GetInstance(instanceName); ok { @@ -31,7 +26,7 @@ func NewSignatureService(ctx context.Context, opts signing.SignatureServiceOptio // TODO: in SDP-1077, implement a `NewDistributionAccountResolver` in the depencency injection and inject it into // the SignatureServiceOptions before calling NewSignatureService. - log.Ctx(ctx).Infof("⚙️ Setting up Signature Service with distribution signer type: %v", opts.DistributionSignerType) + log.Ctx(ctx).Info("⚙️ Setting up Signature Service") newInstance, err := signing.NewSignatureService(opts) if err != nil { return signing.SignatureService{}, fmt.Errorf("creating a new signature service instance: %w", err) diff --git a/internal/dependencyinjection/signature_service_test.go b/internal/dependencyinjection/signature_service_test.go index 6f183585f..44ae91f58 100644 --- a/internal/dependencyinjection/signature_service_test.go +++ b/internal/dependencyinjection/signature_service_test.go @@ -25,6 +25,7 @@ func Test_dependencyinjection_NewSignatureService(t *testing.T) { distributionPrivateKey := keypair.MustRandom().Seed() chAccEncryptionPassphrase := keypair.MustRandom().Seed() + distAccEncryptionPassphrase := keypair.MustRandom().Seed() mDistAccResolver := sigMocks.NewMockDistributionAccountResolver(t) ctx := context.Background() @@ -32,13 +33,12 @@ func Test_dependencyinjection_NewSignatureService(t *testing.T) { ClearInstancesTestHelper(t) opts := signing.SignatureServiceOptions{ - DistributionSignerType: signing.DistributionAccountEnvSignatureClientType, - NetworkPassphrase: network.TestNetworkPassphrase, - DBConnectionPool: dbConnectionPool, - DistributionPrivateKey: distributionPrivateKey, - ChAccEncryptionPassphrase: chAccEncryptionPassphrase, - LedgerNumberTracker: preconditionsMocks.NewMockLedgerNumberTracker(t), - + NetworkPassphrase: network.TestNetworkPassphrase, + DBConnectionPool: dbConnectionPool, + DistributionPrivateKey: distributionPrivateKey, + ChAccEncryptionPassphrase: chAccEncryptionPassphrase, + LedgerNumberTracker: preconditionsMocks.NewMockLedgerNumberTracker(t), + DistAccEncryptionPassphrase: distAccEncryptionPassphrase, DistributionAccountResolver: mDistAccResolver, } @@ -51,20 +51,19 @@ func Test_dependencyinjection_NewSignatureService(t *testing.T) { assert.Equal(t, &gotDependency, &gotDependencyDuplicate) }) - t.Run("should return an error on an invalid sig service type", func(t *testing.T) { + t.Run("should return an error on a nil distribution account resolver", func(t *testing.T) { ClearInstancesTestHelper(t) opts := signing.SignatureServiceOptions{} gotDependency, err := NewSignatureService(ctx, opts) assert.Empty(t, gotDependency) - assert.EqualError(t, err, "creating a new signature service instance: invalid distribution signer type \"\"") + assert.EqualError(t, err, "creating a new signature service instance: distribution account resolver cannot be nil") }) t.Run("should return an error on a invalid option", func(t *testing.T) { ClearInstancesTestHelper(t) opts := signing.SignatureServiceOptions{ - DistributionSignerType: signing.DistributionAccountEnvSignatureClientType, DistributionAccountResolver: mDistAccResolver, } gotDependency, err := NewSignatureService(ctx, opts) @@ -76,16 +75,15 @@ func Test_dependencyinjection_NewSignatureService(t *testing.T) { t.Run("should return an error if there's an invalid instance pre-stored", func(t *testing.T) { ClearInstancesTestHelper(t) - distributionSignerType := signing.DistributionAccountEnvSignatureClientType - instanceName := buildSignatureServiceInstanceName(distributionSignerType) + instanceName := SignatureServiceInstanceName SetInstance(instanceName, false) opts := signing.SignatureServiceOptions{ - DistributionSignerType: distributionSignerType, NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, DistributionPrivateKey: distributionPrivateKey, ChAccEncryptionPassphrase: chAccEncryptionPassphrase, + DistAccEncryptionPassphrase: distAccEncryptionPassphrase, DistributionAccountResolver: mDistAccResolver, } gotDependency, err := NewSignatureService(ctx, opts) diff --git a/internal/dependencyinjection/sms_client.go b/internal/dependencyinjection/sms_client.go index 804eb27af..4562d0fad 100644 --- a/internal/dependencyinjection/sms_client.go +++ b/internal/dependencyinjection/sms_client.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/message" ) diff --git a/internal/dependencyinjection/sms_client_test.go b/internal/dependencyinjection/sms_client_test.go index 1b2f40639..1c0615bd8 100644 --- a/internal/dependencyinjection/sms_client_test.go +++ b/internal/dependencyinjection/sms_client_test.go @@ -3,9 +3,10 @@ package dependencyinjection import ( "testing" - "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/message" ) func Test_NewSMSClient(t *testing.T) { diff --git a/internal/dependencyinjection/tx_submitter_engine.go b/internal/dependencyinjection/tx_submitter_engine.go index 976dcdad8..3b59965e7 100644 --- a/internal/dependencyinjection/tx_submitter_engine.go +++ b/internal/dependencyinjection/tx_submitter_engine.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" ) diff --git a/internal/dependencyinjection/tx_submitter_engine_test.go b/internal/dependencyinjection/tx_submitter_engine_test.go index 4eb861913..921655ef7 100644 --- a/internal/dependencyinjection/tx_submitter_engine_test.go +++ b/internal/dependencyinjection/tx_submitter_engine_test.go @@ -19,15 +19,13 @@ func Test_dependencyinjection_NewTxSubmitterEngine(t *testing.T) { t.Run("should create and return the same instance on the second call", func(t *testing.T) { ClearInstancesTestHelper(t) - sigService, _, _, _, _ := signing.NewMockSignatureService(t) - istanceName := buildSignatureServiceInstanceName(signing.DistributionAccountEnvSignatureClientType) + sigService, _, _ := signing.NewMockSignatureService(t) + istanceName := SignatureServiceInstanceName SetInstance(istanceName, sigService) opts := TxSubmitterEngineOptions{ - MaxBaseFee: 100, - SignatureServiceOptions: signing.SignatureServiceOptions{ - DistributionSignerType: signing.DistributionAccountEnvSignatureClientType, - }, + MaxBaseFee: 100, + SignatureServiceOptions: signing.SignatureServiceOptions{}, } gotDependency, err := NewTxSubmitterEngine(ctx, opts) require.NoError(t, err) diff --git a/internal/events/event_consumer_test.go b/internal/events/event_consumer_test.go index 4c2287e01..75f30008f 100644 --- a/internal/events/event_consumer_test.go +++ b/internal/events/event_consumer_test.go @@ -7,10 +7,11 @@ import ( "time" "github.com/stellar/go/support/log" - "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" ) func Test_EventConsumer_Consume(t *testing.T) { diff --git a/internal/events/eventhandlers/circle_payment_to_submitter_event_handler.go b/internal/events/eventhandlers/circle_payment_to_submitter_event_handler.go new file mode 100644 index 000000000..2cf753944 --- /dev/null +++ b/internal/events/eventhandlers/circle_payment_to_submitter_event_handler.go @@ -0,0 +1,95 @@ +package eventhandlers + +import ( + "context" + "fmt" + + "github.com/stellar/go/support/log" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/events" + "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/paymentdispatchers" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" +) + +type CirclePaymentToSubmitterEventHandlerOptions struct { + AdminDBConnectionPool db.DBConnectionPool + MtnDBConnectionPool db.DBConnectionPool + DistAccountResolver signing.DistributionAccountResolver + CircleService circle.ServiceInterface +} + +type CirclePaymentToSubmitterEventHandler struct { + tenantManager tenant.ManagerInterface + service services.PaymentToSubmitterServiceInterface + distAccountResolver signing.DistributionAccountResolver +} + +var _ events.EventHandler = new(CirclePaymentToSubmitterEventHandler) + +func NewCirclePaymentToSubmitterEventHandler(opts CirclePaymentToSubmitterEventHandlerOptions) *CirclePaymentToSubmitterEventHandler { + tm := tenant.NewManager(tenant.WithDatabase(opts.AdminDBConnectionPool)) + + models, err := data.NewModels(opts.MtnDBConnectionPool) + if err != nil { + log.Fatalf("error getting models: %s", err.Error()) + } + + circlePaymentDispatcher := paymentdispatchers.NewCirclePaymentDispatcher(models, opts.CircleService, opts.DistAccountResolver) + + s := services.NewPaymentToSubmitterService(services.PaymentToSubmitterServiceOptions{ + Models: models, + DistAccountResolver: opts.DistAccountResolver, + PaymentDispatcher: circlePaymentDispatcher, + }) + + return &CirclePaymentToSubmitterEventHandler{ + tenantManager: tm, + service: s, + distAccountResolver: opts.DistAccountResolver, + } +} + +func (h *CirclePaymentToSubmitterEventHandler) Name() string { + return utils.GetTypeName(h) +} + +func (h *CirclePaymentToSubmitterEventHandler) CanHandleMessage(ctx context.Context, message *events.Message) bool { + return message.Topic == events.CirclePaymentReadyToPayTopic +} + +func (h *CirclePaymentToSubmitterEventHandler) Handle(ctx context.Context, message *events.Message) error { + paymentsReadyToPay, err := utils.ConvertType[any, schemas.EventPaymentsReadyToPayData](message.Data) + if err != nil { + return fmt.Errorf("could not convert message data to %T: %w", schemas.EventPaymentsReadyToPayData{}, err) + } + + t, err := h.tenantManager.GetTenantByID(ctx, message.TenantID) + if err != nil { + return fmt.Errorf("getting tenant by id %s: %w", message.TenantID, err) + } + + ctx = tenant.SaveTenantInContext(ctx, t) + + distAccount, err := h.distAccountResolver.DistributionAccountFromContext(ctx) + if err != nil { + return fmt.Errorf("getting distribution account: %w", err) + } + + if !distAccount.Type.IsCircle() { + log.Ctx(ctx).Debugf("distribution account is not a Circle account. Skipping for tenant %s", message.TenantID) + return nil + } + + if sendErr := h.service.SendPaymentsReadyToPay(ctx, paymentsReadyToPay); sendErr != nil { + return fmt.Errorf("sending payments ready to pay: %w", sendErr) + } + + return nil +} diff --git a/internal/events/eventhandlers/circle_payment_to_submitter_event_handler_test.go b/internal/events/eventhandlers/circle_payment_to_submitter_event_handler_test.go new file mode 100644 index 000000000..518e62a90 --- /dev/null +++ b/internal/events/eventhandlers/circle_payment_to_submitter_event_handler_test.go @@ -0,0 +1,120 @@ +package eventhandlers + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/events" + "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" + servicesMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/services/mocks" + sigMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" +) + +func Test_CirclePaymentToSubmitterEventHandler_Handle(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + tenantManager := tenant.NewManager(tenant.WithDatabase(dbConnectionPool)) + + service := servicesMocks.MockPaymentToSubmitterService{} + mDistAccResolver := sigMocks.NewMockDistributionAccountResolver(t) + mDistAccResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountCircleDBVault}, nil). + Maybe() + + handler := CirclePaymentToSubmitterEventHandler{ + tenantManager: tenantManager, + service: &service, + distAccountResolver: mDistAccResolver, + } + + ctx := context.Background() + t.Run("logs and report error when message Data is invalid", func(t *testing.T) { + handleErr := handler.Handle(ctx, &events.Message{Data: "invalid"}) + assert.ErrorContains(t, handleErr, "could not convert message data to schemas.EventPaymentsReadyToPayData") + }) + + t.Run("logs and report error when fails getting tenant by ID", func(t *testing.T) { + handleErr := handler.Handle(ctx, &events.Message{ + TenantID: "tenant-id", + Data: schemas.EventPaymentCompletedData{ + TransactionID: "tx-id", + }, + }) + assert.ErrorIs(t, handleErr, tenant.ErrTenantDoesNotExist) + }) + + t.Run("logs and report error when service returns error", func(t *testing.T) { + tenant.DeleteAllTenantsFixture(t, ctx, dbConnectionPool) + + tnt, err := tenantManager.AddTenant(ctx, "myorg1") + require.NoError(t, err) + + paymentsReadyToPay := schemas.EventPaymentsReadyToPayData{ + TenantID: tnt.ID, + Payments: []schemas.PaymentReadyToPay{ + { + ID: "payment-id", + }, + }, + } + + ctxWithTenant := tenant.SaveTenantInContext(ctx, tnt) + + service. + On("SendPaymentsReadyToPay", ctxWithTenant, paymentsReadyToPay). + Return(errors.New("unexpected error")). + Once() + + handleErr := handler.Handle(ctx, &events.Message{ + TenantID: tnt.ID, + Data: paymentsReadyToPay, + }) + assert.ErrorContains(t, handleErr, "sending payments ready to pay") + }) + + t.Run("successfully sends payments ready to pay to Circle", func(t *testing.T) { + tenant.DeleteAllTenantsFixture(t, ctx, dbConnectionPool) + + tnt, err := tenantManager.AddTenant(ctx, "myorg1") + require.NoError(t, err) + + paymentsReadyToPay := schemas.EventPaymentsReadyToPayData{ + TenantID: tnt.ID, + Payments: []schemas.PaymentReadyToPay{ + { + ID: "payment-id", + }, + }, + } + + ctxWithTenant := tenant.SaveTenantInContext(ctx, tnt) + + service. + On("SendPaymentsReadyToPay", ctxWithTenant, paymentsReadyToPay). + Return(nil). + Once() + + handleErr := handler.Handle(ctx, &events.Message{ + TenantID: tnt.ID, + Data: paymentsReadyToPay, + }) + assert.NoError(t, handleErr) + }) + + service.AssertExpectations(t) +} diff --git a/internal/events/eventhandlers/payment_from_submitter_event_handler.go b/internal/events/eventhandlers/payment_from_submitter_event_handler.go index 872ec0e11..911e15008 100644 --- a/internal/events/eventhandlers/payment_from_submitter_event_handler.go +++ b/internal/events/eventhandlers/payment_from_submitter_event_handler.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/events" diff --git a/internal/events/eventhandlers/payment_to_submitter_event_handler.go b/internal/events/eventhandlers/payment_to_submitter_event_handler.go deleted file mode 100644 index e21327405..000000000 --- a/internal/events/eventhandlers/payment_to_submitter_event_handler.go +++ /dev/null @@ -1,73 +0,0 @@ -package eventhandlers - -import ( - "context" - "fmt" - - "github.com/stellar/go/support/log" - - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" - "github.com/stellar/stellar-disbursement-platform-backend/internal/events" - "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" - "github.com/stellar/stellar-disbursement-platform-backend/internal/services" - "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" - "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" -) - -type PaymentToSubmitterEventHandlerOptions struct { - AdminDBConnectionPool db.DBConnectionPool - MtnDBConnectionPool db.DBConnectionPool - TSSDBConnectionPool db.DBConnectionPool -} - -type PaymentToSubmitterEventHandler struct { - tenantManager tenant.ManagerInterface - service services.PaymentToSubmitterServiceInterface -} - -var _ events.EventHandler = new(PaymentToSubmitterEventHandler) - -func NewPaymentToSubmitterEventHandler(options PaymentToSubmitterEventHandlerOptions) *PaymentToSubmitterEventHandler { - tm := tenant.NewManager(tenant.WithDatabase(options.AdminDBConnectionPool)) - - models, err := data.NewModels(options.MtnDBConnectionPool) - if err != nil { - log.Fatalf("error getting models: %s", err.Error()) - } - - s := services.NewPaymentToSubmitterService(models, options.TSSDBConnectionPool) - - return &PaymentToSubmitterEventHandler{ - tenantManager: tm, - service: s, - } -} - -func (h *PaymentToSubmitterEventHandler) Name() string { - return utils.GetTypeName(h) -} - -func (h *PaymentToSubmitterEventHandler) CanHandleMessage(ctx context.Context, message *events.Message) bool { - return message.Topic == events.PaymentReadyToPayTopic -} - -func (h *PaymentToSubmitterEventHandler) Handle(ctx context.Context, message *events.Message) error { - paymentsReadyToPay, err := utils.ConvertType[any, schemas.EventPaymentsReadyToPayData](message.Data) - if err != nil { - return fmt.Errorf("could not convert message data to %T: %w", schemas.EventPaymentsReadyToPayData{}, err) - } - - t, err := h.tenantManager.GetTenantByID(ctx, message.TenantID) - if err != nil { - return fmt.Errorf("getting tenant by id %s: %w", message.TenantID, err) - } - - ctx = tenant.SaveTenantInContext(ctx, t) - - if sendErr := h.service.SendPaymentsReadyToPay(ctx, paymentsReadyToPay); sendErr != nil { - return fmt.Errorf("sending payments ready to pay: %w", sendErr) - } - - return nil -} diff --git a/internal/events/eventhandlers/stellar_payment_to_submitter_event_handler.go b/internal/events/eventhandlers/stellar_payment_to_submitter_event_handler.go new file mode 100644 index 000000000..e5bbea5ac --- /dev/null +++ b/internal/events/eventhandlers/stellar_payment_to_submitter_event_handler.go @@ -0,0 +1,99 @@ +package eventhandlers + +import ( + "context" + "fmt" + + "github.com/stellar/go/support/log" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/events" + "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/paymentdispatchers" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" + txSubStore "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" +) + +type StellarPaymentToSubmitterEventHandlerOptions struct { + AdminDBConnectionPool db.DBConnectionPool + MtnDBConnectionPool db.DBConnectionPool + TSSDBConnectionPool db.DBConnectionPool + DistAccountResolver signing.DistributionAccountResolver +} + +type StellarPaymentToSubmitterEventHandler struct { + tenantManager tenant.ManagerInterface + service services.PaymentToSubmitterServiceInterface + distAccountResolver signing.DistributionAccountResolver +} + +var _ events.EventHandler = new(StellarPaymentToSubmitterEventHandler) + +func NewStellarPaymentToSubmitterEventHandler(opts StellarPaymentToSubmitterEventHandlerOptions) *StellarPaymentToSubmitterEventHandler { + tm := tenant.NewManager(tenant.WithDatabase(opts.AdminDBConnectionPool)) + + models, err := data.NewModels(opts.MtnDBConnectionPool) + if err != nil { + log.Fatalf("error getting models: %s", err.Error()) + } + + stellarPaymentDispatcher := paymentdispatchers.NewStellarPaymentDispatcher( + models, + txSubStore.NewTransactionModel(opts.TSSDBConnectionPool), + opts.DistAccountResolver) + + s := services.NewPaymentToSubmitterService(services.PaymentToSubmitterServiceOptions{ + Models: models, + DistAccountResolver: opts.DistAccountResolver, + PaymentDispatcher: stellarPaymentDispatcher, + }) + + return &StellarPaymentToSubmitterEventHandler{ + tenantManager: tm, + service: s, + distAccountResolver: opts.DistAccountResolver, + } +} + +func (h *StellarPaymentToSubmitterEventHandler) Name() string { + return utils.GetTypeName(h) +} + +func (h *StellarPaymentToSubmitterEventHandler) CanHandleMessage(ctx context.Context, message *events.Message) bool { + return message.Topic == events.PaymentReadyToPayTopic +} + +func (h *StellarPaymentToSubmitterEventHandler) Handle(ctx context.Context, message *events.Message) error { + paymentsReadyToPay, err := utils.ConvertType[any, schemas.EventPaymentsReadyToPayData](message.Data) + if err != nil { + return fmt.Errorf("could not convert message data to %T: %w", schemas.EventPaymentsReadyToPayData{}, err) + } + + // Save tenant in context + t, err := h.tenantManager.GetTenantByID(ctx, message.TenantID) + if err != nil { + return fmt.Errorf("getting tenant by id %s: %w", message.TenantID, err) + } + ctx = tenant.SaveTenantInContext(ctx, t) + + // Bypass Sending payments if tenant doesn't have a Stellar account + distAccount, err := h.distAccountResolver.DistributionAccountFromContext(ctx) + if err != nil { + return fmt.Errorf("getting distribution account: %w", err) + } + + if !distAccount.Type.IsStellar() { + log.Ctx(ctx).Debugf("distribution account is not a Stellar account. Skipping for tenant %s", message.TenantID) + return nil + } + + if sendErr := h.service.SendPaymentsReadyToPay(ctx, paymentsReadyToPay); sendErr != nil { + return fmt.Errorf("sending payments ready to pay: %w", sendErr) + } + + return nil +} diff --git a/internal/events/eventhandlers/payment_to_submitter_event_handler_test.go b/internal/events/eventhandlers/stellar_payment_to_submitter_event_handler_test.go similarity index 83% rename from internal/events/eventhandlers/payment_to_submitter_event_handler_test.go rename to internal/events/eventhandlers/stellar_payment_to_submitter_event_handler_test.go index f80b163fd..ffcb5936d 100644 --- a/internal/events/eventhandlers/payment_to_submitter_event_handler_test.go +++ b/internal/events/eventhandlers/stellar_payment_to_submitter_event_handler_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -14,6 +15,8 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/events" "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" servicesMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/services/mocks" + sigMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) @@ -28,10 +31,16 @@ func Test_PaymentToSubmitterEventHandler_Handle(t *testing.T) { tenantManager := tenant.NewManager(tenant.WithDatabase(dbConnectionPool)) service := servicesMocks.MockPaymentToSubmitterService{} - - handler := PaymentToSubmitterEventHandler{ - tenantManager: tenantManager, - service: &service, + mDistAccResolver := sigMocks.NewMockDistributionAccountResolver(t) + mDistAccResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarEnv}, nil). + Maybe() + + handler := StellarPaymentToSubmitterEventHandler{ + tenantManager: tenantManager, + service: &service, + distAccountResolver: mDistAccResolver, } ctx := context.Background() diff --git a/internal/events/handler.go b/internal/events/handler.go index 9c77246c8..c704e048f 100644 --- a/internal/events/handler.go +++ b/internal/events/handler.go @@ -13,6 +13,7 @@ const ( ReceiverWalletNewInvitationTopic = "events.receiver-wallets.new_invitation" PaymentCompletedTopic = "events.payment.payment_completed" PaymentReadyToPayTopic = "events.payment.ready_to_pay" + CirclePaymentReadyToPayTopic = "events.payment.circle_ready_to_pay" ) // Type Names diff --git a/internal/events/message.go b/internal/events/message.go index c2808c4be..28d86d2b4 100644 --- a/internal/events/message.go +++ b/internal/events/message.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) @@ -55,6 +56,20 @@ func NewMessage(ctx context.Context, topic, key, messageType string, data any) ( }, nil } +// NewPaymentReadyToPayMessage returns a new message for the `PaymentReadyToPayTopic` topic or `CirclePaymentReadyToPayTopic` topic. +func NewPaymentReadyToPayMessage(ctx context.Context, platform schema.Platform, key, messageType string) (*Message, error) { + var targetTopic string + switch platform { + case schema.StellarPlatform: + targetTopic = PaymentReadyToPayTopic + case schema.CirclePlatform: + targetTopic = CirclePaymentReadyToPayTopic + default: + return nil, fmt.Errorf("unsupported platform: %s", platform) + } + return NewMessage(ctx, targetTopic, key, messageType, nil) +} + func NewHandlerError(hError error, handlerName string) HandlerError { return HandlerError{ FailedAt: time.Now(), diff --git a/internal/events/message_test.go b/internal/events/message_test.go index 107f94b9e..4411e941b 100644 --- a/internal/events/message_test.go +++ b/internal/events/message_test.go @@ -1,10 +1,14 @@ package events import ( + "context" "errors" "testing" "github.com/stretchr/testify/assert" + + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) func Test_Message_Validate(t *testing.T) { @@ -65,3 +69,31 @@ func Test_Message_RecordError(t *testing.T) { assert.Equal(t, "test-handler-2", m.Errors[1].HandlerName) }) } + +func Test_NewPaymentReadyToPayMessage(t *testing.T) { + tenantID := "test-tenant" + key := "test-key" + messageType := "test-type" + + ctxWithTenant := tenant.SaveTenantInContext(context.Background(), &tenant.Tenant{ID: tenantID}) + + t.Run("unsupported platform", func(t *testing.T) { + _, err := NewPaymentReadyToPayMessage(ctxWithTenant, "unsupported-platform", key, messageType) + assert.EqualError(t, err, "unsupported platform: unsupported-platform") + }) + + t.Run("stellar platform", func(t *testing.T) { + m, err := NewPaymentReadyToPayMessage(ctxWithTenant, schema.StellarPlatform, key, messageType) + assert.NoError(t, err) + assert.Equal(t, PaymentReadyToPayTopic, m.Topic) + assert.Equal(t, tenantID, m.TenantID) + }) + + t.Run("circle platform", func(t *testing.T) { + m, err := NewPaymentReadyToPayMessage(ctxWithTenant, schema.CirclePlatform, key, messageType) + assert.NoError(t, err) + assert.Equal(t, CirclePaymentReadyToPayTopic, m.Topic) + assert.Equal(t, key, m.Key) + assert.Equal(t, tenantID, m.TenantID) + }) +} diff --git a/internal/integrationtests/admin_api.go b/internal/integrationtests/admin_api.go index 9404473f9..4e7ad126f 100644 --- a/internal/integrationtests/admin_api.go +++ b/internal/integrationtests/admin_api.go @@ -11,6 +11,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) @@ -26,14 +27,14 @@ type AdminApiIntegrationTestsInterface interface { } type CreateTenantRequest struct { - Name string `json:"name"` - OwnerEmail string `json:"owner_email"` - OwnerFirstName string `json:"owner_first_name"` - OwnerLastName string `json:"owner_last_name"` - OrganizationName string `json:"organization_name"` - BaseURL string `json:"base_url"` - SDPUIBaseURL string `json:"sdp_ui_base_url"` - DistributionAccount string `json:"distribution_account"` + Name string `json:"name"` + OwnerEmail string `json:"owner_email"` + OwnerFirstName string `json:"owner_first_name"` + OwnerLastName string `json:"owner_last_name"` + OrganizationName string `json:"organization_name"` + DistributionAccountType schema.AccountType `json:"distribution_account_type"` + BaseURL string `json:"base_url"` + SDPUIBaseURL string `json:"sdp_ui_base_url"` } func (aa AdminApiIntegrationTests) CreateTenant(ctx context.Context, body CreateTenantRequest) (*tenant.Tenant, error) { diff --git a/internal/integrationtests/admin_api_test.go b/internal/integrationtests/admin_api_test.go index f3d1020ef..dd9563cf3 100644 --- a/internal/integrationtests/admin_api_test.go +++ b/internal/integrationtests/admin_api_test.go @@ -8,10 +8,11 @@ import ( "strings" "testing" - httpclientMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + httpclientMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient/mocks" ) func Test_AdminApiIntegrationTests_CreateTenant(t *testing.T) { diff --git a/internal/integrationtests/anchor_platform.go b/internal/integrationtests/anchor_platform.go index 57152c5c2..203626d96 100644 --- a/internal/integrationtests/anchor_platform.go +++ b/internal/integrationtests/anchor_platform.go @@ -12,6 +12,7 @@ import ( "github.com/stellar/go/keypair" "github.com/stellar/go/txnbuild" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient" ) diff --git a/internal/integrationtests/anchor_platform_test.go b/internal/integrationtests/anchor_platform_test.go index 6c6dbfe9e..34ac6a79d 100644 --- a/internal/integrationtests/anchor_platform_test.go +++ b/internal/integrationtests/anchor_platform_test.go @@ -10,10 +10,11 @@ import ( "github.com/stellar/go/keypair" "github.com/stellar/go/txnbuild" - httpclientMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient/mocks" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + httpclientMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient/mocks" ) func Test_StartChallengeTransaction(t *testing.T) { diff --git a/internal/integrationtests/docker-compose-e2e-tests.yml b/internal/integrationtests/docker/docker-compose-e2e-tests.yml similarity index 94% rename from internal/integrationtests/docker-compose-e2e-tests.yml rename to internal/integrationtests/docker/docker-compose-e2e-tests.yml index 8e4ba7b0d..b4547721b 100644 --- a/internal/integrationtests/docker-compose-e2e-tests.yml +++ b/internal/integrationtests/docker/docker-compose-e2e-tests.yml @@ -16,7 +16,7 @@ services: container_name: e2e-sdp-api image: stellar/sdp-v2:latest build: - context: ../../ + context: ../../../ dockerfile: Dockerfile ports: - "8000:8000" @@ -36,8 +36,8 @@ services: SEP10_SIGNING_PUBLIC_KEY: ${SEP10_SIGNING_PUBLIC_KEY} ANCHOR_PLATFORM_BASE_SEP_URL: http://anchor-platform:8080 ANCHOR_PLATFORM_BASE_PLATFORM_URL: http://anchor-platform:8085 + DISTRIBUTION_ACCOUNT_TYPE: ${DISTRIBUTION_ACCOUNT_TYPE} DISTRIBUTION_PUBLIC_KEY: ${DISTRIBUTION_PUBLIC_KEY} - DISTRIBUTION_SIGNER_TYPE: DISTRIBUTION_ACCOUNT_ENV RECAPTCHA_SITE_KEY: 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI CORS_ALLOWED_ORIGINS: "*" DISABLE_MFA: "true" @@ -65,12 +65,13 @@ services: DISBURSED_ASSET_ISSUER: GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 RECEIVER_ACCOUNT_PUBLIC_KEY: GCDYFAJSZPH3RCXL6NWMMOY54CXNUBYFTDCBW7GGG6VPBW3WSDKSB2NU RECEIVER_ACCOUNT_PRIVATE_KEY: SDSAVUWVNOFG2JEHKIWEUHAYIA6PLGEHLMHX2TMVKEQGZKOFQ7XXKDFE - DISBURSEMENT_CSV_FILE_PATH: files + DISBURSEMENT_CSV_FILE_PATH: resources DISBURSEMENT_CSV_FILE_NAME: disbursement_integration_tests.csv SERVER_API_BASE_URL: http://localhost:8000 ADMIN_SERVER_BASE_URL: http://localhost:8003 ADMIN_SERVER_ACCOUNT_ID: SDP-admin ADMIN_SERVER_API_KEY: api_key_1234567890 + CIRCLE_USDC_WALLET_ID: ${CIRCLE_USDC_WALLET_ID} # secrets: AWS_ACCESS_KEY_ID: MY_AWS_ACCESS_KEY_ID @@ -87,6 +88,8 @@ services: ANCHOR_PLATFORM_OUTGOING_JWT_SECRET: mySdpToAnchorPlatformSecret DISTRIBUTION_SEED: ${DISTRIBUTION_SEED} CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE: ${DISTRIBUTION_SEED} + DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE: ${DISTRIBUTION_SEED} + CIRCLE_API_KEY: ${CIRCLE_API_KEY} entrypoint: "" command: - sh @@ -111,7 +114,7 @@ services: container_name: e2e-sdp-tss image: stellar/sdp-v2:latest build: - context: ../../ + context: ../../../ dockerfile: Dockerfile ports: - "9000:9000" @@ -126,8 +129,8 @@ services: TSS_METRICS_TYPE: "TSS_PROMETHEUS" DISTRIBUTION_PUBLIC_KEY: ${DISTRIBUTION_PUBLIC_KEY} DISTRIBUTION_SEED: ${DISTRIBUTION_SEED} - DISTRIBUTION_SIGNER_TYPE: DISTRIBUTION_ACCOUNT_ENV CHANNEL_ACCOUNT_ENCRYPTION_PASSPHRASE: ${DISTRIBUTION_SEED} + DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE: ${DISTRIBUTION_SEED} # multi-tenant EVENT_BROKER_TYPE: "KAFKA" @@ -264,10 +267,12 @@ services: kafka-topics.sh --create --if-not-exists --topic events.receiver-wallets.new_invitation --bootstrap-server kafka:9092 kafka-topics.sh --create --if-not-exists --topic events.payment.payment_completed --bootstrap-server kafka:9092 kafka-topics.sh --create --if-not-exists --topic events.payment.ready_to_pay --bootstrap-server kafka:9092 + kafka-topics.sh --create --if-not-exists --topic events.payment.circle_ready_to_pay --bootstrap-server kafka:9092 kafka-topics.sh --create --if-not-exists --topic events.receiver-wallets.new_invitation.dlq --bootstrap-server kafka:9092 kafka-topics.sh --create --if-not-exists --topic events.payment.payment_completed.dlq --bootstrap-server kafka:9092 kafka-topics.sh --create --if-not-exists --topic events.payment.ready_to_pay.dlq --bootstrap-server kafka:9092 + kafka-topics.sh --create --if-not-exists --topic events.payment.circle_ready_to_pay.dlq --bootstrap-server kafka:9092 " depends_on: kafka: diff --git a/internal/integrationtests/e2e_integration_test.sh b/internal/integrationtests/e2e_integration_test.sh deleted file mode 100755 index 31cab1d65..000000000 --- a/internal/integrationtests/e2e_integration_test.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash -# This script is used to run e2e integration tests locally with all necessary steps. -set -eu - -export DIVIDER="----------------------------------------" -# prepare -echo "====> 👀Step 1: start preparation" -docker container ps -aq -f name='e2e' --format '{{.ID}}' | xargs docker stop | xargs docker rm -v && -docker volume ls -f name='e2e' --format '{{.Name}}' | xargs docker volume rm -echo "====> ✅Step 1: finish preparation" - -# Run docker compose -echo $DIVIDER -echo "====> 👀Step 2: build sdp-api, anchor-platform and tss" -docker-compose -f docker-compose-e2e-tests.yml up --build -d -sleep 20 -echo "====> ✅Step 2: finishing build" - -# Create integration test data -echo $DIVIDER -echo "====> 👀Step 3: provision new tenant and populate new asset and test wallet on database" -docker exec e2e-sdp-api bash -c "./stellar-disbursement-platform integration-tests create-data" -echo "====> ✅Step 3: finish creating integration test data" - -# Restart anchor platform container -echo $DIVIDER -echo "====> 👀Step 4: restart anchor platform container to get the new created asset" -docker restart e2e-anchor-platform -echo "waiting for anchor platform to initialize" -sleep 120 -echo "====> ✅Step 4: finish restarting anchor platform container" - -# Run integration tests -echo $DIVIDER -echo "====> 👀Step 5: run integration tests command" -docker exec e2e-sdp-api bash -c "./stellar-disbursement-platform integration-tests start" -echo "====> ✅Step 5: finish running integration test data" - -# Cleanup container and volumes -echo $DIVIDER -echo "====> 👀Step 6: cleaning up e2e containers and volumes" -docker container ps -aq -f name='e2e' --format '{{.ID}}' | xargs docker stop | xargs docker rm -v && -docker volume ls -f name='e2e' --format '{{.Name}}' | xargs docker volume rm -echo "====> ✅Step 6: finish cleaning up containers and volumes" - -echo $DIVIDER -echo "🎉🎉🎉🎉 SUCCESS! 🎉🎉🎉🎉" diff --git a/internal/integrationtests/integration_tests.go b/internal/integrationtests/integration_tests.go index bea1e9f12..db209500c 100644 --- a/internal/integrationtests/integration_tests.go +++ b/internal/integrationtests/integration_tests.go @@ -5,17 +5,18 @@ import ( "fmt" "time" - "github.com/stellar/stellar-disbursement-platform-backend/db/router" - "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" - "golang.org/x/crypto/bcrypt" - "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/support/log" + "golang.org/x/crypto/bcrypt" + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/router" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httphandler" tss "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) const ( @@ -32,6 +33,7 @@ type IntegrationTestsOpts struct { TenantName string UserEmail string UserPassword string + DistributionAccountType string DisbursedAssetCode string DisbursetAssetIssuer string WalletName string @@ -51,6 +53,8 @@ type IntegrationTestsOpts struct { AdminServerBaseURL string AdminServerAccountId string AdminServerApiKey string + CircleUSDCWalletID string + CircleAPIKey string } type IntegrationTestsService struct { @@ -73,7 +77,7 @@ func NewIntegrationTestsService(opts IntegrationTestsOpts) (*IntegrationTestsSer } adminDbConnectionPool, err := db.OpenDBConnectionPool(adminDSN) if err != nil { - return nil, fmt.Errorf("error connecting to the database: %w", err) + return nil, fmt.Errorf("connecting to the database: %w", err) } tm := tenant.NewManager(tenant.WithDatabase(adminDbConnectionPool)) tr := tenant.NewMultiTenantDataSourceRouter(tm) @@ -91,7 +95,7 @@ func NewIntegrationTestsService(opts IntegrationTestsOpts) (*IntegrationTestsSer } tssDbConnectionPool, err := db.OpenDBConnectionPool(tssDSN) if err != nil { - return nil, fmt.Errorf("error connecting to the tss database: %w", err) + return nil, fmt.Errorf("connecting to the tss database: %w", err) } it := &IntegrationTestsService{ models: models, @@ -153,7 +157,7 @@ func (it *IntegrationTestsService) StartIntegrationTests(ctx context.Context, op log.Ctx(ctx).Info("Login user to get server API auth token") authToken, err := it.serverAPI.Login(ctx) if err != nil { - return fmt.Errorf("error trying to login in server API: %w", err) + return fmt.Errorf("trying to login in server API: %w", err) } log.Ctx(ctx).Info("User logged in") log.Ctx(ctx).Info(authToken) @@ -161,13 +165,13 @@ func (it *IntegrationTestsService) StartIntegrationTests(ctx context.Context, op log.Ctx(ctx).Info("Getting test asset in database") asset, err := it.models.Assets.GetByCodeAndIssuer(ctx, opts.DisbursedAssetCode, opts.DisbursetAssetIssuer) if err != nil { - return fmt.Errorf("error getting test asset: %w", err) + return fmt.Errorf("getting test asset: %w", err) } log.Ctx(ctx).Info("Getting test wallet in database") wallet, err := it.models.Wallets.GetByWalletName(ctx, opts.WalletName) if err != nil { - return fmt.Errorf("error getting test wallet: %w", err) + return fmt.Errorf("getting test wallet: %w", err) } log.Ctx(ctx).Info("Creating disbursement using server API") @@ -179,35 +183,35 @@ func (it *IntegrationTestsService) StartIntegrationTests(ctx context.Context, op VerificationField: data.VerificationFieldDateOfBirth, }) if err != nil { - return fmt.Errorf("error creating disbursement: %w", err) + return fmt.Errorf("creating disbursement: %w", err) } log.Ctx(ctx).Info("Disbursement created") log.Ctx(ctx).Info("Processing disbursement CSV file using server API") err = it.serverAPI.ProcessDisbursement(ctx, authToken, disbursement.ID) if err != nil { - return fmt.Errorf("error processing disbursement: %w", err) + return fmt.Errorf("processing disbursement: %w", err) } log.Ctx(ctx).Info("CSV disbursement file processed") log.Ctx(ctx).Info("Validating disbursement data after processing the disbursement file") err = validateExpectationsAfterProcessDisbursement(ctx, disbursement.ID, it.models, it.mtnDbConnectionPool) if err != nil { - return fmt.Errorf("error validating data after process disbursement: %w", err) + return fmt.Errorf("validating data after process disbursement: %w", err) } log.Ctx(ctx).Info("Disbursement data validated") log.Ctx(ctx).Info("Starting disbursement using server API") err = it.serverAPI.StartDisbursement(ctx, authToken, disbursement.ID, &httphandler.PatchDisbursementStatusRequest{Status: "STARTED"}) if err != nil { - return fmt.Errorf("error starting disbursement: %w", err) + return fmt.Errorf("starting disbursement: %w", err) } log.Ctx(ctx).Info("Disbursement started") log.Ctx(ctx).Info("Validating disbursement data after starting disbursement using server API") err = validateExpectationsAfterStartDisbursement(ctx, disbursement.ID, it.models, it.mtnDbConnectionPool) if err != nil { - return fmt.Errorf("error validating data after process disbursement: %w", err) + return fmt.Errorf("validating data after process disbursement: %w", err) } log.Ctx(ctx).Info("Disbursement data validated") @@ -215,34 +219,34 @@ func (it *IntegrationTestsService) StartIntegrationTests(ctx context.Context, op log.Ctx(ctx).Info("Starting challenge transaction on anchor platform") challengeTx, err := it.anchorPlatform.StartChallengeTransaction() if err != nil { - return fmt.Errorf("error creating SEP10 challenge transaction: %w", err) + return fmt.Errorf("creating SEP10 challenge transaction: %w", err) } log.Ctx(ctx).Info("Challenge transaction created") log.Ctx(ctx).Info("Signing challenge transaction with Sep10SigningKey") signedTx, err := it.anchorPlatform.SignChallengeTransaction(challengeTx) if err != nil { - return fmt.Errorf("error signing SEP10 challenge transaction: %w", err) + return fmt.Errorf("signing SEP10 challenge transaction: %w", err) } log.Ctx(ctx).Info("Challenge transaction signed") log.Ctx(ctx).Info("Sending challenge transaction to anchor platform") authSEP10Token, err := it.anchorPlatform.SendSignedChallengeTransaction(signedTx) if err != nil { - return fmt.Errorf("error sending SEP10 challenge transaction: %w", err) + return fmt.Errorf("sending SEP10 challenge transaction: %w", err) } log.Ctx(ctx).Info("Received authSEP10Token") log.Ctx(ctx).Info("Creating SEP24 deposit transaction on anchor platform") authSEP24Token, _, err := it.anchorPlatform.CreateSep24DepositTransaction(authSEP10Token) if err != nil { - return fmt.Errorf("error creating SEP24 deposit transaction: %w", err) + return fmt.Errorf("creating SEP24 deposit transaction: %w", err) } log.Ctx(ctx).Info("Received authSEP24Token") disbursementData, err := readDisbursementCSV(opts.DisbursementCSVFilePath, opts.DisbursementCSVFileName) if err != nil { - return fmt.Errorf("error reading disbursement CSV: %w", err) + return fmt.Errorf("reading disbursement CSV: %w", err) } log.Ctx(ctx).Info("Completing receiver registration using server API") @@ -254,14 +258,14 @@ func (it *IntegrationTestsService) StartIntegrationTests(ctx context.Context, op ReCAPTCHAToken: opts.RecaptchaSiteKey, }) if err != nil { - return fmt.Errorf("error registring receiver: %w", err) + return fmt.Errorf("registring receiver: %w", err) } log.Ctx(ctx).Info("Receiver OTP obtained") log.Ctx(ctx).Info("Validating receiver data after completing registration") err = validateExpectationsAfterReceiverRegistration(ctx, it.models, opts.ReceiverAccountPublicKey, opts.ReceiverAccountStellarMemo, opts.WalletSEP10Domain) if err != nil { - return fmt.Errorf("error validating receiver after registration: %w", err) + return fmt.Errorf("validating receiver after registration: %w", err) } log.Ctx(ctx).Info("Receiver data validated") @@ -271,34 +275,36 @@ func (it *IntegrationTestsService) StartIntegrationTests(ctx context.Context, op log.Ctx(ctx).Info("Querying database to get disbursement receiver with payment data") receivers, err := it.models.DisbursementReceivers.GetAll(ctx, it.mtnDbConnectionPool, &data.QueryParams{}, disbursement.ID) if err != nil { - return fmt.Errorf("error getting receivers: %w", err) - } - - payment := receivers[0].Payment - q := `SELECT * FROM submitter_transactions WHERE external_id = $1` - var tx tss.Transaction - err = it.tssDbConnectionPool.GetContext(ctx, &tx, q, payment.ID) - if err != nil { - return fmt.Errorf("getting TSS transaction from database: %w", err) - } - log.Ctx(ctx).Infof("TSS transaction: %+v", tx) - - log.Ctx(ctx).Info("Getting payment from disbursement receiver") - if payment.Status != data.SuccessPaymentStatus || payment.StellarTransactionID == "" { - return fmt.Errorf("payment was not processed successfully by TSS: %+v", payment) - } - - log.Ctx(ctx).Info("Payment was successfully updated by the TSS") - log.Ctx(ctx).Info("Validating transaction on Horizon Network") - ph, err := getTransactionOnHorizon(it.horizonClient, payment.StellarTransactionID) - if err != nil { - return fmt.Errorf("error getting transaction on horizon network: %w", err) - } - err = validateStellarTransaction(ph, opts.ReceiverAccountPublicKey, opts.DisbursedAssetCode, opts.DisbursetAssetIssuer, receivers[0].Payment.Amount) - if err != nil { - return fmt.Errorf("error validating stellar transaction: %w", err) + return fmt.Errorf("getting receivers: %w", err) + } + + if schema.AccountType(opts.DistributionAccountType).IsStellar() { + payment := receivers[0].Payment + q := `SELECT * FROM submitter_transactions WHERE external_id = $1` + var tx tss.Transaction + err = it.tssDbConnectionPool.GetContext(ctx, &tx, q, payment.ID) + if err != nil { + return fmt.Errorf("getting TSS transaction from database: %w", err) + } + log.Ctx(ctx).Infof("TSS transaction: %+v", tx) + + log.Ctx(ctx).Info("Getting payment from disbursement receiver") + if payment.Status != data.SuccessPaymentStatus || payment.StellarTransactionID == "" { + return fmt.Errorf("payment was not processed successfully by TSS: %+v", payment) + } + + log.Ctx(ctx).Info("Payment was successfully updated by the TSS") + log.Ctx(ctx).Info("Validating transaction on Horizon Network") + ph, getPaymentErr := getTransactionOnHorizon(it.horizonClient, payment.StellarTransactionID) + if getPaymentErr != nil { + return fmt.Errorf("getting transaction on horizon network: %w", getPaymentErr) + } + err = validateStellarTransaction(ph, opts.ReceiverAccountPublicKey, opts.DisbursedAssetCode, opts.DisbursetAssetIssuer, receivers[0].Payment.Amount) + if err != nil { + return fmt.Errorf("validating stellar transaction: %w", err) + } + log.Ctx(ctx).Info("Transaction validated") } - log.Ctx(ctx).Info("Transaction validated") log.Ctx(ctx).Info("🎉🎉🎉Finishing integration tests, the receiver was successfully funded 🎉🎉🎉") @@ -307,14 +313,16 @@ func (it *IntegrationTestsService) StartIntegrationTests(ctx context.Context, op func (it *IntegrationTestsService) CreateTestData(ctx context.Context, opts IntegrationTestsOpts) error { // 1. Create new tenant and add owner user + distributionAccType := schema.AccountType(opts.DistributionAccountType) t, err := it.adminAPI.CreateTenant(ctx, CreateTenantRequest{ - Name: opts.TenantName, - OwnerEmail: opts.UserEmail, - OwnerFirstName: "John", - OwnerLastName: "Doe", - OrganizationName: "Integration Tests Organization", - BaseURL: "http://localhost:8000", - SDPUIBaseURL: "http://localhost:3000", + Name: opts.TenantName, + OwnerEmail: opts.UserEmail, + OwnerFirstName: "John", + OwnerLastName: "Doe", + OrganizationName: "Integration Tests Organization", + DistributionAccountType: distributionAccType, + BaseURL: "http://localhost:8000", + SDPUIBaseURL: "http://localhost:3000", }) if err != nil { return fmt.Errorf("creating tenant: %w", err) @@ -325,23 +333,43 @@ func (it *IntegrationTestsService) CreateTestData(ctx context.Context, opts Inte // 2. Reset password for the user hashedPassword, err := bcrypt.GenerateFromPassword([]byte(opts.UserPassword), bcrypt.DefaultCost) if err != nil { - return fmt.Errorf("error hashing owner user password: %w", err) + return fmt.Errorf("hashing owner user password: %w", err) } query := `UPDATE auth_users SET encrypted_password = $1 WHERE email = $2` _, err = it.mtnDbConnectionPool.ExecContext(ctx, query, hashedPassword, opts.UserEmail) if err != nil { - return fmt.Errorf("error updating owner user password: %w", err) + return fmt.Errorf("updating owner user password: %w", err) } // 3. Create test asset and wallet _, err = it.models.Assets.GetOrCreate(ctx, opts.DisbursedAssetCode, opts.DisbursetAssetIssuer) if err != nil { - return fmt.Errorf("error getting or creating test asset: %w", err) + return fmt.Errorf("getting or creating test asset: %w", err) } _, err = it.models.Wallets.GetOrCreate(ctx, opts.WalletName, opts.WalletHomepage, opts.WalletDeepLink, opts.WalletSEP10Domain) if err != nil { - return fmt.Errorf("error getting or creating test wallet: %w", err) + return fmt.Errorf("getting or creating test wallet: %w", err) + } + + // 4. Provision Circle distribution account if needed + if distributionAccType.IsCircle() { + // 4.1. Create Circle configuration by calling endpoint + it.initServices(ctx, opts) + authToken, loginErr := it.serverAPI.Login(ctx) + if loginErr != nil { + return fmt.Errorf("trying to login in server API: %w", loginErr) + } + + err = it.serverAPI.ConfigureCircleAccess(ctx, + authToken, + &httphandler.PatchCircleConfigRequest{ + WalletID: &opts.CircleUSDCWalletID, + APIKey: &opts.CircleAPIKey, + }) + if err != nil { + return fmt.Errorf("configuring Circle access: %w", err) + } } return nil diff --git a/internal/integrationtests/main.go b/internal/integrationtests/main.go index 5884bbcef..c8eb93bf7 100644 --- a/internal/integrationtests/main.go +++ b/internal/integrationtests/main.go @@ -2,5 +2,5 @@ package integrationtests import "embed" -//go:embed files/* +//go:embed resources/* var DisbursementCSVFiles embed.FS diff --git a/internal/integrationtests/files/disbursement_integration_tests.csv b/internal/integrationtests/resources/disbursement_integration_tests.csv similarity index 100% rename from internal/integrationtests/files/disbursement_integration_tests.csv rename to internal/integrationtests/resources/disbursement_integration_tests.csv diff --git a/internal/integrationtests/files/empty_csv_file.csv b/internal/integrationtests/resources/empty_csv_file.csv similarity index 100% rename from internal/integrationtests/files/empty_csv_file.csv rename to internal/integrationtests/resources/empty_csv_file.csv diff --git a/internal/integrationtests/resources/single_tenant_dump.sql b/internal/integrationtests/resources/single_tenant_dump.sql new file mode 100644 index 000000000..510743a12 --- /dev/null +++ b/internal/integrationtests/resources/single_tenant_dump.sql @@ -0,0 +1,1796 @@ +-- +-- PostgreSQL database dump +-- + +-- Dumped from database version 12.17 +-- Dumped by pg_dump version 12.19 (Debian 12.19-1.pgdg120+1) + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +-- +-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public; + + +-- +-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner: +-- + +COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)'; + + +-- +-- Name: disbursement_status; Type: TYPE; Schema: public; Owner: postgres +-- + +CREATE TYPE public.disbursement_status AS ENUM ( + 'DRAFT', + 'READY', + 'STARTED', + 'PAUSED', + 'COMPLETED' +); + + +ALTER TYPE public.disbursement_status OWNER TO postgres; + +-- +-- Name: message_status; Type: TYPE; Schema: public; Owner: postgres +-- + +CREATE TYPE public.message_status AS ENUM ( + 'PENDING', + 'SUCCESS', + 'FAILURE' +); + + +ALTER TYPE public.message_status OWNER TO postgres; + +-- +-- Name: message_type; Type: TYPE; Schema: public; Owner: postgres +-- + +CREATE TYPE public.message_type AS ENUM ( + 'TWILIO_SMS', + 'AWS_SMS', + 'AWS_EMAIL', + 'DRY_RUN' +); + + +ALTER TYPE public.message_type OWNER TO postgres; + +-- +-- Name: payment_status; Type: TYPE; Schema: public; Owner: postgres +-- + +CREATE TYPE public.payment_status AS ENUM ( + 'DRAFT', + 'READY', + 'PENDING', + 'PAUSED', + 'SUCCESS', + 'FAILED', + 'CANCELED' +); + + +ALTER TYPE public.payment_status OWNER TO postgres; + +-- +-- Name: receiver_wallet_status; Type: TYPE; Schema: public; Owner: postgres +-- + +CREATE TYPE public.receiver_wallet_status AS ENUM ( + 'DRAFT', + 'READY', + 'REGISTERED', + 'FLAGGED' +); + + +ALTER TYPE public.receiver_wallet_status OWNER TO postgres; + +-- +-- Name: transaction_status; Type: TYPE; Schema: public; Owner: postgres +-- + +CREATE TYPE public.transaction_status AS ENUM ( + 'PENDING', + 'PROCESSING', + 'SUCCESS', + 'ERROR' +); + + +ALTER TYPE public.transaction_status OWNER TO postgres; + +-- +-- Name: verification_type; Type: TYPE; Schema: public; Owner: postgres +-- + +CREATE TYPE public.verification_type AS ENUM ( + 'DATE_OF_BIRTH', + 'PIN', + 'NATIONAL_ID_NUMBER' +); + + +ALTER TYPE public.verification_type OWNER TO postgres; + +-- +-- Name: auth_user_mfa_codes_before_update(); Type: FUNCTION; Schema: public; Owner: postgres +-- + +CREATE FUNCTION public.auth_user_mfa_codes_before_update() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$; + + +ALTER FUNCTION public.auth_user_mfa_codes_before_update() OWNER TO postgres; + +-- +-- Name: auth_user_password_reset_before_insert(); Type: FUNCTION; Schema: public; Owner: postgres +-- + +CREATE FUNCTION public.auth_user_password_reset_before_insert() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + UPDATE + auth_user_password_reset + SET + is_valid = false + WHERE + auth_user_id = NEW.auth_user_id; + + RETURN NEW; +END; +$$; + + +ALTER FUNCTION public.auth_user_password_reset_before_insert() OWNER TO postgres; + +-- +-- Name: create_disbursement_status_history(timestamp with time zone, public.disbursement_status, character varying); Type: FUNCTION; Schema: public; Owner: postgres +-- + +CREATE FUNCTION public.create_disbursement_status_history(time_stamp timestamp with time zone, disb_status public.disbursement_status, user_id character varying) RETURNS jsonb + LANGUAGE plpgsql + AS $$ + BEGIN + RETURN json_build_object( + 'timestamp', time_stamp, + 'status', disb_status, + 'user_id', user_id + ); + END; +$$; + + +ALTER FUNCTION public.create_disbursement_status_history(time_stamp timestamp with time zone, disb_status public.disbursement_status, user_id character varying) OWNER TO postgres; + +-- +-- Name: create_message_status_history(timestamp with time zone, public.message_status, character varying); Type: FUNCTION; Schema: public; Owner: postgres +-- + +CREATE FUNCTION public.create_message_status_history(time_stamp timestamp with time zone, m_status public.message_status, status_message character varying) RETURNS jsonb + LANGUAGE plpgsql + AS $$ + BEGIN + RETURN jsonb_build_object( + 'timestamp', time_stamp, + 'status', m_status, + 'status_message', status_message + ); + END; +$$; + + +ALTER FUNCTION public.create_message_status_history(time_stamp timestamp with time zone, m_status public.message_status, status_message character varying) OWNER TO postgres; + +-- +-- Name: create_payment_status_history(timestamp with time zone, public.payment_status, character varying); Type: FUNCTION; Schema: public; Owner: postgres +-- + +CREATE FUNCTION public.create_payment_status_history(time_stamp timestamp with time zone, pay_status public.payment_status, status_message character varying) RETURNS jsonb + LANGUAGE plpgsql + AS $$ + BEGIN + RETURN json_build_object( + 'timestamp', time_stamp, + 'status', pay_status, + 'status_message', status_message + ); + END; +$$; + + +ALTER FUNCTION public.create_payment_status_history(time_stamp timestamp with time zone, pay_status public.payment_status, status_message character varying) OWNER TO postgres; + +-- +-- Name: create_receiver_wallet_status_history(timestamp with time zone, public.receiver_wallet_status); Type: FUNCTION; Schema: public; Owner: postgres +-- + +CREATE FUNCTION public.create_receiver_wallet_status_history(time_stamp timestamp with time zone, rw_status public.receiver_wallet_status) RETURNS jsonb + LANGUAGE plpgsql + AS $$ + BEGIN + RETURN json_build_object( + 'timestamp', time_stamp, + 'status', rw_status + ); + END; +$$; + + +ALTER FUNCTION public.create_receiver_wallet_status_history(time_stamp timestamp with time zone, rw_status public.receiver_wallet_status) OWNER TO postgres; + +-- +-- Name: create_submitter_transactions_status_history(timestamp with time zone, public.transaction_status, character varying, text, text, text); Type: FUNCTION; Schema: public; Owner: postgres +-- + +CREATE FUNCTION public.create_submitter_transactions_status_history(time_stamp timestamp with time zone, tss_status public.transaction_status, status_message character varying, stellar_transaction_hash text, xdr_sent text, xdr_received text) RETURNS jsonb + LANGUAGE plpgsql + AS $$ + BEGIN + RETURN json_build_object( + 'timestamp', time_stamp, + 'status', tss_status, + 'status_message', status_message, + 'stellar_transaction_hash', stellar_transaction_hash, + 'xdr_sent', xdr_sent, + 'xdr_received', xdr_received + ); + END; +$$; + + +ALTER FUNCTION public.create_submitter_transactions_status_history(time_stamp timestamp with time zone, tss_status public.transaction_status, status_message character varying, stellar_transaction_hash text, xdr_sent text, xdr_received text) OWNER TO postgres; + +-- +-- Name: enforce_single_row_for_organizations(); Type: FUNCTION; Schema: public; Owner: postgres +-- + +CREATE FUNCTION public.enforce_single_row_for_organizations() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF (SELECT COUNT(*) FROM public.organizations) != 0 THEN + RAISE EXCEPTION 'public.organizations can must contain exactly one row'; + END IF; + RETURN NEW; +END; +$$; + + +ALTER FUNCTION public.enforce_single_row_for_organizations() OWNER TO postgres; + +-- +-- Name: update_at_refresh(); Type: FUNCTION; Schema: public; Owner: postgres +-- + +CREATE FUNCTION public.update_at_refresh() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$; + + +ALTER FUNCTION public.update_at_refresh() OWNER TO postgres; + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: assets; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.assets ( + id character varying(36) DEFAULT public.uuid_generate_v4() NOT NULL, + code character varying(12) NOT NULL, + issuer character varying(56) NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + deleted_at timestamp with time zone, + CONSTRAINT asset_issuer_length_check CHECK (((((code)::text = 'XLM'::text) AND (char_length((issuer)::text) = 0)) OR (char_length((issuer)::text) = 56))) +); + + +ALTER TABLE public.assets OWNER TO postgres; + +-- +-- Name: auth_migrations; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.auth_migrations ( + id text NOT NULL, + applied_at timestamp with time zone +); + + +ALTER TABLE public.auth_migrations OWNER TO postgres; + +-- +-- Name: auth_user_mfa_codes; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.auth_user_mfa_codes ( + device_id text NOT NULL, + auth_user_id character varying(36) NOT NULL, + code character varying(8), + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + device_expires_at timestamp with time zone, + code_expires_at timestamp with time zone +); + + +ALTER TABLE public.auth_user_mfa_codes OWNER TO postgres; + +-- +-- Name: auth_user_password_reset; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.auth_user_password_reset ( + token text NOT NULL, + auth_user_id character varying(36) NOT NULL, + is_valid boolean DEFAULT true NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL +); + + +ALTER TABLE public.auth_user_password_reset OWNER TO postgres; + +-- +-- Name: auth_users; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.auth_users ( + id character varying(36) DEFAULT public.uuid_generate_v4() NOT NULL, + encrypted_password text NOT NULL, + email text NOT NULL, + is_owner boolean DEFAULT false NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + roles text[], + is_active boolean DEFAULT true, + first_name character varying(128) DEFAULT ''::character varying NOT NULL, + last_name character varying(128) DEFAULT ''::character varying NOT NULL +); + + +ALTER TABLE public.auth_users OWNER TO postgres; + +-- +-- Name: channel_accounts; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.channel_accounts ( + public_key character varying(64) NOT NULL, + private_key character varying(256), + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + locked_at timestamp with time zone, + locked_until_ledger_number integer +); + + +ALTER TABLE public.channel_accounts OWNER TO postgres; + +-- +-- Name: countries; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.countries ( + code character varying(3) NOT NULL, + name character varying(100) NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + deleted_at timestamp with time zone, + CONSTRAINT country_code_length_check CHECK ((char_length((code)::text) = 3)) +); + + +ALTER TABLE public.countries OWNER TO postgres; + +-- +-- Name: disbursements; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.disbursements ( + id character varying(64) DEFAULT public.uuid_generate_v4() NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + asset_id character varying(36) NOT NULL, + country_code character varying(3) NOT NULL, + wallet_id character varying(36) NOT NULL, + name character varying(128) NOT NULL, + status public.disbursement_status DEFAULT 'DRAFT'::public.disbursement_status NOT NULL, + status_history jsonb[] DEFAULT ARRAY[public.create_disbursement_status_history(now(), 'DRAFT'::public.disbursement_status, NULL::character varying)] NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + verification_field public.verification_type DEFAULT 'DATE_OF_BIRTH'::public.verification_type NOT NULL, + file_content bytea, + file_name text, + sms_registration_message_template text +); + + +ALTER TABLE public.disbursements OWNER TO postgres; + +-- +-- Name: gorp_migrations; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.gorp_migrations ( + id text NOT NULL, + applied_at timestamp with time zone +); + + +ALTER TABLE public.gorp_migrations OWNER TO postgres; + +-- +-- Name: messages; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.messages ( + id character varying(36) DEFAULT public.uuid_generate_v4() NOT NULL, + type public.message_type NOT NULL, + asset_id character varying(36), + wallet_id character varying(36) NOT NULL, + receiver_id character varying(36) NOT NULL, + text_encrypted character varying(1024) NOT NULL, + title_encrypted character varying(128), + created_at timestamp with time zone DEFAULT now() NOT NULL, + status public.message_status DEFAULT 'PENDING'::public.message_status NOT NULL, + status_history jsonb[] DEFAULT ARRAY[public.create_message_status_history(now(), 'PENDING'::public.message_status, NULL::character varying)] NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + receiver_wallet_id character varying(36) +); + + +ALTER TABLE public.messages OWNER TO postgres; + +-- +-- Name: organizations; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.organizations ( + id character varying(36) DEFAULT public.uuid_generate_v4() NOT NULL, + name character varying(64) NOT NULL, + timezone_utc_offset character varying(6) DEFAULT '+00:00'::character varying NOT NULL, + sms_registration_message_template character varying(255) DEFAULT 'You have a payment waiting for you from the {{.OrganizationName}}. Click {{.RegistrationLink}} to register.'::character varying NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + logo bytea, + is_approval_required boolean DEFAULT false NOT NULL, + otp_message_template character varying(255) DEFAULT '{{.OTP}} is your {{.OrganizationName}} phone verification code.'::character varying NOT NULL, + sms_resend_interval integer, + payment_cancellation_period_days integer, + CONSTRAINT organization_name_not_empty_check CHECK ((char_length((name)::text) > 1)), + CONSTRAINT organization_sms_resend_interval_valid_value_check CHECK ((((sms_resend_interval IS NOT NULL) AND (sms_resend_interval > 0)) OR (sms_resend_interval IS NULL))), + CONSTRAINT organization_timezone_size_check CHECK ((char_length((timezone_utc_offset)::text) = 6)) +); + + +ALTER TABLE public.organizations OWNER TO postgres; + +-- +-- Name: COLUMN organizations.is_approval_required; Type: COMMENT; Schema: public; Owner: postgres +-- + +COMMENT ON COLUMN public.organizations.is_approval_required IS 'Column used to enable disbursement approval for organizations, requiring multiple users to start a disbursement.'; + + +-- +-- Name: payments; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.payments ( + id character varying(64) DEFAULT public.uuid_generate_v4() NOT NULL, + stellar_transaction_id character varying(64), + created_at timestamp with time zone DEFAULT now() NOT NULL, + receiver_id character varying(64) NOT NULL, + disbursement_id character varying(64) NOT NULL, + amount numeric(19,7) NOT NULL, + asset_id character varying(36) NOT NULL, + stellar_operation_id character varying(32), + blockchain_sender_id character varying(69), + status public.payment_status DEFAULT 'DRAFT'::public.payment_status NOT NULL, + status_history jsonb[] DEFAULT ARRAY[public.create_payment_status_history(now(), 'DRAFT'::public.payment_status, NULL::character varying)] NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + receiver_wallet_id character varying(64) NOT NULL, + external_payment_id character varying(64) +); + + +ALTER TABLE public.payments OWNER TO postgres; + +-- +-- Name: receiver_verifications; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.receiver_verifications ( + receiver_id character varying(64) NOT NULL, + verification_field public.verification_type NOT NULL, + hashed_value text NOT NULL, + attempts smallint DEFAULT 0 NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + confirmed_at timestamp with time zone, + failed_at timestamp with time zone +); + + +ALTER TABLE public.receiver_verifications OWNER TO postgres; + +-- +-- Name: receiver_wallets; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.receiver_wallets ( + id character varying(36) DEFAULT public.uuid_generate_v4() NOT NULL, + receiver_id character varying(36) NOT NULL, + wallet_id character varying(36) NOT NULL, + stellar_address character varying(56), + stellar_memo character varying(56), + stellar_memo_type character varying(56), + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + status public.receiver_wallet_status DEFAULT 'DRAFT'::public.receiver_wallet_status NOT NULL, + status_history jsonb[] DEFAULT ARRAY[public.create_receiver_wallet_status_history(now(), 'DRAFT'::public.receiver_wallet_status)] NOT NULL, + otp text, + otp_created_at timestamp with time zone, + otp_confirmed_at timestamp with time zone, + anchor_platform_transaction_id text, + invitation_sent_at timestamp with time zone, + anchor_platform_transaction_synced_at timestamp with time zone +); + + +ALTER TABLE public.receiver_wallets OWNER TO postgres; + +-- +-- Name: receivers; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.receivers ( + id character varying(64) DEFAULT public.uuid_generate_v4() NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + phone_number character varying(32) NOT NULL, + email character varying(254), + updated_at timestamp with time zone DEFAULT now() NOT NULL, + external_id character varying(64) +); + + +ALTER TABLE public.receivers OWNER TO postgres; + +-- +-- Name: submitter_transactions; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.submitter_transactions ( + id character varying(64) DEFAULT public.uuid_generate_v4() NOT NULL, + external_id character varying(64) NOT NULL, + status_message text, + asset_code character varying(12) NOT NULL, + asset_issuer character varying(56) NOT NULL, + amount numeric(19,7) NOT NULL, + destination character varying(56) NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + started_at timestamp with time zone, + sent_at timestamp with time zone, + completed_at timestamp with time zone, + stellar_transaction_hash character varying(64), + attempts_count integer DEFAULT 0, + synced_at timestamp with time zone, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + xdr_sent text, + xdr_received text, + locked_at timestamp with time zone, + locked_until_ledger_number integer, + status public.transaction_status DEFAULT 'PENDING'::public.transaction_status NOT NULL, + status_history jsonb[] DEFAULT ARRAY[public.create_submitter_transactions_status_history(now(), 'PENDING'::public.transaction_status, NULL::character varying, NULL::text, NULL::text, NULL::text)], + CONSTRAINT asset_issuer_length_check CHECK (((((asset_code)::text = 'XLM'::text) AND (char_length((asset_issuer)::text) = 0)) OR (char_length((asset_issuer)::text) = 56))), + CONSTRAINT check_retry_count CHECK ((attempts_count >= 0)) +); + + +ALTER TABLE public.submitter_transactions OWNER TO postgres; + +-- +-- Name: wallets; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.wallets ( + id character varying(36) DEFAULT public.uuid_generate_v4() NOT NULL, + name character varying(30) NOT NULL, + homepage character varying(255) NOT NULL, + deep_link_schema character varying(255) NOT NULL, + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + deleted_at timestamp with time zone, + sep_10_client_domain character varying(255) DEFAULT ''::character varying NOT NULL, + enabled boolean DEFAULT true NOT NULL +); + + +ALTER TABLE public.wallets OWNER TO postgres; + +-- +-- Name: wallets_assets; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.wallets_assets ( + wallet_id character varying(36), + asset_id character varying(36) +); + + +ALTER TABLE public.wallets_assets OWNER TO postgres; + +-- +-- Data for Name: assets; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.assets (id, code, issuer, created_at, updated_at, deleted_at) FROM stdin; +4c62168d-b092-4073-b1c2-0e4c19377188 USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 2023-06-02 17:26:12.256765+00 2023-06-02 17:26:12.256765+00 \N +8cf40625-7eb8-49e6-bd29-0352a175d059 EUROC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 2023-06-02 17:26:12.45239+00 2023-06-02 17:26:12.45239+00 \N +e7cc851e-ed85-479f-a68d-8c74cadfa755 XLM 2023-09-06 00:55:30.430546+00 2023-09-06 00:55:30.430546+00 \N +\. + + +-- +-- Data for Name: auth_migrations; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.auth_migrations (id, applied_at) FROM stdin; +2023-02-09.0.add-users-table.sql 2023-06-02 17:26:12.55839+00 +2023-03-07.0.add-password-reset-table.sql 2023-06-02 17:26:12.569327+00 +2023-03-10.0.alter-users-table-add-roles-column.sql 2023-06-02 17:26:12.571269+00 +2023-03-22.0.alter-users-table-add-is_active-column.sql 2023-06-02 17:26:12.573017+00 +2023-03-28.0.alter-users-table-add-new-columns-and-drop-username-column.sql 2023-06-02 17:26:12.575227+00 +2023-07-20.0-create-auth_user_mfa_codes_table.sql 2023-09-06 00:48:57.3333+00 +\. + + +-- +-- Data for Name: auth_user_mfa_codes; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.auth_user_mfa_codes (device_id, auth_user_id, code, created_at, updated_at, device_expires_at, code_expires_at) FROM stdin; +df6ffbf0-f5ab-450c-a906-4a6841d52eac 1888f2c6-7ffd-4af7-833a-3ee34e6603db \N 2023-09-28 16:55:44.521235+00 2024-03-20 18:53:04.080603+00 2024-03-27 18:53:04.061926+00 \N +9e86495b-eb10-41c6-a70a-c450b7bdb4cf e6d26f49-7736-4cea-bb4f-0f3cb573fae5 \N 2023-10-17 17:49:06.278766+00 2024-06-10 14:59:24.701781+00 2024-06-17 14:59:24.677946+00 \N +0f20c606-a1eb-4952-afec-dcdca83ff897 32097fa4-c3fb-4829-a509-c1e95c75ae62 \N 2024-04-17 21:33:09.948818+00 2024-04-17 21:33:29.236204+00 2024-04-24 21:33:29.213983+00 \N +6b6d0712-caab-49e8-ba5e-4a5dcf0b0103 eba6665d-aeb9-4918-a9ea-07d9c52fe23c 002147 2023-09-19 20:16:12.22511+00 2023-09-19 20:23:15.325055+00 \N 2023-09-19 20:28:15.326416+00 +9e86495b-eb10-41c6-a70a-c450b7bdb4cf b4e1354e-6998-4910-814c-8bd21d8e70ba \N 2024-02-27 20:39:54.168166+00 2024-02-27 20:40:08.912322+00 2024-03-05 20:40:08.88545+00 \N +4c798efb-6908-421b-a847-ae0e2f28d00e e2c2d239-a4e6-4a8d-98d0-372708b7769e \N 2024-04-25 19:33:55.158231+00 2024-04-25 19:34:21.315844+00 2024-05-02 19:34:21.288711+00 \N +9e86495b-eb10-41c6-a70a-c450b7bdb4cf fec3508c-8414-42e8-9e9b-d1144d135991 536456 2023-10-17 17:48:39.778657+00 2023-10-23 22:57:54.304632+00 \N 2023-10-23 23:02:54.300583+00 +d41411eb-f78b-403d-8726-24a23833439b fec3508c-8414-42e8-9e9b-d1144d135991 493318 2023-10-30 17:29:31.996695+00 2023-10-30 17:29:31.996695+00 \N 2023-10-30 17:34:32.000395+00 +d41411eb-f78b-403d-8726-24a23833439b 71807142-7483-4c75-b1b4-a382323fcd0f 664640 2023-10-30 17:30:30.197115+00 2023-10-30 17:30:30.197115+00 \N 2023-10-30 17:35:30.200797+00 +f8b8898b-24dd-40d3-965b-3802b29cbe2a 42fcd8be-cc7f-4087-bd54-1716fe26fa12 \N 2024-04-30 06:36:44.164286+00 2024-04-30 06:37:07.105476+00 2024-05-07 06:37:07.077717+00 \N +d41411eb-f78b-403d-8726-24a23833439b c7032759-cf6a-4494-bce5-014961f77820 \N 2023-10-30 17:34:25.997118+00 2023-10-30 17:34:51.139664+00 2023-11-06 17:34:51.119285+00 \N +9bda184f-378f-4b8d-90f9-0fa520cf6368 66c98f75-4368-4913-8757-b8d41225c609 \N 2024-02-23 21:15:44.244549+00 2024-05-01 13:37:26.003546+00 2024-05-08 13:37:25.985541+00 \N +9bda184f-378f-4b8d-90f9-0fa520cf6368 32097fa4-c3fb-4829-a509-c1e95c75ae62 \N 2024-02-28 20:15:17.96726+00 2024-02-28 20:15:38.644909+00 2024-03-06 20:15:38.616111+00 \N +c91a8dfe-0491-4f9f-8ee8-6373c065d402 66c98f75-4368-4913-8757-b8d41225c609 \N 2023-09-06 01:14:40.106699+00 2024-02-09 19:15:34.851485+00 2024-02-16 19:15:34.830646+00 \N +7b0635fc-1252-4ab9-ab13-8eefba5257ce f02906dc-feb0-48c4-8376-f26648bae486 \N 2023-10-16 22:47:53.0262+00 2024-07-17 22:58:15.606135+00 2024-07-24 22:58:15.575103+00 \N +ababf015-b8d0-454d-a072-70869a50b799 66c98f75-4368-4913-8757-b8d41225c609 \N 2023-09-06 01:19:13.626213+00 2024-07-17 22:59:30.648542+00 2024-07-24 22:59:30.620179+00 \N +ababf015-b8d0-454d-a072-70869a50b799 32097fa4-c3fb-4829-a509-c1e95c75ae62 \N 2024-02-06 02:45:35.886778+00 2024-02-29 02:23:24.662823+00 2024-03-07 02:23:24.633368+00 \N +4e43a551-1785-47ec-9b85-1f66bf07a9b5 13c0af15-269e-4f13-94a2-ba30e64d1981 \N 2024-02-23 22:20:19.267219+00 2024-03-11 16:27:00.160158+00 2024-03-18 16:27:00.137488+00 \N +\. + + +-- +-- Data for Name: auth_user_password_reset; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.auth_user_password_reset (token, auth_user_id, is_valid, created_at) FROM stdin; +Y1Cmp8atgh e6d26f49-7736-4cea-bb4f-0f3cb573fae5 f 2023-07-05 22:49:34.742438+00 +LfdfaGtzh0 7c19c62a-f05a-4676-8f2e-0bc66e6f08b7 t 2023-07-17 21:45:48.697907+00 +kzrBobEjTm 0f129e4c-6a4d-433c-a922-f8203203f348 t 2023-07-17 21:55:15.821328+00 +8JD9BLKrd4 73078b8e-4617-476c-8e34-c25e06f2677e f 2023-07-17 21:48:17.720417+00 +PXp8q8HU96 73078b8e-4617-476c-8e34-c25e06f2677e f 2023-07-17 21:55:56.886549+00 +k6WOiHSu6D 73078b8e-4617-476c-8e34-c25e06f2677e f 2023-07-17 21:56:07.746534+00 +fAtpl2Xy4J 86e3b2ad-af15-419c-b7ac-d33c35198413 f 2023-07-17 21:57:27.159719+00 +onVJwgMeI5 53510b69-aa3b-4332-a18b-60ad253991f5 f 2023-07-17 21:57:34.231307+00 +wvN0dbh46k 7e930e21-a5ee-4e09-9bbe-acaabcc673c3 f 2023-08-17 17:41:52.020442+00 +l2Qa20lDf0 1b146511-63fc-4286-b828-8403cc3b83c2 f 2023-08-17 19:36:16.2278+00 +8RSpRlFZ8Z f02906dc-feb0-48c4-8376-f26648bae486 f 2023-09-19 22:45:05.896337+00 +EblWd1BRhb f02906dc-feb0-48c4-8376-f26648bae486 f 2023-10-16 22:47:09.472406+00 +yXceAQVI6X c7032759-cf6a-4494-bce5-014961f77820 f 2023-07-17 16:37:21.205316+00 +F8xwq4KnAb c7032759-cf6a-4494-bce5-014961f77820 f 2023-07-17 17:21:03.390257+00 +zfNUfJOvKh c7032759-cf6a-4494-bce5-014961f77820 f 2023-07-17 17:50:06.991901+00 +SJ7Abid6Yz c7032759-cf6a-4494-bce5-014961f77820 f 2023-07-17 17:52:58.168792+00 +Rdz1Ds4ATi c7032759-cf6a-4494-bce5-014961f77820 f 2023-07-28 18:35:31.862075+00 +XSKtz3sbQC c7032759-cf6a-4494-bce5-014961f77820 f 2023-10-30 17:32:24.696499+00 +hhu7TQ0Asl 32097fa4-c3fb-4829-a509-c1e95c75ae62 f 2024-02-06 02:39:44.897673+00 +3yAGyXP3I8 32097fa4-c3fb-4829-a509-c1e95c75ae62 f 2024-02-06 02:44:30.264815+00 +xr0mRtKNYI 13c0af15-269e-4f13-94a2-ba30e64d1981 f 2024-02-23 22:19:44.316539+00 +9kk7Q5lT5r b4e1354e-6998-4910-814c-8bd21d8e70ba f 2024-02-27 20:36:13.236823+00 +NkaDxetHBE 1888f2c6-7ffd-4af7-833a-3ee34e6603db f 2023-08-17 12:21:37.454496+00 +qfWflJCg6t 1888f2c6-7ffd-4af7-833a-3ee34e6603db f 2023-09-28 16:54:49.712146+00 +rlxF9eoW8b 1888f2c6-7ffd-4af7-833a-3ee34e6603db f 2024-03-20 18:52:00.515443+00 +YHP8aGIKu7 e2c2d239-a4e6-4a8d-98d0-372708b7769e f 2024-04-25 19:32:31.297648+00 +qFm1mQweon 42fcd8be-cc7f-4087-bd54-1716fe26fa12 f 2024-04-30 06:34:52.126436+00 +\. + + +-- +-- Data for Name: auth_users; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.auth_users (id, encrypted_password, email, is_owner, created_at, roles, is_active, first_name, last_name) FROM stdin; +eba6665d-aeb9-4918-a9ea-07d9c52fe23c $2a$10$d/OrXRFJQoYb528yA61jX.b8h26JuCKaK1Ngf0E30H.ScYiKLlkrG jake+owner@stellar.org t 2023-06-06 20:30:51.89708+00 {owner} t Jake Owner +d4ffa91c-be6b-4a5a-88a3-21b5d8410559 $2a$10$99c1YTs63qlZfDjNb5IFQ.ffyNcL3Gmex3pKe79PRTSqSoqtZcli2 nando+owner@stellar.org t 2023-06-12 20:36:19.00376+00 {owner} t Nando Owner +fec3508c-8414-42e8-9e9b-d1144d135991 $2a$10$MVSZvGFptylxh76n8syJzuXGoepf47dvJGs4qKRfL5o4NBk6Oyz1G audit.owner@stellar.org f 2023-07-14 20:22:36.481631+00 {owner} t owner auditor +71807142-7483-4c75-b1b4-a382323fcd0f $2a$10$CqfVoNSm5DKYvbKsjM5zi.qJD.MuzVOW9NTsN.S9pku7qit/jsWOW audit.financial@stellar.org f 2023-07-14 20:28:52.488267+00 {financial_controller} t financial auditor +6a12d770-aa39-4372-b944-37380be181a8 $2a$10$wKGH532/IwxEwDs9Kasa6eGuADRoChmpq9axtCIGMi9UIhI58q4IW audit.developer@stellar.org f 2023-07-14 20:29:37.872054+00 {developer} t developer auditor +7d609e78-29be-41bd-b86b-4f4105b5d688 $2a$10$xFaRPtrzKynTZ1pb8V/pp.hn6oR4nzSb3gSQrPClrStSImwLL0n42 audit.business@stellar.org f 2023-07-14 20:30:11.183525+00 {business} t business auditor +644aef15-41ad-4f21-a9f3-ae0356a88d69 $2a$10$DSUn7R0VMJ1mldxi0YAMmeim9enL4qVDsJAxuuOoYfdrJiDbQlOye msequeira+t1@coinspect.com f 2023-07-17 22:16:51.553748+00 {financial_controller} f test1 test1 +3321a56b-01ba-42f2-b250-82245a2d31b4 $2a$10$qraJpicxBarGkDolA0vTme0FS9Wn4QnYx62HNCZN9FqJZR3vhKvhi msequeira+t3@coinspect.com f 2023-07-17 22:38:01.057056+00 {financial_controller} t test3 test3 +7c19c62a-f05a-4676-8f2e-0bc66e6f08b7 $2a$10$rDetABe/ZxN4wv2kv04JKuBPAWmWZlKCWfHdNqqd1JgDrqmDFNqR. msequeira.business@coinspect.com f 2023-07-17 21:35:46.85659+00 {business} f MatiasBusiness Coinspect +0f129e4c-6a4d-433c-a922-f8203203f348 $2a$10$Y/KbGJT5nVAl4IBuHF8YHuHPvM4AWKQfhhnNLAYwJc67tsQh4rD1. msequeira.developer@coinspect.com f 2023-07-17 21:35:01.252207+00 {developer} f MatiasDeveloper Coinspect +c412fbbe-e7dc-4ff7-be79-a5119db41b8a $2a$10$xb16snCtVZKqHF9eHttfAOpkWHy2UL2P0.j1gRjNBaRWxeV56VXv6 msequeira.financialcont@coinspect.com f 2023-07-17 21:33:57.958158+00 {financial_controller} f MatiasFinancialCont Coinspect +53510b69-aa3b-4332-a18b-60ad253991f5 $2a$10$vzuF2/1f.Txae5EnZK3hN.Y6xgzl/CrVgjlzPrzB1X3Z4W9Muwaze msequeira+financial@coinspect.com f 2023-07-17 21:55:05.643189+00 {financial_controller} t MatiasFinanciall Coinspect +73078b8e-4617-476c-8e34-c25e06f2677e $2a$10$E195lifXN3N8BzYt5raSoO1ALbXsLjC7WCVnjk0P/txEZzrwr4ihi msequeira+business@coinspect.com f 2023-07-17 21:47:53.360226+00 {business} t MatiasBusiness2 Coinspect +86e3b2ad-af15-419c-b7ac-d33c35198413 $2a$10$PwIrwDTIRZ/xGydsTe7kOel1fmwlDnqrULS2RZJlmLoBUtiYE6EH2 msequeira+developer@coinspect.com f 2023-07-17 21:54:35.860308+00 {developer} t MatiasDeveloper Coinspect +64bafd6b-2252-494e-b50e-3791cf1f3201 $2a$10$y6tFuqBAMSI6uXVk5iW6MeX9kiYZaFFPpMhQtglRITN8JFivjkww6 natam.oliveira+owner@ckl.io f 2023-08-16 22:39:04.374692+00 {owner} t Natam Owner +240b9cfa-c566-474f-be5c-0148b23dcf06 $2a$10$kyJ2JsPBhlobVAR7t9E81eZyzOp1MAihyfGB4DoLNAuFn/A8Z.6ua msequeira+t2@coinspect.com f 2023-07-17 22:31:18.658787+00 {business} t test2 test2 +928b0f3e-a61c-4be0-9420-c01a015cda7c $2a$10$o61TUDv1e1iN9Wa61JcI7eJlA.BKIEzRYmIeDX4HrKXYbHdHYyFSq erica.liu+owner@stellar.org t 2023-09-19 23:10:28.924608+00 {owner} t Erica Owner +7ad32e05-bea3-4472-bf9e-33f562cb0069 $2a$10$yWZeJNaMVufD7zYKSeHDfOghSFKZ4ua4v8lYa7RXafegCiqfRtz9u msequeira+t23@coinspect.com f 2023-07-17 22:32:48.546237+00 {developer} f test2 test +32097fa4-c3fb-4829-a509-c1e95c75ae62 $2a$10$acBudGvjaqptIJmhN8nI6u7IFFs71/ir15Q93ekbmKnvFnct/h5EO marcelo+financial@stellar.org f 2024-02-06 02:38:23.687569+00 {financial_controller} t Marcelo Financial +7e930e21-a5ee-4e09-9bbe-acaabcc673c3 $2a$10$4RUFHDVBssRJcNKTn4X0IuuLIbeQ4mMKrLCQoGRBuUMBUB8116k/K fabricius+owner@ckl.io f 2023-08-16 22:39:46.969326+00 {owner} t Fabricius Owner +1b146511-63fc-4286-b828-8403cc3b83c2 $2a$10$15.xcmbi0JAl4u5KzKT.7eu10y1fUMnW68iEV4HxGWWFzuhPOe2iu gracietti+owner@ckl.io f 2023-08-16 22:39:19.6733+00 {owner} t Gracietti Owner +f02906dc-feb0-48c4-8376-f26648bae486 $2a$10$H9ydjghbWoLAszSsRjDlMubt.y.tP01aIWOEWnlx/GgW3WU6Rnpjq reece+owner@stellar.org t 2023-09-19 21:40:32.008386+00 {owner} t Reece Owner +97c2e8db-72b5-4303-a2c6-e738913832b4 $2a$10$1F4fh0fLvA6rj99r4aDb7eOSjhM1I1LeWbt3Y/SctXvP0XPrGyYDy marwen.abid+owner@stellar.org f 2023-10-17 18:27:43.793619+00 {owner} f Marwen Owner +e6d26f49-7736-4cea-bb4f-0f3cb573fae5 $2a$10$QqKHI/2BeEQuyCpNDh/7NOrET1juB63o6YKtw3thqbY/yQ184iW12 marwen.abid@stellar.org t 2023-07-05 21:04:59.189365+00 {owner} t marwen abid +c7032759-cf6a-4494-bce5-014961f77820 $2a$10$n8YYS9CU6techI3GwFaOrO4JCoj597UDfplY6R/CJMs69aciq/C8q msequeira@coinspect.com f 2023-07-17 16:32:42.357297+00 {owner} t Matias'"<> '"<>Coinspect +e2c2d239-a4e6-4a8d-98d0-372708b7769e $2a$10$3xPQG5.IO.cyf.OyN833Y.TKQDFhqdyiWzeG3JmlzY4v0IStXfQR6 danny.gamboa@stellar.org f 2024-04-25 13:38:58.657217+00 {owner} t Danny Gamboa +13c0af15-269e-4f13-94a2-ba30e64d1981 $2a$10$fInprKEnPcdTmDMBupatqORBkF2NKYVcwnHxV1pHvczwx5C1YyZ92 tori+owner@stellar.org t 2023-06-02 17:28:20.073996+00 {owner} t Tori Owner +b4e1354e-6998-4910-814c-8bd21d8e70ba $2a$10$saXqYTyzCoveSUjDso1q9.85Meh9mzJ7ffcKHZAahDuxeBIJzce7S marwen.abid+financial@stellar.org f 2024-02-27 20:35:48.759509+00 {financial_controller} t Marwen Financial +1888f2c6-7ffd-4af7-833a-3ee34e6603db $2a$10$FTGso42U625OtbtYbCdqGuZexVZAT6QO2zXTT25RPvA4iHreYV7N2 caio.teixeira+owner@ckl.io f 2023-08-16 22:39:33.671756+00 {owner} t Caio Owner +42fcd8be-cc7f-4087-bd54-1716fe26fa12 $2a$10$vcD0.Kw45Hug4OZ0DAXWIODvTkPk1vOcReYiZXJ0KWpW.7mRczaP6 nick@stellar.org f 2024-03-11 16:27:19.073456+00 {owner} t Nick Gilbert +66c98f75-4368-4913-8757-b8d41225c609 $2a$10$.8zFEWgoymgtqRNWSvqaJevIc0CedA6rT3lBYJFEKtFIpxWPDHlRG marcelo+owner@stellar.org t 2023-06-02 17:28:06.674186+00 {owner} t Marcelo Owner +\. + + +-- +-- Data for Name: channel_accounts; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.channel_accounts (public_key, private_key, created_at, updated_at, locked_at, locked_until_ledger_number) FROM stdin; +GBZ6SYNJ4S44GLULIONUAG4ZEGCTJQJX5Z6PTF26IX5PQT3WZDBLBZYQ HldI56fQ7A66TzQH8w11DOGM7igRQnROGJpcj7T13E9WhTXw/XBwfna9+XXz0aF/Y5fJJKkzFlPnXRtrIirwTNz4DvWPS5WT80pKxNwJVzqfuE8u 2024-06-12 16:41:17.213934+00 2024-06-25 19:30:18.962652+00 \N \N +GBL5IFAZLGLFWUTIQSMCBUWHWGQFUYCWABQTTIYDJPEEL6YY4QRKUY2W I1kj1IEsZorFV1/JWgXrFUXDDk1iw0IleyjaulOyYEVoB/9HmWHFphqAvyjzS9a5Gfk4oh+1SRLs+l8i1UptZhgeQY8f1PMWma1uymDaFdiZWnhX 2024-06-12 16:41:17.213934+00 2024-06-25 19:46:56.868753+00 \N \N +\. + + +-- +-- Data for Name: countries; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.countries (code, name, created_at, updated_at, deleted_at) FROM stdin; +UKR Ukraine 2023-06-02 17:26:12.269565+00 2023-06-02 17:26:12.269565+00 \N +BRA Brazil 2023-06-02 17:26:12.45239+00 2023-06-02 17:26:12.45239+00 \N +USA United States of America 2023-06-02 17:26:12.45239+00 2023-06-02 17:26:12.45239+00 \N +COL Colombia 2023-06-02 17:26:12.45239+00 2023-06-02 17:26:12.45239+00 \N +AFG Afghanistan 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ALB Albania 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +DZA Algeria 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ASM American Samoa 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +AND Andorra 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +AGO Angola 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ATG Antigua and Barbuda 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ARG Argentina 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ARM Armenia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ABW Aruba 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +AUS Australia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +AUT Austria 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +AZE Azerbaijan 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BHS Bahamas 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BHR Bahrain 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BGD Bangladesh 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BRB Barbados 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BLR Belarus 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BEL Belgium 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BLZ Belize 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BEN Benin 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BMU Bermuda 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BTN Bhutan 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BOL Bolivia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BIH Bosnia and Herzegovina 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BWA Botswana 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BRN Brunei 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BGR Bulgaria 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BFA Burkina Faso 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BDI Burundi 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +CPV Cabo Verde 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +KHM Cambodia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +CMR Cameroon 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +CAN Canada 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +CAF Central African Republic 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +TCD Chad 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +CHL Chile 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +CHN China 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +COM Comoros (the) 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +COG Congo (the) 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +COK Cook Islands (the) 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +CRI Costa Rica 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +HRV Croatia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +CYP Cyprus 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +CZE Czechia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +CIV Côte d'Ivoire (Ivory Coast) 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +COD Democratic Republic of the Congo 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +DNK Denmark 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +DJI Djibouti 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +DMA Dominica 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +DOM Dominican Republic 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ECU Ecuador 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +EGY Egypt 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SLV El Salvador 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +GNQ Equatorial Guinea 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ERI Eritrea 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +EST Estonia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SWZ Eswatini 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ETH Ethiopia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +FJI Fiji 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +FIN Finland 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +FRA France 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +GUF French Guiana 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +PYF French Polynesia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ATF French Southern Territories (the) 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +GAB Gabon 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +GMB Gambia (the) 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +GEO Georgia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +DEU Germany 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +GHA Ghana 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +GRC Greece 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +GRL Greenland 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +GRD Grenada 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +GUM Guam 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +GTM Guatemala 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +GIN Guinea 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +GNB Guinea-Bissau 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +GUY Guyana 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +HTI Haiti 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +HND Honduras 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +HUN Hungary 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ISL Iceland 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +IND India 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +IDN Indonesia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +IRQ Iraq 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +IRL Ireland 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ISR Israel 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ITA Italy 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +JAM Jamaica 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +JPN Japan 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +JOR Jordan 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +KAZ Kazakhstan 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +KEN Kenya 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +KIR Kiribati 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +KOR South Korea 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +KWT Kuwait 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +KGZ Kyrgyzstan 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +LAO Laos 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +LVA Latvia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +LBN Lebanon 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +LSO Lesotho 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +LBR Liberia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +LBY Libya 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +LIE Liechtenstein 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +LTU Lithuania 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +LUX Luxembourg 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MDG Madagascar 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MWI Malawi 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MYS Malaysia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MDV Maldives 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MLI Mali 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MLT Malta 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MHL Marshall Islands (the) 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MTQ Martinique 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MRT Mauritania 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MUS Mauritius 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MEX Mexico 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +FSM Micronesia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MDA Moldova 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MCO Monaco 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MNG Mongolia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MNE Montenegro 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MAR Morocco 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MOZ Mozambique 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MMR Myanmar 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +NAM Namibia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +NRU Nauru 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +NPL Nepal 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +NLD Netherlands (the) 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +NZL New Zealand 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +NIC Nicaragua 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +NER Niger 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +NGA Nigeria 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MKD North Macedonia (Republic of) 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +NOR Norway 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +OMN Oman 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +PAK Pakistan 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +PLW Palau 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +PAN Panama 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +PNG Papua New Guinea 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +PRY Paraguay 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +PER Peru 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +PHL Philippines (the) 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +POL Poland 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +PRT Portugal 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +PRI Puerto Rico 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +QAT Qatar 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ROU Romania 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +RUS Russia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +RWA Rwanda 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +REU Réunion 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +BLM Saint Barts 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +KNA Saint Kitts and Nevis 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +LCA Saint Lucia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +MAF Saint Martin 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +VCT Saint Vincent and the Grenadines 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +WSM Samoa 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SMR San Marino 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +STP Sao Tome and Principe 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SAU Saudi Arabia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SEN Senegal 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SRB Serbia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SYC Seychelles 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SLE Sierra Leone 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SGP Singapore 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SVK Slovakia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SVN Slovenia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SLB Solomon Islands 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SOM Somalia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ZAF South Africa 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SSD South Sudan 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ESP Spain 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +LKA Sri Lanka 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SDN Sudan (the) 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SUR Suriname 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +SWE Sweden 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +CHE Switzerland 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +TWN Taiwan 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +TJK Tajikistan 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +TZA Tanzania 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +THA Thailand 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +TLS Timor-Leste 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +TGO Togo 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +TON Tonga 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +TTO Trinidad and Tobago 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +TUN Tunisia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +TUR Turkey 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +TKM Turkmenistan 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +TCA Turks and Caicos Islands 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +TUV Tuvalu 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +UGA Uganda 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ARE United Arab Emirates 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +GBR United Kingdom 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +UMI United States Minor Outlying Islands 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +URY Uruguay 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +UZB Uzbekistan 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +VUT Vanuatu 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +VEN Venezuela 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +VNM Vietnam 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +VGB Virgin Islands (British) 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +VIR Virgin Islands (U.S.) 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +YEM Yemen 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ZMB Zambia 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +ZWE Zimbabwe 2023-09-06 00:55:10.595308+00 2023-09-06 00:55:10.595308+00 \N +\. + + +-- +-- Data for Name: disbursements; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.disbursements (id, created_at, asset_id, country_code, wallet_id, name, status, status_history, updated_at, verification_field, file_content, file_name, sms_registration_message_template) FROM stdin; +760a8542-c656-42ba-bcb1-397b24c4f744 2024-06-12 16:37:07.77471+00 4c62168d-b092-4073-b1c2-0e4c19377188 USA 7a0c5a0a-33c1-42b9-a27b-d657567c2925 Payroll 2024-05 STARTED {"{\\"status\\": \\"DRAFT\\", \\"user_id\\": \\"66c98f75-4368-4913-8757-b8d41225c609\\", \\"timestamp\\": \\"2024-06-12T16:37:07.773651305Z\\"}","{\\"status\\": \\"READY\\", \\"user_id\\": \\"66c98f75-4368-4913-8757-b8d41225c609\\", \\"timestamp\\": \\"2024-06-12T16:37:07.976312+00:00\\"}","{\\"status\\": \\"STARTED\\", \\"user_id\\": \\"66c98f75-4368-4913-8757-b8d41225c609\\", \\"timestamp\\": \\"2024-06-12T16:37:08.210896+00:00\\"}"} 2024-06-12 16:37:08.210896+00 DATE_OF_BIRTH \\x70686f6e652c69642c616d6f756e742c766572696669636174696f6e0a2b31343135393431383930362c696e7465726e616c2d69642d412c302e312c323030302d30312d30310a2b353534383939363238363335342c696e7465726e616c2d69642d422c302e312c323030302d30312d30310a2b31343135333030303731302c696e7465726e616c2d69642d432c302e312c323030302d30312d30310a payroll.csv +f1e46a04-dbf6-49f7-a928-271a19fa1fd5 2024-07-03 15:34:39.426097+00 4c62168d-b092-4073-b1c2-0e4c19377188 HTI 79308ea6-da07-4520-9db4-1b9b390d5d7e January Payments STARTED {"{\\"status\\": \\"DRAFT\\", \\"user_id\\": \\"f02906dc-feb0-48c4-8376-f26648bae486\\", \\"timestamp\\": \\"2024-07-03T15:34:39.426677625Z\\"}","{\\"status\\": \\"READY\\", \\"user_id\\": \\"f02906dc-feb0-48c4-8376-f26648bae486\\", \\"timestamp\\": \\"2024-07-03T15:34:39.618582+00:00\\"}","{\\"status\\": \\"STARTED\\", \\"user_id\\": \\"f02906dc-feb0-48c4-8376-f26648bae486\\", \\"timestamp\\": \\"2024-07-03T15:34:39.819203+00:00\\"}"} 2024-07-03 15:34:39.819203+00 DATE_OF_BIRTH \\x70686f6e652c69642c616d6f756e742c766572696669636174696f6e0a2b31373738323436353130302c72656563652d69642d30322c3530302c313936382d30392d3235 demo_disbursement.csv +\. + + +-- +-- Data for Name: gorp_migrations; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.gorp_migrations (id, applied_at) FROM stdin; +2023-01-20.0-initial.sql 2023-06-01 23:47:12.220119+00 +2023-01-23.0-dump-from-sdp-v1.sql 2023-06-01 23:47:12.444892+00 +2023-01-26.0-delete-all-django-stuff.sql 2023-06-01 23:47:12.464515+00 +2023-01-26.1-drop-unused-sdp-v1-tables.sql 2023-06-01 23:47:12.475685+00 +2023-01-26.2-updated-at-trigger.sql 2023-06-01 23:47:12.48321+00 +2023-01-27.0-create-assets-table.sql 2023-06-02 17:26:12.267357+00 +2023-01-27.1-create-countries-table.sql 2023-06-02 17:26:12.275933+00 +2023-01-27.2-create-wallets-table.sql 2023-06-02 17:26:12.35124+00 +2023-01-27.3-create-receiver-wallets-table.sql 2023-06-02 17:26:12.360136+00 +2023-01-27.4-create-messages-table.sql 2023-06-02 17:26:12.368177+00 +2023-01-30.0-update-disbursements-table.sql 2023-06-02 17:26:12.386507+00 +2023-01-30.1-update-payments-table.sql 2023-06-02 17:26:12.410142+00 +2023-01-30.2-drop-unused-payments-columns.sql 2023-06-02 17:26:12.416328+00 +2023-01-30.3-update-receivers-table.sql 2023-06-02 17:26:12.41957+00 +2023-01-30.4-receiver-wallets-status.sql 2023-06-02 17:26:12.435509+00 +2023-02-03.0-update-messages-add-new-columns.sql 2023-06-02 17:26:12.447536+00 +2023-03-09.0-populate-static-data-countries-assets-wallets.sql 2023-06-02 17:26:12.453185+00 +2023-03-16.0-create-organization-table.sql 2023-06-02 17:26:12.460911+00 +2023-03-22.0-enforce-one-row-for-organizations-table.sql 2023-06-02 17:26:12.463362+00 +2023-04-12.0-create-submitter-transactions-table.sql 2023-06-02 17:26:12.473848+00 +2023-04-17.0-create-receiver_verifications-table.sql 2023-06-02 17:26:12.484687+00 +2023-04-21.0-add-receiver-wallets-otp.sql 2023-06-02 17:26:12.486949+00 +2023-04-25.0.alter-messages-table-add-receiver-wallet-id.sql 2023-06-02 17:26:12.489388+00 +2023-04-26.0-add-demo-wallet.sql 2023-06-02 17:26:12.495413+00 +2023-05-01.0-add-sync-column-tss.sql 2023-06-02 17:26:12.496989+00 +2023-05-02.0-alter-organizations-table-add-logo.sql 2023-06-02 17:26:12.500638+00 +2023-05-23.0-alter-channel-accounts-pk-type.sql 2023-06-02 17:26:12.502175+00 +2023-05-31.0-replace-payment-status-enum.sql 2023-06-02 17:26:12.503363+00 +2023-06-01.0-add-file-fields-to-disbursements.sql 2023-06-07 19:36:13.750798+00 +2023-06-07.0-add-retry-after-column.sql 2023-06-13 18:25:13.803136+00 +2023-06-08.0-add-dryrun-message-type.sql 2023-06-13 18:25:13.804819+00 +2023-06-22.0-add-unique-constraint-wallet-table.sql 2023-09-06 00:49:23.718587+00 +2023-07-05.0-tss-transactions-table-constraints.sql 2023-09-06 00:55:10.533331+00 +2023-07-17.0-channel-accounts-management-locks.sql 2023-09-06 00:55:10.540676+00 +2023-07-17.1-tss-remove-SENT-status.sql 2023-09-06 00:55:10.54727+00 +2023-07-17.2-add-status-history-column-submitter-transactions-table.sql 2023-09-06 00:55:10.56663+00 +2023-07-20.0-tss-remove-retry_after-and-rename-retry_count.sql 2023-09-06 00:55:10.587653+00 +2023-08-02.0-organizations-table-add-approver-function.sql 2023-09-06 00:55:10.594017+00 +2023-08-10.0-countries-seed.sql 2023-09-06 00:55:10.598425+00 +2023-08-15.0-alter-issuer-constraints.sql 2023-09-06 00:55:10.60099+00 +2023-08-28.0-wallets-countries-and-assets.sql 2023-10-17 18:08:39.698998+00 +2023-09-17.0-add-anchor-platform-tx-id.sql 2023-10-17 18:08:39.780243+00 +2023-09-20.0-alter-wallets-add-enabled-column.sql 2023-10-17 18:08:39.783422+00 +2023-09-21.0-alter-organizations-table-invite-and-otp-messages.sql 2023-10-17 18:08:39.786699+00 +2023-09-26.0-fix-payment-status-history-and-remove-unused-org-columns.sql 2023-10-17 18:08:39.791451+00 +2023-10-05.0-alter-receiver_wallets-add-invitation_sent_at-column.sql 2023-10-17 18:08:39.796661+00 +2023-10-05.1-alter-receiver-wallets-add-anchor-platform-transaction-synced-at.sql 2023-10-17 18:08:39.799666+00 +2023-10-12.0.alter-organizations-table-add-sms-resend-interval.sql 2023-10-17 18:08:39.803073+00 +2023-10-25.0-update-payments-status-type-and-organizations-table.sql 2024-02-23 21:11:01.04439+00 +2023-12-18.0-alter-payments-table-add-external-payment-id.sql 2024-02-23 21:11:01.049252+00 +2024-01-12.0-alter-disbursements-table-add-sms-template.sql 2024-02-23 21:11:01.053956+00 +2024-02-05.0-tss-transactions-table-amount-constraing.sql 2024-02-23 21:11:01.058698+00 +\. + + +-- +-- Data for Name: messages; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.messages (id, type, asset_id, wallet_id, receiver_id, text_encrypted, title_encrypted, created_at, status, status_history, updated_at, receiver_wallet_id) FROM stdin; +02c5ffa3-3dd4-48cb-9005-1d96a0332b53 TWILIO_SMS 4c62168d-b092-4073-b1c2-0e4c19377188 7a0c5a0a-33c1-42b9-a27b-d657567c2925 560177da-47a7-4e1c-8361-757fc3ba4c7f You have a payment waiting for you from the SDP Demo Org. Click https://vibrantapp.com/sdp-dev?asset=USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5&domain=ap-demo-sdp-backend-dev.stellar.org&name=SDP+Demo+Org&signature=b99e68b53bc6c26cc0b13f76e51d310ed9b5df406bfc5a3ff1f0b2b3f7b62833698dee3906a722092180b0c65d3f7984c81cc8ea827c7a2113ba9d74e3e36b0f to register. 2024-06-25 22:40:05.773325+00 SUCCESS {"{\\"status\\": \\"PENDING\\", \\"timestamp\\": \\"2024-06-25T22:40:05.773325+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"SUCCESS\\", \\"timestamp\\": \\"2024-06-25T22:40:05.773325+00:00\\", \\"status_message\\": null}"} 2024-06-25 22:40:05.773325+00 8ec2ff7b-8193-420b-ac40-698dc4d0f139 +0558f3af-2f14-4c30-a3af-4f8bbb13d161 TWILIO_SMS 4c62168d-b092-4073-b1c2-0e4c19377188 7a0c5a0a-33c1-42b9-a27b-d657567c2925 b24fd41a-5cb9-4f3e-be5a-a6d2a657da4e You have a payment waiting for you from the SDP Demo Org. Click https://vibrantapp.com/sdp-dev?asset=USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5&domain=ap-demo-sdp-backend-dev.stellar.org&name=SDP+Demo+Org&signature=b99e68b53bc6c26cc0b13f76e51d310ed9b5df406bfc5a3ff1f0b2b3f7b62833698dee3906a722092180b0c65d3f7984c81cc8ea827c7a2113ba9d74e3e36b0f to register. 2024-06-25 22:40:05.773325+00 SUCCESS {"{\\"status\\": \\"PENDING\\", \\"timestamp\\": \\"2024-06-25T22:40:05.773325+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"SUCCESS\\", \\"timestamp\\": \\"2024-06-25T22:40:05.773325+00:00\\", \\"status_message\\": null}"} 2024-06-25 22:40:05.773325+00 c08eea5f-0c25-4611-a869-31bf4844b468 +b77c984a-c633-4edc-b48e-46920ad68c61 TWILIO_SMS 4c62168d-b092-4073-b1c2-0e4c19377188 7a0c5a0a-33c1-42b9-a27b-d657567c2925 560177da-47a7-4e1c-8361-757fc3ba4c7f You have a payment waiting for you from the SDP Demo Org. Click https://vibrantapp.com/sdp-dev?asset=USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5&domain=ap-demo-sdp-backend-dev.stellar.org&name=SDP+Demo+Org&signature=b99e68b53bc6c26cc0b13f76e51d310ed9b5df406bfc5a3ff1f0b2b3f7b62833698dee3906a722092180b0c65d3f7984c81cc8ea827c7a2113ba9d74e3e36b0f to register. 2024-06-25 22:40:15.746255+00 SUCCESS {"{\\"status\\": \\"PENDING\\", \\"timestamp\\": \\"2024-06-25T22:40:15.746255+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"SUCCESS\\", \\"timestamp\\": \\"2024-06-25T22:40:15.746255+00:00\\", \\"status_message\\": null}"} 2024-06-25 22:40:15.746255+00 8ec2ff7b-8193-420b-ac40-698dc4d0f139 +750bee14-d9d1-45a1-8781-95dab443419d TWILIO_SMS 4c62168d-b092-4073-b1c2-0e4c19377188 7a0c5a0a-33c1-42b9-a27b-d657567c2925 b24fd41a-5cb9-4f3e-be5a-a6d2a657da4e You have a payment waiting for you from the SDP Demo Org. Click https://vibrantapp.com/sdp-dev?asset=USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5&domain=ap-demo-sdp-backend-dev.stellar.org&name=SDP+Demo+Org&signature=b99e68b53bc6c26cc0b13f76e51d310ed9b5df406bfc5a3ff1f0b2b3f7b62833698dee3906a722092180b0c65d3f7984c81cc8ea827c7a2113ba9d74e3e36b0f to register. 2024-06-25 22:40:10.767832+00 SUCCESS {"{\\"status\\": \\"PENDING\\", \\"timestamp\\": \\"2024-06-25T22:40:10.767832+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"SUCCESS\\", \\"timestamp\\": \\"2024-06-25T22:40:10.767832+00:00\\", \\"status_message\\": null}"} 2024-06-25 22:40:10.767832+00 c08eea5f-0c25-4611-a869-31bf4844b468 +9755626e-5130-490e-918e-3c6e9dd452b0 TWILIO_SMS 4c62168d-b092-4073-b1c2-0e4c19377188 7a0c5a0a-33c1-42b9-a27b-d657567c2925 560177da-47a7-4e1c-8361-757fc3ba4c7f You have a payment waiting for you from the SDP Demo Org. Click https://vibrantapp.com/sdp-dev?asset=USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5&domain=ap-demo-sdp-backend-dev.stellar.org&name=SDP+Demo+Org&signature=b99e68b53bc6c26cc0b13f76e51d310ed9b5df406bfc5a3ff1f0b2b3f7b62833698dee3906a722092180b0c65d3f7984c81cc8ea827c7a2113ba9d74e3e36b0f to register. 2024-06-25 22:40:10.767832+00 SUCCESS {"{\\"status\\": \\"PENDING\\", \\"timestamp\\": \\"2024-06-25T22:40:10.767832+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"SUCCESS\\", \\"timestamp\\": \\"2024-06-25T22:40:10.767832+00:00\\", \\"status_message\\": null}"} 2024-06-25 22:40:10.767832+00 8ec2ff7b-8193-420b-ac40-698dc4d0f139 +b7ce25eb-9f13-4c62-aa02-bde5da5bd5da TWILIO_SMS 4c62168d-b092-4073-b1c2-0e4c19377188 7a0c5a0a-33c1-42b9-a27b-d657567c2925 b24fd41a-5cb9-4f3e-be5a-a6d2a657da4e You have a payment waiting for you from the SDP Demo Org. Click https://vibrantapp.com/sdp-dev?asset=USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5&domain=ap-demo-sdp-backend-dev.stellar.org&name=SDP+Demo+Org&signature=b99e68b53bc6c26cc0b13f76e51d310ed9b5df406bfc5a3ff1f0b2b3f7b62833698dee3906a722092180b0c65d3f7984c81cc8ea827c7a2113ba9d74e3e36b0f to register. 2024-06-25 22:40:15.746255+00 SUCCESS {"{\\"status\\": \\"PENDING\\", \\"timestamp\\": \\"2024-06-25T22:40:15.746255+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"SUCCESS\\", \\"timestamp\\": \\"2024-06-25T22:40:15.746255+00:00\\", \\"status_message\\": null}"} 2024-06-25 22:40:15.746255+00 c08eea5f-0c25-4611-a869-31bf4844b468 +d5b52d13-1801-4588-9be2-d1fa60a4ad27 TWILIO_SMS 4c62168d-b092-4073-b1c2-0e4c19377188 79308ea6-da07-4520-9db4-1b9b390d5d7e 179c2ed5-6dce-46f5-967f-60c4bdfe8f03 You have a payment waiting for you from the SDP Demo Org. Click https://demo-wallet.stellar.org?asset=USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5&domain=ap-demo-sdp-backend-dev.stellar.org&name=SDP+Demo+Org&signature=b89ae5713e3eda3c41c453b0b10fb73e46a642a4955e248080363c8a7022c6bed979789fe730f58fe128489eb25aab075c091dd5e46536d246511de47a061207 to register. 2024-07-03 15:34:40.68967+00 SUCCESS {"{\\"status\\": \\"PENDING\\", \\"timestamp\\": \\"2024-07-03T15:34:40.68967+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"SUCCESS\\", \\"timestamp\\": \\"2024-07-03T15:34:40.68967+00:00\\", \\"status_message\\": null}"} 2024-07-03 15:34:40.68967+00 d8366a73-7fce-44f9-b892-9e80383ba84e +0c479c3f-ac09-40fc-9869-1b044bedc830 TWILIO_SMS 4c62168d-b092-4073-b1c2-0e4c19377188 7a0c5a0a-33c1-42b9-a27b-d657567c2925 677820fb-68c4-4595-b0ec-ffa7df5390ef You have a payment waiting for you from the SDP Demo Org. Click https://vibrantapp.com/sdp-dev?asset=USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5&domain=ap-demo-sdp-backend-dev.stellar.org&name=SDP+Demo+Org&signature=b99e68b53bc6c26cc0b13f76e51d310ed9b5df406bfc5a3ff1f0b2b3f7b62833698dee3906a722092180b0c65d3f7984c81cc8ea827c7a2113ba9d74e3e36b0f to register. 2024-06-12 16:37:10.908561+00 SUCCESS {"{\\"status\\": \\"PENDING\\", \\"timestamp\\": \\"2024-06-12T16:37:10.908561+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"SUCCESS\\", \\"timestamp\\": \\"2024-06-12T16:37:10.908561+00:00\\", \\"status_message\\": null}"} 2024-06-12 16:37:10.908561+00 5a7476b8-df72-4663-b2b3-5031829e04e4 +badae3b5-ef36-4d66-a212-43b6861b963a TWILIO_SMS 4c62168d-b092-4073-b1c2-0e4c19377188 7a0c5a0a-33c1-42b9-a27b-d657567c2925 b24fd41a-5cb9-4f3e-be5a-a6d2a657da4e You have a payment waiting for you from the SDP Demo Org. Click https://vibrantapp.com/sdp-dev?asset=USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5&domain=ap-demo-sdp-backend-dev.stellar.org&name=SDP+Demo+Org&signature=b99e68b53bc6c26cc0b13f76e51d310ed9b5df406bfc5a3ff1f0b2b3f7b62833698dee3906a722092180b0c65d3f7984c81cc8ea827c7a2113ba9d74e3e36b0f to register. 2024-06-12 16:37:10.908561+00 SUCCESS {"{\\"status\\": \\"PENDING\\", \\"timestamp\\": \\"2024-06-12T16:37:10.908561+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"SUCCESS\\", \\"timestamp\\": \\"2024-06-12T16:37:10.908561+00:00\\", \\"status_message\\": null}"} 2024-06-12 16:37:10.908561+00 c08eea5f-0c25-4611-a869-31bf4844b468 +df2438cd-d1d1-4abc-94b1-706e7ab639d5 TWILIO_SMS 4c62168d-b092-4073-b1c2-0e4c19377188 7a0c5a0a-33c1-42b9-a27b-d657567c2925 560177da-47a7-4e1c-8361-757fc3ba4c7f You have a payment waiting for you from the SDP Demo Org. Click https://vibrantapp.com/sdp-dev?asset=USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5&domain=ap-demo-sdp-backend-dev.stellar.org&name=SDP+Demo+Org&signature=b99e68b53bc6c26cc0b13f76e51d310ed9b5df406bfc5a3ff1f0b2b3f7b62833698dee3906a722092180b0c65d3f7984c81cc8ea827c7a2113ba9d74e3e36b0f to register. 2024-06-12 16:37:10.908561+00 SUCCESS {"{\\"status\\": \\"PENDING\\", \\"timestamp\\": \\"2024-06-12T16:37:10.908561+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"SUCCESS\\", \\"timestamp\\": \\"2024-06-12T16:37:10.908561+00:00\\", \\"status_message\\": null}"} 2024-06-12 16:37:10.908561+00 8ec2ff7b-8193-420b-ac40-698dc4d0f139 +b7b3e38d-9632-45c7-9246-7b7c72ffc5f1 TWILIO_SMS 4c62168d-b092-4073-b1c2-0e4c19377188 79308ea6-da07-4520-9db4-1b9b390d5d7e 179c2ed5-6dce-46f5-967f-60c4bdfe8f03 You have a payment waiting for you from the SDP Demo Org. Click https://demo-wallet.stellar.org?asset=USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5&domain=ap-demo-sdp-backend-dev.stellar.org&name=SDP+Demo+Org&signature=b89ae5713e3eda3c41c453b0b10fb73e46a642a4955e248080363c8a7022c6bed979789fe730f58fe128489eb25aab075c091dd5e46536d246511de47a061207 to register. 2024-07-05 15:34:45.671916+00 SUCCESS {"{\\"status\\": \\"PENDING\\", \\"timestamp\\": \\"2024-07-05T15:34:45.671916+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"SUCCESS\\", \\"timestamp\\": \\"2024-07-05T15:34:45.671916+00:00\\", \\"status_message\\": null}"} 2024-07-05 15:34:45.671916+00 d8366a73-7fce-44f9-b892-9e80383ba84e +37d942b5-cbc6-4df8-a75d-0a32367b3520 TWILIO_SMS 4c62168d-b092-4073-b1c2-0e4c19377188 79308ea6-da07-4520-9db4-1b9b390d5d7e 179c2ed5-6dce-46f5-967f-60c4bdfe8f03 You have a payment waiting for you from the SDP Demo Org. Click https://demo-wallet.stellar.org?asset=USDC-GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5&domain=ap-demo-sdp-backend-dev.stellar.org&name=SDP+Demo+Org&signature=b89ae5713e3eda3c41c453b0b10fb73e46a642a4955e248080363c8a7022c6bed979789fe730f58fe128489eb25aab075c091dd5e46536d246511de47a061207 to register. 2024-07-07 15:34:45.668527+00 SUCCESS {"{\\"status\\": \\"PENDING\\", \\"timestamp\\": \\"2024-07-07T15:34:45.668527+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"SUCCESS\\", \\"timestamp\\": \\"2024-07-07T15:34:45.668527+00:00\\", \\"status_message\\": null}"} 2024-07-07 15:34:45.668527+00 d8366a73-7fce-44f9-b892-9e80383ba84e +\. + + +-- +-- Data for Name: organizations; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.organizations (id, name, timezone_utc_offset, sms_registration_message_template, created_at, updated_at, logo, is_approval_required, otp_message_template, sms_resend_interval, payment_cancellation_period_days) FROM stdin; +98e82622-2d07-4c70-8ed1-739c6302f7ae SDP Demo Org +00:00 You have a payment waiting for you from the {{.OrganizationName}}. Click {{.RegistrationLink}} to register. 2023-06-02 17:26:12.454669+00 2024-06-25 22:41:09.355985+00 \\x89504e470d0a1a0a0000000d4948445200000150000001500806000000e9e826d9000000017352474200aece1ce900000050655849664d4d002a000000080002011200030000000100010000876900040000000100000026000000000003a00100030000000100010000a00200040000000100000150a0030004000000010000015000000000073307150000015969545874584d4c3a636f6d2e61646f62652e786d7000000000003c783a786d706d65746120786d6c6e733a783d2261646f62653a6e733a6d6574612f2220783a786d70746b3d22584d5020436f726520362e302e30223e0a2020203c7264663a52444620786d6c6e733a7264663d22687474703a2f2f7777772e77332e6f72672f313939392f30322f32322d7264662d73796e7461782d6e7323223e0a2020202020203c7264663a4465736372697074696f6e207264663a61626f75743d22220a202020202020202020202020786d6c6e733a746966663d22687474703a2f2f6e732e61646f62652e636f6d2f746966662f312e302f223e0a2020202020202020203c746966663a4f7269656e746174696f6e3e313c2f746966663a4f7269656e746174696f6e3e0a2020202020203c2f7264663a4465736372697074696f6e3e0a2020203c2f7264663a5244463e0a3c2f783a786d706d6574613e0a195ee10700001e6a494441547801ed9d4d8c244975c75f567557774fafd6bbb0360661049285407c08c937e31b485c6d094e3e7099c31acbf60a63890f236691768c2cb0d62befb292f7601f6c5fb8fa661f912ffe4008c9802c192d78d941c07e687a7ababb2ad3ef65757657755765674465654664fc42eaa9acccf878eff7b2fe199595f126bbfdcc0b85502000010840c099c0c8b9050d2000010840a0248080722240000210f02480807a82a319042000010494730002108080270104d4131ccd2000010820a09c03108000043c0920a09ee068060108400001e51c80000420e0490001f5044733084000020828e700042000014f0208a827389a410002104040390720000108781240403dc1d10c0210800002ca3900010840c0930002ea098e66108000041050ce01084000029e0410504f7034830004208080720e40000210f02480807a82a319042000010494730002108080270104d4131ccd2000010820a09c03108000043c0920a09ee068060108400001e51c80000420e0490001f5044733084000023b208040af048a4cb29e0c286cdcacfcb7270b183676020868ec118cc0fe4c7219173315ca7cc9dab9769980b62ba1b9f637cb76a490fa2f5899d6403e9742c21b470208a82330aabb1330f17cf1e9cf8b1467cb8de753c0e57d6dbccb26f2075ff90b99ea2b0502db2480806e932e7d9704ca99a789673ebd4e641b53409d788e985b5e67cd9ed609d47fc7697d383a84000420301c02cc408713cb703db159e6aa99e6aa7d6d78a13f4c95e395b756d70fb2fe481b46d0470a0410d014a2dcb38f731dd32f3b5715ebeafbb6ecbcf84d4a7f24ba4144db1a927ed2248080a619f78ebd3e57b46d09e6356f2e14548f7436e8352bd8317c02dc031d7e8cf1100210d8120104744b60e9160210183e010474f831c6430840604b0410d02d81a55b084060f80410d0e1c7180f2100812d114040b70436ae6eed97eae1fd5a3d3c8fe23aab52b096c798528872ad8f8b3263db8b8f00cd1b56493f6abb5971b0ecb9976c47f6fca78edecbd82b40b06bb00410d0c186b68963957856af269eb67d29a2975bb6b7d0ac4a538775e62a64a663d6e7f850ff6e3531eab2cee881b639b585ed6ea538956c742493e5e44f37f6916796c569acd6ba0e7863d7541828010474a081dd865b269edf7cfa0baab12a6a4dca7c1950299eeff8c4a7e4c85140b3c9cbf29ef7ff44ecd5a51c1c3f21cfdffd92c8b16336a6d1ae3c79e7ae667142405d78a75c17014d39fa8ebe97198e4c3cf32b69e9eafab109ad0aa789e71b93c7ea6a5e3fb6f7aabc7ef8a6647bf7ae1fbb698f4d9df38642bfd0d7683e655ed8c32604d613e052bb9e0d4720000108d41240406bf17010021080c07a0208e87a361c81000420504b0001adc5c34108400002eb0920a0ebd97004021080402d0104b4160f0721000108ac278080ae67c31108400002b50410d05a3c1c84000420b09e0002ba9e0d4720000108d41240406bf17010021080c07a0208e87a361c81000420504b80b5f0b578867930935cb32acd344b527e9ef1cd16ac5ba9722f55afe77bcac3b6afcaaa54d52f1bd5ff630945262399edbda2afafd6d7bd72341bff4264fa848dea54cef27d917db7b1ca01c8e2e4c499ca22086882678189e78b9655a94c0aa202d748a1b49e260429b32aed686abaa645fb36f17cfb077f2e53135197a2e2f9a31f1e4831fb884b2bd91ddd930ffdf147f4d54d44c9e2e48499ca4a00014df034b099a7149a51a9fc53008d0454eb8d0f34abd2a1bcb1eb985549679e269ed9de0f9c689b59a5789ebccfa9ddd99ec8fdbd9f92c5c9891a957d08700fd4871a6d2000010828010494d30002108080270104d4131ccd2000010820a09c03108000043c0920a09ee068060108400001e51c80000420e0490001f5044733084000020828e700042000014f0208a827b8e89b954fa9ab174d1fa28fde611c8040fb0410d0f69986df23a2197e8cb0300a0208681461c2480840204402ac850f312aa1da347a20d9e465913db7241dbe59952c1393b52df6beef44c477bce2f4514db0e2e69b9361541e1c0104747021dda243e35379cffb7f22af1fbee9368867562513c277bff75853de7ca793f11e3b39d5d4513696be5220d0800002da001255ce09e80d1f9b81667bf79c90f866552a679e2a9e5d65712a4e5fd719e82df50d01750a70c295b9079a70f0711d0210d88c0002ba19bf685a574f2d456330864220020208680441dad4c4c5a7966cdbfe730e0a0420b039010474738671f460aa8972c6112bac8c8600021a4da830140210088d00021a5a44b0070210888600021a4da830140210088d00021a5a44b0070210888600021a4da830140210088d00021a5a44b0070210888600021a4da830140210088d006be1438b88833d99e4322e66322af21b5b15e70f81968f836a5625d93f13a92e9f8b4fdad7f5b42f7296ffba14278fd5d5ba7eace3ac4abe599c6699729c181447ff946da699a927b9db1afa3ccb64968db5651588ebe8d81336010434ecf8d45a67e2f9e29d2f68567915c355a512c672f9d1c253f4fba7f23b9ffd901cedffbc6c651ffe26c5c4f3fbfff3b88ae8db9a54bfa8d3755625dff1764ede213f958fcb585f5dcae1ec813cffd5af6a26a72397667a01db9527efdc95698680ba810ba736021a4e2c9c2d29679e269ef97a01ad3e9a856a64a9a33a4aa13b1f1cfc52d3d2dd2b674e4d07b69967299e27ef6bdaa4acd7795625cb1fea91c569aad6de9ffc9a02729c8196134fa5eb38033538230bccc2b5ad04c63fd1104040a309d52a43f59357d8dfaa63ec830004b64da09aa06c7b1cfaef8100139b1ea03364520410d0a4c28db31080409b0410d03669d21704209014010434a970e32c0420d0260104b44d9af4050108244500014d2adc380b0108b44900016d93267d41000249114040930a37ce4200026d124040dba419555fd5eaf8a88cc6580804458095487d87435712f93ef09ed92aa49ad68b0b94aaed8b575b42c812a6bea3cff8911340407b0ee05c020bcdaa34d59c3c95bc35346aa4c92b0e4eea3474758f9a5569aa09418a935f6938d079b548b22a593211cbc8e448b36c636dcbb5fb0e64c8e2e4006b605511d000026ae2f9cda72dab925b3a344b49f7d1a73e20c7072a180e65e859954c3c7ff4c30329661f71a0a2d72115cf77bff7b84c44e2d2902c4e2eb4865517010d209ee5ccd3c4735d56a57536ea1dec072a9e6f3c726f5d8d95fb879e55a9bc3961e2d951d628b238ad3ccd92d8c98f48498419272100816d104040b741953e210081240820a0498419272100816d104040b741953e2100812408f02352e861ae7b16478ff93e431abadbd80781180830038d214ad808010804498019686861a99b7186662bf640207102cc40433801ca0717f9321e4228b001022e041050175adbaecbec73db84e91f02ad1240405bc5b94967cc4037a1475b08f4410001ed837a83312d47937f9ea6060350050210d898003f226d8c70b30e36fad69e5bfe91776956a5c7dc8c20abd24a5e5d6771ca44f31f8c1eacb4859d71104040fb8e535648a17f577373eade9b2d9b4de47ffffb9df2fadee4e6ba0b357cb30e759de568e8e33d76f4a8fcd73f3b66e05a88239bfd134040fb8f81bf05f9ad72062ae236032df35dee7c47b2bd1f388d5dca7c87598e863e5e76f6369d813a8580ca8111207c81050473200081780820a0f1c40a4b210081c00820a081050473200081780820a0f1c40a4b210081c00820a081050473200081780820a0f1c40a4b210081c00820a081050473200081780820a0f1c40a4b210081c00820a081050473200081780820a0f1c40a4b210081c00820a0810504732000817808b016bec5586592cbb898691a3a4d93d4a4e8626fcb025a66021d1f8a8c6f35697559673292d9de2b2293572ff735d8ca767f2c994716206b636d1ba43959b262f0e38d7f2196f8c495cb59be2fb2ef16bb126c71aaf13b9249c3d3ac0a469e6532cbc66a27f3a68ac9a6afd9ed675e708dfba6630eb6fd4e71262f3efd794dac74d6cc47235fe8c9ace2f98e4f7c4a8e1c05d4c4f3ed1ffcb94c4d441d4a299ef6a17715514d5e22b3b74a61af0e65e8e35d668d7aab031591ddd13d79df6fbea6af6e227a70fc847cfbd9ef891cbb65e192d1ae3c79e7ae4cb35d273ba9bc9e0033d0f56c9c8f64854e097215cf62daac6d29a05a5585d3c4f38d895b56259b799a78ba66556a66dc8a5a26b8360b5d71682bbb2219af0ca34796aab33d91fb7b3fd5f8dd73c76741c8dd53e18d0ab5b6b300babb155b0b04b4c58895e7a5cd28ed13d5a434add7a42fea4000029d1340405b457e7e6947185ba54a67100895007793438d0c76410002c1134040830f1106420002a1124040438d0c76410002c1134040830f1106420002a1124040438d0c76410002c1134040830f1106420002a1124040438d0c76410002c1134040830f1106420002a1124040438d0c76410002c1136025520821b2f5e5939745f6dc924af866399accc67278b22b93e93804efa3b7e1342fe495d12b72a26bdb5d8a6ffc5cc6a0ee760920a0dbe5dbacf7f1a9bce7fd3f91d70fdf6c56ffbcd6459623a756528ae7277fe3e3f2f8034da746d998c06bfbf645ee3bf2cb839f39f5e51b3fa741a8bc550208e856f136ec5c3f7f3603f5cacad37088c56a36f334f17cdb9b8f2cee66db9bc07d998cfe4fe377e4dd030de324c03dd038e386d5108040000410d0008280091080409c0410d038e386d5108040000410d0008280091080409c0410d038e386d5108040000410d0008280091080409c0410d038e386d5108040000410d0008280091080409c0410d038e386d5108040000410d0008280091080409c0410d038e386d51080400004580b1f40107c4df0cdaaf4f88303b1b6947608184b63ea5a4e776672b47726a7e3996b53ea074200010d24103e66584a3a9fac4a73e19df80c499b15040e4f26f2c9777dcc59085fbbf550bef5e37f91d35b08e80aac51ec4240a308d36a23c9aab49a4bd77b2d0e93a9fb0cd4ec24276bd7d16a773cee81b6cb93de200081840820a009051b57210081760920a0edf2a4370840202102086842c1c6550840a05d020868bb3ce90d0210488800029a50b07115021068970002da2e4f7a8300041222808026146c5c850004da258080b6cb93de200081840820a009051b57210081760920a0edf2a4370840202102ac854f28d897ae169249ae7fc5e5ae2d6ed96885d8b53adbe2286d74dd2d9791cc3a8b411b74e8e33a0104f43a9381eda944b212af4c3fb4b97cf73ffe4d25ad9b2c40b98ce5c3bff5dbe7225ad953bd5676f58fbd6b2eaf3e7a2cd93bf3fe1dc7026f0208a837ba481a56fa54e9959a3d9f0fce5440cf3a73c2c65c30e17232bab4b33373560ed43597acbc80050460251576d611e01e681d1d8e41000210a8218080d6c0e110042000813a0208681d1d8e41000210a8218080d6c0e110042000813a0208681d1d8e41000210a821c0aff035700671a8f51f79ab9ff5e774ec97eba195650febbd1b9ef7f5fe7274990002bacc6380efaeca817de47d3ff6da5761fdcdfbbc14cffafe32ab6e55e6cd7423dc529a58efce92f1e69b43f5a5b6bc899f00021a7f0c3bf6c0246651096df5ce0d0585b901108763258080c61a39ecde0a8152eb6fbc225c0ecdb5e192458a5b08688a51c7e7b5044c1011c5b57838708500027a05086fd325500ae7c8eeec369b8296b572e436dd334604014d30fab986fd247b64f95666030ea3622a3bc5898c8a791292cbd5edf58293dbe1fa2a0d46df7e953cdb918772a8b6361445ad96a9e08e4b2e0f35658a6b729608a06c1f7bd42320a05187cfcff8b36c5f9efba7ef3aa712d9d3e19efbf2efca5e7e7431705efec45e2f04968da9e9aceea2e31e36a6b22f7ff38f7e5c9efdf2efc95e71dfc9ea5b8f3e94ff7cf8ef4e6da81c16010434ac787462cdac9c69899c788c362b540c8bf90ccd44d1b66e9aaf9533d008a6a0360335262e5c2aff67d9ae82705b9712c345c5e31449aa09029a54b8db75b694cf72065adfef5c566e92d9fa3e380a81100920a0214625609b1665b0daaeff027ff30c356077310d02b50410d05a3c1c5c45a05c8cb4ea00fb20901801b79b3689c1c1dd6502e5fdbe9ba69bcb4d78078141134040071d5e9c830004b6490001dd26dd21f5ad33cfea9ee790dcc217086c420001dd841e6d210081a40920a049871fe72100814d0820a09bd0a32d04209034010434e9f0e33c0420b009010474137ab48500049226c083f40986dfb2294d5cfdd69fe0ab36961c645eeca1d0ea6f7d874566d7e92b0f9006f8937ee6c1c5dcd055f0655680d90597f52c168f1491245959b499ed650208e8328f24deedc8a9bcf4f5cf489e5542d8cc6d4b67b75b58dab65be70d2eff57a4fa1eacded52f3b5704b5be834e8e1a97bf552e45432e269e961064a469ec8ccba8a8b83433f7f0d123291e5ee5d2ac2db5c22080808611874ead98cf408fbd1fecb47ca2432cae5c4a015d48e767d99c5c4a39936f908cc5a54fea764b80cb5fb7bc190d0210181001b74be6801cc71508b441c092d7174d33d85f1bd01a5fdbc98e880820a011052b6e5387aa147a17d4d3b5f95d60cfc6719f0c83b11e011d4c2871a46b029bff0c8678761db3b6c7e31e68db44e90f0210488600029a4ca871140210689b0002da3651fa8300049221808026136a1c850004da268080b64d94fe200081640820a0c9841a47210081b60920a06d13a53f0840201902086832a1c6510840a06d020868db44e90f0210488600029a4ca871140210689b0002da3651fa8300049221808026136a1c850004da268080b64d94fe200081640820a0c9841a47210081b60920a06d13a53f0840201902086832a1c6510840a06d020868db44e90f0210488600029a4ca871140210689b0002da3651fa8300049221808026136a1c850004da268080b64d94fe2000816408f0bf722613eaf81ccdb3b14c6522f6ea5246c54c76e454ec9502816d124040b74997be372260e279fb732fa814ba9589567fe9eb9fd1d6c76e0da90d01470208a82330aa7747c0669e269e271e4396b356fedb750f72347121c03d50175ad485000420b04000015d80c12604200001170208a80b2dea42000210582080802ec06013021080800b0104d4851675210001082c1040401760b009010840c0850002ea428bba10800004160820a00b30d884000420e042000175a1455d084000020b0410d005186c4200021070218080bad0a22e042000810502ac855f80c16658042c9b922506712dd6864c4caed4a8ef430001f5a1469b4e08584a3acbaae49bceae1323192469020868d2e10fdbf9f90c5453d2915529ec40256c1df740130e3eae4300029b11404037e3476b08402061020868c2c1c775084060330208e866fc680d0108244c00014d38f8b80e01086c460001dd8c1fad210081840920a009071fd7210081cd0820a09bf1a33504209030010434e1e0e33a0420b019010474337eb486000412268080261c7c5c8700043623c05af8cdf8d1ba01814c3219e5fa6f9135a8dd5f95222b241f15baf49ec5f7fd4521ae9111d0b8e215a5b5269efff0e2dfcb380ffb0bcf6c94cbef3ff96999a9885220d0840002da841275362260334f13cfddd978a37eba681cfa2cb90b068cd19c40d85382e67e501302108040e70410d0ce913320042030140208e85022891f108040e70410d0ce913320042030140208e85022891f108040e70410d0ce913320042030140208e85022891f108040e70410d0ce913320042030140208e85022891f108040e70410d0ce91332004203014022ce5dc3492ba4cb14a91315f0658bddbb463da430002a1134040574428935cc6c54c85315f71f4729726efd162027a2e9aa323918313dbe5560e44a6e353b7365afb746726afdd7ae8dc6ea26bd20f4f26329976b336ddb21c59a28ed08bd968b6ba168bc3d1dea99c8e674e4d2d76d6d6b594e78a9e33cea538954ccfd1896328f22c935936d61c557c61bdca3cbbfdcc0bee67ccd55e06f67ea73893179ffebc88bed6967301bda8b37f261f7dea03727cf08b8b5d4d36ec03f17072e42ca27321dc7516c2c71f1cc827dff53179fcc8e753d8c4a3e53a768119723abbd70e8fe55b2fffab5ecc8e971dbfe1dd5c78cf9c8577673691fdd343b157977270fc847cfbd9ef891cbbb593d1ae3c79e7ae4cb35d97e192a8cb0c744598b3422fd1b98a67315d717461d7d54b8f5ea01fa878bef1c8bd854adbdbb419cfe92df7198c59e43a5bdac40bcbaf39e41471c6d2c4f3dea3fa0da4836217dcfb07eedf584ad3ecdb51eede7654e8c9eefacdaa03167d0f8180ae8840799e14aa86570572455d76410002e912404057c6fefc52db4440b92aaf24c84e08a44080bbc22944191f210081ad104040b782954e210081140820a02944191f210081ad104040b782954e210081140820a02944191f210081ad104040b782954e210081140820a02944191f210081ad104040b782954e210081140820a02944191f210081ad106025d20aac967dc61228342a8bd97b74fdfcade3b73a2f19f64d26e29b54e2509349b826a23016b966e499caa47c6dc4e6bcd248335bedc8a9d86bc8c5d7bf3c1fcbc1c35f95c7468f38b9d775dc2d998814afeab9ed9e4ca4fc4c38799746650474459c2d7597659f291328ac38bebceb72bd67367a20cfdffd927bd2054d8af4e1cfbec539418465e4f9ee5ffd52b3eb2c5b74e33bcd91f2e9afd987dd2da98489e7edcfbde0d84ab495c84b5fff8cbeba1a7aa327ad56f0f5ef4cbfc7e57b6a8a6b76c0aee3aee2f9875f7c468afcd0895b95cecea951229511d01581b6bc87d34c3f158eebdc27b936b054611ed96e7c6684651bd3a4fb2b9ca8d9659ff5f9786e026a33346ba1194f9d8bb50d3d398bb77f965fd3f3dad065dc6de669e279ea3a03758e763a0db8079a4eacf114021068990002da3250ba830004d2218080a6136b3c8500045a268080b60c94ee200081740820a0e9c41a4f2100819609f02b7ccb40bdbb5bf8ef9197fbb87c4c6a793fef200081be0920a07d47a07c56aad07f4792d9ffc3543deb533ea07f9378de74bc77e73000028326c057f860c27b2986f67f935fbe5b6fa0e363aaeb3be2080420e0458019a817b6f61a652a9573b1d4a7b1b399d833d9564c1c6d5e6ac729108040980410d09ee352c9e3c5acf37c5a79f1df70ebbdd1cba2b517df5e1e602b11025c50c30a3402da773c2c71897d65d797f24fed318db4b96776553cedc885b26aa5a5e37d3bc2f8db21702999975b7a21d59b6fa3d1c23df30683e7233bb3aa4b76830654b99100027a23a2e6159cb2382d76ab6be7f735534e5ecaa6fd6b9a6a6bc76dab2ad74ffc5bc78f8bcc34bb4e47c5b22939e6f1292db393cc12753c74cc56d4915b17c39c64ba4edcde75b556bc38953243d28505cb1b760b675eaa57db53c8e1f15b74f73dc9c71a0dbb00372dd9ae9e52fcecd11457937ad9ed675eb8fec96cd2923ad708642a8163159966599cf4735acceb17e307f2dcd7be32cfe663d1a82252bd5e1be97c876587b34ffccce143a4d5f77480bffbc61fc97eee9685c437dd9b89e7937ffa0d15d1b08ba17ceae9afc9c968bf1343b3d1915ff62ebd51fed49ffd79991864aa495af286a268f58a1d4b478888b6156066a06d91d47e5cb3385502ba5b6671d20f6d71a67fe73933cbd9e75561bc49515b74664557f319a8a61d7234c3669e269e3e599c5698b1bd5d3af334f1ec2a5bd1a4cce2e491bd2bd38f6df1889ce90c74aadb4e828878b67afe20a0ade274eb6c9ea476acb7b35434eddea6ce482fc569954aaddae73626b50740404570a633c95979ab419f1fbef8aa7fb36fd7cfa0eb7b6eee851a150104b422d1cbab7ee9d77b5845f500bd89e852b9fa7ee9206f1226303311d5bfe51f1a1306d293eb08684fe0191602ed10e022db0e47bf5e10503f6eb482401004ec56f9d53be5eb0c436ad791f1df8f80fab3a3250482208030f617069e67e88f3d2343000291134040fb0ea07e079bff10d0f48b58df06333e0420501140402b123dbd229b3d81675808b44000016d01225d40000269124040d38c3b5e4300022d1040405b8048171080409a04788ca9e7b8db2328e5924e5bdf3c727d2045ebdbda795b021a70f1cde264346c0dbdb377b6de5b936c347f42f21cde68771e8bf3b7db7ef1ceded5b19ddbe61073ff64630a207a96bd695c4ccb54652ee6ec6af291bfbef34555184d42e250f6b4ae4f3626872196aafa66713acb0ee4f667ff729e626ea9c71bdea8c0fcc99dbb72a6e9db5c8a09da4c85d792c274515cb3775536756d67352eafd7093003bdcea4f33df681c81d3feca591e5d42cfcdff17db338998ef9799795e2d9555625df13c6357b97ef38b4db1e816e2eb5dbb39f9e21000108f4460001ed0d3d03430002b1134040638f20f6430002bd1140407b43cfc0108040ec0410d0d82388fd1080406f0410d0ded033300420103b010434f608623f0420d01b0104b437f40c0c0108c44e00018d3d82d80f0108f4460001ed0d3d03430002b1134040638f20f6430002bd1140407b43cfc0108040ec0410d0d82388fd1080406f0410d0ded033300420103b010434f608623f0420d01b0104b437f40c0c0108c44e00018d3d82d80f0108f4460001ed0d3d03430002b1134040638f20f6430002bd1140407b43cfc0108040ec0410d0d82388fd1080406f0410d0ded033300420103b010434f608623f0420d01b0104b437f40c0c0108c44e00018d3d82d80f0108f4460001ed0d3d03430002b113f87fee96129c4624f8260000000049454e44ae426082 f {{.OTP}} is your {{.OrganizationName}} phone verification code. 2 5 +\. + + +-- +-- Data for Name: payments; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.payments (id, stellar_transaction_id, created_at, receiver_id, disbursement_id, amount, asset_id, stellar_operation_id, blockchain_sender_id, status, status_history, updated_at, receiver_wallet_id, external_payment_id) FROM stdin; +825e8c3c-4d5e-412f-8d98-eefdc0da7a19 \N 2024-07-03 15:34:39.618582+00 179c2ed5-6dce-46f5-967f-60c4bdfe8f03 f1e46a04-dbf6-49f7-a928-271a19fa1fd5 500.0000000 4c62168d-b092-4073-b1c2-0e4c19377188 \N \N CANCELED {"{\\"status\\": \\"DRAFT\\", \\"timestamp\\": \\"2024-07-03T15:34:39.618582+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"READY\\", \\"timestamp\\": \\"2024-07-03T15:34:39.819203+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"CANCELED\\", \\"timestamp\\": \\"2024-07-08T15:34:40.522598+00:00\\", \\"status_message\\": null}"} 2024-07-08 15:34:40.522598+00 d8366a73-7fce-44f9-b892-9e80383ba84e \N +ba278017-8bd6-4282-8963-e6257badea63 \N 2024-06-12 16:37:07.976312+00 b24fd41a-5cb9-4f3e-be5a-a6d2a657da4e 760a8542-c656-42ba-bcb1-397b24c4f744 0.1000000 4c62168d-b092-4073-b1c2-0e4c19377188 \N \N CANCELED {"{\\"status\\": \\"DRAFT\\", \\"timestamp\\": \\"2024-06-12T16:37:07.976312+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"READY\\", \\"timestamp\\": \\"2024-06-12T16:37:08.210896+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"CANCELED\\", \\"timestamp\\": \\"2024-06-25T22:44:40.520233+00:00\\", \\"status_message\\": null}"} 2024-06-25 22:44:40.520233+00 c08eea5f-0c25-4611-a869-31bf4844b468 \N +962452e2-6e9d-4246-b05c-1fdd9ab8bd03 \N 2024-06-12 16:37:07.976312+00 560177da-47a7-4e1c-8361-757fc3ba4c7f 760a8542-c656-42ba-bcb1-397b24c4f744 0.1000000 4c62168d-b092-4073-b1c2-0e4c19377188 \N \N CANCELED {"{\\"status\\": \\"DRAFT\\", \\"timestamp\\": \\"2024-06-12T16:37:07.976312+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"READY\\", \\"timestamp\\": \\"2024-06-12T16:37:08.210896+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"CANCELED\\", \\"timestamp\\": \\"2024-06-25T22:44:40.520233+00:00\\", \\"status_message\\": null}"} 2024-06-25 22:44:40.520233+00 8ec2ff7b-8193-420b-ac40-698dc4d0f139 \N +9336b377-8922-4014-868d-9a2bad8e9ef4 b566fa278958aeac1f457f7f13e4adc6b3037a2099b8ab4941e48c3b94e476e1 2024-06-12 16:37:07.976312+00 677820fb-68c4-4595-b0ec-ffa7df5390ef 760a8542-c656-42ba-bcb1-397b24c4f744 0.1000000 4c62168d-b092-4073-b1c2-0e4c19377188 \N \N SUCCESS {"{\\"status\\": \\"DRAFT\\", \\"timestamp\\": \\"2024-06-12T16:37:07.976312+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"READY\\", \\"timestamp\\": \\"2024-06-12T16:37:08.210896+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"PENDING\\", \\"timestamp\\": \\"2024-06-12T16:37:50.525664+00:00\\", \\"status_message\\": null}","{\\"status\\": \\"SUCCESS\\", \\"timestamp\\": \\"2024-06-12T16:42:00.528987+00:00\\", \\"status_message\\": \\"\\"}"} 2024-06-12 16:42:00.528987+00 5a7476b8-df72-4663-b2b3-5031829e04e4 \N +\. + + +-- +-- Data for Name: receiver_verifications; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.receiver_verifications (receiver_id, verification_field, hashed_value, attempts, created_at, updated_at, confirmed_at, failed_at) FROM stdin; +b24fd41a-5cb9-4f3e-be5a-a6d2a657da4e DATE_OF_BIRTH $2a$04$J/WuvEtcrPp/n6f2cFsL3Ork1BVotzFtnPQHskHN910pzruUbkK9e 0 2024-06-12 16:37:07.976312+00 2024-06-12 16:37:07.976312+00 \N \N +560177da-47a7-4e1c-8361-757fc3ba4c7f DATE_OF_BIRTH $2a$04$A5wJK51k1swfxXEgzmRYJuWeC3/c15cp3pnO5OCJG8tPAHFxypTYW 0 2024-06-12 16:37:07.976312+00 2024-06-12 16:37:07.976312+00 \N \N +677820fb-68c4-4595-b0ec-ffa7df5390ef DATE_OF_BIRTH $2a$04$llSDbJHAgzOL9yp/6L8Jte7mjg/0kcGfzGYx.d4IIe7oLk.uhM9Lu 0 2024-06-12 16:37:07.976312+00 2024-06-12 16:37:47.043759+00 2024-06-12 16:37:47.0469+00 \N +179c2ed5-6dce-46f5-967f-60c4bdfe8f03 DATE_OF_BIRTH $2a$04$U7sASLgTOUkHAZSWCUO24uokOK0VfPRZSc0VzGSosk5Ushyb8V1Ki 3 2024-07-03 15:34:39.618582+00 2024-07-03 15:51:12.903247+00 \N 2024-07-03 15:51:12.888708+00 +\. + + +-- +-- Data for Name: receiver_wallets; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.receiver_wallets (id, receiver_id, wallet_id, stellar_address, stellar_memo, stellar_memo_type, created_at, updated_at, status, status_history, otp, otp_created_at, otp_confirmed_at, anchor_platform_transaction_id, invitation_sent_at, anchor_platform_transaction_synced_at) FROM stdin; +c08eea5f-0c25-4611-a869-31bf4844b468 b24fd41a-5cb9-4f3e-be5a-a6d2a657da4e 7a0c5a0a-33c1-42b9-a27b-d657567c2925 \N \N \N 2024-06-12 16:37:07.976312+00 2024-06-12 16:37:10.908561+00 READY {"{\\"status\\": \\"DRAFT\\", \\"timestamp\\": \\"2024-06-12T16:37:07.976312+00:00\\"}","{\\"status\\": \\"READY\\", \\"timestamp\\": \\"2024-06-12T16:37:08.210896+00:00\\"}"} \N \N \N \N 2024-06-12 16:37:10.908561+00 \N +8ec2ff7b-8193-420b-ac40-698dc4d0f139 560177da-47a7-4e1c-8361-757fc3ba4c7f 7a0c5a0a-33c1-42b9-a27b-d657567c2925 \N \N \N 2024-06-12 16:37:07.976312+00 2024-06-12 16:37:10.908561+00 READY {"{\\"status\\": \\"DRAFT\\", \\"timestamp\\": \\"2024-06-12T16:37:07.976312+00:00\\"}","{\\"status\\": \\"READY\\", \\"timestamp\\": \\"2024-06-12T16:37:08.210896+00:00\\"}"} \N \N \N \N 2024-06-12 16:37:10.908561+00 \N +5a7476b8-df72-4663-b2b3-5031829e04e4 677820fb-68c4-4595-b0ec-ffa7df5390ef 7a0c5a0a-33c1-42b9-a27b-d657567c2925 GBBMN6CHPVLHATN6B7GP2KYOCTLOGF4IML7WRL4VMIOXXJNSV5GCUDAB \N \N 2024-06-12 16:37:07.976312+00 2024-06-12 16:42:10.550569+00 REGISTERED {"{\\"status\\": \\"DRAFT\\", \\"timestamp\\": \\"2024-06-12T16:37:07.976312+00:00\\"}","{\\"status\\": \\"READY\\", \\"timestamp\\": \\"2024-06-12T16:37:08.210896+00:00\\"}"} 422610 2024-06-12 16:37:27.818973+00 2024-06-12 16:37:47.054832+00 2c9b47ba-da5e-426d-a4ca-bbf722e1877e 2024-06-12 16:37:10.908561+00 2024-06-12 16:42:10.550569+00 +d8366a73-7fce-44f9-b892-9e80383ba84e 179c2ed5-6dce-46f5-967f-60c4bdfe8f03 79308ea6-da07-4520-9db4-1b9b390d5d7e \N \N \N 2024-07-03 15:34:39.618582+00 2024-07-03 15:43:31.471024+00 READY {"{\\"status\\": \\"DRAFT\\", \\"timestamp\\": \\"2024-07-03T15:34:39.618582+00:00\\"}","{\\"status\\": \\"READY\\", \\"timestamp\\": \\"2024-07-03T15:34:39.819203+00:00\\"}"} 487190 2024-07-03 15:43:31.471024+00 \N \N 2024-07-03 15:34:40.68967+00 \N +\. + + +-- +-- Data for Name: receivers; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.receivers (id, created_at, phone_number, email, updated_at, external_id) FROM stdin; +677820fb-68c4-4595-b0ec-ffa7df5390ef 2024-06-12 16:37:07.976312+00 +14155555555 \N 2024-06-12 16:37:07.976312+00 internal-id-A +b24fd41a-5cb9-4f3e-be5a-a6d2a657da4e 2024-06-12 16:37:07.976312+00 +5548999999999 \N 2024-06-12 16:37:07.976312+00 internal-id-B +560177da-47a7-4e1c-8361-757fc3ba4c7f 2024-06-12 16:37:07.976312+00 +14154444444 \N 2024-06-12 16:37:07.976312+00 internal-id-C +179c2ed5-6dce-46f5-967f-60c4bdfe8f03 2024-07-03 15:34:39.618582+00 +14153333333 \N 2024-07-03 15:34:39.618582+00 internal-id-D +\. + + +-- +-- Data for Name: submitter_transactions; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.submitter_transactions (id, external_id, status_message, asset_code, asset_issuer, amount, destination, created_at, started_at, sent_at, completed_at, stellar_transaction_hash, attempts_count, synced_at, updated_at, xdr_sent, xdr_received, locked_at, locked_until_ledger_number, status, status_history) FROM stdin; +048c2c23-dc15-49ad-ba8b-c91f2307c9f0 a9498205-8d8b-45b0-bcd9-57dc0058d815 \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GDSPHTXJIMA762ZXHPPR5QR3ZA6CT7M3QQHYAFUDIBB5AJL2DM7BNY2F 2024-04-18 14:54:04.654075+00 \N 2024-04-18 14:54:22.595344+00 2024-04-18 14:54:28.073046+00 67d8a6a01d1229502ee6a76e7e9223fd79781749d7f5140ceddc4963d3df12d0 1 2024-04-18 14:54:34.65445+00 2024-04-18 14:54:34.65445+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAAUpLy0VzB+Rsc9VGJiE4nxha9upU3XLTtjAOC6z9aZGAABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZiE1SgAAAAEAAAAAABIIlAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAIbcpxhDfULSGMy3NETWeLaW4xJukWfD5zzM98bgWTzzyYQHGL0r5ZjpHjfLsbYD2WA0U1gDXtFeMk5NQ9KHfB8/WmRgAAABADC+o4DldeMrPwWD9tFLmgxeqHMX32vSHIAHSaxB9fsjkUlUrLhYJ0uc27f0xZ8K1MyuoxOALY4cOVS7dAmlpBAAAAAAAAAAB7OKZaQAAAECy15xyqhXiwYclSTqCZoa/PwynhjQ5W5a0xd9imra48mvhGvPxo677woWC4etE3XDCybUG+wcuxLcnAU6g8XIN AAAAAAAAAMgAAAABtdvpZSvBEj6fiHnxAPEw/8WB2P0zetjvEyeYEACoOGkAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-04-18T14:54:04.654075+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAAUpLy0VzB+Rsc9VGJiE4nxha9upU3XLTtjAOC6z9aZGAABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZiE1SgAAAAEAAAAAABIIlAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAIbcpxhDfULSGMy3NETWeLaW4xJukWfD5zzM98bgWTzzyYQHGL0r5ZjpHjfLsbYD2WA0U1gDXtFeMk5NQ9KHfB8/WmRgAAABADC+o4DldeMrPwWD9tFLmgxeqHMX32vSHIAHSaxB9fsjkUlUrLhYJ0uc27f0xZ8K1MyuoxOALY4cOVS7dAmlpBAAAAAAAAAAB7OKZaQAAAECy15xyqhXiwYclSTqCZoa/PwynhjQ5W5a0xd9imra48mvhGvPxo677woWC4etE3XDCybUG+wcuxLcnAU6g8XIN\\", \\"timestamp\\": \\"2024-04-18T14:54:22.595344+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"67d8a6a01d1229502ee6a76e7e9223fd79781749d7f5140ceddc4963d3df12d0\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAAUpLy0VzB+Rsc9VGJiE4nxha9upU3XLTtjAOC6z9aZGAABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZiE1SgAAAAEAAAAAABIIlAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAIbcpxhDfULSGMy3NETWeLaW4xJukWfD5zzM98bgWTzzyYQHGL0r5ZjpHjfLsbYD2WA0U1gDXtFeMk5NQ9KHfB8/WmRgAAABADC+o4DldeMrPwWD9tFLmgxeqHMX32vSHIAHSaxB9fsjkUlUrLhYJ0uc27f0xZ8K1MyuoxOALY4cOVS7dAmlpBAAAAAAAAAAB7OKZaQAAAECy15xyqhXiwYclSTqCZoa/PwynhjQ5W5a0xd9imra48mvhGvPxo677woWC4etE3XDCybUG+wcuxLcnAU6g8XIN\\", \\"timestamp\\": \\"2024-04-18T14:54:28.07135+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABtdvpZSvBEj6fiHnxAPEw/8WB2P0zetjvEyeYEACoOGkAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"67d8a6a01d1229502ee6a76e7e9223fd79781749d7f5140ceddc4963d3df12d0\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAAUpLy0VzB+Rsc9VGJiE4nxha9upU3XLTtjAOC6z9aZGAABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZiE1SgAAAAEAAAAAABIIlAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAIbcpxhDfULSGMy3NETWeLaW4xJukWfD5zzM98bgWTzzyYQHGL0r5ZjpHjfLsbYD2WA0U1gDXtFeMk5NQ9KHfB8/WmRgAAABADC+o4DldeMrPwWD9tFLmgxeqHMX32vSHIAHSaxB9fsjkUlUrLhYJ0uc27f0xZ8K1MyuoxOALY4cOVS7dAmlpBAAAAAAAAAAB7OKZaQAAAECy15xyqhXiwYclSTqCZoa/PwynhjQ5W5a0xd9imra48mvhGvPxo677woWC4etE3XDCybUG+wcuxLcnAU6g8XIN\\", \\"timestamp\\": \\"2024-04-18T14:54:28.073046+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABtdvpZSvBEj6fiHnxAPEw/8WB2P0zetjvEyeYEACoOGkAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"67d8a6a01d1229502ee6a76e7e9223fd79781749d7f5140ceddc4963d3df12d0\\"}"} +b8bcf39c-65a1-4c1f-8e2b-65dbf6ab2374 84dd1a36-8396-4d0f-9c46-f8102a16058b \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GDSPHTXJIMA762ZXHPPR5QR3ZA6CT7M3QQHYAFUDIBB5AJL2DM7BNY2F 2024-02-23 21:01:47.278919+00 \N 2024-02-23 21:01:59.079888+00 2024-02-23 21:02:02.750159+00 7d0bb1a7ac8487a9a4d7cc518fc811fcaf7a27be45d21794a87ae92253220f91 1 2024-02-23 21:02:07.260842+00 2024-02-23 21:02:07.260842+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAClQIC/e947X6e9QKlO2w41w+5Zlpq8+NNlwux92C/i5gABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZdkI8wAAAAEAAAAAAARNAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABA1AAqvQpJr/Lnd1Yf1gA8sDyy7ifRp+IpOJ3J2H688UCIOcGxlhyhWJTTIMb4nHmvlpl0yOKDDkfCJ3paqjtmDtgv4uYAAABAhxSDvsXe96MdMMWF96xe3RRAG+pEBABU9MA8Goe/HNczBhx1WTidKU4H7BGmjEgqO2Bvz8BeOTWzzS45M1pCAwAAAAAAAAAB7OKZaQAAAEBCfEZVAaLK65Nimur9EXYUvZvR2PDVxokkr5co1l2aOvJze61Ohuj7yYgNOSPC16gmEVUsc1gizEu74MDR/HMJ AAAAAAAAAMgAAAAB0FDlaRoY08DU7/ykmfJCBzYU4DgWAAjpuUVH9nfo0qoAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-02-23T21:01:47.278919+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAClQIC/e947X6e9QKlO2w41w+5Zlpq8+NNlwux92C/i5gABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZdkI8wAAAAEAAAAAAARNAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABA1AAqvQpJr/Lnd1Yf1gA8sDyy7ifRp+IpOJ3J2H688UCIOcGxlhyhWJTTIMb4nHmvlpl0yOKDDkfCJ3paqjtmDtgv4uYAAABAhxSDvsXe96MdMMWF96xe3RRAG+pEBABU9MA8Goe/HNczBhx1WTidKU4H7BGmjEgqO2Bvz8BeOTWzzS45M1pCAwAAAAAAAAAB7OKZaQAAAEBCfEZVAaLK65Nimur9EXYUvZvR2PDVxokkr5co1l2aOvJze61Ohuj7yYgNOSPC16gmEVUsc1gizEu74MDR/HMJ\\", \\"timestamp\\": \\"2024-02-23T21:01:59.079888+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"7d0bb1a7ac8487a9a4d7cc518fc811fcaf7a27be45d21794a87ae92253220f91\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAClQIC/e947X6e9QKlO2w41w+5Zlpq8+NNlwux92C/i5gABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZdkI8wAAAAEAAAAAAARNAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABA1AAqvQpJr/Lnd1Yf1gA8sDyy7ifRp+IpOJ3J2H688UCIOcGxlhyhWJTTIMb4nHmvlpl0yOKDDkfCJ3paqjtmDtgv4uYAAABAhxSDvsXe96MdMMWF96xe3RRAG+pEBABU9MA8Goe/HNczBhx1WTidKU4H7BGmjEgqO2Bvz8BeOTWzzS45M1pCAwAAAAAAAAAB7OKZaQAAAEBCfEZVAaLK65Nimur9EXYUvZvR2PDVxokkr5co1l2aOvJze61Ohuj7yYgNOSPC16gmEVUsc1gizEu74MDR/HMJ\\", \\"timestamp\\": \\"2024-02-23T21:02:02.736434+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAAB0FDlaRoY08DU7/ykmfJCBzYU4DgWAAjpuUVH9nfo0qoAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"7d0bb1a7ac8487a9a4d7cc518fc811fcaf7a27be45d21794a87ae92253220f91\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAClQIC/e947X6e9QKlO2w41w+5Zlpq8+NNlwux92C/i5gABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZdkI8wAAAAEAAAAAAARNAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABA1AAqvQpJr/Lnd1Yf1gA8sDyy7ifRp+IpOJ3J2H688UCIOcGxlhyhWJTTIMb4nHmvlpl0yOKDDkfCJ3paqjtmDtgv4uYAAABAhxSDvsXe96MdMMWF96xe3RRAG+pEBABU9MA8Goe/HNczBhx1WTidKU4H7BGmjEgqO2Bvz8BeOTWzzS45M1pCAwAAAAAAAAAB7OKZaQAAAEBCfEZVAaLK65Nimur9EXYUvZvR2PDVxokkr5co1l2aOvJze61Ohuj7yYgNOSPC16gmEVUsc1gizEu74MDR/HMJ\\", \\"timestamp\\": \\"2024-02-23T21:02:02.750159+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAAB0FDlaRoY08DU7/ykmfJCBzYU4DgWAAjpuUVH9nfo0qoAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"7d0bb1a7ac8487a9a4d7cc518fc811fcaf7a27be45d21794a87ae92253220f91\\"}"} +12670c3a-7028-400b-8407-dd2c1a9c3b26 6e2e93fe-2e2b-4d5d-b896-50811ecff519 \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GDSPHTXJIMA762ZXHPPR5QR3ZA6CT7M3QQHYAFUDIBB5AJL2DM7BNY2F 2024-06-06 21:18:50.516389+00 \N 2024-06-06 21:18:54.051283+00 2024-06-06 21:18:55.791184+00 f272d174bd519345ce948f037e692c0177b65ab81d846f95847824c31f560bbc 1 2024-06-06 21:19:00.513812+00 2024-06-06 21:19:00.513812+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABiCL2u3jniLJEZELecVq/Nui5SjpgCIMff9KJ9QsPlvQABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmIo6gAAAAEAAAAAAB5kdgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABABlG+ZsrmwfzNFnAZ+DhgUWD6dfPgjpMwaejxhYKeh58wVLGK84FOOOwNrS1Fr9g7UfRF5iiVUCQSTwBEA9ePDELD5b0AAABA84EqRX+uFBtfs/mQ46mbKA+6etbCQqPAnqRPNeCQ/abLJ5oequTNJ7nWNHwRddBTZNu6rxYYcF1kVjDiQFK/BQAAAAAAAAAB7OKZaQAAAEAkE23MF0O+q/0ST0oGiwB89X+YwqoqU79qSW7DFLSZh9T4e4ZtAPOPEr1u+oOADM7iJUmG4iAVZU2Y3u0X3y0O AAAAAAAAAMgAAAAB1LI/eA4N42EQURIVP5oBNg+E8rm47Y1PBTqIrB0moqgAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-06-06T21:18:50.516389+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABiCL2u3jniLJEZELecVq/Nui5SjpgCIMff9KJ9QsPlvQABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmIo6gAAAAEAAAAAAB5kdgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABABlG+ZsrmwfzNFnAZ+DhgUWD6dfPgjpMwaejxhYKeh58wVLGK84FOOOwNrS1Fr9g7UfRF5iiVUCQSTwBEA9ePDELD5b0AAABA84EqRX+uFBtfs/mQ46mbKA+6etbCQqPAnqRPNeCQ/abLJ5oequTNJ7nWNHwRddBTZNu6rxYYcF1kVjDiQFK/BQAAAAAAAAAB7OKZaQAAAEAkE23MF0O+q/0ST0oGiwB89X+YwqoqU79qSW7DFLSZh9T4e4ZtAPOPEr1u+oOADM7iJUmG4iAVZU2Y3u0X3y0O\\", \\"timestamp\\": \\"2024-06-06T21:18:54.051283+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"f272d174bd519345ce948f037e692c0177b65ab81d846f95847824c31f560bbc\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABiCL2u3jniLJEZELecVq/Nui5SjpgCIMff9KJ9QsPlvQABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmIo6gAAAAEAAAAAAB5kdgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABABlG+ZsrmwfzNFnAZ+DhgUWD6dfPgjpMwaejxhYKeh58wVLGK84FOOOwNrS1Fr9g7UfRF5iiVUCQSTwBEA9ePDELD5b0AAABA84EqRX+uFBtfs/mQ46mbKA+6etbCQqPAnqRPNeCQ/abLJ5oequTNJ7nWNHwRddBTZNu6rxYYcF1kVjDiQFK/BQAAAAAAAAAB7OKZaQAAAEAkE23MF0O+q/0ST0oGiwB89X+YwqoqU79qSW7DFLSZh9T4e4ZtAPOPEr1u+oOADM7iJUmG4iAVZU2Y3u0X3y0O\\", \\"timestamp\\": \\"2024-06-06T21:18:55.789591+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAAB1LI/eA4N42EQURIVP5oBNg+E8rm47Y1PBTqIrB0moqgAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"f272d174bd519345ce948f037e692c0177b65ab81d846f95847824c31f560bbc\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABiCL2u3jniLJEZELecVq/Nui5SjpgCIMff9KJ9QsPlvQABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmIo6gAAAAEAAAAAAB5kdgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABABlG+ZsrmwfzNFnAZ+DhgUWD6dfPgjpMwaejxhYKeh58wVLGK84FOOOwNrS1Fr9g7UfRF5iiVUCQSTwBEA9ePDELD5b0AAABA84EqRX+uFBtfs/mQ46mbKA+6etbCQqPAnqRPNeCQ/abLJ5oequTNJ7nWNHwRddBTZNu6rxYYcF1kVjDiQFK/BQAAAAAAAAAB7OKZaQAAAEAkE23MF0O+q/0ST0oGiwB89X+YwqoqU79qSW7DFLSZh9T4e4ZtAPOPEr1u+oOADM7iJUmG4iAVZU2Y3u0X3y0O\\", \\"timestamp\\": \\"2024-06-06T21:18:55.791184+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAAB1LI/eA4N42EQURIVP5oBNg+E8rm47Y1PBTqIrB0moqgAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"f272d174bd519345ce948f037e692c0177b65ab81d846f95847824c31f560bbc\\"}"} +767a2b1f-dc3d-417d-aaef-4aafe0795e9f e60a06a2-92e5-4a53-8dee-10677a0efc11 \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GDSPHTXJIMA762ZXHPPR5QR3ZA6CT7M3QQHYAFUDIBB5AJL2DM7BNY2F 2024-02-23 21:17:23.549317+00 \N 2024-02-23 21:17:41.521762+00 2024-02-23 21:17:47.785902+00 266bdcc3fe18fad98390da1cdda4aac80562a12428529da3a70077f32b0ccefc 1 2024-02-23 21:17:53.549034+00 2024-02-23 21:17:53.549034+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAA0ItZNO63siMmrGBguUNDwhf3SQIKJwo7nUAMkZlgQQQABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZdkMoQAAAAEAAAAAAARNtgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABA/LITDNi4swQIFtL57leslk4W4jKt4JgM3+OR4OcuavBM0H2YkLsNuInlXBhI2WdqcK1jdE1tnc4xdo233OhiB2ZYEEEAAABA1aFKyLlbdgDq+XyESbEbRfo4mMmuJHlL6BUD61AVXFIN/UeXiWuwScAdG7XZi02fAUCtlaGlp/7bjSaDIozMAAAAAAAAAAAB7OKZaQAAAEAOCfEXi7ma+y0NSG4yOKB3F8I1OkkCc5Sj0OuOgiD4MHnd48bYyEI0YAdBsPkacV3rUwzK7v08bM5X4gRbDkYK AAAAAAAAAMgAAAAB7uXreqL6MQa3wIjFBvqpDNhUw+cBeOl1CXSHoq6S5I8AAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-02-23T21:17:23.549317+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAA0ItZNO63siMmrGBguUNDwhf3SQIKJwo7nUAMkZlgQQQABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZdkMoQAAAAEAAAAAAARNtgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABA/LITDNi4swQIFtL57leslk4W4jKt4JgM3+OR4OcuavBM0H2YkLsNuInlXBhI2WdqcK1jdE1tnc4xdo233OhiB2ZYEEEAAABA1aFKyLlbdgDq+XyESbEbRfo4mMmuJHlL6BUD61AVXFIN/UeXiWuwScAdG7XZi02fAUCtlaGlp/7bjSaDIozMAAAAAAAAAAAB7OKZaQAAAEAOCfEXi7ma+y0NSG4yOKB3F8I1OkkCc5Sj0OuOgiD4MHnd48bYyEI0YAdBsPkacV3rUwzK7v08bM5X4gRbDkYK\\", \\"timestamp\\": \\"2024-02-23T21:17:41.521762+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"266bdcc3fe18fad98390da1cdda4aac80562a12428529da3a70077f32b0ccefc\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAA0ItZNO63siMmrGBguUNDwhf3SQIKJwo7nUAMkZlgQQQABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZdkMoQAAAAEAAAAAAARNtgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABA/LITDNi4swQIFtL57leslk4W4jKt4JgM3+OR4OcuavBM0H2YkLsNuInlXBhI2WdqcK1jdE1tnc4xdo233OhiB2ZYEEEAAABA1aFKyLlbdgDq+XyESbEbRfo4mMmuJHlL6BUD61AVXFIN/UeXiWuwScAdG7XZi02fAUCtlaGlp/7bjSaDIozMAAAAAAAAAAAB7OKZaQAAAEAOCfEXi7ma+y0NSG4yOKB3F8I1OkkCc5Sj0OuOgiD4MHnd48bYyEI0YAdBsPkacV3rUwzK7v08bM5X4gRbDkYK\\", \\"timestamp\\": \\"2024-02-23T21:17:47.783766+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAAB7uXreqL6MQa3wIjFBvqpDNhUw+cBeOl1CXSHoq6S5I8AAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"266bdcc3fe18fad98390da1cdda4aac80562a12428529da3a70077f32b0ccefc\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAA0ItZNO63siMmrGBguUNDwhf3SQIKJwo7nUAMkZlgQQQABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZdkMoQAAAAEAAAAAAARNtgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABA/LITDNi4swQIFtL57leslk4W4jKt4JgM3+OR4OcuavBM0H2YkLsNuInlXBhI2WdqcK1jdE1tnc4xdo233OhiB2ZYEEEAAABA1aFKyLlbdgDq+XyESbEbRfo4mMmuJHlL6BUD61AVXFIN/UeXiWuwScAdG7XZi02fAUCtlaGlp/7bjSaDIozMAAAAAAAAAAAB7OKZaQAAAEAOCfEXi7ma+y0NSG4yOKB3F8I1OkkCc5Sj0OuOgiD4MHnd48bYyEI0YAdBsPkacV3rUwzK7v08bM5X4gRbDkYK\\", \\"timestamp\\": \\"2024-02-23T21:17:47.785902+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAAB7uXreqL6MQa3wIjFBvqpDNhUw+cBeOl1CXSHoq6S5I8AAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"266bdcc3fe18fad98390da1cdda4aac80562a12428529da3a70077f32b0ccefc\\"}"} +49c8c13e-e881-4d46-a4c7-5adcba449bbd 8b7fa22c-055e-4fc1-b376-94bbde18ff9b \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.0100000 GBWAUMIIMXNUSZGWWA7AKGBDWEJ7MH7IRS56NTP6V2XS3J3RHUXZI36O 2024-02-27 20:50:23.560547+00 \N 2024-02-27 20:50:41.523333+00 2024-02-27 20:50:46.734293+00 c22f78b2575eed4a6370c12db9dd46c86c2b667be5a0e72bd2d8228dc847d300 1 2024-02-27 20:50:53.550306+00 2024-02-27 20:50:53.550306+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAD0brcNt/rxac0AcXJoVooMOOwMdW10PJpC32Jqmcpa0AABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZd5MTQAAAAEAAAAAAAVNYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABsCjEIZdtJZNawPgUYI7ET9h/ojLvmzf6ury2ncT0vlAAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAABhqAAAAAAAAAAAuzimWkAAABAbArlpSGXfFLs9dVNrBT0Kjyjb8LJkUP7VgQK5dsiJYX6gJ+oiKxp6MywBMF7yR2KjN4D/wBuSeeJzbJKDaIOBZnKWtAAAABAoHs1IvdXgKvC03ntMzGcjXcBNcRO/YXtd8qd+aGgRo+2xqr3lBc0L5R5DSXK+SkOQe0+XImiSMu+wVvTA41GBwAAAAAAAAAB7OKZaQAAAED4FTpDSjio9vhDo61iLCpm67RCeIy7QZLAEN1tMVELVCteJtLu8V1ZixPjlPIVJ4bxfHkKxBzMWPhEU/0dNO0P AAAAAAAAAMgAAAABZzFOrfoyu6xoY9bXZ46ypPZK3tyUj8AYRUfLoesOLCAAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-02-27T20:50:23.560547+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAD0brcNt/rxac0AcXJoVooMOOwMdW10PJpC32Jqmcpa0AABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZd5MTQAAAAEAAAAAAAVNYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABsCjEIZdtJZNawPgUYI7ET9h/ojLvmzf6ury2ncT0vlAAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAABhqAAAAAAAAAAAuzimWkAAABAbArlpSGXfFLs9dVNrBT0Kjyjb8LJkUP7VgQK5dsiJYX6gJ+oiKxp6MywBMF7yR2KjN4D/wBuSeeJzbJKDaIOBZnKWtAAAABAoHs1IvdXgKvC03ntMzGcjXcBNcRO/YXtd8qd+aGgRo+2xqr3lBc0L5R5DSXK+SkOQe0+XImiSMu+wVvTA41GBwAAAAAAAAAB7OKZaQAAAED4FTpDSjio9vhDo61iLCpm67RCeIy7QZLAEN1tMVELVCteJtLu8V1ZixPjlPIVJ4bxfHkKxBzMWPhEU/0dNO0P\\", \\"timestamp\\": \\"2024-02-27T20:50:41.523333+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"c22f78b2575eed4a6370c12db9dd46c86c2b667be5a0e72bd2d8228dc847d300\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAD0brcNt/rxac0AcXJoVooMOOwMdW10PJpC32Jqmcpa0AABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZd5MTQAAAAEAAAAAAAVNYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABsCjEIZdtJZNawPgUYI7ET9h/ojLvmzf6ury2ncT0vlAAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAABhqAAAAAAAAAAAuzimWkAAABAbArlpSGXfFLs9dVNrBT0Kjyjb8LJkUP7VgQK5dsiJYX6gJ+oiKxp6MywBMF7yR2KjN4D/wBuSeeJzbJKDaIOBZnKWtAAAABAoHs1IvdXgKvC03ntMzGcjXcBNcRO/YXtd8qd+aGgRo+2xqr3lBc0L5R5DSXK+SkOQe0+XImiSMu+wVvTA41GBwAAAAAAAAAB7OKZaQAAAED4FTpDSjio9vhDo61iLCpm67RCeIy7QZLAEN1tMVELVCteJtLu8V1ZixPjlPIVJ4bxfHkKxBzMWPhEU/0dNO0P\\", \\"timestamp\\": \\"2024-02-27T20:50:46.731974+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABZzFOrfoyu6xoY9bXZ46ypPZK3tyUj8AYRUfLoesOLCAAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"c22f78b2575eed4a6370c12db9dd46c86c2b667be5a0e72bd2d8228dc847d300\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAD0brcNt/rxac0AcXJoVooMOOwMdW10PJpC32Jqmcpa0AABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZd5MTQAAAAEAAAAAAAVNYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABsCjEIZdtJZNawPgUYI7ET9h/ojLvmzf6ury2ncT0vlAAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAABhqAAAAAAAAAAAuzimWkAAABAbArlpSGXfFLs9dVNrBT0Kjyjb8LJkUP7VgQK5dsiJYX6gJ+oiKxp6MywBMF7yR2KjN4D/wBuSeeJzbJKDaIOBZnKWtAAAABAoHs1IvdXgKvC03ntMzGcjXcBNcRO/YXtd8qd+aGgRo+2xqr3lBc0L5R5DSXK+SkOQe0+XImiSMu+wVvTA41GBwAAAAAAAAAB7OKZaQAAAED4FTpDSjio9vhDo61iLCpm67RCeIy7QZLAEN1tMVELVCteJtLu8V1ZixPjlPIVJ4bxfHkKxBzMWPhEU/0dNO0P\\", \\"timestamp\\": \\"2024-02-27T20:50:46.734293+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABZzFOrfoyu6xoY9bXZ46ypPZK3tyUj8AYRUfLoesOLCAAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"c22f78b2575eed4a6370c12db9dd46c86c2b667be5a0e72bd2d8228dc847d300\\"}"} +c9907d67-ef34-4859-bd0d-e56df8463844 c29fe338-273c-4906-8aad-d48d10ee862d \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GDJUCRN3DXK2JXDS7SVII2UDACR5L2LQUOBLO5H6PYIIK62J37TRVFNN 2024-02-28 20:18:43.56227+00 \N 2024-02-28 20:19:01.524637+00 2024-02-28 20:19:07.736143+00 ae32b4d455a5332bbcc948da4b3369a596b11a72a28e2d4723cf555568e1cd13 1 2024-02-28 20:19:13.560899+00 2024-02-28 20:19:13.560899+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAADORp7ouunwVUrrow+mOKF/J+8IWgb1HFabrnTNb3uqpwABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZd+WYQAAAAEAAAAAAAWMKwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADTQUW7HdWk3HL8qoRqgwCj1elwo4K3dP5+EIV7Sd/nGgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAAjKNArBBQwp+dHPIuKFciP6Gd6ZBbCSRbA68uL4M7yTdUmO1x/MRWd+ds/mQQkUAV6FkQ8SY0vdOMzxYRcAPD297qqcAAABAiS73b+KLeiuaVnYtyeDbTa3oX96f5GOGvoLKM13DZ9hS4OKhzVjIJK5cfFEWPWgMcaqeroEUzoqj1W72Uh4iCgAAAAAAAAAB7OKZaQAAAECzdn9z7XGik4IIOADBbJ9v0xT6zUXtlXh5zMWSAHZ/n309HBQDpaYC0iB9ie+dxjE1aupxaD24VlhNHMx9JNYC AAAAAAAAAMgAAAABF8I9Mfw5TKx91jvEa8AKP0i/jgiNl+eMalWftvJ0krcAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-02-28T20:18:43.56227+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAADORp7ouunwVUrrow+mOKF/J+8IWgb1HFabrnTNb3uqpwABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZd+WYQAAAAEAAAAAAAWMKwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADTQUW7HdWk3HL8qoRqgwCj1elwo4K3dP5+EIV7Sd/nGgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAAjKNArBBQwp+dHPIuKFciP6Gd6ZBbCSRbA68uL4M7yTdUmO1x/MRWd+ds/mQQkUAV6FkQ8SY0vdOMzxYRcAPD297qqcAAABAiS73b+KLeiuaVnYtyeDbTa3oX96f5GOGvoLKM13DZ9hS4OKhzVjIJK5cfFEWPWgMcaqeroEUzoqj1W72Uh4iCgAAAAAAAAAB7OKZaQAAAECzdn9z7XGik4IIOADBbJ9v0xT6zUXtlXh5zMWSAHZ/n309HBQDpaYC0iB9ie+dxjE1aupxaD24VlhNHMx9JNYC\\", \\"timestamp\\": \\"2024-02-28T20:19:01.524637+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"ae32b4d455a5332bbcc948da4b3369a596b11a72a28e2d4723cf555568e1cd13\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAADORp7ouunwVUrrow+mOKF/J+8IWgb1HFabrnTNb3uqpwABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZd+WYQAAAAEAAAAAAAWMKwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADTQUW7HdWk3HL8qoRqgwCj1elwo4K3dP5+EIV7Sd/nGgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAAjKNArBBQwp+dHPIuKFciP6Gd6ZBbCSRbA68uL4M7yTdUmO1x/MRWd+ds/mQQkUAV6FkQ8SY0vdOMzxYRcAPD297qqcAAABAiS73b+KLeiuaVnYtyeDbTa3oX96f5GOGvoLKM13DZ9hS4OKhzVjIJK5cfFEWPWgMcaqeroEUzoqj1W72Uh4iCgAAAAAAAAAB7OKZaQAAAECzdn9z7XGik4IIOADBbJ9v0xT6zUXtlXh5zMWSAHZ/n309HBQDpaYC0iB9ie+dxjE1aupxaD24VlhNHMx9JNYC\\", \\"timestamp\\": \\"2024-02-28T20:19:07.73344+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABF8I9Mfw5TKx91jvEa8AKP0i/jgiNl+eMalWftvJ0krcAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"ae32b4d455a5332bbcc948da4b3369a596b11a72a28e2d4723cf555568e1cd13\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAADORp7ouunwVUrrow+mOKF/J+8IWgb1HFabrnTNb3uqpwABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZd+WYQAAAAEAAAAAAAWMKwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADTQUW7HdWk3HL8qoRqgwCj1elwo4K3dP5+EIV7Sd/nGgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAAjKNArBBQwp+dHPIuKFciP6Gd6ZBbCSRbA68uL4M7yTdUmO1x/MRWd+ds/mQQkUAV6FkQ8SY0vdOMzxYRcAPD297qqcAAABAiS73b+KLeiuaVnYtyeDbTa3oX96f5GOGvoLKM13DZ9hS4OKhzVjIJK5cfFEWPWgMcaqeroEUzoqj1W72Uh4iCgAAAAAAAAAB7OKZaQAAAECzdn9z7XGik4IIOADBbJ9v0xT6zUXtlXh5zMWSAHZ/n309HBQDpaYC0iB9ie+dxjE1aupxaD24VlhNHMx9JNYC\\", \\"timestamp\\": \\"2024-02-28T20:19:07.736143+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABF8I9Mfw5TKx91jvEa8AKP0i/jgiNl+eMalWftvJ0krcAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"ae32b4d455a5332bbcc948da4b3369a596b11a72a28e2d4723cf555568e1cd13\\"}"} +c15f3cc7-5598-44bd-8c58-1b4391691f73 d272d048-bca2-4be6-abb8-1325e81ad2ce \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 50.0000000 GDSPHTXJIMA762ZXHPPR5QR3ZA6CT7M3QQHYAFUDIBB5AJL2DM7BNY2F 2024-05-01 13:58:22.51672+00 \N 2024-05-01 13:58:32.852212+00 2024-05-01 13:58:35.678616+00 f9f329e7a52038301cb29dcc4f49ed64a15f0a88520f856469683c6c8f9bfe05 1 2024-05-01 13:58:42.507497+00 2024-05-01 13:58:42.507497+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAACt5L3ULU+nP8IOSCrlV7hBi9zaLqYxI8IEcajJLAWq6AABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZjJLtAAAAAEAAAAAABVJMwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAB3NZQAAAAAAAAAAAuzimWkAAABAs3XiWoOw2BJ2zIUrQrFAUuR7qPrzNYLJKhVMVjVwjzZHuUO/SSw0De59KzqAlOiV704L6A8jvkwWeVuKDsY0CiwFqugAAABAu6UVRl47PBbg9/WVyCY55XXRJblmRo3igTfC2NHntjwfX45PxS0q6iVoyzbW51LuXs3inBxUQU1TyD2ccIssCAAAAAAAAAAB7OKZaQAAAEBC8fdskyy5Bvz1rP3JiB7cqr5BKTc4YjlmnXDcNn4WhBOs6clw46OM5wnX7ZbpbYoHg8XWzXVGj3oUSOtJh48A AAAAAAAAAMgAAAABKon62edkFrwPZYHu226qMC9NEdMDBCvDJrhIHHBAnRUAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-05-01T13:58:22.51672+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAACt5L3ULU+nP8IOSCrlV7hBi9zaLqYxI8IEcajJLAWq6AABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZjJLtAAAAAEAAAAAABVJMwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAB3NZQAAAAAAAAAAAuzimWkAAABAs3XiWoOw2BJ2zIUrQrFAUuR7qPrzNYLJKhVMVjVwjzZHuUO/SSw0De59KzqAlOiV704L6A8jvkwWeVuKDsY0CiwFqugAAABAu6UVRl47PBbg9/WVyCY55XXRJblmRo3igTfC2NHntjwfX45PxS0q6iVoyzbW51LuXs3inBxUQU1TyD2ccIssCAAAAAAAAAAB7OKZaQAAAEBC8fdskyy5Bvz1rP3JiB7cqr5BKTc4YjlmnXDcNn4WhBOs6clw46OM5wnX7ZbpbYoHg8XWzXVGj3oUSOtJh48A\\", \\"timestamp\\": \\"2024-05-01T13:58:32.852212+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"f9f329e7a52038301cb29dcc4f49ed64a15f0a88520f856469683c6c8f9bfe05\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAACt5L3ULU+nP8IOSCrlV7hBi9zaLqYxI8IEcajJLAWq6AABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZjJLtAAAAAEAAAAAABVJMwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAB3NZQAAAAAAAAAAAuzimWkAAABAs3XiWoOw2BJ2zIUrQrFAUuR7qPrzNYLJKhVMVjVwjzZHuUO/SSw0De59KzqAlOiV704L6A8jvkwWeVuKDsY0CiwFqugAAABAu6UVRl47PBbg9/WVyCY55XXRJblmRo3igTfC2NHntjwfX45PxS0q6iVoyzbW51LuXs3inBxUQU1TyD2ccIssCAAAAAAAAAAB7OKZaQAAAEBC8fdskyy5Bvz1rP3JiB7cqr5BKTc4YjlmnXDcNn4WhBOs6clw46OM5wnX7ZbpbYoHg8XWzXVGj3oUSOtJh48A\\", \\"timestamp\\": \\"2024-05-01T13:58:35.676425+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABKon62edkFrwPZYHu226qMC9NEdMDBCvDJrhIHHBAnRUAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"f9f329e7a52038301cb29dcc4f49ed64a15f0a88520f856469683c6c8f9bfe05\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAACt5L3ULU+nP8IOSCrlV7hBi9zaLqYxI8IEcajJLAWq6AABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZjJLtAAAAAEAAAAAABVJMwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAB3NZQAAAAAAAAAAAuzimWkAAABAs3XiWoOw2BJ2zIUrQrFAUuR7qPrzNYLJKhVMVjVwjzZHuUO/SSw0De59KzqAlOiV704L6A8jvkwWeVuKDsY0CiwFqugAAABAu6UVRl47PBbg9/WVyCY55XXRJblmRo3igTfC2NHntjwfX45PxS0q6iVoyzbW51LuXs3inBxUQU1TyD2ccIssCAAAAAAAAAAB7OKZaQAAAEBC8fdskyy5Bvz1rP3JiB7cqr5BKTc4YjlmnXDcNn4WhBOs6clw46OM5wnX7ZbpbYoHg8XWzXVGj3oUSOtJh48A\\", \\"timestamp\\": \\"2024-05-01T13:58:35.678616+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABKon62edkFrwPZYHu226qMC9NEdMDBCvDJrhIHHBAnRUAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"f9f329e7a52038301cb29dcc4f49ed64a15f0a88520f856469683c6c8f9bfe05\\"}"} +e5f781e4-6113-4ad7-b17f-41143e927419 978ea265-13a6-401e-9def-5cfdcac4dea2 \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GDSPHTXJIMA762ZXHPPR5QR3ZA6CT7M3QQHYAFUDIBB5AJL2DM7BNY2F 2024-02-29 02:48:43.550705+00 \N 2024-02-29 02:49:01.528453+00 2024-02-29 02:49:07.786201+00 3e62c6c63939a230281deefb500496ea7d0503d257edd34d90e8df536bd67504 1 2024-02-29 02:49:13.553025+00 2024-02-29 02:49:13.553025+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABg02CcFBNhea+NEyOl6y4Ohaxed3YFMJaee3k+fmxclQABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZd/xyQAAAAEAAAAAAAWdoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAuumu9WAHVBsWeB2SOyfi+LGdCkU31FIv19shhOgH0yiIzrFnXW72QW56rZFf0xj3pJXtiazAWtIby4FAI8QIC35sXJUAAABA/CI5WxDJQPIV/fkMszCT0FPCFOYznnGkgMiroAbDo50wAcTqx/01tfFm20hhjA0pyGCx/2oZmOeP01wI3mETDgAAAAAAAAAB7OKZaQAAAEBAbgPF7nJ8i9ZhWRoImz2X7djD/+euNp9Xzcsb8EC54JilEBeedN4LnKzq0ZGpymcRC3DyWYnyZ4MNJGYS5DYA AAAAAAAAAMgAAAAB9MRSiTrZ2RNwgId8MVJ2NbVg4giD7dBr6/42S7615qkAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-02-29T02:48:43.550705+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABg02CcFBNhea+NEyOl6y4Ohaxed3YFMJaee3k+fmxclQABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZd/xyQAAAAEAAAAAAAWdoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAuumu9WAHVBsWeB2SOyfi+LGdCkU31FIv19shhOgH0yiIzrFnXW72QW56rZFf0xj3pJXtiazAWtIby4FAI8QIC35sXJUAAABA/CI5WxDJQPIV/fkMszCT0FPCFOYznnGkgMiroAbDo50wAcTqx/01tfFm20hhjA0pyGCx/2oZmOeP01wI3mETDgAAAAAAAAAB7OKZaQAAAEBAbgPF7nJ8i9ZhWRoImz2X7djD/+euNp9Xzcsb8EC54JilEBeedN4LnKzq0ZGpymcRC3DyWYnyZ4MNJGYS5DYA\\", \\"timestamp\\": \\"2024-02-29T02:49:01.528453+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"3e62c6c63939a230281deefb500496ea7d0503d257edd34d90e8df536bd67504\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABg02CcFBNhea+NEyOl6y4Ohaxed3YFMJaee3k+fmxclQABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZd/xyQAAAAEAAAAAAAWdoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAuumu9WAHVBsWeB2SOyfi+LGdCkU31FIv19shhOgH0yiIzrFnXW72QW56rZFf0xj3pJXtiazAWtIby4FAI8QIC35sXJUAAABA/CI5WxDJQPIV/fkMszCT0FPCFOYznnGkgMiroAbDo50wAcTqx/01tfFm20hhjA0pyGCx/2oZmOeP01wI3mETDgAAAAAAAAAB7OKZaQAAAEBAbgPF7nJ8i9ZhWRoImz2X7djD/+euNp9Xzcsb8EC54JilEBeedN4LnKzq0ZGpymcRC3DyWYnyZ4MNJGYS5DYA\\", \\"timestamp\\": \\"2024-02-29T02:49:07.784121+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAAB9MRSiTrZ2RNwgId8MVJ2NbVg4giD7dBr6/42S7615qkAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"3e62c6c63939a230281deefb500496ea7d0503d257edd34d90e8df536bd67504\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABg02CcFBNhea+NEyOl6y4Ohaxed3YFMJaee3k+fmxclQABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZd/xyQAAAAEAAAAAAAWdoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAuumu9WAHVBsWeB2SOyfi+LGdCkU31FIv19shhOgH0yiIzrFnXW72QW56rZFf0xj3pJXtiazAWtIby4FAI8QIC35sXJUAAABA/CI5WxDJQPIV/fkMszCT0FPCFOYznnGkgMiroAbDo50wAcTqx/01tfFm20hhjA0pyGCx/2oZmOeP01wI3mETDgAAAAAAAAAB7OKZaQAAAEBAbgPF7nJ8i9ZhWRoImz2X7djD/+euNp9Xzcsb8EC54JilEBeedN4LnKzq0ZGpymcRC3DyWYnyZ4MNJGYS5DYA\\", \\"timestamp\\": \\"2024-02-29T02:49:07.786201+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAAB9MRSiTrZ2RNwgId8MVJ2NbVg4giD7dBr6/42S7615qkAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"3e62c6c63939a230281deefb500496ea7d0503d257edd34d90e8df536bd67504\\"}"} +dcc20f8b-839d-48aa-8f98-29bb2857684d 1d29022b-5ce6-4b23-9322-7449cd934e7a \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GDSPHTXJIMA762ZXHPPR5QR3ZA6CT7M3QQHYAFUDIBB5AJL2DM7BNY2F 2024-04-18 13:44:44.65892+00 \N 2024-04-18 13:45:02.59496+00 2024-04-18 13:45:05.070108+00 ff69554c8bc05f99d5021ddde7ff67e24c46f125dc1e4c7da8ac88512cd1b8aa 1 2024-04-18 13:45:14.658954+00 2024-04-18 13:45:14.658954+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABM0TP0rJBU8h1RzaEnx4q6FinHR3M6bzswdOeqUbrj6gABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZiElCgAAAAEAAAAAABIFfAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAM+nm124s0AcpX4vsR62eHq+PyMjllFs+Wrk9zPZb0oK92EUkALLV8aOKYA8/ZCRPDJ05by+Vy+lBwP90OeYwAVG64+oAAABA7iXuk2rrDI43lbM6ghIWgs96grrF8ygxeNfcEhl2zu3jUnyAHeEbbItINoI3hydraCeBh2SAIdBdBksJJkW/BQAAAAAAAAAB7OKZaQAAAEAp01B28NC4ny+esmWmkTkkJje67noC4l/FAu/bJ/17HEcPwF/mich7jEvenyZduaa6B7zBS4VNyXlBEO1ZbIoF AAAAAAAAAMgAAAABMPoAd3IKOMLULV4FjmOGR89YnB4jRpI9F9onUOWiEREAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-04-18T13:44:44.65892+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABM0TP0rJBU8h1RzaEnx4q6FinHR3M6bzswdOeqUbrj6gABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZiElCgAAAAEAAAAAABIFfAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAM+nm124s0AcpX4vsR62eHq+PyMjllFs+Wrk9zPZb0oK92EUkALLV8aOKYA8/ZCRPDJ05by+Vy+lBwP90OeYwAVG64+oAAABA7iXuk2rrDI43lbM6ghIWgs96grrF8ygxeNfcEhl2zu3jUnyAHeEbbItINoI3hydraCeBh2SAIdBdBksJJkW/BQAAAAAAAAAB7OKZaQAAAEAp01B28NC4ny+esmWmkTkkJje67noC4l/FAu/bJ/17HEcPwF/mich7jEvenyZduaa6B7zBS4VNyXlBEO1ZbIoF\\", \\"timestamp\\": \\"2024-04-18T13:45:02.59496+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"ff69554c8bc05f99d5021ddde7ff67e24c46f125dc1e4c7da8ac88512cd1b8aa\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABM0TP0rJBU8h1RzaEnx4q6FinHR3M6bzswdOeqUbrj6gABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZiElCgAAAAEAAAAAABIFfAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAM+nm124s0AcpX4vsR62eHq+PyMjllFs+Wrk9zPZb0oK92EUkALLV8aOKYA8/ZCRPDJ05by+Vy+lBwP90OeYwAVG64+oAAABA7iXuk2rrDI43lbM6ghIWgs96grrF8ygxeNfcEhl2zu3jUnyAHeEbbItINoI3hydraCeBh2SAIdBdBksJJkW/BQAAAAAAAAAB7OKZaQAAAEAp01B28NC4ny+esmWmkTkkJje67noC4l/FAu/bJ/17HEcPwF/mich7jEvenyZduaa6B7zBS4VNyXlBEO1ZbIoF\\", \\"timestamp\\": \\"2024-04-18T13:45:05.066491+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABMPoAd3IKOMLULV4FjmOGR89YnB4jRpI9F9onUOWiEREAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"ff69554c8bc05f99d5021ddde7ff67e24c46f125dc1e4c7da8ac88512cd1b8aa\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABM0TP0rJBU8h1RzaEnx4q6FinHR3M6bzswdOeqUbrj6gABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZiElCgAAAAEAAAAAABIFfAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAM+nm124s0AcpX4vsR62eHq+PyMjllFs+Wrk9zPZb0oK92EUkALLV8aOKYA8/ZCRPDJ05by+Vy+lBwP90OeYwAVG64+oAAABA7iXuk2rrDI43lbM6ghIWgs96grrF8ygxeNfcEhl2zu3jUnyAHeEbbItINoI3hydraCeBh2SAIdBdBksJJkW/BQAAAAAAAAAB7OKZaQAAAEAp01B28NC4ny+esmWmkTkkJje67noC4l/FAu/bJ/17HEcPwF/mich7jEvenyZduaa6B7zBS4VNyXlBEO1ZbIoF\\", \\"timestamp\\": \\"2024-04-18T13:45:05.070108+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABMPoAd3IKOMLULV4FjmOGR89YnB4jRpI9F9onUOWiEREAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"ff69554c8bc05f99d5021ddde7ff67e24c46f125dc1e4c7da8ac88512cd1b8aa\\"}"} +f6b31925-2bb5-4771-a7a2-e8dd5b7c4a39 8fa6f334-a0e4-4542-a0a0-4b8b69421161 horizon response error: StatusCode=400, Type=https://stellar.org/horizon-errors/transaction_failed, Title=Transaction Failed, Detail=The transaction failed when submitted to the stellar network. The `extras.result_codes` field on this response contains further details. Descriptions of each code can be found at: https://developers.stellar.org/api/errors/http-status-codes/horizon-specific/transaction-failed/, Extras=transaction: tx_fee_bump_inner_failed - inner transaction: tx_failed - operation codes: [ op_no_trust ] USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GBLDUES2TP67B2MWDSLR3XEJQUHR5MEFPKCWM6M3FIP2AEOH2COMYV6N 2024-03-01 21:37:23.562068+00 \N 2024-03-01 21:37:41.524179+00 2024-03-01 21:37:44.525422+00 abbe9c4016c54e912983110fe7e2939351564483bbdefc77290435e4504b85de 1 2024-03-01 21:37:53.564799+00 2024-03-01 21:37:53.564799+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAAb0LEEo+94GoNXx9zSSdObfBSH/Z8ZSaNo2mLvPt9iEwABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZeJL0QAAAAEAAAAAAAYQMwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABWOhJam/3w6ZYclx3ciYUPHrCFeoVmeZsqH6ARx9CczAAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAXMm18f+1YlxXIICq7LtCHzZWSXMT9SnjMVBnSbv0GK1bBV2ot4ZfrZr0anl4mAAc8goaidpp4L6m0xKN9Uq6CD7fYhMAAABAmrXGanMX5o/tRnu/mj+RZuY4gHMNDC4jmZ+o9AS6NZk1j3qnyU7/HF5vmeSNFxSY3p+h9jxvu0gvdB/DrfPjCwAAAAAAAAAB7OKZaQAAAECR6a3OJq1Wj07ot08ZpYY6h/lHo6uEqkQHCSRbVDvC7BXPhZCYOWBt6LHmi2q7zhkEyjpM9p0e+YSOPUFXO9MJ \N \N \N ERROR {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-03-01T21:37:23.562068+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAAb0LEEo+94GoNXx9zSSdObfBSH/Z8ZSaNo2mLvPt9iEwABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZeJL0QAAAAEAAAAAAAYQMwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABWOhJam/3w6ZYclx3ciYUPHrCFeoVmeZsqH6ARx9CczAAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAXMm18f+1YlxXIICq7LtCHzZWSXMT9SnjMVBnSbv0GK1bBV2ot4ZfrZr0anl4mAAc8goaidpp4L6m0xKN9Uq6CD7fYhMAAABAmrXGanMX5o/tRnu/mj+RZuY4gHMNDC4jmZ+o9AS6NZk1j3qnyU7/HF5vmeSNFxSY3p+h9jxvu0gvdB/DrfPjCwAAAAAAAAAB7OKZaQAAAECR6a3OJq1Wj07ot08ZpYY6h/lHo6uEqkQHCSRbVDvC7BXPhZCYOWBt6LHmi2q7zhkEyjpM9p0e+YSOPUFXO9MJ\\", \\"timestamp\\": \\"2024-03-01T21:37:41.524179+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"abbe9c4016c54e912983110fe7e2939351564483bbdefc77290435e4504b85de\\"}","{\\"status\\": \\"ERROR\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAAb0LEEo+94GoNXx9zSSdObfBSH/Z8ZSaNo2mLvPt9iEwABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZeJL0QAAAAEAAAAAAAYQMwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABWOhJam/3w6ZYclx3ciYUPHrCFeoVmeZsqH6ARx9CczAAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAXMm18f+1YlxXIICq7LtCHzZWSXMT9SnjMVBnSbv0GK1bBV2ot4ZfrZr0anl4mAAc8goaidpp4L6m0xKN9Uq6CD7fYhMAAABAmrXGanMX5o/tRnu/mj+RZuY4gHMNDC4jmZ+o9AS6NZk1j3qnyU7/HF5vmeSNFxSY3p+h9jxvu0gvdB/DrfPjCwAAAAAAAAAB7OKZaQAAAECR6a3OJq1Wj07ot08ZpYY6h/lHo6uEqkQHCSRbVDvC7BXPhZCYOWBt6LHmi2q7zhkEyjpM9p0e+YSOPUFXO9MJ\\", \\"timestamp\\": \\"2024-03-01T21:37:44.525422+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"horizon response error: StatusCode=400, Type=https://stellar.org/horizon-errors/transaction_failed, Title=Transaction Failed, Detail=The transaction failed when submitted to the stellar network. The `extras.result_codes` field on this response contains further details. Descriptions of each code can be found at: https://developers.stellar.org/api/errors/http-status-codes/horizon-specific/transaction-failed/, Extras=transaction: tx_fee_bump_inner_failed - inner transaction: tx_failed - operation codes: [ op_no_trust ]\\", \\"stellar_transaction_hash\\": \\"abbe9c4016c54e912983110fe7e2939351564483bbdefc77290435e4504b85de\\"}"} +3217f528-2d2d-47c0-ba57-dc28c1bc1176 9294a461-5277-4bd5-9c73-74efed2192c9 \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GDSPHTXJIMA762ZXHPPR5QR3ZA6CT7M3QQHYAFUDIBB5AJL2DM7BNY2F 2024-04-25 17:17:35.176125+00 \N 2024-04-25 17:17:49.636629+00 2024-04-25 17:17:54.791597+00 21930d96cc4a33b113d57de72f602c7f45eb156dc24ad29ecbf87f1fddde14ed 1 2024-04-25 17:17:55.175895+00 2024-04-25 17:17:55.175895+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAAUpLy0VzB+Rsc9VGJiE4nxha9upU3XLTtjAOC6z9aZGAABhqAABEziAAAAAgAAAAIAAAABAAAAAAAAAAAAAAAAZiqRaQAAAAEAAAAAABPQvgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABABCMp5ccH7pP0pZW+uKroucUhbhUGniBQA8fsW6VBiUEnENbUdJJg+9/1f2T+NRWXG1IiGciA65oNKL5w6LoBAs/WmRgAAABA7Va2IbQKM+Ya8pdMjwmMQfL7oBB0m7+hIuOqHU2LTAUMb/5GbT5FVzIDuErZ/8jFclx2DHYt0i3Ry96+c3weBQAAAAAAAAAB7OKZaQAAAEBl0O+QEGMFbzQjerbf5s9f1RhWdHz0zYZaM9TZ+97ZMkT4Cqchlgt/7yJ6r8rLgLZVf45gaZo1f1Ue9358jO8L AAAAAAAAAMgAAAABz6mVKpoZRD4PiniWSo8AfXeHQsaF8rrznDD5ftBFDwcAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-04-25T17:17:35.176125+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAAUpLy0VzB+Rsc9VGJiE4nxha9upU3XLTtjAOC6z9aZGAABhqAABEziAAAAAgAAAAIAAAABAAAAAAAAAAAAAAAAZiqRaQAAAAEAAAAAABPQvgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABABCMp5ccH7pP0pZW+uKroucUhbhUGniBQA8fsW6VBiUEnENbUdJJg+9/1f2T+NRWXG1IiGciA65oNKL5w6LoBAs/WmRgAAABA7Va2IbQKM+Ya8pdMjwmMQfL7oBB0m7+hIuOqHU2LTAUMb/5GbT5FVzIDuErZ/8jFclx2DHYt0i3Ry96+c3weBQAAAAAAAAAB7OKZaQAAAEBl0O+QEGMFbzQjerbf5s9f1RhWdHz0zYZaM9TZ+97ZMkT4Cqchlgt/7yJ6r8rLgLZVf45gaZo1f1Ue9358jO8L\\", \\"timestamp\\": \\"2024-04-25T17:17:49.636629+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"21930d96cc4a33b113d57de72f602c7f45eb156dc24ad29ecbf87f1fddde14ed\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAAUpLy0VzB+Rsc9VGJiE4nxha9upU3XLTtjAOC6z9aZGAABhqAABEziAAAAAgAAAAIAAAABAAAAAAAAAAAAAAAAZiqRaQAAAAEAAAAAABPQvgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABABCMp5ccH7pP0pZW+uKroucUhbhUGniBQA8fsW6VBiUEnENbUdJJg+9/1f2T+NRWXG1IiGciA65oNKL5w6LoBAs/WmRgAAABA7Va2IbQKM+Ya8pdMjwmMQfL7oBB0m7+hIuOqHU2LTAUMb/5GbT5FVzIDuErZ/8jFclx2DHYt0i3Ry96+c3weBQAAAAAAAAAB7OKZaQAAAEBl0O+QEGMFbzQjerbf5s9f1RhWdHz0zYZaM9TZ+97ZMkT4Cqchlgt/7yJ6r8rLgLZVf45gaZo1f1Ue9358jO8L\\", \\"timestamp\\": \\"2024-04-25T17:17:54.788951+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABz6mVKpoZRD4PiniWSo8AfXeHQsaF8rrznDD5ftBFDwcAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"21930d96cc4a33b113d57de72f602c7f45eb156dc24ad29ecbf87f1fddde14ed\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAAUpLy0VzB+Rsc9VGJiE4nxha9upU3XLTtjAOC6z9aZGAABhqAABEziAAAAAgAAAAIAAAABAAAAAAAAAAAAAAAAZiqRaQAAAAEAAAAAABPQvgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABABCMp5ccH7pP0pZW+uKroucUhbhUGniBQA8fsW6VBiUEnENbUdJJg+9/1f2T+NRWXG1IiGciA65oNKL5w6LoBAs/WmRgAAABA7Va2IbQKM+Ya8pdMjwmMQfL7oBB0m7+hIuOqHU2LTAUMb/5GbT5FVzIDuErZ/8jFclx2DHYt0i3Ry96+c3weBQAAAAAAAAAB7OKZaQAAAEBl0O+QEGMFbzQjerbf5s9f1RhWdHz0zYZaM9TZ+97ZMkT4Cqchlgt/7yJ6r8rLgLZVf45gaZo1f1Ue9358jO8L\\", \\"timestamp\\": \\"2024-04-25T17:17:54.791597+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABz6mVKpoZRD4PiniWSo8AfXeHQsaF8rrznDD5ftBFDwcAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"21930d96cc4a33b113d57de72f602c7f45eb156dc24ad29ecbf87f1fddde14ed\\"}"} +caa71aab-643c-447e-89d6-752553c1677f 7483b195-0cbf-482e-9d41-05df186b75b5 \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GARGMZ6CX25KPH3ZZEK4ZRN63SEDRHTZDKXL27UYX54JBITHXXIXJTBN 2024-06-11 14:48:30.514827+00 \N 2024-06-11 14:48:34.056707+00 2024-06-11 14:48:38.126167+00 ce864b259ff9e086c3d85104328128b459f14e6a665d9731e295ac7e731415e1 1 2024-06-11 14:48:40.51209+00 2024-06-11 14:48:40.51209+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAADXifQopEJ2MNaY8dDVeA3vnUm1OhCDAwYSiTJqobECRAABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmhk7gAAAAEAAAAAAB+UdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAAAiZmfCvrqnn3nJFczFvtyIOJ55Gq69fpi/eJCiZ73RdAAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAO0e/pLyS8k93pMiaUe9C7HdC9k85kwa8dgylj/K3cSSS7vH3ActJiJoZRrhnxalrApZqy7H3BY502ggyDbN+BaGxAkQAAABATiF8U7+LIAes9+aTVOeCD/k+u/e5datOJTtODXyjaBhYmHAWPu6CG6x66zJsBU4Vk7QJ1l0rUATQYHFo8XGoBAAAAAAAAAAB7OKZaQAAAECbFfEXj4iL+9aHXupAt3gvhnSL2kFvl0szAMtqkq/XHoKtiD2YogqsGF5+kStCtJZx97yrXTlscg/uq7IqhKEM AAAAAAAAAMgAAAABaip1feYIED61tt4VHbqg2o1JTD997imvQ2XnSQ6dCN0AAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-06-11T14:48:30.514827+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAADXifQopEJ2MNaY8dDVeA3vnUm1OhCDAwYSiTJqobECRAABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmhk7gAAAAEAAAAAAB+UdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAAAiZmfCvrqnn3nJFczFvtyIOJ55Gq69fpi/eJCiZ73RdAAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAO0e/pLyS8k93pMiaUe9C7HdC9k85kwa8dgylj/K3cSSS7vH3ActJiJoZRrhnxalrApZqy7H3BY502ggyDbN+BaGxAkQAAABATiF8U7+LIAes9+aTVOeCD/k+u/e5datOJTtODXyjaBhYmHAWPu6CG6x66zJsBU4Vk7QJ1l0rUATQYHFo8XGoBAAAAAAAAAAB7OKZaQAAAECbFfEXj4iL+9aHXupAt3gvhnSL2kFvl0szAMtqkq/XHoKtiD2YogqsGF5+kStCtJZx97yrXTlscg/uq7IqhKEM\\", \\"timestamp\\": \\"2024-06-11T14:48:34.056707+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"ce864b259ff9e086c3d85104328128b459f14e6a665d9731e295ac7e731415e1\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAADXifQopEJ2MNaY8dDVeA3vnUm1OhCDAwYSiTJqobECRAABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmhk7gAAAAEAAAAAAB+UdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAAAiZmfCvrqnn3nJFczFvtyIOJ55Gq69fpi/eJCiZ73RdAAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAO0e/pLyS8k93pMiaUe9C7HdC9k85kwa8dgylj/K3cSSS7vH3ActJiJoZRrhnxalrApZqy7H3BY502ggyDbN+BaGxAkQAAABATiF8U7+LIAes9+aTVOeCD/k+u/e5datOJTtODXyjaBhYmHAWPu6CG6x66zJsBU4Vk7QJ1l0rUATQYHFo8XGoBAAAAAAAAAAB7OKZaQAAAECbFfEXj4iL+9aHXupAt3gvhnSL2kFvl0szAMtqkq/XHoKtiD2YogqsGF5+kStCtJZx97yrXTlscg/uq7IqhKEM\\", \\"timestamp\\": \\"2024-06-11T14:48:38.124441+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABaip1feYIED61tt4VHbqg2o1JTD997imvQ2XnSQ6dCN0AAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"ce864b259ff9e086c3d85104328128b459f14e6a665d9731e295ac7e731415e1\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAADXifQopEJ2MNaY8dDVeA3vnUm1OhCDAwYSiTJqobECRAABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmhk7gAAAAEAAAAAAB+UdAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAAAiZmfCvrqnn3nJFczFvtyIOJ55Gq69fpi/eJCiZ73RdAAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAO0e/pLyS8k93pMiaUe9C7HdC9k85kwa8dgylj/K3cSSS7vH3ActJiJoZRrhnxalrApZqy7H3BY502ggyDbN+BaGxAkQAAABATiF8U7+LIAes9+aTVOeCD/k+u/e5datOJTtODXyjaBhYmHAWPu6CG6x66zJsBU4Vk7QJ1l0rUATQYHFo8XGoBAAAAAAAAAAB7OKZaQAAAECbFfEXj4iL+9aHXupAt3gvhnSL2kFvl0szAMtqkq/XHoKtiD2YogqsGF5+kStCtJZx97yrXTlscg/uq7IqhKEM\\", \\"timestamp\\": \\"2024-06-11T14:48:38.126167+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABaip1feYIED61tt4VHbqg2o1JTD997imvQ2XnSQ6dCN0AAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"ce864b259ff9e086c3d85104328128b459f14e6a665d9731e295ac7e731415e1\\"}"} +a51b05a8-3fa2-482e-b0a6-0cd1e9704f55 144b389b-d2e4-42eb-beb7-93d602868864 \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GDSPHTXJIMA762ZXHPPR5QR3ZA6CT7M3QQHYAFUDIBB5AJL2DM7BNY2F 2024-04-17 21:36:34.656142+00 \N 2024-04-17 21:36:42.597066+00 2024-04-17 21:36:49.067407+00 4b4a73d0852b6023c1c6d3478fd0e422e548664ca7f4e273154d6e0839ec8175 1 2024-04-17 21:36:54.660121+00 2024-04-17 21:36:54.660121+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAADFe9bhyGIiRRmIy5eizU2QBqrGuamyReJorXSS+yQm+QABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZiBCFgAAAAEAAAAAABHaUgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABA1FKE/utQEhgk/mOgrCf3NBJNggth5p2KEl5kCd+LqC08a7asy7Wtro67/PgdqVXoBBZUF9LQF0kz4GyV1f8vAvskJvkAAABAOlg8pjP9M14Uxd9PlCDwpkH0HMvzjZfUgH+bvUgK+hSig2v2U2pN2WV3Oh0wKO3B4CNBFmtK3L/wIvQ2iHuaBgAAAAAAAAAB7OKZaQAAAEDBGrlq3fRhVxBLOqaCMvSDpjERGuuKePpcGPZXV1AwX0+nrf9fcL/411l2h37XP/nCDJ2lSpAxmf9Bz6Y3QpAK AAAAAAAAAMgAAAABclEN+Fz+abvYfW9VFGZ52KUHtP340dPhc/eeixNGNd4AAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-04-17T21:36:34.656142+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAADFe9bhyGIiRRmIy5eizU2QBqrGuamyReJorXSS+yQm+QABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZiBCFgAAAAEAAAAAABHaUgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABA1FKE/utQEhgk/mOgrCf3NBJNggth5p2KEl5kCd+LqC08a7asy7Wtro67/PgdqVXoBBZUF9LQF0kz4GyV1f8vAvskJvkAAABAOlg8pjP9M14Uxd9PlCDwpkH0HMvzjZfUgH+bvUgK+hSig2v2U2pN2WV3Oh0wKO3B4CNBFmtK3L/wIvQ2iHuaBgAAAAAAAAAB7OKZaQAAAEDBGrlq3fRhVxBLOqaCMvSDpjERGuuKePpcGPZXV1AwX0+nrf9fcL/411l2h37XP/nCDJ2lSpAxmf9Bz6Y3QpAK\\", \\"timestamp\\": \\"2024-04-17T21:36:42.597066+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"4b4a73d0852b6023c1c6d3478fd0e422e548664ca7f4e273154d6e0839ec8175\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAADFe9bhyGIiRRmIy5eizU2QBqrGuamyReJorXSS+yQm+QABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZiBCFgAAAAEAAAAAABHaUgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABA1FKE/utQEhgk/mOgrCf3NBJNggth5p2KEl5kCd+LqC08a7asy7Wtro67/PgdqVXoBBZUF9LQF0kz4GyV1f8vAvskJvkAAABAOlg8pjP9M14Uxd9PlCDwpkH0HMvzjZfUgH+bvUgK+hSig2v2U2pN2WV3Oh0wKO3B4CNBFmtK3L/wIvQ2iHuaBgAAAAAAAAAB7OKZaQAAAEDBGrlq3fRhVxBLOqaCMvSDpjERGuuKePpcGPZXV1AwX0+nrf9fcL/411l2h37XP/nCDJ2lSpAxmf9Bz6Y3QpAK\\", \\"timestamp\\": \\"2024-04-17T21:36:49.065329+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABclEN+Fz+abvYfW9VFGZ52KUHtP340dPhc/eeixNGNd4AAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"4b4a73d0852b6023c1c6d3478fd0e422e548664ca7f4e273154d6e0839ec8175\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAADFe9bhyGIiRRmIy5eizU2QBqrGuamyReJorXSS+yQm+QABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZiBCFgAAAAEAAAAAABHaUgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABA1FKE/utQEhgk/mOgrCf3NBJNggth5p2KEl5kCd+LqC08a7asy7Wtro67/PgdqVXoBBZUF9LQF0kz4GyV1f8vAvskJvkAAABAOlg8pjP9M14Uxd9PlCDwpkH0HMvzjZfUgH+bvUgK+hSig2v2U2pN2WV3Oh0wKO3B4CNBFmtK3L/wIvQ2iHuaBgAAAAAAAAAB7OKZaQAAAEDBGrlq3fRhVxBLOqaCMvSDpjERGuuKePpcGPZXV1AwX0+nrf9fcL/411l2h37XP/nCDJ2lSpAxmf9Bz6Y3QpAK\\", \\"timestamp\\": \\"2024-04-17T21:36:49.067407+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABclEN+Fz+abvYfW9VFGZ52KUHtP340dPhc/eeixNGNd4AAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"4b4a73d0852b6023c1c6d3478fd0e422e548664ca7f4e273154d6e0839ec8175\\"}"} +401375ca-533e-4a0f-b7b7-c78922533810 78bf1822-aeaa-4797-95bf-bf1890d23c42 \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GDJUCRN3DXK2JXDS7SVII2UDACR5L2LQUOBLO5H6PYIIK62J37TRVFNN 2024-03-07 17:02:53.780316+00 \N 2024-03-07 17:03:08.968624+00 2024-03-07 17:03:12.545345+00 d0316c750af02c639f069efc816c0cac1cf0d41c95b326d86470780e1fbd14bc 1 2024-03-07 17:03:13.781017+00 2024-03-07 17:03:13.781017+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAACphCFW/piUVrsWfdg3ttUsQsqGxNL3N0Df0VXT5J3BwwABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZen0eAAAAAEAAAAAAAeFBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADTQUW7HdWk3HL8qoRqgwCj1elwo4K3dP5+EIV7Sd/nGgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAk3i3y/MBCGtE5QchKE6rDm64lt/QPpDEOWM7h54NPAsAdfvn7Fu5vEno+jN13NPOoBBAEv4wgoajaLaglfi1AOSdwcMAAABASAnGX1db5ygclR2XaiUG0lAdx9qBNzZusSMhKUTSpYYu5bKFZDz2Pi+xlKh+rUgI18RDezPjvj9wA+7RhchaAQAAAAAAAAAB7OKZaQAAAEBroXo99VDARl0BVb7j2EbhobcFHKfh0gzv3reA5j7uQ/aTLo9xo9U5eEGh9fIFO0l/sTUJHdRQ51DYMfIbwz4D AAAAAAAAAMgAAAABK40toYsrg1WmrDl2yS9ZJFmyjkZ5i2NnVwZAe6AzVQ8AAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-03-07T17:02:53.780316+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAACphCFW/piUVrsWfdg3ttUsQsqGxNL3N0Df0VXT5J3BwwABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZen0eAAAAAEAAAAAAAeFBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADTQUW7HdWk3HL8qoRqgwCj1elwo4K3dP5+EIV7Sd/nGgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAk3i3y/MBCGtE5QchKE6rDm64lt/QPpDEOWM7h54NPAsAdfvn7Fu5vEno+jN13NPOoBBAEv4wgoajaLaglfi1AOSdwcMAAABASAnGX1db5ygclR2XaiUG0lAdx9qBNzZusSMhKUTSpYYu5bKFZDz2Pi+xlKh+rUgI18RDezPjvj9wA+7RhchaAQAAAAAAAAAB7OKZaQAAAEBroXo99VDARl0BVb7j2EbhobcFHKfh0gzv3reA5j7uQ/aTLo9xo9U5eEGh9fIFO0l/sTUJHdRQ51DYMfIbwz4D\\", \\"timestamp\\": \\"2024-03-07T17:03:08.968624+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"d0316c750af02c639f069efc816c0cac1cf0d41c95b326d86470780e1fbd14bc\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAACphCFW/piUVrsWfdg3ttUsQsqGxNL3N0Df0VXT5J3BwwABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZen0eAAAAAEAAAAAAAeFBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADTQUW7HdWk3HL8qoRqgwCj1elwo4K3dP5+EIV7Sd/nGgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAk3i3y/MBCGtE5QchKE6rDm64lt/QPpDEOWM7h54NPAsAdfvn7Fu5vEno+jN13NPOoBBAEv4wgoajaLaglfi1AOSdwcMAAABASAnGX1db5ygclR2XaiUG0lAdx9qBNzZusSMhKUTSpYYu5bKFZDz2Pi+xlKh+rUgI18RDezPjvj9wA+7RhchaAQAAAAAAAAAB7OKZaQAAAEBroXo99VDARl0BVb7j2EbhobcFHKfh0gzv3reA5j7uQ/aTLo9xo9U5eEGh9fIFO0l/sTUJHdRQ51DYMfIbwz4D\\", \\"timestamp\\": \\"2024-03-07T17:03:12.537403+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABK40toYsrg1WmrDl2yS9ZJFmyjkZ5i2NnVwZAe6AzVQ8AAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"d0316c750af02c639f069efc816c0cac1cf0d41c95b326d86470780e1fbd14bc\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAACphCFW/piUVrsWfdg3ttUsQsqGxNL3N0Df0VXT5J3BwwABhqAABEziAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZen0eAAAAAEAAAAAAAeFBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADTQUW7HdWk3HL8qoRqgwCj1elwo4K3dP5+EIV7Sd/nGgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAk3i3y/MBCGtE5QchKE6rDm64lt/QPpDEOWM7h54NPAsAdfvn7Fu5vEno+jN13NPOoBBAEv4wgoajaLaglfi1AOSdwcMAAABASAnGX1db5ygclR2XaiUG0lAdx9qBNzZusSMhKUTSpYYu5bKFZDz2Pi+xlKh+rUgI18RDezPjvj9wA+7RhchaAQAAAAAAAAAB7OKZaQAAAEBroXo99VDARl0BVb7j2EbhobcFHKfh0gzv3reA5j7uQ/aTLo9xo9U5eEGh9fIFO0l/sTUJHdRQ51DYMfIbwz4D\\", \\"timestamp\\": \\"2024-03-07T17:03:12.545345+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABK40toYsrg1WmrDl2yS9ZJFmyjkZ5i2NnVwZAe6AzVQ8AAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"d0316c750af02c639f069efc816c0cac1cf0d41c95b326d86470780e1fbd14bc\\"}"} +ca101403-934f-4aa2-af9f-e6f0f4a94774 31a26bef-6261-42f6-bfac-00c63b37c73f \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 50.0000000 GDSPHTXJIMA762ZXHPPR5QR3ZA6CT7M3QQHYAFUDIBB5AJL2DM7BNY2F 2024-05-01 14:01:12.517849+00 \N 2024-05-01 14:01:12.840692+00 2024-05-01 14:01:13.678721+00 b00c4ae10c7a57c48f80b7f0badf9b1c0d3104b5167360762d722f654f75f13e 1 2024-05-01 14:01:22.518588+00 2024-05-01 14:01:22.518588+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAACjuceo3QHUNHgodwSafznZMVsr5nCCYMdfrpWTD4CdZAABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZjJMVAAAAAEAAAAAABVJUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAB3NZQAAAAAAAAAAAuzimWkAAABAuYlxKeg01m5n1DKRzwhFRbeLz55f3BtXiIQaZ2fEKMIt3iXbmIMhm7L+EK1PVB3xEsSc1cSkPzLbqdiU6Bv3Cw+AnWQAAABA7/XgQ42ocCfwX/aei08ZLQaXyx/OOlZ6DbTqbNmaoaSuJHRjRso9xv/qRkmcSQRwocivZBc+ZEa7FFAuAXhNCwAAAAAAAAAB7OKZaQAAAEBO/3D6V8syTThg8gX87S1v2owRX2B4xioxk6zAwV1uaBwqHIxd5yfiVgWQJfpwj1gz3c5EUdSbcBFHBSjT2o4D AAAAAAAAAMgAAAABNpmsx+5fxLtwkG45s8KROwjMxT8HLFPDsA4S2NNrWNEAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-05-01T14:01:12.517849+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAACjuceo3QHUNHgodwSafznZMVsr5nCCYMdfrpWTD4CdZAABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZjJMVAAAAAEAAAAAABVJUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAB3NZQAAAAAAAAAAAuzimWkAAABAuYlxKeg01m5n1DKRzwhFRbeLz55f3BtXiIQaZ2fEKMIt3iXbmIMhm7L+EK1PVB3xEsSc1cSkPzLbqdiU6Bv3Cw+AnWQAAABA7/XgQ42ocCfwX/aei08ZLQaXyx/OOlZ6DbTqbNmaoaSuJHRjRso9xv/qRkmcSQRwocivZBc+ZEa7FFAuAXhNCwAAAAAAAAAB7OKZaQAAAEBO/3D6V8syTThg8gX87S1v2owRX2B4xioxk6zAwV1uaBwqHIxd5yfiVgWQJfpwj1gz3c5EUdSbcBFHBSjT2o4D\\", \\"timestamp\\": \\"2024-05-01T14:01:12.840692+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"b00c4ae10c7a57c48f80b7f0badf9b1c0d3104b5167360762d722f654f75f13e\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAACjuceo3QHUNHgodwSafznZMVsr5nCCYMdfrpWTD4CdZAABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZjJMVAAAAAEAAAAAABVJUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAB3NZQAAAAAAAAAAAuzimWkAAABAuYlxKeg01m5n1DKRzwhFRbeLz55f3BtXiIQaZ2fEKMIt3iXbmIMhm7L+EK1PVB3xEsSc1cSkPzLbqdiU6Bv3Cw+AnWQAAABA7/XgQ42ocCfwX/aei08ZLQaXyx/OOlZ6DbTqbNmaoaSuJHRjRso9xv/qRkmcSQRwocivZBc+ZEa7FFAuAXhNCwAAAAAAAAAB7OKZaQAAAEBO/3D6V8syTThg8gX87S1v2owRX2B4xioxk6zAwV1uaBwqHIxd5yfiVgWQJfpwj1gz3c5EUdSbcBFHBSjT2o4D\\", \\"timestamp\\": \\"2024-05-01T14:01:13.676477+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABNpmsx+5fxLtwkG45s8KROwjMxT8HLFPDsA4S2NNrWNEAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"b00c4ae10c7a57c48f80b7f0badf9b1c0d3104b5167360762d722f654f75f13e\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAACjuceo3QHUNHgodwSafznZMVsr5nCCYMdfrpWTD4CdZAABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZjJMVAAAAAEAAAAAABVJUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADk887pQwH/azc73x7CO8g8Kf2bhA+AFoNAQ9Alehs+FgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAB3NZQAAAAAAAAAAAuzimWkAAABAuYlxKeg01m5n1DKRzwhFRbeLz55f3BtXiIQaZ2fEKMIt3iXbmIMhm7L+EK1PVB3xEsSc1cSkPzLbqdiU6Bv3Cw+AnWQAAABA7/XgQ42ocCfwX/aei08ZLQaXyx/OOlZ6DbTqbNmaoaSuJHRjRso9xv/qRkmcSQRwocivZBc+ZEa7FFAuAXhNCwAAAAAAAAAB7OKZaQAAAEBO/3D6V8syTThg8gX87S1v2owRX2B4xioxk6zAwV1uaBwqHIxd5yfiVgWQJfpwj1gz3c5EUdSbcBFHBSjT2o4D\\", \\"timestamp\\": \\"2024-05-01T14:01:13.678721+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABNpmsx+5fxLtwkG45s8KROwjMxT8HLFPDsA4S2NNrWNEAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"b00c4ae10c7a57c48f80b7f0badf9b1c0d3104b5167360762d722f654f75f13e\\"}"} +42542da9-dae3-40a8-98b8-42f3ee446585 1277b3c5-fa16-4ea5-a789-e738d9bfc5f6 \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GAOFCIOXJT3OSZI2IPVN5RKYPSUKM3AZDFHJ4657D3QR74UAZ4OZHZD2 2024-06-07 04:36:10.527436+00 \N 2024-06-07 04:36:14.058428+00 2024-06-07 04:36:16.79112+00 f3a96dc70a58435bcc6f796f9b74622ba802c4f7ef2ce37bef712094ad86a773 1 2024-06-07 04:36:20.526156+00 2024-06-07 04:36:20.526156+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAACEoM6T2yr3fNFMfwxlO4sI5KGKPRvbnB1VJU5eeMJE0AABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmKPagAAAAEAAAAAAB54AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAAAcUSHXTPbpZRpD6t7FWHyopmwZGU6ee78e4R/ygM8dkwAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABACyOBwLqw+J2cTay4rX6W6VXmtpTFHipnJXRQfoTYN3SxVxd3JGADuI2lk635L/skMK2MeiL4e5dJVdVDJjFyBXjCRNAAAABAw6vAbeiyM8urwBFJZT08PhBcfBztr1KLrketM9KUxn9Vxb+bhol1q8JilZ77tn/iGj+TSx1iXVDAbhdaPdKJDwAAAAAAAAAB7OKZaQAAAED39SfZMrXy5FBmXSmzRMktafA9bmdN/YzFk4PQu6vy1BN14cycZZFHwaejtYSaUxCg585jjgradoRTFW69t74J AAAAAAAAAMgAAAABRW2IBujXp38bUGlp4mqDDnFf3ojHhFzVcKnEpyZ3np8AAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-06-07T04:36:10.527436+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAACEoM6T2yr3fNFMfwxlO4sI5KGKPRvbnB1VJU5eeMJE0AABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmKPagAAAAEAAAAAAB54AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAAAcUSHXTPbpZRpD6t7FWHyopmwZGU6ee78e4R/ygM8dkwAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABACyOBwLqw+J2cTay4rX6W6VXmtpTFHipnJXRQfoTYN3SxVxd3JGADuI2lk635L/skMK2MeiL4e5dJVdVDJjFyBXjCRNAAAABAw6vAbeiyM8urwBFJZT08PhBcfBztr1KLrketM9KUxn9Vxb+bhol1q8JilZ77tn/iGj+TSx1iXVDAbhdaPdKJDwAAAAAAAAAB7OKZaQAAAED39SfZMrXy5FBmXSmzRMktafA9bmdN/YzFk4PQu6vy1BN14cycZZFHwaejtYSaUxCg585jjgradoRTFW69t74J\\", \\"timestamp\\": \\"2024-06-07T04:36:14.058428+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"f3a96dc70a58435bcc6f796f9b74622ba802c4f7ef2ce37bef712094ad86a773\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAACEoM6T2yr3fNFMfwxlO4sI5KGKPRvbnB1VJU5eeMJE0AABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmKPagAAAAEAAAAAAB54AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAAAcUSHXTPbpZRpD6t7FWHyopmwZGU6ee78e4R/ygM8dkwAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABACyOBwLqw+J2cTay4rX6W6VXmtpTFHipnJXRQfoTYN3SxVxd3JGADuI2lk635L/skMK2MeiL4e5dJVdVDJjFyBXjCRNAAAABAw6vAbeiyM8urwBFJZT08PhBcfBztr1KLrketM9KUxn9Vxb+bhol1q8JilZ77tn/iGj+TSx1iXVDAbhdaPdKJDwAAAAAAAAAB7OKZaQAAAED39SfZMrXy5FBmXSmzRMktafA9bmdN/YzFk4PQu6vy1BN14cycZZFHwaejtYSaUxCg585jjgradoRTFW69t74J\\", \\"timestamp\\": \\"2024-06-07T04:36:16.789498+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABRW2IBujXp38bUGlp4mqDDnFf3ojHhFzVcKnEpyZ3np8AAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"f3a96dc70a58435bcc6f796f9b74622ba802c4f7ef2ce37bef712094ad86a773\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAACEoM6T2yr3fNFMfwxlO4sI5KGKPRvbnB1VJU5eeMJE0AABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmKPagAAAAEAAAAAAB54AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAAAcUSHXTPbpZRpD6t7FWHyopmwZGU6ee78e4R/ygM8dkwAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABACyOBwLqw+J2cTay4rX6W6VXmtpTFHipnJXRQfoTYN3SxVxd3JGADuI2lk635L/skMK2MeiL4e5dJVdVDJjFyBXjCRNAAAABAw6vAbeiyM8urwBFJZT08PhBcfBztr1KLrketM9KUxn9Vxb+bhol1q8JilZ77tn/iGj+TSx1iXVDAbhdaPdKJDwAAAAAAAAAB7OKZaQAAAED39SfZMrXy5FBmXSmzRMktafA9bmdN/YzFk4PQu6vy1BN14cycZZFHwaejtYSaUxCg585jjgradoRTFW69t74J\\", \\"timestamp\\": \\"2024-06-07T04:36:16.79112+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABRW2IBujXp38bUGlp4mqDDnFf3ojHhFzVcKnEpyZ3np8AAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"f3a96dc70a58435bcc6f796f9b74622ba802c4f7ef2ce37bef712094ad86a773\\"}"} +759b55a6-6061-4397-ae02-86f864437931 9336b377-8922-4014-868d-9a2bad8e9ef4 \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GBBMN6CHPVLHATN6B7GP2KYOCTLOGF4IML7WRL4VMIOXXJNSV5GCUDAB 2024-06-12 16:37:50.525664+00 \N 2024-06-12 16:41:54.04118+00 2024-06-12 16:41:58.368047+00 b566fa278958aeac1f457f7f13e4adc6b3037a2099b8ab4941e48c3b94e476e1 1 2024-06-12 16:42:00.528987+00 2024-06-12 16:42:00.528987+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABz6WGp5LnDLotDm0AbmSGFNME37nz5l15F+vhPdsjCsAABhqAAADUfAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmnQ/gAAAAEAAAAAAAA1LgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABCxvhHfVZwTb4PzP0rDhTW4xeIYv9or5ViHXulsq9MKgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAIBz6UoXTsF8PauJh/fLIJi3qXC9v5qAkOhrqNTDCg5KcPma1oIwHqbkuvmYo7dVl6pCQRpq8Aw2ni+07/jpsBnbIwrAAAABAvF+g/CVF9Ja/TLaBRX+tZHrs3AmDJXIQZmIE57Uz9NdYq74uQH2/H35ygq1vGmVPCkgav5V13HuCNMdc/S0nAwAAAAAAAAAB7OKZaQAAAEDqXLpEjCTIGQOk3m1FE/2D0tmL1jzSzdjsQUDNzzShJvmLeVjwdT5sDfort00lf0MlOHHi3REMwUGbjYJxFy8M AAAAAAAAAMgAAAABzFp5/RToERMvSYgqjl5yOUufDBRyTf2xbGbgP9Wok7MAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-06-12T16:37:50.525664+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABz6WGp5LnDLotDm0AbmSGFNME37nz5l15F+vhPdsjCsAABhqAAADUfAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmnQ/gAAAAEAAAAAAAA1LgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABCxvhHfVZwTb4PzP0rDhTW4xeIYv9or5ViHXulsq9MKgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAIBz6UoXTsF8PauJh/fLIJi3qXC9v5qAkOhrqNTDCg5KcPma1oIwHqbkuvmYo7dVl6pCQRpq8Aw2ni+07/jpsBnbIwrAAAABAvF+g/CVF9Ja/TLaBRX+tZHrs3AmDJXIQZmIE57Uz9NdYq74uQH2/H35ygq1vGmVPCkgav5V13HuCNMdc/S0nAwAAAAAAAAAB7OKZaQAAAEDqXLpEjCTIGQOk3m1FE/2D0tmL1jzSzdjsQUDNzzShJvmLeVjwdT5sDfort00lf0MlOHHi3REMwUGbjYJxFy8M\\", \\"timestamp\\": \\"2024-06-12T16:41:54.04118+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"b566fa278958aeac1f457f7f13e4adc6b3037a2099b8ab4941e48c3b94e476e1\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABz6WGp5LnDLotDm0AbmSGFNME37nz5l15F+vhPdsjCsAABhqAAADUfAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmnQ/gAAAAEAAAAAAAA1LgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABCxvhHfVZwTb4PzP0rDhTW4xeIYv9or5ViHXulsq9MKgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAIBz6UoXTsF8PauJh/fLIJi3qXC9v5qAkOhrqNTDCg5KcPma1oIwHqbkuvmYo7dVl6pCQRpq8Aw2ni+07/jpsBnbIwrAAAABAvF+g/CVF9Ja/TLaBRX+tZHrs3AmDJXIQZmIE57Uz9NdYq74uQH2/H35ygq1vGmVPCkgav5V13HuCNMdc/S0nAwAAAAAAAAAB7OKZaQAAAEDqXLpEjCTIGQOk3m1FE/2D0tmL1jzSzdjsQUDNzzShJvmLeVjwdT5sDfort00lf0MlOHHi3REMwUGbjYJxFy8M\\", \\"timestamp\\": \\"2024-06-12T16:41:58.366387+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABzFp5/RToERMvSYgqjl5yOUufDBRyTf2xbGbgP9Wok7MAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"b566fa278958aeac1f457f7f13e4adc6b3037a2099b8ab4941e48c3b94e476e1\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABz6WGp5LnDLotDm0AbmSGFNME37nz5l15F+vhPdsjCsAABhqAAADUfAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmnQ/gAAAAEAAAAAAAA1LgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABCxvhHfVZwTb4PzP0rDhTW4xeIYv9or5ViHXulsq9MKgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAIBz6UoXTsF8PauJh/fLIJi3qXC9v5qAkOhrqNTDCg5KcPma1oIwHqbkuvmYo7dVl6pCQRpq8Aw2ni+07/jpsBnbIwrAAAABAvF+g/CVF9Ja/TLaBRX+tZHrs3AmDJXIQZmIE57Uz9NdYq74uQH2/H35ygq1vGmVPCkgav5V13HuCNMdc/S0nAwAAAAAAAAAB7OKZaQAAAEDqXLpEjCTIGQOk3m1FE/2D0tmL1jzSzdjsQUDNzzShJvmLeVjwdT5sDfort00lf0MlOHHi3REMwUGbjYJxFy8M\\", \\"timestamp\\": \\"2024-06-12T16:41:58.368047+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABzFp5/RToERMvSYgqjl5yOUufDBRyTf2xbGbgP9Wok7MAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"b566fa278958aeac1f457f7f13e4adc6b3037a2099b8ab4941e48c3b94e476e1\\"}"} +a0358a9b-1a1c-4db4-8c2b-6ac9f51571eb 8c4cc967-45d0-44ea-b802-568408632169 \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GAMK7PX4TXWUXXPPI4PG4JA4W22WJUIS7GAQVBFX6SQMZAN6FT6XE2F7 2024-06-11 00:40:40.529246+00 \N 2024-06-11 00:40:54.047183+00 2024-06-11 00:41:00.136202+00 808f1086ad35efdadc573a1b70b8dc5829cdc75ad92972b1a40bb62b28dc3ed8 1 2024-06-11 00:41:00.535688+00 2024-06-11 00:41:00.535688+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAA/7GlXvR+YYEdKhvrXsAnHBQ3hbePgVfH7y1akiJ/9sgABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmeeQgAAAAEAAAAAAB9ujwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAAAYr778ne1L3e9HHm4kHLa1ZNES+YEKhLf0oMyBviz9cgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAqDDMmbR3TCC3enpVLsSc+7uK1I2XLLp+xyPhj2rixQ8AyPXsKe8K7gfMGPNVU5itolO9TC28g8rvAtx+jLrVCIif/bIAAABAYeHwQdKUhhgmtU9cnmwH+A7xFdBGYl+V/BUMcVWWgxYUC/Wo9KTF6Mg00NwbIidZR3xkD7E+d2GmHI9KCgZkCAAAAAAAAAAB7OKZaQAAAEAcsT9Sma0CkBJWNcjdLbOBYPGWjLquqp/uMrnD13x21bnqeti3UtKhzmaCtP00OBfkV9L9VfmfsOyWSYlkEdID AAAAAAAAAMgAAAAB+72EpJV9RNtUugaEkHH+C8eT3lNH8V6TXMaJSBLZlnYAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-06-11T00:40:40.529246+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAA/7GlXvR+YYEdKhvrXsAnHBQ3hbePgVfH7y1akiJ/9sgABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmeeQgAAAAEAAAAAAB9ujwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAAAYr778ne1L3e9HHm4kHLa1ZNES+YEKhLf0oMyBviz9cgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAqDDMmbR3TCC3enpVLsSc+7uK1I2XLLp+xyPhj2rixQ8AyPXsKe8K7gfMGPNVU5itolO9TC28g8rvAtx+jLrVCIif/bIAAABAYeHwQdKUhhgmtU9cnmwH+A7xFdBGYl+V/BUMcVWWgxYUC/Wo9KTF6Mg00NwbIidZR3xkD7E+d2GmHI9KCgZkCAAAAAAAAAAB7OKZaQAAAEAcsT9Sma0CkBJWNcjdLbOBYPGWjLquqp/uMrnD13x21bnqeti3UtKhzmaCtP00OBfkV9L9VfmfsOyWSYlkEdID\\", \\"timestamp\\": \\"2024-06-11T00:40:54.047183+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"808f1086ad35efdadc573a1b70b8dc5829cdc75ad92972b1a40bb62b28dc3ed8\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAA/7GlXvR+YYEdKhvrXsAnHBQ3hbePgVfH7y1akiJ/9sgABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmeeQgAAAAEAAAAAAB9ujwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAAAYr778ne1L3e9HHm4kHLa1ZNES+YEKhLf0oMyBviz9cgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAqDDMmbR3TCC3enpVLsSc+7uK1I2XLLp+xyPhj2rixQ8AyPXsKe8K7gfMGPNVU5itolO9TC28g8rvAtx+jLrVCIif/bIAAABAYeHwQdKUhhgmtU9cnmwH+A7xFdBGYl+V/BUMcVWWgxYUC/Wo9KTF6Mg00NwbIidZR3xkD7E+d2GmHI9KCgZkCAAAAAAAAAAB7OKZaQAAAEAcsT9Sma0CkBJWNcjdLbOBYPGWjLquqp/uMrnD13x21bnqeti3UtKhzmaCtP00OBfkV9L9VfmfsOyWSYlkEdID\\", \\"timestamp\\": \\"2024-06-11T00:41:00.133125+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAAB+72EpJV9RNtUugaEkHH+C8eT3lNH8V6TXMaJSBLZlnYAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"808f1086ad35efdadc573a1b70b8dc5829cdc75ad92972b1a40bb62b28dc3ed8\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAA/7GlXvR+YYEdKhvrXsAnHBQ3hbePgVfH7y1akiJ/9sgABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmeeQgAAAAEAAAAAAB9ujwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAAAYr778ne1L3e9HHm4kHLa1ZNES+YEKhLf0oMyBviz9cgAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAqDDMmbR3TCC3enpVLsSc+7uK1I2XLLp+xyPhj2rixQ8AyPXsKe8K7gfMGPNVU5itolO9TC28g8rvAtx+jLrVCIif/bIAAABAYeHwQdKUhhgmtU9cnmwH+A7xFdBGYl+V/BUMcVWWgxYUC/Wo9KTF6Mg00NwbIidZR3xkD7E+d2GmHI9KCgZkCAAAAAAAAAAB7OKZaQAAAEAcsT9Sma0CkBJWNcjdLbOBYPGWjLquqp/uMrnD13x21bnqeti3UtKhzmaCtP00OBfkV9L9VfmfsOyWSYlkEdID\\", \\"timestamp\\": \\"2024-06-11T00:41:00.136202+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAAB+72EpJV9RNtUugaEkHH+C8eT3lNH8V6TXMaJSBLZlnYAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"808f1086ad35efdadc573a1b70b8dc5829cdc75ad92972b1a40bb62b28dc3ed8\\"}"} +ecface82-d31a-42dd-a973-a68f9e1661fb 37a5c8a9-e2a1-4c50-941c-f57643896fae \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GCKUKUGG57SCBQAKZF4Q6DZ6VN5CXOYDRKOD2IEAYPCPN3XZVCFBYVGS 2024-06-25 17:27:50.513827+00 \N 2024-06-25 17:27:54.078364+00 2024-06-25 17:27:59.868827+00 7c8c8de5866dacf0a51392d74be8943a80aec0a0760a6631976b3afac2c51eeb 1 2024-06-25 17:28:00.51319+00 2024-06-25 17:28:00.51319+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABX1BQZWZZbUmiEmCDSx7GgWmBWAGE5owNLyEX7GOQiqgABhqAAADUfAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZnr/RgAAAAEAAAAAAAN52gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAACVRVDG7+QgwArJeQ8PPqt6K7sDipw9IIDDxPbu+aiKHAAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAXZTomH/vCEcBTuKKlf8kMNX6NvRUp1BzIXmDgiFfJzWGGqZpV6IaW6wHVHD1HuWXBJg6UqbYEswaRE5ZJ3s9DxjkIqoAAABA0ZmyTPiauU+XTco/JhiQUlxsyQAHCsPjLjSPHiSeNLNAhfwAuEEpo4LqS/rKO/TamrgWnI9P/ngrJ5L0QFhjBQAAAAAAAAAB7OKZaQAAAEAbxS8k+MH640oI6IIVoc8SP3ZukRIj7iAGd+AxMnZGf/pGWZME1DqKPu3/wEAhomloniv3giuNIDAz20RiMw0O AAAAAAAAAMgAAAABer8yeZ1gsVHJltXLHzaoVJymZwFKl4uihWf399xq1AYAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-06-25T17:27:50.513827+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABX1BQZWZZbUmiEmCDSx7GgWmBWAGE5owNLyEX7GOQiqgABhqAAADUfAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZnr/RgAAAAEAAAAAAAN52gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAACVRVDG7+QgwArJeQ8PPqt6K7sDipw9IIDDxPbu+aiKHAAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAXZTomH/vCEcBTuKKlf8kMNX6NvRUp1BzIXmDgiFfJzWGGqZpV6IaW6wHVHD1HuWXBJg6UqbYEswaRE5ZJ3s9DxjkIqoAAABA0ZmyTPiauU+XTco/JhiQUlxsyQAHCsPjLjSPHiSeNLNAhfwAuEEpo4LqS/rKO/TamrgWnI9P/ngrJ5L0QFhjBQAAAAAAAAAB7OKZaQAAAEAbxS8k+MH640oI6IIVoc8SP3ZukRIj7iAGd+AxMnZGf/pGWZME1DqKPu3/wEAhomloniv3giuNIDAz20RiMw0O\\", \\"timestamp\\": \\"2024-06-25T17:27:54.078364+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"7c8c8de5866dacf0a51392d74be8943a80aec0a0760a6631976b3afac2c51eeb\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABX1BQZWZZbUmiEmCDSx7GgWmBWAGE5owNLyEX7GOQiqgABhqAAADUfAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZnr/RgAAAAEAAAAAAAN52gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAACVRVDG7+QgwArJeQ8PPqt6K7sDipw9IIDDxPbu+aiKHAAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAXZTomH/vCEcBTuKKlf8kMNX6NvRUp1BzIXmDgiFfJzWGGqZpV6IaW6wHVHD1HuWXBJg6UqbYEswaRE5ZJ3s9DxjkIqoAAABA0ZmyTPiauU+XTco/JhiQUlxsyQAHCsPjLjSPHiSeNLNAhfwAuEEpo4LqS/rKO/TamrgWnI9P/ngrJ5L0QFhjBQAAAAAAAAAB7OKZaQAAAEAbxS8k+MH640oI6IIVoc8SP3ZukRIj7iAGd+AxMnZGf/pGWZME1DqKPu3/wEAhomloniv3giuNIDAz20RiMw0O\\", \\"timestamp\\": \\"2024-06-25T17:27:59.866284+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABer8yeZ1gsVHJltXLHzaoVJymZwFKl4uihWf399xq1AYAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"7c8c8de5866dacf0a51392d74be8943a80aec0a0760a6631976b3afac2c51eeb\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABX1BQZWZZbUmiEmCDSx7GgWmBWAGE5owNLyEX7GOQiqgABhqAAADUfAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZnr/RgAAAAEAAAAAAAN52gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAACVRVDG7+QgwArJeQ8PPqt6K7sDipw9IIDDxPbu+aiKHAAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAXZTomH/vCEcBTuKKlf8kMNX6NvRUp1BzIXmDgiFfJzWGGqZpV6IaW6wHVHD1HuWXBJg6UqbYEswaRE5ZJ3s9DxjkIqoAAABA0ZmyTPiauU+XTco/JhiQUlxsyQAHCsPjLjSPHiSeNLNAhfwAuEEpo4LqS/rKO/TamrgWnI9P/ngrJ5L0QFhjBQAAAAAAAAAB7OKZaQAAAEAbxS8k+MH640oI6IIVoc8SP3ZukRIj7iAGd+AxMnZGf/pGWZME1DqKPu3/wEAhomloniv3giuNIDAz20RiMw0O\\", \\"timestamp\\": \\"2024-06-25T17:27:59.868827+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABer8yeZ1gsVHJltXLHzaoVJymZwFKl4uihWf399xq1AYAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"7c8c8de5866dacf0a51392d74be8943a80aec0a0760a6631976b3afac2c51eeb\\"}"} +9fca415f-5fa8-4e62-91e6-7e778bec3a2c 6f9fd75a-5950-4ef9-b885-46efe6686b1d \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GCDUPD3AIFYFQBCCMP5Q3DIVRWA5R2GCDZRAOKIXRDCVUQP4HSVTCZZ2 2024-06-11 01:04:50.515127+00 \N 2024-06-11 01:04:54.044529+00 2024-06-11 01:04:59.325496+00 e6186d245dc3181dd0e0b0b066b6c3f72ef5212fd3fcfef5a6f7102200acfe19 1 2024-06-11 01:05:00.531945+00 2024-06-11 01:05:00.531945+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAADkEGFFFZNP6Hf0XHiokcVq/e7RdgujMTK68vqNiwraJgABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmej4gAAAAEAAAAAAB9voAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAACHR49gQXBYBEJj+w2NFY2B2OjCHmIHKReIxVpB/DyrMQAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAJRojb6Dy0E7DI3B/9nBwj3/sC/HefSWlLHZx5s60kcQLXKwSFMAO/lCypyDhjo8jFiRfoCK1YkrIduid0s17AYsK2iYAAABARmiHBNOJywluwzuZe+WJTR6uX0OQswwyL4uAKf1PzcslSNnhMb0TMhoNlaNLlB89WqKv3lIZh2Wk1YAAiKtKAwAAAAAAAAAB7OKZaQAAAECDxGA1cCQE1WVfUAqiyj0F3j+t8o3z9fM/4gwxVoVyXvP4DJYmDsRHN4PK5lX9acOVqvDAQL+yiup/roeEEbAP AAAAAAAAAMgAAAAB3dhhMj0EnaRpJMeeFqbfjh6bknd4TSdhMvnOTFYDd8wAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-06-11T01:04:50.515127+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAADkEGFFFZNP6Hf0XHiokcVq/e7RdgujMTK68vqNiwraJgABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmej4gAAAAEAAAAAAB9voAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAACHR49gQXBYBEJj+w2NFY2B2OjCHmIHKReIxVpB/DyrMQAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAJRojb6Dy0E7DI3B/9nBwj3/sC/HefSWlLHZx5s60kcQLXKwSFMAO/lCypyDhjo8jFiRfoCK1YkrIduid0s17AYsK2iYAAABARmiHBNOJywluwzuZe+WJTR6uX0OQswwyL4uAKf1PzcslSNnhMb0TMhoNlaNLlB89WqKv3lIZh2Wk1YAAiKtKAwAAAAAAAAAB7OKZaQAAAECDxGA1cCQE1WVfUAqiyj0F3j+t8o3z9fM/4gwxVoVyXvP4DJYmDsRHN4PK5lX9acOVqvDAQL+yiup/roeEEbAP\\", \\"timestamp\\": \\"2024-06-11T01:04:54.044529+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"e6186d245dc3181dd0e0b0b066b6c3f72ef5212fd3fcfef5a6f7102200acfe19\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAADkEGFFFZNP6Hf0XHiokcVq/e7RdgujMTK68vqNiwraJgABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmej4gAAAAEAAAAAAB9voAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAACHR49gQXBYBEJj+w2NFY2B2OjCHmIHKReIxVpB/DyrMQAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAJRojb6Dy0E7DI3B/9nBwj3/sC/HefSWlLHZx5s60kcQLXKwSFMAO/lCypyDhjo8jFiRfoCK1YkrIduid0s17AYsK2iYAAABARmiHBNOJywluwzuZe+WJTR6uX0OQswwyL4uAKf1PzcslSNnhMb0TMhoNlaNLlB89WqKv3lIZh2Wk1YAAiKtKAwAAAAAAAAAB7OKZaQAAAECDxGA1cCQE1WVfUAqiyj0F3j+t8o3z9fM/4gwxVoVyXvP4DJYmDsRHN4PK5lX9acOVqvDAQL+yiup/roeEEbAP\\", \\"timestamp\\": \\"2024-06-11T01:04:59.323729+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAAB3dhhMj0EnaRpJMeeFqbfjh6bknd4TSdhMvnOTFYDd8wAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"e6186d245dc3181dd0e0b0b066b6c3f72ef5212fd3fcfef5a6f7102200acfe19\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAADkEGFFFZNP6Hf0XHiokcVq/e7RdgujMTK68vqNiwraJgABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmej4gAAAAEAAAAAAB9voAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAACHR49gQXBYBEJj+w2NFY2B2OjCHmIHKReIxVpB/DyrMQAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAJRojb6Dy0E7DI3B/9nBwj3/sC/HefSWlLHZx5s60kcQLXKwSFMAO/lCypyDhjo8jFiRfoCK1YkrIduid0s17AYsK2iYAAABARmiHBNOJywluwzuZe+WJTR6uX0OQswwyL4uAKf1PzcslSNnhMb0TMhoNlaNLlB89WqKv3lIZh2Wk1YAAiKtKAwAAAAAAAAAB7OKZaQAAAECDxGA1cCQE1WVfUAqiyj0F3j+t8o3z9fM/4gwxVoVyXvP4DJYmDsRHN4PK5lX9acOVqvDAQL+yiup/roeEEbAP\\", \\"timestamp\\": \\"2024-06-11T01:04:59.325496+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAAB3dhhMj0EnaRpJMeeFqbfjh6bknd4TSdhMvnOTFYDd8wAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"e6186d245dc3181dd0e0b0b066b6c3f72ef5212fd3fcfef5a6f7102200acfe19\\"}"} +b53b1213-cdfe-46b5-b167-bb7daf7b28cd 7756d2d0-43a2-49c7-af3c-efbca3813b85 horizon response error: StatusCode=400, Type=https://stellar.org/horizon-errors/transaction_failed, Title=Transaction Failed, Detail=The transaction failed when submitted to the stellar network. The `extras.result_codes` field on this response contains further details. Descriptions of each code can be found at: https://developers.stellar.org/api/errors/http-status-codes/horizon-specific/transaction-failed/, Extras=transaction: tx_fee_bump_inner_failed - inner transaction: tx_failed - operation codes: [ op_no_trust ] USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GBFPZN7JKFO6WELOQDUBP2ZHNXJEKH7KSWIBYRQUU2TLMD3RIUOYDE7I 2024-06-25 19:30:00.528598+00 \N 2024-06-25 19:30:14.059321+00 2024-06-25 19:30:18.960623+00 f9e4ff4c72701f89e3d58882ff6da355990117d5f4d783bcf1305962249e2041 1 2024-06-25 19:30:20.525717+00 2024-06-25 19:30:20.525717+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABz6WGp5LnDLotDm0AbmSGFNME37nz5l15F+vhPdsjCsAABhqAAADUfAAAAAgAAAAIAAAABAAAAAAAAAAAAAAAAZnsb8gAAAAEAAAAAAAN/SQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABK/LfpUV3rEW6A6BfrJ23SRR/qlZAcRhSmprYPcUUdgQAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABARpQeYoyLx1+7fdNnHFEvCC1IfaNrHBcGTm1M2/M0lGJ0bIIP38mzDYH18rlyPJrJ/seSi9BDD2PUnbFwT08WCHbIwrAAAABAbhLOjS5smu0bVbME0SGU9ErgF56jVCcFCfWbE2RLSX+6p5wnLxgTwZ7LxubsaCeIupl8N+wAzpDTFyXspCFFCAAAAAAAAAAB7OKZaQAAAEAWBDBGLNyXV6vTBM4dU3jVLmlgWzuqFdMV5nCz/Rekw2j3o5lIEpLaukDird8PR9x9SHCYWCE4BOroi9VVPjsB \N \N \N ERROR {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-06-25T19:30:00.528598+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABz6WGp5LnDLotDm0AbmSGFNME37nz5l15F+vhPdsjCsAABhqAAADUfAAAAAgAAAAIAAAABAAAAAAAAAAAAAAAAZnsb8gAAAAEAAAAAAAN/SQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABK/LfpUV3rEW6A6BfrJ23SRR/qlZAcRhSmprYPcUUdgQAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABARpQeYoyLx1+7fdNnHFEvCC1IfaNrHBcGTm1M2/M0lGJ0bIIP38mzDYH18rlyPJrJ/seSi9BDD2PUnbFwT08WCHbIwrAAAABAbhLOjS5smu0bVbME0SGU9ErgF56jVCcFCfWbE2RLSX+6p5wnLxgTwZ7LxubsaCeIupl8N+wAzpDTFyXspCFFCAAAAAAAAAAB7OKZaQAAAEAWBDBGLNyXV6vTBM4dU3jVLmlgWzuqFdMV5nCz/Rekw2j3o5lIEpLaukDird8PR9x9SHCYWCE4BOroi9VVPjsB\\", \\"timestamp\\": \\"2024-06-25T19:30:14.059321+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"f9e4ff4c72701f89e3d58882ff6da355990117d5f4d783bcf1305962249e2041\\"}","{\\"status\\": \\"ERROR\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABz6WGp5LnDLotDm0AbmSGFNME37nz5l15F+vhPdsjCsAABhqAAADUfAAAAAgAAAAIAAAABAAAAAAAAAAAAAAAAZnsb8gAAAAEAAAAAAAN/SQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABK/LfpUV3rEW6A6BfrJ23SRR/qlZAcRhSmprYPcUUdgQAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABARpQeYoyLx1+7fdNnHFEvCC1IfaNrHBcGTm1M2/M0lGJ0bIIP38mzDYH18rlyPJrJ/seSi9BDD2PUnbFwT08WCHbIwrAAAABAbhLOjS5smu0bVbME0SGU9ErgF56jVCcFCfWbE2RLSX+6p5wnLxgTwZ7LxubsaCeIupl8N+wAzpDTFyXspCFFCAAAAAAAAAAB7OKZaQAAAEAWBDBGLNyXV6vTBM4dU3jVLmlgWzuqFdMV5nCz/Rekw2j3o5lIEpLaukDird8PR9x9SHCYWCE4BOroi9VVPjsB\\", \\"timestamp\\": \\"2024-06-25T19:30:18.960623+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"horizon response error: StatusCode=400, Type=https://stellar.org/horizon-errors/transaction_failed, Title=Transaction Failed, Detail=The transaction failed when submitted to the stellar network. The `extras.result_codes` field on this response contains further details. Descriptions of each code can be found at: https://developers.stellar.org/api/errors/http-status-codes/horizon-specific/transaction-failed/, Extras=transaction: tx_fee_bump_inner_failed - inner transaction: tx_failed - operation codes: [ op_no_trust ]\\", \\"stellar_transaction_hash\\": \\"f9e4ff4c72701f89e3d58882ff6da355990117d5f4d783bcf1305962249e2041\\"}"} +928d1754-ae86-4296-b9d4-ada167971b93 93a1867d-01be-4857-b5a8-8ebbec37bd83 \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GDXMT6WEUU6ZDRWLIOV2FYDAPUQHN45EW2TOHN5UZ6VVYYIFRIHZ3OYI 2024-06-11 14:14:20.525022+00 \N 2024-06-11 14:14:34.044928+00 2024-06-11 14:14:37.322793+00 df0ef7a009b326b78d1048fd61ec345d11f38b6a9cad60dfffe8ee000b169679 1 2024-06-11 14:14:40.525231+00 2024-06-11 14:14:40.525231+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAB71wvmn42iG2UcM+kxvPMI23BbXLp+pxmjaTQc57YuJwABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmhc9gAAAAEAAAAAAB+S7gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADuyfrEpT2RxstDq6LgYH0gdvOktqbjt7TPq1xhBYoPnQAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAjZ24tfziklfhkK8LhoMNSNIAsDSwatF7SrHFSt65sngYWjyYwI+pI7wj4t0sMWc5iOdHzlerWfYap0Hfd5idC+e2LicAAABAXWqdr/6nKEL+sb+0h4vB6Kz2Y4Old0CtJHPEzVwJuzDtviLnvbV7EB0AOpuWo8DUxNq4TWTWsx/p8nlAxuAjCAAAAAAAAAAB7OKZaQAAAEAVHsZ6w29c73sBpfA7m7Qs5zrBmyFG1yGcLvXY38v3IfJp9gX42XZW2MKFeg8jYcpQEcOHlWLtBPxo9YghthwN AAAAAAAAAMgAAAABPpjSIJjf1peMqwOe6XacQhDvqjr4q37Cy676Aa5oaQQAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-06-11T14:14:20.525022+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAB71wvmn42iG2UcM+kxvPMI23BbXLp+pxmjaTQc57YuJwABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmhc9gAAAAEAAAAAAB+S7gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADuyfrEpT2RxstDq6LgYH0gdvOktqbjt7TPq1xhBYoPnQAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAjZ24tfziklfhkK8LhoMNSNIAsDSwatF7SrHFSt65sngYWjyYwI+pI7wj4t0sMWc5iOdHzlerWfYap0Hfd5idC+e2LicAAABAXWqdr/6nKEL+sb+0h4vB6Kz2Y4Old0CtJHPEzVwJuzDtviLnvbV7EB0AOpuWo8DUxNq4TWTWsx/p8nlAxuAjCAAAAAAAAAAB7OKZaQAAAEAVHsZ6w29c73sBpfA7m7Qs5zrBmyFG1yGcLvXY38v3IfJp9gX42XZW2MKFeg8jYcpQEcOHlWLtBPxo9YghthwN\\", \\"timestamp\\": \\"2024-06-11T14:14:34.044928+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"df0ef7a009b326b78d1048fd61ec345d11f38b6a9cad60dfffe8ee000b169679\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAB71wvmn42iG2UcM+kxvPMI23BbXLp+pxmjaTQc57YuJwABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmhc9gAAAAEAAAAAAB+S7gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADuyfrEpT2RxstDq6LgYH0gdvOktqbjt7TPq1xhBYoPnQAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAjZ24tfziklfhkK8LhoMNSNIAsDSwatF7SrHFSt65sngYWjyYwI+pI7wj4t0sMWc5iOdHzlerWfYap0Hfd5idC+e2LicAAABAXWqdr/6nKEL+sb+0h4vB6Kz2Y4Old0CtJHPEzVwJuzDtviLnvbV7EB0AOpuWo8DUxNq4TWTWsx/p8nlAxuAjCAAAAAAAAAAB7OKZaQAAAEAVHsZ6w29c73sBpfA7m7Qs5zrBmyFG1yGcLvXY38v3IfJp9gX42XZW2MKFeg8jYcpQEcOHlWLtBPxo9YghthwN\\", \\"timestamp\\": \\"2024-06-11T14:14:37.320773+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABPpjSIJjf1peMqwOe6XacQhDvqjr4q37Cy676Aa5oaQQAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"df0ef7a009b326b78d1048fd61ec345d11f38b6a9cad60dfffe8ee000b169679\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAAB71wvmn42iG2UcM+kxvPMI23BbXLp+pxmjaTQc57YuJwABhqAAE4mkAAAAAQAAAAIAAAABAAAAAAAAAAAAAAAAZmhc9gAAAAEAAAAAAB+S7gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAADuyfrEpT2RxstDq6LgYH0gdvOktqbjt7TPq1xhBYoPnQAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAjZ24tfziklfhkK8LhoMNSNIAsDSwatF7SrHFSt65sngYWjyYwI+pI7wj4t0sMWc5iOdHzlerWfYap0Hfd5idC+e2LicAAABAXWqdr/6nKEL+sb+0h4vB6Kz2Y4Old0CtJHPEzVwJuzDtviLnvbV7EB0AOpuWo8DUxNq4TWTWsx/p8nlAxuAjCAAAAAAAAAAB7OKZaQAAAEAVHsZ6w29c73sBpfA7m7Qs5zrBmyFG1yGcLvXY38v3IfJp9gX42XZW2MKFeg8jYcpQEcOHlWLtBPxo9YghthwN\\", \\"timestamp\\": \\"2024-06-11T14:14:37.322793+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABPpjSIJjf1peMqwOe6XacQhDvqjr4q37Cy676Aa5oaQQAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"df0ef7a009b326b78d1048fd61ec345d11f38b6a9cad60dfffe8ee000b169679\\"}"} +8100e8ab-6c87-4d26-a179-358039372a7b 7756d2d0-43a2-49c7-af3c-efbca3813b85 \N USDC GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 0.1000000 GBFPZN7JKFO6WELOQDUBP2ZHNXJEKH7KSWIBYRQUU2TLMD3RIUOYDE7I 2024-06-25 19:46:50.529293+00 \N 2024-06-25 19:46:54.054159+00 2024-06-25 19:46:56.866734+00 a4bf715a77d1d7df186d8585b536dc7eeff4ddecfc1ff1ecbdc4ef0dd11243aa 1 2024-06-25 19:47:00.513123+00 2024-06-25 19:47:00.513123+00 AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABX1BQZWZZbUmiEmCDSx7GgWmBWAGE5owNLyEX7GOQiqgABhqAAADUfAAAAAgAAAAIAAAABAAAAAAAAAAAAAAAAZnsf2gAAAAEAAAAAAAOACgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABK/LfpUV3rEW6A6BfrJ23SRR/qlZAcRhSmprYPcUUdgQAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAZuwiyO3K7t6nqy8llLoZfypMQWrdhBgEORPCubcA2ukAMzDMXvXWAhIrhMk0X9mBpTjN/lRVagf2Um3qJvFTCBjkIqoAAABAz2Q8EI32VCXxbZZsuYmtl1tQ4V7cE4+lo7l2VzOW+Z1hu956zlOp+1w6Tn8o6VSlkc6brgvp52Kr55o4PoqMAAAAAAAAAAAB7OKZaQAAAEBKeoIcou009yEgxR5yNQbNxRjBgQZN91j1IB0rVtwbykZY57UL+m6REH4ZPJYHYGAdpQQeTU+0F4qLmpaC6YwA AAAAAAAAAMgAAAABu0ACGeObfcNLwjbmXBat42d8OL0Ztt1+GJaEvppQKMgAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA= \N \N SUCCESS {"{\\"status\\": \\"PENDING\\", \\"xdr_sent\\": null, \\"timestamp\\": \\"2024-06-25T19:46:50.529293+00:00\\", \\"xdr_received\\": null, \\"status_message\\": null, \\"stellar_transaction_hash\\": null}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABX1BQZWZZbUmiEmCDSx7GgWmBWAGE5owNLyEX7GOQiqgABhqAAADUfAAAAAgAAAAIAAAABAAAAAAAAAAAAAAAAZnsf2gAAAAEAAAAAAAOACgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABK/LfpUV3rEW6A6BfrJ23SRR/qlZAcRhSmprYPcUUdgQAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAZuwiyO3K7t6nqy8llLoZfypMQWrdhBgEORPCubcA2ukAMzDMXvXWAhIrhMk0X9mBpTjN/lRVagf2Um3qJvFTCBjkIqoAAABAz2Q8EI32VCXxbZZsuYmtl1tQ4V7cE4+lo7l2VzOW+Z1hu956zlOp+1w6Tn8o6VSlkc6brgvp52Kr55o4PoqMAAAAAAAAAAAB7OKZaQAAAEBKeoIcou009yEgxR5yNQbNxRjBgQZN91j1IB0rVtwbykZY57UL+m6REH4ZPJYHYGAdpQQeTU+0F4qLmpaC6YwA\\", \\"timestamp\\": \\"2024-06-25T19:46:54.054159+00:00\\", \\"xdr_received\\": null, \\"status_message\\": \\"Updating Stellar Transaction Hash\\", \\"stellar_transaction_hash\\": \\"a4bf715a77d1d7df186d8585b536dc7eeff4ddecfc1ff1ecbdc4ef0dd11243aa\\"}","{\\"status\\": \\"PROCESSING\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABX1BQZWZZbUmiEmCDSx7GgWmBWAGE5owNLyEX7GOQiqgABhqAAADUfAAAAAgAAAAIAAAABAAAAAAAAAAAAAAAAZnsf2gAAAAEAAAAAAAOACgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABK/LfpUV3rEW6A6BfrJ23SRR/qlZAcRhSmprYPcUUdgQAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAZuwiyO3K7t6nqy8llLoZfypMQWrdhBgEORPCubcA2ukAMzDMXvXWAhIrhMk0X9mBpTjN/lRVagf2Um3qJvFTCBjkIqoAAABAz2Q8EI32VCXxbZZsuYmtl1tQ4V7cE4+lo7l2VzOW+Z1hu956zlOp+1w6Tn8o6VSlkc6brgvp52Kr55o4PoqMAAAAAAAAAAAB7OKZaQAAAEBKeoIcou009yEgxR5yNQbNxRjBgQZN91j1IB0rVtwbykZY57UL+m6REH4ZPJYHYGAdpQQeTU+0F4qLmpaC6YwA\\", \\"timestamp\\": \\"2024-06-25T19:46:56.864502+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABu0ACGeObfcNLwjbmXBat42d8OL0Ztt1+GJaEvppQKMgAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": \\"Updating XDR Received\\", \\"stellar_transaction_hash\\": \\"a4bf715a77d1d7df186d8585b536dc7eeff4ddecfc1ff1ecbdc4ef0dd11243aa\\"}","{\\"status\\": \\"SUCCESS\\", \\"xdr_sent\\": \\"AAAABQAAAABFo9bFQVv7JOV0ahENSXRN5dp227UAndSK9qPl7OKZaQAAAAAAAw1AAAAAAgAAAABX1BQZWZZbUmiEmCDSx7GgWmBWAGE5owNLyEX7GOQiqgABhqAAADUfAAAAAgAAAAIAAAABAAAAAAAAAAAAAAAAZnsf2gAAAAEAAAAAAAOACgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAABAAAAAEWj1sVBW/sk5XRqEQ1JdE3l2nbbtQCd1Ir2o+Xs4plpAAAAAQAAAABK/LfpUV3rEW6A6BfrJ23SRR/qlZAcRhSmprYPcUUdgQAAAAFVU0RDAAAAAEI+fQXy7K+/7BkrIVo/G+lq7bjY5wJUq+NBPgIH3layAAAAAAAPQkAAAAAAAAAAAuzimWkAAABAZuwiyO3K7t6nqy8llLoZfypMQWrdhBgEORPCubcA2ukAMzDMXvXWAhIrhMk0X9mBpTjN/lRVagf2Um3qJvFTCBjkIqoAAABAz2Q8EI32VCXxbZZsuYmtl1tQ4V7cE4+lo7l2VzOW+Z1hu956zlOp+1w6Tn8o6VSlkc6brgvp52Kr55o4PoqMAAAAAAAAAAAB7OKZaQAAAEBKeoIcou009yEgxR5yNQbNxRjBgQZN91j1IB0rVtwbykZY57UL+m6REH4ZPJYHYGAdpQQeTU+0F4qLmpaC6YwA\\", \\"timestamp\\": \\"2024-06-25T19:46:56.866734+00:00\\", \\"xdr_received\\": \\"AAAAAAAAAMgAAAABu0ACGeObfcNLwjbmXBat42d8OL0Ztt1+GJaEvppQKMgAAAAAAAAAZAAAAAAAAAABAAAAAAAAAAEAAAAAAAAAAAAAAAA=\\", \\"status_message\\": null, \\"stellar_transaction_hash\\": \\"a4bf715a77d1d7df186d8585b536dc7eeff4ddecfc1ff1ecbdc4ef0dd11243aa\\"}"} +\. + + +-- +-- Data for Name: wallets; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.wallets (id, name, homepage, deep_link_schema, created_at, updated_at, deleted_at, sep_10_client_domain, enabled) FROM stdin; +7a0c5a0a-33c1-42b9-a27b-d657567c2925 Vibrant Assist https://vibrantapp.com/vibrant-assist https://vibrantapp.com/sdp-dev 2023-06-02 17:26:12.27763+00 2024-05-16 10:39:38.858311+00 \N api-dev.vibrantapp.com t +79308ea6-da07-4520-9db4-1b9b390d5d7e Demo Wallet https://demo-wallet.stellar.org https://demo-wallet.stellar.org 2023-06-02 17:26:12.490761+00 2024-05-16 10:39:38.858311+00 \N demo-wallet-server.stellar.org t +0c5faa7e-5dd1-4838-abf1-53771f3b04ae BOSS Money https://www.walletbyboss.com https://www.walletbyboss.com 2023-06-02 17:26:12.45239+00 2024-03-07 17:07:41.899882+00 \N www.walletbyboss.com t +\. + + +-- +-- Data for Name: wallets_assets; Type: TABLE DATA; Schema: public; Owner: postgres +-- + +COPY public.wallets_assets (wallet_id, asset_id) FROM stdin; +79308ea6-da07-4520-9db4-1b9b390d5d7e 4c62168d-b092-4073-b1c2-0e4c19377188 +79308ea6-da07-4520-9db4-1b9b390d5d7e e7cc851e-ed85-479f-a68d-8c74cadfa755 +7a0c5a0a-33c1-42b9-a27b-d657567c2925 4c62168d-b092-4073-b1c2-0e4c19377188 +\. + + +-- +-- Name: assets assets_code_issuer_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.assets + ADD CONSTRAINT assets_code_issuer_key UNIQUE (code, issuer); + + +-- +-- Name: assets assets_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.assets + ADD CONSTRAINT assets_pkey PRIMARY KEY (id); + + +-- +-- Name: auth_migrations auth_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.auth_migrations + ADD CONSTRAINT auth_migrations_pkey PRIMARY KEY (id); + + +-- +-- Name: auth_user_mfa_codes auth_user_mfa_codes_device_id_code_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.auth_user_mfa_codes + ADD CONSTRAINT auth_user_mfa_codes_device_id_code_key UNIQUE (device_id, code); + + +-- +-- Name: auth_user_mfa_codes auth_user_mfa_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.auth_user_mfa_codes + ADD CONSTRAINT auth_user_mfa_codes_pkey PRIMARY KEY (device_id, auth_user_id); + + +-- +-- Name: auth_user_password_reset auth_user_password_reset_token_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.auth_user_password_reset + ADD CONSTRAINT auth_user_password_reset_token_key UNIQUE (token); + + +-- +-- Name: auth_users auth_users_email_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.auth_users + ADD CONSTRAINT auth_users_email_key UNIQUE (email); + + +-- +-- Name: auth_users auth_users_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.auth_users + ADD CONSTRAINT auth_users_pkey PRIMARY KEY (id); + + +-- +-- Name: channel_accounts channel_accounts_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.channel_accounts + ADD CONSTRAINT channel_accounts_pkey PRIMARY KEY (public_key); + + +-- +-- Name: countries countries_name_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.countries + ADD CONSTRAINT countries_name_key UNIQUE (name); + + +-- +-- Name: countries countries_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.countries + ADD CONSTRAINT countries_pkey PRIMARY KEY (code); + + +-- +-- Name: disbursements disbursement_name_unique; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.disbursements + ADD CONSTRAINT disbursement_name_unique UNIQUE (name); + + +-- +-- Name: gorp_migrations gorp_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.gorp_migrations + ADD CONSTRAINT gorp_migrations_pkey PRIMARY KEY (id); + + +-- +-- Name: messages messages_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_pkey PRIMARY KEY (id); + + +-- +-- Name: organizations organizations_name_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.organizations + ADD CONSTRAINT organizations_name_key UNIQUE (name); + + +-- +-- Name: organizations organizations_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.organizations + ADD CONSTRAINT organizations_pkey PRIMARY KEY (id); + + +-- +-- Name: receivers payments_account_phone_number_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.receivers + ADD CONSTRAINT payments_account_phone_number_key UNIQUE (phone_number); + + +-- +-- Name: receivers payments_account_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.receivers + ADD CONSTRAINT payments_account_pkey PRIMARY KEY (id); + + +-- +-- Name: disbursements payments_disbursement_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.disbursements + ADD CONSTRAINT payments_disbursement_pkey PRIMARY KEY (id); + + +-- +-- Name: payments payments_payment_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.payments + ADD CONSTRAINT payments_payment_pkey PRIMARY KEY (id); + + +-- +-- Name: receiver_verifications receiver_verifications_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.receiver_verifications + ADD CONSTRAINT receiver_verifications_pkey PRIMARY KEY (receiver_id, verification_field); + + +-- +-- Name: receiver_wallets receiver_wallets_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.receiver_wallets + ADD CONSTRAINT receiver_wallets_pkey PRIMARY KEY (id); + + +-- +-- Name: receiver_wallets receiver_wallets_receiver_id_wallet_id_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.receiver_wallets + ADD CONSTRAINT receiver_wallets_receiver_id_wallet_id_key UNIQUE (receiver_id, wallet_id); + + +-- +-- Name: submitter_transactions submitter_transactions_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.submitter_transactions + ADD CONSTRAINT submitter_transactions_pkey PRIMARY KEY (id); + + +-- +-- Name: submitter_transactions submitter_transactions_xdr_received_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.submitter_transactions + ADD CONSTRAINT submitter_transactions_xdr_received_key UNIQUE (xdr_received); + + +-- +-- Name: submitter_transactions submitter_transactions_xdr_sent_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.submitter_transactions + ADD CONSTRAINT submitter_transactions_xdr_sent_key UNIQUE (xdr_sent); + + +-- +-- Name: submitter_transactions unique_stellar_transaction_hash; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.submitter_transactions + ADD CONSTRAINT unique_stellar_transaction_hash UNIQUE (stellar_transaction_hash); + + +-- +-- Name: wallets_assets wallets_assets_wallet_id_asset_id_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.wallets_assets + ADD CONSTRAINT wallets_assets_wallet_id_asset_id_key UNIQUE (wallet_id, asset_id); + + +-- +-- Name: wallets wallets_deep_link_schema_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.wallets + ADD CONSTRAINT wallets_deep_link_schema_key UNIQUE (deep_link_schema); + + +-- +-- Name: wallets wallets_homepage_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.wallets + ADD CONSTRAINT wallets_homepage_key UNIQUE (homepage); + + +-- +-- Name: wallets wallets_name_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.wallets + ADD CONSTRAINT wallets_name_key UNIQUE (name); + + +-- +-- Name: wallets wallets_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.wallets + ADD CONSTRAINT wallets_pkey PRIMARY KEY (id); + + +-- +-- Name: disbursement_request_16523d_idx; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX disbursement_request_16523d_idx ON public.disbursements USING btree (created_at DESC); + + +-- +-- Name: idx_unique_external_id; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE UNIQUE INDEX idx_unique_external_id ON public.submitter_transactions USING btree (external_id) WHERE (status <> 'ERROR'::public.transaction_status); + + +-- +-- Name: payment_account_id_idx; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX payment_account_id_idx ON public.payments USING btree (receiver_id); + + +-- +-- Name: payment_account_id_like_idx; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX payment_account_id_like_idx ON public.payments USING btree (receiver_id varchar_pattern_ops); + + +-- +-- Name: payment_disbursement_id_idx; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX payment_disbursement_id_idx ON public.payments USING btree (disbursement_id); + + +-- +-- Name: payment_requested_at_idx; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX payment_requested_at_idx ON public.payments USING btree (created_at DESC); + + +-- +-- Name: receiver_phone_number_idx; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX receiver_phone_number_idx ON public.receivers USING btree (phone_number varchar_pattern_ops); + + +-- +-- Name: receiver_registered_at_idx; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE INDEX receiver_registered_at_idx ON public.receivers USING btree (created_at DESC); + + +-- +-- Name: unique_user_valid_token; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE UNIQUE INDEX unique_user_valid_token ON public.auth_user_password_reset USING btree (auth_user_id, is_valid) WHERE (is_valid IS TRUE); + + +-- +-- Name: unique_wallets_index; Type: INDEX; Schema: public; Owner: postgres +-- + +CREATE UNIQUE INDEX unique_wallets_index ON public.wallets USING btree (name, homepage, deep_link_schema); + + +-- +-- Name: auth_user_mfa_codes auth_user_mfa_codes_before_update_trigger; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER auth_user_mfa_codes_before_update_trigger BEFORE UPDATE ON public.auth_user_mfa_codes FOR EACH ROW EXECUTE FUNCTION public.auth_user_mfa_codes_before_update(); + + +-- +-- Name: auth_user_password_reset auth_user_password_reset_before_insert_trigger; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER auth_user_password_reset_before_insert_trigger BEFORE INSERT ON public.auth_user_password_reset FOR EACH ROW EXECUTE FUNCTION public.auth_user_password_reset_before_insert(); + + +-- +-- Name: organizations enforce_single_row_for_organizations_delete_trigger; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER enforce_single_row_for_organizations_delete_trigger BEFORE DELETE ON public.organizations FOR EACH ROW EXECUTE FUNCTION public.enforce_single_row_for_organizations(); + + +-- +-- Name: organizations enforce_single_row_for_organizations_insert_trigger; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER enforce_single_row_for_organizations_insert_trigger BEFORE INSERT ON public.organizations FOR EACH ROW EXECUTE FUNCTION public.enforce_single_row_for_organizations(); + + +-- +-- Name: assets refresh_asset_updated_at; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER refresh_asset_updated_at BEFORE UPDATE ON public.assets FOR EACH ROW EXECUTE FUNCTION public.update_at_refresh(); + + +-- +-- Name: channel_accounts refresh_channel_accounts_updated_at; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER refresh_channel_accounts_updated_at BEFORE UPDATE ON public.channel_accounts FOR EACH ROW EXECUTE FUNCTION public.update_at_refresh(); + + +-- +-- Name: countries refresh_country_updated_at; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER refresh_country_updated_at BEFORE UPDATE ON public.countries FOR EACH ROW EXECUTE FUNCTION public.update_at_refresh(); + + +-- +-- Name: disbursements refresh_disbursement_updated_at; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER refresh_disbursement_updated_at BEFORE UPDATE ON public.disbursements FOR EACH ROW EXECUTE FUNCTION public.update_at_refresh(); + + +-- +-- Name: messages refresh_message_updated_at; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER refresh_message_updated_at BEFORE UPDATE ON public.messages FOR EACH ROW EXECUTE FUNCTION public.update_at_refresh(); + + +-- +-- Name: organizations refresh_organization_updated_at; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER refresh_organization_updated_at BEFORE UPDATE ON public.organizations FOR EACH ROW EXECUTE FUNCTION public.update_at_refresh(); + + +-- +-- Name: payments refresh_payment_updated_at; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER refresh_payment_updated_at BEFORE UPDATE ON public.payments FOR EACH ROW EXECUTE FUNCTION public.update_at_refresh(); + + +-- +-- Name: receivers refresh_receiver_updated_at; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER refresh_receiver_updated_at BEFORE UPDATE ON public.receivers FOR EACH ROW EXECUTE FUNCTION public.update_at_refresh(); + + +-- +-- Name: receiver_verifications refresh_receiver_verifications_updated_at; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER refresh_receiver_verifications_updated_at BEFORE UPDATE ON public.receiver_verifications FOR EACH ROW EXECUTE FUNCTION public.update_at_refresh(); + + +-- +-- Name: receiver_wallets refresh_receiver_wallet_updated_at; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER refresh_receiver_wallet_updated_at BEFORE UPDATE ON public.receiver_wallets FOR EACH ROW EXECUTE FUNCTION public.update_at_refresh(); + + +-- +-- Name: submitter_transactions refresh_submitter_transactions_updated_at; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER refresh_submitter_transactions_updated_at BEFORE UPDATE ON public.submitter_transactions FOR EACH ROW EXECUTE FUNCTION public.update_at_refresh(); + + +-- +-- Name: wallets refresh_wallet_updated_at; Type: TRIGGER; Schema: public; Owner: postgres +-- + +CREATE TRIGGER refresh_wallet_updated_at BEFORE UPDATE ON public.wallets FOR EACH ROW EXECUTE FUNCTION public.update_at_refresh(); + + +-- +-- Name: disbursements fk_disbursement_asset_id; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.disbursements + ADD CONSTRAINT fk_disbursement_asset_id FOREIGN KEY (asset_id) REFERENCES public.assets(id); + + +-- +-- Name: disbursements fk_disbursement_country_code; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.disbursements + ADD CONSTRAINT fk_disbursement_country_code FOREIGN KEY (country_code) REFERENCES public.countries(code); + + +-- +-- Name: disbursements fk_disbursement_wallet_id; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.disbursements + ADD CONSTRAINT fk_disbursement_wallet_id FOREIGN KEY (wallet_id) REFERENCES public.wallets(id); + + +-- +-- Name: auth_user_mfa_codes fk_mfa_codes_auth_user_id; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.auth_user_mfa_codes + ADD CONSTRAINT fk_mfa_codes_auth_user_id FOREIGN KEY (auth_user_id) REFERENCES public.auth_users(id); + + +-- +-- Name: auth_user_password_reset fk_password_reset_auth_user_id; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.auth_user_password_reset + ADD CONSTRAINT fk_password_reset_auth_user_id FOREIGN KEY (auth_user_id) REFERENCES public.auth_users(id); + + +-- +-- Name: payments fk_payment_asset_id; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.payments + ADD CONSTRAINT fk_payment_asset_id FOREIGN KEY (asset_id) REFERENCES public.assets(id); + + +-- +-- Name: payments fk_payments_receiver_wallet_id; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.payments + ADD CONSTRAINT fk_payments_receiver_wallet_id FOREIGN KEY (receiver_wallet_id) REFERENCES public.receiver_wallets(id); + + +-- +-- Name: messages messages_asset_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_asset_id_fkey FOREIGN KEY (asset_id) REFERENCES public.assets(id); + + +-- +-- Name: messages messages_receiver_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_receiver_id_fkey FOREIGN KEY (receiver_id) REFERENCES public.receivers(id); + + +-- +-- Name: messages messages_receiver_wallet_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_receiver_wallet_id_fkey FOREIGN KEY (receiver_wallet_id) REFERENCES public.receiver_wallets(id); + + +-- +-- Name: messages messages_wallet_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.messages + ADD CONSTRAINT messages_wallet_id_fkey FOREIGN KEY (wallet_id) REFERENCES public.wallets(id); + + +-- +-- Name: payments payments_payment_account_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.payments + ADD CONSTRAINT payments_payment_account_id_fkey FOREIGN KEY (receiver_id) REFERENCES public.receivers(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: payments payments_payment_disbursement_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.payments + ADD CONSTRAINT payments_payment_disbursement_id_fkey FOREIGN KEY (disbursement_id) REFERENCES public.disbursements(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: receiver_verifications receiver_verifications_receiver_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.receiver_verifications + ADD CONSTRAINT receiver_verifications_receiver_id_fkey FOREIGN KEY (receiver_id) REFERENCES public.receivers(id) ON DELETE CASCADE; + + +-- +-- Name: receiver_wallets receiver_wallets_receiver_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.receiver_wallets + ADD CONSTRAINT receiver_wallets_receiver_id_fkey FOREIGN KEY (receiver_id) REFERENCES public.receivers(id); + + +-- +-- Name: receiver_wallets receiver_wallets_wallet_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.receiver_wallets + ADD CONSTRAINT receiver_wallets_wallet_id_fkey FOREIGN KEY (wallet_id) REFERENCES public.wallets(id); + + +-- +-- Name: wallets_assets wallets_assets_asset_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.wallets_assets + ADD CONSTRAINT wallets_assets_asset_id_fkey FOREIGN KEY (asset_id) REFERENCES public.assets(id); + + +-- +-- Name: wallets_assets wallets_assets_wallet_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.wallets_assets + ADD CONSTRAINT wallets_assets_wallet_id_fkey FOREIGN KEY (wallet_id) REFERENCES public.wallets(id); + + +-- +-- Name: SCHEMA public; Type: ACL; Schema: -; Owner: postgres +-- + +GRANT ALL ON SCHEMA public TO PUBLIC; + + +-- +-- PostgreSQL database dump complete +-- + diff --git a/internal/integrationtests/scripts/e2e_integration_test.sh b/internal/integrationtests/scripts/e2e_integration_test.sh new file mode 100755 index 000000000..185ad74bb --- /dev/null +++ b/internal/integrationtests/scripts/e2e_integration_test.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# This script is used to run e2e integration tests locally with all necessary steps. +set -eu + +export DIVIDER="----------------------------------------" +# prepare + +wait_for_server() { + local endpoint=$1 + local max_wait_time=$2 + + SECONDS=0 + while ! curl -s $endpoint > /dev/null; do + echo "Waiting for server at $endpoint to be up... $SECONDS seconds elapsed" + sleep 4 + if [ $SECONDS -ge $max_wait_time ]; then + echo "Server at $endpoint is not up after $max_wait_time seconds." + exit 1 + fi + done + echo "Server at $endpoint is up." +} + +accountTypes=("DISTRIBUTION_ACCOUNT.STELLAR.ENV" "DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT") +for accountType in "${accountTypes[@]}"; do + export DISTRIBUTION_ACCOUNT_TYPE=$accountType + if [ $accountType="DISTRIBUTION_ACCOUNT.STELLAR.ENV" ] + then + platform="Stellar" + else + platform="Circle" + fi + + echo "====> 👀Starting e2e setup and integration test ($platform)" + echo $DIVIDER + echo "====> 👀Step 1: start preparation" + docker container ps -aq -f name='e2e' --format '{{.ID}}' | xargs docker stop | xargs docker rm -v && + docker volume ls -f name='e2e' --format '{{.Name}}' | xargs docker volume rm + echo "====> ✅Step 1: finish preparation" + + # Run docker compose + echo $DIVIDER + echo "====> 👀Step 2: build sdp-api, anchor-platform and tss" + docker-compose -f ../docker/docker-compose-e2e-tests.yml up --build -d + wait_for_server "http://localhost:8000/health" 20 + echo "====> ✅Step 2: finishing build" + + # Create integration test data + echo $DIVIDER + echo "====> 👀Step 3: provision new tenant and populate new asset and test wallet on database" + docker exec e2e-sdp-api bash -c "./stellar-disbursement-platform integration-tests create-data" + echo "====> ✅Step 3: finish creating integration test data ($platform)" + + # Restart anchor platform container + echo $DIVIDER + echo "====> 👀Step 4: restart anchor platform container to get the new created asset" + docker restart e2e-anchor-platform + echo "waiting for anchor platform to initialize" + wait_for_server "http://localhost:8080/health" 120 + wait_for_server "http://localhost:8085/health" 120 + echo "====> ✅Step 4: finish restarting anchor platform container" + + # Run integration tests + echo $DIVIDER + echo "====> 👀Step 5: run integration tests command" + docker exec e2e-sdp-api bash -c "./stellar-disbursement-platform integration-tests start" + echo "====> ✅Step 5: finish running integration test data ($platform)" + + # Cleanup container and volumes + echo $DIVIDER + echo "====> 👀Step 6: cleaning up e2e containers and volumes" + docker container ps -aq -f name='e2e' --format '{{.ID}}' | xargs docker stop | xargs docker rm -v && + docker volume ls -f name='e2e' --format '{{.Name}}' | xargs docker volume rm + echo "====> ✅Step 6: finish cleaning up containers and volumes" +done + +echo $DIVIDER +echo "🎉🎉🎉🎉 SUCCESS! 🎉🎉🎉🎉" diff --git a/internal/integrationtests/scripts/singletenant_to_multitenant_db_migration_test.sh b/internal/integrationtests/scripts/singletenant_to_multitenant_db_migration_test.sh new file mode 100755 index 000000000..41e4e98c4 --- /dev/null +++ b/internal/integrationtests/scripts/singletenant_to_multitenant_db_migration_test.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# This script is used to run e2e integration tests locally with all necessary steps. +set -eu + +export DIVIDER="----------------------------------------" + +wait_for_server() { + local endpoint=$1 + local max_wait_time=$2 + + SECONDS=0 + while ! curl -s $endpoint > /dev/null; do + echo "Waiting for server at $endpoint to be up... $SECONDS seconds elapsed" + sleep 4 + if [ $SECONDS -ge $max_wait_time ]; then + echo "Server at $endpoint is not up after $max_wait_time seconds." + exit 1 + fi + done + echo "Server at $endpoint is up." +} + +export DISTRIBUTION_ACCOUNT_TYPE="DISTRIBUTION_ACCOUNT.STELLAR.ENV" +export DATABASE_URL="postgres://postgres@db:5432/e2e-sdp?sslmode=disable" + +echo "====> 👀Starting setup for DB migration test" +echo $DIVIDER +echo "====> 👀Step 1: start preparation" +docker container ps -aq -f name='e2e' --format '{{.ID}}' | xargs docker stop | xargs docker rm -v && +docker volume ls -f name='e2e' --format '{{.Name}}' | xargs docker volume rm +echo "====> ✅Step 1: finish preparation" + +# Run docker compose +echo $DIVIDER +echo "====> 👀Step 2: build sdp-api, anchor-platform and tss" +docker-compose -f ../docker/docker-compose-e2e-tests.yml up --build -d +wait_for_server "http://localhost:8000/health" 20 +echo "====> ✅Step 2: finishing build" + +echo $DIVIDER +echo "====> 👀Step 3: copy DB dump to container and restore it into the newly created database" +docker cp ../resources/single_tenant_dump.sql e2e-sdp-v2-database:/tmp/single_tenant_dump.sql +docker exec e2e-sdp-v2-database bash -c "psql -d $DATABASE_URL -f /tmp/single_tenant_dump.sql" +echo "====> ✅Step 3: finish copying and restoring DB dump" + +echo $DIVIDER +echo "====> 👀Step 4: provision new tenant" +adminAccount="SDP-admin" +adminApiKey="api_key_1234567890" +encodedCredentials=$(echo -n "$adminAccount:$adminApiKey" | base64) +AuthHeader="Authorization: Basic $encodedCredentials" +tenant="migrated-tenant" +baseURL="http://$tenant.stellar.local:8000" +sdpUIBaseURL="http://$tenant.stellar.local:3000" +ownerEmail="init_owner@$tenant.local" +AdminTenantURL="http://localhost:8003/tenants" +response=$(curl -s -w "\n%{http_code}" -X POST $AdminTenantURL \ + -H "Content-Type: application/json" \ + -H "$AuthHeader" \ + -d '{ + "name": "'"$tenant"'", + "organization_name": "'"$tenant"'", + "base_url": "'"$baseURL"'", + "sdp_ui_base_url": "'"$sdpUIBaseURL"'", + "owner_email": "'"$ownerEmail"'", + "owner_first_name": "jane", + "owner_last_name": "doe", + "distribution_account_type": "DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT" + }') + +http_code=$(echo "$response" | tail -n1) +response_body=$(echo "$response" | sed '$d') + +if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then + echo "✅ Tenant $tenant created successfully." + echo "🔗 You can now reset the password for the owner $ownerEmail on $sdpUIBaseURL/forgot-password" + echo "Response body: $response_body" +else + echo "❌ Failed to create tenant $tenant. HTTP status code: $http_code" + echo "Server response: $response_body" +fi + +echo $DIVIDER +echo "====> 👀Step 5: run migration" +docker exec e2e-sdp-v2-database bash -c "psql -d $DATABASE_URL -c \"SELECT admin.migrate_tenant_data_from_v1_to_v2('migrated-tenant');\"" +echo "====> ✅Step 5: run migration" + +echo $DIVIDER +echo "====> 👀Step 6: exclude deprecated tables" +docker exec e2e-sdp-v2-database bash -c "psql -d $DATABASE_URL -c \" + BEGIN TRANSACTION; + DROP TABLE public.messages CASCADE; + DROP TABLE public.payments CASCADE; + DROP TABLE public.disbursements CASCADE; + DROP TABLE public.receiver_verifications CASCADE; + DROP TABLE public.receiver_wallets CASCADE; + DROP TABLE public.auth_user_password_reset CASCADE; + DROP TABLE public.auth_user_mfa_codes CASCADE; + DROP TABLE public.receivers CASCADE; + DROP TABLE public.auth_users CASCADE; + DROP TABLE public.wallets_assets CASCADE; + DROP TABLE public.assets CASCADE; + DROP TABLE public.wallets CASCADE; + DROP TABLE public.organizations CASCADE; + DROP TABLE public.gorp_migrations CASCADE; + DROP TABLE public.auth_migrations CASCADE; + DROP TABLE public.countries CASCADE; + DROP TABLE public.submitter_transactions CASCADE; + DROP TABLE public.channel_accounts CASCADE; + COMMIT; +\"" +echo "====> ✅Step 6: exclude deprecated tables" + +# Cleanup container and volumes +echo $DIVIDER +echo "====> 👀Step 7: cleaning up e2e containers and volumes" +docker container ps -aq -f name='e2e' --format '{{.ID}}' | xargs docker stop | xargs docker rm -v && +docker volume ls -f name='e2e' --format '{{.Name}}' | xargs docker volume rm +echo "====> ✅Step 7: finish cleaning up containers and volumes" + +echo $DIVIDER +echo "🎉🎉🎉🎉 SUCCESS! 🎉🎉🎉🎉" \ No newline at end of file diff --git a/internal/integrationtests/server_api.go b/internal/integrationtests/server_api.go index 797707b34..f36ab72a5 100644 --- a/internal/integrationtests/server_api.go +++ b/internal/integrationtests/server_api.go @@ -21,6 +21,7 @@ import ( const ( loginURL = "login" disbursementURL = "disbursements" + organizationURL = "organization" registrationURL = "wallet-registration" ) @@ -30,6 +31,7 @@ type ServerApiIntegrationTestsInterface interface { ProcessDisbursement(ctx context.Context, authToken *ServerApiAuthToken, disbursementID string) error StartDisbursement(ctx context.Context, authToken *ServerApiAuthToken, disbursementID string, body *httphandler.PatchDisbursementStatusRequest) error ReceiverRegistration(ctx context.Context, authSEP24Token *AnchorPlatformAuthSEP24Token, body *data.ReceiverRegistrationRequest) error + ConfigureCircleAccess(ctx context.Context, authToken *ServerApiAuthToken, body *httphandler.PatchCircleConfigRequest) error } type ServerApiIntegrationTests struct { @@ -260,5 +262,38 @@ func (sa *ServerApiIntegrationTests) ReceiverRegistration(ctx context.Context, a return nil } +func (sa *ServerApiIntegrationTests) ConfigureCircleAccess(ctx context.Context, authToken *ServerApiAuthToken, body *httphandler.PatchCircleConfigRequest) error { + reqURL, err := url.JoinPath(sa.ServerApiBaseURL, organizationURL, "circle-config") + if err != nil { + return fmt.Errorf("error creating url: %w", err) + } + + reqBody, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("error creating json post body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, reqURL, strings.NewReader(string(reqBody))) + if err != nil { + return fmt.Errorf("error creating new request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+authToken.Token) + req.Header.Set("SDP-Tenant-Name", sa.TenantName) + + resp, err := sa.HttpClient.Do(req) + if err != nil { + return fmt.Errorf("error making request to server API patch CIRCLE CONFIG: %w", err) + } + + if resp.StatusCode/100 != 2 { + logErrorResponses(ctx, resp.Body) + return fmt.Errorf("error trying to configure Circle access on the server API") + } + + return nil +} + // Ensuring that ServerApiIntegrationTests is implementing ServerApiIntegrationTestsInterface. var _ ServerApiIntegrationTestsInterface = (*ServerApiIntegrationTests)(nil) diff --git a/internal/integrationtests/server_api_test.go b/internal/integrationtests/server_api_test.go index 05906cef7..75815ac82 100644 --- a/internal/integrationtests/server_api_test.go +++ b/internal/integrationtests/server_api_test.go @@ -8,12 +8,13 @@ import ( "strings" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" - httpclientMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient/mocks" - "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httphandler" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + httpclientMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httphandler" ) func Test_Login(t *testing.T) { @@ -177,7 +178,7 @@ func Test_ProcessDisbursement(t *testing.T) { sa := ServerApiIntegrationTests{ HttpClient: &httpClientMock, ServerApiBaseURL: "http://mock_server.com/", - DisbursementCSVFilePath: "files", + DisbursementCSVFilePath: "resources", DisbursementCSVFileName: "disbursement_integration_tests.csv", } diff --git a/internal/integrationtests/utils.go b/internal/integrationtests/utils.go index 9148741e0..4be1232f3 100644 --- a/internal/integrationtests/utils.go +++ b/internal/integrationtests/utils.go @@ -11,6 +11,7 @@ import ( "github.com/gocarina/gocsv" "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" ) diff --git a/internal/integrationtests/utils_test.go b/internal/integrationtests/utils_test.go index 1318cdd8e..2c5c97a18 100644 --- a/internal/integrationtests/utils_test.go +++ b/internal/integrationtests/utils_test.go @@ -37,22 +37,22 @@ func Test_logErrorResponses(t *testing.T) { func Test_readDisbursementCSV(t *testing.T) { t.Run("error trying read csv file", func(t *testing.T) { - filePath := path.Join("files", "invalid_file.csv") + filePath := path.Join("resources", "invalid_file.csv") expectedError := fmt.Sprintf("error reading csv file: open %s: file does not exist", filePath) - data, err := readDisbursementCSV("files", "invalid_file.csv") + data, err := readDisbursementCSV("resources", "invalid_file.csv") require.EqualError(t, err, expectedError) assert.Empty(t, data) }) t.Run("error opening empty csv file", func(t *testing.T) { - data, err := readDisbursementCSV("files", "empty_csv_file.csv") + data, err := readDisbursementCSV("resources", "empty_csv_file.csv") require.EqualError(t, err, "error parsing csv file: empty csv file given") assert.Empty(t, data) }) t.Run("reading csv file", func(t *testing.T) { - data, err := readDisbursementCSV("files", "disbursement_integration_tests.csv") + data, err := readDisbursementCSV("resources", "disbursement_integration_tests.csv") require.NoError(t, err) assert.Equal(t, data[0].Amount, "0.1") assert.Equal(t, data[0].Phone, "+12025550191") diff --git a/internal/integrationtests/validations_test.go b/internal/integrationtests/validations_test.go index 4937b57e4..c94e76f2a 100644 --- a/internal/integrationtests/validations_test.go +++ b/internal/integrationtests/validations_test.go @@ -4,10 +4,11 @@ import ( "context" "testing" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" - "github.com/stretchr/testify/require" ) func Test_validationAfterProcessDisbursement(t *testing.T) { diff --git a/internal/message/aws_ses_client.go b/internal/message/aws_ses_client.go index 6d75bbfb7..cb0111689 100644 --- a/internal/message/aws_ses_client.go +++ b/internal/message/aws_ses_client.go @@ -9,6 +9,7 @@ import ( "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/ses" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/htmltemplate" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) diff --git a/internal/message/aws_sns_client.go b/internal/message/aws_sns_client.go index 40d90a00a..f6aca46fc 100644 --- a/internal/message/aws_sns_client.go +++ b/internal/message/aws_sns_client.go @@ -9,6 +9,7 @@ import ( "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/sns" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) diff --git a/internal/message/twilio_client.go b/internal/message/twilio_client.go index 1d11602a9..f81bbb9f3 100644 --- a/internal/message/twilio_client.go +++ b/internal/message/twilio_client.go @@ -5,9 +5,10 @@ import ( "strings" "github.com/stellar/go/support/log" - "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/twilio/twilio-go" twilioApi "github.com/twilio/twilio-go/rest/api/v2010" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) type twilioApiInterface interface { diff --git a/internal/monitor/mocks/monitor_client.go b/internal/monitor/mocks/monitor_client.go index ebddc0b8a..d8a9b14b6 100644 --- a/internal/monitor/mocks/monitor_client.go +++ b/internal/monitor/mocks/monitor_client.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.27.1. DO NOT EDIT. +// Code generated by mockery v2.40.1. DO NOT EDIT. package mocks @@ -19,6 +19,10 @@ type MockMonitorClient struct { func (_m *MockMonitorClient) GetMetricHttpHandler() http.Handler { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for GetMetricHttpHandler") + } + var r0 http.Handler if rf, ok := ret.Get(0).(func() http.Handler); ok { r0 = rf() @@ -35,6 +39,10 @@ func (_m *MockMonitorClient) GetMetricHttpHandler() http.Handler { func (_m *MockMonitorClient) GetMetricType() monitor.MetricType { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for GetMetricType") + } + var r0 monitor.MetricType if rf, ok := ret.Get(0).(func() monitor.MetricType); ok { r0 = rf() @@ -70,13 +78,12 @@ func (_m *MockMonitorClient) MonitorHttpRequestDuration(duration time.Duration, _m.Called(duration, labels) } -type mockConstructorTestingTNewMockMonitorClient interface { +// NewMockMonitorClient creates a new instance of MockMonitorClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockMonitorClient(t interface { mock.TestingT Cleanup(func()) -} - -// NewMockMonitorClient creates a new instance of MockMonitorClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewMockMonitorClient(t mockConstructorTestingTNewMockMonitorClient) *MockMonitorClient { +}) *MockMonitorClient { mock := &MockMonitorClient{} mock.Mock.Test(t) diff --git a/internal/monitor/mocks/monitor_service_interface.go b/internal/monitor/mocks/monitor_service_interface.go index f86d6c6cf..be7dadf21 100644 --- a/internal/monitor/mocks/monitor_service_interface.go +++ b/internal/monitor/mocks/monitor_service_interface.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.27.1. DO NOT EDIT. +// Code generated by mockery v2.40.1. DO NOT EDIT. package mocks @@ -19,6 +19,10 @@ type MockMonitorService struct { func (_m *MockMonitorService) GetMetricHttpHandler() (http.Handler, error) { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for GetMetricHttpHandler") + } + var r0 http.Handler var r1 error if rf, ok := ret.Get(0).(func() (http.Handler, error)); ok { @@ -45,6 +49,10 @@ func (_m *MockMonitorService) GetMetricHttpHandler() (http.Handler, error) { func (_m *MockMonitorService) GetMetricType() (monitor.MetricType, error) { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for GetMetricType") + } + var r0 monitor.MetricType var r1 error if rf, ok := ret.Get(0).(func() (monitor.MetricType, error)); ok { @@ -69,6 +77,10 @@ func (_m *MockMonitorService) GetMetricType() (monitor.MetricType, error) { func (_m *MockMonitorService) MonitorCounters(tag monitor.MetricTag, labels map[string]string) error { ret := _m.Called(tag, labels) + if len(ret) == 0 { + panic("no return value specified for MonitorCounters") + } + var r0 error if rf, ok := ret.Get(0).(func(monitor.MetricTag, map[string]string) error); ok { r0 = rf(tag, labels) @@ -83,6 +95,10 @@ func (_m *MockMonitorService) MonitorCounters(tag monitor.MetricTag, labels map[ func (_m *MockMonitorService) MonitorDBQueryDuration(duration time.Duration, tag monitor.MetricTag, labels monitor.DBQueryLabels) error { ret := _m.Called(duration, tag, labels) + if len(ret) == 0 { + panic("no return value specified for MonitorDBQueryDuration") + } + var r0 error if rf, ok := ret.Get(0).(func(time.Duration, monitor.MetricTag, monitor.DBQueryLabels) error); ok { r0 = rf(duration, tag, labels) @@ -97,6 +113,10 @@ func (_m *MockMonitorService) MonitorDBQueryDuration(duration time.Duration, tag func (_m *MockMonitorService) MonitorDuration(duration time.Duration, tag monitor.MetricTag, labels map[string]string) error { ret := _m.Called(duration, tag, labels) + if len(ret) == 0 { + panic("no return value specified for MonitorDuration") + } + var r0 error if rf, ok := ret.Get(0).(func(time.Duration, monitor.MetricTag, map[string]string) error); ok { r0 = rf(duration, tag, labels) @@ -111,6 +131,10 @@ func (_m *MockMonitorService) MonitorDuration(duration time.Duration, tag monito func (_m *MockMonitorService) MonitorHistogram(value float64, tag monitor.MetricTag, labels map[string]string) error { ret := _m.Called(value, tag, labels) + if len(ret) == 0 { + panic("no return value specified for MonitorHistogram") + } + var r0 error if rf, ok := ret.Get(0).(func(float64, monitor.MetricTag, map[string]string) error); ok { r0 = rf(value, tag, labels) @@ -125,6 +149,10 @@ func (_m *MockMonitorService) MonitorHistogram(value float64, tag monitor.Metric func (_m *MockMonitorService) MonitorHttpRequestDuration(duration time.Duration, labels monitor.HttpRequestLabels) error { ret := _m.Called(duration, labels) + if len(ret) == 0 { + panic("no return value specified for MonitorHttpRequestDuration") + } + var r0 error if rf, ok := ret.Get(0).(func(time.Duration, monitor.HttpRequestLabels) error); ok { r0 = rf(duration, labels) @@ -139,6 +167,10 @@ func (_m *MockMonitorService) MonitorHttpRequestDuration(duration time.Duration, func (_m *MockMonitorService) Start(opts monitor.MetricOptions) error { ret := _m.Called(opts) + if len(ret) == 0 { + panic("no return value specified for Start") + } + var r0 error if rf, ok := ret.Get(0).(func(monitor.MetricOptions) error); ok { r0 = rf(opts) @@ -149,13 +181,12 @@ func (_m *MockMonitorService) Start(opts monitor.MetricOptions) error { return r0 } -type mockConstructorTestingTNewMockMonitorService interface { +// NewMockMonitorService creates a new instance of MockMonitorService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockMonitorService(t interface { mock.TestingT Cleanup(func()) -} - -// NewMockMonitorService creates a new instance of MockMonitorService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewMockMonitorService(t mockConstructorTestingTNewMockMonitorService) *MockMonitorService { +}) *MockMonitorService { mock := &MockMonitorService{} mock.Mock.Test(t) diff --git a/internal/scheduler/jobs/anchor_platform_auth_enforcement_job.go b/internal/scheduler/jobs/anchor_platform_auth_enforcement_job.go index c2edffe8f..a2a4e5b18 100644 --- a/internal/scheduler/jobs/anchor_platform_auth_enforcement_job.go +++ b/internal/scheduler/jobs/anchor_platform_auth_enforcement_job.go @@ -6,6 +6,7 @@ import ( "time" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/anchorplatform" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor" diff --git a/internal/scheduler/jobs/circle_payment_to_submitter_job.go b/internal/scheduler/jobs/circle_payment_to_submitter_job.go new file mode 100644 index 000000000..edb5969c2 --- /dev/null +++ b/internal/scheduler/jobs/circle_payment_to_submitter_job.go @@ -0,0 +1,88 @@ +package jobs + +import ( + "context" + "fmt" + "time" + + "github.com/stellar/go/support/log" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/paymentdispatchers" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" +) + +const ( + circlePaymentToSubmitterJobName = "circle_payment_to_submitter_job" + circlePaymentToSubmitterBatchSize = 100 +) + +// circlePaymentToSubmitterJob is a job that periodically sends any ready-to-pay SDP payments to the transaction submission +// service. +type circlePaymentToSubmitterJob struct { + paymentToSubmitterSvc services.PaymentToSubmitterServiceInterface + jobIntervalSeconds int + distAccountResolver signing.DistributionAccountResolver +} + +type CirclePaymentToSubmitterJobOptions struct { + JobIntervalSeconds int + Models *data.Models + DistAccountResolver signing.DistributionAccountResolver + CircleService circle.ServiceInterface +} + +func NewCirclePaymentToSubmitterJob(opts CirclePaymentToSubmitterJobOptions) Job { + if opts.JobIntervalSeconds < DefaultMinimumJobIntervalSeconds { + log.Fatalf("job interval is not set for %s. Instantiation failed", circlePaymentToSubmitterJobName) + } + + circlePaymentDispatcher := paymentdispatchers.NewCirclePaymentDispatcher(opts.Models, opts.CircleService, opts.DistAccountResolver) + + return &circlePaymentToSubmitterJob{ + paymentToSubmitterSvc: services.NewPaymentToSubmitterService(services.PaymentToSubmitterServiceOptions{ + Models: opts.Models, + DistAccountResolver: opts.DistAccountResolver, + PaymentDispatcher: circlePaymentDispatcher, + }), + jobIntervalSeconds: opts.JobIntervalSeconds, + distAccountResolver: opts.DistAccountResolver, + } +} + +func (d circlePaymentToSubmitterJob) IsJobMultiTenant() bool { + return true +} + +func (d circlePaymentToSubmitterJob) GetInterval() time.Duration { + if d.jobIntervalSeconds == 0 { + log.Warnf("job interval is not set for %s. Using default interval: %d seconds", d.GetName(), DefaultMinimumJobIntervalSeconds) + return DefaultMinimumJobIntervalSeconds * time.Second + } + return time.Duration(d.jobIntervalSeconds) * time.Second +} + +func (d circlePaymentToSubmitterJob) GetName() string { + return circlePaymentToSubmitterJobName +} + +func (d circlePaymentToSubmitterJob) Execute(ctx context.Context) error { + distAccount, err := d.distAccountResolver.DistributionAccountFromContext(ctx) + if err != nil { + return fmt.Errorf("getting distribution account: %w", err) + } + + if !distAccount.Type.IsCircle() { + log.Ctx(ctx).Debug("distribution account is not a Circle account. Skipping for current tenant") + return nil + } + + if payErr := d.paymentToSubmitterSvc.SendBatchPayments(ctx, circlePaymentToSubmitterBatchSize); payErr != nil { + return fmt.Errorf("executing circlePaymentToSubmitterJob: %w", payErr) + } + return nil +} + +var _ Job = (*circlePaymentToSubmitterJob)(nil) diff --git a/internal/scheduler/jobs/circle_payment_to_submitter_job_test.go b/internal/scheduler/jobs/circle_payment_to_submitter_job_test.go new file mode 100644 index 000000000..49444fbca --- /dev/null +++ b/internal/scheduler/jobs/circle_payment_to_submitter_job_test.go @@ -0,0 +1,83 @@ +package jobs + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/mocks" + sigMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" +) + +func Test_CirclePaymentToSubmitterJob_GetInterval(t *testing.T) { + interval := 5 + p := NewCirclePaymentToSubmitterJob(CirclePaymentToSubmitterJobOptions{JobIntervalSeconds: interval}) + require.Equal(t, time.Duration(interval)*time.Second, p.GetInterval()) +} + +func Test_CirclePaymentToSubmitterJob_GetName(t *testing.T) { + p := NewCirclePaymentToSubmitterJob(CirclePaymentToSubmitterJobOptions{JobIntervalSeconds: 5}) + require.Equal(t, circlePaymentToSubmitterJobName, p.GetName()) +} + +func Test_CirclePaymentToSubmitterJob_IsJobMultiTenant(t *testing.T) { + p := NewCirclePaymentToSubmitterJob(CirclePaymentToSubmitterJobOptions{JobIntervalSeconds: 5}) + require.Equal(t, true, p.IsJobMultiTenant()) +} + +func Test_CirclePaymentToSubmitterJob_Execute(t *testing.T) { + tests := []struct { + name string + sendPayments func(ctx context.Context, batchSize int) error + wantErr error + }{ + { + name: "SendBatchPayments success", + sendPayments: func(ctx context.Context, batchSize int) error { + return nil + }, + wantErr: nil, + }, + { + name: "SendBatchPayments returns error", + sendPayments: func(ctx context.Context, batchSize int) error { + return fmt.Errorf("error") + }, + wantErr: fmt.Errorf("error"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockPaymentToSubmitterService := &mocks.MockPaymentToSubmitterService{} + mockPaymentToSubmitterService.On("SendBatchPayments", mock.Anything, circlePaymentToSubmitterBatchSize). + Return(tt.sendPayments(nil, circlePaymentToSubmitterBatchSize)) + mDistAccResolver := sigMocks.NewMockDistributionAccountResolver(t) + mDistAccResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountCircleDBVault}, nil). + Maybe() + + p := circlePaymentToSubmitterJob{ + paymentToSubmitterSvc: mockPaymentToSubmitterService, + distAccountResolver: mDistAccResolver, + } + + err := p.Execute(context.Background()) + if tt.wantErr != nil { + assert.NotNil(t, err) + assert.ErrorContains(t, err, tt.wantErr.Error()) + } else { + assert.NoError(t, err) + } + + mockPaymentToSubmitterService.AssertExpectations(t) + }) + } +} diff --git a/internal/scheduler/jobs/circle_reconciliation_job.go b/internal/scheduler/jobs/circle_reconciliation_job.go new file mode 100644 index 000000000..f349cab71 --- /dev/null +++ b/internal/scheduler/jobs/circle_reconciliation_job.go @@ -0,0 +1,68 @@ +package jobs + +import ( + "context" + "fmt" + "time" + + "github.com/stellar/go/support/log" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" +) + +const ( + circleReconciliationJobName = "circle_reconciliation_job" + circleReconciliationJobIntervalSeconds = 30 +) + +type CircleReconciliationJobOptions struct { + Models *data.Models + DistAccountResolver signing.DistributionAccountResolver + CircleService circle.ServiceInterface +} + +func NewCircleReconciliationJob(opts CircleReconciliationJobOptions) Job { + return &circleReconciliationJob{ + jobIntervalSeconds: circleReconciliationJobIntervalSeconds, + reconciliationService: &services.CircleReconciliationService{ + Models: opts.Models, + DistAccountResolver: opts.DistAccountResolver, + CircleService: opts.CircleService, + }, + } +} + +type circleReconciliationJob struct { + jobIntervalSeconds int + reconciliationService services.CircleReconciliationServiceInterface +} + +func (j circleReconciliationJob) IsJobMultiTenant() bool { + return true +} + +func (j circleReconciliationJob) GetInterval() time.Duration { + jobIntervalSeconds := j.jobIntervalSeconds + if j.jobIntervalSeconds == 0 { + log.Warnf("job interval is not set for %s. Using default interval: %d seconds", j.GetName(), DefaultMinimumJobIntervalSeconds) + jobIntervalSeconds = DefaultMinimumJobIntervalSeconds + } + return time.Duration(jobIntervalSeconds) * time.Second +} + +func (j circleReconciliationJob) GetName() string { + return circleReconciliationJobName +} + +func (j circleReconciliationJob) Execute(ctx context.Context) error { + err := j.reconciliationService.Reconcile(ctx) + if err != nil { + return fmt.Errorf("executing Job %s: %w", j.GetName(), err) + } + return nil +} + +var _ Job = (*circleReconciliationJob)(nil) diff --git a/internal/scheduler/jobs/circle_reconciliation_job_test.go b/internal/scheduler/jobs/circle_reconciliation_job_test.go new file mode 100644 index 000000000..c9d013d7a --- /dev/null +++ b/internal/scheduler/jobs/circle_reconciliation_job_test.go @@ -0,0 +1,76 @@ +package jobs + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/mocks" +) + +func Test_circleReconciliationJob_GetInterval(t *testing.T) { + job := NewCircleReconciliationJob(CircleReconciliationJobOptions{}) + require.Equal(t, circleReconciliationJobIntervalSeconds*time.Second, job.GetInterval()) +} + +func Test_circleReconciliationJob_GetName(t *testing.T) { + job := NewCircleReconciliationJob(CircleReconciliationJobOptions{}) + require.Equal(t, circleReconciliationJobName, job.GetName()) +} + +func Test_circleReconciliationJob_IsJobMultiTenant(t *testing.T) { + job := NewCircleReconciliationJob(CircleReconciliationJobOptions{}) + require.Equal(t, true, job.IsJobMultiTenant()) +} + +func Test_circleReconciliationJob_Execute(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + prepareMocksFn func(mReconciliationService *mocks.MockCircleReconciliationService) + wantErrContains string + }{ + { + name: "🔴 execution fails", + prepareMocksFn: func(mReconciliationService *mocks.MockCircleReconciliationService) { + mReconciliationService. + On("Reconcile", ctx). + Return(assert.AnError). + Once() + }, + wantErrContains: "executing Job", + }, + { + name: "🟢 execution succeeds", + prepareMocksFn: func(mReconciliationService *mocks.MockCircleReconciliationService) { + mReconciliationService. + On("Reconcile", ctx). + Return(nil). + Once() + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mReconciliationService := mocks.NewMockCircleReconciliationService(t) + tc.prepareMocksFn(mReconciliationService) + job := circleReconciliationJob{ + jobIntervalSeconds: 5, + reconciliationService: mReconciliationService, + } + + err := job.Execute(ctx) + if tc.wantErrContains != "" { + require.Error(t, err) + assert.ErrorContains(t, err, tc.wantErrContains) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/scheduler/jobs/patch_anchor_platform_transactions_job.go b/internal/scheduler/jobs/patch_anchor_platform_transactions_job.go index 531ab2dd5..6e66c46f4 100644 --- a/internal/scheduler/jobs/patch_anchor_platform_transactions_job.go +++ b/internal/scheduler/jobs/patch_anchor_platform_transactions_job.go @@ -6,6 +6,7 @@ import ( "time" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/anchorplatform" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/services" @@ -44,7 +45,8 @@ func (j patchAnchorPlatformTransactionsCompletionJob) GetInterval() time.Duratio } func (j patchAnchorPlatformTransactionsCompletionJob) Execute(ctx context.Context) error { - if err := j.service.PatchAPTransactionsForPayments(ctx); err != nil { + err := j.service.PatchAPTransactionsForPayments(ctx) + if err != nil { err = fmt.Errorf("patching anchor platform transactions completion: %w", err) log.Ctx(ctx).Error(err) return err diff --git a/internal/scheduler/jobs/patch_anchor_platform_transactions_job_test.go b/internal/scheduler/jobs/patch_anchor_platform_transactions_job_test.go index 67ca4f24f..2474c1abb 100644 --- a/internal/scheduler/jobs/patch_anchor_platform_transactions_job_test.go +++ b/internal/scheduler/jobs/patch_anchor_platform_transactions_job_test.go @@ -19,6 +19,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/anchorplatform" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" servicesMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/services/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) func Test_NewPatchAnchorPlatformTransactionCompletionJob(t *testing.T) { @@ -108,7 +109,9 @@ func Test_PatchAnchorPlatformTransactionsCompletionJob_Execute(t *testing.T) { require.NoError(t, outerErr) defer dbConnectionPool.Close() - ctx := context.Background() + tenantInfo := &tenant.Tenant{ID: "95e788b6-c80e-4975-9d12-141001fe6e44", Name: "aid-org-1"} + ctx := tenant.SaveTenantInContext(context.Background(), tenantInfo) + apAPISvcMock := anchorplatform.AnchorPlatformAPIServiceMock{} patchAnchorSvcMock := servicesMocks.MockPatchAnchorPlatformTransactionCompletionService{} diff --git a/internal/scheduler/jobs/payment_from_submitter_job.go b/internal/scheduler/jobs/payment_from_submitter_job.go index 5d279b39b..d58f3a658 100644 --- a/internal/scheduler/jobs/payment_from_submitter_job.go +++ b/internal/scheduler/jobs/payment_from_submitter_job.go @@ -5,13 +5,12 @@ import ( "fmt" "time" - "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" - "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" - "github.com/stellar/stellar-disbursement-platform-backend/internal/services" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) const ( @@ -53,6 +52,10 @@ func (d paymentFromSubmitterJob) Execute(ctx context.Context) error { if err != nil { return fmt.Errorf("error getting tenant from context for %s: %w", paymentFromSubmitterJobName, err) } + if !t.DistributionAccountType.IsStellar() { + log.Ctx(ctx).Debugf("Skipping job %s for tenant %s as it uses a %s Distribution account", d.GetName(), t.ID, t.DistributionAccountType.Platform()) + return nil + } err = d.service.SyncBatchTransactions(ctx, paymentFromSubmitterBatchSize, t.ID) if err != nil { return fmt.Errorf("error executing paymentFromSubmitterJob: %w", err) diff --git a/internal/scheduler/jobs/payment_from_submitter_job_test.go b/internal/scheduler/jobs/payment_from_submitter_job_test.go index 169faf589..dd7257427 100644 --- a/internal/scheduler/jobs/payment_from_submitter_job_test.go +++ b/internal/scheduler/jobs/payment_from_submitter_job_test.go @@ -6,12 +6,13 @@ import ( "testing" "time" - "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/services/mocks" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) func Test_PaymentFromSubmitterJob_GetInterval(t *testing.T) { @@ -55,7 +56,11 @@ func Test_PaymentFromSubmitterJob_Execute(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { ctx := context.Background() - tenantInfo := &tenant.Tenant{ID: "95e788b6-c80e-4975-9d12-141001fe6e44", Name: "aid-org-1"} + tenantInfo := &tenant.Tenant{ + ID: "95e788b6-c80e-4975-9d12-141001fe6e44", + Name: "aid-org-1", + DistributionAccountType: schema.DistributionAccountStellarEnv, + } ctx = tenant.SaveTenantInContext(ctx, tenantInfo) mockPaymentFromSubmitterService := &mocks.MockPaymentFromSubmitterService{} diff --git a/internal/scheduler/jobs/payment_to_submitter_job.go b/internal/scheduler/jobs/payment_to_submitter_job.go deleted file mode 100644 index 865de6ce3..000000000 --- a/internal/scheduler/jobs/payment_to_submitter_job.go +++ /dev/null @@ -1,61 +0,0 @@ -package jobs - -import ( - "context" - "fmt" - "time" - - "github.com/stellar/go/support/log" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" - - "github.com/stellar/stellar-disbursement-platform-backend/internal/services" -) - -const ( - paymentToSubmitterJobName = "payment_to_submitter_job" - paymentToSubmitterBatchSize = 100 -) - -// paymentToSubmitterJob is a job that periodically sends any ready-to-pay SDP payments to the transaction submission -// service. -type paymentToSubmitterJob struct { - paymentToSubmitterSvc services.PaymentToSubmitterServiceInterface - jobIntervalSeconds int -} - -func NewPaymentToSubmitterJob(jobIntervalSeconds int, models *data.Models, tssDBConnectionPool db.DBConnectionPool) Job { - if jobIntervalSeconds < DefaultMinimumJobIntervalSeconds { - log.Fatalf("job interval is not set for %s. Instantiation failed", paymentToSubmitterJobName) - } - return &paymentToSubmitterJob{ - paymentToSubmitterSvc: services.NewPaymentToSubmitterService(models, tssDBConnectionPool), - jobIntervalSeconds: jobIntervalSeconds, - } -} - -func (d paymentToSubmitterJob) IsJobMultiTenant() bool { - return true -} - -func (d paymentToSubmitterJob) GetInterval() time.Duration { - if d.jobIntervalSeconds == 0 { - log.Warnf("job interval is not set for %s. Using default interval: %d seconds", d.GetName(), DefaultMinimumJobIntervalSeconds) - return DefaultMinimumJobIntervalSeconds * time.Second - } - return time.Duration(d.jobIntervalSeconds) * time.Second -} - -func (d paymentToSubmitterJob) GetName() string { - return paymentToSubmitterJobName -} - -func (d paymentToSubmitterJob) Execute(ctx context.Context) error { - err := d.paymentToSubmitterSvc.SendBatchPayments(ctx, paymentToSubmitterBatchSize) - if err != nil { - return fmt.Errorf("error executing paymentToSubmitterJob: %w", err) - } - return nil -} - -var _ Job = (*paymentToSubmitterJob)(nil) diff --git a/internal/scheduler/jobs/payment_to_submitter_job_test.go b/internal/scheduler/jobs/payment_to_submitter_job_test.go deleted file mode 100644 index 5bbc43b9f..000000000 --- a/internal/scheduler/jobs/payment_to_submitter_job_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package jobs - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" - "github.com/stellar/stellar-disbursement-platform-backend/internal/services/mocks" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" -) - -func Test_PaymentToSubmitterJob_GetInterval(t *testing.T) { - interval := 5 - p := NewPaymentToSubmitterJob(interval, &data.Models{}, nil) - require.Equal(t, time.Duration(interval)*time.Second, p.GetInterval()) -} - -func Test_PaymentToSubmitterJob_GetName(t *testing.T) { - p := NewPaymentToSubmitterJob(5, &data.Models{}, nil) - require.Equal(t, paymentToSubmitterJobName, p.GetName()) -} - -func Test_PaymentToSubmitterJob_IsJobMultiTenant(t *testing.T) { - p := NewPaymentToSubmitterJob(5, &data.Models{}, nil) - require.Equal(t, true, p.IsJobMultiTenant()) -} - -func Test_PaymentToSubmitterJob_Execute(t *testing.T) { - tests := []struct { - name string - sendPayments func(ctx context.Context, batchSize int) error - wantErr bool - }{ - { - name: "SendBatchPayments success", - sendPayments: func(ctx context.Context, batchSize int) error { - return nil - }, - wantErr: false, - }, - { - name: "SendBatchPayments returns error", - sendPayments: func(ctx context.Context, batchSize int) error { - return fmt.Errorf("error") - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - mockPaymentToSubmitterService := &mocks.MockPaymentToSubmitterService{} - mockPaymentToSubmitterService.On("SendBatchPayments", mock.Anything, paymentToSubmitterBatchSize). - Return(tt.sendPayments(nil, paymentToSubmitterBatchSize)) - - p := paymentToSubmitterJob{ - paymentToSubmitterSvc: mockPaymentToSubmitterService, - } - - err := p.Execute(context.Background()) - if (err != nil) != tt.wantErr { - t.Errorf("paymentToSubmitterJob.Execute() error = %v, wantErr %v", err, tt.wantErr) - } - - mockPaymentToSubmitterService.AssertExpectations(t) - }) - } -} diff --git a/internal/scheduler/jobs/ready_payments_cancellation_job.go b/internal/scheduler/jobs/ready_payments_cancellation_job.go index eaf010e89..38a74776a 100644 --- a/internal/scheduler/jobs/ready_payments_cancellation_job.go +++ b/internal/scheduler/jobs/ready_payments_cancellation_job.go @@ -6,6 +6,7 @@ import ( "time" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/services" ) diff --git a/internal/scheduler/jobs/ready_payments_cancellation_job_test.go b/internal/scheduler/jobs/ready_payments_cancellation_job_test.go index f6ace0dd5..ba5b1880d 100644 --- a/internal/scheduler/jobs/ready_payments_cancellation_job_test.go +++ b/internal/scheduler/jobs/ready_payments_cancellation_job_test.go @@ -7,12 +7,13 @@ import ( "time" "github.com/stellar/go/support/log" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" ) type mockReadyPaymentsCancellation struct { diff --git a/internal/scheduler/jobs/send_receiver_wallets_sms_invitation_job.go b/internal/scheduler/jobs/send_receiver_wallets_sms_invitation_job.go index d50ce9d5c..1af631718 100644 --- a/internal/scheduler/jobs/send_receiver_wallets_sms_invitation_job.go +++ b/internal/scheduler/jobs/send_receiver_wallets_sms_invitation_job.go @@ -6,6 +6,7 @@ import ( "time" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" diff --git a/internal/scheduler/jobs/send_receiver_wallets_sms_invitation_job_test.go b/internal/scheduler/jobs/send_receiver_wallets_sms_invitation_job_test.go index 37e1e943a..07b12be90 100644 --- a/internal/scheduler/jobs/send_receiver_wallets_sms_invitation_job_test.go +++ b/internal/scheduler/jobs/send_receiver_wallets_sms_invitation_job_test.go @@ -10,17 +10,16 @@ import ( "time" "github.com/google/uuid" - "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" - "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stellar/stellar-disbursement-platform-backend/internal/services" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) func Test_NewSendReceiverWalletsSMSInvitationJob(t *testing.T) { diff --git a/internal/scheduler/jobs/stellar_payment_to_submitter_job.go b/internal/scheduler/jobs/stellar_payment_to_submitter_job.go new file mode 100644 index 000000000..0ee5ac824 --- /dev/null +++ b/internal/scheduler/jobs/stellar_payment_to_submitter_job.go @@ -0,0 +1,92 @@ +package jobs + +import ( + "context" + "fmt" + "time" + + "github.com/stellar/go/support/log" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/paymentdispatchers" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" + txSubStore "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" +) + +const ( + stellarPaymentToSubmitterJobName = "stellar_payment_to_submitter_job" + stellarPaymentToSubmitterBatchSize = 100 +) + +// stellarPaymentToSubmitterJob is a job that periodically sends any ready-to-pay SDP stellar payments to the transaction submission +// service. +type stellarPaymentToSubmitterJob struct { + paymentToSubmitterSvc services.PaymentToSubmitterServiceInterface + jobIntervalSeconds int + distAccountResolver signing.DistributionAccountResolver +} + +type StellarPaymentToSubmitterJobOptions struct { + JobIntervalSeconds int + Models *data.Models + TSSDBConnectionPool db.DBConnectionPool + DistAccountResolver signing.DistributionAccountResolver +} + +func NewStellarPaymentToSubmitterJob(opts StellarPaymentToSubmitterJobOptions) Job { + if opts.JobIntervalSeconds < DefaultMinimumJobIntervalSeconds { + log.Fatalf("job interval is not set for %s. Instantiation failed", stellarPaymentToSubmitterJobName) + } + + stellarPaymentDispatcher := paymentdispatchers.NewStellarPaymentDispatcher( + opts.Models, + txSubStore.NewTransactionModel(opts.TSSDBConnectionPool), + opts.DistAccountResolver) + + return &stellarPaymentToSubmitterJob{ + paymentToSubmitterSvc: services.NewPaymentToSubmitterService(services.PaymentToSubmitterServiceOptions{ + Models: opts.Models, + DistAccountResolver: opts.DistAccountResolver, + PaymentDispatcher: stellarPaymentDispatcher, + }), + jobIntervalSeconds: opts.JobIntervalSeconds, + distAccountResolver: opts.DistAccountResolver, + } +} + +func (d stellarPaymentToSubmitterJob) IsJobMultiTenant() bool { + return true +} + +func (d stellarPaymentToSubmitterJob) GetInterval() time.Duration { + if d.jobIntervalSeconds == 0 { + log.Warnf("job interval is not set for %s. Using default interval: %d seconds", d.GetName(), DefaultMinimumJobIntervalSeconds) + return DefaultMinimumJobIntervalSeconds * time.Second + } + return time.Duration(d.jobIntervalSeconds) * time.Second +} + +func (d stellarPaymentToSubmitterJob) GetName() string { + return stellarPaymentToSubmitterJobName +} + +func (d stellarPaymentToSubmitterJob) Execute(ctx context.Context) error { + distAccount, err := d.distAccountResolver.DistributionAccountFromContext(ctx) + if err != nil { + return fmt.Errorf("getting distribution account: %w", err) + } + + if !distAccount.Type.IsStellar() { + log.Ctx(ctx).Debug("distribution account is not a Stellar account. Skipping for current tenant") + return nil + } + + if payErr := d.paymentToSubmitterSvc.SendBatchPayments(ctx, stellarPaymentToSubmitterBatchSize); payErr != nil { + return fmt.Errorf("executing paymentToSubmitterJob: %w", payErr) + } + return nil +} + +var _ Job = (*stellarPaymentToSubmitterJob)(nil) diff --git a/internal/scheduler/jobs/stellar_payment_to_submitter_job_test.go b/internal/scheduler/jobs/stellar_payment_to_submitter_job_test.go new file mode 100644 index 000000000..91b45fc7b --- /dev/null +++ b/internal/scheduler/jobs/stellar_payment_to_submitter_job_test.go @@ -0,0 +1,83 @@ +package jobs + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/mocks" + sigMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" +) + +func Test_StellarPaymentToSubmitterJob_GetInterval(t *testing.T) { + interval := 5 + p := NewStellarPaymentToSubmitterJob(StellarPaymentToSubmitterJobOptions{JobIntervalSeconds: interval}) + require.Equal(t, time.Duration(interval)*time.Second, p.GetInterval()) +} + +func Test_StellarPaymentToSubmitterJob_GetName(t *testing.T) { + p := NewStellarPaymentToSubmitterJob(StellarPaymentToSubmitterJobOptions{JobIntervalSeconds: 5}) + require.Equal(t, stellarPaymentToSubmitterJobName, p.GetName()) +} + +func Test_StellarPaymentToSubmitterJob_IsJobMultiTenant(t *testing.T) { + p := NewStellarPaymentToSubmitterJob(StellarPaymentToSubmitterJobOptions{JobIntervalSeconds: 5}) + require.Equal(t, true, p.IsJobMultiTenant()) +} + +func Test_StellarPaymentToSubmitterJob_Execute(t *testing.T) { + tests := []struct { + name string + sendPayments func(ctx context.Context, batchSize int) error + wantErr error + }{ + { + name: "SendBatchPayments success", + sendPayments: func(ctx context.Context, batchSize int) error { + return nil + }, + wantErr: nil, + }, + { + name: "SendBatchPayments returns error", + sendPayments: func(ctx context.Context, batchSize int) error { + return fmt.Errorf("error") + }, + wantErr: fmt.Errorf("error"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockPaymentToSubmitterService := &mocks.MockPaymentToSubmitterService{} + mockPaymentToSubmitterService.On("SendBatchPayments", mock.Anything, stellarPaymentToSubmitterBatchSize). + Return(tt.sendPayments(nil, stellarPaymentToSubmitterBatchSize)) + mDistAccResolver := sigMocks.NewMockDistributionAccountResolver(t) + mDistAccResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarDBVault}, nil). + Maybe() + + p := stellarPaymentToSubmitterJob{ + paymentToSubmitterSvc: mockPaymentToSubmitterService, + distAccountResolver: mDistAccResolver, + } + + err := p.Execute(context.Background()) + if tt.wantErr != nil { + assert.NotNil(t, err) + assert.ErrorContains(t, err, tt.wantErr.Error()) + } else { + assert.NoError(t, err) + } + + mockPaymentToSubmitterService.AssertExpectations(t) + }) + } +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index dbde1c4c2..790d6a312 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "sync" "syscall" "time" @@ -25,9 +26,11 @@ import ( type Scheduler struct { jobs map[string]jobs.Job cancel context.CancelFunc - jobQueue chan jobs.Job crashTrackerClient crashtracker.CrashTrackerClient tenantManager tenant.ManagerInterface + jobQueue chan jobs.Job + // enqueuedJobs is used to keep track of enqueued jobs to avoid enqueuing the same job multiple times in case it takes longer to execute than its interval. + enqueuedJobs sync.Map } type SchedulerOptions struct { @@ -101,7 +104,7 @@ func (s *Scheduler) start(ctx context.Context) { // 1. We start all the workers that will process jobs from the job queue. for i := 1; i <= SchedulerWorkerCount; i++ { // start a new worker passing a CrashTrackerClient clone to report errors when the job is executed - go worker(ctx, i, s.crashTrackerClient.Clone(), s.tenantManager, s.jobQueue) + go worker(ctx, i, s.crashTrackerClient.Clone(), s.tenantManager, s) } // 2. Enqueue jobs to jobQueue. @@ -112,8 +115,13 @@ func (s *Scheduler) start(ctx context.Context) { for { select { case <-ticker.C: - log.Ctx(ctx).Debugf("Enqueuing job: %s", job.GetName()) - s.jobQueue <- job + jobName := job.GetName() + if _, alreadyEnqueued := s.enqueuedJobs.LoadOrStore(jobName, true); !alreadyEnqueued { + log.Ctx(ctx).Debugf("Enqueuing job: %s", jobName) + s.jobQueue <- job + } else { + log.Ctx(ctx).Debugf("Skipping job %s, already in queue", jobName) + } case <-ctx.Done(): ticker.Stop() return @@ -130,7 +138,7 @@ func (s *Scheduler) stop() { } // worker is a goroutine that processes jobs from the job queue. -func worker(ctx context.Context, workerID int, crashTrackerClient crashtracker.CrashTrackerClient, tenantManager tenant.ManagerInterface, jobQueue <-chan jobs.Job) { +func worker(ctx context.Context, workerID int, crashTrackerClient crashtracker.CrashTrackerClient, tenantManager tenant.ManagerInterface, scheduler *Scheduler) { defer func() { if r := recover(); r != nil { log.Ctx(ctx).Errorf("Worker %d encountered a panic while processing a job: %v", workerID, r) @@ -138,8 +146,9 @@ func worker(ctx context.Context, workerID int, crashTrackerClient crashtracker.C }() for { select { - case job := <-jobQueue: + case job := <-scheduler.jobQueue: executeJob(ctx, job, workerID, crashTrackerClient, tenantManager) + scheduler.enqueuedJobs.Delete(job.GetName()) // Remove job from tracking after execution case <-ctx.Done(): log.Ctx(ctx).Infof("Worker %d stopping...", workerID) return @@ -159,8 +168,8 @@ func executeJob(ctx context.Context, job jobs.Job, workerID int, crashTrackerCli } for _, t := range tenants { log.Ctx(ctx).Debugf("Processing job %s for tenant %s on worker %d", job.GetName(), t.ID, workerID) - tenantCtx := tenant.SaveTenantInContext(context.Background(), &t) - if jobErr := job.Execute(tenantCtx); jobErr != nil { + tenantCtx := tenant.SaveTenantInContext(ctx, &t) + if err = job.Execute(tenantCtx); err != nil { msg := fmt.Sprintf("error processing job %s for tenant %s on worker %d", job.GetName(), t.ID, workerID) crashTrackerClient.LogAndReportErrors(tenantCtx, err, msg) } @@ -191,12 +200,23 @@ func WithReadyPaymentsCancellationJobOption(models *data.Models) SchedulerJobReg } } -func WithPaymentToSubmitterJobOption(jobIntervalSeconds int, - models *data.Models, - tssDBConnectionPool db.DBConnectionPool, -) SchedulerJobRegisterOption { +func WithCirclePaymentToSubmitterJobOption(options jobs.CirclePaymentToSubmitterJobOptions) SchedulerJobRegisterOption { + return func(s *Scheduler) { + j := jobs.NewCirclePaymentToSubmitterJob(options) + s.addJob(j) + } +} + +func WithStellarPaymentToSubmitterJobOption(options jobs.StellarPaymentToSubmitterJobOptions) SchedulerJobRegisterOption { + return func(s *Scheduler) { + j := jobs.NewStellarPaymentToSubmitterJob(options) + s.addJob(j) + } +} + +func WithCircleReconciliationJobOption(options jobs.CircleReconciliationJobOptions) SchedulerJobRegisterOption { return func(s *Scheduler) { - j := jobs.NewPaymentToSubmitterJob(jobIntervalSeconds, models, tssDBConnectionPool) + j := jobs.NewCircleReconciliationJob(options) s.addJob(j) } } diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index 5037125ce..71a8c42e4 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -5,15 +5,13 @@ import ( "testing" "time" - "github.com/stretchr/testify/mock" - - "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" "github.com/stretchr/testify/assert" - - "github.com/stellar/stellar-disbursement-platform-backend/internal/scheduler/jobs" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" - "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/internal/scheduler/jobs" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) func TestScheduler(t *testing.T) { diff --git a/internal/serve/httpclient/mocks/http_client_mock.go b/internal/serve/httpclient/mocks/http_client_mock.go index 9baac178a..4feb4ba05 100644 --- a/internal/serve/httpclient/mocks/http_client_mock.go +++ b/internal/serve/httpclient/mocks/http_client_mock.go @@ -14,6 +14,22 @@ type HttpClientMock struct { mock.Mock } +type testInterface interface { + mock.TestingT + Cleanup(func()) +} + +// NewHttpClientMock creates a new instance of HttpClientMock. It also registers a testing interface on the mock and a +// cleanup function to assert the mocks expectations. +func NewHttpClientMock(t testInterface) *HttpClientMock { + m := &HttpClientMock{} + m.Mock.Test(t) + + t.Cleanup(func() { m.AssertExpectations(t) }) + + return m +} + func (h *HttpClientMock) Do(req *http.Request) (*http.Response, error) { args := h.Called(req) if args.Get(0) == nil { diff --git a/internal/serve/httphandler/assets_handler.go b/internal/serve/httphandler/assets_handler.go index 68e8c7b00..eed22aa93 100644 --- a/internal/serve/httphandler/assets_handler.go +++ b/internal/serve/httphandler/assets_handler.go @@ -23,6 +23,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine" tssUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" ) const stellarNativeAssetCode = "XLM" @@ -61,8 +62,20 @@ func (c AssetsHandler) GetAssets(w http.ResponseWriter, r *http.Request) { // CreateAsset adds a new asset. func (c AssetsHandler) CreateAsset(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + distributionAccount, err := c.DistributionAccountResolver.DistributionAccountFromContext(ctx) + if err != nil { + err = fmt.Errorf("resolving distribution account from context: %w", err) + httperror.InternalError(ctx, "Cannot resolve distribution account from context", err, nil).Render(w) + return + } else if !distributionAccount.IsStellar() { + httperror.BadRequest("Distribution account affiliated with tenant is not a Stellar account", nil, nil).Render(w) + return + } + var assetRequest AssetRequest - err := json.NewDecoder(r.Body).Decode(&assetRequest) + err = json.NewDecoder(r.Body).Decode(&assetRequest) if err != nil { httperror.BadRequest("invalid request body", err, nil).Render(w) return @@ -82,8 +95,6 @@ func (c AssetsHandler) CreateAsset(w http.ResponseWriter, r *http.Request) { return } - ctx := r.Context() - asset, err := db.RunInTransactionWithResult(ctx, c.Models.DBConnectionPool, nil, func(dbTx db.DBTransaction) (*data.Asset, error) { insertedAsset, insertErr := c.Models.Assets.Insert(ctx, dbTx, assetCode, assetIssuer) if insertErr != nil { @@ -91,7 +102,7 @@ func (c AssetsHandler) CreateAsset(w http.ResponseWriter, r *http.Request) { } assetToAdd := &txnbuild.CreditAsset{Code: assetCode, Issuer: assetIssuer} - trustlineErr := c.handleUpdateAssetTrustlineForDistributionAccount(ctx, assetToAdd, nil) + trustlineErr := c.handleUpdateAssetTrustlineForDistributionAccount(ctx, assetToAdd, nil, distributionAccount) if trustlineErr != nil { return nil, fmt.Errorf("adding trustline for the distribution account: %w", trustlineErr) } @@ -110,6 +121,17 @@ func (c AssetsHandler) CreateAsset(w http.ResponseWriter, r *http.Request) { // DeleteAsset marks an asset for soft delete. func (c AssetsHandler) DeleteAsset(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + + distributionAccount, err := c.DistributionAccountResolver.DistributionAccountFromContext(ctx) + if err != nil { + err = fmt.Errorf("resolving distribution account from context: %w", err) + httperror.InternalError(ctx, "Cannot resolve distribution account from context", err, nil).Render(w) + return + } else if !distributionAccount.IsStellar() { + httperror.BadRequest("Distribution account affiliated with tenant is not a Stellar account", nil, nil).Render(w) + return + } + assetID := chi.URLParam(r, "id") asset, err := c.Models.Assets.Get(ctx, assetID) @@ -128,15 +150,13 @@ func (c AssetsHandler) DeleteAsset(w http.ResponseWriter, r *http.Request) { asset, err = db.RunInTransactionWithResult(ctx, c.Models.DBConnectionPool, nil, func(dbTx db.DBTransaction) (*data.Asset, error) { deletedAsset, deleteErr := c.Models.Assets.SoftDelete(ctx, dbTx, assetID) if deleteErr != nil { - return nil, fmt.Errorf("error performing soft delete on asset id %s: %w", assetID, deleteErr) + return nil, fmt.Errorf("performing soft delete on asset id %s: %w", assetID, deleteErr) } - trustlineErr := c.handleUpdateAssetTrustlineForDistributionAccount(ctx, nil, &txnbuild.CreditAsset{ - Code: deletedAsset.Code, - Issuer: deletedAsset.Issuer, - }) + assetToRemove := &txnbuild.CreditAsset{Code: deletedAsset.Code, Issuer: deletedAsset.Issuer} + trustlineErr := c.handleUpdateAssetTrustlineForDistributionAccount(ctx, nil, assetToRemove, distributionAccount) if trustlineErr != nil { - return nil, fmt.Errorf("error removing trustline: %w", trustlineErr) + return nil, fmt.Errorf("removing trustline: %w", trustlineErr) } return asset, nil @@ -154,7 +174,18 @@ func (c AssetsHandler) DeleteAsset(w http.ResponseWriter, r *http.Request) { httpjson.Render(w, asset, httpjson.JSON) } -func (c AssetsHandler) handleUpdateAssetTrustlineForDistributionAccount(ctx context.Context, assetToAddTrustline *txnbuild.CreditAsset, assetToRemoveTrustline *txnbuild.CreditAsset) error { +func (c AssetsHandler) handleUpdateAssetTrustlineForDistributionAccount( + ctx context.Context, + assetToAddTrustline *txnbuild.CreditAsset, + assetToRemoveTrustline *txnbuild.CreditAsset, + distributionAccount schema.TransactionAccount, +) error { + // Non-native Stellar distribution accounts will not require asset trustlines to be managed on our end. This is + // technically unreachable from the endpoint entry points, but we will still check for this case here. + if !distributionAccount.IsStellar() { + return fmt.Errorf("distribution account is not a native Stellar account") + } + if assetToAddTrustline == nil && assetToRemoveTrustline == nil { return fmt.Errorf("should provide at least one asset") } @@ -164,18 +195,8 @@ func (c AssetsHandler) handleUpdateAssetTrustlineForDistributionAccount(ctx cont return fmt.Errorf("should provide different assets") } - // TODO: move it to the beginning of the callers in SDP-1183 - var distributionAccountPubKey string - if distributionAccount, err := c.DistributionAccountResolver.DistributionAccountFromContext(ctx); err != nil { - return fmt.Errorf("resolving distribution account from context: %w", err) - } else if !distributionAccount.IsStellar() { - return fmt.Errorf("expected distribution account to be a STELLAR account but got %q", distributionAccount.Type) - } else { - distributionAccountPubKey = distributionAccount.Address - } - acc, err := c.HorizonClient.AccountDetail(horizonclient.AccountRequest{ - AccountID: distributionAccountPubKey, + AccountID: distributionAccount.Address, }) if err != nil { return fmt.Errorf("getting distribution account details: %w", err) @@ -186,9 +207,9 @@ func (c AssetsHandler) handleUpdateAssetTrustlineForDistributionAccount(ctx cont if assetToRemoveTrustline != nil && strings.ToUpper(assetToRemoveTrustline.Code) != stellarNativeAssetCode { for _, balance := range acc.Balances { if balance.Asset.Code == assetToRemoveTrustline.Code && balance.Asset.Issuer == assetToRemoveTrustline.Issuer { - assetToRemoveTrustlineBalance, err := amount.ParseInt64(balance.Balance) - if err != nil { - return fmt.Errorf("converting asset to remove trustline balance to int64: %w", err) + assetToRemoveTrustlineBalance, parseBalErr := amount.ParseInt64(balance.Balance) + if parseBalErr != nil { + return fmt.Errorf("converting asset to remove trustline balance to int64: %w", parseBalErr) } if assetToRemoveTrustlineBalance > 0 { log.Ctx(ctx).Warnf( @@ -205,7 +226,7 @@ func (c AssetsHandler) handleUpdateAssetTrustlineForDistributionAccount(ctx cont Asset: *assetToRemoveTrustline, }, Limit: "0", // 0 means remove trustline - SourceAccount: distributionAccountPubKey, + SourceAccount: distributionAccount.Address, }) break @@ -238,7 +259,7 @@ func (c AssetsHandler) handleUpdateAssetTrustlineForDistributionAccount(ctx cont Asset: *assetToAddTrustline, }, Limit: "", // empty means no limit - SourceAccount: distributionAccountPubKey, + SourceAccount: distributionAccount.Address, }) } } @@ -249,28 +270,20 @@ func (c AssetsHandler) handleUpdateAssetTrustlineForDistributionAccount(ctx cont return nil } - if err := c.submitChangeTrustTransaction(ctx, &acc, changeTrustOperations); err != nil { + if err = c.submitChangeTrustTransaction(ctx, &acc, changeTrustOperations, distributionAccount); err != nil { return fmt.Errorf("submitting change trust transaction: %w", err) } return nil } -func (c AssetsHandler) submitChangeTrustTransaction(ctx context.Context, acc *horizon.Account, changeTrustOperations []*txnbuild.ChangeTrust) error { +func (c AssetsHandler) submitChangeTrustTransaction( + ctx context.Context, acc *horizon.Account, changeTrustOperations []*txnbuild.ChangeTrust, distributionAccount schema.TransactionAccount, +) error { if len(changeTrustOperations) < 1 { return fmt.Errorf("should have at least one change trust operation") } - // TODO: move it to the beginning of the callers in SDP-1183 - var distributionAccountPubKey string - if distributionAccount, err := c.DistributionAccountResolver.DistributionAccountFromContext(ctx); err != nil { - return fmt.Errorf("resolving distribution account from context: %w", err) - } else if !distributionAccount.IsStellar() { - return fmt.Errorf("expected distribution account to be a STELLAR account but got %q", distributionAccount.Type) - } else { - distributionAccountPubKey = distributionAccount.Address - } - operations := make([]txnbuild.Operation, 0, len(changeTrustOperations)) for _, ctOp := range changeTrustOperations { operations = append(operations, ctOp) @@ -283,7 +296,7 @@ func (c AssetsHandler) submitChangeTrustTransaction(ctx context.Context, acc *ho tx, err := txnbuild.NewTransaction( txnbuild.TransactionParams{ SourceAccount: &txnbuild.SimpleAccount{ - AccountID: distributionAccountPubKey, + AccountID: distributionAccount.Address, Sequence: acc.Sequence, }, IncrementSequenceNum: true, @@ -296,7 +309,7 @@ func (c AssetsHandler) submitChangeTrustTransaction(ctx context.Context, acc *ho return fmt.Errorf("creating change trust transaction: %w", err) } - tx, err = c.DistAccountSigner.SignStellarTransaction(ctx, tx, distributionAccountPubKey) + tx, err = c.SignerRouter.SignStellarTransaction(ctx, tx, distributionAccount) if err != nil { return fmt.Errorf("signing change trust transaction: %w", err) } diff --git a/internal/serve/httphandler/assets_handler_test.go b/internal/serve/httphandler/assets_handler_test.go index 33dd8011d..2b2669239 100644 --- a/internal/serve/httphandler/assets_handler_test.go +++ b/internal/serve/httphandler/assets_handler_test.go @@ -99,7 +99,6 @@ func Test_AssetsHandlerGetAssets(t *testing.T) { func Test_AssetHandler_CreateAsset(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) defer dbConnectionPool.Close() @@ -108,8 +107,9 @@ func Test_AssetHandler_CreateAsset(t *testing.T) { require.NoError(t, err) distributionKP := keypair.MustRandom() + distAccount := schema.NewDefaultStellarTransactionAccount(distributionKP.Address()) horizonClientMock := &horizonclient.MockClient{} - signatureService, _, distAccSigClient, _, distAccResolver := signing.NewMockSignatureService(t) + signatureService, sigRouter, distAccResolver := signing.NewMockSignatureService(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) handler := &AssetsHandler{ @@ -127,9 +127,37 @@ func Test_AssetHandler_CreateAsset(t *testing.T) { issuer := "GBHC5ADV2XYITXCYC5F6X6BM2OYTYHV4ZU2JF6QWJORJQE2O7RKH2LAQ" ctx := context.Background() + t.Run("failed to get distribution account", func(t *testing.T) { + distAccResolver.On("DistributionAccountFromContext", ctx). + Return(schema.TransactionAccount{}, errors.New("foobar")).Once() + + rr := httptest.NewRecorder() + requestBody, _ := json.Marshal(AssetRequest{code, issuer}) + + req, _ := http.NewRequest(http.MethodPost, "/assets", strings.NewReader(string(requestBody))) + http.HandlerFunc(handler.CreateAsset).ServeHTTP(rr, req) + + assert.Equal(t, http.StatusInternalServerError, rr.Result().StatusCode) + assert.Contains(t, rr.Body.String(), "Cannot resolve distribution account from context") + }) + + t.Run("cannot process request if distribution account is not a native-Stellar account", func(t *testing.T) { + distAccResolver.On("DistributionAccountFromContext", ctx). + Return(schema.TransactionAccount{Type: schema.DistributionAccountCircleDBVault}, nil).Once() + + rr := httptest.NewRecorder() + requestBody, _ := json.Marshal(AssetRequest{code, issuer}) + + req, _ := http.NewRequest(http.MethodPost, "/assets", strings.NewReader(string(requestBody))) + http.HandlerFunc(handler.CreateAsset).ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Result().StatusCode) + assert.Contains(t, rr.Body.String(), "Distribution account affiliated with tenant is not a Stellar account") + }) + distAccResolver. On("DistributionAccountFromContext", ctx). - Return(schema.NewDefaultStellarDistributionAccount(distributionKP.Address()), nil) + Return(distAccount, nil) t.Run("successfully create an asset", func(t *testing.T) { getEntries := log.DefaultLogger.StartTest(log.InfoLevel) @@ -162,8 +190,8 @@ func Test_AssetHandler_CreateAsset(t *testing.T) { signedTx, err := tx.Sign(network.TestNetworkPassphrase, distributionKP) require.NoError(t, err) - distAccSigClient. - On("SignStellarTransaction", mock.Anything, tx, distributionKP.Address()). + sigRouter. + On("SignStellarTransaction", mock.Anything, tx, distAccount). Return(signedTx, nil). Once() @@ -348,8 +376,8 @@ func Test_AssetHandler_CreateAsset(t *testing.T) { signedTx, err := tx.Sign(network.TestNetworkPassphrase, distributionKP) require.NoError(t, err) - distAccSigClient. - On("SignStellarTransaction", mock.Anything, tx, distributionKP.Address()). + sigRouter. + On("SignStellarTransaction", mock.Anything, tx, distAccount). Return(signedTx, nil). Twice() @@ -423,8 +451,8 @@ func Test_AssetHandler_CreateAsset(t *testing.T) { signedTx, err := tx.Sign(network.TestNetworkPassphrase, distributionKP) require.NoError(t, err) - distAccSigClient. - On("SignStellarTransaction", mock.Anything, tx, distributionKP.Address()). + sigRouter. + On("SignStellarTransaction", mock.Anything, tx, distAccount). Return(signedTx, nil). Once() @@ -530,8 +558,9 @@ func Test_AssetHandler_DeleteAsset(t *testing.T) { require.NoError(t, err) distributionKP := keypair.MustRandom() + distAccount := schema.NewDefaultStellarTransactionAccount(distributionKP.Address()) horizonClientMock := &horizonclient.MockClient{} - signatureService, _, distAccSigClient, _, distAccResolver := signing.NewMockSignatureService(t) + signatureService, sigRouter, distAccResolver := signing.NewMockSignatureService(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) handler := &AssetsHandler{ @@ -548,9 +577,39 @@ func Test_AssetHandler_DeleteAsset(t *testing.T) { r := chi.NewRouter() r.Delete("/assets/{id}", handler.DeleteAsset) + t.Run("failed to get distribution account", func(t *testing.T) { + data.DeleteAllAssetFixtures(t, ctx, dbConnectionPool) + asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "ABC", "GBHC5ADV2XYITXCYC5F6X6BM2OYTYHV4ZU2JF6QWJORJQE2O7RKH2LAQ") + + distAccResolver.On("DistributionAccountFromContext", mock.AnythingOfType("*context.valueCtx")). + Return(schema.TransactionAccount{}, errors.New("foobar")).Once() + + rr := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodDelete, fmt.Sprintf("/assets/%s", asset.ID), nil) + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusInternalServerError, rr.Result().StatusCode) + assert.Contains(t, rr.Body.String(), "Cannot resolve distribution account from context") + }) + + t.Run("cannot process request if distribution account is not a native-Stellar account", func(t *testing.T) { + data.DeleteAllAssetFixtures(t, ctx, dbConnectionPool) + asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "ABC", "GBHC5ADV2XYITXCYC5F6X6BM2OYTYHV4ZU2JF6QWJORJQE2O7RKH2LAQ") + + distAccResolver.On("DistributionAccountFromContext", mock.AnythingOfType("*context.valueCtx")). + Return(schema.TransactionAccount{Type: schema.DistributionAccountCircleDBVault}, nil).Once() + + rr := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodDelete, fmt.Sprintf("/assets/%s", asset.ID), nil) + r.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Result().StatusCode) + assert.Contains(t, rr.Body.String(), "Distribution account affiliated with tenant is not a Stellar account") + }) + distAccResolver. On("DistributionAccountFromContext", mock.AnythingOfType("*context.valueCtx")). - Return(schema.NewDefaultStellarDistributionAccount(distributionKP.Address()), nil) + Return(distAccount, nil) t.Run("successfully delete an asset and remove the trustline", func(t *testing.T) { data.DeleteAllAssetFixtures(t, ctx, dbConnectionPool) @@ -586,8 +645,8 @@ func Test_AssetHandler_DeleteAsset(t *testing.T) { signedTx, err := tx.Sign(network.TestNetworkPassphrase, distributionKP) require.NoError(t, err) - distAccSigClient. - On("SignStellarTransaction", mock.Anything, tx, distributionKP.Address()). + sigRouter. + On("SignStellarTransaction", mock.Anything, tx, distAccount). Return(signedTx, nil). Once() @@ -738,8 +797,9 @@ func Test_AssetHandler_DeleteAsset(t *testing.T) { func Test_AssetHandler_handleUpdateAssetTrustlineForDistributionAccount(t *testing.T) { distributionKP := keypair.MustRandom() + distAccount := schema.NewDefaultStellarTransactionAccount(distributionKP.Address()) horizonClientMock := &horizonclient.MockClient{} - signatureService, _, distAccSigClient, _, distAccResolver := signing.NewMockSignatureService(t) + signatureService, sigRouter, _ := signing.NewMockSignatureService(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) handler := &AssetsHandler{ @@ -764,29 +824,22 @@ func Test_AssetHandler_handleUpdateAssetTrustlineForDistributionAccount(t *testi ctx := context.Background() + t.Run("returns error if distribution account is not a native Stellar account", func(t *testing.T) { + err := handler.handleUpdateAssetTrustlineForDistributionAccount( + ctx, nil, nil, schema.TransactionAccount{Type: schema.DistributionAccountCircleDBVault}) + assert.EqualError(t, err, "distribution account is not a native Stellar account") + }) + t.Run("returns error if no asset is provided", func(t *testing.T) { - err := handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, nil, nil) + err := handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, nil, nil, distAccount) assert.EqualError(t, err, "should provide at least one asset") }) t.Run("returns error if the assets are the same", func(t *testing.T) { - err := handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, assetToRemoveTrustline, assetToRemoveTrustline) + err := handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, assetToRemoveTrustline, assetToRemoveTrustline, distAccount) assert.EqualError(t, err, "should provide different assets") }) - t.Run("returns error if fails getting distribution account from the resolver", func(t *testing.T) { - distAccResolver. - On("DistributionAccountFromContext", ctx). - Return(nil, errors.New("resolver error")). - Once() - err := handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, assetToAddTrustline, assetToRemoveTrustline) - require.EqualError(t, err, "resolving distribution account from context: resolver error") - }) - - distAccResolver. - On("DistributionAccountFromContext", ctx). - Return(schema.NewDefaultStellarDistributionAccount(distributionKP.Address()), nil) - t.Run("returns error if fails getting distribution account details", func(t *testing.T) { horizonClientMock. On("AccountDetail", horizonclient.AccountRequest{ @@ -803,7 +856,7 @@ func Test_AssetHandler_handleUpdateAssetTrustlineForDistributionAccount(t *testi }). Once() - err := handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, assetToAddTrustline, assetToRemoveTrustline) + err := handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, assetToAddTrustline, assetToRemoveTrustline, distAccount) assert.EqualError(t, err, "getting distribution account details: horizon error: \"Error occurred\" - check horizon.Error.Problem for more information") }) @@ -846,8 +899,8 @@ func Test_AssetHandler_handleUpdateAssetTrustlineForDistributionAccount(t *testi signedTx, err := tx.Sign(network.TestNetworkPassphrase, distributionKP) require.NoError(t, err) - distAccSigClient. - On("SignStellarTransaction", ctx, tx, distributionKP.Address()). + sigRouter. + On("SignStellarTransaction", ctx, tx, distAccount). Return(signedTx, nil). Once() @@ -894,7 +947,7 @@ func Test_AssetHandler_handleUpdateAssetTrustlineForDistributionAccount(t *testi }). Once() - err = handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, assetToAddTrustline, assetToRemoveTrustline) + err = handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, assetToAddTrustline, assetToRemoveTrustline, distAccount) assert.EqualError(t, err, "submitting change trust transaction: submitting change trust transaction to network: horizon response error: StatusCode=0, Extras=transaction: tx_failed - operation codes: [ op_no_issuer ]") }) @@ -937,8 +990,8 @@ func Test_AssetHandler_handleUpdateAssetTrustlineForDistributionAccount(t *testi signedTx, err := tx.Sign(network.TestNetworkPassphrase, distributionKP) require.NoError(t, err) - distAccSigClient. - On("SignStellarTransaction", ctx, tx, distributionKP.Address()). + sigRouter. + On("SignStellarTransaction", ctx, tx, distAccount). Return(signedTx, nil). Once() @@ -973,7 +1026,7 @@ func Test_AssetHandler_handleUpdateAssetTrustlineForDistributionAccount(t *testi Return(horizon.Transaction{}, nil). Once() - err = handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, assetToAddTrustline, assetToRemoveTrustline) + err = handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, assetToAddTrustline, assetToRemoveTrustline, distAccount) assert.NoError(t, err) }) @@ -1006,7 +1059,7 @@ func Test_AssetHandler_handleUpdateAssetTrustlineForDistributionAccount(t *testi }, nil). Once() - err := handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, assetToAddTrustline, assetToRemoveTrustline) + err := handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, assetToAddTrustline, assetToRemoveTrustline, distAccount) assert.EqualError(t, err, errCouldNotRemoveTrustline.Error()) }) @@ -1033,7 +1086,7 @@ func Test_AssetHandler_handleUpdateAssetTrustlineForDistributionAccount(t *testi }, nil). Once() - err := handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, nil, assetToRemoveTrustline) + err := handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, nil, assetToRemoveTrustline, distAccount) assert.NoError(t, err) entries := getEntries() @@ -1070,7 +1123,7 @@ func Test_AssetHandler_handleUpdateAssetTrustlineForDistributionAccount(t *testi }, nil). Once() - err := handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, assetToAddTrustline, nil) + err := handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, assetToAddTrustline, nil, distAccount) assert.NoError(t, err) }) @@ -1111,7 +1164,7 @@ func Test_AssetHandler_handleUpdateAssetTrustlineForDistributionAccount(t *testi // add trustline getEntries := log.DefaultLogger.StartTest(log.WarnLevel) - err := handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, nativeAsset, nil) + err := handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, nativeAsset, nil, distAccount) require.NoError(t, err) entries := getEntries() @@ -1121,7 +1174,7 @@ func Test_AssetHandler_handleUpdateAssetTrustlineForDistributionAccount(t *testi // remove trustline getEntries = log.DefaultLogger.StartTest(log.WarnLevel) - err = handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, nil, nativeAsset) + err = handler.handleUpdateAssetTrustlineForDistributionAccount(ctx, nil, nativeAsset, distAccount) require.NoError(t, err) entries = getEntries() @@ -1134,8 +1187,9 @@ func Test_AssetHandler_handleUpdateAssetTrustlineForDistributionAccount(t *testi func Test_AssetHandler_submitChangeTrustTransaction(t *testing.T) { distributionKP := keypair.MustRandom() + distAccount := schema.NewDefaultStellarTransactionAccount(distributionKP.Address()) horizonClientMock := &horizonclient.MockClient{} - signatureService, _, distAccSigClient, _, distAccResolver := signing.NewMockSignatureService(t) + signatureService, sigRouter, _ := signing.NewMockSignatureService(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) handler := &AssetsHandler{ @@ -1177,23 +1231,10 @@ func Test_AssetHandler_submitChangeTrustTransaction(t *testing.T) { ctx := context.Background() t.Run("returns error if no change trust operations is passed", func(t *testing.T) { - err := handler.submitChangeTrustTransaction(ctx, acc, []*txnbuild.ChangeTrust{}) + err := handler.submitChangeTrustTransaction(ctx, acc, []*txnbuild.ChangeTrust{}, distAccount) assert.EqualError(t, err, "should have at least one change trust operation") }) - t.Run("returns error if fails getting distribution account from the resolver", func(t *testing.T) { - distAccResolver. - On("DistributionAccountFromContext", ctx). - Return(nil, errors.New("resolver error")). - Once() - err := handler.submitChangeTrustTransaction(ctx, acc, []*txnbuild.ChangeTrust{{}}) - require.EqualError(t, err, "resolving distribution account from context: resolver error") - }) - - distAccResolver. - On("DistributionAccountFromContext", ctx). - Return(schema.NewDefaultStellarDistributionAccount(distributionKP.Address()), nil) - t.Run("returns error when fails signing transaction", func(t *testing.T) { tx, err := txnbuild.NewTransaction( txnbuild.TransactionParams{ @@ -1220,8 +1261,8 @@ func Test_AssetHandler_submitChangeTrustTransaction(t *testing.T) { ) require.NoError(t, err) - distAccSigClient. - On("SignStellarTransaction", ctx, tx, distributionKP.Address()). + sigRouter. + On("SignStellarTransaction", ctx, tx, distAccount). Return(nil, errors.New("unexpected error")). Once() @@ -1236,7 +1277,7 @@ func Test_AssetHandler_submitChangeTrustTransaction(t *testing.T) { Limit: "", SourceAccount: distributionKP.Address(), }, - }) + }, distAccount) assert.EqualError(t, err, "signing change trust transaction: unexpected error") }) @@ -1269,8 +1310,8 @@ func Test_AssetHandler_submitChangeTrustTransaction(t *testing.T) { signedTx, err := tx.Sign(network.TestNetworkPassphrase, distributionKP) require.NoError(t, err) - distAccSigClient. - On("SignStellarTransaction", ctx, tx, distributionKP.Address()). + sigRouter. + On("SignStellarTransaction", ctx, tx, distAccount). Return(signedTx, nil). Once() @@ -1303,7 +1344,7 @@ func Test_AssetHandler_submitChangeTrustTransaction(t *testing.T) { Limit: "", SourceAccount: distributionKP.Address(), }, - }) + }, distAccount) assert.EqualError(t, err, "submitting change trust transaction to network: horizon response error: StatusCode=400, Extras=transaction: tx_failed - operation codes: [ op_no_issuer ]") }) @@ -1336,8 +1377,8 @@ func Test_AssetHandler_submitChangeTrustTransaction(t *testing.T) { signedTx, err := tx.Sign(network.TestNetworkPassphrase, distributionKP) require.NoError(t, err) - distAccSigClient. - On("SignStellarTransaction", ctx, tx, distributionKP.Address()). + sigRouter. + On("SignStellarTransaction", ctx, tx, distAccount). Return(signedTx, nil). Once() @@ -1357,7 +1398,7 @@ func Test_AssetHandler_submitChangeTrustTransaction(t *testing.T) { Limit: "", SourceAccount: distributionKP.Address(), }, - }) + }, distAccount) assert.NoError(t, err) }) @@ -1366,25 +1407,21 @@ func Test_AssetHandler_submitChangeTrustTransaction(t *testing.T) { type assetTestMock struct { SignatureService signing.SignatureService - DistAccSigClient *sigMocks.MockSignatureClient + SignatureRouter *sigMocks.MockSignerRouter HorizonClientMock *horizonclient.MockClient Handler AssetsHandler } -func newAssetTestMock(t *testing.T, distributionAccountAddress string) *assetTestMock { +func newAssetTestMock(t *testing.T) *assetTestMock { t.Helper() horizonClientMock := &horizonclient.MockClient{} - signatureService, _, distAccSigClient, _, distAccResolver := signing.NewMockSignatureService(t) - distAccResolver. - On("DistributionAccountFromContext", mock.Anything). - Return(schema.NewDefaultStellarDistributionAccount(distributionAccountAddress), nil) - + signatureService, sigRouter, _ := signing.NewMockSignatureService(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) return &assetTestMock{ SignatureService: signatureService, - DistAccSigClient: distAccSigClient, + SignatureRouter: sigRouter, HorizonClientMock: horizonClientMock, Handler: AssetsHandler{ SubmitterEngine: engine.SubmitterEngine{ @@ -1400,9 +1437,10 @@ func newAssetTestMock(t *testing.T, distributionAccountAddress string) *assetTes func Test_AssetHandler_submitChangeTrustTransaction_makeSurePreconditionsAreSetAsExpected(t *testing.T) { ctx := context.Background() distributionKP := keypair.MustRandom() + distAccount := schema.NewDefaultStellarTransactionAccount(distributionKP.Address()) // matchPreconditionsTimeboundsFn is a function meant to be used with mock.MatchedBy to check that the preconditions are set as expected. - assertExpectedPreconditionsWithTimeboundsTolerance := func(expectedTx *txnbuild.Transaction, actualTxIndex int) func(args mock.Arguments) { + assertExpectedPreconditionsWithTimeboundsTolerance := func(t *testing.T, expectedTx *txnbuild.Transaction, actualTxIndex int) func(args mock.Arguments) { return func(args mock.Arguments) { actualTx, ok := args.Get(int(actualTxIndex)).(*txnbuild.Transaction) require.True(t, ok) @@ -1451,7 +1489,7 @@ func Test_AssetHandler_submitChangeTrustTransaction_makeSurePreconditionsAreSetA } t.Run("makes sure a non-empty precondition is used if none is explicitly set", func(t *testing.T) { - mocks := newAssetTestMock(t, distributionKP.Address()) + mocks := newAssetTestMock(t) mocks.Handler.GetPreconditionsFn = nil txParams := txParamsWithoutPreconditions @@ -1462,25 +1500,25 @@ func Test_AssetHandler_submitChangeTrustTransaction_makeSurePreconditionsAreSetA signedTx, err := tx.Sign(network.TestNetworkPassphrase, distributionKP) require.NoError(t, err) - mocks.DistAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), distributionKP.Address()). - Run(assertExpectedPreconditionsWithTimeboundsTolerance(signedTx, 1)). + mocks.SignatureRouter. + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), distAccount). + Run(assertExpectedPreconditionsWithTimeboundsTolerance(t, signedTx, 1)). Return(signedTx, nil). Once() mocks.HorizonClientMock. On("SubmitTransactionWithOptions", mock.AnythingOfType("*txnbuild.Transaction"), horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}). - Run(assertExpectedPreconditionsWithTimeboundsTolerance(signedTx, 0)). + Run(assertExpectedPreconditionsWithTimeboundsTolerance(t, signedTx, 0)). Return(horizon.Transaction{}, nil). Once() defer mocks.HorizonClientMock.AssertExpectations(t) - err = mocks.Handler.submitChangeTrustTransaction(ctx, acc, []*txnbuild.ChangeTrust{changeTrustOp}) + err = mocks.Handler.submitChangeTrustTransaction(ctx, acc, []*txnbuild.ChangeTrust{changeTrustOp}, distAccount) assert.NoError(t, err) }) t.Run("makes sure a the precondition that was set is used", func(t *testing.T) { - mocks := newAssetTestMock(t, distributionKP.Address()) + mocks := newAssetTestMock(t) newPreconditions := txnbuild.Preconditions{TimeBounds: txnbuild.NewTimeout(int64(rand.Intn(999999999)))} mocks.Handler.GetPreconditionsFn = func() txnbuild.Preconditions { return newPreconditions } @@ -1492,20 +1530,20 @@ func Test_AssetHandler_submitChangeTrustTransaction_makeSurePreconditionsAreSetA signedTx, err := tx.Sign(network.TestNetworkPassphrase, distributionKP) require.NoError(t, err) - mocks.DistAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), distributionKP.Address()). - Run(assertExpectedPreconditionsWithTimeboundsTolerance(signedTx, 1)). + mocks.SignatureRouter. + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), distAccount). + Run(assertExpectedPreconditionsWithTimeboundsTolerance(t, signedTx, 1)). Return(signedTx, nil). Once() mocks.HorizonClientMock. On("SubmitTransactionWithOptions", mock.AnythingOfType("*txnbuild.Transaction"), horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}). Return(horizon.Transaction{}, nil). - Run(assertExpectedPreconditionsWithTimeboundsTolerance(signedTx, 0)). + Run(assertExpectedPreconditionsWithTimeboundsTolerance(t, signedTx, 0)). Once() defer mocks.HorizonClientMock.AssertExpectations(t) - err = mocks.Handler.submitChangeTrustTransaction(ctx, acc, []*txnbuild.ChangeTrust{changeTrustOp}) + err = mocks.Handler.submitChangeTrustTransaction(ctx, acc, []*txnbuild.ChangeTrust{changeTrustOp}, distAccount) assert.NoError(t, err) }) } diff --git a/internal/serve/httphandler/balances_handler.go b/internal/serve/httphandler/balances_handler.go new file mode 100644 index 000000000..64bb958d0 --- /dev/null +++ b/internal/serve/httphandler/balances_handler.go @@ -0,0 +1,103 @@ +package httphandler + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/stellar/go/support/log" + "github.com/stellar/go/support/render/httpjson" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" +) + +type Balance struct { + Amount string `json:"amount"` + AssetCode string `json:"asset_code"` + AssetIssuer string `json:"asset_issuer"` +} + +type GetBalanceResponse struct { + Account schema.TransactionAccount `json:"account"` + Balances []Balance `json:"balances"` +} + +type BalancesHandler struct { + DistributionAccountResolver signing.DistributionAccountResolver + CircleService circle.ServiceInterface + NetworkType utils.NetworkType +} + +// Get returns the balances of the distribution account. +func (h BalancesHandler) Get(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + distAccount, err := h.DistributionAccountResolver.DistributionAccountFromContext(ctx) + if err != nil { + httperror.InternalError(ctx, "Cannot retrieve distribution account", err, nil).Render(w) + return + } + + if !distAccount.IsCircle() { + errResponseMsg := fmt.Sprintf("This endpoint is only available for tenants using %v", schema.CirclePlatform) + httperror.BadRequest(errResponseMsg, nil, nil).Render(w) + return + } + + if distAccount.Status != schema.AccountStatusActive { + errResponseMsg := fmt.Sprintf("This organization's distribution account is in %s state, please complete the %s activation process to access this endpoint.", distAccount.Status, distAccount.Type.Platform()) + httperror.BadRequest(errResponseMsg, nil, nil).Render(w) + return + } + + circleWallet, err := h.CircleService.GetWalletByID(ctx, distAccount.CircleWalletID) + if err != nil { + wrapCircleError(ctx, err).Render(w) + return + } + + balances := h.filterBalances(ctx, circleWallet) + + response := GetBalanceResponse{ + Account: distAccount, + Balances: balances, + } + httpjson.Render(w, response, httpjson.JSON) +} + +func (h BalancesHandler) filterBalances(ctx context.Context, circleWallet *circle.Wallet) []Balance { + balances := []Balance{} + for _, balance := range circleWallet.Balances { + asset, err := circle.ParseStellarAsset(balance.Currency, h.NetworkType) + if err != nil { + log.Ctx(ctx).Debugf("Ignoring balance for asset %s, as it's not supported by the SDP: %v", balance.Currency, err) + continue + } + + balances = append(balances, Balance{ + Amount: balance.Amount, + AssetCode: asset.Code, + AssetIssuer: asset.Issuer, + }) + } + return balances +} + +func wrapCircleError(ctx context.Context, err error) *httperror.HTTPError { + if err == nil { + return nil + } + + var circleAPIErr *circle.APIError + if errors.As(err, &circleAPIErr) { + extras := map[string]interface{}{"circle_errors": circleAPIErr.Errors} + msg := fmt.Sprintf("Cannot complete Circle request: %s", circleAPIErr.Message) + return httperror.BadRequest(msg, circleAPIErr, extras) + } + return httperror.InternalError(ctx, "Cannot complete Circle request", err, nil) +} diff --git a/internal/serve/httphandler/balances_handler_test.go b/internal/serve/httphandler/balances_handler_test.go new file mode 100644 index 000000000..0212d5f5c --- /dev/null +++ b/internal/serve/httphandler/balances_handler_test.go @@ -0,0 +1,374 @@ +package httphandler + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" + sigMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" +) + +func Test_BalancesHandler_Get(t *testing.T) { + circleAPIError := &circle.APIError{ + Code: 400, + Message: "some circle error", + Errors: []circle.APIErrorDetail{ + { + Error: "some error", + Message: "some message", + Location: "some location", + }, + }, + } + + testCases := []struct { + name string + networkType utils.NetworkType + prepareMocks func(t *testing.T, mCircleService *circle.MockService, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) + expectedStatus int + expectedResponse string + }{ + { + name: "returns a 500 error in DistributionAccountResolver", + networkType: utils.TestnetNetworkType, + prepareMocks: func(t *testing.T, mCircleService *circle.MockService, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) { + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{}, errors.New("distribution account error")). + Once() + }, + expectedStatus: http.StatusInternalServerError, + expectedResponse: `{"error":"Cannot retrieve distribution account"}`, + }, + { + name: "returns a 400 error if the distribution account is not Circle", + networkType: utils.TestnetNetworkType, + prepareMocks: func(t *testing.T, mCircleService *circle.MockService, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) { + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarEnv}, nil). + Once() + }, + expectedStatus: http.StatusBadRequest, + expectedResponse: fmt.Sprintf(`{"error":"This endpoint is only available for tenants using %v"}`, schema.CirclePlatform), + }, + { + name: "propagate Circle API error if GetWalletByID fails", + networkType: utils.TestnetNetworkType, + prepareMocks: func(t *testing.T, mCircleService *circle.MockService, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) { + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{ + Type: schema.DistributionAccountCircleDBVault, + CircleWalletID: "circle-wallet-id", + Status: schema.AccountStatusActive, + }, nil). + Once() + + mCircleService. + On("GetWalletByID", mock.Anything, "circle-wallet-id"). + Return(nil, fmt.Errorf("wrapped error: %w", circleAPIError)). + Once() + }, + expectedStatus: circleAPIError.Code, + expectedResponse: `{ + "error": "Cannot complete Circle request: some circle error", + "extras": { + "circle_errors": [ + { + "error": "some error", + "message": "some message", + "location": "some location" + } + ] + } + }`, + }, + { + name: "returns a 400 if account status is PENDING_USER_ACTIVATION", + networkType: utils.TestnetNetworkType, + prepareMocks: func(t *testing.T, mCircleService *circle.MockService, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) { + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{ + Type: schema.DistributionAccountCircleDBVault, + Status: schema.AccountStatusPendingUserActivation, + }, nil). + Once() + }, + expectedStatus: http.StatusBadRequest, + expectedResponse: `{"error": "This organization's distribution account is in PENDING_USER_ACTIVATION state, please complete the CIRCLE activation process to access this endpoint."}`, + }, + { + name: "returns a 500 if circle.GetWalletByID fails with an unexpected error", + networkType: utils.TestnetNetworkType, + prepareMocks: func(t *testing.T, mCircleService *circle.MockService, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) { + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{ + Type: schema.DistributionAccountCircleDBVault, + CircleWalletID: "circle-wallet-id", + Status: schema.AccountStatusActive, + }, nil). + Once() + mCircleService. + On("GetWalletByID", mock.Anything, "circle-wallet-id"). + Return(nil, errors.New("unexpected error")). + Once() + }, + expectedStatus: http.StatusInternalServerError, + expectedResponse: `{"error": "Cannot complete Circle request"}`, + }, + { + name: "[Testnet] 🎉 successfully returns balances", + networkType: utils.TestnetNetworkType, + prepareMocks: func(t *testing.T, mCircleService *circle.MockService, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) { + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{ + Type: schema.DistributionAccountCircleDBVault, + CircleWalletID: "circle-wallet-id", + Status: schema.AccountStatusActive, + }, nil). + Once() + mCircleService. + On("GetWalletByID", mock.Anything, "circle-wallet-id"). + Return(&circle.Wallet{ + WalletID: "test-id", + EntityID: "2f47c999-9022-4939-acea-dc3afa9ccbaf", + Type: "end_user_wallet", + Description: "Treasury Wallet", + Balances: []circle.Balance{ + {Amount: "123.00", Currency: "USD"}, + }, + }, nil). + Once() + }, + expectedStatus: http.StatusOK, + expectedResponse: `{ + "account": { + "circle_wallet_id": "circle-wallet-id", + "type": "DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT", + "status": "ACTIVE" + }, + "balances": [{ + "amount": "123.00", + "asset_code": "USDC", + "asset_issuer": "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" + }] + }`, + }, + { + name: "[Pubnet] 🎉 successfully returns balances", + networkType: utils.PubnetNetworkType, + prepareMocks: func(t *testing.T, mCircleService *circle.MockService, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) { + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{ + Type: schema.DistributionAccountCircleDBVault, + CircleWalletID: "circle-wallet-id", + Status: schema.AccountStatusActive, + }, nil). + Once() + mCircleService. + On("GetWalletByID", mock.Anything, "circle-wallet-id"). + Return(&circle.Wallet{ + WalletID: "test-id", + EntityID: "2f47c999-9022-4939-acea-dc3afa9ccbaf", + Type: "end_user_wallet", + Description: "Treasury Wallet", + Balances: []circle.Balance{ + {Amount: "123.00", Currency: "USD"}, + }, + }, nil). + Once() + }, + expectedStatus: http.StatusOK, + expectedResponse: `{ + "account": { + "circle_wallet_id": "circle-wallet-id", + "type": "DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT", + "status": "ACTIVE" + }, + "balances": [{ + "amount": "123.00", + "asset_code": "USDC", + "asset_issuer": "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" + }] + }`, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mDistributionAccountResolver := sigMocks.NewMockDistributionAccountResolver(t) + mCircleService := circle.NewMockService(t) + tc.prepareMocks(t, mCircleService, mDistributionAccountResolver) + + h := BalancesHandler{ + DistributionAccountResolver: mDistributionAccountResolver, + NetworkType: tc.networkType, + CircleService: mCircleService, + } + + rr := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/balances", nil) + require.NoError(t, err) + http.HandlerFunc(h.Get).ServeHTTP(rr, req) + resp := rr.Result() + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, tc.expectedStatus, resp.StatusCode) + assert.JSONEq(t, tc.expectedResponse, string(respBody)) + }) + } +} + +func Test_BalancesHandler_filterBalances(t *testing.T) { + ctx := context.Background() + + testCases := []struct { + name string + networkType utils.NetworkType + circleWallet *circle.Wallet + expectedBalances []Balance + }{ + { + name: "[Pubnet] only supported assets are included", + networkType: utils.PubnetNetworkType, + circleWallet: &circle.Wallet{ + Balances: []circle.Balance{ + {Currency: "FOO", Amount: "100"}, + {Currency: "USD", Amount: "200"}, + {Currency: "BAR", Amount: "300"}, + {Currency: "EUR", Amount: "400"}, + }, + }, + expectedBalances: []Balance{ + { + Amount: "200", + AssetCode: assets.USDCAssetPubnet.Code, + AssetIssuer: assets.USDCAssetPubnet.Issuer, + }, + { + Amount: "400", + AssetCode: assets.EURCAssetPubnet.Code, + AssetIssuer: assets.EURCAssetPubnet.Issuer, + }, + }, + }, + { + name: "[Testnet] only supported assets are included", + networkType: utils.TestnetNetworkType, + circleWallet: &circle.Wallet{ + Balances: []circle.Balance{ + {Currency: "FOO", Amount: "100"}, + {Currency: "USD", Amount: "200"}, + {Currency: "BAR", Amount: "300"}, + {Currency: "EUR", Amount: "400"}, + }, + }, + expectedBalances: []Balance{ + { + Amount: "200", + AssetCode: assets.USDCAssetTestnet.Code, + AssetIssuer: assets.USDCAssetTestnet.Issuer, + }, + { + Amount: "400", + AssetCode: assets.EURCAssetTestnet.Code, + AssetIssuer: assets.EURCAssetTestnet.Issuer, + }, + }, + }, + { + name: "[Pubnet] none of the provided assets is supported", + networkType: utils.PubnetNetworkType, + circleWallet: &circle.Wallet{ + Balances: []circle.Balance{ + {Currency: "FOO", Amount: "100"}, + }, + }, + expectedBalances: []Balance{}, + }, + { + name: "[Testnet] none of the provided assets is supported", + networkType: utils.TestnetNetworkType, + circleWallet: &circle.Wallet{ + Balances: []circle.Balance{ + {Currency: "FOO", Amount: "100"}, + }, + }, + expectedBalances: []Balance{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + h := BalancesHandler{NetworkType: tc.networkType} + + actualBalances := h.filterBalances(ctx, tc.circleWallet) + + assert.Equal(t, tc.expectedBalances, actualBalances) + }) + } +} + +func Test_wrapCircleError(t *testing.T) { + circleAPIError := &circle.APIError{ + Code: 400, + Message: "some circle error", + Errors: []circle.APIErrorDetail{ + { + Error: "some error", + Message: "some message", + Location: "some location", + }, + }, + } + + ctx := context.Background() + testCases := []struct { + name string + err error + wantHTTPError *httperror.HTTPError + }{ + { + name: "nil error", + err: nil, + wantHTTPError: nil, + }, + { + name: "unexpected error", + err: errors.New("unexpected error"), + wantHTTPError: httperror.InternalError(ctx, "Cannot complete Circle request", errors.New("unexpected error"), nil), + }, + { + name: "circle.APIError", + err: circleAPIError, + wantHTTPError: httperror.BadRequest("Cannot complete Circle request: some circle error", circleAPIError, map[string]interface{}{"circle_errors": circleAPIError.Errors}), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actualHTTPError := wrapCircleError(ctx, tc.err) + assert.Equal(t, tc.wantHTTPError, actualHTTPError) + }) + } +} diff --git a/internal/serve/httphandler/circle_config_handler.go b/internal/serve/httphandler/circle_config_handler.go new file mode 100644 index 000000000..1aad847e6 --- /dev/null +++ b/internal/serve/httphandler/circle_config_handler.go @@ -0,0 +1,186 @@ +package httphandler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/stellar/go/keypair" + "github.com/stellar/go/support/render/httpjson" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" +) + +// CircleConfigHandler implements a handler to configure the Circle API access. +type CircleConfigHandler struct { + NetworkType sdpUtils.NetworkType + CircleFactory circle.ClientFactory + TenantManager tenant.ManagerInterface + Encrypter sdpUtils.PrivateKeyEncrypter + EncryptionPassphrase string + CircleClientConfigModel circle.ClientConfigModelInterface + DistributionAccountResolver signing.DistributionAccountResolver +} + +type PatchCircleConfigRequest struct { + WalletID *string `json:"wallet_id"` + APIKey *string `json:"api_key"` +} + +// validate validates the request. +func (r PatchCircleConfigRequest) validate() error { + if r.WalletID == nil && r.APIKey == nil { + return fmt.Errorf("wallet_id or api_key must be provided") + } + return nil +} + +// Patch is a handler to configure the Circle API access. +func (h CircleConfigHandler) Patch(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + tnt, err := tenant.GetTenantFromContext(ctx) + if err != nil { + httperror.InternalError(ctx, "Cannot retrieve the tenant from the context", err, nil).Render(w) + return + } + + distAccount, err := h.DistributionAccountResolver.DistributionAccountFromContext(ctx) + if err != nil { + httperror.InternalError(ctx, "Cannot retrieve distribution account", err, nil).Render(w) + return + } + + if !distAccount.IsCircle() { + errResponseMsg := fmt.Sprintf("This endpoint is only available for tenants using %v", schema.CirclePlatform) + httperror.BadRequest(errResponseMsg, nil, nil).Render(w) + return + } + + var patchRequest PatchCircleConfigRequest + err = json.NewDecoder(r.Body).Decode(&patchRequest) + if err != nil { + httperror.BadRequest("Request body is not valid", err, nil).Render(w) + return + } + + if err = patchRequest.validate(); err != nil { + extras := map[string]interface{}{"validation_error": err.Error()} + httperror.BadRequest("Request body is not valid", err, extras).Render(w) + return + } + + validationErr := h.validateConfigWithCircle(ctx, patchRequest) + if validationErr != nil { + validationErr.Render(w) + return + } + + var clientConfigUpdate circle.ClientConfigUpdate + if patchRequest.APIKey != nil { + kp, kpErr := keypair.ParseFull(h.EncryptionPassphrase) + if kpErr != nil { + httperror.InternalError(ctx, "Cannot parse the encryption keypair", kpErr, nil).Render(w) + return + } + + encryptedAPIKey, encryptErr := h.Encrypter.Encrypt(*patchRequest.APIKey, kp.Seed()) + if encryptErr != nil { + httperror.InternalError(ctx, "Cannot encrypt the API key", encryptErr, nil).Render(w) + return + } + clientConfigUpdate.EncryptedAPIKey = &encryptedAPIKey + encrypterPublicKey := kp.Address() + clientConfigUpdate.EncrypterPublicKey = &encrypterPublicKey + } + + if patchRequest.WalletID != nil { + clientConfigUpdate.WalletID = patchRequest.WalletID + } + + err = h.CircleClientConfigModel.Upsert(ctx, clientConfigUpdate) + if err != nil { + httperror.InternalError(ctx, "Cannot insert the Circle configuration", err, nil).Render(w) + return + } + + // Update tenant status to active + _, err = h.TenantManager.UpdateTenantConfig(ctx, &tenant.TenantUpdate{ + ID: tnt.ID, + DistributionAccountStatus: schema.AccountStatusActive, + }) + if err != nil { + httperror.InternalError(ctx, "Could not update the tenant status to ACTIVE", err, nil).Render(w) + return + } + + httpjson.RenderStatus(w, http.StatusOK, map[string]string{"message": "Circle configuration updated"}, httpjson.JSON) +} + +func (h CircleConfigHandler) validateConfigWithCircle(ctx context.Context, patchRequest PatchCircleConfigRequest) *httperror.HTTPError { + if err := patchRequest.validate(); err != nil { + return httperror.BadRequest("Request body is not valid", err, nil) + } + + // Use the request values for walletID and apiKey, if they were provided. + var walletID, apiKey string + if patchRequest.APIKey != nil { + apiKey = *patchRequest.APIKey + } + if patchRequest.WalletID != nil { + walletID = *patchRequest.WalletID + } + + // If walletID or apiKey are not provided, try to get them from the existing configuration. + if walletID == "" || apiKey == "" { + existingConfig, err := h.CircleClientConfigModel.Get(ctx) + if err != nil { + return httperror.InternalError(ctx, "Cannot retrieve the existing Circle configuration", err, nil) + } + + if existingConfig == nil { + return httperror.BadRequest("You must provide both the Circle walletID and Circle APIKey during the first configuration", nil, nil) + } + + if walletID == "" && existingConfig.WalletID != nil { // walletID is not provided but exists in the DB + walletID = *existingConfig.WalletID + } + + if apiKey == "" && existingConfig.EncryptedAPIKey != nil { // apiKey is not provided but exists in the DB + apiKey, err = h.Encrypter.Decrypt(*existingConfig.EncryptedAPIKey, h.EncryptionPassphrase) + if err != nil { + return httperror.InternalError(ctx, "Cannot decrypt the API key", err, nil) + } + } + } + + circleClient := h.CircleFactory(h.NetworkType, apiKey, h.TenantManager) + + // validate incoming APIKey + if patchRequest.APIKey != nil { + ok, err := circleClient.Ping(ctx) + if err != nil { + return wrapCircleError(ctx, err) + } + + if !ok { + return httperror.BadRequest("Failed to ping, please make sure that the provided API Key is correct.", nil, nil) + } + } + + // validate incoming WalletID + if patchRequest.WalletID != nil { + _, err := circleClient.GetWalletByID(ctx, walletID) + if err != nil { + return wrapCircleError(ctx, err) + } + } + + return nil +} diff --git a/internal/serve/httphandler/circle_config_handler_test.go b/internal/serve/httphandler/circle_config_handler_test.go new file mode 100644 index 000000000..7555ddad6 --- /dev/null +++ b/internal/serve/httphandler/circle_config_handler_test.go @@ -0,0 +1,382 @@ +package httphandler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stellar/go/keypair" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" + "github.com/stellar/stellar-disbursement-platform-backend/internal/testutils" + sigMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" +) + +func TestCircleConfigHandler_Patch(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + // Creates a tenant and inserts it in the context + tnt := tenant.Tenant{ID: "test-tenant-id"} + ctx := tenant.SaveTenantInContext(context.Background(), &tnt) + + kp := keypair.MustRandom() + encryptionPassphrase := kp.Seed() + encryptionPublicKey := kp.Address() + + ccm := circle.ClientConfigModel{DBConnectionPool: dbConnectionPool} + encrypter := utils.DefaultPrivateKeyEncrypter{} + + validPatchRequest := PatchCircleConfigRequest{ + APIKey: utils.StringPtr("new_api_key"), + WalletID: utils.StringPtr("new_wallet_id"), + } + validRequestBody, err := json.Marshal(validPatchRequest) + require.NoError(t, err) + + invalidRequestBody, err := json.Marshal(PatchCircleConfigRequest{}) + require.NoError(t, err) + + testCases := []struct { + name string + prepareMocksFn func(t *testing.T, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver, mCircleClient *circle.MockClient, mTenantManager *tenant.TenantManagerMock) + requestBody string + statusCode int + assertions func(t *testing.T, rr *httptest.ResponseRecorder) + }{ + { + name: "returns bad request if distribution account type is not Circle", + prepareMocksFn: func(t *testing.T, mDistAccResolver *sigMocks.MockDistributionAccountResolver, mCircleClient *circle.MockClient, mTenantManager *tenant.TenantManagerMock) { + t.Helper() + mDistAccResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarEnv}, nil). + Once() + }, + requestBody: string(validRequestBody), + statusCode: http.StatusBadRequest, + assertions: func(t *testing.T, rr *httptest.ResponseRecorder) { + t.Helper() + + assert.JSONEq(t, `{"error": "This endpoint is only available for tenants using CIRCLE"}`, rr.Body.String()) + }, + }, + { + name: "returns bad request for invalid request json body", + prepareMocksFn: func(t *testing.T, mDistAccResolver *sigMocks.MockDistributionAccountResolver, mCircleClient *circle.MockClient, mTenantManager *tenant.TenantManagerMock) { + t.Helper() + mDistAccResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountCircleDBVault}, nil). + Once() + }, + requestBody: "invalid json", + statusCode: http.StatusBadRequest, + assertions: func(t *testing.T, rr *httptest.ResponseRecorder) { + t.Helper() + + assert.JSONEq(t, `{"error": "Request body is not valid"}`, rr.Body.String()) + }, + }, + { + name: "returns bad request for invalid patch request data", + prepareMocksFn: func(t *testing.T, mDistAccResolver *sigMocks.MockDistributionAccountResolver, mCircleClient *circle.MockClient, mTenantManager *tenant.TenantManagerMock) { + t.Helper() + mDistAccResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountCircleDBVault}, nil). + Once() + }, + requestBody: string(invalidRequestBody), + statusCode: http.StatusBadRequest, + assertions: func(t *testing.T, rr *httptest.ResponseRecorder) { + t.Helper() + + assert.JSONEq(t, `{"error":"Request body is not valid", "extras":{"validation_error":"wallet_id or api_key must be provided"}}`, rr.Body.String()) + }, + }, + { + name: "returns an error if Circle client ping fails", + prepareMocksFn: func(t *testing.T, mDistAccResolver *sigMocks.MockDistributionAccountResolver, mCircleClient *circle.MockClient, mTenantManager *tenant.TenantManagerMock) { + t.Helper() + mDistAccResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountCircleDBVault}, nil). + Once() + + mCircleClient. + On("Ping", mock.Anything). + Return(false, nil). + Once() + }, + requestBody: string(validRequestBody), + statusCode: http.StatusBadRequest, + assertions: func(t *testing.T, rr *httptest.ResponseRecorder) { + t.Helper() + + assert.JSONEq(t, `{"error":"Failed to ping, please make sure that the provided API Key is correct."}`, rr.Body.String()) + }, + }, + { + name: "🎉 successfully updates Circle configuration and the tenant DistributionAccountStatus", + prepareMocksFn: func(t *testing.T, mDistAccResolver *sigMocks.MockDistributionAccountResolver, mCircleClient *circle.MockClient, mTenantManager *tenant.TenantManagerMock) { + t.Helper() + mDistAccResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountCircleDBVault}, nil). + Once() + + mCircleClient. + On("Ping", mock.Anything). + Return(true, nil). + Once() + mCircleClient. + On("GetWalletByID", mock.Anything, "new_wallet_id"). + Return(&circle.Wallet{WalletID: "new_wallet_id"}, nil). + Once() + + mTenantManager. + On("UpdateTenantConfig", mock.Anything, &tenant.TenantUpdate{ + ID: "test-tenant-id", + DistributionAccountStatus: schema.AccountStatusActive, + }). + Return(&tenant.Tenant{}, nil). + Once() + }, + requestBody: string(validRequestBody), + statusCode: http.StatusOK, + assertions: func(t *testing.T, rr *httptest.ResponseRecorder) { + t.Helper() + + // Check the updated config in the database + config, err := ccm.Get(context.Background()) + require.NoError(t, err) + require.NotNil(t, config) + assert.Equal(t, "new_wallet_id", *config.WalletID) + + decryptedAPIKey, err := encrypter.Decrypt(*config.EncryptedAPIKey, encryptionPassphrase) + assert.NoError(t, err) + assert.Equal(t, "new_api_key", decryptedAPIKey) + assert.Equal(t, encryptionPublicKey, *config.EncrypterPublicKey) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + handler := CircleConfigHandler{ + Encrypter: &encrypter, + EncryptionPassphrase: encryptionPassphrase, + CircleClientConfigModel: &ccm, + } + + if tc.prepareMocksFn != nil { + mDistributionAccountResolver := sigMocks.NewMockDistributionAccountResolver(t) + mCircleClient := circle.NewMockClient(t) + mTenantManager := tenant.NewTenantManagerMock(t) + tc.prepareMocksFn(t, mDistributionAccountResolver, mCircleClient, mTenantManager) + + handler.DistributionAccountResolver = mDistributionAccountResolver + handler.CircleFactory = func(networkType utils.NetworkType, apiKey string, tntManager tenant.ManagerInterface) circle.ClientInterface { + return mCircleClient + } + handler.TenantManager = mTenantManager + } + + r := chi.NewRouter() + url := "/organization/circle-config" + r.Patch(url, handler.Patch) + + rr := testutils.Request(t, ctx, r, url, http.MethodPatch, strings.NewReader(tc.requestBody)) + assert.Equal(t, tc.statusCode, rr.Code) + tc.assertions(t, rr) + }) + } +} + +func Test_CircleConfigHandler_validateConfigWithCircle(t *testing.T) { + ctx := context.Background() + + encryptionPassphrase := "SCW5I426WV3IDTLSTLQEHC6BMXWI2Z6C4DXAOC4ZA2EIHTAZQ6VD3JI6" + newAPIKey := "new-api-key" + newWalletID := "new-wallet-id" + + testCases := []struct { + name string + patchRequest PatchCircleConfigRequest + prepareMocksFn func(t *testing.T, mEncrypter *utils.PrivateKeyEncrypterMock, mCircleClientConfigModel *circle.MockClientConfigModel, mCircleClient *circle.MockClient) + expectedError *httperror.HTTPError + }{ + { + name: "returns error if request body is not valid", + patchRequest: PatchCircleConfigRequest{}, + expectedError: httperror.BadRequest("Request body is not valid", fmt.Errorf("wallet_id or api_key must be provided"), nil), + }, + { + name: "returns error if CircleClientConfigModel.Get returns error", + patchRequest: PatchCircleConfigRequest{APIKey: &newAPIKey, WalletID: nil}, + prepareMocksFn: func(t *testing.T, mEncrypter *utils.PrivateKeyEncrypterMock, mCircleClientConfigModel *circle.MockClientConfigModel, mCircleClient *circle.MockClient) { + mCircleClientConfigModel. + On("Get", ctx). + Return(nil, fmt.Errorf("get error")). + Once() + }, + expectedError: httperror.InternalError(ctx, "Cannot retrieve the existing Circle configuration", fmt.Errorf("get error"), nil), + }, + { + name: "returns error if CircleClientConfigModel.Get returns nil", + patchRequest: PatchCircleConfigRequest{APIKey: &newAPIKey, WalletID: nil}, + prepareMocksFn: func(t *testing.T, mEncrypter *utils.PrivateKeyEncrypterMock, mCircleClientConfigModel *circle.MockClientConfigModel, mCircleClient *circle.MockClient) { + mCircleClientConfigModel. + On("Get", ctx). + Return(nil, nil). + Once() + }, + expectedError: httperror.BadRequest("You must provide both the Circle walletID and Circle APIKey during the first configuration", nil, nil), + }, + { + name: "returns an error if the existing API Key cannot be decrypted", + patchRequest: PatchCircleConfigRequest{WalletID: &newWalletID}, + prepareMocksFn: func(t *testing.T, mEncrypter *utils.PrivateKeyEncrypterMock, mCircleClientConfigModel *circle.MockClientConfigModel, mCircleClient *circle.MockClient) { + mCircleClientConfigModel. + On("Get", ctx). + Return(&circle.ClientConfig{EncryptedAPIKey: utils.StringPtr("encrypted-api-key")}, nil). + Once() + mEncrypter. + On("Decrypt", "encrypted-api-key", encryptionPassphrase). + Return("", fmt.Errorf("decrypt error")). + Once() + }, + expectedError: httperror.InternalError(ctx, "Cannot decrypt the API key", fmt.Errorf("decrypt error"), nil), + }, + { + name: "returns an error if circleClient.Ping returns an error", + patchRequest: PatchCircleConfigRequest{APIKey: &newAPIKey, WalletID: &newWalletID}, + prepareMocksFn: func(t *testing.T, mEncrypter *utils.PrivateKeyEncrypterMock, mCircleClientConfigModel *circle.MockClientConfigModel, mCircleClient *circle.MockClient) { + mCircleClient. + On("Ping", ctx). + Return(false, fmt.Errorf("ping error")). + Once() + }, + expectedError: wrapCircleError(ctx, fmt.Errorf("ping error")), + }, + { + name: "returns an error if circleClient.Ping returns 'false'", + patchRequest: PatchCircleConfigRequest{APIKey: &newAPIKey, WalletID: &newWalletID}, + prepareMocksFn: func(t *testing.T, mEncrypter *utils.PrivateKeyEncrypterMock, mCircleClientConfigModel *circle.MockClientConfigModel, mCircleClient *circle.MockClient) { + mCircleClient. + On("Ping", ctx). + Return(false, nil). + Once() + }, + expectedError: httperror.BadRequest("Failed to ping, please make sure that the provided API Key is correct.", nil, nil), + }, + { + name: "returns an error if circleClient.GetWalletByID returns an error", + patchRequest: PatchCircleConfigRequest{APIKey: &newAPIKey, WalletID: &newWalletID}, + prepareMocksFn: func(t *testing.T, mEncrypter *utils.PrivateKeyEncrypterMock, mCircleClientConfigModel *circle.MockClientConfigModel, mCircleClient *circle.MockClient) { + mCircleClient. + On("Ping", ctx). + Return(true, nil). + Once() + mCircleClient. + On("GetWalletByID", ctx, newWalletID). + Return(nil, fmt.Errorf("get wallet error")). + Once() + }, + expectedError: wrapCircleError(ctx, fmt.Errorf("get wallet error")), + }, + { + name: "🎉 successfully validate for a new pair of apiKey and walletID", + patchRequest: PatchCircleConfigRequest{APIKey: &newAPIKey, WalletID: &newWalletID}, + prepareMocksFn: func(t *testing.T, mEncrypter *utils.PrivateKeyEncrypterMock, mCircleClientConfigModel *circle.MockClientConfigModel, mCircleClient *circle.MockClient) { + mCircleClient. + On("Ping", ctx). + Return(true, nil). + Once() + mCircleClient. + On("GetWalletByID", ctx, newWalletID). + Return(&circle.Wallet{WalletID: newWalletID}, nil). + Once() + }, + expectedError: nil, + }, + { + name: "🎉 successfully validate for a new apiKey", + patchRequest: PatchCircleConfigRequest{APIKey: &newAPIKey, WalletID: nil}, + prepareMocksFn: func(t *testing.T, mEncrypter *utils.PrivateKeyEncrypterMock, mCircleClientConfigModel *circle.MockClientConfigModel, mCircleClient *circle.MockClient) { + mCircleClientConfigModel. + On("Get", ctx). + Return(&circle.ClientConfig{EncryptedAPIKey: utils.StringPtr("encrypted-api-key")}, nil). + Once() + mCircleClient. + On("Ping", ctx). + Return(true, nil). + Once() + }, + expectedError: nil, + }, + { + name: "🎉 successfully validate for a new walletID", + patchRequest: PatchCircleConfigRequest{APIKey: nil, WalletID: &newWalletID}, + prepareMocksFn: func(t *testing.T, mEncrypter *utils.PrivateKeyEncrypterMock, mCircleClientConfigModel *circle.MockClientConfigModel, mCircleClient *circle.MockClient) { + mCircleClientConfigModel. + On("Get", ctx). + Return(&circle.ClientConfig{EncryptedAPIKey: utils.StringPtr("encrypted-api-key")}, nil). + Once() + mEncrypter. + On("Decrypt", "encrypted-api-key", encryptionPassphrase). + Return("api-key", nil). + Once() + mCircleClient. + On("GetWalletByID", ctx, newWalletID). + Return(&circle.Wallet{WalletID: newWalletID}, nil). + Once() + }, + expectedError: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + handler := CircleConfigHandler{EncryptionPassphrase: encryptionPassphrase} + + if tc.prepareMocksFn != nil { + mEncrypter := utils.NewPrivateKeyEncrypterMock(t) + mCircleClientConfigModel := circle.NewMockClientConfigModel(t) + mCircleClient := circle.NewMockClient(t) + tc.prepareMocksFn(t, mEncrypter, mCircleClientConfigModel, mCircleClient) + + handler.Encrypter = mEncrypter + handler.CircleClientConfigModel = mCircleClientConfigModel + handler.CircleFactory = func(networkType utils.NetworkType, apiKey string, tntManager tenant.ManagerInterface) circle.ClientInterface { + return mCircleClient + } + } + + err := handler.validateConfigWithCircle(ctx, tc.patchRequest) + if tc.expectedError != nil { + assert.Equal(t, tc.expectedError, err) + } else { + assert.Nil(t, err, "expected no error") + } + }) + } +} diff --git a/internal/serve/httphandler/countries_handler.go b/internal/serve/httphandler/countries_handler.go index 413294df2..565728d3c 100644 --- a/internal/serve/httphandler/countries_handler.go +++ b/internal/serve/httphandler/countries_handler.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/stellar/go/support/render/httpjson" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" ) diff --git a/internal/serve/httphandler/countries_handler_test.go b/internal/serve/httphandler/countries_handler_test.go index e390fc84b..96a1e3cf9 100644 --- a/internal/serve/httphandler/countries_handler_test.go +++ b/internal/serve/httphandler/countries_handler_test.go @@ -8,11 +8,12 @@ import ( "net/http/httptest" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_CountriesHandlerGetCountries(t *testing.T) { diff --git a/internal/serve/httphandler/delete_phone_number_handler.go b/internal/serve/httphandler/delete_phone_number_handler.go index 118d21430..6a6ead241 100644 --- a/internal/serve/httphandler/delete_phone_number_handler.go +++ b/internal/serve/httphandler/delete_phone_number_handler.go @@ -8,6 +8,7 @@ import ( "github.com/stellar/go/network" "github.com/stellar/go/support/log" "github.com/stellar/go/support/render/httpjson" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" diff --git a/internal/serve/httphandler/delete_phone_number_handler_test.go b/internal/serve/httphandler/delete_phone_number_handler_test.go index 67a8c895f..48d7eba94 100644 --- a/internal/serve/httphandler/delete_phone_number_handler_test.go +++ b/internal/serve/httphandler/delete_phone_number_handler_test.go @@ -8,11 +8,12 @@ import ( "github.com/go-chi/chi/v5" "github.com/stellar/go/network" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_DeletePhoneNumberHandler(t *testing.T) { diff --git a/internal/serve/httphandler/disbursement_handler.go b/internal/serve/httphandler/disbursement_handler.go index 08a862b40..b6107305b 100644 --- a/internal/serve/httphandler/disbursement_handler.go +++ b/internal/serve/httphandler/disbursement_handler.go @@ -368,20 +368,13 @@ func (d DisbursementHandler) PatchDisbursementStatus(w http.ResponseWriter, r *h switch toStatus { case data.StartedDisbursementStatus: - var distributionPublicKey string - var distributionAccount *schema.DistributionAccount + var distributionAccount schema.TransactionAccount if distributionAccount, err = d.DistributionAccountResolver.DistributionAccountFromContext(ctx); err != nil { httperror.InternalError(ctx, "Cannot get distribution account", err, nil).Render(w) return - } else if !distributionAccount.IsStellar() { - // TODO: during SDP-1177, refactor StartDisbursement to receive the whole distribution account object, rather than just the public key - msg := fmt.Sprintf("expected distribution account to be a STELLAR account but got %q", distributionAccount.Type) - httperror.BadRequest(msg, err, nil).Render(w) - return - } else { - distributionPublicKey = distributionAccount.Address } - err = d.DisbursementManagementService.StartDisbursement(ctx, disbursementID, user, distributionPublicKey) + + err = d.DisbursementManagementService.StartDisbursement(ctx, disbursementID, user, &distributionAccount) response.Message = "Disbursement started" case data.PausedDisbursementStatus: err = d.DisbursementManagementService.PauseDisbursement(ctx, disbursementID, user) diff --git a/internal/serve/httphandler/disbursement_handler_test.go b/internal/serve/httphandler/disbursement_handler_test.go index 4a49eb9a3..b414273ce 100644 --- a/internal/serve/httphandler/disbursement_handler_test.go +++ b/internal/serve/httphandler/disbursement_handler_test.go @@ -16,10 +16,6 @@ import ( "time" "github.com/go-chi/chi/v5" - "github.com/stellar/go/clients/horizonclient" - "github.com/stellar/go/keypair" - "github.com/stellar/go/protocols/horizon" - "github.com/stellar/go/protocols/horizon/base" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -34,8 +30,9 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpresponse" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" "github.com/stellar/stellar-disbursement-platform-backend/internal/services" - "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" + svcMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/services/mocks" sigMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) @@ -167,7 +164,7 @@ func Test_DisbursementHandler_PostDisbursement(t *testing.T) { }) require.NoError(t, err) - want := `{"error":"Verification field invalid", "extras": {"verification_field": "invalid parameter. valid values are: DATE_OF_BIRTH, PIN, NATIONAL_ID_NUMBER"}}` + want := `{"error":"Verification field invalid", "extras": {"verification_field": "invalid parameter. valid values are: [DATE_OF_BIRTH YEAR_MONTH PIN NATIONAL_ID_NUMBER]"}}` assertPOSTResponse(t, ctx, handler, method, url, string(requestBody), want, http.StatusBadRequest) }) @@ -868,7 +865,7 @@ func Test_DisbursementHandler_PostDisbursementInstructions(t *testing.T) { {"+380445555555", "123456789", "100.5", "1990/01/01"}, }, expectedStatus: http.StatusBadRequest, - expectedMessage: "invalid date of birth format. Correct format: 1990-01-01", + expectedMessage: "invalid date of birth format. Correct format: 1990-01-30", }, { name: "invalid phone number", @@ -894,7 +891,7 @@ func Test_DisbursementHandler_PostDisbursementInstructions(t *testing.T) { expectedMessage: "could not parse file", }, { - name: "disbursement not in draft/ready starte", + name: "disbursement not in draft/ready status", disbursementID: startedDisbursement.ID, expectedStatus: http.StatusBadRequest, expectedMessage: "disbursement is not in draft or ready status", @@ -1265,26 +1262,22 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { authManagerMock := &auth.AuthManagerMock{} mockEventProducer := events.MockProducer{} - hMock := &horizonclient.MockClient{} + mockDistAccSvc := svcMocks.NewMockDistributionAccountService(t) asset := data.GetAssetFixture(t, ctx, dbConnectionPool, data.FixtureAssetUSDC) - hostDistAccPublicKey := keypair.MustRandom().Address() defaultTenantDistAcc := "GDIVVKL6QYF6C6K3C5PZZBQ2NQDLN2OSLMVIEQRHS6DZE7WRL33ZDNXL" - distAccResolver, err := signing.NewDistributionAccountResolver(signing.DistributionAccountResolverOptions{ - AdminDBConnectionPool: dbConnectionPool, - HostDistributionAccountPublicKey: hostDistAccPublicKey, - }) - require.NoError(t, err) + distAcc := schema.NewStellarEnvTransactionAccount(defaultTenantDistAcc) + mockDistAccResolver := sigMocks.NewMockDistributionAccountResolver(t) handler := &DisbursementHandler{ Models: models, AuthManager: authManagerMock, - DistributionAccountResolver: distAccResolver, + DistributionAccountResolver: mockDistAccResolver, DisbursementManagementService: &services.DisbursementManagementService{ - Models: models, - AuthManager: authManagerMock, - HorizonClient: hMock, - EventProducer: &mockEventProducer, + Models: models, + AuthManager: authManagerMock, + EventProducer: &mockEventProducer, + DistributionAccountService: mockDistAccSvc, }, } @@ -1343,20 +1336,13 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { Return(user, nil). Once() - mDistAccResolver := sigMocks.NewMockDistributionAccountResolver(t) - mDistAccResolver. + mockDistAccResolver. On("DistributionAccountFromContext", mock.Anything). - Return(nil, errors.New("unexpected error")). + Return(schema.TransactionAccount{}, errors.New("unexpected error")). Once() - h := &DisbursementHandler{ - Models: handler.Models, - AuthManager: handler.AuthManager, - DistributionAccountResolver: mDistAccResolver, - DisbursementManagementService: handler.DisbursementManagementService, - } httpRouter := chi.NewRouter() - httpRouter.Patch("/disbursements/{id}/status", h.PatchDisbursementStatus) + httpRouter.Patch("/disbursements/{id}/status", handler.PatchDisbursementStatus) err := json.NewEncoder(reqBody).Encode(PatchDisbursementStatusRequest{Status: "STARTED"}) require.NoError(t, err) @@ -1376,6 +1362,11 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { Return(user, nil). Once() + mockDistAccResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(distAcc, nil). + Once() + err := json.NewEncoder(reqBody).Encode(PatchDisbursementStatusRequest{Status: "Started"}) require.NoError(t, err) @@ -1403,6 +1394,11 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { Return(user, nil). Once() + mockDistAccResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(distAcc, nil). + Once() + err := json.NewEncoder(reqBody).Encode(PatchDisbursementStatusRequest{Status: "Started"}) require.NoError(t, err) @@ -1417,20 +1413,6 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { }) t.Run("disbursement can be started by approver who is not a creator", func(t *testing.T) { - hMock.On( - "AccountDetail", horizonclient.AccountRequest{AccountID: defaultTenantDistAcc}, - ).Return(horizon.Account{ - Balances: []horizon.Balance{ - { - Balance: "10000", - Asset: base.Asset{ - Code: asset.Code, - Issuer: asset.Issuer, - }, - }, - }, - }, nil).Once() - data.EnableDisbursementApproval(t, ctx, handler.Models.Organizations) defer data.DisableDisbursementApproval(t, ctx, handler.Models.Organizations) @@ -1460,6 +1442,14 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { Return(approverUser, nil). Once() + mockDistAccResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(distAcc, nil). + Once() + + mockDistAccSvc.On("GetBalance", mock.Anything, &distAcc, mock.AnythingOfType("data.Asset")). + Return(10000.0, nil).Once() + mockEventProducer. On("WriteMessages", mock.Anything, mock.AnythingOfType("[]events.Message")). Return(nil). @@ -1479,25 +1469,19 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { }) t.Run("disbursement started - then paused", func(t *testing.T) { - hMock.On( - "AccountDetail", horizonclient.AccountRequest{AccountID: defaultTenantDistAcc}, - ).Return(horizon.Account{ - Balances: []horizon.Balance{ - { - Balance: "10000", - Asset: base.Asset{ - Code: asset.Code, - Issuer: asset.Issuer, - }, - }, - }, - }, nil).Once() - authManagerMock. On("GetUser", mock.Anything, token). Return(user, nil). Twice() + mockDistAccResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(distAcc, nil). + Once() + + mockDistAccSvc.On("GetBalance", mock.Anything, &distAcc, mock.AnythingOfType("data.Asset")). + Return(10000.0, nil).Once() + mockEventProducer. On("WriteMessages", mock.Anything, mock.AnythingOfType("[]events.Message")). Return(nil). @@ -1598,6 +1582,11 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { Return(user, nil). Once() + mockDistAccResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(distAcc, nil). + Once() + id := "5e1f1c7f5b6c9c0001c1b1b1" err := json.NewEncoder(reqBody).Encode(PatchDisbursementStatusRequest{Status: "STARTED"}) require.NoError(t, err) @@ -1613,7 +1602,6 @@ func Test_DisbursementHandler_PatchDisbursementStatus(t *testing.T) { }) authManagerMock.AssertExpectations(t) - hMock.AssertExpectations(t) mockEventProducer.AssertExpectations(t) } diff --git a/internal/serve/httphandler/forgot_password_handler.go b/internal/serve/httphandler/forgot_password_handler.go index e0de65408..ff2f00b49 100644 --- a/internal/serve/httphandler/forgot_password_handler.go +++ b/internal/serve/httphandler/forgot_password_handler.go @@ -7,17 +7,17 @@ import ( "net/http" "net/url" - "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" - "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" - "github.com/stellar/go/support/log" "github.com/stellar/go/support/render/httpjson" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/htmltemplate" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) const forgotPasswordMessageTitle = "Reset Account Password" diff --git a/internal/serve/httphandler/forgot_password_handler_test.go b/internal/serve/httphandler/forgot_password_handler_test.go index 3310de728..3edb386fc 100644 --- a/internal/serve/httphandler/forgot_password_handler_test.go +++ b/internal/serve/httphandler/forgot_password_handler_test.go @@ -10,18 +10,18 @@ import ( "strings" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" - "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/htmltemplate" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) func Test_ForgotPasswordHandler(t *testing.T) { diff --git a/internal/serve/httphandler/health_handler_test.go b/internal/serve/httphandler/health_handler_test.go index 3132e11c5..6f1a1ff3c 100644 --- a/internal/serve/httphandler/health_handler_test.go +++ b/internal/serve/httphandler/health_handler_test.go @@ -6,14 +6,14 @@ import ( "net/http/httptest" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" - "github.com/stellar/stellar-disbursement-platform-backend/internal/events" - "github.com/stretchr/testify/mock" - "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/events" ) // test HealthHandler: diff --git a/internal/serve/httphandler/list_roles_handler.go b/internal/serve/httphandler/list_roles_handler.go index 6356b143a..311a53177 100644 --- a/internal/serve/httphandler/list_roles_handler.go +++ b/internal/serve/httphandler/list_roles_handler.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/stellar/go/support/render/httpjson" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" ) diff --git a/internal/serve/httphandler/login_handler.go b/internal/serve/httphandler/login_handler.go index 02edd3d91..3f452e3a2 100644 --- a/internal/serve/httphandler/login_handler.go +++ b/internal/serve/httphandler/login_handler.go @@ -5,17 +5,16 @@ import ( "fmt" "net/http" - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" - "github.com/stellar/stellar-disbursement-platform-backend/internal/htmltemplate" - "github.com/stellar/stellar-disbursement-platform-backend/internal/message" - - "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" - "github.com/stellar/go/support/http/httpdecode" "github.com/stellar/go/support/log" "github.com/stellar/go/support/render/httpjson" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/htmltemplate" + "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" ) diff --git a/internal/serve/httphandler/login_handler_test.go b/internal/serve/httphandler/login_handler_test.go index 952ead384..6bd5a050f 100644 --- a/internal/serve/httphandler/login_handler_test.go +++ b/internal/serve/httphandler/login_handler_test.go @@ -8,21 +8,20 @@ import ( "strings" "testing" + "github.com/go-chi/chi/v5" + "github.com/stellar/go/support/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/htmltemplate" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" - - "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" - - "github.com/go-chi/chi/v5" - "github.com/stellar/go/support/log" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" ) func Test_LoginRequest_validate(t *testing.T) { diff --git a/internal/serve/httphandler/mfa_handler.go b/internal/serve/httphandler/mfa_handler.go index 915b9506f..15efa4f77 100644 --- a/internal/serve/httphandler/mfa_handler.go +++ b/internal/serve/httphandler/mfa_handler.go @@ -7,6 +7,7 @@ import ( "github.com/stellar/go/support/http/httpdecode" "github.com/stellar/go/support/log" "github.com/stellar/go/support/render/httpjson" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" diff --git a/internal/serve/httphandler/mfa_handler_test.go b/internal/serve/httphandler/mfa_handler_test.go index 3c3542bc5..dcf7dac07 100644 --- a/internal/serve/httphandler/mfa_handler_test.go +++ b/internal/serve/httphandler/mfa_handler_test.go @@ -11,14 +11,14 @@ import ( "testing" "github.com/stellar/go/support/log" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" ) const mfaEndpoint = "/mfa" diff --git a/internal/serve/httphandler/payments_handler.go b/internal/serve/httphandler/payments_handler.go index b9cdec099..039ee17e3 100644 --- a/internal/serve/httphandler/payments_handler.go +++ b/internal/serve/httphandler/payments_handler.go @@ -22,16 +22,18 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" "github.com/stellar/stellar-disbursement-platform-backend/internal/services" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" ) type PaymentsHandler struct { - Models *data.Models - DBConnectionPool db.DBConnectionPool - AuthManager auth.AuthManager - EventProducer events.Producer - CrashTrackerClient crashtracker.CrashTrackerClient + Models *data.Models + DBConnectionPool db.DBConnectionPool + AuthManager auth.AuthManager + EventProducer events.Producer + CrashTrackerClient crashtracker.CrashTrackerClient + DistributionAccountResolver signing.DistributionAccountResolver } type RetryPaymentsRequest struct { @@ -48,24 +50,63 @@ func (r *RetryPaymentsRequest) validate() *httperror.HTTPError { return nil } +func (p PaymentsHandler) decorateWithCircleTransactionInfo(ctx context.Context, payments ...data.Payment) ([]data.Payment, error) { + if len(payments) == 0 { + return payments, nil + } + + distAccount, err := p.DistributionAccountResolver.DistributionAccountFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("resolving distribution account: %w", err) + } + + if !distAccount.IsCircle() { + return payments, nil + } + + paymentIDs := make([]string, len(payments)) + for i, payment := range payments { + paymentIDs[i] = payment.ID + } + + transfersByPaymentID, err := p.Models.CircleTransferRequests.GetCurrentTransfersForPaymentIDs(ctx, p.DBConnectionPool, paymentIDs) + if err != nil { + return nil, fmt.Errorf("getting circle transfers for payment IDs: %w", err) + } + + for i, payment := range payments { + if transfer, ok := transfersByPaymentID[payment.ID]; ok { + payments[i].CircleTransferRequestID = transfer.CircleTransferID + } + } + + return payments, nil +} + func (p PaymentsHandler) GetPayment(w http.ResponseWriter, r *http.Request) { - payment_id := chi.URLParam(r, "id") + paymentID := chi.URLParam(r, "id") - payment, err := p.Models.Payment.Get(r.Context(), payment_id, p.DBConnectionPool) + payment, err := p.Models.Payment.Get(r.Context(), paymentID, p.DBConnectionPool) if err != nil { if errors.Is(data.ErrRecordNotFound, err) { - errorResponse := fmt.Sprintf("Cannot retrieve payment with ID: %s", payment_id) + errorResponse := fmt.Sprintf("Cannot retrieve payment with ID: %s", paymentID) httperror.NotFound(errorResponse, err, nil).Render(w) return } else { ctx := r.Context() - msg := fmt.Sprintf("Cannot retrieve payment with id %s", payment_id) + msg := fmt.Sprintf("Cannot retrieve payment with id %s", paymentID) httperror.InternalError(ctx, msg, err, nil).Render(w) return } } - httpjson.RenderStatus(w, http.StatusOK, payment, httpjson.JSON) + payments, err := p.decorateWithCircleTransactionInfo(r.Context(), *payment) + if err != nil { + httperror.InternalError(r.Context(), "Cannot retrieve payment with circle info", err, nil).Render(w) + return + } + + httpjson.RenderStatus(w, http.StatusOK, payments[0], httpjson.JSON) } func (p PaymentsHandler) GetPayments(w http.ResponseWriter, r *http.Request) { @@ -183,7 +224,12 @@ func (p PaymentsHandler) buildPaymentsReadyEventMessage(ctx context.Context, pay return nil, nil } - msg, err := events.NewMessage(ctx, events.PaymentReadyToPayTopic, "", events.PaymentReadyToPayRetryFailedPayment, nil) + distAccount, err := p.DistributionAccountResolver.DistributionAccountFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("resolving distribution account: %w", err) + } + + msg, err := events.NewPaymentReadyToPayMessage(ctx, distAccount.Type.Platform(), "", events.PaymentReadyToPayRetryFailedPayment) if err != nil { return nil, fmt.Errorf("creating a new message: %w", err) } @@ -218,6 +264,11 @@ func (p PaymentsHandler) getPaymentsWithCount(ctx context.Context, queryParams * } } + payments, err := p.decorateWithCircleTransactionInfo(ctx, payments...) + if err != nil { + return nil, fmt.Errorf("adding circle info to payments: %w", err) + } + return utils.NewResultWithTotal(totalPayments, payments), nil }) } diff --git a/internal/serve/httphandler/payments_handler_test.go b/internal/serve/httphandler/payments_handler_test.go index 14be35096..cd5abcce3 100644 --- a/internal/serve/httphandler/payments_handler_test.go +++ b/internal/serve/httphandler/payments_handler_test.go @@ -15,6 +15,10 @@ import ( "github.com/go-chi/chi/v5" "github.com/stellar/go/support/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" @@ -24,12 +28,11 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpresponse" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" "github.com/stellar/stellar-disbursement-platform-backend/internal/services" + sigMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" ) func Test_PaymentsHandlerGet(t *testing.T) { @@ -43,12 +46,19 @@ func Test_PaymentsHandlerGet(t *testing.T) { models, err := data.NewModels(dbConnectionPool) require.NoError(t, err) + mDistributionAccountResolver := sigMocks.NewMockDistributionAccountResolver(t) + handler := &PaymentsHandler{ - Models: models, - DBConnectionPool: dbConnectionPool, + Models: models, + DBConnectionPool: dbConnectionPool, + DistributionAccountResolver: mDistributionAccountResolver, } - // setup + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarEnv}, nil). + Maybe() + r := chi.NewRouter() r.Get("/payments/{id}", handler.GetPayment) @@ -179,6 +189,154 @@ func Test_PaymentsHandlerGet(t *testing.T) { }) } +func Test_PaymentHandler_GetPayments_CirclePayments(t *testing.T) { + ctx := context.Background() + + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + models, outerErr := data.NewModels(dbConnectionPool) + require.NoError(t, outerErr) + + // Create fixtures + asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") + country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") + wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") + disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Country: country, + Wallet: wallet, + Status: data.ReadyDisbursementStatus, + Asset: asset, + }) + receiverReady := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + rwReady := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverReady.ID, wallet.ID, data.ReadyReceiversWalletStatus) + payment1 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursement, + Asset: *asset, + Amount: "100", + Status: data.DraftPaymentStatus, + }) + payment2 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursement, + Asset: *asset, + Amount: "200", + Status: data.DraftPaymentStatus, + }) + data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursement, + Asset: *asset, + Amount: "300", + Status: data.DraftPaymentStatus, + }) + + data.CreateCircleTransferRequestFixture(t, ctx, dbConnectionPool, data.CircleTransferRequest{ + IdempotencyKey: "idempotency-key-1", + PaymentID: payment1.ID, + CircleTransferID: utils.StringPtr("circle-transfer-id-1"), + }) + + data.CreateCircleTransferRequestFixture(t, ctx, dbConnectionPool, data.CircleTransferRequest{ + IdempotencyKey: "idempotency-key-2", + PaymentID: payment2.ID, + CircleTransferID: utils.StringPtr("circle-transfer-id-2"), + }) + + testCases := []struct { + name string + prepareMocks func(t *testing.T, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) + runAssertions func(t *testing.T, responseStatus int, response string) + }{ + { + name: "returns error when distribution account resolver fails", + prepareMocks: func(t *testing.T, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) { + t.Helper() + + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{}, errors.New("unexpected error")). + Once() + }, + runAssertions: func(t *testing.T, responseStatus int, response string) { + t.Helper() + + assert.Equal(t, http.StatusInternalServerError, responseStatus) + assert.JSONEq(t, `{"error":"Cannot retrieve payments"}`, string(response)) + }, + }, + { + name: "successfully returns payments with circle transaction IDs", + prepareMocks: func(t *testing.T, mDistributionAccountResolver *sigMocks.MockDistributionAccountResolver) { + t.Helper() + + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountCircleDBVault}, nil). + Maybe() + }, + runAssertions: func(t *testing.T, responseStatus int, response string) { + t.Helper() + + assert.Equal(t, http.StatusOK, responseStatus) + + var actualResponse httpresponse.PaginatedResponse + err := json.Unmarshal([]byte(response), &actualResponse) + require.NoError(t, err) + + assert.Equal(t, 3, actualResponse.Pagination.Total) + + var payments []data.Payment + err = json.Unmarshal(actualResponse.Data, &payments) + require.NoError(t, err) + + assert.Len(t, payments, 3) + for _, payment := range payments { + if payment.ID == payment1.ID { + assert.Equal(t, "circle-transfer-id-1", *payment.CircleTransferRequestID) + } + if payment.ID == payment2.ID { + assert.Equal(t, "circle-transfer-id-2", *payment.CircleTransferRequestID) + } + if payment.ID != payment1.ID && payment.ID != payment2.ID { + assert.Nil(t, payment.CircleTransferRequestID) + } + } + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mDistributionAccountResolver := sigMocks.NewMockDistributionAccountResolver(t) + + tc.prepareMocks(t, mDistributionAccountResolver) + + h := &PaymentsHandler{ + Models: models, + DBConnectionPool: dbConnectionPool, + DistributionAccountResolver: mDistributionAccountResolver, + } + + rr := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/payments", nil) + require.NoError(t, err) + http.HandlerFunc(h.GetPayments).ServeHTTP(rr, req) + resp := rr.Result() + defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + tc.runAssertions(t, resp.StatusCode, string(respBody)) + }) + } +} + func Test_PaymentHandler_GetPayments_Errors(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() @@ -242,7 +400,7 @@ func Test_PaymentHandler_GetPayments_Errors(t *testing.T) { "status": "invalid_status", }, expectedStatusCode: http.StatusBadRequest, - expectedResponse: `{"error":"request invalid", "extras":{"status":"invalid parameter. valid values are: DRAFT, READY, PENDING, PAUSED, SUCCESS, FAILED, CANCELLED"}}`, + expectedResponse: fmt.Sprintf(`{"error":"request invalid", "extras":{"status":"invalid parameter. valid values are: %v"}}`, data.PaymentStatuses()), }, { name: "returns error when created_at_after is invalid", @@ -296,9 +454,16 @@ func Test_PaymentHandler_GetPayments_Success(t *testing.T) { models, err := data.NewModels(dbConnectionPool) require.NoError(t, err) + mDistributionAccountResolver := sigMocks.NewMockDistributionAccountResolver(t) + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarEnv}, nil). + Maybe() + handler := &PaymentsHandler{ - Models: models, - DBConnectionPool: dbConnectionPool, + Models: models, + DBConnectionPool: dbConnectionPool, + DistributionAccountResolver: mDistributionAccountResolver, } ts := httptest.NewServer(http.HandlerFunc(handler.GetPayments)) @@ -927,11 +1092,17 @@ func Test_PaymentHandler_RetryPayments(t *testing.T) { }). Return(nil). Once() + distAccountResolverMock := sigMocks.NewMockDistributionAccountResolver(t) + distAccountResolverMock. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarEnv}, nil). + Once() handler := PaymentsHandler{ - Models: models, - DBConnectionPool: dbConnectionPool, - AuthManager: authManagerMock, - EventProducer: eventProducerMock, + Models: models, + DBConnectionPool: dbConnectionPool, + AuthManager: authManagerMock, + EventProducer: eventProducerMock, + DistributionAccountResolver: distAccountResolverMock, } rw := httptest.NewRecorder() @@ -969,6 +1140,80 @@ func Test_PaymentHandler_RetryPayments(t *testing.T) { assert.Equal(t, "User email@test.com has requested to retry the payment - Previous Stellar Transaction ID: stellar-transaction-id-2", payment2DB.StatusHistory[1].StatusMessage) }) + t.Run("successfully retries failed circle payment", func(t *testing.T) { + data.DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) + + failedPayment := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + Amount: "1", + StellarTransactionID: "stellar-transaction-id-1", + StellarOperationID: "operation-id-1", + Status: data.FailedPaymentStatus, + Disbursement: disbursement, + ReceiverWallet: receiverWallet, + Asset: *asset, + }) + + ctx = context.WithValue(ctx, middleware.TokenContextKey, "mytoken") + + payload := strings.NewReader(fmt.Sprintf(`{ "payment_ids": [%q] } `, failedPayment.ID)) + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, "/retry", payload) + require.NoError(t, err) + + // Prepare the handler and its mocks + authManagerMock := auth.NewAuthManagerMock(t) + authManagerMock. + On("GetUser", ctx, "mytoken"). + Return(&auth.User{Email: "email@test.com"}, nil). + Once() + eventProducerMock := events.NewMockProducer(t) + eventProducerMock. + On("WriteMessages", ctx, []events.Message{ + { + Topic: events.CirclePaymentReadyToPayTopic, + Key: tnt.ID, + TenantID: tnt.ID, + Type: events.PaymentReadyToPayRetryFailedPayment, + Data: schemas.EventPaymentsReadyToPayData{ + TenantID: tnt.ID, + Payments: []schemas.PaymentReadyToPay{ + {ID: failedPayment.ID}, + }, + }, + }, + }). + Return(nil). + Once() + distAccountResolverMock := sigMocks.NewMockDistributionAccountResolver(t) + distAccountResolverMock. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountCircleDBVault}, nil). + Once() + handler := PaymentsHandler{ + Models: models, + DBConnectionPool: dbConnectionPool, + AuthManager: authManagerMock, + EventProducer: eventProducerMock, + DistributionAccountResolver: distAccountResolverMock, + } + + rw := httptest.NewRecorder() + http.HandlerFunc(handler.RetryPayments).ServeHTTP(rw, req) + + resp := rw.Result() + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.JSONEq(t, `{"message": "Payments retried successfully"}`, string(respBody)) + + previouslyFailedPayment, err := models.Payment.Get(ctx, failedPayment.ID, dbConnectionPool) + require.NoError(t, err) + + assert.Equal(t, data.ReadyPaymentStatus, previouslyFailedPayment.Status) + }) + t.Run("returns error when tenant is not in the context", func(t *testing.T) { data.DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) @@ -1008,10 +1253,16 @@ func Test_PaymentHandler_RetryPayments(t *testing.T) { On("GetUser", ctxWithoutTenant, "mytoken"). Return(&auth.User{Email: "email@test.com"}, nil). Once() + distAccountResolverMock := sigMocks.NewMockDistributionAccountResolver(t) + distAccountResolverMock. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarEnv}, nil). + Once() handler := PaymentsHandler{ - Models: models, - DBConnectionPool: dbConnectionPool, - AuthManager: authManagerMock, + Models: models, + DBConnectionPool: dbConnectionPool, + AuthManager: authManagerMock, + DistributionAccountResolver: distAccountResolverMock, } rw := httptest.NewRecorder() @@ -1078,12 +1329,18 @@ func Test_PaymentHandler_RetryPayments(t *testing.T) { crashTrackerMock. On("LogAndReportErrors", mock.Anything, mock.Anything, "writing retry payment message on the event producer"). Once() + distAccountResolverMock := sigMocks.NewMockDistributionAccountResolver(t) + distAccountResolverMock. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarEnv}, nil). + Once() handler := PaymentsHandler{ - Models: models, - DBConnectionPool: dbConnectionPool, - AuthManager: authManagerMock, - EventProducer: eventProducerMock, - CrashTrackerClient: crashTrackerMock, + Models: models, + DBConnectionPool: dbConnectionPool, + AuthManager: authManagerMock, + EventProducer: eventProducerMock, + CrashTrackerClient: crashTrackerMock, + DistributionAccountResolver: distAccountResolverMock, } rw := httptest.NewRecorder() @@ -1138,10 +1395,16 @@ func Test_PaymentHandler_RetryPayments(t *testing.T) { On("GetUser", ctx, "mytoken"). Return(&auth.User{Email: "email@test.com"}, nil). Once() + distAccountResolverMock := sigMocks.NewMockDistributionAccountResolver(t) + distAccountResolverMock. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarEnv}, nil). + Once() handler := PaymentsHandler{ - Models: models, - DBConnectionPool: dbConnectionPool, - AuthManager: authManagerMock, + Models: models, + DBConnectionPool: dbConnectionPool, + AuthManager: authManagerMock, + DistributionAccountResolver: distAccountResolverMock, } getEntries := log.DefaultLogger.StartTest(log.DebugLevel) @@ -1189,11 +1452,20 @@ func Test_PaymentsHandler_getPaymentsWithCount(t *testing.T) { ctx := context.Background() models, err := data.NewModels(dbConnectionPool) require.NoError(t, err) + + mDistributionAccountResolver := sigMocks.NewMockDistributionAccountResolver(t) + handler := &PaymentsHandler{ - Models: models, - DBConnectionPool: dbConnectionPool, + Models: models, + DBConnectionPool: dbConnectionPool, + DistributionAccountResolver: mDistributionAccountResolver, } + mDistributionAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarEnv}, nil). + Maybe() + t.Run("0 payments created", func(t *testing.T) { response, err := handler.getPaymentsWithCount(ctx, &data.QueryParams{}) require.NoError(t, err) diff --git a/internal/serve/httphandler/profile_handler.go b/internal/serve/httphandler/profile_handler.go index 9395158fc..e9d054817 100644 --- a/internal/serve/httphandler/profile_handler.go +++ b/internal/serve/httphandler/profile_handler.go @@ -177,13 +177,10 @@ func (h ProfileHandler) PatchOrganizationProfile(rw http.ResponseWriter, req *ht } var nonEmptyChanges []string for k, v := range requestDict { - if !utils.IsEmpty(v) { - value := v - if k == "Logo" { - value = "..." - } - nonEmptyChanges = append(nonEmptyChanges, fmt.Sprintf("%s='%v'", k, value)) + if k == "Logo" { + v = "..." } + nonEmptyChanges = append(nonEmptyChanges, fmt.Sprintf("%s='%v'", k, v)) } sort.Strings(nonEmptyChanges) diff --git a/internal/serve/httphandler/profile_handler_test.go b/internal/serve/httphandler/profile_handler_test.go index bac035fb1..a38b31621 100644 --- a/internal/serve/httphandler/profile_handler_test.go +++ b/internal/serve/httphandler/profile_handler_test.go @@ -1034,7 +1034,7 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { url := "/profile/info" newDistAccountJSON := func(t *testing.T, distAcc string) string { - distributionAccount := schema.NewDefaultStellarDistributionAccount(distAcc) + distributionAccount := schema.NewDefaultStellarTransactionAccount(distAcc) bytes, err := json.Marshal(distributionAccount) require.NoError(t, err) return string(bytes) @@ -1094,7 +1094,7 @@ func Test_ProfileHandler_GetOrganizationInfo(t *testing.T) { mDistAccResolver := sigMocks.NewMockDistributionAccountResolver(t) mDistAccResolver. On("DistributionAccountFromContext", ctx). - Return(nil, errors.New("unexpected error")). + Return(schema.TransactionAccount{}, errors.New("unexpected error")). Once() h := &ProfileHandler{Models: models, BaseURL: "http://localhost:8000", DistributionAccountResolver: mDistAccResolver} http.HandlerFunc(h.GetOrganizationInfo).ServeHTTP(w, req) diff --git a/internal/serve/httphandler/receiver_handler.go b/internal/serve/httphandler/receiver_handler.go index 7c818f3cb..ccc30eac9 100644 --- a/internal/serve/httphandler/receiver_handler.go +++ b/internal/serve/httphandler/receiver_handler.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/stellar/go/support/render/httpjson" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" diff --git a/internal/serve/httphandler/receiver_handler_test.go b/internal/serve/httphandler/receiver_handler_test.go index 34a38245a..754d2c3a9 100644 --- a/internal/serve/httphandler/receiver_handler_test.go +++ b/internal/serve/httphandler/receiver_handler_test.go @@ -11,13 +11,14 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_ReceiverHandlerGet(t *testing.T) { @@ -1631,6 +1632,7 @@ func Test_ReceiverHandler_GetReceiverVerificatioTypes(t *testing.T) { defer resp.Body.Close() expectedBody := `[ "DATE_OF_BIRTH", + "YEAR_MONTH", "PIN", "NATIONAL_ID_NUMBER" ]` diff --git a/internal/serve/httphandler/receiver_registration.go b/internal/serve/httphandler/receiver_registration.go index 25d181a88..fbdd82178 100644 --- a/internal/serve/httphandler/receiver_registration.go +++ b/internal/serve/httphandler/receiver_registration.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/anchorplatform" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" htmlTpl "github.com/stellar/stellar-disbursement-platform-backend/internal/htmltemplate" diff --git a/internal/serve/httphandler/receiver_registration_test.go b/internal/serve/httphandler/receiver_registration_test.go index 8ad482387..7dc943e6d 100644 --- a/internal/serve/httphandler/receiver_registration_test.go +++ b/internal/serve/httphandler/receiver_registration_test.go @@ -10,12 +10,13 @@ import ( "github.com/go-chi/chi/v5" "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/anchorplatform" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_ReceiverRegistrationHandler_ServeHTTP(t *testing.T) { diff --git a/internal/serve/httphandler/receiver_send_otp_handler.go b/internal/serve/httphandler/receiver_send_otp_handler.go index e88349212..b35e0e019 100644 --- a/internal/serve/httphandler/receiver_send_otp_handler.go +++ b/internal/serve/httphandler/receiver_send_otp_handler.go @@ -10,6 +10,7 @@ import ( "github.com/stellar/go/support/log" "github.com/stellar/go/support/render/httpjson" + "github.com/stellar/stellar-disbursement-platform-backend/internal/anchorplatform" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" diff --git a/internal/serve/httphandler/receiver_send_otp_handler_test.go b/internal/serve/httphandler/receiver_send_otp_handler_test.go index 49d1d2df7..5ef742ed5 100644 --- a/internal/serve/httphandler/receiver_send_otp_handler_test.go +++ b/internal/serve/httphandler/receiver_send_otp_handler_test.go @@ -14,15 +14,16 @@ import ( "github.com/go-chi/chi/v5" "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/anchorplatform" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" ) func Test_ReceiverSendOTPHandler_ServeHTTP(t *testing.T) { diff --git a/internal/serve/httphandler/receiver_wallets_handler_test.go b/internal/serve/httphandler/receiver_wallets_handler_test.go index c7cfa13c5..40592d814 100644 --- a/internal/serve/httphandler/receiver_wallets_handler_test.go +++ b/internal/serve/httphandler/receiver_wallets_handler_test.go @@ -11,6 +11,10 @@ import ( "github.com/go-chi/chi/v5" "github.com/stellar/go/support/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" @@ -18,9 +22,6 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/events" "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" ) func Test_RetryInvitation(t *testing.T) { diff --git a/internal/serve/httphandler/refresh_token_handler.go b/internal/serve/httphandler/refresh_token_handler.go index e20f9c9b8..42363966c 100644 --- a/internal/serve/httphandler/refresh_token_handler.go +++ b/internal/serve/httphandler/refresh_token_handler.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/stellar/go/support/render/httpjson" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" diff --git a/internal/serve/httphandler/refresh_token_handler_test.go b/internal/serve/httphandler/refresh_token_handler_test.go index 96524edba..cc4da610f 100644 --- a/internal/serve/httphandler/refresh_token_handler_test.go +++ b/internal/serve/httphandler/refresh_token_handler_test.go @@ -8,11 +8,12 @@ import ( "net/http/httptest" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" - "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" ) func Test_RefreshTokenHandler(t *testing.T) { diff --git a/internal/serve/httphandler/reset_password_handler.go b/internal/serve/httphandler/reset_password_handler.go index 7a002d628..f25cc2209 100644 --- a/internal/serve/httphandler/reset_password_handler.go +++ b/internal/serve/httphandler/reset_password_handler.go @@ -5,11 +5,11 @@ import ( "errors" "net/http" - "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" - "github.com/stellar/go/support/log" "github.com/stellar/go/support/render/httpjson" + "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" authUtils "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/utils" ) diff --git a/internal/serve/httphandler/statistics_handler.go b/internal/serve/httphandler/statistics_handler.go index dde6eb878..59038bced 100644 --- a/internal/serve/httphandler/statistics_handler.go +++ b/internal/serve/httphandler/statistics_handler.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/stellar/go/support/render/httpjson" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" "github.com/stellar/stellar-disbursement-platform-backend/internal/statistics" diff --git a/internal/serve/httphandler/statistics_handler_test.go b/internal/serve/httphandler/statistics_handler_test.go index ab02da06f..dd7bc83fa 100644 --- a/internal/serve/httphandler/statistics_handler_test.go +++ b/internal/serve/httphandler/statistics_handler_test.go @@ -8,12 +8,13 @@ import ( "testing" "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestStatisticsHandler(t *testing.T) { diff --git a/internal/serve/httphandler/stellar_toml_handler.go b/internal/serve/httphandler/stellar_toml_handler.go index 4d14b4bae..d74892ba9 100644 --- a/internal/serve/httphandler/stellar_toml_handler.go +++ b/internal/serve/httphandler/stellar_toml_handler.go @@ -108,7 +108,7 @@ func (s StellarTomlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { httperror.InternalError(ctx, "Couldn't generate stellar.toml file for this instance", innerErr, nil).Render(w) return } - instanceAssets := services.DefaultAssetsNetworkMap[networkType] + instanceAssets := services.StellarAssetsNetworkMap[networkType] stellarToml = s.buildGeneralInformation(ctx, r) + s.buildOrganizationDocumentation(s.InstanceName) + s.buildCurrencyInformation(instanceAssets) } else { // return a stellar.toml file for this tenant. diff --git a/internal/serve/httphandler/stellar_toml_handler_test.go b/internal/serve/httphandler/stellar_toml_handler_test.go index 6461c47cc..66c2e8e71 100644 --- a/internal/serve/httphandler/stellar_toml_handler_test.go +++ b/internal/serve/httphandler/stellar_toml_handler_test.go @@ -16,6 +16,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" sigMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" @@ -52,6 +53,7 @@ func Test_StellarTomlHandler_buildGeneralInformation(t *testing.T) { req := httptest.NewRequest("GET", "https://test.com/.well-known/stellar.toml", nil) req.Host = "test.com" tenantDistAccPublicKey := "GDEWLTJMGKABNF3GBA3VTVBYPES3FXQHHJVJVI6X3CRKKFH5EMLRT5JZ" + distAccount := schema.NewDefaultStellarTransactionAccount(tenantDistAccPublicKey) testCases := []struct { name string @@ -141,12 +143,12 @@ func Test_StellarTomlHandler_buildGeneralInformation(t *testing.T) { if tc.isTenantInContext { mDistAccResolver. On("DistributionAccountFromContext", ctx). - Return(schema.NewDefaultStellarDistributionAccount(tenantDistAccPublicKey), nil). + Return(distAccount, nil). Once() } else { mDistAccResolver. On("DistributionAccountFromContext", ctx). - Return(nil, tenant.ErrTenantNotFoundInContext). + Return(schema.TransactionAccount{}, tenant.ErrTenantNotFoundInContext). Once() } tc.s.DistributionAccountResolver = mDistAccResolver @@ -426,12 +428,20 @@ func Test_StellarTomlHandler_ServeHTTP(t *testing.T) { ORG_NAME="SDP Pubnet" [[CURRENCIES]] - code = "USDC" - issuer = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" + code = %q + issuer = %q is_asset_anchored = true anchor_asset_type = "fiat" status = "live" - desc = "USDC" + desc = %q + + [[CURRENCIES]] + code = %q + issuer = %q + is_asset_anchored = true + anchor_asset_type = "fiat" + status = "live" + desc = %q [[CURRENCIES]] code = "native" @@ -439,7 +449,10 @@ func Test_StellarTomlHandler_ServeHTTP(t *testing.T) { is_asset_anchored = true anchor_asset_type = "crypto" desc = "XLM, the native token of the Stellar Network." - `, network.PublicNetworkPassphrase, horizonPubnetURL) + `, + network.PublicNetworkPassphrase, horizonPubnetURL, + assets.EURCAssetCode, assets.EURCAssetIssuerPubnet, assets.EURCAssetCode, + assets.USDCAssetCode, assets.USDCAssetIssuerPubnet, assets.USDCAssetCode) wantToml = strings.TrimSpace(wantToml) wantToml = strings.ReplaceAll(wantToml, "\t", "") assert.Equal(t, wantToml, rr.Body.String()) diff --git a/internal/serve/httphandler/update_receiver_handler.go b/internal/serve/httphandler/update_receiver_handler.go index a2233b2b6..d9eb52c58 100644 --- a/internal/serve/httphandler/update_receiver_handler.go +++ b/internal/serve/httphandler/update_receiver_handler.go @@ -1,6 +1,7 @@ package httphandler import ( + "errors" "fmt" "net/http" @@ -8,6 +9,7 @@ import ( "github.com/stellar/go/support/http/httpdecode" "github.com/stellar/go/support/log" "github.com/stellar/go/support/render/httpjson" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" @@ -21,29 +23,27 @@ type UpdateReceiverHandler struct { func createVerificationInsert(updateReceiverInfo *validators.UpdateReceiverRequest, receiverID string) []data.ReceiverVerificationInsert { receiverVerifications := []data.ReceiverVerificationInsert{} - - if updateReceiverInfo.DateOfBirth != "" { - receiverVerifications = append(receiverVerifications, data.ReceiverVerificationInsert{ - ReceiverID: receiverID, - VerificationField: data.VerificationFieldDateOfBirth, - VerificationValue: updateReceiverInfo.DateOfBirth, - }) - } - - if updateReceiverInfo.Pin != "" { - receiverVerifications = append(receiverVerifications, data.ReceiverVerificationInsert{ - ReceiverID: receiverID, - VerificationField: data.VerificationFieldPin, - VerificationValue: updateReceiverInfo.Pin, - }) + appendNewVerificationValue := func(verificationField data.VerificationField, verificationValue string) { + if verificationValue != "" { + receiverVerifications = append(receiverVerifications, data.ReceiverVerificationInsert{ + ReceiverID: receiverID, + VerificationField: verificationField, + VerificationValue: verificationValue, + }) + } } - if updateReceiverInfo.NationalID != "" { - receiverVerifications = append(receiverVerifications, data.ReceiverVerificationInsert{ - ReceiverID: receiverID, - VerificationField: data.VerificationFieldNationalID, - VerificationValue: updateReceiverInfo.NationalID, - }) + for _, verificationField := range data.GetAllVerificationFields() { + switch verificationField { + case data.VerificationFieldDateOfBirth: + appendNewVerificationValue(verificationField, updateReceiverInfo.DateOfBirth) + case data.VerificationFieldYearMonth: + appendNewVerificationValue(verificationField, updateReceiverInfo.YearMonth) + case data.VerificationFieldPin: + appendNewVerificationValue(verificationField, updateReceiverInfo.Pin) + case data.VerificationFieldNationalID: + appendNewVerificationValue(verificationField, updateReceiverInfo.NationalID) + } } return receiverVerifications @@ -71,6 +71,16 @@ func (h UpdateReceiverHandler) UpdateReceiver(rw http.ResponseWriter, req *http. } receiverID := chi.URLParam(req, "id") + _, err = h.Models.Receiver.Get(ctx, h.DBConnectionPool, receiverID) + if err != nil { + if errors.Is(err, data.ErrRecordNotFound) { + httperror.NotFound("Receiver not found", err, nil).Render(rw) + } else { + httperror.InternalError(ctx, "Cannot retrieve receiver", err, nil).Render(rw) + } + return + } + receiverVerifications := createVerificationInsert(&reqBody, receiverID) receiver, err := db.RunInTransactionWithResult(ctx, h.DBConnectionPool, nil, func(dbTx db.DBTransaction) (response *data.Receiver, innerErr error) { for _, rv := range receiverVerifications { diff --git a/internal/serve/httphandler/update_receiver_handler_test.go b/internal/serve/httphandler/update_receiver_handler_test.go index 8cb1ae714..5a267d1a6 100644 --- a/internal/serve/httphandler/update_receiver_handler_test.go +++ b/internal/serve/httphandler/update_receiver_handler_test.go @@ -11,12 +11,14 @@ import ( "testing" "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) func Test_UpdateReceiverHandler_createVerificationInsert(t *testing.T) { @@ -28,6 +30,12 @@ func Test_UpdateReceiverHandler_createVerificationInsert(t *testing.T) { VerificationValue: "1999-01-01", } + verificationYearMonth := data.ReceiverVerificationInsert{ + ReceiverID: receiverID, + VerificationField: data.VerificationFieldYearMonth, + VerificationValue: "1999-01", + } + verificationPIN := data.ReceiverVerificationInsert{ ReceiverID: receiverID, VerificationField: data.VerificationFieldPin, @@ -55,6 +63,11 @@ func Test_UpdateReceiverHandler_createVerificationInsert(t *testing.T) { updateReceiverRequest: validators.UpdateReceiverRequest{DateOfBirth: "1999-01-01"}, want: []data.ReceiverVerificationInsert{verificationDOB}, }, + { + name: "insert receiver verification year month", + updateReceiverRequest: validators.UpdateReceiverRequest{YearMonth: "1999-01"}, + want: []data.ReceiverVerificationInsert{verificationYearMonth}, + }, { name: "insert receiver verification pin", updateReceiverRequest: validators.UpdateReceiverRequest{Pin: "123"}, @@ -142,6 +155,18 @@ func Test_UpdateReceiverHandler(t *testing.T) { } `, }, + { + name: "invalid year/month", + request: validators.UpdateReceiverRequest{YearMonth: "invalid"}, + want: ` + { + "error": "request invalid", + "extras": { + "year_month": "invalid year/month format. Correct format: 1990-12" + } + } + `, + }, { name: "invalid pin", request: validators.UpdateReceiverRequest{Pin: " "}, @@ -149,19 +174,31 @@ func Test_UpdateReceiverHandler(t *testing.T) { { "error": "request invalid", "extras": { - "pin": "invalid pin format" + "pin": "invalid pin length. Cannot have less than 4 or more than 8 characters in pin" } } `, }, { - name: "invalid national ID", + name: "invalid national ID - empty", request: validators.UpdateReceiverRequest{NationalID: " "}, want: ` { "error": "request invalid", "extras": { - "national_id": "invalid national ID format" + "national_id": "national id cannot be empty" + } + } + `, + }, + { + name: "invalid national ID - too long", + request: validators.UpdateReceiverRequest{NationalID: fmt.Sprintf("%0*d", utils.VerificationFieldMaxIdLength+1, 0)}, + want: ` + { + "error": "request invalid", + "extras": { + "national_id": "invalid national id. Cannot have more than 50 characters in national id" } } `, @@ -212,6 +249,22 @@ func Test_UpdateReceiverHandler(t *testing.T) { } }) + t.Run("receiver not found", func(t *testing.T) { + request := validators.UpdateReceiverRequest{DateOfBirth: "1999-01-01"} + + route := fmt.Sprintf("/receivers/%s", "invalid_receiver_id") + reqBody, err := json.Marshal(request) + require.NoError(t, err) + req, err := http.NewRequest("PATCH", route, strings.NewReader(string(reqBody))) + require.NoError(t, err) + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + resp := rr.Result() + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + }) + t.Run("update date of birth value", func(t *testing.T) { data.CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, data.ReceiverVerificationInsert{ ReceiverID: receiver.ID, @@ -256,14 +309,58 @@ func Test_UpdateReceiverHandler(t *testing.T) { assert.Equal(t, "externalID", receiverDB.ExternalID) }) + t.Run("update year/month value", func(t *testing.T) { + data.CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, data.ReceiverVerificationInsert{ + ReceiverID: receiver.ID, + VerificationField: data.VerificationFieldYearMonth, + VerificationValue: "2000-01", + }) + + request := validators.UpdateReceiverRequest{YearMonth: "1999-01"} + + route := fmt.Sprintf("/receivers/%s", receiver.ID) + reqBody, err := json.Marshal(request) + require.NoError(t, err) + req, err := http.NewRequest("PATCH", route, strings.NewReader(string(reqBody))) + require.NoError(t, err) + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + resp := rr.Result() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + query := ` + SELECT + hashed_value + FROM + receiver_verifications + WHERE + receiver_id = $1 AND + verification_field = $2 + ` + + newReceiverVerification := data.ReceiverVerification{} + err = dbConnectionPool.GetContext(ctx, &newReceiverVerification, query, receiver.ID, data.VerificationFieldYearMonth) + require.NoError(t, err) + + assert.True(t, data.CompareVerificationValue(newReceiverVerification.HashedValue, "1999-01")) + assert.False(t, data.CompareVerificationValue(newReceiverVerification.HashedValue, "2000-01")) + + receiverDB, err := models.Receiver.Get(ctx, dbConnectionPool, receiver.ID) + require.NoError(t, err) + assert.Equal(t, "receiver@email.com", *receiverDB.Email) + assert.Equal(t, "externalID", receiverDB.ExternalID) + }) + t.Run("update pin value", func(t *testing.T) { data.CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, data.ReceiverVerificationInsert{ ReceiverID: receiver.ID, VerificationField: data.VerificationFieldPin, - VerificationValue: "890", + VerificationValue: "8901", }) - request := validators.UpdateReceiverRequest{Pin: "123"} + request := validators.UpdateReceiverRequest{Pin: "1234"} route := fmt.Sprintf("/receivers/%s", receiver.ID) reqBody, err := json.Marshal(request) @@ -278,12 +375,12 @@ func Test_UpdateReceiverHandler(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) query := ` - SELECT + SELECT hashed_value - FROM + FROM receiver_verifications - WHERE - receiver_id = $1 AND + WHERE + receiver_id = $1 AND verification_field = $2 ` @@ -291,8 +388,8 @@ func Test_UpdateReceiverHandler(t *testing.T) { err = dbConnectionPool.GetContext(ctx, &newReceiverVerification, query, receiver.ID, data.VerificationFieldPin) require.NoError(t, err) - assert.True(t, data.CompareVerificationValue(newReceiverVerification.HashedValue, "123")) - assert.False(t, data.CompareVerificationValue(newReceiverVerification.HashedValue, "890")) + assert.True(t, data.CompareVerificationValue(newReceiverVerification.HashedValue, "1234")) + assert.False(t, data.CompareVerificationValue(newReceiverVerification.HashedValue, "8901")) receiverDB, err := models.Receiver.Get(ctx, dbConnectionPool, receiver.ID) require.NoError(t, err) @@ -322,12 +419,12 @@ func Test_UpdateReceiverHandler(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) query := ` - SELECT + SELECT hashed_value - FROM + FROM receiver_verifications - WHERE - receiver_id = $1 AND + WHERE + receiver_id = $1 AND verification_field = $2 ` @@ -353,10 +450,16 @@ func Test_UpdateReceiverHandler(t *testing.T) { VerificationValue: "2000-01-01", }) + data.CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, data.ReceiverVerificationInsert{ + ReceiverID: receiver.ID, + VerificationField: data.VerificationFieldYearMonth, + VerificationValue: "2000-01", + }) + data.CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, data.ReceiverVerificationInsert{ ReceiverID: receiver.ID, VerificationField: data.VerificationFieldPin, - VerificationValue: "890", + VerificationValue: "8901", }) data.CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, data.ReceiverVerificationInsert{ @@ -367,7 +470,8 @@ func Test_UpdateReceiverHandler(t *testing.T) { request := validators.UpdateReceiverRequest{ DateOfBirth: "1999-01-01", - Pin: "123", + YearMonth: "1999-01", + Pin: "1234", NationalID: "NEWID123", } @@ -403,10 +507,15 @@ func Test_UpdateReceiverHandler(t *testing.T) { newVerificationValue: "1999-01-01", oldVerificationValue: "2000-01-01", }, + { + verificationField: data.VerificationFieldYearMonth, + newVerificationValue: "1999-01", + oldVerificationValue: "2000-01", + }, { verificationField: data.VerificationFieldPin, - newVerificationValue: "123", - oldVerificationValue: "890", + newVerificationValue: "1234", + oldVerificationValue: "8901", }, { verificationField: data.VerificationFieldNationalID, @@ -432,15 +541,10 @@ func Test_UpdateReceiverHandler(t *testing.T) { t.Run("updates and inserts receiver verifications values", func(t *testing.T) { data.DeleteAllReceiverVerificationFixtures(t, ctx, dbConnectionPool) - data.CreateReceiverVerificationFixture(t, ctx, dbConnectionPool, data.ReceiverVerificationInsert{ - ReceiverID: receiver.ID, - VerificationField: data.VerificationFieldPin, - VerificationValue: "890", - }) - request := validators.UpdateReceiverRequest{ DateOfBirth: "1999-01-01", - Pin: "123", + YearMonth: "1999-01", + Pin: "1234", NationalID: "NEWID123", } @@ -476,9 +580,14 @@ func Test_UpdateReceiverHandler(t *testing.T) { newVerificationValue: "1999-01-01", oldVerificationValue: "2000-01-01", }, + { + verificationField: data.VerificationFieldYearMonth, + newVerificationValue: "1999-01", + oldVerificationValue: "", + }, { verificationField: data.VerificationFieldPin, - newVerificationValue: "123", + newVerificationValue: "1234", oldVerificationValue: "", }, { @@ -491,6 +600,7 @@ func Test_UpdateReceiverHandler(t *testing.T) { newReceiverVerification := data.ReceiverVerification{} err = dbConnectionPool.GetContext(ctx, &newReceiverVerification, query, receiver.ID, v.verificationField) require.NoError(t, err) + t.Logf("newReceiverVerification: %+v", newReceiverVerification) assert.True(t, data.CompareVerificationValue(newReceiverVerification.HashedValue, v.newVerificationValue)) diff --git a/internal/serve/httphandler/user_handler.go b/internal/serve/httphandler/user_handler.go index d6b53b964..d6c602993 100644 --- a/internal/serve/httphandler/user_handler.go +++ b/internal/serve/httphandler/user_handler.go @@ -9,6 +9,7 @@ import ( "github.com/stellar/go/support/http/httpdecode" "github.com/stellar/go/support/log" "github.com/stellar/go/support/render/httpjson" + "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" diff --git a/internal/serve/httphandler/user_handler_test.go b/internal/serve/httphandler/user_handler_test.go index 6308f1e10..77892ed50 100644 --- a/internal/serve/httphandler/user_handler_test.go +++ b/internal/serve/httphandler/user_handler_test.go @@ -12,6 +12,10 @@ import ( "github.com/go-chi/chi/v5" "github.com/stellar/go/support/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" @@ -22,9 +26,6 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" ) func Test_UserHandler_UserActivation(t *testing.T) { diff --git a/internal/serve/httphandler/verifiy_receiver_registration_handler.go b/internal/serve/httphandler/verifiy_receiver_registration_handler.go index 88f45286a..3f47552c3 100644 --- a/internal/serve/httphandler/verifiy_receiver_registration_handler.go +++ b/internal/serve/httphandler/verifiy_receiver_registration_handler.go @@ -19,6 +19,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) @@ -44,12 +45,13 @@ const ( ) type VerifyReceiverRegistrationHandler struct { - AnchorPlatformAPIService anchorplatform.AnchorPlatformAPIServiceInterface - Models *data.Models - ReCAPTCHAValidator validators.ReCAPTCHAValidator - NetworkPassphrase string - EventProducer events.Producer - CrashTrackerClient crashtracker.CrashTrackerClient + AnchorPlatformAPIService anchorplatform.AnchorPlatformAPIServiceInterface + Models *data.Models + ReCAPTCHAValidator validators.ReCAPTCHAValidator + NetworkPassphrase string + EventProducer events.Producer + CrashTrackerClient crashtracker.CrashTrackerClient + DistributionAccountResolver signing.DistributionAccountResolver } // validate validates the request [header, body, body.reCAPTCHA_token], and returns the decoded payload, or an http error. @@ -348,7 +350,12 @@ func (v VerifyReceiverRegistrationHandler) buildPaymentsReadyToPayEventMessage(c return nil, nil } - msg, err := events.NewMessage(ctx, events.PaymentReadyToPayTopic, rw.ID, events.PaymentReadyToPayReceiverVerificationCompleted, nil) + distAccount, err := v.DistributionAccountResolver.DistributionAccountFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("resolving distribution account: %w", err) + } + + msg, err := events.NewPaymentReadyToPayMessage(ctx, distAccount.Type.Platform(), rw.ID, events.PaymentReadyToPayReceiverVerificationCompleted) if err != nil { return nil, fmt.Errorf("creating new message: %w", err) } diff --git a/internal/serve/httphandler/verifiy_receiver_registration_handler_test.go b/internal/serve/httphandler/verifiy_receiver_registration_handler_test.go index 45c4787a4..8b2600a18 100644 --- a/internal/serve/httphandler/verifiy_receiver_registration_handler_test.go +++ b/internal/serve/httphandler/verifiy_receiver_registration_handler_test.go @@ -29,6 +29,8 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httperror" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/validators" + sigMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) @@ -237,7 +239,17 @@ func Test_VerifyReceiverRegistrationHandler_processReceiverVerificationPII(t *te wantErrContains: "DATE_OF_BIRTH not found for receiver with phone number +38...333", }, { - name: "returns an error if the receiver does not have any receiverVerification row with the given verification type", + name: "returns an error if the receiver does not have any receiverVerification row with the given verification type (YEAR_MONTH)", + receiver: *receiver, + registrationRequest: data.ReceiverRegistrationRequest{ + PhoneNumber: receiver.PhoneNumber, + VerificationType: data.VerificationFieldYearMonth, + VerificationValue: "1999-12", + }, + wantErrContains: "YEAR_MONTH not found for receiver with phone number +38...555", + }, + { + name: "returns an error if the receiver does not have any receiverVerification row with the given verification type (NATIONAL_ID_NUMBER)", receiver: *receiver, registrationRequest: data.ReceiverRegistrationRequest{ PhoneNumber: receiver.PhoneNumber, @@ -557,7 +569,6 @@ func Test_VerifyReceiverRegistrationHandler_buildPaymentsReadyToPayEventMessage( models, err := data.NewModels(dbConnectionPool) require.NoError(t, err) - handler := VerifyReceiverRegistrationHandler{Models: models} data.DeleteAllFixtures(t, ctx, dbConnectionPool) @@ -571,6 +582,10 @@ func Test_VerifyReceiverRegistrationHandler_buildPaymentsReadyToPayEventMessage( defer data.DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) defer data.DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) + handler := VerifyReceiverRegistrationHandler{ + Models: models, + } + pausedDisbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ Wallet: wallet, Asset: asset, @@ -601,6 +616,15 @@ func Test_VerifyReceiverRegistrationHandler_buildPaymentsReadyToPayEventMessage( defer data.DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) defer data.DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) ctxWithoutTenant := context.Background() + distAccountResolverMock := sigMocks.NewMockDistributionAccountResolver(t) + distAccountResolverMock. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarEnv}, nil). + Once() + handler := VerifyReceiverRegistrationHandler{ + Models: models, + DistributionAccountResolver: distAccountResolverMock, + } disbursement := data.CreateDisbursementFixture(t, ctxWithoutTenant, dbConnectionPool, models.Disbursements, &data.Disbursement{ Wallet: wallet, @@ -622,10 +646,20 @@ func Test_VerifyReceiverRegistrationHandler_buildPaymentsReadyToPayEventMessage( assert.Nil(t, msg) }) - t.Run("🎉 successfully builds the message", func(t *testing.T) { + t.Run("🎉 successfully builds the message for stellar payment", func(t *testing.T) { defer data.DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) defer data.DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) + distAccountResolverMock := sigMocks.NewMockDistributionAccountResolver(t) + distAccountResolverMock. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarEnv}, nil). + Once() + handler := VerifyReceiverRegistrationHandler{ + Models: models, + DistributionAccountResolver: distAccountResolverMock, + } + disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ Wallet: wallet, Asset: asset, @@ -660,6 +694,55 @@ func Test_VerifyReceiverRegistrationHandler_buildPaymentsReadyToPayEventMessage( assert.NoError(t, err) assert.Equal(t, expectedMessage, *msg) }) + + t.Run("🎉 successfully builds the message for circle payment", func(t *testing.T) { + defer data.DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) + defer data.DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) + + distAccountResolverMock := sigMocks.NewMockDistributionAccountResolver(t) + distAccountResolverMock. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountCircleDBVault}, nil). + Once() + handler := VerifyReceiverRegistrationHandler{ + Models: models, + DistributionAccountResolver: distAccountResolverMock, + } + + disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Wallet: wallet, + Asset: asset, + Country: country, + Status: data.StartedDisbursementStatus, + }) + + payment := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + Amount: "100", + Status: data.ReadyPaymentStatus, + Disbursement: disbursement, + Asset: *asset, + ReceiverWallet: rw, + }) + + expectedMessage := events.Message{ + Topic: events.CirclePaymentReadyToPayTopic, + Key: rw.ID, + TenantID: tnt.ID, + Type: events.PaymentReadyToPayReceiverVerificationCompleted, + Data: schemas.EventPaymentsReadyToPayData{ + TenantID: tnt.ID, + Payments: []schemas.PaymentReadyToPay{ + { + ID: payment.ID, + }, + }, + }, + } + + msg, err := handler.buildPaymentsReadyToPayEventMessage(ctx, dbConnectionPool, rw) + assert.NoError(t, err) + assert.Equal(t, expectedMessage, *msg) + }) } func Test_VerifyReceiverRegistrationHandler_VerifyReceiverRegistration(t *testing.T) { @@ -1271,6 +1354,13 @@ func Test_VerifyReceiverRegistrationHandler_VerifyReceiverRegistration(t *testin mockCrashTracker := &crashtracker.MockCrashTrackerClient{} defer mockCrashTracker.AssertExpectations(t) mockEventProducer := events.NewMockProducer(t) + + distAccountResolverMock := sigMocks.NewMockDistributionAccountResolver(t) + distAccountResolverMock. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarEnv}, nil). + Maybe() + if tc.produccesEventSuccessfully { mockEventProducer. On("WriteMessages", mock.Anything, []events.Message{ @@ -1300,11 +1390,12 @@ func Test_VerifyReceiverRegistrationHandler_VerifyReceiverRegistration(t *testin // create handler handler := &VerifyReceiverRegistrationHandler{ - Models: models, - ReCAPTCHAValidator: reCAPTCHAValidator, - AnchorPlatformAPIService: mockAnchorPlatformService, - EventProducer: mockEventProducer, - CrashTrackerClient: mockCrashTracker, + Models: models, + ReCAPTCHAValidator: reCAPTCHAValidator, + AnchorPlatformAPIService: mockAnchorPlatformService, + EventProducer: mockEventProducer, + CrashTrackerClient: mockCrashTracker, + DistributionAccountResolver: distAccountResolverMock, } // setup router and execute request diff --git a/internal/serve/httphandler/wallets_handler_test.go b/internal/serve/httphandler/wallets_handler_test.go index 0a0813c7e..3ca54842c 100644 --- a/internal/serve/httphandler/wallets_handler_test.go +++ b/internal/serve/httphandler/wallets_handler_test.go @@ -11,11 +11,12 @@ import ( "testing" "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_WalletsHandlerGetWallets(t *testing.T) { diff --git a/internal/serve/publicfiles/js/receiver_registration.js b/internal/serve/publicfiles/js/receiver_registration.js index a0a32b972..375e3bc4f 100644 --- a/internal/serve/publicfiles/js/receiver_registration.js +++ b/internal/serve/publicfiles/js/receiver_registration.js @@ -163,6 +163,11 @@ async function submitPhoneNumber(event) { verificationFieldInput.name = "date_of_birth"; verificationFieldInput.type = "date"; } + else if(verificationField === "YEAR_MONTH") { + verificationFieldTitle.textContent = "Date of birth (Year/Month)"; + verificationFieldInput.name = "year_month"; + verificationFieldInput.type = "month"; + } else if(verificationField === "NATIONAL_ID_NUMBER") { verificationFieldTitle.textContent = "National ID number"; verificationFieldInput.name = "national_id_number"; diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 5dd5d0369..dff12a2b0 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -17,6 +17,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/anchorplatform" + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/events" @@ -69,6 +70,7 @@ type ServeOptions struct { BaseURL string ResetTokenExpirationHours int NetworkPassphrase string + NetworkType utils.NetworkType SubmitterEngine engine.SubmitterEngine Sep10SigningPublicKey string Sep10SigningPrivateKey string @@ -84,9 +86,12 @@ type ServeOptions struct { PasswordValidator *authUtils.PasswordValidator EnableScheduler bool tenantManager tenant.ManagerInterface + DistributionAccountService services.DistributionAccountServiceInterface + DistAccEncryptionPassphrase string EventProducer events.Producer MaxInvitationSMSResendAttempts int SingleTenantMode bool + CircleService circle.ServiceInterface } // SetupDependencies uses the serve options to setup the dependencies for the server. @@ -252,11 +257,11 @@ func handleHTTP(o ServeOptions) *chi.Mux { MonitorService: o.MonitorService, DistributionAccountResolver: o.SubmitterEngine.DistributionAccountResolver, DisbursementManagementService: &services.DisbursementManagementService{ - Models: o.Models, - AuthManager: authManager, - HorizonClient: o.SubmitterEngine.HorizonClient, - EventProducer: o.EventProducer, - CrashTrackerClient: o.CrashTrackerClient, + Models: o.Models, + AuthManager: authManager, + EventProducer: o.EventProducer, + CrashTrackerClient: o.CrashTrackerClient, + DistributionAccountService: o.DistributionAccountService, }, } r.With(middleware.AnyRoleMiddleware(authManager, data.OwnerUserRole, data.FinancialControllerUserRole)). @@ -283,11 +288,12 @@ func handleHTTP(o ServeOptions) *chi.Mux { r.With(middleware.AnyRoleMiddleware(authManager, data.OwnerUserRole, data.FinancialControllerUserRole, data.BusinessUserRole)).Route("/payments", func(r chi.Router) { paymentsHandler := httphandler.PaymentsHandler{ - Models: o.Models, - DBConnectionPool: o.MtnDBConnectionPool, - AuthManager: o.authManager, - EventProducer: o.EventProducer, - CrashTrackerClient: o.CrashTrackerClient, + Models: o.Models, + DBConnectionPool: o.MtnDBConnectionPool, + AuthManager: o.authManager, + EventProducer: o.EventProducer, + CrashTrackerClient: o.CrashTrackerClient, + DistributionAccountResolver: o.SubmitterEngine.DistributionAccountResolver, } r.Get("/", paymentsHandler.GetPayments) r.Get("/{id}", paymentsHandler.GetPayment) @@ -380,7 +386,25 @@ func handleHTTP(o ServeOptions) *chi.Mux { r.With(middleware.AnyRoleMiddleware(authManager, data.GetAllRoles()...)). Get("/logo", profileHandler.GetOrganizationLogo) + + r.With(middleware.AnyRoleMiddleware(authManager, data.OwnerUserRole)). + Patch("/circle-config", httphandler.CircleConfigHandler{ + NetworkType: o.NetworkType, + CircleFactory: circle.NewClient, + TenantManager: o.tenantManager, + Encrypter: &utils.DefaultPrivateKeyEncrypter{}, + EncryptionPassphrase: o.DistAccEncryptionPassphrase, + CircleClientConfigModel: circle.NewClientConfigModel(o.MtnDBConnectionPool), + DistributionAccountResolver: o.SubmitterEngine.DistributionAccountResolver, + }.Patch) }) + + balancesHandler := httphandler.BalancesHandler{ + DistributionAccountResolver: o.SubmitterEngine.DistributionAccountResolver, + CircleService: o.CircleService, + NetworkType: o.NetworkType, + } + r.Get("/balances", balancesHandler.Get) }) reCAPTCHAValidator := validators.NewGoogleReCAPTCHAValidator(o.ReCAPTCHASiteSecretKey, httpclient.DefaultClient()) @@ -447,12 +471,13 @@ func handleHTTP(o ServeOptions) *chi.Mux { sep24HeaderTokenAuthenticationMiddleware := anchorplatform.SEP24HeaderTokenAuthenticateMiddleware(o.sep24JWTManager, o.NetworkPassphrase, o.tenantManager, o.SingleTenantMode) r.With(sep24HeaderTokenAuthenticationMiddleware).Post("/otp", httphandler.ReceiverSendOTPHandler{Models: o.Models, SMSMessengerClient: o.SMSMessengerClient, ReCAPTCHAValidator: reCAPTCHAValidator}.ServeHTTP) r.With(sep24HeaderTokenAuthenticationMiddleware).Post("/verification", httphandler.VerifyReceiverRegistrationHandler{ - AnchorPlatformAPIService: o.AnchorPlatformAPIService, - Models: o.Models, - ReCAPTCHAValidator: reCAPTCHAValidator, - NetworkPassphrase: o.NetworkPassphrase, - EventProducer: o.EventProducer, - CrashTrackerClient: o.CrashTrackerClient, + AnchorPlatformAPIService: o.AnchorPlatformAPIService, + Models: o.Models, + ReCAPTCHAValidator: reCAPTCHAValidator, + NetworkPassphrase: o.NetworkPassphrase, + EventProducer: o.EventProducer, + CrashTrackerClient: o.CrashTrackerClient, + DistributionAccountResolver: o.SubmitterEngine.DistributionAccountResolver, }.VerifyReceiverRegistration) }) diff --git a/internal/serve/serve_metrics.go b/internal/serve/serve_metrics.go index 584a3111c..27d7cac98 100644 --- a/internal/serve/serve_metrics.go +++ b/internal/serve/serve_metrics.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" supporthttp "github.com/stellar/go/support/http" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor" ) diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index bc14d173d..98b19b220 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -9,8 +9,6 @@ import ( "testing" "time" - "github.com/stellar/stellar-disbursement-platform-backend/internal/events" - "github.com/go-chi/chi/v5" "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/network" @@ -25,6 +23,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/events" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor" monitorMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/monitor/mocks" @@ -315,16 +314,17 @@ func getServeOptionsForTests(t *testing.T, dbConnectionPool db.DBConnectionPool) mHorizonClient := &horizonclient.MockClient{} mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, _, _, _, distAccResolver := signing.NewMockSignatureService(t) + sigService, _, distAccResolver := signing.NewMockSignatureService(t) submitterEngine := engine.SubmitterEngine{ HorizonClient: mHorizonClient, SignatureService: sigService, LedgerNumberTracker: mLedgerNumberTracker, MaxBaseFee: 100 * txnbuild.MinBaseFee, } + distAccount := schema.NewDefaultStellarTransactionAccount(distAccPublicKey) distAccResolver. On("DistributionAccountFromContext", mock.Anything). - Return(schema.NewDefaultStellarDistributionAccount(distAccPublicKey), nil). + Return(distAccount, nil). Maybe() producerMock := events.NewMockProducer(t) @@ -414,7 +414,7 @@ func Test_handleHTTP_authenticatedEndpoints(t *testing.T) { handlerMux := handleHTTP(serveOptions) // Authenticated endpoints - authenticatedEndpoints := []struct { // TODO: body to requests + authenticatedEndpoints := []struct { method string path string }{ @@ -468,6 +468,9 @@ func Test_handleHTTP_authenticatedEndpoints(t *testing.T) { {http.MethodGet, "/organization"}, {http.MethodPatch, "/organization"}, {http.MethodGet, "/organization/logo"}, + {http.MethodPatch, "/organization/circle-config"}, + // Balances + {http.MethodGet, "/balances"}, } // Expect 401 as a response: diff --git a/internal/serve/validators/disbursement_instructions_validator.go b/internal/serve/validators/disbursement_instructions_validator.go index f7c988de9..2c9e85b45 100644 --- a/internal/serve/validators/disbursement_instructions_validator.go +++ b/internal/serve/validators/disbursement_instructions_validator.go @@ -3,19 +3,11 @@ package validators import ( "fmt" "strings" - "time" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) -const ( - VERIFICATION_FIELD_PIN_MIN_LENGTH = 4 - VERIFICATION_FIELD_PIN_MAX_LENGTH = 8 - - VERIFICATION_FIELD_MAX_ID_LENGTH = 50 -) - type DisbursementInstructionsValidator struct { verificationField data.VerificationField *Validator @@ -29,17 +21,19 @@ func NewDisbursementInstructionsValidator(verificationField data.VerificationFie } func (iv *DisbursementInstructionsValidator) ValidateInstruction(instruction *data.DisbursementInstruction, lineNumber int) { - phone := instruction.Phone - id := instruction.ID - amount := instruction.Amount - verification := instruction.VerificationValue + phone := strings.TrimSpace(instruction.Phone) + id := strings.TrimSpace(instruction.ID) + amount := strings.TrimSpace(instruction.Amount) + verification := strings.TrimSpace(instruction.VerificationValue) // validate phone field - iv.CheckError(utils.ValidatePhoneNumber(phone), fmt.Sprintf("line %d - phone", lineNumber), "invalid phone format. Correct format: +380445555555") - iv.Check(strings.TrimSpace(phone) != "", fmt.Sprintf("line %d - phone", lineNumber), "phone cannot be empty") + iv.Check(phone != "", fmt.Sprintf("line %d - phone", lineNumber), "phone cannot be empty") + if phone != "" { + iv.CheckError(utils.ValidatePhoneNumber(phone), fmt.Sprintf("line %d - phone", lineNumber), "invalid phone format. Correct format: +380445555555") + } // validate id field - iv.Check(strings.TrimSpace(id) != "", fmt.Sprintf("line %d - id", lineNumber), "id cannot be empty") + iv.Check(id != "", fmt.Sprintf("line %d - id", lineNumber), "id cannot be empty") // validate amount field iv.CheckError(utils.ValidateAmount(amount), fmt.Sprintf("line %d - amount", lineNumber), "invalid amount. Amount must be a positive number") @@ -47,20 +41,13 @@ func (iv *DisbursementInstructionsValidator) ValidateInstruction(instruction *da // validate verification field switch iv.verificationField { case data.VerificationFieldDateOfBirth: - // date of birth with format 2006-01-02 - dob, err := time.Parse("2006-01-02", verification) - iv.CheckError(err, fmt.Sprintf("line %d - birthday", lineNumber), "invalid date of birth format. Correct format: 1990-01-01") - - // check if date of birth is in the past - iv.Check(dob.Before(time.Now()), fmt.Sprintf("line %d - birthday", lineNumber), "date of birth cannot be in the future") + iv.CheckError(utils.ValidateDateOfBirthVerification(verification), fmt.Sprintf("line %d - date of birth", lineNumber), "") + case data.VerificationFieldYearMonth: + iv.CheckError(utils.ValidateYearMonthVerification(verification), fmt.Sprintf("line %d - year/month", lineNumber), "") case data.VerificationFieldPin: - if len(verification) < VERIFICATION_FIELD_PIN_MIN_LENGTH || len(verification) > VERIFICATION_FIELD_PIN_MAX_LENGTH { - iv.addError(fmt.Sprintf("line %d - pin", lineNumber), "invalid pin. Cannot have less than 4 or more than 8 characters in pin") - } + iv.CheckError(utils.ValidatePinVerification(verification), fmt.Sprintf("line %d - pin", lineNumber), "") case data.VerificationFieldNationalID: - if len(verification) > VERIFICATION_FIELD_MAX_ID_LENGTH { - iv.addError(fmt.Sprintf("line %d - national id", lineNumber), "invalid national id. Cannot have more than 50 characters in national id") - } + iv.CheckError(utils.ValidateNationalIDVerification(verification), fmt.Sprintf("line %d - national id", lineNumber), "") } } diff --git a/internal/serve/validators/disbursement_instructions_validator_test.go b/internal/serve/validators/disbursement_instructions_validator_test.go index f880d4ce2..f577c79a8 100644 --- a/internal/serve/validators/disbursement_instructions_validator_test.go +++ b/internal/serve/validators/disbursement_instructions_validator_test.go @@ -3,34 +3,23 @@ package validators import ( "testing" - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stretchr/testify/assert" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" ) func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing.T) { tests := []struct { name string - actual *data.DisbursementInstruction + instruction *data.DisbursementInstruction lineNumber int verificationField data.VerificationField hasErrors bool expectedErrors map[string]interface{} }{ { - name: "valid record", - actual: &data.DisbursementInstruction{ - Phone: "+380445555555", - ID: "123456789", - Amount: "100.5", - VerificationValue: "1990-01-01", - }, - lineNumber: 1, - verificationField: data.VerificationFieldDateOfBirth, - hasErrors: false, - }, - { - name: "empty phone number", - actual: &data.DisbursementInstruction{ + name: "error if phone number is empty", + instruction: &data.DisbursementInstruction{ ID: "123456789", Amount: "100.5", VerificationValue: "1990-01-01", @@ -43,21 +32,21 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing }, }, { - name: "empty phone, id, amount and birthday", - actual: &data.DisbursementInstruction{}, + name: "error with all fields empty (phone, id, amount, date of birth)", + instruction: &data.DisbursementInstruction{}, lineNumber: 2, verificationField: data.VerificationFieldDateOfBirth, hasErrors: true, expectedErrors: map[string]interface{}{ - "line 2 - amount": "invalid amount. Amount must be a positive number", - "line 2 - birthday": "invalid date of birth format. Correct format: 1990-01-01", - "line 2 - id": "id cannot be empty", - "line 2 - phone": "phone cannot be empty", + "line 2 - amount": "invalid amount. Amount must be a positive number", + "line 2 - date of birth": "date of birth cannot be empty", + "line 2 - id": "id cannot be empty", + "line 2 - phone": "phone cannot be empty", }, }, { - name: "invalid phone number", - actual: &data.DisbursementInstruction{ + name: "error if phone number format is invalid", + instruction: &data.DisbursementInstruction{ Phone: "+123-12-345-678", ID: "123456789", Amount: "100.5", @@ -71,8 +60,8 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing }, }, { - name: "invalid amount format", - actual: &data.DisbursementInstruction{ + name: "error if amount format is invalid", + instruction: &data.DisbursementInstruction{ Phone: "+380445555555", ID: "123456789", Amount: "100.5USDC", @@ -86,8 +75,8 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing }, }, { - name: "amount must be positive", - actual: &data.DisbursementInstruction{ + name: "error if amount is not positive", + instruction: &data.DisbursementInstruction{ Phone: "+380445555555", ID: "123456789", Amount: "-100.5", @@ -101,8 +90,8 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing }, }, { - name: "invalid birthday format", - actual: &data.DisbursementInstruction{ + name: "error if DoB format is invalid", + instruction: &data.DisbursementInstruction{ Phone: "+380445555555", ID: "123456789", Amount: "100.5", @@ -112,12 +101,12 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing verificationField: data.VerificationFieldDateOfBirth, hasErrors: true, expectedErrors: map[string]interface{}{ - "line 3 - birthday": "invalid date of birth format. Correct format: 1990-01-01", + "line 3 - date of birth": "invalid date of birth format. Correct format: 1990-01-30", }, }, { - name: "date of birth in the future", - actual: &data.DisbursementInstruction{ + name: "error if DoB in the future", + instruction: &data.DisbursementInstruction{ Phone: "+380445555555", ID: "123456789", Amount: "100.5", @@ -127,24 +116,42 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing verificationField: data.VerificationFieldDateOfBirth, hasErrors: true, expectedErrors: map[string]interface{}{ - "line 3 - birthday": "date of birth cannot be in the future", + "line 3 - date of birth": "date of birth cannot be in the future", }, }, { - name: "valid pin", - actual: &data.DisbursementInstruction{ + name: "error if year month format is invalid", + instruction: &data.DisbursementInstruction{ Phone: "+380445555555", ID: "123456789", Amount: "100.5", - VerificationValue: "1234", + VerificationValue: "1990/01", }, lineNumber: 3, - verificationField: data.VerificationFieldPin, - hasErrors: false, + verificationField: data.VerificationFieldYearMonth, + hasErrors: true, + expectedErrors: map[string]interface{}{ + "line 3 - year/month": "invalid year/month format. Correct format: 1990-12", + }, }, { - name: "invalid pin - less than 4 characters", - actual: &data.DisbursementInstruction{ + name: "error if year month in the future", + instruction: &data.DisbursementInstruction{ + Phone: "+380445555555", + ID: "123456789", + Amount: "100.5", + VerificationValue: "2090-01", + }, + lineNumber: 3, + verificationField: data.VerificationFieldYearMonth, + hasErrors: true, + expectedErrors: map[string]interface{}{ + "line 3 - year/month": "year/month cannot be in the future", + }, + }, + { + name: "error if PIN is invalid - less than 4 characters", + instruction: &data.DisbursementInstruction{ Phone: "+380445555555", ID: "123456789", Amount: "100.5", @@ -154,12 +161,12 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing verificationField: data.VerificationFieldPin, hasErrors: true, expectedErrors: map[string]interface{}{ - "line 3 - pin": "invalid pin. Cannot have less than 4 or more than 8 characters in pin", + "line 3 - pin": "invalid pin length. Cannot have less than 4 or more than 8 characters in pin", }, }, { - name: "invalid pin - more than 8 characters", - actual: &data.DisbursementInstruction{ + name: "error if PIN is invalid - more than 8 characters", + instruction: &data.DisbursementInstruction{ Phone: "+380445555555", ID: "123456789", Amount: "100.5", @@ -169,42 +176,79 @@ func Test_DisbursementInstructionsValidator_ValidateAndGetInstruction(t *testing verificationField: data.VerificationFieldPin, hasErrors: true, expectedErrors: map[string]interface{}{ - "line 3 - pin": "invalid pin. Cannot have less than 4 or more than 8 characters in pin", + "line 3 - pin": "invalid pin length. Cannot have less than 4 or more than 8 characters in pin", }, }, { - name: "valid national id", - actual: &data.DisbursementInstruction{ + name: "error if NATIONAL_ID_NUMBER is invalid - more than 50 characters", + instruction: &data.DisbursementInstruction{ Phone: "+380445555555", ID: "123456789", Amount: "100.5", - VerificationValue: "ABCD123", + VerificationValue: "6UZMB56FWTKV4U0PJ21TBR6VOQVYSGIMZG2HW2S0L7EK5K83W78", }, lineNumber: 3, verificationField: data.VerificationFieldNationalID, + hasErrors: true, + expectedErrors: map[string]interface{}{ + "line 3 - national id": "invalid national id. Cannot have more than 50 characters in national id", + }, + }, + // VALID CASES + { + name: "🎉 successfully validates instructions (DATE_OF_BIRTH)", + instruction: &data.DisbursementInstruction{ + Phone: "+380445555555", + ID: "123456789", + Amount: "100.5", + VerificationValue: "1990-01-01", + }, + lineNumber: 1, + verificationField: data.VerificationFieldDateOfBirth, hasErrors: false, }, { - name: "invalid national - more than 50 characters", - actual: &data.DisbursementInstruction{ + name: "🎉 successfully validates instructions (YEAR_MONTH)", + instruction: &data.DisbursementInstruction{ Phone: "+380445555555", ID: "123456789", Amount: "100.5", - VerificationValue: "6UZMB56FWTKV4U0PJ21TBR6VOQVYSGIMZG2HW2S0L7EK5K83W78", + VerificationValue: "1990-01", + }, + lineNumber: 1, + verificationField: data.VerificationFieldYearMonth, + hasErrors: false, + }, + { + name: "🎉 successfully validates instructions (NATIONAL_ID_NUMBER)", + instruction: &data.DisbursementInstruction{ + Phone: "+380445555555", + ID: "123456789", + Amount: "100.5", + VerificationValue: "ABCD123", }, lineNumber: 3, verificationField: data.VerificationFieldNationalID, - hasErrors: true, - expectedErrors: map[string]interface{}{ - "line 3 - national id": "invalid national id. Cannot have more than 50 characters in national id", + hasErrors: false, + }, + { + name: "🎉 successfully validates instructions (PIN)", + instruction: &data.DisbursementInstruction{ + Phone: "+380445555555", + ID: "123456789", + Amount: "100.5", + VerificationValue: "1234", }, + lineNumber: 3, + verificationField: data.VerificationFieldPin, + hasErrors: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { iv := NewDisbursementInstructionsValidator(tt.verificationField) - iv.ValidateInstruction(tt.actual, tt.lineNumber) + iv.ValidateInstruction(tt.instruction, tt.lineNumber) if tt.hasErrors { assert.Equal(t, tt.expectedErrors, iv.Errors) diff --git a/internal/serve/validators/disbursement_query_validator_test.go b/internal/serve/validators/disbursement_query_validator_test.go index ef9ef43e2..09f13ba23 100644 --- a/internal/serve/validators/disbursement_query_validator_test.go +++ b/internal/serve/validators/disbursement_query_validator_test.go @@ -4,8 +4,9 @@ import ( "testing" "time" - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stretchr/testify/assert" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" ) func Test_DisbursementQueryValidator_ValidateDisbursementFilters(t *testing.T) { diff --git a/internal/serve/validators/disbursement_request_validator.go b/internal/serve/validators/disbursement_request_validator.go index 7bbcc1c6d..f93721308 100644 --- a/internal/serve/validators/disbursement_request_validator.go +++ b/internal/serve/validators/disbursement_request_validator.go @@ -1,6 +1,11 @@ package validators -import "github.com/stellar/stellar-disbursement-platform-backend/internal/data" +import ( + "fmt" + "slices" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" +) type DisbursementRequestValidator struct { verificationField data.VerificationField @@ -16,11 +21,9 @@ func NewDisbursementRequestValidator(verificationField data.VerificationField) * // ValidateAndGetVerificationType validates if the verification type field is a valid value. func (dv *DisbursementRequestValidator) ValidateAndGetVerificationType() data.VerificationField { - switch dv.verificationField { - case data.VerificationFieldDateOfBirth, data.VerificationFieldPin, data.VerificationFieldNationalID: - return dv.verificationField - default: - dv.Check(false, "verification_field", "invalid parameter. valid values are: DATE_OF_BIRTH, PIN, NATIONAL_ID_NUMBER") + if !slices.Contains(data.GetAllVerificationFields(), dv.verificationField) { + dv.Check(false, "verification_field", fmt.Sprintf("invalid parameter. valid values are: %v", data.GetAllVerificationFields())) return "" } + return dv.verificationField } diff --git a/internal/serve/validators/disbursement_request_validator_test.go b/internal/serve/validators/disbursement_request_validator_test.go index b2e326c47..8d65be8cf 100644 --- a/internal/serve/validators/disbursement_request_validator_test.go +++ b/internal/serve/validators/disbursement_request_validator_test.go @@ -3,14 +3,16 @@ package validators import ( "testing" - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stretchr/testify/assert" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" ) func Test_DisbursementRequestValidator_ValidateAndGetVerificationType(t *testing.T) { t.Run("Valid verification type", func(t *testing.T) { validField := []data.VerificationField{ data.VerificationFieldDateOfBirth, + data.VerificationFieldYearMonth, data.VerificationFieldPin, data.VerificationFieldNationalID, } @@ -27,6 +29,6 @@ func Test_DisbursementRequestValidator_ValidateAndGetVerificationType(t *testing actual := validator.ValidateAndGetVerificationType() assert.Empty(t, actual) assert.Equal(t, 1, len(validator.Errors)) - assert.Equal(t, "invalid parameter. valid values are: DATE_OF_BIRTH, PIN, NATIONAL_ID_NUMBER", validator.Errors["verification_field"]) + assert.Equal(t, "invalid parameter. valid values are: [DATE_OF_BIRTH YEAR_MONTH PIN NATIONAL_ID_NUMBER]", validator.Errors["verification_field"]) }) } diff --git a/internal/serve/validators/payment_query_validator.go b/internal/serve/validators/payment_query_validator.go index d0ad4e227..13ef8278a 100644 --- a/internal/serve/validators/payment_query_validator.go +++ b/internal/serve/validators/payment_query_validator.go @@ -1,6 +1,8 @@ package validators import ( + "fmt" + "slices" "strings" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" @@ -56,11 +58,10 @@ func (qv *PaymentQueryValidator) ValidateAndGetPaymentFilters(filters map[data.F // validateAndGetPaymentStatus validates the status parameter and returns the corresponding PaymentStatus. func (qv *PaymentQueryValidator) validateAndGetPaymentStatus(status string) data.PaymentStatus { s := data.PaymentStatus(strings.ToUpper(status)) - switch s { - case data.DraftPaymentStatus, data.ReadyPaymentStatus, data.PendingPaymentStatus, data.PausedPaymentStatus, data.SuccessPaymentStatus, data.FailedPaymentStatus, data.CanceledPaymentStatus: + if slices.Contains(data.PaymentStatuses(), s) { return s - default: - qv.Check(false, string(data.FilterKeyStatus), "invalid parameter. valid values are: DRAFT, READY, PENDING, PAUSED, SUCCESS, FAILED, CANCELLED") - return "" } + + qv.Check(false, string(data.FilterKeyStatus), fmt.Sprintf("invalid parameter. valid values are: %v", data.PaymentStatuses())) + return "" } diff --git a/internal/serve/validators/payment_query_validator_test.go b/internal/serve/validators/payment_query_validator_test.go index 7e26e45de..ccaeacfb9 100644 --- a/internal/serve/validators/payment_query_validator_test.go +++ b/internal/serve/validators/payment_query_validator_test.go @@ -1,11 +1,13 @@ package validators import ( + "fmt" "testing" "time" - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stretchr/testify/assert" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" ) func Test_PaymentQueryValidator_ValidateDisbursementFilters(t *testing.T) { @@ -35,7 +37,7 @@ func Test_PaymentQueryValidator_ValidateDisbursementFilters(t *testing.T) { validator.ValidateAndGetPaymentFilters(filters) assert.Equal(t, 1, len(validator.Errors)) - assert.Equal(t, "invalid parameter. valid values are: DRAFT, READY, PENDING, PAUSED, SUCCESS, FAILED, CANCELLED", validator.Errors["status"]) + assert.Equal(t, fmt.Sprintf("invalid parameter. valid values are: %v", data.PaymentStatuses()), validator.Errors["status"]) }) t.Run("Invalid date", func(t *testing.T) { @@ -84,6 +86,6 @@ func Test_PaymentQueryValidator_ValidateAndGetPaymentStatus(t *testing.T) { actual := validator.validateAndGetPaymentStatus(invalidStatus) assert.Empty(t, actual) assert.Equal(t, 1, len(validator.Errors)) - assert.Equal(t, "invalid parameter. valid values are: DRAFT, READY, PENDING, PAUSED, SUCCESS, FAILED, CANCELLED", validator.Errors["status"]) + assert.Equal(t, fmt.Sprintf("invalid parameter. valid values are: %v", data.PaymentStatuses()), validator.Errors["status"]) }) } diff --git a/internal/serve/validators/query_validator_test.go b/internal/serve/validators/query_validator_test.go index fa7699412..b49d8b9a9 100644 --- a/internal/serve/validators/query_validator_test.go +++ b/internal/serve/validators/query_validator_test.go @@ -5,8 +5,9 @@ import ( "net/http/httptest" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stretchr/testify/assert" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" ) func Test_QueryValidator_ParseQueryParameters(t *testing.T) { diff --git a/internal/serve/validators/receiver_query_validator_test.go b/internal/serve/validators/receiver_query_validator_test.go index 07f09ef20..259268adf 100644 --- a/internal/serve/validators/receiver_query_validator_test.go +++ b/internal/serve/validators/receiver_query_validator_test.go @@ -4,8 +4,9 @@ import ( "testing" "time" - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stretchr/testify/assert" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" ) func Test_ReceiverQueryValidator_ValidateReceiverFilters(t *testing.T) { diff --git a/internal/serve/validators/receiver_registration_validator.go b/internal/serve/validators/receiver_registration_validator.go index b702ea4f6..1a92f0455 100644 --- a/internal/serve/validators/receiver_registration_validator.go +++ b/internal/serve/validators/receiver_registration_validator.go @@ -1,8 +1,9 @@ package validators import ( + "fmt" + "slices" "strings" - "time" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" @@ -29,7 +30,7 @@ func (rv *ReceiverRegistrationValidator) ValidateReceiver(receiverInfo *data.Rec // validate phone field rv.CheckError(utils.ValidatePhoneNumber(phone), "phone_number", "invalid phone format. Correct format: +380445555555") - rv.Check(strings.TrimSpace(phone) != "", "phone_number", "phone cannot be empty") + rv.Check(phone != "", "phone_number", "phone cannot be empty") // validate otp field rv.CheckError(utils.ValidateOTP(otp), "otp", "invalid otp format. Needs to be a 6 digit value") @@ -41,20 +42,13 @@ func (rv *ReceiverRegistrationValidator) ValidateReceiver(receiverInfo *data.Rec // validate verification fields switch vt { case data.VerificationFieldDateOfBirth: - // date of birth with format 2006-01-02 - dob, err := time.Parse("2006-01-02", verification) - rv.CheckError(err, "verification", "invalid date of birth format. Correct format: 1990-01-01") - - // check if date of birth is in the past - rv.Check(dob.Before(time.Now()), "verification", "date of birth cannot be in the future") + rv.CheckError(utils.ValidateDateOfBirthVerification(verification), "verification", "") + case data.VerificationFieldYearMonth: + rv.CheckError(utils.ValidateYearMonthVerification(verification), "verification", "") case data.VerificationFieldPin: - if len(verification) < VERIFICATION_FIELD_PIN_MIN_LENGTH || len(verification) > VERIFICATION_FIELD_PIN_MAX_LENGTH { - rv.addError("verification", "invalid pin. Cannot have less than 4 or more than 8 characters in pin") - } + rv.CheckError(utils.ValidatePinVerification(verification), "verification", "") case data.VerificationFieldNationalID: - if len(verification) > VERIFICATION_FIELD_MAX_ID_LENGTH { - rv.addError("verification", "invalid national id. Cannot have more than 50 characters in national id") - } + rv.CheckError(utils.ValidateNationalIDVerification(verification), "verification", "") } receiverInfo.PhoneNumber = phone @@ -67,11 +61,9 @@ func (rv *ReceiverRegistrationValidator) ValidateReceiver(receiverInfo *data.Rec func (rv *ReceiverRegistrationValidator) validateAndGetVerificationType(verificationType string) data.VerificationField { vt := data.VerificationField(strings.ToUpper(verificationType)) - switch vt { - case data.VerificationFieldDateOfBirth, data.VerificationFieldPin, data.VerificationFieldNationalID: - return vt - default: - rv.Check(false, "verification_type", "invalid parameter. valid values are: DATE_OF_BIRTH, PIN, NATIONAL_ID_NUMBER") + if !slices.Contains(data.GetAllVerificationFields(), vt) { + rv.Check(false, "verification_type", fmt.Sprintf("invalid parameter. valid values are: %v", data.GetAllVerificationFields())) return "" } + return vt } diff --git a/internal/serve/validators/receiver_registration_validator_test.go b/internal/serve/validators/receiver_registration_validator_test.go index cebce9c71..b338f3bd7 100644 --- a/internal/serve/validators/receiver_registration_validator_test.go +++ b/internal/serve/validators/receiver_registration_validator_test.go @@ -3,149 +3,201 @@ package validators import ( "testing" - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stretchr/testify/assert" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" ) func Test_ReceiverRegistrationValidator_ValidateReceiver(t *testing.T) { - t.Run("Invalid phone number", func(t *testing.T) { - validator := NewReceiverRegistrationValidator() - - receiverInfo := data.ReceiverRegistrationRequest{ - PhoneNumber: "invalid", - OTP: "123456", - VerificationValue: "1990-01-01", - VerificationType: "DATE_OF_BIRTH", - } - validator.ValidateReceiver(&receiverInfo) - - assert.Equal(t, 1, len(validator.Errors)) - assert.Equal(t, "invalid phone format. Correct format: +380445555555", validator.Errors["phone_number"]) - }) - - t.Run("Empty phone number", func(t *testing.T) { - validator := NewReceiverRegistrationValidator() - - receiverInfo := data.ReceiverRegistrationRequest{ - PhoneNumber: "", - OTP: "123456", - VerificationValue: "1990-01-01", - VerificationType: "DATE_OF_BIRTH", - } - validator.ValidateReceiver(&receiverInfo) - - assert.Equal(t, 1, len(validator.Errors)) - assert.Equal(t, "phone cannot be empty", validator.Errors["phone_number"]) - }) - - t.Run("Invalid otp", func(t *testing.T) { - validator := NewReceiverRegistrationValidator() - - receiverInfo := data.ReceiverRegistrationRequest{ - PhoneNumber: "+380445555555", - OTP: "12mock", - VerificationValue: "1990-01-01", - VerificationType: "DATE_OF_BIRTH", - } - validator.ValidateReceiver(&receiverInfo) - - assert.Equal(t, 1, len(validator.Errors)) - assert.Equal(t, "invalid otp format. Needs to be a 6 digit value", validator.Errors["otp"]) - }) - - t.Run("Invalid verification type", func(t *testing.T) { - validator := NewReceiverRegistrationValidator() - - receiverInfo := data.ReceiverRegistrationRequest{ - PhoneNumber: "+380445555555", - OTP: "123456", - VerificationValue: "1990-01-01", - VerificationType: "mock_type", - } - validator.ValidateReceiver(&receiverInfo) - - assert.Equal(t, 1, len(validator.Errors)) - assert.Equal(t, "invalid parameter. valid values are: DATE_OF_BIRTH, PIN, NATIONAL_ID_NUMBER", validator.Errors["verification_type"]) - }) - - t.Run("Invalid date of birth", func(t *testing.T) { - validator := NewReceiverRegistrationValidator() - - receiverInfo := data.ReceiverRegistrationRequest{ - PhoneNumber: "+380445555555", - OTP: "123456", - VerificationValue: "90/01/01", - VerificationType: "DATE_OF_BIRTH", - } - validator.ValidateReceiver(&receiverInfo) - - assert.Equal(t, 1, len(validator.Errors)) - assert.Equal(t, "invalid date of birth format. Correct format: 1990-01-01", validator.Errors["verification"]) - }) - - t.Run("Invalid pin", func(t *testing.T) { - validator := NewReceiverRegistrationValidator() - - receiverInfo := data.ReceiverRegistrationRequest{ - PhoneNumber: "+380445555555", - OTP: "123456", - VerificationValue: "ABCDE1234", - VerificationType: "PIN", - } - validator.ValidateReceiver(&receiverInfo) - - assert.Equal(t, 1, len(validator.Errors)) - assert.Equal(t, "invalid pin. Cannot have less than 4 or more than 8 characters in pin", validator.Errors["verification"]) - }) - - t.Run("Invalid national ID number", func(t *testing.T) { - validator := NewReceiverRegistrationValidator() - - receiverInfo := data.ReceiverRegistrationRequest{ - PhoneNumber: "+380445555555", - OTP: "123456", - VerificationValue: "6UZMB56FWTKV4U0PJ21TBR6VOQVYSGIMZG2HW2S0L7EK5K83W78XXXXX", - VerificationType: "NATIONAL_ID_NUMBER", - } - validator.ValidateReceiver(&receiverInfo) - - assert.Equal(t, 1, len(validator.Errors)) - assert.Equal(t, "invalid national id. Cannot have more than 50 characters in national id", validator.Errors["verification"]) - }) - - t.Run("Valid receiver values", func(t *testing.T) { - validator := NewReceiverRegistrationValidator() - - receiverInfo := data.ReceiverRegistrationRequest{ - PhoneNumber: "+380445555555 ", - OTP: " 123456 ", - VerificationValue: "1990-01-01 ", - VerificationType: "date_of_birth", - } - validator.ValidateReceiver(&receiverInfo) - - assert.Equal(t, 0, len(validator.Errors)) - assert.Equal(t, "+380445555555", receiverInfo.PhoneNumber) - assert.Equal(t, "123456", receiverInfo.OTP) - assert.Equal(t, "1990-01-01", receiverInfo.VerificationValue) - assert.Equal(t, data.VerificationField("DATE_OF_BIRTH"), receiverInfo.VerificationType) - - receiverInfo.VerificationValue = "1234" - receiverInfo.VerificationType = "pin" - validator.ValidateReceiver(&receiverInfo) - - assert.Equal(t, 0, len(validator.Errors)) - assert.Equal(t, "1234", receiverInfo.VerificationValue) - assert.Equal(t, data.VerificationField("PIN"), receiverInfo.VerificationType) - - receiverInfo.VerificationValue = "NATIONALIDNUMBER123" - receiverInfo.VerificationType = "national_id_number" - validator.ValidateReceiver(&receiverInfo) - - assert.Equal(t, 0, len(validator.Errors)) - assert.Equal(t, "NATIONALIDNUMBER123", receiverInfo.VerificationValue) - assert.Equal(t, data.VerificationField("NATIONAL_ID_NUMBER"), receiverInfo.VerificationType) - }) + type testCase struct { + name string + receiverInfo data.ReceiverRegistrationRequest + expectedErrorLen int + expectedErrorMsg string + expectedErrorKey string + expectedReceiver data.ReceiverRegistrationRequest + } + + testCases := []testCase{ + { + name: "error if phone number is invalid", + receiverInfo: data.ReceiverRegistrationRequest{ + PhoneNumber: "invalid", + OTP: "123456", + VerificationValue: "1990-01-01", + VerificationType: data.VerificationFieldDateOfBirth, + }, + expectedErrorLen: 1, + expectedErrorMsg: "invalid phone format. Correct format: +380445555555", + expectedErrorKey: "phone_number", + }, + { + name: "error if phone number is empty", + receiverInfo: data.ReceiverRegistrationRequest{ + PhoneNumber: "", + OTP: "123456", + VerificationValue: "1990-01-01", + VerificationType: data.VerificationFieldDateOfBirth, + }, + expectedErrorLen: 1, + expectedErrorMsg: "phone cannot be empty", + expectedErrorKey: "phone_number", + }, + { + name: "error if OTP is invalid", + receiverInfo: data.ReceiverRegistrationRequest{ + PhoneNumber: "+380445555555", + OTP: "12mock", + VerificationValue: "1990-01-01", + VerificationType: data.VerificationFieldDateOfBirth, + }, + expectedErrorLen: 1, + expectedErrorMsg: "invalid otp format. Needs to be a 6 digit value", + expectedErrorKey: "otp", + }, + { + name: "error if verification type is invalid", + receiverInfo: data.ReceiverRegistrationRequest{ + PhoneNumber: "+380445555555", + OTP: "123456", + VerificationValue: "1990-01-01", + VerificationType: "mock_type", + }, + expectedErrorLen: 1, + expectedErrorMsg: "invalid parameter. valid values are: [DATE_OF_BIRTH YEAR_MONTH PIN NATIONAL_ID_NUMBER]", + expectedErrorKey: "verification_type", + }, + { + name: "error if verification[DATE_OF_BIRTH] is invalid", + receiverInfo: data.ReceiverRegistrationRequest{ + PhoneNumber: "+380445555555", + OTP: "123456", + VerificationValue: "90/01/01", + VerificationType: data.VerificationFieldDateOfBirth, + }, + expectedErrorLen: 1, + expectedErrorMsg: "invalid date of birth format. Correct format: 1990-01-30", + expectedErrorKey: "verification", + }, + { + name: "error if verification[YEAR_MONTH] is invalid", + receiverInfo: data.ReceiverRegistrationRequest{ + PhoneNumber: "+380445555555", + OTP: "123456", + VerificationValue: "90/12", + VerificationType: data.VerificationFieldYearMonth, + }, + expectedErrorLen: 1, + expectedErrorMsg: "invalid year/month format. Correct format: 1990-12", + expectedErrorKey: "verification", + }, + { + name: "error if verification[PIN] is invalid", + receiverInfo: data.ReceiverRegistrationRequest{ + PhoneNumber: "+380445555555", + OTP: "123456", + VerificationValue: "ABCDE1234", + VerificationType: data.VerificationFieldPin, + }, + expectedErrorLen: 1, + expectedErrorMsg: "invalid pin length. Cannot have less than 4 or more than 8 characters in pin", + expectedErrorKey: "verification", + }, + { + name: "error if verification[NATIONAL_ID_NUMBER] is invalid", + receiverInfo: data.ReceiverRegistrationRequest{ + PhoneNumber: "+380445555555", + OTP: "123456", + VerificationValue: "6UZMB56FWTKV4U0PJ21TBR6VOQVYSGIMZG2HW2S0L7EK5K83W78XXXXX", + VerificationType: data.VerificationFieldNationalID, + }, + expectedErrorLen: 1, + expectedErrorMsg: "invalid national id. Cannot have more than 50 characters in national id", + expectedErrorKey: "verification", + }, + { + name: "🎉 successfully validates receiver values [DATE_OF_BIRTH]", + receiverInfo: data.ReceiverRegistrationRequest{ + PhoneNumber: "+380445555555 ", + OTP: " 123456 ", + VerificationValue: "1990-01-01 ", + VerificationType: "date_of_birth", + }, + expectedErrorLen: 0, + expectedReceiver: data.ReceiverRegistrationRequest{ + PhoneNumber: "+380445555555", + OTP: "123456", + VerificationValue: "1990-01-01", + VerificationType: data.VerificationFieldDateOfBirth, + }, + }, + { + name: "🎉 successfully validates receiver values [YEAR_MONTH]", + receiverInfo: data.ReceiverRegistrationRequest{ + PhoneNumber: "+380445555555 ", + OTP: " 123456 ", + VerificationValue: "1990-12 ", + VerificationType: "year_month", + }, + expectedErrorLen: 0, + expectedReceiver: data.ReceiverRegistrationRequest{ + PhoneNumber: "+380445555555", + OTP: "123456", + VerificationValue: "1990-12", + VerificationType: data.VerificationFieldYearMonth, + }, + }, + { + name: "🎉 successfully validates receiver values [PIN]", + receiverInfo: data.ReceiverRegistrationRequest{ + PhoneNumber: "+380445555555 ", + OTP: " 123456 ", + VerificationValue: "1234 ", + VerificationType: "pin", + }, + expectedErrorLen: 0, + expectedReceiver: data.ReceiverRegistrationRequest{ + PhoneNumber: "+380445555555", + OTP: "123456", + VerificationValue: "1234", + VerificationType: data.VerificationFieldPin, + }, + }, + { + name: "🎉 successfully validates receiver values [NATIONAL_ID_NUMBER]", + receiverInfo: data.ReceiverRegistrationRequest{ + PhoneNumber: "+380445555555 ", + OTP: " 123456 ", + VerificationValue: " NATIONALIDNUMBER123", + VerificationType: "national_id_number", + }, + expectedErrorLen: 0, + expectedReceiver: data.ReceiverRegistrationRequest{ + PhoneNumber: "+380445555555", + OTP: "123456", + VerificationValue: "NATIONALIDNUMBER123", + VerificationType: data.VerificationFieldNationalID, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + validator := NewReceiverRegistrationValidator() + validator.ValidateReceiver(&tc.receiverInfo) + + assert.Equal(t, tc.expectedErrorLen, len(validator.Errors)) + + if tc.expectedErrorLen > 0 { + assert.Equal(t, tc.expectedErrorMsg, validator.Errors[tc.expectedErrorKey]) + } else { + assert.Equal(t, tc.expectedReceiver.PhoneNumber, tc.receiverInfo.PhoneNumber) + assert.Equal(t, tc.expectedReceiver.OTP, tc.receiverInfo.OTP) + assert.Equal(t, tc.expectedReceiver.VerificationValue, tc.receiverInfo.VerificationValue) + assert.Equal(t, tc.expectedReceiver.VerificationType, tc.receiverInfo.VerificationType) + } + }) + } } func Test_ReceiverRegistrationValidator_ValidateAndGetVerificationType(t *testing.T) { @@ -153,6 +205,7 @@ func Test_ReceiverRegistrationValidator_ValidateAndGetVerificationType(t *testin validator := NewReceiverRegistrationValidator() validField := []data.VerificationField{ data.VerificationFieldDateOfBirth, + data.VerificationFieldYearMonth, data.VerificationFieldPin, data.VerificationFieldNationalID, } @@ -168,6 +221,6 @@ func Test_ReceiverRegistrationValidator_ValidateAndGetVerificationType(t *testin actual := validator.validateAndGetVerificationType(invalidStatus) assert.Empty(t, actual) assert.Equal(t, 1, len(validator.Errors)) - assert.Equal(t, "invalid parameter. valid values are: DATE_OF_BIRTH, PIN, NATIONAL_ID_NUMBER", validator.Errors["verification_type"]) + assert.Equal(t, "invalid parameter. valid values are: [DATE_OF_BIRTH YEAR_MONTH PIN NATIONAL_ID_NUMBER]", validator.Errors["verification_type"]) }) } diff --git a/internal/serve/validators/receiver_update_validator.go b/internal/serve/validators/receiver_update_validator.go index 1213b1f6c..b77bf0e42 100644 --- a/internal/serve/validators/receiver_update_validator.go +++ b/internal/serve/validators/receiver_update_validator.go @@ -2,13 +2,13 @@ package validators import ( "strings" - "time" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) type UpdateReceiverRequest struct { DateOfBirth string `json:"date_of_birth"` + YearMonth string `json:"year_month"` Pin string `json:"pin"` NationalID string `json:"national_id"` Email string `json:"email"` @@ -34,24 +34,26 @@ func (ur *UpdateReceiverValidator) ValidateReceiver(updateReceiverRequest *Updat } dateOfBirth := strings.TrimSpace(updateReceiverRequest.DateOfBirth) + yearMonth := strings.TrimSpace(updateReceiverRequest.YearMonth) pin := strings.TrimSpace(updateReceiverRequest.Pin) nationalID := strings.TrimSpace(updateReceiverRequest.NationalID) email := strings.TrimSpace(updateReceiverRequest.Email) externalID := strings.TrimSpace(updateReceiverRequest.ExternalID) if dateOfBirth != "" { - _, err := time.Parse("2006-01-02", updateReceiverRequest.DateOfBirth) - ur.CheckError(err, "date_of_birth", "invalid date of birth format. Correct format: 1990-01-30") + ur.CheckError(utils.ValidateDateOfBirthVerification(dateOfBirth), "date_of_birth", "") + } + + if yearMonth != "" { + ur.CheckError(utils.ValidateYearMonthVerification(yearMonth), "year_month", "") } if updateReceiverRequest.Pin != "" { - // TODO: add new validation to PIN type. - ur.Check(pin != "", "pin", "invalid pin format") + ur.CheckError(utils.ValidatePinVerification(pin), "pin", "") } if updateReceiverRequest.NationalID != "" { - // TODO: add new validation to NationalID type. - ur.Check(nationalID != "", "national_id", "invalid national ID format") + ur.CheckError(utils.ValidateNationalIDVerification(nationalID), "national_id", "") } if updateReceiverRequest.Email != "" { @@ -63,6 +65,7 @@ func (ur *UpdateReceiverValidator) ValidateReceiver(updateReceiverRequest *Updat } updateReceiverRequest.DateOfBirth = dateOfBirth + updateReceiverRequest.YearMonth = yearMonth updateReceiverRequest.Pin = pin updateReceiverRequest.NationalID = nationalID updateReceiverRequest.Email = email diff --git a/internal/serve/validators/receiver_update_validator_test.go b/internal/serve/validators/receiver_update_validator_test.go index b04d0de98..f1960b338 100644 --- a/internal/serve/validators/receiver_update_validator_test.go +++ b/internal/serve/validators/receiver_update_validator_test.go @@ -38,7 +38,7 @@ func Test_UpdateReceiverValidator_ValidateReceiver(t *testing.T) { validator.ValidateReceiver(&receiverInfo) assert.Equal(t, 1, len(validator.Errors)) - assert.Equal(t, "invalid pin format", validator.Errors["pin"]) + assert.Equal(t, "invalid pin length. Cannot have less than 4 or more than 8 characters in pin", validator.Errors["pin"]) }) t.Run("Invalid national ID", func(t *testing.T) { @@ -50,7 +50,7 @@ func Test_UpdateReceiverValidator_ValidateReceiver(t *testing.T) { validator.ValidateReceiver(&receiverInfo) assert.Equal(t, 1, len(validator.Errors)) - assert.Equal(t, "invalid national ID format", validator.Errors["national_id"]) + assert.Equal(t, "national id cannot be empty", validator.Errors["national_id"]) }) t.Run("invalid email", func(t *testing.T) { @@ -90,7 +90,7 @@ func Test_UpdateReceiverValidator_ValidateReceiver(t *testing.T) { receiverInfo := UpdateReceiverRequest{ DateOfBirth: "1999-01-01", - Pin: "123 ", + Pin: "1234 ", NationalID: " 12345CODE", Email: "receiver@email.com", ExternalID: "externalID", @@ -99,7 +99,7 @@ func Test_UpdateReceiverValidator_ValidateReceiver(t *testing.T) { assert.Equal(t, 0, len(validator.Errors)) assert.Equal(t, "1999-01-01", receiverInfo.DateOfBirth) - assert.Equal(t, "123", receiverInfo.Pin) + assert.Equal(t, "1234", receiverInfo.Pin) assert.Equal(t, "12345CODE", receiverInfo.NationalID) }) } diff --git a/internal/serve/validators/validator.go b/internal/serve/validators/validator.go index d4282fee6..9a9b2a028 100644 --- a/internal/serve/validators/validator.go +++ b/internal/serve/validators/validator.go @@ -20,6 +20,9 @@ func (v *Validator) Check(ok bool, key, message string) { // CheckError is a convenience method for checking if an error is nil func (v *Validator) CheckError(err error, key, message string) { + if err != nil && message == "" { + message = err.Error() + } v.Check(err == nil, key, message) } diff --git a/internal/serve/validators/validator_test.go b/internal/serve/validators/validator_test.go index 537e5efb2..d9fb156b2 100644 --- a/internal/serve/validators/validator_test.go +++ b/internal/serve/validators/validator_test.go @@ -1,6 +1,7 @@ package validators import ( + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -39,3 +40,47 @@ func Test_addError(t *testing.T) { assert.Equal(t, validator.Errors["key"], "error message") assert.Equal(t, validator.Errors["key2"], "error message 2") } + +func Test_Validator_CheckError(t *testing.T) { + testCases := []struct { + name string + err error + key string + message string + expectedErrors map[string]interface{} + }{ + { + name: "error is not nil and message is not empty", + err: fmt.Errorf("error message"), + key: "key", + message: "real error message", + expectedErrors: map[string]interface{}{ + "key": "real error message", + }, + }, + { + name: "error is not nil and message is empty", + err: fmt.Errorf("error message"), + key: "key", + message: "", + expectedErrors: map[string]interface{}{ + "key": "error message", + }, + }, + { + name: "error is nil", + err: nil, + key: "key", + message: "real error message", + expectedErrors: map[string]interface{}{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + validator := NewValidator() + validator.CheckError(tc.err, tc.key, tc.message) + assert.Equal(t, tc.expectedErrors, validator.Errors) + }) + } +} diff --git a/internal/services/assets/assets_pubnet.go b/internal/services/assets/assets_pubnet.go index c3472fb86..8e875a521 100644 --- a/internal/services/assets/assets_pubnet.go +++ b/internal/services/assets/assets_pubnet.go @@ -2,22 +2,32 @@ package assets import "github.com/stellar/stellar-disbursement-platform-backend/internal/data" -// USDCAssetCode is the code for the USDC asset for pubnet and testnet -const USDCAssetCode = "USDC" +// USDC -// XLMAssetCode is the code for the XLM asset for pubnet and testnet -const XLMAssetCode = "XLM" +const USDCAssetCode = "USDC" -// USDCAssetIssuerPubnet is the issuer for the USDC asset for pubnet const USDCAssetIssuerPubnet = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" -// USDCAssetPubnet is the USDC asset for pubnet var USDCAssetPubnet = data.Asset{ Code: USDCAssetCode, Issuer: USDCAssetIssuerPubnet, } -// XLMAsset is the XLM asset for pubnet +// EURC + +const EURCAssetCode = "EURC" + +const EURCAssetIssuerPubnet = "GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2" + +var EURCAssetPubnet = data.Asset{ + Code: EURCAssetCode, + Issuer: EURCAssetIssuerPubnet, +} + +// XLM + +const XLMAssetCode = "XLM" + var XLMAsset = data.Asset{ Code: XLMAssetCode, Issuer: "", diff --git a/internal/services/assets/assets_testnet.go b/internal/services/assets/assets_testnet.go index 5dfb4ab0b..22a702827 100644 --- a/internal/services/assets/assets_testnet.go +++ b/internal/services/assets/assets_testnet.go @@ -2,11 +2,20 @@ package assets import "github.com/stellar/stellar-disbursement-platform-backend/internal/data" -// USDCAssetIssuerTestnet is the issuer for the USDC asset for testnet +// USDC + const USDCAssetIssuerTestnet = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" -// USDCAssetTestnet is the USDC asset for testnet var USDCAssetTestnet = data.Asset{ Code: USDCAssetCode, Issuer: USDCAssetIssuerTestnet, } + +// EURC + +const EURCAssetIssuerTestnet = "GB3Q6QDZYTHWT7E5PVS3W7FUT5GVAFC5KSZFFLPU25GO7VTC3NM2ZTVO" + +var EURCAssetTestnet = data.Asset{ + Code: EURCAssetCode, + Issuer: EURCAssetIssuerTestnet, +} diff --git a/internal/services/circle_reconciliation_service.go b/internal/services/circle_reconciliation_service.go new file mode 100644 index 000000000..11bb9a098 --- /dev/null +++ b/internal/services/circle_reconciliation_service.go @@ -0,0 +1,174 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + "github.com/stellar/go/support/log" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" +) + +//go:generate mockery --name=CircleReconciliationServiceInterface --case=underscore --structname=MockCircleReconciliationService --filename=circle_reconciliation_service.go +type CircleReconciliationServiceInterface interface { + Reconcile(ctx context.Context) error +} + +type CircleReconciliationService struct { + Models *data.Models + CircleService circle.ServiceInterface + DistAccountResolver signing.DistributionAccountResolver +} + +// Reconcile reconciles the pending Circle transfer requests for the tenant in the context. It fetches the rows from +// circte_transfer_request where status is set to pending, and then fetches the transfer details from Circle API. It +// updates the status of the transfer request in the DB based on the status of the transfer in Circle. If the transfer +// reached a successful/failure status, it updates the payment status in the DB as well to reflect that. +func (s *CircleReconciliationService) Reconcile(ctx context.Context) error { + // Step 1: Get the tenant from the context. + tnt, outerErr := tenant.GetTenantFromContext(ctx) + if outerErr != nil { + return fmt.Errorf("getting tenant from context: %w", outerErr) + } + + // Step 2: check if the tenant distribution account is of type Circle, and if it is Active. + distAcc, outerErr := s.DistAccountResolver.DistributionAccountFromContext(ctx) + if outerErr != nil { + return fmt.Errorf("getting distribution account from context: %w", outerErr) + } + if !distAcc.IsCircle() { + log.Ctx(ctx).Debugf("Distribution account for tenant %q is not of type %q, skipping reconciliation...", tnt.Name, schema.CirclePlatform) + return nil + } + if distAcc.Status != schema.AccountStatusActive { + log.Ctx(ctx).Debugf("Distribution account for tenant %q is not %q, skipping reconciliation...", tnt.Name, schema.AccountStatusActive) + return nil + } + + var reconciliationErrors []error + var reconciliationCount int + outerErr = db.RunInTransaction(ctx, s.Models.DBConnectionPool, nil, func(dbTx db.DBTransaction) error { + // Step 3: Get pending Circle transfer requests. + circleRequests, err := s.Models.CircleTransferRequests.GetPendingReconciliation(ctx, dbTx) + if err != nil { + return fmt.Errorf("getting pending Circle transfer requests: %w", err) + } + + log.Ctx(ctx).Debugf("Found %d pending Circle transfer requests in tenant %q", len(circleRequests), tnt.Name) + if len(circleRequests) == 0 { + return nil + } + + // Step 4: Reconcile the pending Circle transfer requests. + reconciliationCount = len(circleRequests) + for _, circleRequest := range circleRequests { + err = s.reconcileTransferRequest(ctx, dbTx, tnt, circleRequest) + if err != nil { + err = fmt.Errorf("reconciling Circle transfer request: %w", err) + reconciliationErrors = append(reconciliationErrors, err) + } + } + + return nil + }) + if outerErr != nil { + return fmt.Errorf("running Circle reconciliation for tenant %q: %w", tnt.Name, outerErr) + } + + if len(reconciliationErrors) > 0 { + return fmt.Errorf("attempted to reconcyle %d circle requests but failed on %d reconciliations: %v", reconciliationCount, len(reconciliationErrors), reconciliationErrors) + } + + return nil +} + +// reconcileTransferRequest reconciles a Circle transfer request and updates the payment status in the DB. It returns an +// error if the reconciliation fails. +func (s *CircleReconciliationService) reconcileTransferRequest(ctx context.Context, dbTx db.DBTransaction, tnt *tenant.Tenant, circleRequest *data.CircleTransferRequest) error { + // 4.1. get the Circle transfer by ID + transfer, err := s.CircleService.GetTransferByID(ctx, *circleRequest.CircleTransferID) + if err != nil { + var cAPIErr *circle.APIError + if errors.As(err, &cAPIErr) && cAPIErr.StatusCode == http.StatusBadRequest { + // if the the Circle API returns a 400, increment the sync attempts and update the last sync + errJSONBody, marshalErr := json.Marshal(cAPIErr) + if marshalErr != nil { + log.Ctx(ctx).Errorf("marshalling Circle APIError: %v", marshalErr) + } + + // increment the sync attempts and update the last sync attempt time. + var updateErr error + circleRequest, updateErr = s.Models.CircleTransferRequests.Update(ctx, dbTx, circleRequest.IdempotencyKey, data.CircleTransferRequestUpdate{ + LastSyncAttemptAt: utils.TimePtr(time.Now()), + SyncAttempts: circleRequest.SyncAttempts + 1, + ResponseBody: errJSONBody, + }) + if updateErr != nil { + return fmt.Errorf("updating Circle transfer request sync attempts: %w", updateErr) + } + } + return fmt.Errorf("getting Circle transfer by ID %q: %w", *circleRequest.CircleTransferID, err) + } + jsonBody, err := json.Marshal(transfer) + if err != nil { + return fmt.Errorf("converting transfer body to json: %w", err) + } + + // 4.2. update the circle transfer request entry in the DB. + newStatus := data.CircleTransferStatus(transfer.Status) + if *circleRequest.Status == newStatus { + // this condition should be unrechable, but we're adding this log just in case... + log.Ctx(ctx).Debugf("[tenant=%s] Circle transfer request %q is already in status %q, skipping reconciliation...", tnt.Name, circleRequest.IdempotencyKey, newStatus) + return nil + } + + now := time.Now() + var completedAt *time.Time + if newStatus.IsCompleted() { + completedAt = &now + } + circleRequest, err = s.Models.CircleTransferRequests.Update(ctx, dbTx, circleRequest.IdempotencyKey, data.CircleTransferRequestUpdate{ + Status: newStatus, + CompletedAt: completedAt, + LastSyncAttemptAt: &now, + SyncAttempts: circleRequest.SyncAttempts + 1, + ResponseBody: jsonBody, + }) + if err != nil { + return fmt.Errorf("updating Circle transfer request: %w", err) + } + + // 4.3. update the payment status in the DB. + newPaymentStatus, err := transfer.Status.ToPaymentStatus() + if err != nil { + return fmt.Errorf("converting Circle transfer status to Payment status: %w", err) + } + var statusMsg string + switch newStatus { + case data.CircleTransferStatusSuccess: + statusMsg = fmt.Sprintf("Circle transfer completed successfully with the Stellar transaction hash: %q", transfer.TransactionHash) + case data.CircleTransferStatusFailed: + statusMsg = fmt.Sprintf("Circle transfer failed with error: %q", transfer.ErrorCode) + default: + return fmt.Errorf("unexpected Circle transfer status: %q", newStatus) + } + + err = s.Models.Payment.UpdateStatus(ctx, dbTx, circleRequest.PaymentID, newPaymentStatus, &statusMsg, transfer.TransactionHash) + if err != nil { + return fmt.Errorf("updating payment status: %w", err) + } + + log.Ctx(ctx).Infof("[tenant=%s] Reconciled Circle transfer request %q with status %q", tnt.Name, *circleRequest.CircleTransferID, newStatus) + + return nil +} diff --git a/internal/services/circle_reconciliation_service_test.go b/internal/services/circle_reconciliation_service_test.go new file mode 100644 index 000000000..c89549b67 --- /dev/null +++ b/internal/services/circle_reconciliation_service_test.go @@ -0,0 +1,507 @@ +package services + +import ( + "context" + "errors" + "net/http" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stellar/go/support/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" + sigMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" +) + +func Test_NewCircleReconciliationService_Reconcile_failure(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + tnt := &tenant.Tenant{ID: "95e788b6-c80e-4975-9d12-141001fe6e44", Name: "test-tenant"} + + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + + // Create distribution accounts + stellarDistAccountEnv := schema.NewStellarEnvTransactionAccount("GAAHIL6ZW4QFNLCKALZ3YOIWPP4TXQ7B7J5IU7RLNVGQAV6GFDZHLDTA") + innactiveCircleDistAccountDBVault := schema.TransactionAccount{ + CircleWalletID: "circle-wallet-id", + Type: schema.DistributionAccountCircleDBVault, + Status: schema.AccountStatusPendingUserActivation, + } + circleDistAccountDBVault := schema.TransactionAccount{ + CircleWalletID: "circle-wallet-id", + Type: schema.DistributionAccountCircleDBVault, + Status: schema.AccountStatusActive, + } + + testCases := []struct { + name string + tenant *tenant.Tenant + setupMocksAndDBFn func(t *testing.T, mDistAccountResolver *sigMocks.MockDistributionAccountResolver, mCircleService *circle.MockService) + wantErrorContains string + assertLogsFn func(entries []logrus.Entry) + }{ + { + name: "returns error when getting tenant from context fails", + wantErrorContains: "getting tenant from context", + }, + { + name: "returns error when getting distribution account from context fails", + tenant: tnt, + setupMocksAndDBFn: func(t *testing.T, mDistAccountResolver *sigMocks.MockDistributionAccountResolver, _ *circle.MockService) { + mDistAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(schema.TransactionAccount{}, assert.AnError). + Once() + }, + wantErrorContains: "getting distribution account from context", + }, + { + name: "skips reconciliation when distribution account is not of type CIRCLE", + tenant: tnt, + setupMocksAndDBFn: func(t *testing.T, mDistAccountResolver *sigMocks.MockDistributionAccountResolver, mCircleService *circle.MockService) { + mDistAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(stellarDistAccountEnv, nil). + Once() + }, + assertLogsFn: func(entries []logrus.Entry) { + assert.Equal(t, "Distribution account for tenant \"test-tenant\" is not of type \"CIRCLE\", skipping reconciliation...", entries[0].Message) + }, + }, + { + name: "skips reconciliation when distribution account is CIRCLE but it's not ACTIVE", + tenant: tnt, + setupMocksAndDBFn: func(t *testing.T, mDistAccountResolver *sigMocks.MockDistributionAccountResolver, mCircleService *circle.MockService) { + mDistAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(innactiveCircleDistAccountDBVault, nil). + Once() + }, + assertLogsFn: func(entries []logrus.Entry) { + assert.Equal(t, "Distribution account for tenant \"test-tenant\" is not \"ACTIVE\", skipping reconciliation...", entries[0].Message) + }, + }, + { + name: "skips reconciliation when there are no pending Circle transfer requests", + tenant: tnt, + setupMocksAndDBFn: func(t *testing.T, mDistAccountResolver *sigMocks.MockDistributionAccountResolver, mCircleService *circle.MockService) { + mDistAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(circleDistAccountDBVault, nil). + Once() + }, + assertLogsFn: func(entries []logrus.Entry) { + assert.Equal(t, "Found 0 pending Circle transfer requests in tenant \"test-tenant\"", entries[0].Message) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // prepare mocks + mDistAccountResolver := sigMocks.NewMockDistributionAccountResolver(t) + mCircleService := circle.NewMockService(t) + svc := CircleReconciliationService{ + Models: models, + CircleService: mCircleService, + DistAccountResolver: mDistAccountResolver, + } + if tc.setupMocksAndDBFn != nil { + tc.setupMocksAndDBFn(t, mDistAccountResolver, mCircleService) + } + + // inject tenant in context if configured + updatedCtx := ctx + if tc.tenant != nil { + updatedCtx = tenant.SaveTenantInContext(ctx, tc.tenant) + } + + // run test + getEntries := log.DefaultLogger.StartTest(logrus.DebugLevel) + err := svc.Reconcile(updatedCtx) + + // asserttions + if tc.wantErrorContains != "" { + assert.Error(t, err) + assert.ErrorContains(t, err, tc.wantErrorContains) + } else { + assert.NoError(t, err) + } + entries := getEntries() + if tc.assertLogsFn != nil { + tc.assertLogsFn(entries) + } + }) + } +} + +func Test_NewCircleReconciliationService_Reconcile_partialSuccess(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + tnt := &tenant.Tenant{ID: "95e788b6-c80e-4975-9d12-141001fe6e44", Name: "test-tenant"} + ctx := tenant.SaveTenantInContext(context.Background(), tnt) + + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + + asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, assets.EURCAssetCode, assets.EURCAssetTestnet.Issuer) + country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") + wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "My Wallet", "https://www.wallet.com", "www.wallet.com", "wallet1://") + + // Create distribution accounts + circleDistAccountDBVault := schema.TransactionAccount{ + CircleWalletID: "circle-wallet-id", + Type: schema.DistributionAccountCircleDBVault, + Status: schema.AccountStatusActive, + } + + disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Name: "disbursement", + Status: data.StartedDisbursementStatus, + Asset: asset, + Wallet: wallet, + Country: country, + }) + receiver := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + receiverWallet := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, data.RegisteredReceiversWalletStatus) + + // Create payments with Circle transfer requests + circlePendingStatus := data.CircleTransferStatusPending + + p1WillThrowAnError := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: receiverWallet, + Disbursement: disbursement, + Asset: *asset, + Amount: "100", + Status: data.PendingPaymentStatus, + }) + circleReq1WillThrowAnError := data.CreateCircleTransferRequestFixture(t, ctx, dbConnectionPool, data.CircleTransferRequest{ + PaymentID: p1WillThrowAnError.ID, + Status: &circlePendingStatus, + CircleTransferID: utils.StringPtr("circle-transfer-id-1"), + }) + + p2StaysPending := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: receiverWallet, + Disbursement: disbursement, + Asset: *asset, + Amount: "100", + Status: data.PendingPaymentStatus, + }) + circleReq2StaysPending := data.CreateCircleTransferRequestFixture(t, ctx, dbConnectionPool, data.CircleTransferRequest{ + PaymentID: p2StaysPending.ID, + Status: &circlePendingStatus, + CircleTransferID: utils.StringPtr("circle-transfer-id-2"), + }) + + p3WillSucceed := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: receiverWallet, + Disbursement: disbursement, + Asset: *asset, + Amount: "100", + Status: data.PendingPaymentStatus, + }) + circleReq3WillSucceed := data.CreateCircleTransferRequestFixture(t, ctx, dbConnectionPool, data.CircleTransferRequest{ + PaymentID: p3WillSucceed.ID, + Status: &circlePendingStatus, + CircleTransferID: utils.StringPtr("circle-transfer-id-3"), + }) + + p4WillFail := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: receiverWallet, + Disbursement: disbursement, + Asset: *asset, + Amount: "100", + Status: data.PendingPaymentStatus, + }) + circleReq4WillFail := data.CreateCircleTransferRequestFixture(t, ctx, dbConnectionPool, data.CircleTransferRequest{ + PaymentID: p4WillFail.ID, + Status: &circlePendingStatus, + CircleTransferID: utils.StringPtr("circle-transfer-id-4"), + }) + + // prepare mocks + mDistAccountResolver := sigMocks.NewMockDistributionAccountResolver(t) + mDistAccountResolver. + On("DistributionAccountFromContext", mock.Anything). + Return(circleDistAccountDBVault, nil). + Once() + mCircleService := circle.NewMockService(t) + mCircleService. + On("GetTransferByID", mock.Anything, *circleReq1WillThrowAnError.CircleTransferID). + Return(nil, errors.New("something went wrong")). + Once(). + On("GetTransferByID", mock.Anything, *circleReq2StaysPending.CircleTransferID). + Return(&circle.Transfer{ + ID: *circleReq2StaysPending.CircleTransferID, + Status: circle.TransferStatusPending, + }, nil). + Once(). + On("GetTransferByID", mock.Anything, *circleReq3WillSucceed.CircleTransferID). + Return(&circle.Transfer{ + ID: *circleReq3WillSucceed.CircleTransferID, + Status: circle.TransferStatusComplete, + }, nil). + Once(). + On("GetTransferByID", mock.Anything, *circleReq4WillFail.CircleTransferID). + Return(&circle.Transfer{ + ID: *circleReq4WillFail.CircleTransferID, + Status: circle.TransferStatusFailed, + }, nil). + Once() + + // run test + getEntries := log.DefaultLogger.StartTest(logrus.DebugLevel) + svc := CircleReconciliationService{ + Models: models, + CircleService: mCircleService, + DistAccountResolver: mDistAccountResolver, + } + err = svc.Reconcile(ctx) + assert.Error(t, err) + assert.EqualError(t, err, "attempted to reconcyle 4 circle requests but failed on 1 reconciliations: [reconciling Circle transfer request: getting Circle transfer by ID \"circle-transfer-id-1\": something went wrong]") + + // assert logs + entries := getEntries() + var messages []string + for _, entry := range entries { + messages = append(messages, entry.Message) + } + assert.Contains(t, messages, `[tenant=test-tenant] Reconciled Circle transfer request "circle-transfer-id-3" with status "complete"`) + assert.Contains(t, messages, `[tenant=test-tenant] Reconciled Circle transfer request "circle-transfer-id-4" with status "failed"`) + + // assert results + getPaymentAndCircleRequestFromDB := func(paymentID string) (*data.CircleTransferRequest, *data.Payment) { + updatedCircleRequest, err := models.CircleTransferRequests.Get(ctx, dbConnectionPool, data.QueryParams{Filters: map[data.FilterKey]interface{}{data.FilterKeyPaymentID: paymentID}}) + require.NoError(t, err) + + updatedPayment, err := models.Payment.Get(ctx, paymentID, dbConnectionPool) + require.NoError(t, err) + + return updatedCircleRequest, updatedPayment + } + // p1WillThrowAnError + updatedCircleReq1, updatedPayment1 := getPaymentAndCircleRequestFromDB(p1WillThrowAnError.ID) + assert.Equal(t, data.CircleTransferStatusPending, *updatedCircleReq1.Status) + assert.Equal(t, data.PendingPaymentStatus, updatedPayment1.Status) + // p2StaysPending + updatedCircleReq2, updatedPayment2 := getPaymentAndCircleRequestFromDB(p2StaysPending.ID) + assert.Equal(t, data.CircleTransferStatusPending, *updatedCircleReq2.Status) + assert.Equal(t, data.PendingPaymentStatus, updatedPayment2.Status) + // p3WillSucceed + updatedCircleReq3, updatedPayment3 := getPaymentAndCircleRequestFromDB(p3WillSucceed.ID) + assert.Equal(t, data.CircleTransferStatusSuccess, *updatedCircleReq3.Status) + assert.Equal(t, data.SuccessPaymentStatus, updatedPayment3.Status) + // p4WillFail + updatedCircleReq4, updatedPayment4 := getPaymentAndCircleRequestFromDB(p4WillFail.ID) + assert.Equal(t, data.CircleTransferStatusFailed, *updatedCircleReq4.Status) + assert.Equal(t, data.FailedPaymentStatus, updatedPayment4.Status) +} + +func Test_NewCircleReconciliationService_reconcileTransferRequest(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + tnt := &tenant.Tenant{ID: "95e788b6-c80e-4975-9d12-141001fe6e44", Name: "test-tenant"} + ctx := tenant.SaveTenantInContext(context.Background(), tnt) + + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + + asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, assets.EURCAssetCode, assets.EURCAssetTestnet.Issuer) + country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") + wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "My Wallet", "https://www.wallet.com", "www.wallet.com", "wallet1://") + + disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Name: "disbursement", + Status: data.StartedDisbursementStatus, + Asset: asset, + Wallet: wallet, + Country: country, + }) + receiver := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + receiverWallet := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver.ID, wallet.ID, data.RegisteredReceiversWalletStatus) + + // Create payments with Circle transfer requests + circlePendingStatus := data.CircleTransferStatusPending + + payment := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: receiverWallet, + Disbursement: disbursement, + Asset: *asset, + Amount: "100", + Status: data.PendingPaymentStatus, + }) + circleRequest := data.CreateCircleTransferRequestFixture(t, ctx, dbConnectionPool, data.CircleTransferRequest{ + PaymentID: payment.ID, + Status: &circlePendingStatus, + CircleTransferID: utils.StringPtr("circle-transfer-id"), + }) + + testCases := []struct { + name string + setupMocksAndDBFn func(t *testing.T, mCircleService *circle.MockService) + wantErrorContains []string + shouldIncrementSyncAttempts bool + assertLogsFn func(entries []logrus.Entry) + }{ + { + name: "401 should be logged and an error should be returned", + setupMocksAndDBFn: func(t *testing.T, mCircleService *circle.MockService) { + mCircleService. + On("GetTransferByID", mock.Anything, "circle-transfer-id"). + Return(nil, &circle.APIError{StatusCode: http.StatusUnauthorized}). + Once() + }, + wantErrorContains: []string{"getting Circle transfer by ID", "APIError", "StatusCode=401"}, + }, + { + name: "403 should be logged and an error should be returned", + setupMocksAndDBFn: func(t *testing.T, mCircleService *circle.MockService) { + mCircleService. + On("GetTransferByID", mock.Anything, "circle-transfer-id"). + Return(nil, &circle.APIError{StatusCode: http.StatusForbidden}). + Once() + }, + wantErrorContains: []string{"getting Circle transfer by ID", "APIError", "StatusCode=403"}, + }, + { + name: "404 should be logged and an error should be returned", + setupMocksAndDBFn: func(t *testing.T, mCircleService *circle.MockService) { + mCircleService. + On("GetTransferByID", mock.Anything, "circle-transfer-id"). + Return(nil, &circle.APIError{StatusCode: http.StatusNotFound}). + Once() + }, + wantErrorContains: []string{"getting Circle transfer by ID", "APIError", "StatusCode=404"}, + }, + { + name: "429 should be logged and an error should be returned", + setupMocksAndDBFn: func(t *testing.T, mCircleService *circle.MockService) { + mCircleService. + On("GetTransferByID", mock.Anything, "circle-transfer-id"). + Return(nil, &circle.APIError{StatusCode: http.StatusTooManyRequests}). + Once() + }, + wantErrorContains: []string{"getting Circle transfer by ID", "APIError", "StatusCode=429"}, + }, + { + name: "5xx should be logged and an error should be returned", + setupMocksAndDBFn: func(t *testing.T, mCircleService *circle.MockService) { + mCircleService. + On("GetTransferByID", mock.Anything, "circle-transfer-id"). + Return(nil, &circle.APIError{StatusCode: http.StatusInternalServerError}). + Once() + }, + wantErrorContains: []string{"getting Circle transfer by ID", "APIError", "StatusCode=500"}, + }, + { + name: "non-API error should be logged and an error should be returned", + setupMocksAndDBFn: func(t *testing.T, mCircleService *circle.MockService) { + mCircleService. + On("GetTransferByID", mock.Anything, "circle-transfer-id"). + Return(nil, errors.New("test-error")). + Once() + }, + wantErrorContains: []string{"getting Circle transfer by ID", "test-error"}, + }, + { + name: "400 should increment the sync attempts and an error should be returned", + setupMocksAndDBFn: func(t *testing.T, mCircleService *circle.MockService) { + mCircleService. + On("GetTransferByID", mock.Anything, "circle-transfer-id"). + Return(nil, &circle.APIError{Message: "foo bar", StatusCode: http.StatusBadRequest}). + Once() + }, + wantErrorContains: []string{"getting Circle transfer by ID", "APIError", "StatusCode=400"}, + shouldIncrementSyncAttempts: true, + }, + { + name: "200 should increment the sync attempts and return nil", + setupMocksAndDBFn: func(t *testing.T, mCircleService *circle.MockService) { + mCircleService. + On("GetTransferByID", mock.Anything, "circle-transfer-id"). + Return(&circle.Transfer{ + ID: *circleRequest.CircleTransferID, + Status: circle.TransferStatusComplete, + }, nil). + Once() + }, + shouldIncrementSyncAttempts: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + dbTx, err := dbConnectionPool.BeginTxx(ctx, nil) + require.NoError(t, err) + defer func() { + err = dbTx.Rollback() + require.NoError(t, err) + }() + + // prepare mocks + mCircleService := circle.NewMockService(t) + tc.setupMocksAndDBFn(t, mCircleService) + svc := CircleReconciliationService{ + Models: models, + CircleService: mCircleService, + } + err = svc.reconcileTransferRequest(ctx, dbTx, tnt, circleRequest) + + // get the updated CircleRequestTransfer and Payment from the DB + circleReqFromDB, dbErr := models.CircleTransferRequests.Get(ctx, dbTx, data.QueryParams{ + Filters: map[data.FilterKey]interface{}{ + data.FilterKeyPaymentID: circleRequest.PaymentID, + }, + }) + require.NoError(t, dbErr) + paymentFromDB, dbErr := models.Payment.Get(ctx, circleRequest.PaymentID, dbTx) + require.NoError(t, dbErr) + + if len(tc.wantErrorContains) != 0 { + require.Error(t, err) + for _, wantErrorContains := range tc.wantErrorContains { + assert.ErrorContains(t, err, wantErrorContains) + } + } else { + require.NoError(t, err) + assert.Equal(t, data.CircleTransferStatusSuccess, *circleReqFromDB.Status) + assert.Equal(t, data.SuccessPaymentStatus, paymentFromDB.Status) + } + + if tc.shouldIncrementSyncAttempts { + assert.Equal(t, circleRequest.SyncAttempts+1, circleReqFromDB.SyncAttempts) + assert.NotNil(t, circleReqFromDB.LastSyncAttemptAt) + assert.NotNil(t, circleReqFromDB.ResponseBody) + } else { + assert.Equal(t, circleRequest.SyncAttempts, circleReqFromDB.SyncAttempts) + assert.Nil(t, circleReqFromDB.LastSyncAttemptAt) + assert.Nil(t, circleReqFromDB.ResponseBody) + } + }) + } +} diff --git a/internal/services/disbursement_management_service.go b/internal/services/disbursement_management_service.go index b132d56ee..ca3ffc498 100644 --- a/internal/services/disbursement_management_service.go +++ b/internal/services/disbursement_management_service.go @@ -7,7 +7,6 @@ import ( "fmt" "strconv" - "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/support/log" "golang.org/x/exp/maps" @@ -16,18 +15,18 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/events" "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" - tssUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" ) // DisbursementManagementService is a service for managing disbursements. type DisbursementManagementService struct { - Models *data.Models - EventProducer events.Producer - AuthManager auth.AuthManager - HorizonClient horizonclient.ClientInterface - CrashTrackerClient crashtracker.CrashTrackerClient + Models *data.Models + EventProducer events.Producer + AuthManager auth.AuthManager + CrashTrackerClient crashtracker.CrashTrackerClient + DistributionAccountService DistributionAccountServiceInterface } type UserReference struct { @@ -79,7 +78,9 @@ func (s *DisbursementManagementService) AppendUserMetadata(ctx context.Context, for _, d := range disbursements { for _, entry := range d.StatusHistory { if entry.Status == data.DraftDisbursementStatus || entry.Status == data.StartedDisbursementStatus { - users[entry.UserID] = nil + if entry.UserID != "" { + users[entry.UserID] = nil + } if entry.Status == data.StartedDisbursementStatus { // Disbursements could have multiple "started" entries in its status history log from being paused and resumed, etc. @@ -110,7 +111,10 @@ func (s *DisbursementManagementService) AppendUserMetadata(ctx context.Context, if entry.Status != data.DraftDisbursementStatus && entry.Status != data.StartedDisbursementStatus { continue } - userInfo := users[entry.UserID] + userInfo, ok := users[entry.UserID] + if !ok { + continue + } userRef := UserReference{ ID: entry.UserID, FirstName: userInfo.FirstName, @@ -191,7 +195,7 @@ func (s *DisbursementManagementService) GetDisbursementReceiversWithCount(ctx co } // StartDisbursement starts a disbursement and all its payments and receivers wallets. -func (s *DisbursementManagementService) StartDisbursement(ctx context.Context, disbursementID string, user *auth.User, distributionPubKey string) error { +func (s *DisbursementManagementService) StartDisbursement(ctx context.Context, disbursementID string, user *auth.User, distributionAccount *schema.TransactionAccount) error { opts := db.TransactionOptions{ DBConnectionPool: s.Models.DBConnectionPool, AtomicFunctionWithPostCommit: func(dbTx db.DBTransaction) (postCommitFn db.PostCommitFunction, err error) { @@ -230,71 +234,9 @@ func (s *DisbursementManagementService) StartDisbursement(ctx context.Context, d } // 4. Check if there is enough balance from the distribution wallet for this disbursement along with any pending disbursements - rootAccount, err := s.HorizonClient.AccountDetail( - horizonclient.AccountRequest{AccountID: distributionPubKey}) - if err != nil { - err = tssUtils.NewHorizonErrorWrapper(err) - return nil, fmt.Errorf("cannot get details for root account from horizon client: %w", err) - } - - var availableBalance float64 - for _, b := range rootAccount.Balances { - if disbursement.Asset.EqualsHorizonAsset(b.Asset) { - availableBalance, err = strconv.ParseFloat(b.Balance, 64) - if err != nil { - return nil, fmt.Errorf("cannot convert Horizon distribution account balance %s into float: %w", b.Balance, err) - } - } - } - - disbursementAmount, err := strconv.ParseFloat(disbursement.TotalAmount, 64) + err = s.validateBalanceForDisbursement(ctx, dbTx, distributionAccount, disbursement) if err != nil { - return nil, fmt.Errorf( - "cannot convert total amount %s for disbursement id %s into float: %w", - disbursement.TotalAmount, - disbursementID, - err, - ) - } - - var totalPendingAmount float64 = 0.0 - incompletePayments, err := s.Models.Payment.GetAll(ctx, &data.QueryParams{ - Filters: map[data.FilterKey]interface{}{ - data.FilterKeyStatus: data.PaymentInProgressStatuses(), - }, - }, dbTx) - if err != nil { - return nil, fmt.Errorf("cannot retrieve incomplete payments: %w", err) - } - - for _, ip := range incompletePayments { - if ip.Disbursement.ID == disbursementID || !ip.Asset.Equals(*disbursement.Asset) { - continue - } - - paymentAmount, parsePaymentAmountErr := strconv.ParseFloat(ip.Amount, 64) - if parsePaymentAmountErr != nil { - return nil, fmt.Errorf( - "cannot convert amount %s for paymment id %s into float: %w", - ip.Amount, - ip.ID, - err, - ) - } - totalPendingAmount += paymentAmount - } - - if (availableBalance - (disbursementAmount + totalPendingAmount)) < 0 { - err = InsufficientBalanceError{ - DisbursementAsset: *disbursement.Asset, - DistributionAddress: distributionPubKey, - DisbursementID: disbursementID, - AvailableBalance: availableBalance, - DisbursementAmount: disbursementAmount, - TotalPendingAmount: totalPendingAmount, - } - log.Ctx(ctx).Error(err) - return nil, err + return nil, fmt.Errorf("validating balance for disbursement: %w", err) } // 5. Update all correct payment status to `ready` @@ -345,21 +287,13 @@ func (s *DisbursementManagementService) StartDisbursement(ctx context.Context, d return nil, fmt.Errorf("getting ready payments for disbursement with id %s: %w", disbursementID, err) } - if len(payments) != 0 { - paymentsReadyToPayMsg, msgErr := events.NewMessage(ctx, events.PaymentReadyToPayTopic, disbursementID, events.PaymentReadyToPayDisbursementStarted, nil) - if msgErr != nil { - return nil, fmt.Errorf("creating new message: %w", msgErr) - } - - paymentsReadyToPay := schemas.EventPaymentsReadyToPayData{TenantID: paymentsReadyToPayMsg.TenantID} - for _, payment := range payments { - paymentsReadyToPay.Payments = append(paymentsReadyToPay.Payments, schemas.PaymentReadyToPay{ID: payment.ID}) - } - paymentsReadyToPayMsg.Data = paymentsReadyToPay + paymentMsgs, err := preparePaymentMessages(ctx, disbursementID, payments, distributionAccount) + if err != nil { + return nil, fmt.Errorf("preparing payment messages: %w", err) + } - msgs = append(msgs, paymentsReadyToPayMsg) - } else { - log.Ctx(ctx).Infof("no payments ready to pay for disbursement ID %s", disbursementID) + if len(paymentMsgs) > 0 { + msgs = append(msgs, paymentMsgs...) } log.Ctx(ctx).Infof("Producing %d messages to be published for disbursement ID %s", len(msgs), disbursementID) @@ -381,6 +315,74 @@ func (s *DisbursementManagementService) StartDisbursement(ctx context.Context, d return db.RunInTransactionWithPostCommit(ctx, &opts) } +func (s *DisbursementManagementService) validateBalanceForDisbursement( + ctx context.Context, + dbTx db.DBTransaction, + distributionAccount *schema.TransactionAccount, + disbursement *data.Disbursement, +) error { + availableBalance, err := s.DistributionAccountService.GetBalance(ctx, distributionAccount, *disbursement.Asset) + if err != nil { + return fmt.Errorf( + "getting balance for asset (%s,%s) on distribution account %v: %w", + disbursement.Asset.Code, + disbursement.Asset.Issuer, + distributionAccount, + err) + } + + disbursementAmount, err := strconv.ParseFloat(disbursement.TotalAmount, 64) + if err != nil { + return fmt.Errorf( + "cannot convert total amount %s for disbursement id %s into float: %w", + disbursement.TotalAmount, + disbursement.ID, + err, + ) + } + + totalPendingAmount := 0.0 + incompletePayments, err := s.Models.Payment.GetAll(ctx, &data.QueryParams{ + Filters: map[data.FilterKey]interface{}{ + data.FilterKeyStatus: data.PaymentInProgressStatuses(), + }, + }, dbTx) + if err != nil { + return fmt.Errorf("cannot retrieve incomplete payments: %w", err) + } + + for _, ip := range incompletePayments { + if ip.Disbursement.ID == disbursement.ID || !ip.Asset.Equals(*disbursement.Asset) { + continue + } + + paymentAmount, parsePaymentAmountErr := strconv.ParseFloat(ip.Amount, 64) + if parsePaymentAmountErr != nil { + return fmt.Errorf( + "cannot convert amount %s for paymment id %s into float: %w", + ip.Amount, + ip.ID, + err, + ) + } + totalPendingAmount += paymentAmount + } + + if (availableBalance - (disbursementAmount + totalPendingAmount)) < 0 { + err = InsufficientBalanceError{ + DisbursementAsset: *disbursement.Asset, + DistributionAddress: distributionAccount.ID(), + DisbursementID: disbursement.ID, + AvailableBalance: availableBalance, + DisbursementAmount: disbursementAmount, + TotalPendingAmount: totalPendingAmount, + } + log.Ctx(ctx).Error(err) + return err + } + return err +} + // PauseDisbursement pauses a disbursement and all its payments. func (s *DisbursementManagementService) PauseDisbursement(ctx context.Context, disbursementID string, user *auth.User) error { return db.RunInTransaction(ctx, s.Models.DBConnectionPool, nil, func(dbTx db.DBTransaction) error { @@ -414,3 +416,26 @@ func (s *DisbursementManagementService) PauseDisbursement(ctx context.Context, d return nil }) } + +// preparePaymentMessages prepares the messages to be sent to the event producer for the payments that are ready to pay. +func preparePaymentMessages(ctx context.Context, disbursementID string, payments []*data.Payment, distributionAccount *schema.TransactionAccount) ([]*events.Message, error) { + // Prepare the messages to be sent to the event producer. + msgs := make([]*events.Message, 0) + if len(payments) != 0 { + paymentsReadyToPayMsg, msgErr := events.NewPaymentReadyToPayMessage(ctx, distributionAccount.Type.Platform(), disbursementID, events.PaymentReadyToPayDisbursementStarted) + if msgErr != nil { + return nil, fmt.Errorf("creating new message: %w", msgErr) + } + + paymentsReadyToPay := schemas.EventPaymentsReadyToPayData{TenantID: paymentsReadyToPayMsg.TenantID} + for _, payment := range payments { + paymentsReadyToPay.Payments = append(paymentsReadyToPay.Payments, schemas.PaymentReadyToPay{ID: payment.ID}) + } + paymentsReadyToPayMsg.Data = paymentsReadyToPay + + msgs = append(msgs, paymentsReadyToPayMsg) + } else { + log.Ctx(ctx).Infof("no payments ready to pay for disbursement ID %s", disbursementID) + } + return msgs, nil +} diff --git a/internal/services/disbursement_management_service_test.go b/internal/services/disbursement_management_service_test.go index 9e5340800..ab0dfcee8 100644 --- a/internal/services/disbursement_management_service_test.go +++ b/internal/services/disbursement_management_service_test.go @@ -17,11 +17,16 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/events" "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/auth" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) @@ -191,7 +196,304 @@ func Test_DisbursementManagementService_GetDisbursementReceiversWithCount(t *tes }) } -func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { +func Test_DisbursementManagementService_StartDisbursement_success(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + ctx := context.Background() + + // Create models and basic DB entries + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + // Create fixtures: asset, wallet, country + asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, assets.EURCAssetCode, assets.EURCAssetIssuerTestnet) + wallet := data.CreateDefaultWalletFixture(t, ctx, dbConnectionPool) + country := data.GetCountryFixture(t, ctx, dbConnectionPool, data.FixtureCountryUKR) + + // Update context with tenant and auth token + tnt := tenant.Tenant{ID: "tenant-id"} + ctx = tenant.SaveTenantInContext(context.Background(), &tnt) + token := "token" + ctx = context.WithValue(ctx, middleware.TokenContextKey, token) + + // Create distribution accounts + distributionAccPubKey := "GAAHIL6ZW4QFNLCKALZ3YOIWPP4TXQ7B7J5IU7RLNVGQAV6GFDZHLDTA" + stellarDistAccountEnv := schema.NewStellarEnvTransactionAccount(distributionAccPubKey) + stellarDistAccountDBVault := schema.NewDefaultStellarTransactionAccount(distributionAccPubKey) + circleDistAccountDBVault := schema.TransactionAccount{ + CircleWalletID: "circle-wallet-id", + Type: schema.DistributionAccountCircleDBVault, + Status: schema.AccountStatusActive, + } + + ownerUser := &auth.User{ID: "owner-user", Email: "owner@test.com"} + financialUser := &auth.User{ID: "financial-user", Email: "financial@test.com"} + + // Shared mocks preparation + prepareHorizonMockFn := func(mHorizonClient *horizonclient.MockClient) { + mHorizonClient. + On("AccountDetail", horizonclient.AccountRequest{AccountID: distributionAccPubKey}). + Return(horizon.Account{ + Balances: []horizon.Balance{ + { + Balance: "10000000", + Asset: base.Asset{Code: asset.Code, Issuer: asset.Issuer}, + }, + }, + }, nil). + Once() + } + prepareCircleServiceMockFn := func(mCircleService *circle.MockService) { + mCircleService. + On("GetWalletByID", ctx, circleDistAccountDBVault.CircleWalletID). + Return(&circle.Wallet{ + WalletID: circleDistAccountDBVault.CircleWalletID, + Balances: []circle.Balance{ + {Currency: "EUR", Amount: "10000000.0"}, + }, + }, nil). + Once() + } + + testCases := []struct { + name string + distributionAccount schema.TransactionAccount + prepareMocksFn func(mHorizonClient *horizonclient.MockClient, mCircleService *circle.MockService) + approvalFlowEnabled bool + }{ + { + name: "[DISTRIBUTION_ACCOUNT.STELLAR.ENV]successfully starts a disbursement", + distributionAccount: stellarDistAccountEnv, + approvalFlowEnabled: false, + prepareMocksFn: func(mHorizonClient *horizonclient.MockClient, _ *circle.MockService) { + prepareHorizonMockFn(mHorizonClient) + }, + }, + { + name: "[DISTRIBUTION_ACCOUNT.STELLAR.ENV](APPROVAL_FLOW)successfully starts a disbursement", + distributionAccount: stellarDistAccountEnv, + approvalFlowEnabled: true, + prepareMocksFn: func(mHorizonClient *horizonclient.MockClient, _ *circle.MockService) { + prepareHorizonMockFn(mHorizonClient) + }, + }, + { + name: "[DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT]successfully starts a disbursement", + distributionAccount: stellarDistAccountDBVault, + approvalFlowEnabled: false, + prepareMocksFn: func(mHorizonClient *horizonclient.MockClient, _ *circle.MockService) { + prepareHorizonMockFn(mHorizonClient) + }, + }, + { + name: "[DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT](APPROVAL_FLOW)successfully starts a disbursement", + distributionAccount: stellarDistAccountDBVault, + approvalFlowEnabled: true, + prepareMocksFn: func(mHorizonClient *horizonclient.MockClient, _ *circle.MockService) { + prepareHorizonMockFn(mHorizonClient) + }, + }, + { + name: "[DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT]successfully starts a disbursement", + distributionAccount: circleDistAccountDBVault, + approvalFlowEnabled: false, + prepareMocksFn: func(mHorizonClient *horizonclient.MockClient, mCircleService *circle.MockService) { + prepareCircleServiceMockFn(mCircleService) + }, + }, + { + name: "[DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT](APPROVAL_FLOW)successfully starts a disbursement", + distributionAccount: circleDistAccountDBVault, + approvalFlowEnabled: true, + prepareMocksFn: func(mHorizonClient *horizonclient.MockClient, mCircleService *circle.MockService) { + prepareCircleServiceMockFn(mCircleService) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + defer data.DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) + defer data.DeleteAllReceiversFixtures(t, ctx, dbConnectionPool) + defer data.DeleteAllReceiverWalletsFixtures(t, ctx, dbConnectionPool) + defer data.DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) + + user := ownerUser + if tc.approvalFlowEnabled { + user = financialUser + + // Enable approval workflow for org. + isApprovalRequired := true + err = models.Organizations.Update(ctx, &data.OrganizationUpdate{IsApprovalRequired: &isApprovalRequired}) + require.NoError(t, err) + // rollback changes + defer func() { + isApprovalRequired = false + err = models.Organizations.Update(ctx, &data.OrganizationUpdate{IsApprovalRequired: &isApprovalRequired}) + require.NoError(t, err) + }() + } + + // Create fixtures: disbursements + readyDisbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Name: "ready disbursement", + Status: data.ReadyDisbursementStatus, + Asset: asset, + Wallet: wallet, + Country: country, + StatusHistory: []data.DisbursementStatusHistoryEntry{ + {UserID: ownerUser.ID, Status: data.DraftDisbursementStatus}, + {UserID: ownerUser.ID, Status: data.ReadyDisbursementStatus}, + }, + }) + + // Create fixtures: receivers & receiver wallets + // rDraft represents a receiver that is being added to the system for the first time + rDraft := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + rwDraft := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, rDraft.ID, wallet.ID, data.DraftReceiversWalletStatus) + // rReady represents a receiver that is already in the systrem but doesn't have a Stellar wallet yet (didn't do SEP-24) + rReady := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + rwReady := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, rReady.ID, wallet.ID, data.ReadyReceiversWalletStatus) + // rRegistered represents a receiver that is already in the system and has a Stellar wallet + rRegistered := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + rwRegistered := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, rRegistered.ID, wallet.ID, data.RegisteredReceiversWalletStatus) + + receiverIDs := []string{rDraft.ID, rReady.ID, rRegistered.ID} + t.Log(receiverIDs) + + // Create fixtures: payments + pDraft := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwDraft, + Disbursement: readyDisbursement, + Asset: *asset, + Amount: "100", + Status: data.DraftPaymentStatus, + }) + pReady := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwReady, + Disbursement: readyDisbursement, + Asset: *asset, + Amount: "200", + Status: data.DraftPaymentStatus, + }) + pRegistered := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwRegistered, + Disbursement: readyDisbursement, + Asset: *asset, + Amount: "300", + Status: data.DraftPaymentStatus, + }) + + payments := []*data.Payment{pDraft, pReady, pRegistered} + t.Log(payments) + + // Create mocks: call prepareMocksFn + mHorizonClient := &horizonclient.MockClient{} + defer mHorizonClient.AssertExpectations(t) + mCircleService := circle.NewMockService(t) + tc.prepareMocksFn(mHorizonClient, mCircleService) + + // Create mocks: events producer + mEventProducer := events.NewMockProducer(t) + mEventProducer. + On("WriteMessages", ctx, mock.AnythingOfType("[]events.Message")). + Run(func(args mock.Arguments) { + msgs, ok := args.Get(1).([]events.Message) + require.True(t, ok) + require.Len(t, msgs, 2) + + // Validating send invite msg + sendInviteMsg := msgs[0] + assert.Equal(t, events.ReceiverWalletNewInvitationTopic, sendInviteMsg.Topic) + assert.Equal(t, readyDisbursement.ID, sendInviteMsg.Key) + assert.Equal(t, events.BatchReceiverWalletSMSInvitationType, sendInviteMsg.Type) + assert.Equal(t, tnt.ID, sendInviteMsg.TenantID) + + eventData, ok := sendInviteMsg.Data.([]schemas.EventReceiverWalletSMSInvitationData) + require.True(t, ok) + require.Len(t, eventData, 2) + wantElements := []schemas.EventReceiverWalletSMSInvitationData{ + {ReceiverWalletID: rwDraft.ID}, // <--- invitation for the receiver that is being included in the system for the first time + {ReceiverWalletID: rwReady.ID}, // <--- invitation for the receiver that is already in the system but doesn't have a Stellar wallet yet + } + assert.ElementsMatch(t, wantElements, eventData) + + var expectedTopic string + switch tc.distributionAccount.Type.Platform() { + case schema.CirclePlatform: + expectedTopic = events.CirclePaymentReadyToPayTopic + case schema.StellarPlatform: + expectedTopic = events.PaymentReadyToPayTopic + } + + // Validating payments ready to pay msg + paymentsReadyToPayMsg := msgs[1] + assert.Equal(t, events.Message{ + Topic: expectedTopic, + Key: readyDisbursement.ID, + TenantID: tnt.ID, + Type: events.PaymentReadyToPayDisbursementStarted, + Data: schemas.EventPaymentsReadyToPayData{ + TenantID: tnt.ID, + Payments: []schemas.PaymentReadyToPay{ + {ID: pRegistered.ID}, + }, + }, + }, paymentsReadyToPayMsg) + }). + Return(nil). + Once() + + // Setup dependent services + distAccSvc, err := NewDistributionAccountService(DistributionAccountServiceOptions{ + HorizonClient: mHorizonClient, + CircleService: mCircleService, + NetworkType: utils.TestnetNetworkType, + }) + require.NoError(t, err) + service := &DisbursementManagementService{ + Models: models, + EventProducer: mEventProducer, + DistributionAccountService: distAccSvc, + } + + // 🚧 StartDisbursement + err = service.StartDisbursement(ctx, readyDisbursement.ID, user, &tc.distributionAccount) + require.NoError(t, err) + + // 👀 Assert status: Disbursement + updatedDisbursement, err := models.Disbursements.Get(ctx, dbConnectionPool, readyDisbursement.ID) + require.NoError(t, err) + assert.Equal(t, data.StartedDisbursementStatus, updatedDisbursement.Status) + assert.Equal(t, user.ID, updatedDisbursement.StatusHistory[2].UserID) + assert.Equal(t, data.StartedDisbursementStatus, updatedDisbursement.StatusHistory[2].Status) + + // 👀 Assert status: ReceiverWallets + receiverWallets, err := models.ReceiverWallet.GetByReceiverIDsAndWalletID(ctx, models.DBConnectionPool, receiverIDs, wallet.ID) + require.NoError(t, err) + require.Equal(t, 3, len(receiverWallets)) + rwExpectedStatuses := map[string]data.ReceiversWalletStatus{ + rwDraft.ID: data.ReadyReceiversWalletStatus, + rwReady.ID: data.ReadyReceiversWalletStatus, + rwRegistered.ID: data.RegisteredReceiversWalletStatus, + } + for _, rw := range receiverWallets { + require.Equal(t, rwExpectedStatuses[rw.ID], rw.Status) + } + + // 👀 Assert status: Payments + for _, p := range payments { + payment, err := models.Payment.Get(ctx, p.ID, dbConnectionPool) + require.NoError(t, err) + require.Equal(t, data.ReadyPaymentStatus, payment.Status) + } + }) + } +} + +func Test_DisbursementManagementService_StartDisbursement_failure(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) @@ -208,6 +510,10 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { // Create fixtures: asset, wallet, country asset := data.GetAssetFixture(t, ctx, dbConnectionPool, data.FixtureAssetUSDC) + distributionAccPubKey := "GAAHIL6ZW4QFNLCKALZ3YOIWPP4TXQ7B7J5IU7RLNVGQAV6GFDZHLDTA" + distributionAcc := schema.NewDefaultStellarTransactionAccount(distributionAccPubKey) + + // create fixtures wallet := data.CreateDefaultWalletFixture(t, ctx, dbConnectionPool) country := data.GetCountryFixture(t, ctx, dbConnectionPool, data.FixtureCountryUKR) @@ -219,60 +525,14 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { Wallet: wallet, Country: country, }) - readyDisbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "ready disbursement", - Status: data.ReadyDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, - }) - - // Create fixtures: receivers, receiver wallets, payments - receiver1 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) - receiver2 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) - receiver3 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) - receiver4 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) - - receiverIds := []string{receiver1.ID, receiver2.ID, receiver3.ID, receiver4.ID} - - rwDraft1 := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet.ID, data.DraftReceiversWalletStatus) - rwDraft2 := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver2.ID, wallet.ID, data.DraftReceiversWalletStatus) - rwReady := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver3.ID, wallet.ID, data.ReadyReceiversWalletStatus) - rwRegistered := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver4.ID, wallet.ID, data.RegisteredReceiversWalletStatus) - - payment1 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rwDraft1, - Disbursement: readyDisbursement, - Asset: *asset, - Amount: "100", - Status: data.DraftPaymentStatus, - }) - payment2 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rwDraft2, - Disbursement: readyDisbursement, - Asset: *asset, - Amount: "200", - Status: data.DraftPaymentStatus, - }) - payment3 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rwReady, - Disbursement: readyDisbursement, - Asset: *asset, - Amount: "300", - Status: data.DraftPaymentStatus, - }) - payment4 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rwRegistered, - Disbursement: readyDisbursement, - Asset: *asset, - Amount: "400", - Status: data.DraftPaymentStatus, - }) - payments := []*data.Payment{payment1, payment2, payment3, payment4} + // Create fixtures: receivers, receiver wallets + receiverReady := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + rwReady := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverReady.ID, wallet.ID, data.ReadyReceiversWalletStatus) + receiverRegistered := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + rwRegistered := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverRegistered.ID, wallet.ID, data.RegisteredReceiversWalletStatus) - distributionPubKey := "GAAHIL6ZW4QFNLCKALZ3YOIWPP4TXQ7B7J5IU7RLNVGQAV6GFDZHLDTA" - hAccRequest := horizonclient.AccountRequest{AccountID: distributionPubKey} + hAccRequest := horizonclient.AccountRequest{AccountID: distributionAccPubKey} hAccResponse := horizon.Account{ Balances: []horizon.Balance{ { @@ -288,7 +548,7 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { t.Run("returns an error if the disbursement doesn't exist", func(t *testing.T) { service := DisbursementManagementService{Models: models} - err = service.StartDisbursement(context.Background(), "not-found-id", nil, distributionPubKey) + err = service.StartDisbursement(context.Background(), "not-found-id", nil, &distributionAcc) require.ErrorIs(t, err, ErrDisbursementNotFound) }) @@ -297,14 +557,14 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { data.EnableOrDisableWalletFixtures(t, ctx, dbConnectionPool, false, wallet.ID) defer data.EnableOrDisableWalletFixtures(t, ctx, dbConnectionPool, true, wallet.ID) - err = service.StartDisbursement(context.Background(), draftDisbursement.ID, nil, distributionPubKey) + err = service.StartDisbursement(context.Background(), draftDisbursement.ID, nil, &distributionAcc) require.ErrorIs(t, err, ErrDisbursementWalletDisabled) }) t.Run("returns an error if the disbursement status is not READY", func(t *testing.T) { service := DisbursementManagementService{Models: models} - err = service.StartDisbursement(context.Background(), draftDisbursement.ID, nil, distributionPubKey) + err = service.StartDisbursement(context.Background(), draftDisbursement.ID, nil, &distributionAcc) require.ErrorIs(t, err, ErrDisbursementNotReadyToStart) }) @@ -340,7 +600,7 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { err = models.Organizations.Update(ctx, &data.OrganizationUpdate{IsApprovalRequired: &isApprovalRequired}) require.NoError(t, err) - err = service.StartDisbursement(ctx, disbursement.ID, user, distributionPubKey) + err = service.StartDisbursement(ctx, disbursement.ID, user, &distributionAcc) require.ErrorIs(t, err, ErrDisbursementStartedByCreator) // rollback changes @@ -349,176 +609,6 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { require.NoError(t, err) }) - t.Run("🎉 (APPROVAL FLOW ENABLED) successfully starts a disbursement using the approval workflow", func(t *testing.T) { - userID := "9ae68f09-cad9-4311-9758-4ff59d2e9e6d" - statusHistory := []data.DisbursementStatusHistoryEntry{ - { - Status: data.DraftDisbursementStatus, - UserID: userID, - }, - { - Status: data.ReadyDisbursementStatus, - UserID: userID, - }, - } - - disbursement := data.CreateDisbursementFixture(t, context.Background(), dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "disbursement #2", - Status: data.ReadyDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, - StatusHistory: statusHistory, - }) - data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rwReady, - Disbursement: disbursement, - Asset: *asset, - Amount: "100", - Status: data.DraftPaymentStatus, - }) - - user := &auth.User{ - ID: "another user id", - Email: "email@email.com", - } - - // Enable approval workflow for org. - isApprovalRequired := true - err = models.Organizations.Update(ctx, &data.OrganizationUpdate{IsApprovalRequired: &isApprovalRequired}) - require.NoError(t, err) - - // Create Mocks - hMock := &horizonclient.MockClient{} - defer hMock.AssertExpectations(t) - hMock.On("AccountDetail", hAccRequest).Return(hAccResponse, nil).Once() - mockEventProducer := events.NewMockProducer(t) - mockEventProducer. - On("WriteMessages", ctx, mock.AnythingOfType("[]events.Message")). - Run(func(args mock.Arguments) { - msgs, ok := args.Get(1).([]events.Message) - require.True(t, ok) - require.Len(t, msgs, 1) - - // Validating send invite msg - sendInviteMsg := msgs[0] - assert.Equal(t, events.ReceiverWalletNewInvitationTopic, sendInviteMsg.Topic) - assert.Equal(t, disbursement.ID, sendInviteMsg.Key) - assert.Equal(t, events.BatchReceiverWalletSMSInvitationType, sendInviteMsg.Type) - assert.Equal(t, tnt.ID, sendInviteMsg.TenantID) - - eventData, ok := sendInviteMsg.Data.([]schemas.EventReceiverWalletSMSInvitationData) - require.True(t, ok) - require.Len(t, eventData, 1) - assert.Equal(t, schemas.EventReceiverWalletSMSInvitationData{ReceiverWalletID: rwReady.ID}, eventData[0]) - }). - Return(nil). - Once() - - // Create service - service := &DisbursementManagementService{ - Models: models, - HorizonClient: hMock, - EventProducer: mockEventProducer, - } - - err = service.StartDisbursement(ctx, disbursement.ID, user, distributionPubKey) - require.NoError(t, err) - - // check disbursement status - disbursement, err = models.Disbursements.Get(context.Background(), models.DBConnectionPool, disbursement.ID) - require.NoError(t, err) - require.Equal(t, data.StartedDisbursementStatus, disbursement.Status) - - // rollback changes - isApprovalRequired = false - err = models.Organizations.Update(ctx, &data.OrganizationUpdate{IsApprovalRequired: &isApprovalRequired}) - require.NoError(t, err) - }) - - t.Run("🎉 successfully starts a disbursement", func(t *testing.T) { - // Create Mocks - hMock := &horizonclient.MockClient{} - defer hMock.AssertExpectations(t) - hMock.On("AccountDetail", hAccRequest).Return(hAccResponse, nil).Once() - mockEventProducer := events.NewMockProducer(t) - mockEventProducer. - On("WriteMessages", ctx, mock.AnythingOfType("[]events.Message")). - Run(func(args mock.Arguments) { - msgs, ok := args.Get(1).([]events.Message) - require.True(t, ok) - require.Len(t, msgs, 2) - - // Validating send invite msg - sendInviteMsg := msgs[0] - assert.Equal(t, events.ReceiverWalletNewInvitationTopic, sendInviteMsg.Topic) - assert.Equal(t, readyDisbursement.ID, sendInviteMsg.Key) - assert.Equal(t, events.BatchReceiverWalletSMSInvitationType, sendInviteMsg.Type) - assert.Equal(t, tnt.ID, sendInviteMsg.TenantID) - - eventData, ok := sendInviteMsg.Data.([]schemas.EventReceiverWalletSMSInvitationData) - require.True(t, ok) - assert.Len(t, eventData, 3) - - // Validating payments ready to pay msg - paymentsReadyToPayMsg := msgs[1] - assert.Equal(t, events.Message{ - Topic: events.PaymentReadyToPayTopic, - Key: readyDisbursement.ID, - TenantID: tnt.ID, - Type: events.PaymentReadyToPayDisbursementStarted, - Data: schemas.EventPaymentsReadyToPayData{ - TenantID: tnt.ID, - Payments: []schemas.PaymentReadyToPay{ - {ID: payment4.ID}, - }, - }, - }, paymentsReadyToPayMsg) - }). - Return(nil). - Once() - - // Create service - service := &DisbursementManagementService{ - Models: models, - HorizonClient: hMock, - EventProducer: mockEventProducer, - } - - user := &auth.User{ID: "user-id", Email: "email@email.com"} - err = service.StartDisbursement(ctx, readyDisbursement.ID, user, distributionPubKey) - require.NoError(t, err) - - // check disbursement status - disbursement, getDisbursementErr := models.Disbursements.Get(context.Background(), models.DBConnectionPool, readyDisbursement.ID) - require.NoError(t, getDisbursementErr) - require.Equal(t, data.StartedDisbursementStatus, disbursement.Status) - - // check disbursement history - require.Equal(t, disbursement.StatusHistory[1].UserID, user.ID) - - // check receivers wallets status - receiverWallets, getReceiversErr := models.ReceiverWallet.GetByReceiverIDsAndWalletID(ctx, models.DBConnectionPool, receiverIds, wallet.ID) - require.NoError(t, getReceiversErr) - require.Equal(t, 4, len(receiverWallets)) - rwExpectedStatuses := map[string]data.ReceiversWalletStatus{ - rwDraft1.ID: data.ReadyReceiversWalletStatus, - rwDraft2.ID: data.ReadyReceiversWalletStatus, - rwReady.ID: data.ReadyReceiversWalletStatus, - rwRegistered.ID: data.RegisteredReceiversWalletStatus, - } - for _, rw := range receiverWallets { - require.Equal(t, rwExpectedStatuses[rw.ID], rw.Status) - } - - // check payments status - for _, p := range payments { - payment, getPaymentErr := models.Payment.Get(ctx, p.ID, dbConnectionPool) - require.NoError(t, getPaymentErr) - require.Equal(t, data.ReadyPaymentStatus, payment.Status) - } - }) - t.Run("returns an error if the distribution account has insuficcient balance", func(t *testing.T) { usdt := data.CreateAssetFixture(t, ctx, dbConnectionPool, "USDT", "GBVHJTRLQRMIHRYTXZQOPVYCVVH7IRJN3DOFT7VC6U75CBWWBVDTWURG") @@ -587,25 +677,34 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { }, }, nil).Once() + // Setup dependent services + distAccSvc, err := NewDistributionAccountService(DistributionAccountServiceOptions{ + HorizonClient: hMock, + CircleService: &circle.Service{}, + NetworkType: utils.TestnetNetworkType, + }) + require.NoError(t, err) + // Create service service := &DisbursementManagementService{ - Models: models, - HorizonClient: hMock, + Models: models, + DistributionAccountService: distAccSvc, } - err = service.StartDisbursement(ctx, disbursementInsufficientBalance.ID, nil, distributionPubKey) + err = service.StartDisbursement(ctx, disbursementInsufficientBalance.ID, nil, &distributionAcc) expectedErr := InsufficientBalanceError{ DisbursementAsset: *usdt, - DistributionAddress: distributionPubKey, + DistributionAddress: distributionAcc.ID(), DisbursementID: disbursementInsufficientBalance.ID, AvailableBalance: 11111.0, DisbursementAmount: 22222.0, TotalPendingAmount: 1100.0, } - require.EqualError(t, err, fmt.Sprintf("running atomic function in RunInTransactionWithPostCommit: %v", expectedErr)) + + require.EqualError(t, err, fmt.Sprintf("running atomic function in RunInTransactionWithPostCommit: validating balance for disbursement: %v", expectedErr)) // PendingTotal includes payments associated with 'readyDisbursement' that were moved from the draft to ready status - expectedErrStr := fmt.Sprintf("the disbursement %s failed due to an account balance (11111.00) that was insufficient to fulfill new amount (22222.00) along with the pending amount (1100.00). To complete this action, your distribution account (GAAHIL6ZW4QFNLCKALZ3YOIWPP4TXQ7B7J5IU7RLNVGQAV6GFDZHLDTA) needs to be recharged with at least 12211.00 USDT", disbursementInsufficientBalance.ID) + expectedErrStr := fmt.Sprintf("the disbursement %s failed due to an account balance (11111.00) that was insufficient to fulfill new amount (22222.00) along with the pending amount (1100.00). To complete this action, your distribution account (stellar:GAAHIL6ZW4QFNLCKALZ3YOIWPP4TXQ7B7J5IU7RLNVGQAV6GFDZHLDTA) needs to be recharged with at least 12211.00 USDT", disbursementInsufficientBalance.ID) assert.Contains(t, buf.String(), expectedErrStr) }) @@ -689,12 +788,20 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { }). Once() + // Setup dependent services + distAccSvc, err := NewDistributionAccountService(DistributionAccountServiceOptions{ + HorizonClient: hMock, + CircleService: &circle.Service{}, + NetworkType: utils.TestnetNetworkType, + }) + require.NoError(t, err) + // Create service service := &DisbursementManagementService{ - Models: models, - HorizonClient: hMock, - EventProducer: mockEventProducer, - CrashTrackerClient: mCrashTracker, + Models: models, + EventProducer: mockEventProducer, + CrashTrackerClient: mCrashTracker, + DistributionAccountService: distAccSvc, } user := &auth.User{ @@ -702,7 +809,7 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { Email: "email@email.com", } - err = service.StartDisbursement(ctx, disbursement.ID, user, distributionPubKey) + err = service.StartDisbursement(ctx, disbursement.ID, user, &distributionAcc) assert.NoError(t, err) }) @@ -755,18 +862,26 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { Return(nil). Once() + // Setup dependent services + distAccSvc, err := NewDistributionAccountService(DistributionAccountServiceOptions{ + HorizonClient: hMock, + CircleService: &circle.Service{}, + NetworkType: utils.TestnetNetworkType, + }) + require.NoError(t, err) + // Create service service := &DisbursementManagementService{ - Models: models, - HorizonClient: hMock, - EventProducer: mockEventProducer, + Models: models, + EventProducer: mockEventProducer, + DistributionAccountService: distAccSvc, } getEntries := log.DefaultLogger.StartTest(log.InfoLevel) user := &auth.User{ID: "user-id", Email: "email@email.com"} - err = service.StartDisbursement(ctx, disbursement.ID, user, distributionPubKey) + err = service.StartDisbursement(ctx, disbursement.ID, user, &distributionAcc) require.NoError(t, err) entries := getEntries() @@ -812,14 +927,22 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { defer hMock.AssertExpectations(t) hMock.On("AccountDetail", hAccRequest).Return(hAccResponse, nil).Once() + // Setup dependent services + distAccSvc, err := NewDistributionAccountService(DistributionAccountServiceOptions{ + HorizonClient: hMock, + CircleService: &circle.Service{}, + NetworkType: utils.TestnetNetworkType, + }) + require.NoError(t, err) + // Create service service := &DisbursementManagementService{ - Models: models, - HorizonClient: hMock, + Models: models, + DistributionAccountService: distAccSvc, } - err = service.StartDisbursement(ctxWithoutTenant, disbursement.ID, user, distributionPubKey) - assert.EqualError(t, err, "running atomic function in RunInTransactionWithPostCommit: creating new message: getting tenant from context: tenant not found in context") + err = service.StartDisbursement(ctxWithoutTenant, disbursement.ID, user, &distributionAcc) + assert.ErrorContains(t, err, "creating new message: getting tenant from context: tenant not found in context") }) t.Run("logs when couldn't write message because EventProducer is nil", func(t *testing.T) { @@ -868,14 +991,22 @@ func Test_DisbursementManagementService_StartDisbursement(t *testing.T) { defer hMock.AssertExpectations(t) hMock.On("AccountDetail", hAccRequest).Return(hAccResponse, nil).Once() + // Setup dependent services + distAccSvc, err := NewDistributionAccountService(DistributionAccountServiceOptions{ + HorizonClient: hMock, + CircleService: &circle.Service{}, + NetworkType: utils.TestnetNetworkType, + }) + require.NoError(t, err) + // Create service service := &DisbursementManagementService{ - Models: models, - HorizonClient: hMock, - EventProducer: nil, // <----- EventProducer is nil + Models: models, + EventProducer: nil, // <----- EventProducer is nil + DistributionAccountService: distAccSvc, } - err = service.StartDisbursement(ctx, disbursement.ID, user, distributionPubKey) + err = service.StartDisbursement(ctx, disbursement.ID, user, &distributionAcc) require.NoError(t, err) msgs := []events.Message{ @@ -942,12 +1073,19 @@ func Test_DisbursementManagementService_PauseDisbursement(t *testing.T) { asset := data.GetAssetFixture(t, ctx, dbConnectionPool, data.FixtureAssetUSDC) hMock := &horizonclient.MockClient{} - distributionPubKey := "ABC" + distributionAccPubKey := "ABC" + distributionAcc := schema.NewDefaultStellarTransactionAccount(distributionAccPubKey) + distAccSvc, err := NewDistributionAccountService(DistributionAccountServiceOptions{ + HorizonClient: hMock, + CircleService: &circle.Service{}, + NetworkType: utils.TestnetNetworkType, + }) + require.NoError(t, err) service := &DisbursementManagementService{ - Models: models, - HorizonClient: hMock, - EventProducer: &mockEventProducer, + Models: models, + EventProducer: &mockEventProducer, + DistributionAccountService: distAccSvc, } // create fixtures @@ -1025,7 +1163,7 @@ func Test_DisbursementManagementService_PauseDisbursement(t *testing.T) { t.Run("disbursement paused", func(t *testing.T) { hMock.On( - "AccountDetail", horizonclient.AccountRequest{AccountID: distributionPubKey}, + "AccountDetail", horizonclient.AccountRequest{AccountID: distributionAccPubKey}, ).Return(horizon.Account{ Balances: []horizon.Balance{ { @@ -1084,7 +1222,7 @@ func Test_DisbursementManagementService_PauseDisbursement(t *testing.T) { Once() // change the disbursement back to started - err = service.StartDisbursement(ctx, startedDisbursement.ID, user, distributionPubKey) + err = service.StartDisbursement(ctx, startedDisbursement.ID, user, &distributionAcc) require.NoError(t, err) // check disbursement is started again @@ -1095,7 +1233,7 @@ func Test_DisbursementManagementService_PauseDisbursement(t *testing.T) { t.Run("start -> pause -> start -> pause", func(t *testing.T) { hMock.On( - "AccountDetail", horizonclient.AccountRequest{AccountID: distributionPubKey}, + "AccountDetail", horizonclient.AccountRequest{AccountID: distributionAccPubKey}, ).Return(horizon.Account{ Balances: []horizon.Balance{ { @@ -1155,7 +1293,7 @@ func Test_DisbursementManagementService_PauseDisbursement(t *testing.T) { Once() // 2. Start disbursement again - err = service.StartDisbursement(ctx, startedDisbursement.ID, user, distributionPubKey) + err = service.StartDisbursement(ctx, startedDisbursement.ID, user, &distributionAcc) require.NoError(t, err) // check disbursement is started again @@ -1203,3 +1341,181 @@ func Test_DisbursementManagementService_PauseDisbursement(t *testing.T) { hMock.AssertExpectations(t) } + +func Test_DisbursementManagementService_validateBalanceForDisbursement(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, outerErr) + defer dbConnectionPool.Close() + + ctx := context.Background() + + // Create fixtures + models, outerErr := data.NewModels(dbConnectionPool) + require.NoError(t, outerErr) + asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") + country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") + wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") + receiverReady := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + rwReady := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverReady.ID, wallet.ID, data.ReadyReceiversWalletStatus) + disbursementOld := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Country: country, + Wallet: wallet, + Status: data.ReadyDisbursementStatus, + Asset: asset, + }) + _ = data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursementOld, + Asset: *asset, + Amount: "10", + Status: data.PendingPaymentStatus, + }) + disbursementNew := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Country: country, + Wallet: wallet, + Status: data.ReadyDisbursementStatus, + Asset: asset, + }) + _ = data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursementNew, + Asset: *asset, + Amount: "90", + Status: data.DraftPaymentStatus, + }) + disbursementNew, err := models.Disbursements.GetWithStatistics(ctx, disbursementNew.ID) + require.NoError(t, err) + + // Create distribution accounts + distributionAccPubKey := "GAAHIL6ZW4QFNLCKALZ3YOIWPP4TXQ7B7J5IU7RLNVGQAV6GFDZHLDTA" + stellarDistAccountEnv := schema.NewStellarEnvTransactionAccount(distributionAccPubKey) + stellarDistAccountDBVault := schema.NewDefaultStellarTransactionAccount(distributionAccPubKey) + circleDistAccountDBVault := schema.TransactionAccount{ + CircleWalletID: "circle-wallet-id", + Type: schema.DistributionAccountCircleDBVault, + Status: schema.AccountStatusActive, + } + + expectedInsufficientBalanceErr := func(account schema.TransactionAccount) InsufficientBalanceError { + return InsufficientBalanceError{ + DisbursementAsset: *asset, + DistributionAddress: account.ID(), + DisbursementID: disbursementNew.ID, + AvailableBalance: 99.99, + DisbursementAmount: 90.00, + TotalPendingAmount: 10.00, + } + } + + // test cases + testCases := []struct { + name string + disbursementAccount schema.TransactionAccount + prepareMocksFn func(mDistAccService *mocks.MockDistributionAccountService) + availableBalance string + expectedErrContains string + }{ + { + name: "return an error when GetBalance fails", + disbursementAccount: stellarDistAccountEnv, + prepareMocksFn: func(mDistAccService *mocks.MockDistributionAccountService) { + mDistAccService. + On("GetBalance", ctx, &stellarDistAccountEnv, *asset). + Return(0.0, errors.New("GetBalance error")). + Once() + }, + expectedErrContains: fmt.Sprintf("getting balance for asset (%s,%s) on distribution account %v: GetBalance error", asset.Code, asset.Issuer, stellarDistAccountEnv), + }, + { + name: "🔴[DISTRIBUTION_ACCOUNT.STELLAR.ENV] insufficient ballance for disbursement", + disbursementAccount: stellarDistAccountEnv, + prepareMocksFn: func(mDistAccService *mocks.MockDistributionAccountService) { + mDistAccService. + On("GetBalance", ctx, &stellarDistAccountEnv, *asset). + Return(99.99, nil). + Once() + }, + expectedErrContains: expectedInsufficientBalanceErr(stellarDistAccountEnv).Error(), + }, + { + name: "🔴[DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT] insufficient ballance for disbursement", + disbursementAccount: stellarDistAccountDBVault, + prepareMocksFn: func(mDistAccService *mocks.MockDistributionAccountService) { + mDistAccService. + On("GetBalance", ctx, &stellarDistAccountDBVault, *asset). + Return(99.99, nil). + Once() + }, + expectedErrContains: expectedInsufficientBalanceErr(stellarDistAccountDBVault).Error(), + }, + { + name: "🔴[DISTRIBUTION_ACCOUNT.CIRCLE_DB_VAULT] insufficient ballance for disbursement", + disbursementAccount: circleDistAccountDBVault, + prepareMocksFn: func(mDistAccService *mocks.MockDistributionAccountService) { + mDistAccService. + On("GetBalance", ctx, &circleDistAccountDBVault, *asset). + Return(99.99, nil). + Once() + }, + expectedErrContains: expectedInsufficientBalanceErr(circleDistAccountDBVault).Error(), + }, + { + name: "🟢[DISTRIBUTION_ACCOUNT.STELLAR.ENV] successfully validate ballance for disbursement", + disbursementAccount: stellarDistAccountEnv, + prepareMocksFn: func(mDistAccService *mocks.MockDistributionAccountService) { + mDistAccService. + On("GetBalance", ctx, &stellarDistAccountEnv, *asset). + Return(100.00, nil). + Once() + }, + }, + { + name: "🟢[DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT] successfully validate ballance for disbursement", + disbursementAccount: stellarDistAccountDBVault, + prepareMocksFn: func(mDistAccService *mocks.MockDistributionAccountService) { + mDistAccService. + On("GetBalance", ctx, &stellarDistAccountDBVault, *asset). + Return(100.00, nil). + Once() + }, + }, + { + name: "🟢[DISTRIBUTION_ACCOUNT.CIRCLE_DB_VAULT] successfully validate ballance for disbursement", + disbursementAccount: circleDistAccountDBVault, + prepareMocksFn: func(mDistAccService *mocks.MockDistributionAccountService) { + mDistAccService. + On("GetBalance", ctx, &circleDistAccountDBVault, *asset). + Return(100.00, nil). + Once() + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + dbTx, err := dbConnectionPool.BeginTxx(ctx, nil) + require.NoError(t, err) + defer func() { + err = dbTx.Rollback() + require.NoError(t, err) + }() + + mDistAccService := mocks.NewMockDistributionAccountService(t) + tc.prepareMocksFn(mDistAccService) + svc := &DisbursementManagementService{ + Models: models, + DistributionAccountService: mDistAccService, + } + + err = svc.validateBalanceForDisbursement(ctx, dbTx, &tc.disbursementAccount, disbursementNew) + + if tc.expectedErrContains != "" { + require.ErrorContains(t, err, tc.expectedErrContains) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/services/distribution_account_service.go b/internal/services/distribution_account_service.go new file mode 100644 index 000000000..b5eb42eaf --- /dev/null +++ b/internal/services/distribution_account_service.go @@ -0,0 +1,193 @@ +package services + +import ( + "context" + "fmt" + "strconv" + + "github.com/stellar/go/clients/horizonclient" + "github.com/stellar/go/support/log" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" +) + +//go:generate mockery --name=DistributionAccountServiceInterface --case=underscore --structname=MockDistributionAccountService --filename=distribution_account_service.go +type DistributionAccountServiceInterface interface { + GetBalances(context context.Context, account *schema.TransactionAccount) (map[data.Asset]float64, error) + GetBalance(context context.Context, account *schema.TransactionAccount, asset data.Asset) (float64, error) +} + +type DistributionAccountServiceOptions struct { + HorizonClient horizonclient.ClientInterface + CircleService circle.ServiceInterface + NetworkType utils.NetworkType +} + +func (opts DistributionAccountServiceOptions) Validate() error { + if opts.HorizonClient == nil { + return fmt.Errorf("Horizon client cannot be nil") + } + + if opts.CircleService == nil { + return fmt.Errorf("Circle service cannot be nil") + } + + err := opts.NetworkType.Validate() + if err != nil { + return fmt.Errorf("validating network type: %w", err) + } + + return nil +} + +type DistributionAccountService struct { + strategies map[schema.AccountType]DistributionAccountServiceInterface +} + +func NewDistributionAccountService(opts DistributionAccountServiceOptions) (*DistributionAccountService, error) { + if err := opts.Validate(); err != nil { + return nil, fmt.Errorf("validating options: %w", err) + } + + stellarDistributionAccSvc := &StellarDistributionAccountService{ + horizonClient: opts.HorizonClient, + } + + circleDistributionAccSvc := &CircleDistributionAccountService{ + CircleService: opts.CircleService, + NetworkType: opts.NetworkType, + } + + strategies := map[schema.AccountType]DistributionAccountServiceInterface{ + schema.DistributionAccountStellarEnv: stellarDistributionAccSvc, + schema.DistributionAccountStellarDBVault: stellarDistributionAccSvc, + schema.DistributionAccountCircleDBVault: circleDistributionAccSvc, + } + return &DistributionAccountService{strategies: strategies}, nil +} + +func (s *DistributionAccountService) GetBalance(ctx context.Context, account *schema.TransactionAccount, asset data.Asset) (float64, error) { + return s.strategies[account.Type].GetBalance(ctx, account, asset) +} + +func (s *DistributionAccountService) GetBalances(ctx context.Context, account *schema.TransactionAccount) (map[data.Asset]float64, error) { + return s.strategies[account.Type].GetBalances(ctx, account) +} + +var _ DistributionAccountServiceInterface = (*DistributionAccountService)(nil) + +type StellarDistributionAccountService struct { + horizonClient horizonclient.ClientInterface +} + +var _ DistributionAccountServiceInterface = (*StellarDistributionAccountService)(nil) + +func (s *StellarDistributionAccountService) GetBalances(_ context.Context, account *schema.TransactionAccount) (map[data.Asset]float64, error) { + accountDetails, err := s.horizonClient.AccountDetail(horizonclient.AccountRequest{AccountID: account.Address}) + if err != nil { + return nil, fmt.Errorf("getting details for account from Horizon: %w", err) + } + + balances := make(map[data.Asset]float64) + for _, b := range accountDetails.Balances { + var code, issuer string + if b.Asset.Type == "native" { + code = assets.XLMAssetCode + } else { + code = b.Asset.Code + issuer = b.Asset.Issuer + } + + assetBal, parseAssetBalErr := strconv.ParseFloat(b.Balance, 64) + if parseAssetBalErr != nil { + return nil, fmt.Errorf("parsing balance to float: %w", parseAssetBalErr) + } + + balances[data.Asset{ + Code: code, + Issuer: issuer, + }] = assetBal + } + + return balances, nil +} + +func (s *StellarDistributionAccountService) GetBalance(ctx context.Context, account *schema.TransactionAccount, asset data.Asset) (float64, error) { + accBalances, err := s.GetBalances(ctx, account) + if err != nil { + return 0, fmt.Errorf("getting balances for distribution account: %w", err) + } + + code := asset.Code + var issuer string + if !asset.IsNative() { + issuer = asset.Issuer + } + + if assetBalance, ok := accBalances[data.Asset{ + Code: code, + Issuer: issuer, + }]; ok { + return assetBalance, nil + } + + return 0, fmt.Errorf("balance for asset %s not found for distribution account", asset) +} + +type CircleDistributionAccountService struct { + CircleService circle.ServiceInterface + NetworkType utils.NetworkType +} + +var _ DistributionAccountServiceInterface = (*CircleDistributionAccountService)(nil) + +func (s *CircleDistributionAccountService) GetBalances(ctx context.Context, account *schema.TransactionAccount) (map[data.Asset]float64, error) { + if !account.IsCircle() { + return nil, fmt.Errorf("distribution account is not a Circle account") + } + if account.Status == schema.AccountStatusPendingUserActivation { + return nil, fmt.Errorf("This organization's distribution account is in %s state, please complete the %s activation process to access this endpoint.", account.Status, account.Type.Platform()) + } + + wallet, err := s.CircleService.GetWalletByID(ctx, account.CircleWalletID) + if err != nil { + return nil, fmt.Errorf("getting wallet by ID: %w", err) + } + + balances := make(map[data.Asset]float64) + for _, b := range wallet.Balances { + asset, err := circle.ParseStellarAsset(b.Currency, s.NetworkType) + if err != nil { + log.Ctx(ctx).Debugf("Ignoring balance for asset %s, as it's not supported by the SDP: %v", b.Currency, err) + continue + } + + assetBal, err := strconv.ParseFloat(b.Amount, 64) + if err != nil { + return nil, fmt.Errorf("parsing balance to float: %w", err) + } + + balances[asset] = assetBal + } + + return balances, nil +} + +func (s *CircleDistributionAccountService) GetBalance(ctx context.Context, account *schema.TransactionAccount, asset data.Asset) (float64, error) { + accBalances, err := s.GetBalances(ctx, account) + if err != nil { + return 0, fmt.Errorf("getting balances for distribution account: %w", err) + } + + asset = data.Asset{Code: asset.Code, Issuer: asset.Issuer} // scrub the other fields + assetBalance, ok := accBalances[asset] + if !ok { + return 0, fmt.Errorf("balance for asset %v not found for distribution account", asset) + } + + return assetBalance, nil +} diff --git a/internal/services/distribution_account_service_test.go b/internal/services/distribution_account_service_test.go new file mode 100644 index 000000000..be69f21cf --- /dev/null +++ b/internal/services/distribution_account_service_test.go @@ -0,0 +1,482 @@ +package services + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/stellar/go/clients/horizonclient" + "github.com/stellar/go/keypair" + "github.com/stellar/go/protocols/horizon" + "github.com/stellar/go/protocols/horizon/base" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" +) + +func Test_DistributionAccountServiceOptions_Validate(t *testing.T) { + testCases := []struct { + name string + opts DistributionAccountServiceOptions + expectedError string + }{ + { + name: "🔴returns error if Horizon client is nil", + opts: DistributionAccountServiceOptions{}, + expectedError: "Horizon client cannot be nil", + }, + { + name: "🔴returns error if Circle service is nil", + opts: DistributionAccountServiceOptions{ + HorizonClient: &horizonclient.Client{}, + }, + expectedError: "Circle service cannot be nil", + }, + { + name: "🔴returns error if network type is invalid", + opts: DistributionAccountServiceOptions{ + HorizonClient: &horizonclient.Client{}, + CircleService: &circle.Service{}, + NetworkType: "foobar", + }, + expectedError: `validating network type: invalid network type "foobar"`, + }, + { + name: "🟢returns nil if all fields are valid", + opts: DistributionAccountServiceOptions{ + HorizonClient: &horizonclient.Client{}, + CircleService: &circle.Service{}, + NetworkType: utils.TestnetNetworkType, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.opts.Validate() + if tc.expectedError != "" { + require.ErrorContains(t, err, tc.expectedError) + } else { + require.NoError(t, err) + } + }) + } +} + +func Test_StellarDistributionAccountService_GetBalances(t *testing.T) { + ctx := context.Background() + accAddress := keypair.MustRandom().Address() + distAcc := schema.NewStellarEnvTransactionAccount(accAddress) + + nativeAsset := data.Asset{Code: assets.XLMAssetCode, Issuer: ""} + usdcAsset := data.Asset{Code: assets.USDCAssetCode, Issuer: assets.USDCAssetIssuerTestnet} + + testCases := []struct { + name string + expectedBalances map[data.Asset]float64 + expectedError error + mockHorizonClientFn func(mHorizonClient *horizonclient.MockClient) + }{ + { + name: "🟢successfully gets balances", + mockHorizonClientFn: func(mHorizonClient *horizonclient.MockClient) { + mHorizonClient.On("AccountDetail", horizonclient.AccountRequest{ + AccountID: distAcc.Address, + }).Return(horizon.Account{ + Balances: []horizon.Balance{ + { + Asset: base.Asset{Code: usdcAsset.Code, Issuer: usdcAsset.Issuer}, + Balance: "100.0000000", + }, + { + Asset: base.Asset{Code: nativeAsset.Code, Type: "native"}, + Balance: "100000.0000000", + }, + }, + }, nil).Once() + }, + expectedBalances: map[data.Asset]float64{ + usdcAsset: 100.0, + nativeAsset: 100000.0, + }, + }, + { + name: "🔴returns error when horizon client request results in error", + mockHorizonClientFn: func(mHorizonClient *horizonclient.MockClient) { + mHorizonClient.On("AccountDetail", horizonclient.AccountRequest{ + AccountID: distAcc.Address, + }).Return(horizon.Account{}, fmt.Errorf("foobar")).Once() + }, + expectedError: errors.New("getting details for account from Horizon: foobar"), + }, + { + name: "🔴returns error when attempting to parse invalid balance into float", + mockHorizonClientFn: func(mHorizonClient *horizonclient.MockClient) { + mHorizonClient.On("AccountDetail", horizonclient.AccountRequest{ + AccountID: distAcc.Address, + }).Return(horizon.Account{ + Balances: []horizon.Balance{ + { + Asset: base.Asset{Code: nativeAsset.Code, Type: "native"}, + Balance: "invalid_balance", + }, + }, + }, nil).Once() + }, + expectedError: errors.New("parsing balance to float"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mHorizonClient := horizonclient.MockClient{} + svc := StellarDistributionAccountService{ + horizonClient: &mHorizonClient, + } + + tc.mockHorizonClientFn(&mHorizonClient) + balances, err := svc.GetBalances(ctx, &distAcc) + if tc.expectedError != nil { + require.ErrorContains(t, err, tc.expectedError.Error()) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expectedBalances, balances) + } + + mHorizonClient.AssertExpectations(t) + }) + } +} + +func Test_StellarDistributionAccountService_GetBalance(t *testing.T) { + ctx := context.Background() + accAddress := keypair.MustRandom().Address() + distAcc := schema.NewStellarEnvTransactionAccount(accAddress) + + nativeAsset := data.Asset{Code: assets.XLMAssetCode} + usdcAsset := assets.USDCAssetTestnet + eurcAsset := assets.EURCAssetTestnet + + mockSetup := func(mHorizonClient *horizonclient.MockClient) { + mHorizonClient.On("AccountDetail", horizonclient.AccountRequest{ + AccountID: distAcc.Address, + }).Return(horizon.Account{ + Balances: []horizon.Balance{ + { + Asset: base.Asset{Code: usdcAsset.Code, Issuer: usdcAsset.Issuer}, + Balance: "100.0000000", + }, + { + Asset: base.Asset{Code: nativeAsset.Code, Type: "native"}, + Balance: "120.0000000", + }, + }, + }, nil).Once() + } + + testCases := []struct { + name string + asset data.Asset + expectedBalance float64 + expectedError error + }{ + { + name: "🟢successfully gets balance for asset with issuer", + asset: usdcAsset, + expectedBalance: 100.0, + }, + { + name: "🟢successfully gets balance for native asset", + asset: nativeAsset, + expectedBalance: 120.0, + }, + { + name: "🔴returns error if asset is not found on account", + asset: eurcAsset, + expectedError: fmt.Errorf("balance for asset %s not found for distribution account", eurcAsset), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mHorizonClient := horizonclient.MockClient{} + svc := StellarDistributionAccountService{ + horizonClient: &mHorizonClient, + } + + mockSetup(&mHorizonClient) + balance, err := svc.GetBalance(ctx, &distAcc, tc.asset) + if tc.expectedError != nil { + require.ErrorContains(t, err, tc.expectedError.Error()) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expectedBalance, balance) + } + + mHorizonClient.AssertExpectations(t) + }) + } +} + +func Test_CircleDistributionAccountService_GetBalances(t *testing.T) { + ctx := context.Background() + circleDistAcc := schema.TransactionAccount{ + CircleWalletID: "circle-wallet-id", + Type: schema.DistributionAccountCircleDBVault, + Status: schema.AccountStatusActive, + } + + testCases := []struct { + name string + networkType utils.NetworkType + account schema.TransactionAccount + prepareMocksFn func(mCircleService *circle.MockService) + expectedBalances map[data.Asset]float64 + expectedError error + }{ + { + name: "🔴returns an error if the account is not a Circle type", + networkType: utils.TestnetNetworkType, + account: schema.NewDefaultHostAccount("gost-account-address"), + expectedError: errors.New("distribution account is not a Circle account"), + }, + { + name: "🔴returns an error if the circle account is not ACTIVE", + networkType: utils.TestnetNetworkType, + account: schema.TransactionAccount{ + CircleWalletID: "circle-wallet-id", + Type: schema.DistributionAccountCircleDBVault, + Status: schema.AccountStatusPendingUserActivation, + }, + expectedError: fmt.Errorf("This organization's distribution account is in %s state, please complete the %s activation process to access this endpoint.", schema.AccountStatusPendingUserActivation, schema.CirclePlatform), + }, + { + name: "🔴wrap error comming from GetWalletByID", + networkType: utils.TestnetNetworkType, + account: circleDistAcc, + prepareMocksFn: func(mCircleService *circle.MockService) { + mCircleService. + On("GetWalletByID", ctx, circleDistAcc.CircleWalletID). + Return(nil, errors.New("foobar")). + Once() + }, + expectedError: errors.New("getting wallet by ID: foobar"), + }, + { + name: "🟢[Testnet]successfully gets balances, ignoring the unsupported ones", + networkType: utils.TestnetNetworkType, + account: circleDistAcc, + prepareMocksFn: func(mCircleService *circle.MockService) { + mCircleService. + On("GetWalletByID", ctx, circleDistAcc.CircleWalletID). + Return(&circle.Wallet{ + WalletID: circleDistAcc.CircleWalletID, + Balances: []circle.Balance{ + {Currency: "USD", Amount: "100.0"}, + {Currency: "EUR", Amount: "200.0"}, + {Currency: "UNSUPPORTED_ASSET", Amount: "300.0"}, + }, + }, nil). + Once() + }, + expectedBalances: map[data.Asset]float64{ + assets.USDCAssetTestnet: 100.0, + assets.EURCAssetTestnet: 200.0, + }, + }, + { + name: "🟢[Pubnet]successfully gets balances, ignoring the unsupported ones", + networkType: utils.PubnetNetworkType, + account: circleDistAcc, + prepareMocksFn: func(mCircleService *circle.MockService) { + mCircleService. + On("GetWalletByID", ctx, circleDistAcc.CircleWalletID). + Return(&circle.Wallet{ + WalletID: circleDistAcc.CircleWalletID, + Balances: []circle.Balance{ + {Currency: "USD", Amount: "100.0"}, + {Currency: "EUR", Amount: "200.0"}, + {Currency: "UNSUPPORTED_ASSET", Amount: "300.0"}, + }, + }, nil). + Once() + }, + expectedBalances: map[data.Asset]float64{ + assets.USDCAssetPubnet: 100.0, + assets.EURCAssetPubnet: 200.0, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + svc := CircleDistributionAccountService{ + NetworkType: tc.networkType, + } + + if tc.prepareMocksFn != nil { + mCircleService := circle.NewMockService(t) + svc.CircleService = mCircleService + tc.prepareMocksFn(mCircleService) + } + + balances, err := svc.GetBalances(ctx, &tc.account) + if tc.expectedError != nil { + require.ErrorContains(t, err, tc.expectedError.Error()) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expectedBalances, balances) + } + }) + } +} + +func Test_CircleDistributionAccountService_GetBalance(t *testing.T) { + ctx := context.Background() + circleDistAcc := schema.TransactionAccount{ + CircleWalletID: "circle-wallet-id", + Type: schema.DistributionAccountCircleDBVault, + Status: schema.AccountStatusActive, + } + unsupportedAsset := data.Asset{Code: "FOO", Issuer: "GCANIBF4EHC5ZKKMSPX2WFGJ4ZO7BI4JFHZHBUQC5FH3JOOLKG7F5DL3"} + mockGetWalletByIDFn := func(mCircleService *circle.MockService) { + mCircleService. + On("GetWalletByID", ctx, circleDistAcc.CircleWalletID). + Return(&circle.Wallet{ + WalletID: circleDistAcc.CircleWalletID, + Balances: []circle.Balance{ + {Currency: "USD", Amount: "100.0"}, + {Currency: "EUR", Amount: "200.0"}, + }, + }, nil). + Once() + } + + testCases := []struct { + name string + networkType utils.NetworkType + account schema.TransactionAccount + asset data.Asset + prepareMocksFn func(mCircleService *circle.MockService) + expectedBalance float64 + expectedError error + }{ + { + name: "🔴wrap error from GetBalances", + networkType: utils.TestnetNetworkType, + account: schema.NewDefaultHostAccount("gost-account-address"), + asset: assets.USDCAssetTestnet, + expectedError: errors.New("distribution account is not a Circle account"), + }, + { + name: "🔴returns an error if the desired asset could not be found", + networkType: utils.TestnetNetworkType, + account: circleDistAcc, + asset: unsupportedAsset, + prepareMocksFn: mockGetWalletByIDFn, + expectedError: fmt.Errorf("balance for asset %v not found for distribution account", unsupportedAsset), + }, + { + name: "🟢[Testnet]successfully gets balance for supported asset USDC", + networkType: utils.TestnetNetworkType, + account: circleDistAcc, + asset: assets.USDCAssetTestnet, + prepareMocksFn: mockGetWalletByIDFn, + expectedBalance: 100.0, + }, + { + name: "🟢[Pubnet]successfully gets balance for supported asset EURC", + networkType: utils.PubnetNetworkType, + account: circleDistAcc, + asset: assets.EURCAssetPubnet, + prepareMocksFn: mockGetWalletByIDFn, + expectedBalance: 200.0, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + svc := CircleDistributionAccountService{ + NetworkType: tc.networkType, + } + + if tc.prepareMocksFn != nil { + mCircleService := circle.NewMockService(t) + svc.CircleService = mCircleService + tc.prepareMocksFn(mCircleService) + } + + // Create some noise by injecting extra fields in the asset boject, to check if the service is (correctly) ignoring them. + now := time.Now() + assetWithExtraFields := data.Asset{ + Code: tc.asset.Code, + Issuer: tc.asset.Issuer, + ID: "asset-id", + CreatedAt: &now, + UpdatedAt: &now, + DeletedAt: &now, + } + + balance, err := svc.GetBalance(ctx, &tc.account, assetWithExtraFields) + if tc.expectedError != nil { + require.ErrorContains(t, err, tc.expectedError.Error()) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expectedBalance, balance) + } + }) + } +} + +func Test_NewDistributionAccountService(t *testing.T) { + mHorizonClient := &horizonclient.MockClient{} + mCircleService := &circle.MockService{} + svcOpts := DistributionAccountServiceOptions{ + HorizonClient: mHorizonClient, + NetworkType: utils.TestnetNetworkType, + CircleService: mCircleService, + } + svc, err := NewDistributionAccountService(svcOpts) + require.NoError(t, err) + + stellarDistributionAccSvc := &StellarDistributionAccountService{ + horizonClient: mHorizonClient, + } + circleDistributionAccSvc := &CircleDistributionAccountService{ + CircleService: mCircleService, + NetworkType: utils.TestnetNetworkType, + } + + testCases := []struct { + accountType schema.AccountType + expectedSvc DistributionAccountServiceInterface + }{ + { + accountType: schema.DistributionAccountStellarEnv, + expectedSvc: stellarDistributionAccSvc, + }, + { + accountType: schema.DistributionAccountStellarDBVault, + expectedSvc: stellarDistributionAccSvc, + }, + { + accountType: schema.DistributionAccountCircleDBVault, + expectedSvc: circleDistributionAccSvc, + }, + } + + for _, tc := range testCases { + t.Run(string(tc.accountType), func(t *testing.T) { + actualSvc, ok := svc.strategies[tc.accountType] + assert.True(t, ok) + assert.Equal(t, tc.expectedSvc, actualSvc) + }) + } +} diff --git a/internal/services/mocks/circle_reconciliation_service.go b/internal/services/mocks/circle_reconciliation_service.go new file mode 100644 index 000000000..573e44fe8 --- /dev/null +++ b/internal/services/mocks/circle_reconciliation_service.go @@ -0,0 +1,46 @@ +// Code generated by mockery v2.43.2. DO NOT EDIT. + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockCircleReconciliationService is an autogenerated mock type for the CircleReconciliationServiceInterface type +type MockCircleReconciliationService struct { + mock.Mock +} + +// Reconcile provides a mock function with given fields: ctx +func (_m *MockCircleReconciliationService) Reconcile(ctx context.Context) error { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Reconcile") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(ctx) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewMockCircleReconciliationService creates a new instance of MockCircleReconciliationService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockCircleReconciliationService(t interface { + mock.TestingT + Cleanup(func()) +}) *MockCircleReconciliationService { + mock := &MockCircleReconciliationService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/services/mocks/distribution_account_service.go b/internal/services/mocks/distribution_account_service.go new file mode 100644 index 000000000..42f0c2d7b --- /dev/null +++ b/internal/services/mocks/distribution_account_service.go @@ -0,0 +1,89 @@ +// Code generated by mockery v2.40.1. DO NOT EDIT. + +package mocks + +import ( + context "context" + + data "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + mock "github.com/stretchr/testify/mock" + + schema "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" +) + +// MockDistributionAccountService is an autogenerated mock type for the DistributionAccountServiceInterface type +type MockDistributionAccountService struct { + mock.Mock +} + +// GetBalance provides a mock function with given fields: _a0, account, asset +func (_m *MockDistributionAccountService) GetBalance(_a0 context.Context, account *schema.TransactionAccount, asset data.Asset) (float64, error) { + ret := _m.Called(_a0, account, asset) + + if len(ret) == 0 { + panic("no return value specified for GetBalance") + } + + var r0 float64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *schema.TransactionAccount, data.Asset) (float64, error)); ok { + return rf(_a0, account, asset) + } + if rf, ok := ret.Get(0).(func(context.Context, *schema.TransactionAccount, data.Asset) float64); ok { + r0 = rf(_a0, account, asset) + } else { + r0 = ret.Get(0).(float64) + } + + if rf, ok := ret.Get(1).(func(context.Context, *schema.TransactionAccount, data.Asset) error); ok { + r1 = rf(_a0, account, asset) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetBalances provides a mock function with given fields: _a0, account +func (_m *MockDistributionAccountService) GetBalances(_a0 context.Context, account *schema.TransactionAccount) (map[data.Asset]float64, error) { + ret := _m.Called(_a0, account) + + if len(ret) == 0 { + panic("no return value specified for GetBalances") + } + + var r0 map[data.Asset]float64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *schema.TransactionAccount) (map[data.Asset]float64, error)); ok { + return rf(_a0, account) + } + if rf, ok := ret.Get(0).(func(context.Context, *schema.TransactionAccount) map[data.Asset]float64); ok { + r0 = rf(_a0, account) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[data.Asset]float64) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *schema.TransactionAccount) error); ok { + r1 = rf(_a0, account) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewMockDistributionAccountService creates a new instance of MockDistributionAccountService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockDistributionAccountService(t interface { + mock.TestingT + Cleanup(func()) +}) *MockDistributionAccountService { + mock := &MockDistributionAccountService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/services/mocks/patch_anchor_platform_transactions_completion.go b/internal/services/mocks/patch_anchor_platform_transactions_completion.go index a4baa96f2..88b4077af 100644 --- a/internal/services/mocks/patch_anchor_platform_transactions_completion.go +++ b/internal/services/mocks/patch_anchor_platform_transactions_completion.go @@ -3,17 +3,15 @@ package mocks import ( "context" - "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" - "github.com/stellar/stellar-disbursement-platform-backend/internal/services" "github.com/stretchr/testify/mock" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" ) type MockPatchAnchorPlatformTransactionCompletionService struct { mock.Mock } -var _ services.PatchAnchorPlatformTransactionCompletionServiceInterface = new(MockPatchAnchorPlatformTransactionCompletionService) - func (s *MockPatchAnchorPlatformTransactionCompletionService) PatchAPTransactionsForPayments(ctx context.Context) error { args := s.Called(ctx) return args.Error(0) diff --git a/internal/services/mocks/payment_from_submitter_service.go b/internal/services/mocks/payment_from_submitter_service.go index 149437901..c9ec24cbc 100644 --- a/internal/services/mocks/payment_from_submitter_service.go +++ b/internal/services/mocks/payment_from_submitter_service.go @@ -3,17 +3,15 @@ package mocks import ( "context" - "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" - "github.com/stellar/stellar-disbursement-platform-backend/internal/services" "github.com/stretchr/testify/mock" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" ) type MockPaymentFromSubmitterService struct { mock.Mock } -var _ services.PaymentFromSubmitterServiceInterface = new(MockPaymentFromSubmitterService) - func (s *MockPaymentFromSubmitterService) SyncTransaction(ctx context.Context, tx *schemas.EventPaymentCompletedData) error { args := s.Called(ctx, tx) return args.Error(0) diff --git a/internal/services/mocks/payment_to_submitter_service.go b/internal/services/mocks/payment_to_submitter_service.go index 853cadcf1..6edf60f4b 100644 --- a/internal/services/mocks/payment_to_submitter_service.go +++ b/internal/services/mocks/payment_to_submitter_service.go @@ -3,9 +3,9 @@ package mocks import ( "context" - "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" - "github.com/stellar/stellar-disbursement-platform-backend/internal/services" "github.com/stretchr/testify/mock" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" ) // MockPaymentToSubmitterService mocks PaymentToSubmitterService. @@ -22,6 +22,3 @@ func (m *MockPaymentToSubmitterService) SendPaymentsReadyToPay(ctx context.Conte args := m.Called(ctx, paymentsReadyToPay) return args.Error(0) } - -// Making sure that ServerService implements ServerServiceInterface: -var _ services.PaymentToSubmitterServiceInterface = (*MockPaymentToSubmitterService)(nil) diff --git a/internal/services/mocks/send_receiver_wallets_invite_service.go b/internal/services/mocks/send_receiver_wallets_invite_service.go index 8c589d6d4..252c7ce3a 100644 --- a/internal/services/mocks/send_receiver_wallets_invite_service.go +++ b/internal/services/mocks/send_receiver_wallets_invite_service.go @@ -3,17 +3,15 @@ package mocks import ( "context" - "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" - "github.com/stellar/stellar-disbursement-platform-backend/internal/services" "github.com/stretchr/testify/mock" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" ) type MockSendReceiverWalletInviteService struct { mock.Mock } -var _ services.SendReceiverWalletInviteServiceInterface = new(MockSendReceiverWalletInviteService) - func (s *MockSendReceiverWalletInviteService) SendInvite(ctx context.Context, receiverWalletsReq ...schemas.EventReceiverWalletSMSInvitationData) error { args := s.Called(ctx, receiverWalletsReq) return args.Error(0) diff --git a/internal/services/payment_management_service_test.go b/internal/services/payment_management_service_test.go index e3bc08bbb..dce483945 100644 --- a/internal/services/payment_management_service_test.go +++ b/internal/services/payment_management_service_test.go @@ -4,11 +4,12 @@ import ( "context" "testing" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" - "github.com/stretchr/testify/require" ) func Test_PaymentManagementService_CancelPayment(t *testing.T) { diff --git a/internal/services/payment_to_submitter_service.go b/internal/services/payment_to_submitter_service.go index e927b582a..d97bc9708 100644 --- a/internal/services/payment_to_submitter_service.go +++ b/internal/services/payment_to_submitter_service.go @@ -3,18 +3,19 @@ package services import ( "context" "fmt" - "strconv" "strings" - "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" - "github.com/stellar/go/support/log" "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/paymentdispatchers" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" txSubStore "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) type PaymentToSubmitterServiceInterface interface { @@ -27,14 +28,24 @@ var _ PaymentToSubmitterServiceInterface = (*PaymentToSubmitterService)(nil) // PaymentToSubmitterService is a service that pushes SDP's ready-to-pay payments to the transaction submission service. type PaymentToSubmitterService struct { - sdpModels *data.Models - tssModel *txSubStore.TransactionModel + sdpModels *data.Models + tssModel *txSubStore.TransactionModel + distAccountResolver signing.DistributionAccountResolver + circleService circle.ServiceInterface + paymentDispatcher paymentdispatchers.PaymentDispatcherInterface } -func NewPaymentToSubmitterService(models *data.Models, tssDBConnectionPool db.DBConnectionPool) *PaymentToSubmitterService { +type PaymentToSubmitterServiceOptions struct { + Models *data.Models + DistAccountResolver signing.DistributionAccountResolver + PaymentDispatcher paymentdispatchers.PaymentDispatcherInterface +} + +func NewPaymentToSubmitterService(opts PaymentToSubmitterServiceOptions) *PaymentToSubmitterService { return &PaymentToSubmitterService{ - sdpModels: models, - tssModel: txSubStore.NewTransactionModel(tssDBConnectionPool), + sdpModels: opts.Models, + distAccountResolver: opts.DistAccountResolver, + paymentDispatcher: opts.PaymentDispatcher, } } @@ -46,7 +57,7 @@ func (s PaymentToSubmitterService) SendPaymentsReadyToPay(ctx context.Context, p } err := s.sendPaymentsReadyToPay(ctx, paymentsReadyToPay.TenantID, func(sdpDBTx db.DBTransaction) ([]*data.Payment, error) { - log.Ctx(ctx).Infof("Registering %d payments into the TSS, paymentIDs=%v", len(paymentIDs), paymentIDs) + log.Ctx(ctx).Infof("Registering %d payments to %s Dispatcher, paymentIDs=%v", len(paymentIDs), s.paymentDispatcher.SupportedPlatform(), paymentIDs) payments, innerErr := s.sdpModels.Payment.GetReadyByID(ctx, sdpDBTx, paymentIDs...) if len(payments) != len(paymentIDs) { @@ -93,85 +104,39 @@ func (s PaymentToSubmitterService) sendPaymentsReadyToPay( getPaymentsFn func(sdpDBTx db.DBTransaction) ([]*data.Payment, error), ) error { outerErr := db.RunInTransaction(ctx, s.sdpModels.DBConnectionPool, nil, func(sdpDBTx db.DBTransaction) error { - return db.RunInTransaction(ctx, s.tssModel.DBConnectionPool, nil, func(tssDBTx db.DBTransaction) error { - payments, err := getPaymentsFn(sdpDBTx) - if err != nil { - return fmt.Errorf("getting payments ready to be sent: %w", err) - } + payments, err := getPaymentsFn(sdpDBTx) + if err != nil { + return fmt.Errorf("getting payments ready to be sent: %w", err) + } - var transactions []txSubStore.Transaction - var failedPayments []*data.Payment - var pendingPayments []*data.Payment - - for _, payment := range payments { - // 1. For each payment, validate it is ready to be sent - err = validatePaymentReadyForSending(payment) - if err != nil { - // if payment is not ready for sending, we will mark it as failed later. - failedPayments = append(failedPayments, payment) - log.Ctx(ctx).Errorf("Payment %s is not ready for sending. Error=%v", payment.ID, err) - continue - } - - // TODO: change TSS to use string amount [SDP-483] - var amount float64 - amount, err = strconv.ParseFloat(payment.Amount, 64) - if err != nil { - return fmt.Errorf("parsing payment amount %s for payment ID %s: %w", payment.Amount, payment.ID, err) - } - transaction := txSubStore.Transaction{ - ExternalID: payment.ID, - AssetCode: payment.Asset.Code, - AssetIssuer: payment.Asset.Issuer, - Amount: amount, - Destination: payment.ReceiverWallet.StellarAddress, - TenantID: tenantID, - } - transactions = append(transactions, transaction) - pendingPayments = append(pendingPayments, payment) - } + var failedPayments []*data.Payment + var pendingPayments []*data.Payment - // 3. Persist data in Transactions table - insertedTransactions, err := s.tssModel.BulkInsert(ctx, tssDBTx, transactions) + // 1. For each payment, validate it is ready to be sent + for _, payment := range payments { + err = validatePaymentReadyForSending(payment) if err != nil { - return fmt.Errorf("inserting transactions: %w", err) - } - if len(insertedTransactions) > 0 { - insertedTxIDs := make([]string, 0, len(insertedTransactions)) - for _, insertedTransaction := range insertedTransactions { - insertedTxIDs = append(insertedTxIDs, insertedTransaction.ID) - } - log.Ctx(ctx).Infof("Submitted %d transaction(s) to TSS=%+v", len(insertedTransactions), insertedTxIDs) + // if payment is not ready for sending, we will mark it as failed later. + failedPayments = append(failedPayments, payment) + log.Ctx(ctx).Errorf("Payment %s is not ready for sending. Error=%v", payment.ID, err) + continue } - // 4. Update payment statuses to `Pending` - if len(pendingPayments) > 0 { - numUpdated, err := s.sdpModels.Payment.UpdateStatuses(ctx, sdpDBTx, pendingPayments, data.PendingPaymentStatus) - if err != nil { - return fmt.Errorf("updating payment statuses to Pending: %w", err) - } - updatedPaymentIDs := make([]string, 0, len(pendingPayments)) - for _, pendingPayment := range pendingPayments { - updatedPaymentIDs = append(updatedPaymentIDs, pendingPayment.ID) - } - log.Ctx(ctx).Infof("Updated %d payments to Pending=%+v", numUpdated, updatedPaymentIDs) - } + pendingPayments = append(pendingPayments, payment) + } - // 5. Update failed payments statuses to `Failed` - if len(failedPayments) != 0 { - numUpdated, err := s.sdpModels.Payment.UpdateStatuses(ctx, sdpDBTx, failedPayments, data.FailedPaymentStatus) - if err != nil { - return fmt.Errorf("updating payment statuses to Failed: %w", err) - } - failedPaymentIDs := make([]string, 0, len(failedPayments)) - for _, failedPayment := range failedPayments { - failedPaymentIDs = append(failedPaymentIDs, failedPayment.ID) - } - log.Ctx(ctx).Warnf("Updated %d payments to Failed=%+v", numUpdated, failedPaymentIDs) - } + // 2. Update failed payments statuses to `Failed`. These payments won't even be attempted. + if err = s.markPaymentsAsFailed(ctx, sdpDBTx, failedPayments); err != nil { + return fmt.Errorf("marking payments as failed: %w", err) + } + + // 3. Submit Payments to proper platform (TSS or Circle) + err = s.paymentDispatcher.DispatchPayments(ctx, sdpDBTx, tenantID, pendingPayments) + if err != nil { + return fmt.Errorf("sending payments to target platform: %w", err) + } - return nil - }) + return nil }) if outerErr != nil { return fmt.Errorf("sending payments ready-to-pay inside syncronized database transactions: %w", outerErr) @@ -180,6 +145,25 @@ func (s PaymentToSubmitterService) sendPaymentsReadyToPay( return nil } +func (s PaymentToSubmitterService) markPaymentsAsFailed(ctx context.Context, sdpDBTx db.DBTransaction, failedPayments []*data.Payment) error { + if len(failedPayments) == 0 { + return nil + } + + numUpdated, err := s.sdpModels.Payment.UpdateStatuses(ctx, sdpDBTx, failedPayments, data.FailedPaymentStatus) + if err != nil { + return fmt.Errorf("updating payment statuses to Failed: %w", err) + } + + failedPaymentIDs := make([]string, 0, len(failedPayments)) + for _, failedPayment := range failedPayments { + failedPaymentIDs = append(failedPaymentIDs, failedPayment.ID) + } + log.Ctx(ctx).Warnf("Updated %d payments to Failed=%+v", numUpdated, failedPaymentIDs) + + return nil +} + // validatePaymentReadyForSending validates that a payment is ready for sending, by: // 1. checking the statuses of Payment, Receiver Wallet, and Disbursement. // 2. checking that the required fields are not empty. diff --git a/internal/services/payment_to_submitter_service_test.go b/internal/services/payment_to_submitter_service_test.go index ed660a15f..8fa2fc734 100644 --- a/internal/services/payment_to_submitter_service_test.go +++ b/internal/services/payment_to_submitter_service_test.go @@ -2,385 +2,289 @@ package services import ( "context" + "fmt" "strconv" "testing" + "time" - "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" - + "github.com/google/uuid" + "github.com/stellar/go/keypair" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/paymentdispatchers" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" txSubStore "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) -func Test_PaymentToSubmitterService_SendBatchPayments(t *testing.T) { +func Test_PaymentToSubmitterService_SendPaymentsMethods(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) defer dbConnectionPool.Close() - models, err := data.NewModels(dbConnectionPool) - require.NoError(t, err) - tssModel := txSubStore.NewTransactionModel(models.DBConnectionPool) - - service := NewPaymentToSubmitterService(models, dbConnectionPool) - ctx := context.Background() - - // create fixtures - wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, - "My Wallet", - "https://www.wallet.com", - "www.wallet.com", - "wallet1://") - asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, - "USDC", - "GDUCE34WW5Z34GMCEPURYANUCUP47J6NORJLKC6GJNMDLN4ZI4PMI2MG") - country := data.CreateCountryFixture(t, ctx, dbConnectionPool, - "FRA", - "France") - - // create disbursements - startedDisbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "ready disbursement", - Status: data.StartedDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, - }) - - // create disbursement receivers - receiver1 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) - receiver2 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) - receiver3 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) - receiver4 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) - - rw1 := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet.ID, data.RegisteredReceiversWalletStatus) - rw2 := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver2.ID, wallet.ID, data.RegisteredReceiversWalletStatus) - rw3 := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver3.ID, wallet.ID, data.RegisteredReceiversWalletStatus) - rwReady := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver4.ID, wallet.ID, data.ReadyReceiversWalletStatus) - - payment1 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rw1, - Disbursement: startedDisbursement, - Asset: *asset, - Amount: "100", - Status: data.ReadyPaymentStatus, - }) - payment2 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rw2, - Disbursement: startedDisbursement, - Asset: *asset, - Amount: "200", - Status: data.ReadyPaymentStatus, - }) - payment3 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rw3, - Disbursement: startedDisbursement, - Asset: *asset, - Amount: "300", - Status: data.ReadyPaymentStatus, - }) - payment4 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rwReady, - Disbursement: startedDisbursement, - Asset: *asset, - Amount: "400", - Status: data.ReadyPaymentStatus, - }) - - batchSize := 4 - // add tenant to context testTenant := tenant.Tenant{ID: "tenant-id", Name: "Test Name"} - ctx = tenant.SaveTenantInContext(ctx, &testTenant) - - t.Run("send payments", func(t *testing.T) { - err = service.SendBatchPayments(ctx, batchSize) - require.NoError(t, err) - - // payments that can be sent - var payment *data.Payment - for _, p := range []*data.Payment{payment1, payment2, payment3} { - payment, err = models.Payment.Get(ctx, p.ID, dbConnectionPool) - require.NoError(t, err) - require.Equal(t, data.PendingPaymentStatus, payment.Status) - } - - // payments that can't be sent (rw status is not REGISTERED) - payment, err = models.Payment.Get(ctx, payment4.ID, dbConnectionPool) - require.NoError(t, err) - require.Equal(t, data.ReadyPaymentStatus, payment.Status) - - // validate transactions - var transactions []*txSubStore.Transaction - transactions, err = tssModel.GetAllByPaymentIDs(ctx, []string{payment1.ID, payment2.ID, payment3.ID, payment4.ID}) - require.NoError(t, err) - require.Len(t, transactions, 3) - - expectedPayments := map[string]*data.Payment{ - payment1.ID: payment1, - payment2.ID: payment2, - payment3.ID: payment3, - } - - for _, tx := range transactions { - assert.Equal(t, txSubStore.TransactionStatusPending, tx.Status) - assert.Equal(t, expectedPayments[tx.ExternalID].Asset.Code, tx.AssetCode) - assert.Equal(t, expectedPayments[tx.ExternalID].Asset.Issuer, tx.AssetIssuer) - assert.Equal(t, expectedPayments[tx.ExternalID].Amount, strconv.FormatFloat(tx.Amount, 'f', 7, 32)) - assert.Equal(t, expectedPayments[tx.ExternalID].ReceiverWallet.StellarAddress, tx.Destination) - assert.Equal(t, expectedPayments[tx.ExternalID].ID, tx.ExternalID) - assert.Equal(t, testTenant.ID, tx.TenantID) - } - }) - - t.Run("send payments with native asset", func(t *testing.T) { - nativeAsset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "XLM", "") + ctx := tenant.SaveTenantInContext(context.Background(), &testTenant) - startedDisbursementNA := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "started disbursement Native Asset", - Status: data.StartedDisbursementStatus, - Asset: nativeAsset, - Wallet: wallet, - Country: country, - }) - - paymentNA1 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rw1, - Disbursement: startedDisbursementNA, - Asset: *nativeAsset, - Amount: "100", - Status: data.ReadyPaymentStatus, - }) - - paymentNA2 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rw2, - Disbursement: startedDisbursementNA, - Asset: *nativeAsset, - Amount: "100", - Status: data.ReadyPaymentStatus, - }) - - err = service.SendBatchPayments(ctx, batchSize) - require.NoError(t, err) - - for _, p := range []*data.Payment{paymentNA1, paymentNA2} { - payment, err := models.Payment.Get(ctx, p.ID, dbConnectionPool) - require.NoError(t, err) - assert.Equal(t, data.PendingPaymentStatus, payment.Status) - } - - transactions, err := tssModel.GetAllByPaymentIDs(ctx, []string{paymentNA1.ID, paymentNA2.ID}) - require.NoError(t, err) - require.Len(t, transactions, 2) - - expectedPayments := map[string]*data.Payment{ - paymentNA1.ID: paymentNA1, - paymentNA2.ID: paymentNA2, - } - - for _, tx := range transactions { - assert.Equal(t, txSubStore.TransactionStatusPending, tx.Status) - assert.Equal(t, expectedPayments[tx.ExternalID].Asset.Code, tx.AssetCode) - assert.Empty(t, tx.AssetIssuer) - assert.Equal(t, expectedPayments[tx.ExternalID].Amount, strconv.FormatFloat(tx.Amount, 'f', 7, 32)) - assert.Equal(t, expectedPayments[tx.ExternalID].ReceiverWallet.StellarAddress, tx.Destination) - assert.Equal(t, expectedPayments[tx.ExternalID].ID, tx.ExternalID) - assert.Equal(t, testTenant.ID, tx.TenantID) - } - }) -} - -func Test_PaymentToSubmitterService_SendPaymentsReadyToPay(t *testing.T) { - dbt := dbtest.Open(t) - defer dbt.Close() - - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) - require.NoError(t, err) - defer dbConnectionPool.Close() + eurcAsset := data.CreateAssetFixture(t, ctx, dbConnectionPool, assets.EURCAssetCode, assets.EURCAssetTestnet.Issuer) + nativeAsset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "XLM", "") + country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") + wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "My Wallet", "https://www.wallet.com", "www.wallet.com", "wallet1://") models, err := data.NewModels(dbConnectionPool) require.NoError(t, err) tssModel := txSubStore.NewTransactionModel(models.DBConnectionPool) - service := NewPaymentToSubmitterService(models, dbConnectionPool) - ctx := context.Background() - - // create fixtures - wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, - "My Wallet", - "https://www.wallet.com", - "www.wallet.com", - "wallet1://") - asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, - "USDC", - "GDUCE34WW5Z34GMCEPURYANUCUP47J6NORJLKC6GJNMDLN4ZI4PMI2MG") - country := data.CreateCountryFixture(t, ctx, dbConnectionPool, - "FRA", - "France") - - // create disbursements - startedDisbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "ready disbursement", - Status: data.StartedDisbursementStatus, - Asset: asset, - Wallet: wallet, - Country: country, - }) - - // create disbursement receivers - receiver1 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) - receiver2 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) - receiver3 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) - receiver4 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + // Create distribution accounts + distributionAccPubKey := "GAAHIL6ZW4QFNLCKALZ3YOIWPP4TXQ7B7J5IU7RLNVGQAV6GFDZHLDTA" + stellarDistAccountEnv := schema.NewStellarEnvTransactionAccount(distributionAccPubKey) + stellarDistAccountDBVault := schema.NewDefaultStellarTransactionAccount(distributionAccPubKey) + circleDistAccountDBVault := schema.TransactionAccount{ + CircleWalletID: "circle-wallet-id", + Type: schema.DistributionAccountCircleDBVault, + Status: schema.AccountStatusActive, + } - rw1 := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, wallet.ID, data.RegisteredReceiversWalletStatus) - rw2 := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver2.ID, wallet.ID, data.RegisteredReceiversWalletStatus) - rw3 := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver3.ID, wallet.ID, data.RegisteredReceiversWalletStatus) - rwReady := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver4.ID, wallet.ID, data.ReadyReceiversWalletStatus) + type methodOption string + const ( + SendPaymentsReadyToPay methodOption = "SendPaymentsReadyToPay" + SendBatchPayments methodOption = "SendBatchPayments" + ) - payment1 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rw1, - Disbursement: startedDisbursement, - Asset: *asset, - Amount: "100", - Status: data.ReadyPaymentStatus, - }) - payment2 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rw2, - Disbursement: startedDisbursement, - Asset: *asset, - Amount: "200", - Status: data.ReadyPaymentStatus, - }) - payment3 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rw3, - Disbursement: startedDisbursement, - Asset: *asset, - Amount: "300", - Status: data.ReadyPaymentStatus, - }) - payment4 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rwReady, - Disbursement: startedDisbursement, - Asset: *asset, - Amount: "400", - Status: data.ReadyPaymentStatus, - }) + testCases := []struct { + distributionAccount schema.TransactionAccount + asset *data.Asset + methodOption methodOption + }{ + { + distributionAccount: stellarDistAccountEnv, + asset: eurcAsset, + methodOption: SendBatchPayments, + }, + { + distributionAccount: stellarDistAccountEnv, + asset: nativeAsset, + methodOption: SendBatchPayments, + }, + { + distributionAccount: stellarDistAccountDBVault, + asset: eurcAsset, + methodOption: SendBatchPayments, + }, + { + distributionAccount: stellarDistAccountDBVault, + asset: nativeAsset, + methodOption: SendBatchPayments, + }, + { + distributionAccount: circleDistAccountDBVault, + asset: eurcAsset, + methodOption: SendBatchPayments, + }, + { + distributionAccount: stellarDistAccountEnv, + asset: eurcAsset, + methodOption: SendPaymentsReadyToPay, + }, + { + distributionAccount: stellarDistAccountEnv, + asset: nativeAsset, + methodOption: SendPaymentsReadyToPay, + }, + { + distributionAccount: stellarDistAccountDBVault, + asset: eurcAsset, + methodOption: SendPaymentsReadyToPay, + }, + { + distributionAccount: stellarDistAccountDBVault, + asset: nativeAsset, + methodOption: SendPaymentsReadyToPay, + }, + { + distributionAccount: circleDistAccountDBVault, + asset: eurcAsset, + methodOption: SendPaymentsReadyToPay, + }, + } - t.Run("send payments", func(t *testing.T) { - tenantID := "tenant-id" - paymentsReadyToPay := schemas.EventPaymentsReadyToPayData{TenantID: tenantID} - for _, p := range []*data.Payment{payment1, payment2, payment3, payment4} { - paymentsReadyToPay.Payments = append(paymentsReadyToPay.Payments, schemas.PaymentReadyToPay{ID: p.ID}) - } + for _, tc := range testCases { + t.Run(fmt.Sprintf("%s:[%s]%s", tc.methodOption, tc.distributionAccount.Type, tc.asset.Code), func(t *testing.T) { + // database cleanup + defer data.DeleteAllDisbursementFixtures(t, ctx, dbConnectionPool) + defer data.DeleteAllReceiversFixtures(t, ctx, dbConnectionPool) + defer data.DeleteAllReceiverVerificationFixtures(t, ctx, dbConnectionPool) + defer data.DeleteAllReceiverWalletsFixtures(t, ctx, dbConnectionPool) + defer data.DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) + + startedDisbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Name: "ready disbursement", + Status: data.StartedDisbursementStatus, + Asset: tc.asset, + Wallet: wallet, + Country: country, + }) + + receiverReady := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + rwReady := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverReady.ID, wallet.ID, data.ReadyReceiversWalletStatus) + paymentReady := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwReady, + Disbursement: startedDisbursement, + Asset: *tc.asset, + Amount: "100", + Status: data.ReadyPaymentStatus, + }) + + receiverRegistered := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + rwRegistered := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverRegistered.ID, wallet.ID, data.RegisteredReceiversWalletStatus) + paymentRegistered := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwRegistered, + Disbursement: startedDisbursement, + Asset: *tc.asset, + Amount: "100", + Status: data.ReadyPaymentStatus, + }) + + // Universal mocks: + mDistAccResolver := mocks.NewMockDistributionAccountResolver(t) + mDistAccResolver. + On("DistributionAccountFromContext", ctx). + Return(tc.distributionAccount, nil). + Once() + mCircleService := circle.NewMockService(t) + if tc.distributionAccount.IsCircle() { + wantPaymentReques := circle.PaymentRequest{ + SourceWalletID: tc.distributionAccount.CircleWalletID, + DestinationStellarAddress: rwRegistered.StellarAddress, + Amount: paymentRegistered.Amount, + StellarAssetCode: paymentRegistered.Asset.Code, + } + var circleAssetCode string + circleAssetCode, err = wantPaymentReques.GetCircleAssetCode() + require.NoError(t, err) + createDate := time.Now() + + mCircleService. + On("SendPayment", ctx, mock.Anything). + Run(func(args mock.Arguments) { + gotPayment, ok := args.Get(1).(circle.PaymentRequest) + require.True(t, ok) + + // Validate payment + assert.Equal(t, wantPaymentReques.SourceWalletID, gotPayment.SourceWalletID) + assert.Equal(t, wantPaymentReques.DestinationStellarAddress, gotPayment.DestinationStellarAddress) + assert.Equal(t, wantPaymentReques.Amount, gotPayment.Amount) + assert.Equal(t, wantPaymentReques.StellarAssetCode, gotPayment.StellarAssetCode) + assert.NoError(t, uuid.Validate(gotPayment.IdempotencyKey), "Idempotency key should be a valid UUID") + wantPaymentReques.IdempotencyKey = gotPayment.IdempotencyKey + }). + Return(&circle.Transfer{ + ID: "62955621-2cf7-4b1f-9f8b-34294ae52938", + Source: circle.TransferAccount{ + ID: tc.distributionAccount.CircleWalletID, + Type: circle.TransferAccountTypeWallet, + }, + Destination: circle.TransferAccount{ + Address: rwRegistered.StellarAddress, + Type: circle.TransferAccountTypeBlockchain, + Chain: circle.StellarChainCode, + }, + Amount: circle.Balance{ + Amount: paymentRegistered.Amount, + Currency: circleAssetCode, + }, + TransactionHash: "f7397c3b61f224401952219061fd3b1ac8c7c7d7e472d14926da7fc35fa9246e", + Status: circle.TransferStatusPending, + CreateDate: createDate, + }, nil). + Once() + } - err = service.SendPaymentsReadyToPay(ctx, paymentsReadyToPay) - require.NoError(t, err) + var paymentDispatcher paymentdispatchers.PaymentDispatcherInterface + if tc.distributionAccount.IsStellar() { + paymentDispatcher = paymentdispatchers.NewStellarPaymentDispatcher(models, tssModel, mDistAccResolver) + } else if tc.distributionAccount.IsCircle() { + paymentDispatcher = paymentdispatchers.NewCirclePaymentDispatcher(models, mCircleService, mDistAccResolver) + } else { + t.Fatalf("unknown distribution account type: %s", tc.distributionAccount.Type) + } - // payments that can be sent - var payment *data.Payment - for _, p := range []*data.Payment{payment1, payment2, payment3} { - payment, err = models.Payment.Get(ctx, p.ID, dbConnectionPool) + // 🚧 Send Payments to the right platform, through the specified method + svc := PaymentToSubmitterService{ + sdpModels: models, + tssModel: tssModel, + distAccountResolver: mDistAccResolver, + circleService: mCircleService, + paymentDispatcher: paymentDispatcher, + } + // Different method, depending on the tc.methodOption value + switch tc.methodOption { + case SendBatchPayments: + err = svc.SendBatchPayments(ctx, 2) + case SendPaymentsReadyToPay: + paymentsReadyToPay := schemas.EventPaymentsReadyToPayData{TenantID: testTenant.ID} + for _, p := range []*data.Payment{paymentReady, paymentRegistered} { + paymentsReadyToPay.Payments = append(paymentsReadyToPay.Payments, schemas.PaymentReadyToPay{ID: p.ID}) + } + err = svc.SendPaymentsReadyToPay(ctx, paymentsReadyToPay) + default: + t.Fatalf("unknown method option: %s", tc.methodOption) + } require.NoError(t, err) - require.Equal(t, data.PendingPaymentStatus, payment.Status) - } - - // payments that can't be sent (rw status is not REGISTERED) - payment, err = models.Payment.Get(ctx, payment4.ID, dbConnectionPool) - require.NoError(t, err) - require.Equal(t, data.ReadyPaymentStatus, payment.Status) - // validate transactions - var transactions []*txSubStore.Transaction - transactions, err = tssModel.GetAllByPaymentIDs(ctx, []string{payment1.ID, payment2.ID, payment3.ID, payment4.ID}) - require.NoError(t, err) - require.Len(t, transactions, 3) - - expectedPayments := map[string]*data.Payment{ - payment1.ID: payment1, - payment2.ID: payment2, - payment3.ID: payment3, - } - - for _, tx := range transactions { - assert.Equal(t, txSubStore.TransactionStatusPending, tx.Status) - assert.Equal(t, expectedPayments[tx.ExternalID].Asset.Code, tx.AssetCode) - assert.Equal(t, expectedPayments[tx.ExternalID].Asset.Issuer, tx.AssetIssuer) - assert.Equal(t, expectedPayments[tx.ExternalID].Amount, strconv.FormatFloat(tx.Amount, 'f', 7, 32)) - assert.Equal(t, expectedPayments[tx.ExternalID].ReceiverWallet.StellarAddress, tx.Destination) - assert.Equal(t, expectedPayments[tx.ExternalID].ID, tx.ExternalID) - assert.Equal(t, tenantID, tx.TenantID) - } - }) - - t.Run("send payments with native asset", func(t *testing.T) { - nativeAsset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "XLM", "") + // 👀 Validate: paymentRegistered (should be sent) + paymentRegistered, err = models.Payment.Get(ctx, paymentRegistered.ID, dbConnectionPool) + require.NoError(t, err) + assert.Equal(t, data.PendingPaymentStatus, paymentRegistered.Status) - startedDisbursementNA := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ - Name: "started disbursement Native Asset", - Status: data.StartedDisbursementStatus, - Asset: nativeAsset, - Wallet: wallet, - Country: country, - }) + // 👀 Validate: paymentReady (should not be sent) + paymentReady, err = models.Payment.Get(ctx, paymentReady.ID, dbConnectionPool) + require.NoError(t, err) + require.Equal(t, data.ReadyPaymentStatus, paymentReady.Status) + + // 👀 [STELLAR] Validate: TSS submitter_transactions table + if tc.distributionAccount.IsStellar() { + transactions, err := tssModel.GetAllByPaymentIDs(ctx, []string{paymentRegistered.ID, paymentReady.ID}) + require.NoError(t, err) + require.Len(t, transactions, 1) + + expectedPayments := map[string]*data.Payment{ + paymentRegistered.ID: paymentRegistered, + } + for _, tx := range transactions { + assert.Equal(t, txSubStore.TransactionStatusPending, tx.Status) + assert.Equal(t, expectedPayments[tx.ExternalID].Asset.Code, tx.AssetCode) + assert.Equal(t, expectedPayments[tx.ExternalID].Asset.Issuer, tx.AssetIssuer) + assert.Equal(t, expectedPayments[tx.ExternalID].Amount, strconv.FormatFloat(tx.Amount, 'f', 7, 32)) + assert.Equal(t, expectedPayments[tx.ExternalID].ReceiverWallet.StellarAddress, tx.Destination) + assert.Equal(t, expectedPayments[tx.ExternalID].ID, tx.ExternalID) + assert.Equal(t, testTenant.ID, tx.TenantID) + } + } - paymentNA1 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rw1, - Disbursement: startedDisbursementNA, - Asset: *nativeAsset, - Amount: "100", - Status: data.ReadyPaymentStatus, - }) + // 👀 [CIRCLE] Validate: CircleTransferRequests + if tc.distributionAccount.IsCircle() { + circleTransferRequest, err := models.CircleTransferRequests.GetIncompleteByPaymentID(ctx, dbConnectionPool, paymentRegistered.ID) + require.NoError(t, err) - paymentNA2 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ - ReceiverWallet: rw2, - Disbursement: startedDisbursementNA, - Asset: *nativeAsset, - Amount: "100", - Status: data.ReadyPaymentStatus, + assert.Equal(t, paymentRegistered.ID, circleTransferRequest.PaymentID) + assert.Equal(t, data.CircleTransferStatusPending, *circleTransferRequest.Status) + assert.Equal(t, "62955621-2cf7-4b1f-9f8b-34294ae52938", *circleTransferRequest.CircleTransferID) + assert.Equal(t, tc.distributionAccount.CircleWalletID, *circleTransferRequest.SourceWalletID) + } }) - - tenantID := "tenant-id" - paymentsReadyToPay := schemas.EventPaymentsReadyToPayData{TenantID: tenantID} - for _, p := range []*data.Payment{paymentNA1, paymentNA2} { - paymentsReadyToPay.Payments = append(paymentsReadyToPay.Payments, schemas.PaymentReadyToPay{ID: p.ID}) - } - - err = service.SendPaymentsReadyToPay(ctx, paymentsReadyToPay) - require.NoError(t, err) - - for _, p := range []*data.Payment{paymentNA1, paymentNA2} { - payment, err := models.Payment.Get(ctx, p.ID, dbConnectionPool) - require.NoError(t, err) - assert.Equal(t, data.PendingPaymentStatus, payment.Status) - } - - transactions, err := tssModel.GetAllByPaymentIDs(ctx, []string{paymentNA1.ID, paymentNA2.ID}) - require.NoError(t, err) - require.Len(t, transactions, 2) - - expectedPayments := map[string]*data.Payment{ - paymentNA1.ID: paymentNA1, - paymentNA2.ID: paymentNA2, - } - - for _, tx := range transactions { - assert.Equal(t, txSubStore.TransactionStatusPending, tx.Status) - assert.Equal(t, expectedPayments[tx.ExternalID].Asset.Code, tx.AssetCode) - assert.Empty(t, tx.AssetIssuer) - assert.Equal(t, expectedPayments[tx.ExternalID].Amount, strconv.FormatFloat(tx.Amount, 'f', 7, 32)) - assert.Equal(t, expectedPayments[tx.ExternalID].ReceiverWallet.StellarAddress, tx.Destination) - assert.Equal(t, expectedPayments[tx.ExternalID].ID, tx.ExternalID) - assert.Equal(t, tenantID, tx.TenantID) - } - }) + } } func Test_PaymentToSubmitterService_ValidatePaymentReadyForSending(t *testing.T) { @@ -553,7 +457,20 @@ func Test_PaymentToSubmitterService_RetryPayment(t *testing.T) { require.NoError(t, err) tssModel := txSubStore.NewTransactionModel(models.DBConnectionPool) - service := NewPaymentToSubmitterService(models, dbConnectionPool) + distAccPubKey := keypair.MustRandom().Address() + distAccount := schema.NewDefaultStellarTransactionAccount(distAccPubKey) + mDistAccountResolver := mocks.NewMockDistributionAccountResolver(t) + mDistAccountResolver. + On("DistributionAccountFromContext", ctx). + Return(distAccount, nil). + Maybe() + + paymentDispatcher := paymentdispatchers.NewStellarPaymentDispatcher(models, tssModel, mDistAccountResolver) + service := NewPaymentToSubmitterService(PaymentToSubmitterServiceOptions{ + Models: models, + DistAccountResolver: mDistAccountResolver, + PaymentDispatcher: paymentDispatcher, + }) // clean test db data.DeleteAllPaymentsFixtures(t, ctx, dbConnectionPool) @@ -661,3 +578,79 @@ func Test_PaymentToSubmitterService_RetryPayment(t *testing.T) { assert.Equal(t, txSubStore.TransactionStatusPending, transaction2.Status) assert.Equal(t, tenantID, transaction2.TenantID) } + +func Test_PaymentToSubmitterService_markPaymentsAsFailed(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + + // Create fixtures + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") + country := data.CreateCountryFixture(t, ctx, dbConnectionPool, "FRA", "France") + wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "wallet1", "https://www.wallet.com", "www.wallet.com", "wallet1://") + disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{ + Country: country, + Wallet: wallet, + Status: data.ReadyDisbursementStatus, + Asset: asset, + }) + receiverReady := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + rwReady := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverReady.ID, wallet.ID, data.ReadyReceiversWalletStatus) + payment1 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursement, + Asset: *asset, + Amount: "100", + Status: data.PendingPaymentStatus, + }) + payment2 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursement, + Asset: *asset, + Amount: "100", + Status: data.PendingPaymentStatus, + }) + + svc := PaymentToSubmitterService{sdpModels: models} + + t.Run("return nil if the list of payments is empty", func(t *testing.T) { + dbTx, err := dbConnectionPool.BeginTxx(ctx, nil) + require.NoError(t, err) + defer func() { + err = dbTx.Rollback() + require.NoError(t, err) + }() + + innerErr := svc.markPaymentsAsFailed(ctx, dbTx, nil) + require.NoError(t, innerErr) + + innerErr = svc.markPaymentsAsFailed(ctx, dbTx, []*data.Payment{}) + require.NoError(t, innerErr) + }) + + t.Run("🎉 successfully mark payments as failed", func(t *testing.T) { + dbTx, err := dbConnectionPool.BeginTxx(ctx, nil) + require.NoError(t, err) + defer func() { + err = dbTx.Rollback() + require.NoError(t, err) + }() + + innerErr := svc.markPaymentsAsFailed(ctx, dbTx, []*data.Payment{payment1, payment2}) + require.NoError(t, innerErr) + + payment1, err = models.Payment.Get(ctx, payment1.ID, dbTx) + require.NoError(t, err) + assert.Equal(t, data.FailedPaymentStatus, payment1.Status) + + payment2, err = models.Payment.Get(ctx, payment2.ID, dbTx) + require.NoError(t, err) + assert.Equal(t, data.FailedPaymentStatus, payment2.Status) + }) +} diff --git a/internal/services/paymentdispatchers/circle_payment_dispatcher.go b/internal/services/paymentdispatchers/circle_payment_dispatcher.go new file mode 100644 index 000000000..d1385b874 --- /dev/null +++ b/internal/services/paymentdispatchers/circle_payment_dispatcher.go @@ -0,0 +1,147 @@ +package paymentdispatchers + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/stellar/go/support/log" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" +) + +type CirclePaymentDispatcher struct { + sdpModels *data.Models + circleService circle.ServiceInterface + distAccountResolver signing.DistributionAccountResolver +} + +func NewCirclePaymentDispatcher(sdpModels *data.Models, circleService circle.ServiceInterface, distAccountResolver signing.DistributionAccountResolver) *CirclePaymentDispatcher { + return &CirclePaymentDispatcher{ + sdpModels: sdpModels, + circleService: circleService, + distAccountResolver: distAccountResolver, + } +} + +func (c *CirclePaymentDispatcher) DispatchPayments(ctx context.Context, sdpDBTx db.DBTransaction, tenantID string, paymentsToDispatch []*data.Payment) error { + if len(paymentsToDispatch) == 0 { + return nil + } + + distAccount, err := c.distAccountResolver.DistributionAccountFromContext(ctx) + if err != nil { + return fmt.Errorf("getting distribution account: %w", err) + } + + if !distAccount.Type.IsCircle() { + return fmt.Errorf("distribution account is not a Circle account for tenant %s", tenantID) + } + + circleWalletID := distAccount.CircleWalletID + return c.sendPaymentsToCircle(ctx, sdpDBTx, circleWalletID, paymentsToDispatch) +} + +func (c *CirclePaymentDispatcher) SupportedPlatform() schema.Platform { + return schema.CirclePlatform +} + +var _ PaymentDispatcherInterface = (*CirclePaymentDispatcher)(nil) + +func (c *CirclePaymentDispatcher) sendPaymentsToCircle(ctx context.Context, sdpDBTx db.DBTransaction, circleWalletID string, paymentsToSubmit []*data.Payment) error { + for _, payment := range paymentsToSubmit { + // 1. Create a new circle transfer request + transferRequest, err := c.sdpModels.CircleTransferRequests.GetOrInsert(ctx, payment.ID) + if err != nil { + return fmt.Errorf("inserting circle transfer request: %w", err) + } + + // 2. Submit the payment to Circle + transfer, err := c.circleService.SendPayment(ctx, circle.PaymentRequest{ + SourceWalletID: circleWalletID, + DestinationStellarAddress: payment.ReceiverWallet.StellarAddress, + Amount: payment.Amount, + StellarAssetCode: payment.Asset.Code, + IdempotencyKey: transferRequest.IdempotencyKey, + }) + + if err != nil { + // 3. If the transfer fails, set the payment status to failed + log.Ctx(ctx).Errorf("Failed to submit payment %s to Circle: %v", payment.ID, err) + err = c.sdpModels.Payment.UpdateStatus(ctx, sdpDBTx, payment.ID, data.FailedPaymentStatus, utils.StringPtr(err.Error()), "") + if err != nil { + return fmt.Errorf("marking payment as failed: %w", err) + } + } else { + // 4. Update the circle transfer request with the response from Circle + if err = c.updateCircleTransferRequest(ctx, sdpDBTx, circleWalletID, transfer, transferRequest); err != nil { + return fmt.Errorf("updating circle transfer request: %w", err) + } + + // 5. Update the payment status based on the transfer status + if err = c.updatePaymentStatusForCircleTransfer(ctx, sdpDBTx, transfer, payment); err != nil { + return fmt.Errorf("updating payment status for Circle transfer: %w", err) + } + } + } + return nil +} + +// updateCircleTransferRequest updates the circle_transfer_request table with the response from Circle. +func (c *CirclePaymentDispatcher) updateCircleTransferRequest( + ctx context.Context, + sdpDBTx db.DBTransaction, + circleWalletID string, + transfer *circle.Transfer, + transferRequest *data.CircleTransferRequest, +) error { + if transfer == nil { + return fmt.Errorf("transfer cannot be nil") + } + + jsonBody, err := json.Marshal(transfer) + if err != nil { + return fmt.Errorf("converting transfer body to json: %w", err) + } + + var completedAt *time.Time + circleStatus := data.CircleTransferStatus(transfer.Status) + if circleStatus.IsCompleted() { + completedAt = utils.TimePtr(time.Now()) + } + + _, err = c.sdpModels.CircleTransferRequests.Update(ctx, sdpDBTx, transferRequest.IdempotencyKey, data.CircleTransferRequestUpdate{ + CircleTransferID: transfer.ID, + Status: circleStatus, + ResponseBody: jsonBody, + SourceWalletID: circleWalletID, + CompletedAt: completedAt, + }) + if err != nil { + return fmt.Errorf("updating circle transfer request: %w", err) + } + + return nil +} + +// updatePaymentStatusForCircleTransfer updates the payment status based on the transfer status. +func (c *CirclePaymentDispatcher) updatePaymentStatusForCircleTransfer(ctx context.Context, sdpDBTx db.DBTransaction, transfer *circle.Transfer, payment *data.Payment) error { + paymentStatus, err := transfer.Status.ToPaymentStatus() + if err != nil { + return fmt.Errorf("converting CIRCLE transfer status to SDP Payment status: %w", err) + } + + statusMsg := fmt.Sprintf("Transfer %s is %s in Circle", transfer.ID, transfer.Status) + err = c.sdpModels.Payment.UpdateStatus(ctx, sdpDBTx, payment.ID, paymentStatus, &statusMsg, transfer.TransactionHash) + if err != nil { + return fmt.Errorf("marking payment as %s: %w", paymentStatus, err) + } + + return nil +} diff --git a/internal/services/paymentdispatchers/circle_payment_dispatcher_test.go b/internal/services/paymentdispatchers/circle_payment_dispatcher_test.go new file mode 100644 index 000000000..80cb467d7 --- /dev/null +++ b/internal/services/paymentdispatchers/circle_payment_dispatcher_test.go @@ -0,0 +1,204 @@ +package paymentdispatchers + +import ( + "context" + "fmt" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" +) + +func Test_CirclePaymentDispatcher_DispatchPayments(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + + circleWalletID := "22322112" + circleTransferID := uuid.NewString() + + tenantID := "tenant-id" + + // Disbursement + disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{}) + + // Receivers + receiver1 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + + // Receiver Wallets + rw1Registered := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, disbursement.Wallet.ID, data.RegisteredReceiversWalletStatus) + + // Payments + payment1 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rw1Registered, + Disbursement: disbursement, + Asset: *disbursement.Asset, + Amount: "100", + Status: data.ReadyPaymentStatus, + }) + + tests := []struct { + name string + paymentsToDispatch []*data.Payment + wantErr error + fnSetup func(*testing.T, *circle.MockService) + fnAsserts func(*testing.T, db.SQLExecuter) + }{ + { + name: "failure validating payment ready for sending", + paymentsToDispatch: []*data.Payment{ + {ID: "123"}, + }, + wantErr: fmt.Errorf("payment with ID 123 does not exist"), + }, + { + name: "payment marked as failed when posting circle transfer fails", + paymentsToDispatch: []*data.Payment{payment1}, + wantErr: nil, + fnSetup: func(t *testing.T, m *circle.MockService) { + transferRequest, setupErr := models.CircleTransferRequests.Insert(ctx, payment1.ID) + require.NoError(t, setupErr) + + m.On("SendPayment", ctx, circle.PaymentRequest{ + SourceWalletID: circleWalletID, + DestinationStellarAddress: payment1.ReceiverWallet.StellarAddress, + Amount: payment1.Amount, + StellarAssetCode: payment1.Asset.Code, + IdempotencyKey: transferRequest.IdempotencyKey, + }). + Return(nil, fmt.Errorf("error posting transfer to Circle")). + Once() + }, + fnAsserts: func(t *testing.T, sqlExecuter db.SQLExecuter) { + // Payment should be marked as failed + payment, assertErr := models.Payment.Get(ctx, payment1.ID, sqlExecuter) + require.NoError(t, assertErr) + assert.Equal(t, data.FailedPaymentStatus, payment.Status) + }, + }, + { + name: "error updating circle transfer request", + paymentsToDispatch: []*data.Payment{payment1}, + wantErr: fmt.Errorf("updating circle transfer request: transfer cannot be nil"), + fnSetup: func(t *testing.T, m *circle.MockService) { + m.On("SendPayment", ctx, mock.AnythingOfType("circle.PaymentRequest")). + Return(nil, nil). + Once() + }, + }, + { + name: "error updating payment status for completed request", + paymentsToDispatch: []*data.Payment{payment1}, + wantErr: fmt.Errorf("invalid input value for enum circle_transfer_status"), + fnSetup: func(t *testing.T, m *circle.MockService) { + m.On("SendPayment", ctx, mock.AnythingOfType("circle.PaymentRequest")). + Return(&circle.Transfer{ + ID: "transfer_id", + Status: "wrong-status", + }, nil). + Once() + }, + }, + { + name: "success posting transfer to Circle", + paymentsToDispatch: []*data.Payment{payment1}, + wantErr: nil, + fnSetup: func(t *testing.T, m *circle.MockService) { + m.On("SendPayment", ctx, mock.AnythingOfType("circle.PaymentRequest")). + Return(&circle.Transfer{ + ID: circleTransferID, + Status: circle.TransferStatusPending, + Amount: circle.Balance{ + Amount: payment1.Amount, + Currency: "USD", + }, + }, nil). + Once() + }, + fnAsserts: func(t *testing.T, sqlExecuter db.SQLExecuter) { + // Payment should be marked as pending + payment, assertErr := models.Payment.Get(ctx, payment1.ID, sqlExecuter) + require.NoError(t, assertErr) + assert.Equal(t, data.PendingPaymentStatus, payment.Status) + + // Transfer request is still not updated for the main connection pool + var transferRequest data.CircleTransferRequest + assertErr = dbConnectionPool.GetContext(ctx, &transferRequest, "SELECT * FROM circle_transfer_requests WHERE payment_id = $1", payment1.ID) + require.NoError(t, assertErr) + assert.Nil(t, transferRequest.CircleTransferID) + assert.Nil(t, transferRequest.SourceWalletID) + + // Transfer request is updated for the transaction + assertErr = sqlExecuter.GetContext(ctx, &transferRequest, "SELECT * FROM circle_transfer_requests WHERE payment_id = $1", payment1.ID) + require.NoError(t, assertErr) + assert.Equal(t, circleTransferID, *transferRequest.CircleTransferID) + assert.Equal(t, circleWalletID, *transferRequest.SourceWalletID) + assert.Equal(t, data.CircleTransferStatusPending, *transferRequest.Status) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dbtx, runErr := dbConnectionPool.BeginTxx(ctx, nil) + require.NoError(t, runErr) + + // Teardown + defer func() { + err = dbtx.Rollback() + require.NoError(t, err) + + _, err = dbConnectionPool.ExecContext(ctx, "DELETE FROM circle_transfer_requests") + require.NoError(t, err) + }() + + mCircleService := circle.NewMockService(t) + + mDistAccountResolver := &mocks.MockDistributionAccountResolver{} + mDistAccountResolver. + On("DistributionAccountFromContext", ctx). + Return(schema.TransactionAccount{ + Type: schema.DistributionAccountCircleDBVault, + CircleWalletID: circleWalletID, + Status: schema.AccountStatusActive, + }, nil).Maybe() + + dispatcher := NewCirclePaymentDispatcher(models, mCircleService, mDistAccountResolver) + + if tt.fnSetup != nil { + tt.fnSetup(t, mCircleService) + } + runErr = dispatcher.DispatchPayments(ctx, dbtx, tenantID, tt.paymentsToDispatch) + if tt.wantErr != nil { + assert.ErrorContains(t, runErr, tt.wantErr.Error()) + } else { + assert.NoError(t, runErr) + } + + if tt.fnAsserts != nil { + tt.fnAsserts(t, dbtx) + } + }) + } +} + +func Test_CirclePaymentDispatcher_SupportedPlatform(t *testing.T) { + dispatcher := CirclePaymentDispatcher{} + assert.Equal(t, schema.CirclePlatform, dispatcher.SupportedPlatform()) +} diff --git a/internal/services/paymentdispatchers/payment_dispatcher.go b/internal/services/paymentdispatchers/payment_dispatcher.go new file mode 100644 index 000000000..c6c96a78c --- /dev/null +++ b/internal/services/paymentdispatchers/payment_dispatcher.go @@ -0,0 +1,14 @@ +package paymentdispatchers + +import ( + "context" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" +) + +type PaymentDispatcherInterface interface { + DispatchPayments(ctx context.Context, sdpDBTx db.DBTransaction, tenantID string, paymentsToDispatch []*data.Payment) error + SupportedPlatform() schema.Platform +} diff --git a/internal/services/paymentdispatchers/stellar_payment_dispatcher.go b/internal/services/paymentdispatchers/stellar_payment_dispatcher.go new file mode 100644 index 000000000..1ea6fcc9e --- /dev/null +++ b/internal/services/paymentdispatchers/stellar_payment_dispatcher.go @@ -0,0 +1,101 @@ +package paymentdispatchers + +import ( + "context" + "fmt" + "strconv" + + "github.com/stellar/go/support/log" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" + txSubStore "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" +) + +type StellarPaymentDispatcher struct { + sdpModels *data.Models + tssModel *txSubStore.TransactionModel + distAccountResolver signing.DistributionAccountResolver +} + +func NewStellarPaymentDispatcher(sdpModels *data.Models, tssModel *txSubStore.TransactionModel, distAccountResolver signing.DistributionAccountResolver) *StellarPaymentDispatcher { + return &StellarPaymentDispatcher{ + sdpModels: sdpModels, + tssModel: tssModel, + distAccountResolver: distAccountResolver, + } +} + +func (s *StellarPaymentDispatcher) DispatchPayments(ctx context.Context, sdpDBTx db.DBTransaction, tenantID string, paymentsToDispatch []*data.Payment) error { + if len(paymentsToDispatch) == 0 { + return nil + } + + distAccount, err := s.distAccountResolver.DistributionAccountFromContext(ctx) + if err != nil { + return fmt.Errorf("getting distribution account: %w", err) + } + + if !distAccount.Type.IsStellar() { + return fmt.Errorf("distribution account is not a Stellar account for tenant %s", tenantID) + } + + return db.RunInTransaction(ctx, s.tssModel.DBConnectionPool, nil, func(tssDBTx db.DBTransaction) error { + return s.sendPaymentsToTSS(ctx, sdpDBTx, tssDBTx, tenantID, paymentsToDispatch) + }) +} + +func (s *StellarPaymentDispatcher) SupportedPlatform() schema.Platform { + return schema.StellarPlatform +} + +var _ PaymentDispatcherInterface = (*StellarPaymentDispatcher)(nil) + +func (s *StellarPaymentDispatcher) sendPaymentsToTSS(ctx context.Context, sdpDBTx, tssDBTx db.DBTransaction, tenantID string, pendingPayments []*data.Payment) error { + var transactions []txSubStore.Transaction + for _, payment := range pendingPayments { + // TODO: change TSS to use string amount [SDP-483] + amount, err := strconv.ParseFloat(payment.Amount, 64) + if err != nil { + return fmt.Errorf("parsing payment amount %s for payment ID %s: %w", payment.Amount, payment.ID, err) + } + + transaction := txSubStore.Transaction{ + ExternalID: payment.ID, + AssetCode: payment.Asset.Code, + AssetIssuer: payment.Asset.Issuer, + Amount: amount, + Destination: payment.ReceiverWallet.StellarAddress, + TenantID: tenantID, + } + transactions = append(transactions, transaction) + } + + insertedTransactions, err := s.tssModel.BulkInsert(ctx, tssDBTx, transactions) + if err != nil { + return fmt.Errorf("inserting transactions: %w", err) + } + if len(insertedTransactions) > 0 { + insertedTxIDs := make([]string, 0, len(insertedTransactions)) + for _, insertedTransaction := range insertedTransactions { + insertedTxIDs = append(insertedTxIDs, insertedTransaction.ID) + } + log.Ctx(ctx).Infof("Submitted %d transaction(s) to TSS=%+v", len(insertedTransactions), insertedTxIDs) + } + + // Update payment status to PENDING in the SDP database: + if len(pendingPayments) > 0 { + numUpdated, updateErr := s.sdpModels.Payment.UpdateStatuses(ctx, sdpDBTx, pendingPayments, data.PendingPaymentStatus) + if updateErr != nil { + return fmt.Errorf("updating payment statuses to %s: %w", data.PendingPaymentStatus, updateErr) + } + updatedPaymentIDs := make([]string, 0, len(pendingPayments)) + for _, pendingPayment := range pendingPayments { + updatedPaymentIDs = append(updatedPaymentIDs, pendingPayment.ID) + } + log.Ctx(ctx).Infof("Updated %d payments to Pending=%+v", numUpdated, updatedPaymentIDs) + } + return nil +} diff --git a/internal/services/paymentdispatchers/stellar_payment_dispatcher_test.go b/internal/services/paymentdispatchers/stellar_payment_dispatcher_test.go new file mode 100644 index 000000000..3b0923b15 --- /dev/null +++ b/internal/services/paymentdispatchers/stellar_payment_dispatcher_test.go @@ -0,0 +1,153 @@ +package paymentdispatchers + +import ( + "context" + "fmt" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/testutils" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" + sigMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" + txSubStore "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" +) + +func Test_StellarPaymentDispatcher_DispatchPayments(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + + tssModel := txSubStore.NewTransactionModel(models.DBConnectionPool) + + tenantID := "tenant-id" + + // Disbursement + disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, models.Disbursements, &data.Disbursement{}) + + // Receivers + receiver1 := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + + // Receiver Wallets + rw1Registered := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiver1.ID, disbursement.Wallet.ID, data.RegisteredReceiversWalletStatus) + + // Payments + payment1 := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rw1Registered, + Disbursement: disbursement, + Asset: *disbursement.Asset, + Amount: "100", + Status: data.ReadyPaymentStatus, + }) + + tests := []struct { + name string + paymentsToDispatch []*data.Payment + wantErr error + fnSetup func(*testing.T, *mocks.MockDistributionAccountResolver) + fnAsserts func(*testing.T, db.SQLExecuter) + }{ + { + name: "failure fetching distribution account", + paymentsToDispatch: []*data.Payment{payment1}, + wantErr: fmt.Errorf("getting distribution account: distribution account not found"), + fnSetup: func(t *testing.T, mDistAccountResolver *mocks.MockDistributionAccountResolver) { + mDistAccountResolver.On("DistributionAccountFromContext", ctx). + Return(schema.TransactionAccount{}, fmt.Errorf("distribution account not found")). + Once() + }, + }, + { + name: "distribution account is not a Stellar account", + paymentsToDispatch: []*data.Payment{payment1}, + wantErr: fmt.Errorf("distribution account is not a Stellar account for tenant tenant-id"), + fnSetup: func(t *testing.T, mDistAccountResolver *mocks.MockDistributionAccountResolver) { + mDistAccountResolver.On("DistributionAccountFromContext", ctx). + Return(schema.TransactionAccount{Type: schema.DistributionAccountCircleDBVault}, nil). + Once() + }, + }, + { + name: "unable to parse payment amount", + paymentsToDispatch: []*data.Payment{ + {ID: "123", Amount: "invalid-amount"}, + }, + wantErr: fmt.Errorf("parsing payment amount invalid-amount for payment ID 123: strconv.ParseFloat: parsing \"invalid-amount\": invalid syntax"), + fnSetup: func(t *testing.T, mDistAccountResolver *mocks.MockDistributionAccountResolver) { + mDistAccountResolver.On("DistributionAccountFromContext", ctx). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarEnv}, nil). + Once() + }, + }, + { + name: "success posting transfer to Stellar", + paymentsToDispatch: []*data.Payment{payment1}, + wantErr: nil, + fnSetup: func(t *testing.T, mDistAccountResolver *mocks.MockDistributionAccountResolver) { + mDistAccountResolver.On("DistributionAccountFromContext", ctx). + Return(schema.TransactionAccount{Type: schema.DistributionAccountStellarEnv}, nil). + Once() + }, + fnAsserts: func(t *testing.T, sqlExecuter db.SQLExecuter) { + // Payment should be marked as pending + payment, assertErr := models.Payment.Get(ctx, payment1.ID, sqlExecuter) + require.NoError(t, assertErr) + assert.Equal(t, data.PendingPaymentStatus, payment.Status) + + // Transaction should be created + transactions, assertErr := tssModel.GetAllByPaymentIDs(ctx, []string{payment1.ID}) + require.NoError(t, assertErr) + assert.Len(t, transactions, 1) + + tx := transactions[0] + assert.Equal(t, txSubStore.TransactionStatusPending, tx.Status) + assert.Equal(t, payment1.Asset.Code, tx.AssetCode) + assert.Equal(t, payment1.Asset.Issuer, tx.AssetIssuer) + assert.Equal(t, payment1.Amount, strconv.FormatFloat(tx.Amount, 'f', 7, 32)) + assert.Equal(t, payment1.ReceiverWallet.StellarAddress, tx.Destination) + assert.Equal(t, payment1.ID, tx.ExternalID) + assert.Equal(t, "tenant-id", tx.TenantID) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mDistAccountResolver := sigMocks.NewMockDistributionAccountResolver(t) + dispatcher := NewStellarPaymentDispatcher(models, tssModel, mDistAccountResolver) + tssTx := testutils.BeginTxWithRollback(t, ctx, tssModel.DBConnectionPool) + + if tt.fnSetup != nil { + tt.fnSetup(t, mDistAccountResolver) + } + runErr := dispatcher.DispatchPayments(ctx, tssTx, tenantID, tt.paymentsToDispatch) + if tt.wantErr != nil { + assert.ErrorContains(t, runErr, tt.wantErr.Error()) + } else { + assert.NoError(t, runErr) + } + + if tt.fnAsserts != nil { + tt.fnAsserts(t, tssTx) + } + }) + } +} + +func Test_StellarPaymentDispatcher_SupportedPlatform(t *testing.T) { + dispatcher := StellarPaymentDispatcher{} + assert.Equal(t, schema.StellarPlatform, dispatcher.SupportedPlatform()) +} diff --git a/internal/services/ready_payments_cancelation_service_test.go b/internal/services/ready_payments_cancelation_service_test.go index 8486369ea..b35dcb3c2 100644 --- a/internal/services/ready_payments_cancelation_service_test.go +++ b/internal/services/ready_payments_cancelation_service_test.go @@ -5,10 +5,10 @@ import ( "testing" "time" + "github.com/stellar/go/support/log" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/stellar/go/support/log" "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" diff --git a/internal/services/ready_payments_cancellation_service.go b/internal/services/ready_payments_cancellation_service.go index d0a9216a8..14050ec72 100644 --- a/internal/services/ready_payments_cancellation_service.go +++ b/internal/services/ready_payments_cancellation_service.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" ) diff --git a/internal/services/send_receiver_wallets_invite_service.go b/internal/services/send_receiver_wallets_invite_service.go index a1a931429..85988ad91 100644 --- a/internal/services/send_receiver_wallets_invite_service.go +++ b/internal/services/send_receiver_wallets_invite_service.go @@ -12,6 +12,7 @@ import ( "github.com/stellar/go/strkey" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" diff --git a/internal/services/send_receiver_wallets_invite_service_test.go b/internal/services/send_receiver_wallets_invite_service_test.go index a4a93ff0a..9abbc4eb0 100644 --- a/internal/services/send_receiver_wallets_invite_service_test.go +++ b/internal/services/send_receiver_wallets_invite_service_test.go @@ -8,9 +8,10 @@ import ( "time" "github.com/google/uuid" - "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" - "github.com/stellar/go/support/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" @@ -18,8 +19,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/events/schemas" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) func Test_GetSignedRegistrationLink_SchemelessDeepLink(t *testing.T) { diff --git a/internal/services/setup_assets_for_network_service.go b/internal/services/setup_assets_for_network_service.go index e55df4b40..4a166a1c4 100644 --- a/internal/services/setup_assets_for_network_service.go +++ b/internal/services/setup_assets_for_network_service.go @@ -12,38 +12,46 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" ) type AssetsNetworkMapType map[utils.NetworkType][]data.Asset -var DefaultAssetsNetworkMap = AssetsNetworkMapType{ - utils.PubnetNetworkType: []data.Asset{assets.USDCAssetPubnet, assets.XLMAsset}, - utils.TestnetNetworkType: []data.Asset{assets.USDCAssetTestnet, assets.XLMAsset}, +var StellarAssetsNetworkMap = AssetsNetworkMapType{ + utils.PubnetNetworkType: []data.Asset{assets.EURCAssetPubnet, assets.USDCAssetPubnet, assets.XLMAsset}, + utils.TestnetNetworkType: []data.Asset{assets.EURCAssetTestnet, assets.USDCAssetTestnet, assets.XLMAsset}, +} + +var CircleAssetsNetworkMap = AssetsNetworkMapType{ + utils.PubnetNetworkType: []data.Asset{assets.EURCAssetPubnet, assets.USDCAssetPubnet}, + utils.TestnetNetworkType: []data.Asset{assets.EURCAssetTestnet, assets.USDCAssetTestnet}, +} + +type AssetsNetworkByPlatformMapType map[schema.Platform]AssetsNetworkMapType + +var AssetsNetworkByPlatformMap = AssetsNetworkByPlatformMapType{ + schema.StellarPlatform: StellarAssetsNetworkMap, + schema.CirclePlatform: CircleAssetsNetworkMap, } // SetupAssetsForProperNetwork updates and inserts assets for the given Network Passphrase (`network`). So it avoids the application having // same asset code with multiple issuers. -func SetupAssetsForProperNetwork(ctx context.Context, dbConnectionPool db.DBConnectionPool, network utils.NetworkType, assetsNetworkMap AssetsNetworkMapType) error { +func SetupAssetsForProperNetwork(ctx context.Context, dbConnectionPool db.DBConnectionPool, network utils.NetworkType, distAccPlatform schema.Platform) error { log.Ctx(ctx).Infof("updating/inserting assets for the '%s' network", network) - assets, ok := assetsNetworkMap[network] + assets, ok := AssetsNetworkByPlatformMap[distAccPlatform][network] if !ok { return fmt.Errorf("invalid network provided") } var codes, issuers []string - separator := strings.Repeat("-", 20) - buf := new(strings.Builder) - buf.WriteString("assets' code that will be updated or inserted:\n\n") for _, asset := range assets { codes = append(codes, asset.Code) issuers = append(issuers, asset.Issuer) - - buf.WriteString(fmt.Sprintf("Code: %s\n%s\n\n", asset.Code, separator)) } - log.Ctx(ctx).Info(buf.String()) + log.Ctx(ctx).Infof("Asset codes to be updated/inserted: %v", codes) err := db.RunInTransaction(ctx, dbConnectionPool, nil, func(dbTx db.DBTransaction) error { query := ` WITH assets_to_update_or_insert AS ( @@ -104,12 +112,11 @@ func SetupAssetsForProperNetwork(ctx context.Context, dbConnectionPool db.DBConn return fmt.Errorf("error getting all available assets on database: %w", err) } - buf.Reset() - buf.WriteString(fmt.Sprintf("Registered assets for network %s:\n\n", network)) + buf := new(strings.Builder) + buf.WriteString(fmt.Sprintf("Updated list of assets for network %s:\n\n", network)) for _, asset := range allAssets { - buf.WriteString(fmt.Sprintf("Code: %s\nIssuer: %s\n%s\n\n", asset.Code, asset.Issuer, separator)) + buf.WriteString(fmt.Sprintf("\t * %s - %s\n", asset.Code, asset.Issuer)) } - log.Ctx(ctx).Info(buf.String()) return nil diff --git a/internal/services/setup_assets_for_network_service_test.go b/internal/services/setup_assets_for_network_service_test.go index 280939aae..9fb2c3ea0 100644 --- a/internal/services/setup_assets_for_network_service_test.go +++ b/internal/services/setup_assets_for_network_service_test.go @@ -8,18 +8,20 @@ import ( "github.com/stellar/go/keypair" "github.com/stellar/go/support/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" ) func Test_SetupAssetsForProperNetwork(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) defer dbConnectionPool.Close() @@ -29,38 +31,44 @@ func Test_SetupAssetsForProperNetwork(t *testing.T) { ctx := context.Background() - t.Run("returns error when a invalid network is set", func(t *testing.T) { + t.Run("[Stellar,Invalidnet] returns error when a invalid network is set", func(t *testing.T) { data.DeleteAllAssetFixtures(t, ctx, dbConnectionPool) - err := SetupAssetsForProperNetwork(ctx, dbConnectionPool, "invalid", DefaultAssetsNetworkMap) + err := SetupAssetsForProperNetwork(ctx, dbConnectionPool, "Invalidnet", schema.StellarPlatform) assert.EqualError(t, err, "invalid network provided") }) - t.Run("inserts new assets when it doesn't exist", func(t *testing.T) { + t.Run("[Stellar,Testnet] inserts new assets", func(t *testing.T) { data.DeleteAllAssetFixtures(t, ctx, dbConnectionPool) + allAssets, err := models.Assets.GetAll(ctx) + require.NoError(t, err) + assert.Len(t, allAssets, 0) + buf := new(strings.Builder) log.DefaultLogger.SetLevel(log.InfoLevel) log.DefaultLogger.SetOutput(buf) - err := SetupAssetsForProperNetwork(ctx, dbConnectionPool, utils.TestnetNetworkType, DefaultAssetsNetworkMap) + err = SetupAssetsForProperNetwork(ctx, dbConnectionPool, utils.TestnetNetworkType, schema.StellarPlatform) require.NoError(t, err) - assets, err := models.Assets.GetAll(ctx) + allAssets, err = models.Assets.GetAll(ctx) require.NoError(t, err) - assert.Len(t, assets, 2) - assert.Equal(t, "USDC", assets[0].Code) - assert.Equal(t, "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", assets[0].Issuer) - assert.Equal(t, "XLM", assets[1].Code) - assert.Empty(t, assets[1].Issuer) + assert.Len(t, allAssets, 3) + assert.Equal(t, assets.EURCAssetCode, allAssets[0].Code) + assert.Equal(t, assets.EURCAssetIssuerTestnet, allAssets[0].Issuer) + assert.Equal(t, assets.USDCAssetCode, allAssets[1].Code) + assert.Equal(t, assets.USDCAssetIssuerTestnet, allAssets[1].Issuer) + assert.Equal(t, assets.XLMAssetCode, allAssets[2].Code) + assert.Empty(t, allAssets[2].Issuer) expectedLogs := []string{ "updating/inserting assets for the 'testnet' network", - "Code: USDC", - "Issuer: GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", - "Code: XLM", - "Issuer: ", + fmt.Sprintf("Asset codes to be updated/inserted: %v", []string{assets.EURCAssetCode, assets.USDCAssetCode, assets.XLMAssetCode}), + fmt.Sprintf("* %s - %s", assets.EURCAssetCode, assets.EURCAssetIssuerTestnet), + fmt.Sprintf("* %s - %s", assets.USDCAssetCode, assets.USDCAssetIssuerTestnet), + fmt.Sprintf("* %s - %s", assets.XLMAssetCode, ""), } logs := buf.String() @@ -69,52 +77,43 @@ func Test_SetupAssetsForProperNetwork(t *testing.T) { } }) - t.Run("updates and inserts assets", func(t *testing.T) { + t.Run("[Stellar,Testnet] updates existing asset with wrong issuer and inserts new assets", func(t *testing.T) { data.DeleteAllAssetFixtures(t, ctx, dbConnectionPool) - pubnetEUROCIssuer := keypair.MustRandom().Address() - data.CreateAssetFixture(t, ctx, dbConnectionPool, "EUROC", pubnetEUROCIssuer) - - testnetUSDCIssuer := keypair.MustRandom().Address() - testnetEUROCIssuer := keypair.MustRandom().Address() + // Start with USDC:{randomIssuer} + randomIssuer := keypair.MustRandom().Address() + data.CreateAssetFixture(t, ctx, dbConnectionPool, assets.EURCAssetCode, randomIssuer) - assert.NotEqual(t, testnetEUROCIssuer, pubnetEUROCIssuer) - - assets, err := models.Assets.GetAll(ctx) + allAssets, err := models.Assets.GetAll(ctx) require.NoError(t, err) - assert.Len(t, assets, 1) - assert.Equal(t, "EUROC", assets[0].Code) - assert.Equal(t, pubnetEUROCIssuer, assets[0].Issuer) - - assetsNetworkMap := AssetsNetworkMapType{ - utils.TestnetNetworkType: []data.Asset{ - {Code: "EUROC", Issuer: testnetEUROCIssuer}, - {Code: "USDC", Issuer: testnetUSDCIssuer}, - }, - } + assert.Len(t, allAssets, 1) + assert.Equal(t, assets.EURCAssetCode, allAssets[0].Code) + assert.Equal(t, randomIssuer, allAssets[0].Issuer) buf := new(strings.Builder) log.DefaultLogger.SetLevel(log.InfoLevel) log.DefaultLogger.SetOutput(buf) - err = SetupAssetsForProperNetwork(ctx, dbConnectionPool, utils.TestnetNetworkType, assetsNetworkMap) + err = SetupAssetsForProperNetwork(ctx, dbConnectionPool, utils.TestnetNetworkType, schema.StellarPlatform) require.NoError(t, err) - assets, err = models.Assets.GetAll(ctx) + allAssets, err = models.Assets.GetAll(ctx) require.NoError(t, err) - assert.Len(t, assets, 2) - assert.Equal(t, "EUROC", assets[0].Code) - assert.Equal(t, testnetEUROCIssuer, assets[0].Issuer) - assert.Equal(t, "USDC", assets[1].Code) - assert.Equal(t, testnetUSDCIssuer, assets[1].Issuer) + assert.Len(t, allAssets, 3) + assert.Equal(t, assets.EURCAssetCode, allAssets[0].Code) + assert.Equal(t, assets.EURCAssetIssuerTestnet, allAssets[0].Issuer) // <--- Issuer was updated + assert.Equal(t, assets.USDCAssetCode, allAssets[1].Code) + assert.Equal(t, assets.USDCAssetIssuerTestnet, allAssets[1].Issuer) + assert.Equal(t, assets.XLMAssetCode, allAssets[2].Code) + assert.Empty(t, allAssets[2].Issuer) expectedLogs := []string{ "updating/inserting assets for the 'testnet' network", - "Code: EUROC", - "Code: USDC", - fmt.Sprintf("Issuer: %s", testnetEUROCIssuer), - fmt.Sprintf("Issuer: %s", testnetEUROCIssuer), + fmt.Sprintf("Asset codes to be updated/inserted: %v", []string{assets.EURCAssetCode, assets.USDCAssetCode, assets.XLMAssetCode}), + fmt.Sprintf("* %s - %s", assets.EURCAssetCode, assets.EURCAssetIssuerTestnet), + fmt.Sprintf("* %s - %s", assets.USDCAssetCode, assets.USDCAssetIssuerTestnet), + fmt.Sprintf("* %s - %s", assets.XLMAssetCode, ""), } logs := buf.String() @@ -123,60 +122,124 @@ func Test_SetupAssetsForProperNetwork(t *testing.T) { } }) - t.Run("doesn't change the asset when it's not in the assetsNetworkMap", func(t *testing.T) { + t.Run("[Stellar,Pubnet] doesn't change the asset when it's not in the StellarAssetsNetworkMap", func(t *testing.T) { data.DeleteAllAssetFixtures(t, ctx, dbConnectionPool) - testnetEUROCIssuer := keypair.MustRandom().Address() - data.CreateAssetFixture(t, ctx, dbConnectionPool, "EUROC", testnetEUROCIssuer) - + // ARST should not get updated since it's not in the StellarAssetsNetworkMap pubnetARSTIssuer := keypair.MustRandom().Address() - data.CreateAssetFixture(t, ctx, dbConnectionPool, "ARST", pubnetARSTIssuer) + arstAssetCode := "ARST" + data.CreateAssetFixture(t, ctx, dbConnectionPool, arstAssetCode, pubnetARSTIssuer) + + allAssets, err := models.Assets.GetAll(ctx) + require.NoError(t, err) + assert.Len(t, allAssets, 1) + assert.Equal(t, arstAssetCode, allAssets[0].Code) + assert.Equal(t, pubnetARSTIssuer, allAssets[0].Issuer) + + buf := new(strings.Builder) + log.DefaultLogger.SetLevel(log.InfoLevel) + log.DefaultLogger.SetOutput(buf) + + err = SetupAssetsForProperNetwork(ctx, dbConnectionPool, utils.PubnetNetworkType, schema.StellarPlatform) + require.NoError(t, err) + + allAssets, err = models.Assets.GetAll(ctx) + require.NoError(t, err) + assert.Len(t, allAssets, 4) + assert.Equal(t, arstAssetCode, allAssets[0].Code) + assert.Equal(t, pubnetARSTIssuer, allAssets[0].Issuer) + assert.Equal(t, assets.EURCAssetCode, allAssets[1].Code) + assert.Equal(t, assets.EURCAssetIssuerPubnet, allAssets[1].Issuer) + assert.Equal(t, assets.USDCAssetCode, allAssets[2].Code) + assert.Equal(t, assets.USDCAssetIssuerPubnet, allAssets[2].Issuer) + assert.Equal(t, assets.XLMAssetCode, allAssets[3].Code) + assert.Empty(t, allAssets[3].Issuer) + + expectedLogs := []string{ + "updating/inserting assets for the 'pubnet' network", + fmt.Sprintf("Asset codes to be updated/inserted: %v", []string{assets.EURCAssetCode, assets.USDCAssetCode, assets.XLMAssetCode}), + fmt.Sprintf("* %s - %s", arstAssetCode, pubnetARSTIssuer), + fmt.Sprintf("* %s - %s", assets.EURCAssetCode, assets.EURCAssetIssuerPubnet), + fmt.Sprintf("* %s - %s", assets.USDCAssetCode, assets.USDCAssetIssuerPubnet), + fmt.Sprintf("* %s - %s", assets.XLMAssetCode, ""), + } + + logs := buf.String() + for _, expectedLog := range expectedLogs { + assert.Contains(t, logs, expectedLog) + } + }) + + t.Run("[Circle,Testnet] inserts new assets", func(t *testing.T) { + data.DeleteAllAssetFixtures(t, ctx, dbConnectionPool) + + allAssets, err := models.Assets.GetAll(ctx) + require.NoError(t, err) + assert.Len(t, allAssets, 0) - pubnetUSDCIssuer := keypair.MustRandom().Address() - pubnetEUROCIssuer := keypair.MustRandom().Address() + buf := new(strings.Builder) + log.DefaultLogger.SetLevel(log.InfoLevel) + log.DefaultLogger.SetOutput(buf) - assert.NotEqual(t, testnetEUROCIssuer, pubnetEUROCIssuer) + err = SetupAssetsForProperNetwork(ctx, dbConnectionPool, utils.TestnetNetworkType, schema.CirclePlatform) + require.NoError(t, err) - assets, err := models.Assets.GetAll(ctx) + allAssets, err = models.Assets.GetAll(ctx) require.NoError(t, err) - assert.Len(t, assets, 2) - assert.Equal(t, "ARST", assets[0].Code) - assert.Equal(t, pubnetARSTIssuer, assets[0].Issuer) - assert.Equal(t, "EUROC", assets[1].Code) - assert.Equal(t, testnetEUROCIssuer, assets[1].Issuer) - - assetsNetworkMap := AssetsNetworkMapType{ - utils.PubnetNetworkType: []data.Asset{ - {Code: "EUROC", Issuer: pubnetEUROCIssuer}, - {Code: "USDC", Issuer: pubnetUSDCIssuer}, - }, + + assert.Len(t, allAssets, 2) + assert.Equal(t, assets.EURCAssetCode, allAssets[0].Code) + assert.Equal(t, assets.EURCAssetIssuerTestnet, allAssets[0].Issuer) + assert.Equal(t, assets.USDCAssetCode, allAssets[1].Code) + assert.Equal(t, assets.USDCAssetIssuerTestnet, allAssets[1].Issuer) + + expectedLogs := []string{ + "updating/inserting assets for the 'testnet' network", + fmt.Sprintf("Asset codes to be updated/inserted: %v", []string{assets.EURCAssetCode, assets.USDCAssetCode}), + fmt.Sprintf("* %s - %s", assets.EURCAssetCode, assets.EURCAssetIssuerTestnet), + fmt.Sprintf("* %s - %s", assets.USDCAssetCode, assets.USDCAssetIssuerTestnet), + } + + logs := buf.String() + for _, expectedLog := range expectedLogs { + assert.Contains(t, logs, expectedLog) } + }) + + t.Run("[Circle,Pubnet] updates existing asset with wrong issuer and inserts new assets", func(t *testing.T) { + data.DeleteAllAssetFixtures(t, ctx, dbConnectionPool) + + // Start with USDC:{randomIssuer} + randomIssuer := keypair.MustRandom().Address() + data.CreateAssetFixture(t, ctx, dbConnectionPool, assets.EURCAssetCode, randomIssuer) + + allAssets, err := models.Assets.GetAll(ctx) + require.NoError(t, err) + assert.Len(t, allAssets, 1) + assert.Equal(t, assets.EURCAssetCode, allAssets[0].Code) + assert.Equal(t, randomIssuer, allAssets[0].Issuer) buf := new(strings.Builder) log.DefaultLogger.SetLevel(log.InfoLevel) log.DefaultLogger.SetOutput(buf) - err = SetupAssetsForProperNetwork(ctx, dbConnectionPool, utils.PubnetNetworkType, assetsNetworkMap) + err = SetupAssetsForProperNetwork(ctx, dbConnectionPool, utils.PubnetNetworkType, schema.CirclePlatform) require.NoError(t, err) - assets, err = models.Assets.GetAll(ctx) + allAssets, err = models.Assets.GetAll(ctx) require.NoError(t, err) - assert.Len(t, assets, 3) - assert.Equal(t, "ARST", assets[0].Code) - assert.Equal(t, pubnetARSTIssuer, assets[0].Issuer) - assert.Equal(t, "EUROC", assets[1].Code) - assert.Equal(t, pubnetEUROCIssuer, assets[1].Issuer) - assert.Equal(t, "USDC", assets[2].Code) - assert.Equal(t, pubnetUSDCIssuer, assets[2].Issuer) + + assert.Len(t, allAssets, 2) + assert.Equal(t, assets.EURCAssetCode, allAssets[0].Code) + assert.Equal(t, assets.EURCAssetIssuerPubnet, allAssets[0].Issuer) // <--- Issuer was updated + assert.Equal(t, assets.USDCAssetCode, allAssets[1].Code) + assert.Equal(t, assets.USDCAssetIssuerPubnet, allAssets[1].Issuer) expectedLogs := []string{ "updating/inserting assets for the 'pubnet' network", - "Code: ARST", - "Code: EUROC", - "Code: USDC", - fmt.Sprintf("Issuer: %s", pubnetARSTIssuer), - fmt.Sprintf("Issuer: %s", pubnetEUROCIssuer), - fmt.Sprintf("Issuer: %s", pubnetUSDCIssuer), + fmt.Sprintf("Asset codes to be updated/inserted: %v", []string{assets.EURCAssetCode, assets.USDCAssetCode}), + fmt.Sprintf("* %s - %s", assets.EURCAssetCode, assets.EURCAssetIssuerPubnet), + fmt.Sprintf("* %s - %s", assets.USDCAssetCode, assets.USDCAssetIssuerPubnet), } logs := buf.String() diff --git a/internal/services/setup_wallets_for_network_service_test.go b/internal/services/setup_wallets_for_network_service_test.go index a6bd857fb..8a4e263f8 100644 --- a/internal/services/setup_wallets_for_network_service_test.go +++ b/internal/services/setup_wallets_for_network_service_test.go @@ -6,12 +6,13 @@ import ( "testing" "github.com/stellar/go/support/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_SetupWalletsForProperNetwork(t *testing.T) { diff --git a/internal/services/wallets/wallets_pubnet.go b/internal/services/wallets/wallets_pubnet.go index 22e31c28f..a1840d8fa 100644 --- a/internal/services/wallets/wallets_pubnet.go +++ b/internal/services/wallets/wallets_pubnet.go @@ -24,15 +24,6 @@ var PubnetWallets = []data.Wallet{ assets.USDCAssetPubnet, }, }, - { - Name: "Freedom Wallet", - Homepage: "https://freedom-public-uat.bpventures.us", - DeepLinkSchema: "https://freedom-public-uat.bpventures.us/disbursement/create", - SEP10ClientDomain: "freedom-public-uat.bpventures.us", - Assets: []data.Asset{ - assets.USDCAssetPubnet, - }, - }, // { // Name: "Beans App", // Homepage: "https://www.beansapp.com/disbursements", diff --git a/internal/statistics/calculate_statistics_test.go b/internal/statistics/calculate_statistics_test.go index f5d287a51..13b7f404a 100644 --- a/internal/statistics/calculate_statistics_test.go +++ b/internal/statistics/calculate_statistics_test.go @@ -5,12 +5,13 @@ import ( "encoding/json" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func TestCalculateStatistics_emptyDatabase(t *testing.T) { diff --git a/internal/testutils/db.go b/internal/testutils/db.go new file mode 100644 index 000000000..c5acfd8b3 --- /dev/null +++ b/internal/testutils/db.go @@ -0,0 +1,37 @@ +package testutils + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" +) + +func BeginTxWithRollback(t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool) db.DBTransaction { + t.Helper() + + return beginTx(t, ctx, dbConnectionPool, true) +} + +func beginTx(t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool, autoRollback bool) db.DBTransaction { + t.Helper() + + dbTx, err := dbConnectionPool.BeginTxx(ctx, nil) + require.NoError(t, err) + + if autoRollback { + t.Cleanup(func() { + rollback(t, dbTx) + }) + } + return dbTx +} + +func rollback(t *testing.T, dbTx db.DBTransaction) { + t.Helper() + + err := dbTx.Rollback() + require.NoError(t, err) +} diff --git a/internal/testutils/http.go b/internal/testutils/http.go new file mode 100644 index 000000000..f7276a327 --- /dev/null +++ b/internal/testutils/http.go @@ -0,0 +1,23 @@ +package testutils + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" +) + +func Request(t *testing.T, ctx context.Context, r *chi.Mux, url, httpMethod string, body io.Reader) *httptest.ResponseRecorder { + t.Helper() + + req, err := http.NewRequestWithContext(ctx, httpMethod, url, body) + require.NoError(t, err) + + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + return rr +} diff --git a/internal/transactionsubmission/engine/mocks/transaction_processing_limiter.go b/internal/transactionsubmission/engine/mocks/transaction_processing_limiter.go index 199fd7759..e1e4218d9 100644 --- a/internal/transactionsubmission/engine/mocks/transaction_processing_limiter.go +++ b/internal/transactionsubmission/engine/mocks/transaction_processing_limiter.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.27.1. DO NOT EDIT. +// Code generated by mockery v2.40.1. DO NOT EDIT. package mocks @@ -21,6 +21,10 @@ func (_m *MockTransactionProcessingLimiter) AdjustLimitIfNeeded(hErr *utils.Hori func (_m *MockTransactionProcessingLimiter) LimitValue() int { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for LimitValue") + } + var r0 int if rf, ok := ret.Get(0).(func() int); ok { r0 = rf() @@ -31,13 +35,12 @@ func (_m *MockTransactionProcessingLimiter) LimitValue() int { return r0 } -type mockConstructorTestingTNewMockTransactionProcessingLimiter interface { +// NewMockTransactionProcessingLimiter creates a new instance of MockTransactionProcessingLimiter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockTransactionProcessingLimiter(t interface { mock.TestingT Cleanup(func()) -} - -// NewMockTransactionProcessingLimiter creates a new instance of MockTransactionProcessingLimiter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewMockTransactionProcessingLimiter(t mockConstructorTestingTNewMockTransactionProcessingLimiter) *MockTransactionProcessingLimiter { +}) *MockTransactionProcessingLimiter { mock := &MockTransactionProcessingLimiter{} mock.Mock.Test(t) diff --git a/internal/transactionsubmission/engine/preconditions/mocks/ledger_number_tracker.go b/internal/transactionsubmission/engine/preconditions/mocks/ledger_number_tracker.go index 44b3c0e2d..4a2ec77dc 100644 --- a/internal/transactionsubmission/engine/preconditions/mocks/ledger_number_tracker.go +++ b/internal/transactionsubmission/engine/preconditions/mocks/ledger_number_tracker.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.27.1. DO NOT EDIT. +// Code generated by mockery v2.40.1. DO NOT EDIT. package mocks @@ -17,6 +17,10 @@ type MockLedgerNumberTracker struct { func (_m *MockLedgerNumberTracker) GetLedgerBounds() (*txnbuild.LedgerBounds, error) { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for GetLedgerBounds") + } + var r0 *txnbuild.LedgerBounds var r1 error if rf, ok := ret.Get(0).(func() (*txnbuild.LedgerBounds, error)); ok { @@ -43,6 +47,10 @@ func (_m *MockLedgerNumberTracker) GetLedgerBounds() (*txnbuild.LedgerBounds, er func (_m *MockLedgerNumberTracker) GetLedgerNumber() (int, error) { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for GetLedgerNumber") + } + var r0 int var r1 error if rf, ok := ret.Get(0).(func() (int, error)); ok { @@ -63,13 +71,12 @@ func (_m *MockLedgerNumberTracker) GetLedgerNumber() (int, error) { return r0, r1 } -type mockConstructorTestingTNewMockLedgerNumberTracker interface { +// NewMockLedgerNumberTracker creates a new instance of MockLedgerNumberTracker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockLedgerNumberTracker(t interface { mock.TestingT Cleanup(func()) -} - -// NewMockLedgerNumberTracker creates a new instance of MockLedgerNumberTracker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewMockLedgerNumberTracker(t mockConstructorTestingTNewMockLedgerNumberTracker) *MockLedgerNumberTracker { +}) *MockLedgerNumberTracker { mock := &MockLedgerNumberTracker{} mock.Mock.Test(t) diff --git a/internal/transactionsubmission/engine/signing/distribution_account_env_signature_client.go b/internal/transactionsubmission/engine/signing/account_env_signature_client.go similarity index 51% rename from internal/transactionsubmission/engine/signing/distribution_account_env_signature_client.go rename to internal/transactionsubmission/engine/signing/account_env_signature_client.go index 8769b0ea2..ab7231274 100644 --- a/internal/transactionsubmission/engine/signing/distribution_account_env_signature_client.go +++ b/internal/transactionsubmission/engine/signing/account_env_signature_client.go @@ -3,22 +3,27 @@ package signing import ( "context" "fmt" + "slices" "github.com/stellar/go/keypair" "github.com/stellar/go/strkey" "github.com/stellar/go/txnbuild" + + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" ) -type DistributionAccountEnvOptions struct { +type AccountEnvOptions struct { DistributionPrivateKey string NetworkPassphrase string + AccountType schema.AccountType } -func (opts DistributionAccountEnvOptions) String() string { +func (opts AccountEnvOptions) String() string { return fmt.Sprintf("%T{NetworkPassphrase: %s}", opts, opts.NetworkPassphrase) } -func (opts *DistributionAccountEnvOptions) Validate() error { +func (opts *AccountEnvOptions) Validate() error { if opts.NetworkPassphrase == "" { return fmt.Errorf("network passphrase cannot be empty") } @@ -27,21 +32,27 @@ func (opts *DistributionAccountEnvOptions) Validate() error { return fmt.Errorf("distribution private key is not a valid Ed25519 secret") } + suuportedAccTypes := []schema.AccountType{schema.HostStellarEnv, schema.DistributionAccountStellarEnv} + if !slices.Contains(suuportedAccTypes, opts.AccountType) { + return fmt.Errorf("the provided account type %s does not match any of the supported account types: %v", opts.AccountType, suuportedAccTypes) + } + return nil } -type DistributionAccountEnvSignatureClient struct { +type AccountEnvSignatureClient struct { networkPassphrase string distributionAccount string distributionKP *keypair.Full + accountType schema.AccountType } -func (c *DistributionAccountEnvSignatureClient) String() string { - return fmt.Sprintf("DistributionAccountEnvSignatureClient{distributionAccount: %s, networkPassphrase: %s}", c.distributionAccount, c.networkPassphrase) +func (c *AccountEnvSignatureClient) String() string { + return fmt.Sprintf("AccountEnvSignatureClient{distributionAccount: %s, networkPassphrase: %s}", c.distributionAccount, c.networkPassphrase) } -// NewDistributionAccountEnvSignatureClient returns a new DistributionAccountEnvSignatureClient instance -func NewDistributionAccountEnvSignatureClient(opts DistributionAccountEnvOptions) (*DistributionAccountEnvSignatureClient, error) { +// NewAccountEnvSignatureClient returns a new AccountEnvSignatureClient instance +func NewAccountEnvSignatureClient(opts AccountEnvOptions) (*AccountEnvSignatureClient, error) { if err := opts.Validate(); err != nil { return nil, fmt.Errorf("validating options: %w", err) } @@ -51,34 +62,35 @@ func NewDistributionAccountEnvSignatureClient(opts DistributionAccountEnvOptions return nil, fmt.Errorf("parsing distribution seed: %w", err) } - return &DistributionAccountEnvSignatureClient{ + return &AccountEnvSignatureClient{ networkPassphrase: opts.NetworkPassphrase, distributionAccount: distributionKP.Address(), distributionKP: distributionKP, + accountType: opts.AccountType, }, nil } -var _ SignatureClient = (*DistributionAccountEnvSignatureClient)(nil) +var _ SignatureClient = (*AccountEnvSignatureClient)(nil) // validateStellarAccounts ensures that the distribution account is the only account signing the transaction -func (c *DistributionAccountEnvSignatureClient) validateStellarAccounts(stellarAccounts ...string) error { +func (c *AccountEnvSignatureClient) validateStellarAccounts(stellarAccounts ...string) error { if len(stellarAccounts) == 0 { - return fmt.Errorf("stellar accounts cannot be empty in %s", c.Type()) + return fmt.Errorf("stellar accounts cannot be empty in %s", c.name()) } // Ensure that the distribution account is the only account signing the transaction for _, stellarAccount := range stellarAccounts { if stellarAccount != c.distributionAccount { - return fmt.Errorf("stellar account %s is not allowed to sign in %s", stellarAccount, c.Type()) + return fmt.Errorf("stellar account %s is not allowed to sign in %s", stellarAccount, c.name()) } } return nil } -func (c *DistributionAccountEnvSignatureClient) SignStellarTransaction(ctx context.Context, stellarTx *txnbuild.Transaction, stellarAccounts ...string) (signedStellarTx *txnbuild.Transaction, err error) { +func (c *AccountEnvSignatureClient) SignStellarTransaction(ctx context.Context, stellarTx *txnbuild.Transaction, stellarAccounts ...string) (signedStellarTx *txnbuild.Transaction, err error) { if stellarTx == nil { - return nil, fmt.Errorf("stellarTx cannot be nil in %s", c.Type()) + return nil, fmt.Errorf("stellarTx cannot be nil in %s", c.name()) } err = c.validateStellarAccounts(stellarAccounts...) @@ -88,15 +100,15 @@ func (c *DistributionAccountEnvSignatureClient) SignStellarTransaction(ctx conte signedStellarTx, err = stellarTx.Sign(c.NetworkPassphrase(), c.distributionKP) if err != nil { - return nil, fmt.Errorf("signing transaction in %s: %w", c.Type(), err) + return nil, fmt.Errorf("signing transaction in %s: %w", c.name(), err) } return signedStellarTx, nil } -func (c *DistributionAccountEnvSignatureClient) SignFeeBumpStellarTransaction(ctx context.Context, feeBumpStellarTx *txnbuild.FeeBumpTransaction, stellarAccounts ...string) (signedFeeBumpStellarTx *txnbuild.FeeBumpTransaction, err error) { +func (c *AccountEnvSignatureClient) SignFeeBumpStellarTransaction(ctx context.Context, feeBumpStellarTx *txnbuild.FeeBumpTransaction, stellarAccounts ...string) (signedFeeBumpStellarTx *txnbuild.FeeBumpTransaction, err error) { if feeBumpStellarTx == nil { - return nil, fmt.Errorf("stellarTx cannot be nil in %s", c.Type()) + return nil, fmt.Errorf("stellarTx cannot be nil in %s", c.name()) } err = c.validateStellarAccounts(stellarAccounts...) @@ -106,13 +118,13 @@ func (c *DistributionAccountEnvSignatureClient) SignFeeBumpStellarTransaction(ct signedFeeBumpStellarTx, err = feeBumpStellarTx.Sign(c.NetworkPassphrase(), c.distributionKP) if err != nil { - return nil, fmt.Errorf("signing transaction in %s: %w", c.Type(), err) + return nil, fmt.Errorf("signing transaction in %s: %w", c.name(), err) } return signedFeeBumpStellarTx, nil } -func (c *DistributionAccountEnvSignatureClient) BatchInsert(ctx context.Context, number int) (publicKeys []string, err error) { +func (c *AccountEnvSignatureClient) BatchInsert(ctx context.Context, number int) (publicKeys []string, err error) { if number <= 0 { return nil, fmt.Errorf("number must be greater than 0") } @@ -121,22 +133,22 @@ func (c *DistributionAccountEnvSignatureClient) BatchInsert(ctx context.Context, for i := 0; i < number; i++ { publicKeys[i] = c.distributionAccount } - err = fmt.Errorf("BatchInsert called for signature client type %s: %w", c.Type(), ErrUnsupportedCommand) + err = fmt.Errorf("BatchInsert called for signature client type %s: %w", c.name(), ErrUnsupportedCommand) return publicKeys, err } -func (c *DistributionAccountEnvSignatureClient) Delete(ctx context.Context, publicKey string) error { +func (c *AccountEnvSignatureClient) Delete(ctx context.Context, publicKey string) error { err := c.validateStellarAccounts(publicKey) if err != nil { return fmt.Errorf("validating stellar account to delete: %w", err) } - return fmt.Errorf("Delete called for signature client type %s: %w", c.Type(), ErrUnsupportedCommand) + return fmt.Errorf("Delete called for signature client type %s: %w", c.name(), ErrUnsupportedCommand) } -func (c *DistributionAccountEnvSignatureClient) Type() string { - return string(DistributionAccountEnvSignatureClientType) +func (c *AccountEnvSignatureClient) name() string { + return fmt.Sprintf("%s.%s", sdpUtils.GetTypeName(c), c.accountType) } -func (c *DistributionAccountEnvSignatureClient) NetworkPassphrase() string { +func (c *AccountEnvSignatureClient) NetworkPassphrase() string { return c.networkPassphrase } diff --git a/internal/transactionsubmission/engine/signing/distribution_account_env_signature_client_test.go b/internal/transactionsubmission/engine/signing/account_env_signature_client_test.go similarity index 79% rename from internal/transactionsubmission/engine/signing/distribution_account_env_signature_client_test.go rename to internal/transactionsubmission/engine/signing/account_env_signature_client_test.go index 7d2123f81..b45c452ed 100644 --- a/internal/transactionsubmission/engine/signing/distribution_account_env_signature_client_test.go +++ b/internal/transactionsubmission/engine/signing/account_env_signature_client_test.go @@ -10,10 +10,12 @@ import ( "github.com/stellar/go/txnbuild" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" ) -func Test_DistributionAccountEnvOptions_String_doesntLeakPrivateKey(t *testing.T) { - opts := DistributionAccountEnvOptions{ +func Test_AccountEnvOptions_String_doesntLeakPrivateKey(t *testing.T) { + opts := AccountEnvOptions{ DistributionPrivateKey: "SOME_PRIVATE_KEY", NetworkPassphrase: "SOME_PASSPHRASE", } @@ -39,37 +41,48 @@ func Test_DistributionAccountEnvOptions_String_doesntLeakPrivateKey(t *testing.T } } -func Test_DistributionAccountEnvOptions_Validate(t *testing.T) { +func Test_AccountEnvOptions_Validate(t *testing.T) { + distributionPrivateKey := keypair.MustRandom().Seed() testCases := []struct { name string - opts DistributionAccountEnvOptions + opts AccountEnvOptions wantErrorContains string }{ { name: "returns an error if the network passphrase is empty", - opts: DistributionAccountEnvOptions{}, + opts: AccountEnvOptions{}, wantErrorContains: "network passphrase cannot be empty", }, { name: "returns an error if the distribution private key is empty", - opts: DistributionAccountEnvOptions{ + opts: AccountEnvOptions{ NetworkPassphrase: network.TestNetworkPassphrase, }, wantErrorContains: "distribution private key is not a valid Ed25519 secret", }, { name: "returns an error if the distribution private key is invalid", - opts: DistributionAccountEnvOptions{ + opts: AccountEnvOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DistributionPrivateKey: "invalid", }, wantErrorContains: "distribution private key is not a valid Ed25519 secret", }, + { + name: "returns an error if the account type is invalid", + opts: AccountEnvOptions{ + NetworkPassphrase: network.TestNetworkPassphrase, + DistributionPrivateKey: distributionPrivateKey, + AccountType: "FOOBAR", + }, + wantErrorContains: "the provided account type FOOBAR does not match any of the supported account types: [HOST.STELLAR.ENV DISTRIBUTION_ACCOUNT.STELLAR.ENV]", + }, { name: "🎉 successfully validate options", - opts: DistributionAccountEnvOptions{ + opts: AccountEnvOptions{ NetworkPassphrase: network.TestNetworkPassphrase, - DistributionPrivateKey: keypair.MustRandom().Seed(), + DistributionPrivateKey: distributionPrivateKey, + AccountType: schema.DistributionAccountStellarEnv, }, }, } @@ -87,37 +100,53 @@ func Test_DistributionAccountEnvOptions_Validate(t *testing.T) { } } -func Test_NewDistributionAccountEnvSignatureClient(t *testing.T) { +func Test_NewAccountEnvSignatureClient(t *testing.T) { distributionKP := keypair.MustRandom() testCases := []struct { name string - opts DistributionAccountEnvOptions + opts AccountEnvOptions wantErrorContains string - wantClient *DistributionAccountEnvSignatureClient + wantClient *AccountEnvSignatureClient }{ { name: "returns an error if the options are invalid", - opts: DistributionAccountEnvOptions{}, + opts: AccountEnvOptions{}, wantErrorContains: "validating options: network passphrase cannot be empty", }, { - name: "🎉 successfully create a new DistributionAccountEnvSignatureClient", - opts: DistributionAccountEnvOptions{ + name: "🎉 successfully create a new AccountEnvSignatureClient (DistributionAccountStellarEnv)", + opts: AccountEnvOptions{ + NetworkPassphrase: network.TestNetworkPassphrase, + DistributionPrivateKey: distributionKP.Seed(), + AccountType: schema.DistributionAccountStellarEnv, + }, + wantClient: &AccountEnvSignatureClient{ + networkPassphrase: network.TestNetworkPassphrase, + distributionAccount: distributionKP.Address(), + distributionKP: distributionKP, + accountType: schema.DistributionAccountStellarEnv, + }, + }, + { + name: "🎉 successfully create a new AccountEnvSignatureClient (HostStellarEnv)", + opts: AccountEnvOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DistributionPrivateKey: distributionKP.Seed(), + AccountType: schema.HostStellarEnv, }, - wantClient: &DistributionAccountEnvSignatureClient{ + wantClient: &AccountEnvSignatureClient{ networkPassphrase: network.TestNetworkPassphrase, distributionAccount: distributionKP.Address(), distributionKP: distributionKP, + accountType: schema.HostStellarEnv, }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - gotClient, err := NewDistributionAccountEnvSignatureClient(tc.opts) + gotClient, err := NewAccountEnvSignatureClient(tc.opts) if tc.wantErrorContains == "" { require.NoError(t, err) @@ -130,12 +159,13 @@ func Test_NewDistributionAccountEnvSignatureClient(t *testing.T) { } } -func Test_DistributionAccountEnvSignatureClient_validateStellarAccounts(t *testing.T) { +func Test_AccountEnvSignatureClient_validateStellarAccounts(t *testing.T) { distributionKP := keypair.MustRandom() unsupportedAccountKP := keypair.MustRandom() - distEnvClient, err := NewDistributionAccountEnvSignatureClient(DistributionAccountEnvOptions{ + distEnvClient, err := NewAccountEnvSignatureClient(AccountEnvOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DistributionPrivateKey: distributionKP.Seed(), + AccountType: schema.DistributionAccountStellarEnv, }) require.NoError(t, err) @@ -147,12 +177,12 @@ func Test_DistributionAccountEnvSignatureClient_validateStellarAccounts(t *testi { name: "returns an error if the stellar accounts are empty", stellarAccounts: []string{}, - wantErrorContains: "stellar accounts cannot be empty in " + distEnvClient.Type(), + wantErrorContains: "stellar accounts cannot be empty in " + distEnvClient.name(), }, { name: "returns an error if an account other than the distribution one is provided", stellarAccounts: []string{unsupportedAccountKP.Address(), distributionKP.Address()}, - wantErrorContains: fmt.Sprintf("stellar account %s is not allowed to sign in %s", unsupportedAccountKP.Address(), distEnvClient.Type()), + wantErrorContains: fmt.Sprintf("stellar account %s is not allowed to sign in %s", unsupportedAccountKP.Address(), distEnvClient.name()), }, { name: "🎉 successfully signs with distribution account", @@ -176,14 +206,15 @@ func Test_DistributionAccountEnvSignatureClient_validateStellarAccounts(t *testi } } -func Test_DistributionAccountEnvSignatureClient_SignStellarTransaction(t *testing.T) { +func Test_AccountEnvSignatureClient_SignStellarTransaction(t *testing.T) { ctx := context.Background() distributionKP := keypair.MustRandom() unsupportedAccountKP := keypair.MustRandom() - distEnvClient, err := NewDistributionAccountEnvSignatureClient(DistributionAccountEnvOptions{ + distEnvClient, err := NewAccountEnvSignatureClient(AccountEnvOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DistributionPrivateKey: distributionKP.Seed(), + AccountType: schema.DistributionAccountStellarEnv, }) require.NoError(t, err) @@ -224,7 +255,7 @@ func Test_DistributionAccountEnvSignatureClient_SignStellarTransaction(t *testin name: "return stellar account validation fails", stellarTx: stellarTx, accounts: []string{unsupportedAccountKP.Address()}, - wantErrContains: fmt.Sprintf("validating stellar accounts: stellar account %s is not allowed to sign in %s", unsupportedAccountKP.Address(), distEnvClient.Type()), + wantErrContains: fmt.Sprintf("validating stellar accounts: stellar account %s is not allowed to sign in %s", unsupportedAccountKP.Address(), distEnvClient.name()), }, { name: "🎉 Successfully sign transaction when all incoming addresse is correct", @@ -254,14 +285,15 @@ func Test_DistributionAccountEnvSignatureClient_SignStellarTransaction(t *testin } } -func Test_DistributionAccountEnvSignatureClient_SignFeeBumpStellarTransaction(t *testing.T) { +func Test_AccountEnvSignatureClient_SignFeeBumpStellarTransaction(t *testing.T) { ctx := context.Background() distributionKP := keypair.MustRandom() unsupportedAccountKP := keypair.MustRandom() - distEnvClient, err := NewDistributionAccountEnvSignatureClient(DistributionAccountEnvOptions{ + distEnvClient, err := NewAccountEnvSignatureClient(AccountEnvOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DistributionPrivateKey: distributionKP.Seed(), + AccountType: schema.DistributionAccountStellarEnv, }) require.NoError(t, err) @@ -314,7 +346,7 @@ func Test_DistributionAccountEnvSignatureClient_SignFeeBumpStellarTransaction(t name: "return stellar account validation fails", feeBumpStellarTx: feeBumpStellarTx, accounts: []string{unsupportedAccountKP.Address()}, - wantErrContains: fmt.Sprintf("validating stellar accounts: stellar account %s is not allowed to sign in %s", unsupportedAccountKP.Address(), distEnvClient.Type()), + wantErrContains: fmt.Sprintf("validating stellar accounts: stellar account %s is not allowed to sign in %s", unsupportedAccountKP.Address(), distEnvClient.name()), }, { name: "🎉 Successfully sign transaction when all incoming addresse is correct", @@ -344,12 +376,13 @@ func Test_DistributionAccountEnvSignatureClient_SignFeeBumpStellarTransaction(t } } -func Test_DistributionAccountEnvSignatureClient_BatchInsert(t *testing.T) { +func Test_AccountEnvSignatureClient_BatchInsert(t *testing.T) { ctx := context.Background() distributionKP := keypair.MustRandom() - distEnvClient, err := NewDistributionAccountEnvSignatureClient(DistributionAccountEnvOptions{ + distEnvClient, err := NewAccountEnvSignatureClient(AccountEnvOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DistributionPrivateKey: distributionKP.Seed(), + AccountType: schema.DistributionAccountStellarEnv, }) require.NoError(t, err) @@ -373,13 +406,14 @@ func Test_DistributionAccountEnvSignatureClient_BatchInsert(t *testing.T) { }) } -func Test_DistributionAccountEnvSignatureClient_Delete(t *testing.T) { +func Test_AccountEnvSignatureClient_Delete(t *testing.T) { ctx := context.Background() distributionKP := keypair.MustRandom() unsupportedAccountKP := keypair.MustRandom() - distEnvClient, err := NewDistributionAccountEnvSignatureClient(DistributionAccountEnvOptions{ + distEnvClient, err := NewAccountEnvSignatureClient(AccountEnvOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DistributionPrivateKey: distributionKP.Seed(), + AccountType: schema.DistributionAccountStellarEnv, }) require.NoError(t, err) diff --git a/internal/transactionsubmission/engine/signing/channel_account_db_signature_client.go b/internal/transactionsubmission/engine/signing/channel_account_db_signature_client.go index 603e0fa99..2545c11c0 100644 --- a/internal/transactionsubmission/engine/signing/channel_account_db_signature_client.go +++ b/internal/transactionsubmission/engine/signing/channel_account_db_signature_client.go @@ -11,14 +11,14 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/preconditions" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" - "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) type ChannelAccountDBSignatureClientOptions struct { NetworkPassphrase string DBConnectionPool db.DBConnectionPool EncryptionPassphrase string - Encrypter utils.PrivateKeyEncrypter + Encrypter sdpUtils.PrivateKeyEncrypter LedgerNumberTracker preconditions.LedgerNumberTracker } @@ -46,7 +46,7 @@ type ChannelAccountDBSignatureClient struct { networkPassphrase string dbConnectionPool db.DBConnectionPool chAccModel store.ChannelAccountStore - encrypter utils.PrivateKeyEncrypter + encrypter sdpUtils.PrivateKeyEncrypter encryptionPassphrase string ledgerNumberTracker preconditions.LedgerNumberTracker } @@ -59,7 +59,7 @@ func NewChannelAccountDBSignatureClient(opts ChannelAccountDBSignatureClientOpti encrypter := opts.Encrypter if encrypter == nil { - encrypter = &utils.DefaultPrivateKeyEncrypter{} + encrypter = &sdpUtils.DefaultPrivateKeyEncrypter{} } return &ChannelAccountDBSignatureClient{ @@ -114,17 +114,17 @@ func (c *ChannelAccountDBSignatureClient) getKPsForPublicKeys(ctx context.Contex func (c *ChannelAccountDBSignatureClient) SignStellarTransaction(ctx context.Context, stellarTx *txnbuild.Transaction, stellarAccounts ...string) (signedStellarTx *txnbuild.Transaction, err error) { if stellarTx == nil { - return nil, fmt.Errorf("stellarTx cannot be nil in %s", c.Type()) + return nil, fmt.Errorf("stellarTx cannot be nil in %s", c.name()) } kps, err := c.getKPsForPublicKeys(ctx, stellarAccounts...) if err != nil { - return nil, fmt.Errorf("getting keypairs for accounts %v in %s: %w", stellarAccounts, c.Type(), err) + return nil, fmt.Errorf("getting keypairs for accounts %v in %s: %w", stellarAccounts, c.name(), err) } signedStellarTx, err = stellarTx.Sign(c.NetworkPassphrase(), kps...) if err != nil { - return nil, fmt.Errorf("signing transaction in %s: %w", c.Type(), err) + return nil, fmt.Errorf("signing transaction in %s: %w", c.name(), err) } return signedStellarTx, nil @@ -132,17 +132,17 @@ func (c *ChannelAccountDBSignatureClient) SignStellarTransaction(ctx context.Con func (c *ChannelAccountDBSignatureClient) SignFeeBumpStellarTransaction(ctx context.Context, feeBumpStellarTx *txnbuild.FeeBumpTransaction, stellarAccounts ...string) (signedFeeBumpStellarTx *txnbuild.FeeBumpTransaction, err error) { if feeBumpStellarTx == nil { - return nil, fmt.Errorf("stellarTx cannot be nil in %s", c.Type()) + return nil, fmt.Errorf("stellarTx cannot be nil in %s", c.name()) } kps, err := c.getKPsForPublicKeys(ctx, stellarAccounts...) if err != nil { - return nil, fmt.Errorf("getting keypairs for accounts %v in %s: %w", stellarAccounts, c.Type(), err) + return nil, fmt.Errorf("getting keypairs for accounts %v in %s: %w", stellarAccounts, c.name(), err) } signedFeeBumpStellarTx, err = feeBumpStellarTx.Sign(c.NetworkPassphrase(), kps...) if err != nil { - return nil, fmt.Errorf("signing transaction in %s: %w", c.Type(), err) + return nil, fmt.Errorf("signing transaction in %s: %w", c.name(), err) } return signedFeeBumpStellarTx, nil @@ -203,8 +203,8 @@ func (c *ChannelAccountDBSignatureClient) Delete(ctx context.Context, publicKey return nil } -func (c *ChannelAccountDBSignatureClient) Type() string { - return string(ChannelAccountDBSignatureClientType) +func (c *ChannelAccountDBSignatureClient) name() string { + return sdpUtils.GetTypeName(c) } func (c *ChannelAccountDBSignatureClient) NetworkPassphrase() string { diff --git a/internal/transactionsubmission/engine/signing/channel_account_db_signature_client_test.go b/internal/transactionsubmission/engine/signing/channel_account_db_signature_client_test.go index 50bd12eae..0009f6eae 100644 --- a/internal/transactionsubmission/engine/signing/channel_account_db_signature_client_test.go +++ b/internal/transactionsubmission/engine/signing/channel_account_db_signature_client_test.go @@ -6,6 +6,8 @@ import ( "reflect" "testing" + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/go/keypair" "github.com/stellar/go/network" "github.com/stellar/go/txnbuild" @@ -16,7 +18,6 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" preconditionsMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/preconditions/mocks" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" - "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" ) func Test_ChannelAccountDBSignatureClientOptions_Validate(t *testing.T) { @@ -119,7 +120,7 @@ func Test_NewChannelAccountDBSignatureClient(t *testing.T) { EncryptionPassphrase: "SCPGNK3MRMXKNWGZ4ET3JZ6RUJIN7FMHT4ASVXDG7YPBL4WKBQNEL63F", LedgerNumberTracker: mLedgerNumberTracker, }, - wantEncrypterTypeName: reflect.TypeOf(&utils.DefaultPrivateKeyEncrypter{}).String(), + wantEncrypterTypeName: reflect.TypeOf(&sdpUtils.DefaultPrivateKeyEncrypter{}).String(), }, { name: "🎉 Successfully instantiates a new channel account DB signature client with a custom encrypter", @@ -128,9 +129,9 @@ func Test_NewChannelAccountDBSignatureClient(t *testing.T) { DBConnectionPool: dbConnectionPool, EncryptionPassphrase: "SCPGNK3MRMXKNWGZ4ET3JZ6RUJIN7FMHT4ASVXDG7YPBL4WKBQNEL63F", LedgerNumberTracker: mLedgerNumberTracker, - Encrypter: &utils.PrivateKeyEncrypterMock{}, + Encrypter: &sdpUtils.PrivateKeyEncrypterMock{}, }, - wantEncrypterTypeName: reflect.TypeOf(&utils.PrivateKeyEncrypterMock{}).String(), + wantEncrypterTypeName: reflect.TypeOf(&sdpUtils.PrivateKeyEncrypterMock{}).String(), }, } @@ -171,7 +172,7 @@ func Test_ChannelAccountDBSignatureClient_getKPsForAccounts(t *testing.T) { chAccountStore := store.NewChannelAccountModel(dbConnectionPool) // create default encrypter - encrypter := &utils.DefaultPrivateKeyEncrypter{} + encrypter := &sdpUtils.DefaultPrivateKeyEncrypter{} encrypterPass := keypair.MustRandom().Seed() // create channel accounts in the DB @@ -264,7 +265,7 @@ func Test_ChannelAccountDBSignatureClient_SignStellarTransaction(t *testing.T) { // create default encrypter encrypterPass := keypair.MustRandom().Seed() - encrypter := &utils.DefaultPrivateKeyEncrypter{} + encrypter := &sdpUtils.DefaultPrivateKeyEncrypter{} // create channel accounts in the DB channelAccounts := store.CreateChannelAccountFixturesEncryptedKPs(t, ctx, dbConnectionPool, encrypter, encrypterPass, 2) @@ -359,7 +360,7 @@ func Test_ChannelAccountDBSignatureClient_SignFeeBumpStellarTransaction(t *testi // create default encrypter encrypterPass := keypair.MustRandom().Seed() - encrypter := &utils.DefaultPrivateKeyEncrypter{} + encrypter := &sdpUtils.DefaultPrivateKeyEncrypter{} // create channel accounts in the DB channelAccounts := store.CreateChannelAccountFixturesEncryptedKPs(t, ctx, dbConnectionPool, encrypter, encrypterPass, 2) @@ -369,7 +370,7 @@ func Test_ChannelAccountDBSignatureClient_SignFeeBumpStellarTransaction(t *testi NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, EncryptionPassphrase: encrypterPass, - Encrypter: &utils.DefaultPrivateKeyEncrypter{}, + Encrypter: &sdpUtils.DefaultPrivateKeyEncrypter{}, LedgerNumberTracker: preconditionsMocks.NewMockLedgerNumberTracker(t), }) require.NoError(t, err) @@ -485,7 +486,7 @@ func Test_ChannelAccountDBSignatureClient_BatchInsert(t *testing.T) { mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) mLedgerNumberTracker.On("GetLedgerNumber").Return(100, nil).Once() - defaultEncrypter := &utils.DefaultPrivateKeyEncrypter{} + defaultEncrypter := &sdpUtils.DefaultPrivateKeyEncrypter{} encrypterPass := distributionKP.Seed() sigClient, err := NewChannelAccountDBSignatureClient(ChannelAccountDBSignatureClientOptions{ NetworkPassphrase: network.TestNetworkPassphrase, @@ -548,7 +549,7 @@ func Test_ChannelAccountDBSignatureClient_Delete(t *testing.T) { // create default encrypter encrypterPass := keypair.MustRandom().Seed() - encrypter := &utils.DefaultPrivateKeyEncrypter{} + encrypter := &sdpUtils.DefaultPrivateKeyEncrypter{} // current ledger number currLedgerNumber := 0 diff --git a/internal/transactionsubmission/engine/signing/distribution_account_db_signature_client.go b/internal/transactionsubmission/engine/signing/distribution_account_db_vault_signature_client.go similarity index 65% rename from internal/transactionsubmission/engine/signing/distribution_account_db_signature_client.go rename to internal/transactionsubmission/engine/signing/distribution_account_db_vault_signature_client.go index 9fe4e01c6..f4b6d8fdf 100644 --- a/internal/transactionsubmission/engine/signing/distribution_account_db_signature_client.go +++ b/internal/transactionsubmission/engine/signing/distribution_account_db_vault_signature_client.go @@ -10,17 +10,17 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" - "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) -type DistributionAccountDBSignatureClientOptions struct { +type DistributionAccountDBVaultSignatureClientOptions struct { NetworkPassphrase string DBConnectionPool db.DBConnectionPool EncryptionPassphrase string - Encrypter utils.PrivateKeyEncrypter + Encrypter sdpUtils.PrivateKeyEncrypter } -func (opts *DistributionAccountDBSignatureClientOptions) Validate() error { +func (opts *DistributionAccountDBVaultSignatureClientOptions) Validate() error { if opts.NetworkPassphrase == "" { return fmt.Errorf("network passphrase cannot be empty") } @@ -36,25 +36,25 @@ func (opts *DistributionAccountDBSignatureClientOptions) Validate() error { return nil } -type DistributionAccountDBSignatureClient struct { +type DistributionAccountDBVaultSignatureClient struct { networkPassphrase string dbVault store.DBVault - encrypter utils.PrivateKeyEncrypter + encrypter sdpUtils.PrivateKeyEncrypter encryptionPassphrase string } -// NewDistributionAccountDBSignatureClient returns a new instance of the DistributionAccountDB SignatureClient. -func NewDistributionAccountDBSignatureClient(opts DistributionAccountDBSignatureClientOptions) (*DistributionAccountDBSignatureClient, error) { +// NewDistributionAccountDBVaultSignatureClient returns a new instance of the DistributionAccountDB SignatureClient. +func NewDistributionAccountDBVaultSignatureClient(opts DistributionAccountDBVaultSignatureClientOptions) (*DistributionAccountDBVaultSignatureClient, error) { if err := opts.Validate(); err != nil { return nil, fmt.Errorf("validating options: %w", err) } encrypter := opts.Encrypter if encrypter == nil { - encrypter = &utils.DefaultPrivateKeyEncrypter{} + encrypter = &sdpUtils.DefaultPrivateKeyEncrypter{} } - return &DistributionAccountDBSignatureClient{ + return &DistributionAccountDBVaultSignatureClient{ networkPassphrase: opts.NetworkPassphrase, dbVault: store.NewDBVaultModel(opts.DBConnectionPool), encrypter: encrypter, @@ -62,9 +62,9 @@ func NewDistributionAccountDBSignatureClient(opts DistributionAccountDBSignature }, nil } -var _ SignatureClient = &DistributionAccountDBSignatureClient{} +var _ SignatureClient = &DistributionAccountDBVaultSignatureClient{} -func (c *DistributionAccountDBSignatureClient) getKPsForPublicKeys(ctx context.Context, publicKeys ...string) ([]*keypair.Full, error) { +func (c *DistributionAccountDBVaultSignatureClient) getKPsForPublicKeys(ctx context.Context, publicKeys ...string) ([]*keypair.Full, error) { if len(publicKeys) == 0 { return nil, fmt.Errorf("no publicKeys provided") } @@ -102,43 +102,43 @@ func (c *DistributionAccountDBSignatureClient) getKPsForPublicKeys(ctx context.C return kps, nil } -func (c *DistributionAccountDBSignatureClient) SignStellarTransaction(ctx context.Context, stellarTx *txnbuild.Transaction, publicKeys ...string) (signedStellarTx *txnbuild.Transaction, err error) { +func (c *DistributionAccountDBVaultSignatureClient) SignStellarTransaction(ctx context.Context, stellarTx *txnbuild.Transaction, publicKeys ...string) (signedStellarTx *txnbuild.Transaction, err error) { if stellarTx == nil { - return nil, fmt.Errorf("stellarTx cannot be nil in %s", c.Type()) + return nil, fmt.Errorf("stellarTx cannot be nil in %s", c.name()) } kps, err := c.getKPsForPublicKeys(ctx, publicKeys...) if err != nil { - return nil, fmt.Errorf("getting keypairs for publicKeys %v in %s: %w", publicKeys, c.Type(), err) + return nil, fmt.Errorf("getting keypairs for publicKeys %v in %s: %w", publicKeys, c.name(), err) } signedStellarTx, err = stellarTx.Sign(c.NetworkPassphrase(), kps...) if err != nil { - return nil, fmt.Errorf("signing transaction in %s: %w", c.Type(), err) + return nil, fmt.Errorf("signing transaction in %s: %w", c.name(), err) } return signedStellarTx, nil } -func (c *DistributionAccountDBSignatureClient) SignFeeBumpStellarTransaction(ctx context.Context, feeBumpStellarTx *txnbuild.FeeBumpTransaction, publicKeys ...string) (signedFeeBumpStellarTx *txnbuild.FeeBumpTransaction, err error) { +func (c *DistributionAccountDBVaultSignatureClient) SignFeeBumpStellarTransaction(ctx context.Context, feeBumpStellarTx *txnbuild.FeeBumpTransaction, publicKeys ...string) (signedFeeBumpStellarTx *txnbuild.FeeBumpTransaction, err error) { if feeBumpStellarTx == nil { - return nil, fmt.Errorf("stellarTx cannot be nil in %s", c.Type()) + return nil, fmt.Errorf("stellarTx cannot be nil in %s", c.name()) } kps, err := c.getKPsForPublicKeys(ctx, publicKeys...) if err != nil { - return nil, fmt.Errorf("getting keypairs for publicKeys %v in %s: %w", publicKeys, c.Type(), err) + return nil, fmt.Errorf("getting keypairs for publicKeys %v in %s: %w", publicKeys, c.name(), err) } signedFeeBumpStellarTx, err = feeBumpStellarTx.Sign(c.NetworkPassphrase(), kps...) if err != nil { - return nil, fmt.Errorf("signing transaction in %s: %w", c.Type(), err) + return nil, fmt.Errorf("signing transaction in %s: %w", c.name(), err) } return signedFeeBumpStellarTx, nil } -func (c *DistributionAccountDBSignatureClient) BatchInsert(ctx context.Context, number int) (publicKeys []string, err error) { +func (c *DistributionAccountDBVaultSignatureClient) BatchInsert(ctx context.Context, number int) (publicKeys []string, err error) { if number < 1 { return nil, fmt.Errorf("the number of publicKeys to insert needs to be greater than zero") } @@ -172,7 +172,7 @@ func (c *DistributionAccountDBSignatureClient) BatchInsert(ctx context.Context, return publicKeys, nil } -func (c *DistributionAccountDBSignatureClient) Delete(ctx context.Context, publicKey string) error { +func (c *DistributionAccountDBVaultSignatureClient) Delete(ctx context.Context, publicKey string) error { err := c.dbVault.Delete(ctx, publicKey) if err != nil { return fmt.Errorf("deleting dbVaultEntry %q from database: %w", publicKey, err) @@ -181,10 +181,10 @@ func (c *DistributionAccountDBSignatureClient) Delete(ctx context.Context, publi return nil } -func (c *DistributionAccountDBSignatureClient) Type() string { - return string(DistributionAccountDBSignatureClientType) +func (c *DistributionAccountDBVaultSignatureClient) name() string { + return sdpUtils.GetTypeName(c) } -func (c *DistributionAccountDBSignatureClient) NetworkPassphrase() string { +func (c *DistributionAccountDBVaultSignatureClient) NetworkPassphrase() string { return c.networkPassphrase } diff --git a/internal/transactionsubmission/engine/signing/distribution_account_db_signature_client_test.go b/internal/transactionsubmission/engine/signing/distribution_account_db_vault_signature_client_test.go similarity index 87% rename from internal/transactionsubmission/engine/signing/distribution_account_db_signature_client_test.go rename to internal/transactionsubmission/engine/signing/distribution_account_db_vault_signature_client_test.go index 920fede16..78c5f2fb5 100644 --- a/internal/transactionsubmission/engine/signing/distribution_account_db_signature_client_test.go +++ b/internal/transactionsubmission/engine/signing/distribution_account_db_vault_signature_client_test.go @@ -5,6 +5,8 @@ import ( "reflect" "testing" + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/go/keypair" "github.com/stellar/go/network" "github.com/stellar/go/txnbuild" @@ -14,10 +16,9 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" - "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" ) -func Test_DistributionAccountDBSignatureClientOptions_Validate(t *testing.T) { +func Test_DistributionAccountDBVaultSignatureClientOptions_Validate(t *testing.T) { dbt := dbtest.OpenWithTSSMigrationsOnly(t) defer dbt.Close() dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) @@ -26,7 +27,7 @@ func Test_DistributionAccountDBSignatureClientOptions_Validate(t *testing.T) { testCases := []struct { name string - opts DistributionAccountDBSignatureClientOptions + opts DistributionAccountDBVaultSignatureClientOptions wantErrContains string }{ { @@ -35,14 +36,14 @@ func Test_DistributionAccountDBSignatureClientOptions_Validate(t *testing.T) { }, { name: "return an error if dbConnectionPool is nil", - opts: DistributionAccountDBSignatureClientOptions{ + opts: DistributionAccountDBVaultSignatureClientOptions{ NetworkPassphrase: network.TestNetworkPassphrase, }, wantErrContains: "database connection pool cannot be nil", }, { name: "return an error if encryption passphrase is empty", - opts: DistributionAccountDBSignatureClientOptions{ + opts: DistributionAccountDBVaultSignatureClientOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, }, @@ -50,7 +51,7 @@ func Test_DistributionAccountDBSignatureClientOptions_Validate(t *testing.T) { }, { name: "return an error if encryption passphrase is invalid", - opts: DistributionAccountDBSignatureClientOptions{ + opts: DistributionAccountDBVaultSignatureClientOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, EncryptionPassphrase: "invalid", @@ -59,7 +60,7 @@ func Test_DistributionAccountDBSignatureClientOptions_Validate(t *testing.T) { }, { name: "🎉 Successfully validates options", - opts: DistributionAccountDBSignatureClientOptions{ + opts: DistributionAccountDBVaultSignatureClientOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, EncryptionPassphrase: "SCPGNK3MRMXKNWGZ4ET3JZ6RUJIN7FMHT4ASVXDG7YPBL4WKBQNEL63F", @@ -80,7 +81,7 @@ func Test_DistributionAccountDBSignatureClientOptions_Validate(t *testing.T) { } } -func Test_NewDistributionAccountDBSignatureClient(t *testing.T) { +func Test_NewDistributionAccountDBVaultSignatureClient(t *testing.T) { dbt := dbtest.OpenWithTSSMigrationsOnly(t) defer dbt.Close() dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) @@ -89,7 +90,7 @@ func Test_NewDistributionAccountDBSignatureClient(t *testing.T) { testCases := []struct { name string - opts DistributionAccountDBSignatureClientOptions + opts DistributionAccountDBVaultSignatureClientOptions wantEncrypterTypeName string wantErrContains string }{ @@ -99,28 +100,28 @@ func Test_NewDistributionAccountDBSignatureClient(t *testing.T) { }, { name: "🎉 Successfully instantiates a new distribution account DB signature client with default encrypter", - opts: DistributionAccountDBSignatureClientOptions{ + opts: DistributionAccountDBVaultSignatureClientOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, EncryptionPassphrase: "SCPGNK3MRMXKNWGZ4ET3JZ6RUJIN7FMHT4ASVXDG7YPBL4WKBQNEL63F", }, - wantEncrypterTypeName: reflect.TypeOf(&utils.DefaultPrivateKeyEncrypter{}).String(), + wantEncrypterTypeName: reflect.TypeOf(&sdpUtils.DefaultPrivateKeyEncrypter{}).String(), }, { name: "🎉 Successfully instantiates a new distribution account DB signature client with a custom encrypter", - opts: DistributionAccountDBSignatureClientOptions{ + opts: DistributionAccountDBVaultSignatureClientOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, EncryptionPassphrase: "SCPGNK3MRMXKNWGZ4ET3JZ6RUJIN7FMHT4ASVXDG7YPBL4WKBQNEL63F", - Encrypter: &utils.PrivateKeyEncrypterMock{}, + Encrypter: &sdpUtils.PrivateKeyEncrypterMock{}, }, - wantEncrypterTypeName: reflect.TypeOf(&utils.PrivateKeyEncrypterMock{}).String(), + wantEncrypterTypeName: reflect.TypeOf(&sdpUtils.PrivateKeyEncrypterMock{}).String(), }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - sigClient, err := NewDistributionAccountDBSignatureClient(tc.opts) + sigClient, err := NewDistributionAccountDBVaultSignatureClient(tc.opts) if tc.wantErrContains != "" { require.Error(t, err) assert.ErrorContains(t, err, tc.wantErrContains) @@ -134,17 +135,17 @@ func Test_NewDistributionAccountDBSignatureClient(t *testing.T) { } } -func Test_DistributionAccountDBSignatureClientOptions_NetworkPassphrase(t *testing.T) { +func Test_DistributionAccountDBVaultSignatureClientOptions_NetworkPassphrase(t *testing.T) { // test with testnet passphrase - sigClient := &DistributionAccountDBSignatureClient{networkPassphrase: network.TestNetworkPassphrase} + sigClient := &DistributionAccountDBVaultSignatureClient{networkPassphrase: network.TestNetworkPassphrase} assert.Equal(t, network.TestNetworkPassphrase, sigClient.NetworkPassphrase()) // test with public network passphrase, to make sure it's changing accordingly - sigClient = &DistributionAccountDBSignatureClient{networkPassphrase: network.PublicNetworkPassphrase} + sigClient = &DistributionAccountDBVaultSignatureClient{networkPassphrase: network.PublicNetworkPassphrase} assert.Equal(t, network.PublicNetworkPassphrase, sigClient.NetworkPassphrase()) } -func Test_DistributionAccountDBSignatureClient_getKPsForAccounts(t *testing.T) { +func Test_DistributionAccountDBVaultSignatureClient_getKPsForAccounts(t *testing.T) { dbt := dbtest.OpenWithTSSMigrationsOnly(t) defer dbt.Close() dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) @@ -155,7 +156,7 @@ func Test_DistributionAccountDBSignatureClient_getKPsForAccounts(t *testing.T) { dbVaultStore := store.NewDBVaultModel(dbConnectionPool) // create default encrypter - encrypter := &utils.DefaultPrivateKeyEncrypter{} + encrypter := &sdpUtils.DefaultPrivateKeyEncrypter{} encrypterPass := keypair.MustRandom().Seed() // create distribution accounts in the DB @@ -175,7 +176,7 @@ func Test_DistributionAccountDBSignatureClient_getKPsForAccounts(t *testing.T) { require.NoError(t, err) // create signature client - sigClient, err := NewDistributionAccountDBSignatureClient(DistributionAccountDBSignatureClientOptions{ + sigClient, err := NewDistributionAccountDBVaultSignatureClient(DistributionAccountDBVaultSignatureClientOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, EncryptionPassphrase: encrypterPass, @@ -237,7 +238,7 @@ func Test_DistributionAccountDBSignatureClient_getKPsForAccounts(t *testing.T) { } } -func Test_DistributionAccountDBSignatureClient_SignStellarTransaction(t *testing.T) { +func Test_DistributionAccountDBVaultSignatureClient_SignStellarTransaction(t *testing.T) { dbt := dbtest.OpenWithTSSMigrationsOnly(t) defer dbt.Close() dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) @@ -248,14 +249,14 @@ func Test_DistributionAccountDBSignatureClient_SignStellarTransaction(t *testing // create default encrypter encrypterPass := keypair.MustRandom().Seed() - encrypter := &utils.DefaultPrivateKeyEncrypter{} + encrypter := &sdpUtils.DefaultPrivateKeyEncrypter{} // create distribution accounts in the DB distributionAccounts := store.CreateDBVaultFixturesEncryptedKPs(t, ctx, dbConnectionPool, encrypter, encrypterPass, 2) require.Len(t, distributionAccounts, 2) distAccKP1, distAccKP2 := distributionAccounts[0], distributionAccounts[1] - sigClient, err := NewDistributionAccountDBSignatureClient(DistributionAccountDBSignatureClientOptions{ + sigClient, err := NewDistributionAccountDBVaultSignatureClient(DistributionAccountDBVaultSignatureClientOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, EncryptionPassphrase: encrypterPass, @@ -332,7 +333,7 @@ func Test_DistributionAccountDBSignatureClient_SignStellarTransaction(t *testing } } -func Test_DistributionAccountDBSignatureClient_SignFeeBumpStellarTransaction(t *testing.T) { +func Test_DistributionAccountDBVaultSignatureClient_SignFeeBumpStellarTransaction(t *testing.T) { dbt := dbtest.OpenWithTSSMigrationsOnly(t) defer dbt.Close() dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) @@ -343,18 +344,18 @@ func Test_DistributionAccountDBSignatureClient_SignFeeBumpStellarTransaction(t * // create default encrypter encrypterPass := keypair.MustRandom().Seed() - encrypter := &utils.DefaultPrivateKeyEncrypter{} + encrypter := &sdpUtils.DefaultPrivateKeyEncrypter{} // create distribution accounts in the DB distributionAccounts := store.CreateDBVaultFixturesEncryptedKPs(t, ctx, dbConnectionPool, encrypter, encrypterPass, 2) require.Len(t, distributionAccounts, 2) distAccKP1, distAccKP2 := distributionAccounts[0], distributionAccounts[1] - sigClient, err := NewDistributionAccountDBSignatureClient(DistributionAccountDBSignatureClientOptions{ + sigClient, err := NewDistributionAccountDBVaultSignatureClient(DistributionAccountDBVaultSignatureClientOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, EncryptionPassphrase: encrypterPass, - Encrypter: &utils.DefaultPrivateKeyEncrypter{}, + Encrypter: &sdpUtils.DefaultPrivateKeyEncrypter{}, }) require.NoError(t, err) @@ -448,7 +449,7 @@ func allDBVaultEntries(t *testing.T, ctx context.Context, dbConnectionPool db.DB return dbVaultEntries } -func Test_DistributionAccountDBSignatureClient_BatchInsert(t *testing.T) { +func Test_DistributionAccountDBVaultSignatureClient_BatchInsert(t *testing.T) { dbt := dbtest.OpenWithTSSMigrationsOnly(t) defer dbt.Close() dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) @@ -475,9 +476,9 @@ func Test_DistributionAccountDBSignatureClient_BatchInsert(t *testing.T) { }, } - defaultEncrypter := &utils.DefaultPrivateKeyEncrypter{} + defaultEncrypter := &sdpUtils.DefaultPrivateKeyEncrypter{} encrypterPass := distributionKP.Seed() - sigClient, err := NewDistributionAccountDBSignatureClient(DistributionAccountDBSignatureClientOptions{ + sigClient, err := NewDistributionAccountDBVaultSignatureClient(DistributionAccountDBVaultSignatureClientOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, EncryptionPassphrase: encrypterPass, @@ -522,7 +523,7 @@ func Test_DistributionAccountDBSignatureClient_BatchInsert(t *testing.T) { } } -func Test_DistributionAccountDBSignatureClient_Delete(t *testing.T) { +func Test_DistributionAccountDBVaultSignatureClient_Delete(t *testing.T) { dbt := dbtest.OpenWithTSSMigrationsOnly(t) defer dbt.Close() dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) @@ -533,7 +534,7 @@ func Test_DistributionAccountDBSignatureClient_Delete(t *testing.T) { // create default encrypter encrypterPass := keypair.MustRandom().Seed() - encrypter := &utils.DefaultPrivateKeyEncrypter{} + encrypter := &sdpUtils.DefaultPrivateKeyEncrypter{} // at start: count=0 allDistAccounts := allDBVaultEntries(t, ctx, dbConnectionPool) @@ -544,7 +545,7 @@ func Test_DistributionAccountDBSignatureClient_Delete(t *testing.T) { allDistAccounts = allDBVaultEntries(t, ctx, dbConnectionPool) require.Len(t, allDistAccounts, 2) - sigClient, err := NewDistributionAccountDBSignatureClient(DistributionAccountDBSignatureClientOptions{ + sigClient, err := NewDistributionAccountDBVaultSignatureClient(DistributionAccountDBVaultSignatureClientOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, EncryptionPassphrase: encrypterPass, diff --git a/internal/transactionsubmission/engine/signing/distribution_account_resolver.go b/internal/transactionsubmission/engine/signing/distribution_account_resolver.go index b0c124bb2..70385cc0d 100644 --- a/internal/transactionsubmission/engine/signing/distribution_account_resolver.go +++ b/internal/transactionsubmission/engine/signing/distribution_account_resolver.go @@ -7,6 +7,7 @@ import ( "github.com/stellar/go/strkey" "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) @@ -17,14 +18,15 @@ var ErrDistributionAccountIsEmpty = fmt.Errorf("distribution account is empty") // //go:generate mockery --name=DistributionAccountResolver --case=underscore --structname=MockDistributionAccountResolver type DistributionAccountResolver interface { - DistributionAccount(ctx context.Context, tenantID string) (*schema.DistributionAccount, error) - DistributionAccountFromContext(ctx context.Context) (*schema.DistributionAccount, error) - HostDistributionAccount() string + DistributionAccount(ctx context.Context, tenantID string) (schema.TransactionAccount, error) + DistributionAccountFromContext(ctx context.Context) (schema.TransactionAccount, error) + HostDistributionAccount() schema.TransactionAccount } type DistributionAccountResolverOptions struct { AdminDBConnectionPool db.DBConnectionPool HostDistributionAccountPublicKey string + MTNDBConnectionPool db.DBConnectionPool } func (c DistributionAccountResolverOptions) Validate() error { @@ -50,6 +52,7 @@ func NewDistributionAccountResolver(config DistributionAccountResolverOptions) ( return &DistributionAccountResolverImpl{ tenantManager: tenant.NewManager(tenant.WithDatabase(config.AdminDBConnectionPool)), hostDistributionAccountPubKey: config.HostDistributionAccountPublicKey, + circleConfigModel: circle.NewClientConfigModel(config.MTNDBConnectionPool), }, nil } @@ -59,37 +62,63 @@ var _ DistributionAccountResolver = (*DistributionAccountResolverImpl)(nil) type DistributionAccountResolverImpl struct { tenantManager tenant.ManagerInterface hostDistributionAccountPubKey string + circleConfigModel circle.ClientConfigModelInterface } // DistributionAccount returns the tenant's distribution account stored in the database. -func (r *DistributionAccountResolverImpl) DistributionAccount(ctx context.Context, tenantID string) (*schema.DistributionAccount, error) { - return r.getDistributionAccount(r.tenantManager.GetTenantByID(ctx, tenantID)) +func (r *DistributionAccountResolverImpl) DistributionAccount(ctx context.Context, tenantID string) (schema.TransactionAccount, error) { + tnt, err := r.tenantManager.GetTenantByID(ctx, tenantID) + if err != nil { + return schema.TransactionAccount{}, fmt.Errorf("getting tenant: %w", err) + } + tenant.SaveTenantInContext(ctx, tnt) + return r.getDistributionAccount(ctx, tnt) } // DistributionAccountFromContext returns the tenant's distribution account from the tenant object stored in the context // provided. -func (r *DistributionAccountResolverImpl) DistributionAccountFromContext(ctx context.Context) (*schema.DistributionAccount, error) { - return r.getDistributionAccount(tenant.GetTenantFromContext(ctx)) +func (r *DistributionAccountResolverImpl) DistributionAccountFromContext(ctx context.Context) (schema.TransactionAccount, error) { + tnt, err := tenant.GetTenantFromContext(ctx) + if err != nil { + return schema.TransactionAccount{}, fmt.Errorf("getting tenant: %w", err) + } + return r.getDistributionAccount(ctx, tnt) } // getDistributionAccount extracts the distribution account from the tenant if it exists. -func (r *DistributionAccountResolverImpl) getDistributionAccount(tnt *tenant.Tenant, err error) (*schema.DistributionAccount, error) { - if err != nil { - return nil, fmt.Errorf("getting tenant: %w", err) - } +func (r *DistributionAccountResolverImpl) getDistributionAccount(ctx context.Context, tnt *tenant.Tenant) (schema.TransactionAccount, error) { + if tnt.DistributionAccountType == schema.DistributionAccountCircleDBVault { + // 1. Circle Account + cc, circleErr := r.circleConfigModel.Get(ctx) + if circleErr != nil { + return schema.TransactionAccount{}, fmt.Errorf("getting circle client config: %w", circleErr) + } + + var walletID string + if cc != nil && cc.WalletID != nil { + walletID = *cc.WalletID + } + return schema.TransactionAccount{ + CircleWalletID: walletID, + Type: schema.DistributionAccountCircleDBVault, + Status: tnt.DistributionAccountStatus, + }, nil + } else { + // 2. Stellar Account + if tnt.DistributionAccountAddress == nil { + return schema.TransactionAccount{}, ErrDistributionAccountIsEmpty + } + + return schema.TransactionAccount{ + Address: *tnt.DistributionAccountAddress, + Type: tnt.DistributionAccountType, + Status: tnt.DistributionAccountStatus, + }, nil - if tnt.DistributionAccountAddress == nil { - return nil, ErrDistributionAccountIsEmpty } - - return &schema.DistributionAccount{ - Address: *tnt.DistributionAccountAddress, - Type: tnt.DistributionAccountType, - Status: tnt.DistributionAccountStatus, - }, nil } // HostDistributionAccount returns the host distribution account from the database. -func (r *DistributionAccountResolverImpl) HostDistributionAccount() string { - return r.hostDistributionAccountPubKey +func (r *DistributionAccountResolverImpl) HostDistributionAccount() schema.TransactionAccount { + return schema.NewDefaultHostAccount(r.hostDistributionAccountPubKey) } diff --git a/internal/transactionsubmission/engine/signing/distribution_account_resolver_test.go b/internal/transactionsubmission/engine/signing/distribution_account_resolver_test.go index 04def6fe8..566eccca7 100644 --- a/internal/transactionsubmission/engine/signing/distribution_account_resolver_test.go +++ b/internal/transactionsubmission/engine/signing/distribution_account_resolver_test.go @@ -5,12 +5,15 @@ import ( "testing" "github.com/stellar/go/keypair" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + "github.com/stellar/stellar-disbursement-platform-backend/internal/circle" + "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_DistributionAccountResolverOptions_Validate(t *testing.T) { @@ -88,6 +91,7 @@ func Test_NewDistributionAccountResolver(t *testing.T) { name: "return an error if config is invalid", config: DistributionAccountResolverOptions{ AdminDBConnectionPool: nil, + MTNDBConnectionPool: nil, HostDistributionAccountPublicKey: "", }, wantErrContains: "validating config in NewDistributionAccountResolver: AdminDBConnectionPool cannot be nil", @@ -96,11 +100,13 @@ func Test_NewDistributionAccountResolver(t *testing.T) { name: "🎉 successfully create a new DistributionAccountResolver", config: DistributionAccountResolverOptions{ AdminDBConnectionPool: dbConnectionPool, + MTNDBConnectionPool: dbConnectionPool, HostDistributionAccountPublicKey: hostDistPublicKey, }, wantResult: &DistributionAccountResolverImpl{ tenantManager: tenant.NewManager(tenant.WithDatabase(dbConnectionPool)), hostDistributionAccountPubKey: hostDistPublicKey, + circleConfigModel: circle.NewClientConfigModel(dbConnectionPool), }, }, } @@ -132,6 +138,7 @@ func Test_DistributionAccountResolverImpl_DistributionAccount(t *testing.T) { hostDistributionAccountPubKey := keypair.MustRandom().Address() distAccResolver, err := NewDistributionAccountResolver(DistributionAccountResolverOptions{ AdminDBConnectionPool: dbConnectionPool, + MTNDBConnectionPool: dbConnectionPool, HostDistributionAccountPublicKey: hostDistributionAccountPubKey, }) require.NoError(t, err) @@ -165,17 +172,17 @@ func Test_DistributionAccountResolverImpl_DistributionAccount(t *testing.T) { tnt, err = m.UpdateTenantConfig(ctx, &tenant.TenantUpdate{ ID: tnt.ID, DistributionAccountAddress: distributionPublicKey, - DistributionAccountType: schema.DistributionAccountTypeDBVaultStellar, - DistributionAccountStatus: schema.DistributionAccountStatusActive, + DistributionAccountType: schema.DistributionAccountStellarDBVault, + DistributionAccountStatus: schema.AccountStatusActive, }) require.NoError(t, err) distAccount, err := distAccResolver.DistributionAccount(ctx, tnt.ID) assert.NoError(t, err) - assert.Equal(t, &schema.DistributionAccount{ + assert.Equal(t, schema.TransactionAccount{ Address: distributionPublicKey, - Type: schema.DistributionAccountTypeDBVaultStellar, - Status: schema.DistributionAccountStatusActive, + Type: schema.DistributionAccountStellarDBVault, + Status: schema.AccountStatusActive, }, distAccount) }) } @@ -190,6 +197,7 @@ func Test_DistributionAccountResolverImpl_DistributionAccountFromContext(t *test hostDistributionAccountPubKey := keypair.MustRandom().Address() distAccResolver, err := NewDistributionAccountResolver(DistributionAccountResolverOptions{ AdminDBConnectionPool: dbConnectionPool, + MTNDBConnectionPool: dbConnectionPool, HostDistributionAccountPublicKey: hostDistributionAccountPubKey, }) require.NoError(t, err) @@ -210,36 +218,79 @@ func Test_DistributionAccountResolverImpl_DistributionAccountFromContext(t *test assert.ErrorIs(t, err, ErrDistributionAccountIsEmpty) }) - t.Run("successfully return the distribution account from the tenant stored in the context", func(t *testing.T) { + t.Run("correctly returns the CIRCLE response after the initial setup, when there's no entry in the circleConfigModel", func(t *testing.T) { + tnt := &tenant.Tenant{ + ID: "95e788b6-c80e-4975-9d12-141001fe6e44", + Name: "aid-org-1", + DistributionAccountType: schema.DistributionAccountCircleDBVault, + DistributionAccountStatus: schema.AccountStatusPendingUserActivation, + } + ctxWithTenant := tenant.SaveTenantInContext(context.Background(), tnt) + + distAccount, err := distAccResolver.DistributionAccountFromContext(ctxWithTenant) + assert.NoError(t, err) + assert.Equal(t, schema.TransactionAccount{ + Type: schema.DistributionAccountCircleDBVault, + Status: schema.AccountStatusPendingUserActivation, + }, distAccount) + }) + + t.Run("🎉 successfully returns the CIRCLE response after it's being fully configured", func(t *testing.T) { + tnt := &tenant.Tenant{ + ID: "95e788b6-c80e-4975-9d12-141001fe6e44", + Name: "aid-org-1", + DistributionAccountType: schema.DistributionAccountCircleDBVault, + DistributionAccountStatus: schema.AccountStatusActive, + } + ctxWithTenant := tenant.SaveTenantInContext(context.Background(), tnt) + + circleConfigModel := circle.NewClientConfigModel(dbConnectionPool) + err := circleConfigModel.Upsert(context.Background(), circle.ClientConfigUpdate{ + EncryptedAPIKey: utils.StringPtr("encrypted-api-key"), + WalletID: utils.StringPtr("wallet-id"), + EncrypterPublicKey: utils.StringPtr("encrypter-public-key"), + }) + require.NoError(t, err) + + distAccount, err := distAccResolver.DistributionAccountFromContext(ctxWithTenant) + assert.NoError(t, err) + assert.Equal(t, schema.TransactionAccount{ + CircleWalletID: "wallet-id", + Type: schema.DistributionAccountCircleDBVault, + Status: schema.AccountStatusActive, + }, distAccount) + }) + + t.Run("🎉 successfully return the distribution account from the tenant stored in the context", func(t *testing.T) { distributionPublicKey := keypair.MustRandom().Address() ctxTenant := &tenant.Tenant{ ID: "95e788b6-c80e-4975-9d12-141001fe6e44", Name: "aid-org-1", DistributionAccountAddress: &distributionPublicKey, - DistributionAccountType: schema.DistributionAccountTypeEnvStellar, - DistributionAccountStatus: schema.DistributionAccountStatusActive, + DistributionAccountType: schema.DistributionAccountStellarEnv, + DistributionAccountStatus: schema.AccountStatusActive, } ctxWithTenant := tenant.SaveTenantInContext(context.Background(), ctxTenant) distAccount, err := distAccResolver.DistributionAccountFromContext(ctxWithTenant) assert.NoError(t, err) - assert.Equal(t, &schema.DistributionAccount{ + assert.Equal(t, schema.TransactionAccount{ Address: distributionPublicKey, - Type: schema.DistributionAccountTypeEnvStellar, - Status: schema.DistributionAccountStatusActive, + Type: schema.DistributionAccountStellarEnv, + Status: schema.AccountStatusActive, }, distAccount) }) } func Test_DistributionAccountResolverImpl_HostDistributionAccount(t *testing.T) { - publicKeys := []string{ - keypair.MustRandom().Address(), - keypair.MustRandom().Address(), - keypair.MustRandom().Address(), + hostAccounts := []schema.TransactionAccount{ + schema.NewDefaultHostAccount(keypair.MustRandom().Address()), + schema.NewDefaultHostAccount(keypair.MustRandom().Address()), + schema.NewDefaultHostAccount(keypair.MustRandom().Address()), } - for i, publicKey := range publicKeys { - distAccResolver := &DistributionAccountResolverImpl{hostDistributionAccountPubKey: publicKey} - assert.Equalf(t, publicKey, distAccResolver.HostDistributionAccount(), "assertion failed at index %d", i) + for i, hostAccount := range hostAccounts { + distAccResolver := &DistributionAccountResolverImpl{hostDistributionAccountPubKey: hostAccount.Address} + assert.Equalf(t, hostAccount, distAccResolver.HostDistributionAccount(), "assertion failed at index %d", i) } } diff --git a/internal/transactionsubmission/engine/signing/mocks/distribution_account_resolver.go b/internal/transactionsubmission/engine/signing/mocks/distribution_account_resolver.go index 5a6c070e1..a6f48cd45 100644 --- a/internal/transactionsubmission/engine/signing/mocks/distribution_account_resolver.go +++ b/internal/transactionsubmission/engine/signing/mocks/distribution_account_resolver.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.27.1. DO NOT EDIT. +// Code generated by mockery v2.40.1. DO NOT EDIT. package mocks @@ -15,20 +15,22 @@ type MockDistributionAccountResolver struct { } // DistributionAccount provides a mock function with given fields: ctx, tenantID -func (_m *MockDistributionAccountResolver) DistributionAccount(ctx context.Context, tenantID string) (*schema.DistributionAccount, error) { +func (_m *MockDistributionAccountResolver) DistributionAccount(ctx context.Context, tenantID string) (schema.TransactionAccount, error) { ret := _m.Called(ctx, tenantID) - var r0 *schema.DistributionAccount + if len(ret) == 0 { + panic("no return value specified for DistributionAccount") + } + + var r0 schema.TransactionAccount var r1 error - if rf, ok := ret.Get(0).(func(context.Context, string) (*schema.DistributionAccount, error)); ok { + if rf, ok := ret.Get(0).(func(context.Context, string) (schema.TransactionAccount, error)); ok { return rf(ctx, tenantID) } - if rf, ok := ret.Get(0).(func(context.Context, string) *schema.DistributionAccount); ok { + if rf, ok := ret.Get(0).(func(context.Context, string) schema.TransactionAccount); ok { r0 = rf(ctx, tenantID) } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*schema.DistributionAccount) - } + r0 = ret.Get(0).(schema.TransactionAccount) } if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { @@ -41,20 +43,22 @@ func (_m *MockDistributionAccountResolver) DistributionAccount(ctx context.Conte } // DistributionAccountFromContext provides a mock function with given fields: ctx -func (_m *MockDistributionAccountResolver) DistributionAccountFromContext(ctx context.Context) (*schema.DistributionAccount, error) { +func (_m *MockDistributionAccountResolver) DistributionAccountFromContext(ctx context.Context) (schema.TransactionAccount, error) { ret := _m.Called(ctx) - var r0 *schema.DistributionAccount + if len(ret) == 0 { + panic("no return value specified for DistributionAccountFromContext") + } + + var r0 schema.TransactionAccount var r1 error - if rf, ok := ret.Get(0).(func(context.Context) (*schema.DistributionAccount, error)); ok { + if rf, ok := ret.Get(0).(func(context.Context) (schema.TransactionAccount, error)); ok { return rf(ctx) } - if rf, ok := ret.Get(0).(func(context.Context) *schema.DistributionAccount); ok { + if rf, ok := ret.Get(0).(func(context.Context) schema.TransactionAccount); ok { r0 = rf(ctx) } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*schema.DistributionAccount) - } + r0 = ret.Get(0).(schema.TransactionAccount) } if rf, ok := ret.Get(1).(func(context.Context) error); ok { @@ -67,26 +71,29 @@ func (_m *MockDistributionAccountResolver) DistributionAccountFromContext(ctx co } // HostDistributionAccount provides a mock function with given fields: -func (_m *MockDistributionAccountResolver) HostDistributionAccount() string { +func (_m *MockDistributionAccountResolver) HostDistributionAccount() schema.TransactionAccount { ret := _m.Called() - var r0 string - if rf, ok := ret.Get(0).(func() string); ok { + if len(ret) == 0 { + panic("no return value specified for HostDistributionAccount") + } + + var r0 schema.TransactionAccount + if rf, ok := ret.Get(0).(func() schema.TransactionAccount); ok { r0 = rf() } else { - r0 = ret.Get(0).(string) + r0 = ret.Get(0).(schema.TransactionAccount) } return r0 } -type mockConstructorTestingTNewMockDistributionAccountResolver interface { +// NewMockDistributionAccountResolver creates a new instance of MockDistributionAccountResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockDistributionAccountResolver(t interface { mock.TestingT Cleanup(func()) -} - -// NewMockDistributionAccountResolver creates a new instance of MockDistributionAccountResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewMockDistributionAccountResolver(t mockConstructorTestingTNewMockDistributionAccountResolver) *MockDistributionAccountResolver { +}) *MockDistributionAccountResolver { mock := &MockDistributionAccountResolver{} mock.Mock.Test(t) diff --git a/internal/transactionsubmission/engine/signing/mocks/signature_client.go b/internal/transactionsubmission/engine/signing/mocks/signature_client.go index a9c2524ab..5361d478f 100644 --- a/internal/transactionsubmission/engine/signing/mocks/signature_client.go +++ b/internal/transactionsubmission/engine/signing/mocks/signature_client.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.27.1. DO NOT EDIT. +// Code generated by mockery v2.40.1. DO NOT EDIT. package mocks @@ -19,6 +19,10 @@ type MockSignatureClient struct { func (_m *MockSignatureClient) BatchInsert(ctx context.Context, number int) ([]string, error) { ret := _m.Called(ctx, number) + if len(ret) == 0 { + panic("no return value specified for BatchInsert") + } + var r0 []string var r1 error if rf, ok := ret.Get(0).(func(context.Context, int) ([]string, error)); ok { @@ -45,6 +49,10 @@ func (_m *MockSignatureClient) BatchInsert(ctx context.Context, number int) ([]s func (_m *MockSignatureClient) Delete(ctx context.Context, publicKey string) error { ret := _m.Called(ctx, publicKey) + if len(ret) == 0 { + panic("no return value specified for Delete") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { r0 = rf(ctx, publicKey) @@ -59,6 +67,10 @@ func (_m *MockSignatureClient) Delete(ctx context.Context, publicKey string) err func (_m *MockSignatureClient) NetworkPassphrase() string { ret := _m.Called() + if len(ret) == 0 { + panic("no return value specified for NetworkPassphrase") + } + var r0 string if rf, ok := ret.Get(0).(func() string); ok { r0 = rf() @@ -80,6 +92,10 @@ func (_m *MockSignatureClient) SignFeeBumpStellarTransaction(ctx context.Context _ca = append(_ca, _va...) ret := _m.Called(_ca...) + if len(ret) == 0 { + panic("no return value specified for SignFeeBumpStellarTransaction") + } + var r0 *txnbuild.FeeBumpTransaction var r1 error if rf, ok := ret.Get(0).(func(context.Context, *txnbuild.FeeBumpTransaction, ...string) (*txnbuild.FeeBumpTransaction, error)); ok { @@ -113,6 +129,10 @@ func (_m *MockSignatureClient) SignStellarTransaction(ctx context.Context, stell _ca = append(_ca, _va...) ret := _m.Called(_ca...) + if len(ret) == 0 { + panic("no return value specified for SignStellarTransaction") + } + var r0 *txnbuild.Transaction var r1 error if rf, ok := ret.Get(0).(func(context.Context, *txnbuild.Transaction, ...string) (*txnbuild.Transaction, error)); ok { @@ -135,27 +155,12 @@ func (_m *MockSignatureClient) SignStellarTransaction(ctx context.Context, stell return r0, r1 } -// Type provides a mock function with given fields: -func (_m *MockSignatureClient) Type() string { - ret := _m.Called() - - var r0 string - if rf, ok := ret.Get(0).(func() string); ok { - r0 = rf() - } else { - r0 = ret.Get(0).(string) - } - - return r0 -} - -type mockConstructorTestingTNewMockSignatureClient interface { +// NewMockSignatureClient creates a new instance of MockSignatureClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockSignatureClient(t interface { mock.TestingT Cleanup(func()) -} - -// NewMockSignatureClient creates a new instance of MockSignatureClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewMockSignatureClient(t mockConstructorTestingTNewMockSignatureClient) *MockSignatureClient { +}) *MockSignatureClient { mock := &MockSignatureClient{} mock.Mock.Test(t) diff --git a/internal/transactionsubmission/engine/signing/mocks/signer_router.go b/internal/transactionsubmission/engine/signing/mocks/signer_router.go new file mode 100644 index 000000000..31cb141fa --- /dev/null +++ b/internal/transactionsubmission/engine/signing/mocks/signer_router.go @@ -0,0 +1,191 @@ +// Code generated by mockery v2.40.1. DO NOT EDIT. + +package mocks + +import ( + context "context" + + schema "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" + mock "github.com/stretchr/testify/mock" + + txnbuild "github.com/stellar/go/txnbuild" +) + +// MockSignerRouter is an autogenerated mock type for the SignerRouter type +type MockSignerRouter struct { + mock.Mock +} + +// BatchInsert provides a mock function with given fields: ctx, accountType, number +func (_m *MockSignerRouter) BatchInsert(ctx context.Context, accountType schema.AccountType, number int) ([]schema.TransactionAccount, error) { + ret := _m.Called(ctx, accountType, number) + + if len(ret) == 0 { + panic("no return value specified for BatchInsert") + } + + var r0 []schema.TransactionAccount + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, schema.AccountType, int) ([]schema.TransactionAccount, error)); ok { + return rf(ctx, accountType, number) + } + if rf, ok := ret.Get(0).(func(context.Context, schema.AccountType, int) []schema.TransactionAccount); ok { + r0 = rf(ctx, accountType, number) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]schema.TransactionAccount) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, schema.AccountType, int) error); ok { + r1 = rf(ctx, accountType, number) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Delete provides a mock function with given fields: ctx, stellarAccount +func (_m *MockSignerRouter) Delete(ctx context.Context, stellarAccount schema.TransactionAccount) error { + ret := _m.Called(ctx, stellarAccount) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, schema.TransactionAccount) error); ok { + r0 = rf(ctx, stellarAccount) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NetworkPassphrase provides a mock function with given fields: +func (_m *MockSignerRouter) NetworkPassphrase() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for NetworkPassphrase") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// SignFeeBumpStellarTransaction provides a mock function with given fields: ctx, feeBumpStellarTx, stellarAccounts +func (_m *MockSignerRouter) SignFeeBumpStellarTransaction(ctx context.Context, feeBumpStellarTx *txnbuild.FeeBumpTransaction, stellarAccounts ...schema.TransactionAccount) (*txnbuild.FeeBumpTransaction, error) { + _va := make([]interface{}, len(stellarAccounts)) + for _i := range stellarAccounts { + _va[_i] = stellarAccounts[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, feeBumpStellarTx) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for SignFeeBumpStellarTransaction") + } + + var r0 *txnbuild.FeeBumpTransaction + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *txnbuild.FeeBumpTransaction, ...schema.TransactionAccount) (*txnbuild.FeeBumpTransaction, error)); ok { + return rf(ctx, feeBumpStellarTx, stellarAccounts...) + } + if rf, ok := ret.Get(0).(func(context.Context, *txnbuild.FeeBumpTransaction, ...schema.TransactionAccount) *txnbuild.FeeBumpTransaction); ok { + r0 = rf(ctx, feeBumpStellarTx, stellarAccounts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*txnbuild.FeeBumpTransaction) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *txnbuild.FeeBumpTransaction, ...schema.TransactionAccount) error); ok { + r1 = rf(ctx, feeBumpStellarTx, stellarAccounts...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SignStellarTransaction provides a mock function with given fields: ctx, stellarTx, stellarAccounts +func (_m *MockSignerRouter) SignStellarTransaction(ctx context.Context, stellarTx *txnbuild.Transaction, stellarAccounts ...schema.TransactionAccount) (*txnbuild.Transaction, error) { + _va := make([]interface{}, len(stellarAccounts)) + for _i := range stellarAccounts { + _va[_i] = stellarAccounts[_i] + } + var _ca []interface{} + _ca = append(_ca, ctx, stellarTx) + _ca = append(_ca, _va...) + ret := _m.Called(_ca...) + + if len(ret) == 0 { + panic("no return value specified for SignStellarTransaction") + } + + var r0 *txnbuild.Transaction + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *txnbuild.Transaction, ...schema.TransactionAccount) (*txnbuild.Transaction, error)); ok { + return rf(ctx, stellarTx, stellarAccounts...) + } + if rf, ok := ret.Get(0).(func(context.Context, *txnbuild.Transaction, ...schema.TransactionAccount) *txnbuild.Transaction); ok { + r0 = rf(ctx, stellarTx, stellarAccounts...) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*txnbuild.Transaction) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *txnbuild.Transaction, ...schema.TransactionAccount) error); ok { + r1 = rf(ctx, stellarTx, stellarAccounts...) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SupportedAccountTypes provides a mock function with given fields: +func (_m *MockSignerRouter) SupportedAccountTypes() []schema.AccountType { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for SupportedAccountTypes") + } + + var r0 []schema.AccountType + if rf, ok := ret.Get(0).(func() []schema.AccountType); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]schema.AccountType) + } + } + + return r0 +} + +// NewMockSignerRouter creates a new instance of MockSignerRouter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockSignerRouter(t interface { + mock.TestingT + Cleanup(func()) +}) *MockSignerRouter { + mock := &MockSignerRouter{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/transactionsubmission/engine/signing/signature_client.go b/internal/transactionsubmission/engine/signing/signature_client.go index 85a8b29cf..e9dd61c9d 100644 --- a/internal/transactionsubmission/engine/signing/signature_client.go +++ b/internal/transactionsubmission/engine/signing/signature_client.go @@ -3,15 +3,13 @@ package signing import ( "context" "fmt" - "strings" + + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/go/txnbuild" - "golang.org/x/exp/maps" - "golang.org/x/exp/slices" "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/preconditions" - "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" ) @@ -24,71 +22,6 @@ type SignatureClient interface { SignFeeBumpStellarTransaction(ctx context.Context, feeBumpStellarTx *txnbuild.FeeBumpTransaction, stellarAccounts ...string) (signedFeeBumpStellarTx *txnbuild.FeeBumpTransaction, err error) BatchInsert(ctx context.Context, number int) (publicKeys []string, err error) Delete(ctx context.Context, publicKey string) error - Type() string -} - -type SignatureClientType string - -func (s SignatureClientType) DistributionAccountType() (schema.DistributionAccountType, error) { - switch strings.TrimSpace(strings.ToUpper(string(s))) { - case string(DistributionAccountEnvSignatureClientType): - return schema.DistributionAccountTypeEnvStellar, nil - case string(DistributionAccountDBSignatureClientType): - return schema.DistributionAccountTypeDBVaultStellar, nil - default: - return "", fmt.Errorf("invalid distribution account type %q", s) - } -} - -const ( - ChannelAccountDBSignatureClientType SignatureClientType = "CHANNEL_ACCOUNT_DB" - DistributionAccountEnvSignatureClientType SignatureClientType = "DISTRIBUTION_ACCOUNT_ENV" - DistributionAccountDBSignatureClientType SignatureClientType = "DISTRIBUTION_ACCOUNT_DB" - HostAccountEnvSignatureClientType SignatureClientType = "HOST_ACCOUNT_ENV" -) - -func AllSignatureClientTypes() []SignatureClientType { - return []SignatureClientType{ChannelAccountDBSignatureClientType, DistributionAccountEnvSignatureClientType, DistributionAccountDBSignatureClientType, HostAccountEnvSignatureClientType} -} - -func ParseSignatureClientType(sigClientType string) (SignatureClientType, error) { - sigClientTypeStrUpper := strings.ToUpper(sigClientType) - scType := SignatureClientType(sigClientTypeStrUpper) - - if slices.Contains(AllSignatureClientTypes(), scType) { - return scType, nil - } - - return "", fmt.Errorf("invalid signature client type %q", sigClientTypeStrUpper) -} - -func DistributionSignatureClientTypes() []SignatureClientType { - return maps.Keys(DistSigClientsDescription) -} - -var DistSigClientsDescription = map[SignatureClientType]string{ - DistributionAccountEnvSignatureClientType: "uses the the same distribution account for all tenants, as well as for the HOST, through the secret configured in DISTRIBUTION_SEED.", - DistributionAccountDBSignatureClientType: "uses the one different distribution account private key per tenant, and stores them in the database, encrypted with the DISTRIBUTION_ACCOUNT_ENCRYPTION_PASSPHRASE.", -} - -func DistSigClientsDescriptionStr() string { - var descriptions []string - for sigClientType, description := range DistSigClientsDescription { - descriptions = append(descriptions, fmt.Sprintf("%s: %s", sigClientType, description)) - } - - return strings.Join(descriptions, " ") -} - -func ParseSignatureClientDistributionType(sigClientType string) (SignatureClientType, error) { - sigClientTypeStrUpper := strings.ToUpper(sigClientType) - scType := SignatureClientType(sigClientTypeStrUpper) - - if slices.Contains(DistributionSignatureClientTypes(), scType) { - return scType, nil - } - - return "", fmt.Errorf("invalid signature client distribution type %q", sigClientTypeStrUpper) } type SignatureClientOptions struct { @@ -107,18 +40,19 @@ type SignatureClientOptions struct { // *AccountDB: DBConnectionPool db.DBConnectionPool LedgerNumberTracker preconditions.LedgerNumberTracker - Encrypter utils.PrivateKeyEncrypter // (optional) + Encrypter sdpUtils.PrivateKeyEncrypter // (optional) } -func NewSignatureClient(sigType SignatureClientType, opts SignatureClientOptions) (SignatureClient, error) { - switch sigType { - case DistributionAccountEnvSignatureClientType, HostAccountEnvSignatureClientType: - return NewDistributionAccountEnvSignatureClient(DistributionAccountEnvOptions{ +func NewSignatureClient(accType schema.AccountType, opts SignatureClientOptions) (SignatureClient, error) { + switch accType { + case schema.HostStellarEnv, schema.DistributionAccountStellarEnv: + return NewAccountEnvSignatureClient(AccountEnvOptions{ NetworkPassphrase: opts.NetworkPassphrase, DistributionPrivateKey: opts.DistributionPrivateKey, + AccountType: accType, }) - case ChannelAccountDBSignatureClientType: + case schema.ChannelAccountStellarDB: return NewChannelAccountDBSignatureClient(ChannelAccountDBSignatureClientOptions{ NetworkPassphrase: opts.NetworkPassphrase, DBConnectionPool: opts.DBConnectionPool, @@ -127,8 +61,8 @@ func NewSignatureClient(sigType SignatureClientType, opts SignatureClientOptions Encrypter: opts.Encrypter, }) - case DistributionAccountDBSignatureClientType: - return NewDistributionAccountDBSignatureClient(DistributionAccountDBSignatureClientOptions{ + case schema.DistributionAccountStellarDBVault: + return NewDistributionAccountDBVaultSignatureClient(DistributionAccountDBVaultSignatureClientOptions{ NetworkPassphrase: opts.NetworkPassphrase, DBConnectionPool: opts.DBConnectionPool, EncryptionPassphrase: opts.DistAccEncryptionPassphrase, @@ -136,6 +70,6 @@ func NewSignatureClient(sigType SignatureClientType, opts SignatureClientOptions }) default: - return nil, fmt.Errorf("invalid signature client type: %v", sigType) + return nil, fmt.Errorf("cannot find a Stellar signature client for accountType=%v", accType) } } diff --git a/internal/transactionsubmission/engine/signing/signature_client_test.go b/internal/transactionsubmission/engine/signing/signature_client_test.go index 80cb03ccf..19b23fb45 100644 --- a/internal/transactionsubmission/engine/signing/signature_client_test.go +++ b/internal/transactionsubmission/engine/signing/signature_client_test.go @@ -1,9 +1,10 @@ package signing import ( - "fmt" "testing" + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/go/keypair" "github.com/stellar/go/network" "github.com/stretchr/testify/assert" @@ -13,92 +14,9 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" preconditionsMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/preconditions/mocks" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" - "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" ) -func Test_SignatureClientType_DistributionAccountType(t *testing.T) { - testCases := []struct { - signatureClientType SignatureClientType - wantErrContains string - wantDistributionAccountType schema.DistributionAccountType - }{ - { - signatureClientType: ChannelAccountDBSignatureClientType, - wantErrContains: fmt.Sprintf("invalid distribution account type %q", ChannelAccountDBSignatureClientType), - }, - { - signatureClientType: DistributionAccountEnvSignatureClientType, - wantDistributionAccountType: schema.DistributionAccountTypeEnvStellar, - }, - { - signatureClientType: DistributionAccountDBSignatureClientType, - wantDistributionAccountType: schema.DistributionAccountTypeDBVaultStellar, - }, - { - signatureClientType: HostAccountEnvSignatureClientType, - wantErrContains: fmt.Sprintf("invalid distribution account type %q", HostAccountEnvSignatureClientType), - }, - } - - for _, tc := range testCases { - t.Run(string(tc.signatureClientType), func(t *testing.T) { - distAccType, err := tc.signatureClientType.DistributionAccountType() - if tc.wantErrContains != "" { - require.ErrorContains(t, err, tc.wantErrContains) - assert.Empty(t, distAccType) - } else { - require.NoError(t, err) - assert.Equal(t, tc.wantDistributionAccountType, distAccType) - } - }) - } -} - -func Test_ParseSignatureClientType(t *testing.T) { - testCases := []struct { - sigServiceTypeStr string - expectedSigClientType SignatureClientType - wantErr error - }{ - {wantErr: fmt.Errorf(`invalid signature client type ""`)}, - {sigServiceTypeStr: "INVALID", wantErr: fmt.Errorf(`invalid signature client type "INVALID"`)}, - {sigServiceTypeStr: "CHANNEL_ACCOUNT_DB", expectedSigClientType: ChannelAccountDBSignatureClientType}, - {sigServiceTypeStr: "DISTRIBUTION_ACCOUNT_ENV", expectedSigClientType: DistributionAccountEnvSignatureClientType}, - {sigServiceTypeStr: "HOST_ACCOUNT_ENV", expectedSigClientType: HostAccountEnvSignatureClientType}, - } - - for _, tc := range testCases { - t.Run("signatureServiceTypeType: "+tc.sigServiceTypeStr, func(t *testing.T) { - sigServiceType, err := ParseSignatureClientType(tc.sigServiceTypeStr) - assert.Equal(t, tc.expectedSigClientType, sigServiceType) - assert.Equal(t, tc.wantErr, err) - }) - } -} - -func Test_ParseSignatureClientDistributionType(t *testing.T) { - testCases := []struct { - sigServiceTypeStr string - expectedSigClientType SignatureClientType - wantErr error - }{ - {wantErr: fmt.Errorf(`invalid signature client distribution type ""`)}, - {sigServiceTypeStr: "INVALID", wantErr: fmt.Errorf(`invalid signature client distribution type "INVALID"`)}, - {sigServiceTypeStr: "CHANNEL_ACCOUNT_DB", wantErr: fmt.Errorf(`invalid signature client distribution type "CHANNEL_ACCOUNT_DB"`)}, - {sigServiceTypeStr: "HOST_ACCOUNT_ENV", wantErr: fmt.Errorf(`invalid signature client distribution type "HOST_ACCOUNT_ENV"`)}, - {sigServiceTypeStr: "DISTRIBUTION_ACCOUNT_ENV", expectedSigClientType: DistributionAccountEnvSignatureClientType}, - } - - for _, tc := range testCases { - t.Run("signatureServiceTypeType: "+tc.sigServiceTypeStr, func(t *testing.T) { - sigServiceType, err := ParseSignatureClientDistributionType(tc.sigServiceTypeStr) - assert.Equal(t, tc.expectedSigClientType, sigServiceType) - assert.Equal(t, tc.wantErr, err) - }) - } -} - func Test_NewSignatureClient(t *testing.T) { dbt := dbtest.Open(t) defer dbt.Close() @@ -112,20 +30,20 @@ func Test_NewSignatureClient(t *testing.T) { testCases := []struct { name string - sigType SignatureClientType + accType schema.AccountType opts SignatureClientOptions wantResult SignatureClient wantErrorMsg string }{ { name: "invalid signature client type", - sigType: SignatureClientType("INVALID"), + accType: schema.AccountType("INVALID"), opts: SignatureClientOptions{}, - wantErrorMsg: "invalid signature client type: INVALID", + wantErrorMsg: "cannot find a Stellar signature client for accountType=INVALID", }, { name: "🎉 successfully instantiate a ChannelAccountDB instance", - sigType: ChannelAccountDBSignatureClientType, + accType: schema.ChannelAccountStellarDB, opts: SignatureClientOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, @@ -135,7 +53,7 @@ func Test_NewSignatureClient(t *testing.T) { wantResult: &ChannelAccountDBSignatureClient{ chAccModel: store.NewChannelAccountModel(dbConnectionPool), dbConnectionPool: dbConnectionPool, - encrypter: &utils.DefaultPrivateKeyEncrypter{}, + encrypter: &sdpUtils.DefaultPrivateKeyEncrypter{}, encryptionPassphrase: encryptionPassphrase, ledgerNumberTracker: mLedgerNumberTracker, networkPassphrase: network.TestNetworkPassphrase, @@ -143,38 +61,53 @@ func Test_NewSignatureClient(t *testing.T) { }, { name: "🎉 successfully instantiate a DistributionAccountDB", - sigType: DistributionAccountDBSignatureClientType, + accType: schema.DistributionAccountStellarDBVault, opts: SignatureClientOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, DistAccEncryptionPassphrase: encryptionPassphrase, - Encrypter: &utils.PrivateKeyEncrypterMock{}, + Encrypter: &sdpUtils.PrivateKeyEncrypterMock{}, }, - wantResult: &DistributionAccountDBSignatureClient{ + wantResult: &DistributionAccountDBVaultSignatureClient{ dbVault: store.NewDBVaultModel(dbConnectionPool), - encrypter: &utils.PrivateKeyEncrypterMock{}, + encrypter: &sdpUtils.PrivateKeyEncrypterMock{}, encryptionPassphrase: encryptionPassphrase, networkPassphrase: network.TestNetworkPassphrase, }, }, { name: "🎉 successfully instantiate a Distribution Account ENV instance", - sigType: DistributionAccountEnvSignatureClientType, + accType: schema.DistributionAccountStellarEnv, + opts: SignatureClientOptions{ + NetworkPassphrase: network.TestNetworkPassphrase, + DistributionPrivateKey: distributionKP.Seed(), + }, + wantResult: &AccountEnvSignatureClient{ + networkPassphrase: network.TestNetworkPassphrase, + distributionAccount: distributionKP.Address(), + distributionKP: distributionKP, + accountType: schema.DistributionAccountStellarEnv, + }, + }, + { + name: "🎉 successfully instantiate a Distribution Account ENV instance (HOST)", + accType: schema.HostStellarEnv, opts: SignatureClientOptions{ NetworkPassphrase: network.TestNetworkPassphrase, DistributionPrivateKey: distributionKP.Seed(), }, - wantResult: &DistributionAccountEnvSignatureClient{ + wantResult: &AccountEnvSignatureClient{ networkPassphrase: network.TestNetworkPassphrase, distributionAccount: distributionKP.Address(), distributionKP: distributionKP, + accountType: schema.HostStellarEnv, }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - sigService, err := NewSignatureClient(tc.sigType, tc.opts) + sigService, err := NewSignatureClient(tc.accType, tc.opts) if tc.wantErrorMsg != "" { assert.EqualError(t, err, tc.wantErrorMsg) } else { diff --git a/internal/transactionsubmission/engine/signing/signature_service.go b/internal/transactionsubmission/engine/signing/signature_service.go index eb4cfd6d1..dde3ae384 100644 --- a/internal/transactionsubmission/engine/signing/signature_service.go +++ b/internal/transactionsubmission/engine/signing/signature_service.go @@ -3,38 +3,29 @@ package signing import ( "fmt" - "golang.org/x/exp/slices" + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/preconditions" - "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" ) type SignatureService struct { - ChAccountSigner SignatureClient - DistAccountSigner SignatureClient - HostAccountSigner SignatureClient + SignerRouter DistributionAccountResolver networkPassphrase string } var _ DistributionAccountResolver = (*SignatureService)(nil) -func (s *SignatureService) Validate() error { - if s.ChAccountSigner == nil { - return fmt.Errorf("channel account signer cannot be nil") - } - - if s.DistAccountSigner == nil { - return fmt.Errorf("distribution account signer cannot be nil") - } +var _ SignerRouter = (*SignatureService)(nil) - if s.HostAccountSigner == nil { - return fmt.Errorf("host account signer cannot be nil") +func (s *SignatureService) Validate() error { + if s.SignerRouter == nil { + return fmt.Errorf("signer router cannot be nil") } - if s.ChAccountSigner.NetworkPassphrase() != s.DistAccountSigner.NetworkPassphrase() || s.DistAccountSigner.NetworkPassphrase() != s.HostAccountSigner.NetworkPassphrase() { - return fmt.Errorf("network passphrase of all signers should be the same") + if len(s.SupportedAccountTypes()) == 0 { + return fmt.Errorf("signer router must support at least one account type") } if s.DistributionAccountResolver == nil { @@ -48,9 +39,6 @@ type SignatureServiceOptions struct { // Shared: NetworkPassphrase string - // DistributionAccount: - DistributionSignerType SignatureClientType - // DistributionAccountEnv: DistributionPrivateKey string @@ -63,7 +51,7 @@ type SignatureServiceOptions struct { // *AccountDB: DBConnectionPool db.DBConnectionPool LedgerNumberTracker preconditions.LedgerNumberTracker - Encrypter utils.PrivateKeyEncrypter + Encrypter sdpUtils.PrivateKeyEncrypter // DistributionAccountResolver DistributionAccountResolver @@ -71,17 +59,13 @@ type SignatureServiceOptions struct { // NewSignatureService creates a new signature service instance, given the distribution signer type and the options. func NewSignatureService(opts SignatureServiceOptions) (SignatureService, error) { - distSignerType := opts.DistributionSignerType - if !slices.Contains(DistributionSignatureClientTypes(), distSignerType) { - return SignatureService{}, fmt.Errorf("invalid distribution signer type %q", distSignerType) - } - if opts.DistributionAccountResolver == nil { return SignatureService{}, fmt.Errorf("distribution account resolver cannot be nil") } - sigClientOpts := SignatureClientOptions{ + sigRouterOpts := SignatureRouterOptions{ NetworkPassphrase: opts.NetworkPassphrase, + HostPrivateKey: opts.DistributionPrivateKey, // TODO: pass it from the outside DistributionPrivateKey: opts.DistributionPrivateKey, DBConnectionPool: opts.DBConnectionPool, ChAccEncryptionPassphrase: opts.ChAccEncryptionPassphrase, @@ -90,25 +74,13 @@ func NewSignatureService(opts SignatureServiceOptions) (SignatureService, error) LedgerNumberTracker: opts.LedgerNumberTracker, } - chAccountSigner, err := NewSignatureClient(ChannelAccountDBSignatureClientType, sigClientOpts) - if err != nil { - return SignatureService{}, fmt.Errorf("creating a new channel account signature client: %w", err) - } - - distAccSigner, err := NewSignatureClient(distSignerType, sigClientOpts) - if err != nil { - return SignatureService{}, fmt.Errorf("creating a new distribution account signature client with type %v: %w", distSignerType, err) - } - - hostAccSigner, err := NewSignatureClient(HostAccountEnvSignatureClientType, sigClientOpts) + sigRouter, err := NewSignerRouter(sigRouterOpts) if err != nil { - return SignatureService{}, fmt.Errorf("creating a new host account signature client: %w", err) + return SignatureService{}, fmt.Errorf("creating a new signer router: %w", err) } return SignatureService{ - ChAccountSigner: chAccountSigner, - DistAccountSigner: distAccSigner, - HostAccountSigner: hostAccSigner, + SignerRouter: sigRouter, DistributionAccountResolver: opts.DistributionAccountResolver, networkPassphrase: opts.NetworkPassphrase, }, nil diff --git a/internal/transactionsubmission/engine/signing/signature_service_mock.go b/internal/transactionsubmission/engine/signing/signature_service_mock.go index 25010b70d..690927fd8 100644 --- a/internal/transactionsubmission/engine/signing/signature_service_mock.go +++ b/internal/transactionsubmission/engine/signing/signature_service_mock.go @@ -5,6 +5,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" ) type mockConstructorTestingTNewMockSignatureService interface { @@ -16,29 +17,28 @@ type mockConstructorTestingTNewMockSignatureService interface { // NewMockSignatureService is a constructor for the SignatureService with mock clients. func NewMockSignatureService(t mockConstructorTestingTNewMockSignatureService) ( sigService SignatureService, - chAccSigClient *mocks.MockSignatureClient, - distAccSigClient *mocks.MockSignatureClient, - hostAccSigClient *mocks.MockSignatureClient, + signerRouter *mocks.MockSignerRouter, distAccResolver *mocks.MockDistributionAccountResolver, ) { t.Helper() - chAccSigClient = mocks.NewMockSignatureClient(t) - chAccSigClient.On("NetworkPassphrase").Return(network.TestNetworkPassphrase).Maybe() + signerRouter = mocks.NewMockSignerRouter(t) + signerRouter.On("NetworkPassphrase").Return(network.TestNetworkPassphrase).Maybe() - distAccSigClient = mocks.NewMockSignatureClient(t) - distAccSigClient.On("NetworkPassphrase").Return(network.TestNetworkPassphrase).Maybe() - - hostAccSigClient = mocks.NewMockSignatureClient(t) - hostAccSigClient.On("NetworkPassphrase").Return(network.TestNetworkPassphrase).Maybe() + signerRouter.On("SupportedAccountTypes"). + Return([]schema.AccountType{ + schema.HostStellarEnv, + schema.ChannelAccountStellarDB, + schema.DistributionAccountStellarDBVault, + schema.DistributionAccountStellarEnv, + }). + Maybe() distAccResolver = mocks.NewMockDistributionAccountResolver(t) sigService = SignatureService{ - ChAccountSigner: chAccSigClient, - DistAccountSigner: distAccSigClient, - HostAccountSigner: hostAccSigClient, + SignerRouter: signerRouter, DistributionAccountResolver: distAccResolver, } - return sigService, chAccSigClient, distAccSigClient, hostAccSigClient, distAccResolver + return sigService, signerRouter, distAccResolver } diff --git a/internal/transactionsubmission/engine/signing/signature_service_test.go b/internal/transactionsubmission/engine/signing/signature_service_test.go index e02144ccb..6d77920b7 100644 --- a/internal/transactionsubmission/engine/signing/signature_service_test.go +++ b/internal/transactionsubmission/engine/signing/signature_service_test.go @@ -1,9 +1,10 @@ package signing import ( - "fmt" "testing" + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/go/keypair" "github.com/stellar/go/network" "github.com/stretchr/testify/require" @@ -13,73 +14,73 @@ import ( preconditionsMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/preconditions/mocks" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" - "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) func Test_SignatureService_Validate(t *testing.T) { - mSignerClient := mocks.NewMockSignatureClient(t) - mSignerClient.On("NetworkPassphrase").Return(network.TestNetworkPassphrase) - mSignerClientPubnet := mocks.NewMockSignatureClient(t) - mSignerClientPubnet.On("NetworkPassphrase").Return(network.PublicNetworkPassphrase) - testCases := []struct { name string - sigService SignatureService + getSigServiceFn func(mSignerRouter *mocks.MockSignerRouter, mDistAccResolver *mocks.MockDistributionAccountResolver) SignatureService wantErrContains string }{ { - name: "ChAccountSigner cannot be nil", - sigService: SignatureService{}, - wantErrContains: "channel account signer cannot be nil", - }, - { - name: "DistAccountSigner cannot be nil", - sigService: SignatureService{ - ChAccountSigner: mSignerClient, - }, - wantErrContains: "distribution account signer cannot be nil", - }, - { - name: "HostAccountSigner cannot be nil", - sigService: SignatureService{ - ChAccountSigner: mSignerClient, - DistAccountSigner: mSignerClient, + name: "signerRouter cannot be nil", + getSigServiceFn: func(_ *mocks.MockSignerRouter, _ *mocks.MockDistributionAccountResolver) SignatureService { + return SignatureService{} }, - wantErrContains: "host account signer cannot be nil", + wantErrContains: "signer router cannot be nil", }, { - name: "Network passphrases needs to be consistent", - sigService: SignatureService{ - ChAccountSigner: mSignerClient, - DistAccountSigner: mSignerClient, - HostAccountSigner: mSignerClientPubnet, + name: "signerRouter cannot be empty", + getSigServiceFn: func(mSignerRouter *mocks.MockSignerRouter, _ *mocks.MockDistributionAccountResolver) SignatureService { + mSignerRouter. + On("SupportedAccountTypes"). + Return([]schema.AccountType{}). + Once() + return SignatureService{ + SignerRouter: mSignerRouter, + } }, - wantErrContains: "network passphrase of all signers should be the same", + wantErrContains: "signer router must support at least one account type", }, { name: "DistributionAccountResolver cannot be nil", - sigService: SignatureService{ - ChAccountSigner: mSignerClient, - DistAccountSigner: mSignerClient, - HostAccountSigner: mSignerClient, + getSigServiceFn: func(mSignerRouter *mocks.MockSignerRouter, _ *mocks.MockDistributionAccountResolver) SignatureService { + mSignerRouter. + On("SupportedAccountTypes"). + Return([]schema.AccountType{schema.DistributionAccountStellarEnv}). + Once() + return SignatureService{ + SignerRouter: mSignerRouter, + } }, wantErrContains: "distribution account resolver cannot be nil", }, { name: "🎉 successfully validates object", - sigService: SignatureService{ - ChAccountSigner: mSignerClient, - DistAccountSigner: mSignerClient, - HostAccountSigner: mSignerClient, - DistributionAccountResolver: mocks.NewMockDistributionAccountResolver(t), + getSigServiceFn: func(mSignerRouter *mocks.MockSignerRouter, mDistAccResolver *mocks.MockDistributionAccountResolver) SignatureService { + mSignerRouter. + On("SupportedAccountTypes"). + Return([]schema.AccountType{schema.DistributionAccountStellarEnv}). + Once() + return SignatureService{ + SignerRouter: mSignerRouter, + DistributionAccountResolver: mDistAccResolver, + } }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - err := tc.sigService.Validate() + // prepareMocks + mSignerRouter := mocks.NewMockSignerRouter(t) + mDistAccResolver := mocks.NewMockDistributionAccountResolver(t) + + sigService := tc.getSigServiceFn(mSignerRouter, mDistAccResolver) + + err := sigService.Validate() if tc.wantErrContains == "" { require.NoError(t, err) } else { @@ -107,19 +108,33 @@ func Test_NewSignatureService(t *testing.T) { encryptionPassphrase: chAccEncryptionPassphrase, ledgerNumberTracker: mLedgerNumberTracker, chAccModel: store.NewChannelAccountModel(dbConnectionPool), - encrypter: &utils.DefaultPrivateKeyEncrypter{}, + encrypter: &sdpUtils.DefaultPrivateKeyEncrypter{}, + } + wantDistAccountEnvSigner := &AccountEnvSignatureClient{ + networkPassphrase: network.TestNetworkPassphrase, + distributionAccount: distributionKP.Address(), + distributionKP: distributionKP, + accountType: schema.DistributionAccountStellarEnv, } - wantDistAccountEnvSigner := &DistributionAccountEnvSignatureClient{ + wantHostAccountEnvSigner := &AccountEnvSignatureClient{ networkPassphrase: network.TestNetworkPassphrase, distributionAccount: distributionKP.Address(), distributionKP: distributionKP, + accountType: schema.HostStellarEnv, } - wantDistAccountDBSigner := &DistributionAccountDBSignatureClient{ + wantDistAccountDBSigner := &DistributionAccountDBVaultSignatureClient{ networkPassphrase: network.TestNetworkPassphrase, encryptionPassphrase: distAccEncryptionPassphrase, dbVault: store.NewDBVaultModel(dbConnectionPool), - encrypter: &utils.DefaultPrivateKeyEncrypter{}, + encrypter: &sdpUtils.DefaultPrivateKeyEncrypter{}, } + wantSigRouterStrategies := map[schema.AccountType]SignatureClient{ + schema.HostStellarEnv: wantHostAccountEnvSigner, + schema.ChannelAccountStellarDB: wantChAccountSigner, + schema.DistributionAccountStellarEnv: wantDistAccountEnvSigner, + schema.DistributionAccountStellarDBVault: wantDistAccountDBSigner, + } + wantDistAccountResolver := &DistributionAccountResolverImpl{ tenantManager: tenant.NewManager(tenant.WithDatabase(dbConnectionPool)), hostDistributionAccountPubKey: distributionKP.Address(), @@ -132,80 +147,34 @@ func Test_NewSignatureService(t *testing.T) { wantSigService SignatureService }{ { - name: "returns an error if the distribution signer type is invalid", - opts: SignatureServiceOptions{DistributionSignerType: SignatureClientType("invalid")}, - wantErrContains: `invalid distribution signer type "invalid"`, - }, - { - name: "returns an error if the distribution account resolver is nil", - opts: SignatureServiceOptions{ - DistributionSignerType: DistributionAccountEnvSignatureClientType, - }, + name: "returns an error if the distribution account resolver is nil", + opts: SignatureServiceOptions{}, wantErrContains: "distribution account resolver cannot be nil", }, { - name: "returns an error if the options are invalid for the channel account signer", - opts: SignatureServiceOptions{ - DistributionSignerType: DistributionAccountEnvSignatureClientType, - DistributionAccountResolver: wantDistAccountResolver, - }, - wantErrContains: "creating a new channel account signature client:", - }, - { - name: "returns an error if the options are invalid for the distribution account signer (DISTRIBUTION_ACCOUNT_ENV)", - opts: SignatureServiceOptions{ - DistributionSignerType: DistributionAccountEnvSignatureClientType, - NetworkPassphrase: network.TestNetworkPassphrase, - DBConnectionPool: dbConnectionPool, - ChAccEncryptionPassphrase: chAccEncryptionPassphrase, - LedgerNumberTracker: mLedgerNumberTracker, - - DistributionAccountResolver: wantDistAccountResolver, - }, - wantErrContains: fmt.Sprintf("creating a new distribution account signature client with type %v", DistributionAccountEnvSignatureClientType), - }, - { - name: "🎉 successfully instantiate new signature service (DISTRIBUTION_ACCOUNT_ENV)", + name: "returns an error if the options are invalid for the NewSignerRouter method", opts: SignatureServiceOptions{ - DistributionSignerType: DistributionAccountEnvSignatureClientType, - NetworkPassphrase: network.TestNetworkPassphrase, - DBConnectionPool: dbConnectionPool, - ChAccEncryptionPassphrase: chAccEncryptionPassphrase, - LedgerNumberTracker: mLedgerNumberTracker, - - DistributionPrivateKey: distributionKP.Seed(), - - DistributionAccountResolver: wantDistAccountResolver, - }, - - wantSigService: SignatureService{ - ChAccountSigner: wantChAccountSigner, - DistAccountSigner: wantDistAccountEnvSigner, - HostAccountSigner: wantDistAccountEnvSigner, DistributionAccountResolver: wantDistAccountResolver, - networkPassphrase: network.TestNetworkPassphrase, }, + wantErrContains: "creating a new signer router", }, { - name: "🎉 successfully instantiate new signature service (DISTRIBUTION_ACCOUNT_DB)", + name: "🎉 successfully instantiate new signature service", opts: SignatureServiceOptions{ - DistributionSignerType: DistributionAccountDBSignatureClientType, - NetworkPassphrase: network.TestNetworkPassphrase, - DBConnectionPool: dbConnectionPool, - ChAccEncryptionPassphrase: chAccEncryptionPassphrase, - LedgerNumberTracker: mLedgerNumberTracker, - + NetworkPassphrase: network.TestNetworkPassphrase, + DBConnectionPool: dbConnectionPool, + ChAccEncryptionPassphrase: chAccEncryptionPassphrase, + LedgerNumberTracker: mLedgerNumberTracker, DistAccEncryptionPassphrase: distAccEncryptionPassphrase, - - DistributionPrivateKey: distributionKP.Seed(), - + DistributionPrivateKey: distributionKP.Seed(), DistributionAccountResolver: wantDistAccountResolver, }, wantSigService: SignatureService{ - ChAccountSigner: wantChAccountSigner, - DistAccountSigner: wantDistAccountDBSigner, - HostAccountSigner: wantDistAccountEnvSigner, + SignerRouter: &SignerRouterImpl{ + strategies: wantSigRouterStrategies, + networkPassphrase: network.TestNetworkPassphrase, + }, DistributionAccountResolver: wantDistAccountResolver, networkPassphrase: network.TestNetworkPassphrase, }, diff --git a/internal/transactionsubmission/engine/signing/signer_router.go b/internal/transactionsubmission/engine/signing/signer_router.go new file mode 100644 index 000000000..a83f511d1 --- /dev/null +++ b/internal/transactionsubmission/engine/signing/signer_router.go @@ -0,0 +1,272 @@ +package signing + +import ( + "context" + "errors" + "fmt" + "sort" + + "github.com/stellar/go/txnbuild" + "golang.org/x/exp/maps" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/preconditions" + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" +) + +//go:generate mockery --name=SignerRouter --case=underscore --structname=MockSignerRouter +type SignerRouter interface { + NetworkPassphrase() string + SupportedAccountTypes() []schema.AccountType + SignStellarTransaction(ctx context.Context, stellarTx *txnbuild.Transaction, stellarAccounts ...schema.TransactionAccount) (signedStellarTx *txnbuild.Transaction, err error) + SignFeeBumpStellarTransaction(ctx context.Context, feeBumpStellarTx *txnbuild.FeeBumpTransaction, stellarAccounts ...schema.TransactionAccount) (signedFeeBumpStellarTx *txnbuild.FeeBumpTransaction, err error) + BatchInsert(ctx context.Context, accountType schema.AccountType, number int) (stellarAccounts []schema.TransactionAccount, err error) + Delete(ctx context.Context, stellarAccount schema.TransactionAccount) error +} + +var _ SignerRouter = (*SignerRouterImpl)(nil) + +type SignerRouterImpl struct { + strategies map[schema.AccountType]SignatureClient + networkPassphrase string +} + +func NewSignerRouterImpl(network string, strategies map[schema.AccountType]SignatureClient) SignerRouterImpl { + return SignerRouterImpl{ + networkPassphrase: network, + strategies: strategies, + } +} + +type SignatureRouterOptions struct { + // Shared for ALL signers: + NetworkPassphrase string + + // HOST.STELLAR.ENV: + HostPrivateKey string + + // DISTRIBUTION_ACCOUNT.STELLAR.ENV: + DistributionPrivateKey string + + // DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT: + DistAccEncryptionPassphrase string + + // CHANNEL_ACCOUNT.STELLAR.DB: + ChAccEncryptionPassphrase string + LedgerNumberTracker preconditions.LedgerNumberTracker + + // *.STELLAR.DB_VAULT: + DBConnectionPool db.DBConnectionPool + Encrypter sdpUtils.PrivateKeyEncrypter // (optional) +} + +func NewSignerRouter(opts SignatureRouterOptions, accountTypes ...schema.AccountType) (SignerRouter, error) { + if len(accountTypes) == 0 { + accountTypes = []schema.AccountType{ + schema.HostStellarEnv, + schema.ChannelAccountStellarDB, + schema.DistributionAccountStellarEnv, + schema.DistributionAccountStellarDBVault, + } + } + + router := map[schema.AccountType]SignatureClient{} + for _, accType := range accountTypes { + var newSigClient SignatureClient + var err error + + switch accType { + case schema.HostStellarEnv: + newSigClient, err = NewAccountEnvSignatureClient(AccountEnvOptions{ + NetworkPassphrase: opts.NetworkPassphrase, + DistributionPrivateKey: opts.HostPrivateKey, + AccountType: accType, + }) + + case schema.ChannelAccountStellarDB: + newSigClient, err = NewChannelAccountDBSignatureClient(ChannelAccountDBSignatureClientOptions{ + NetworkPassphrase: opts.NetworkPassphrase, + DBConnectionPool: opts.DBConnectionPool, + EncryptionPassphrase: opts.ChAccEncryptionPassphrase, + LedgerNumberTracker: opts.LedgerNumberTracker, + Encrypter: opts.Encrypter, + }) + + case schema.DistributionAccountStellarEnv: + newSigClient, err = NewAccountEnvSignatureClient(AccountEnvOptions{ + NetworkPassphrase: opts.NetworkPassphrase, + DistributionPrivateKey: opts.DistributionPrivateKey, + AccountType: accType, + }) + + case schema.DistributionAccountStellarDBVault: + newSigClient, err = NewDistributionAccountDBVaultSignatureClient(DistributionAccountDBVaultSignatureClientOptions{ + NetworkPassphrase: opts.NetworkPassphrase, + DBConnectionPool: opts.DBConnectionPool, + EncryptionPassphrase: opts.DistAccEncryptionPassphrase, + Encrypter: opts.Encrypter, + }) + + default: + return nil, fmt.Errorf("cannot find a Stellar signature client for accountType=%v", accType) + } + + if err != nil { + return nil, fmt.Errorf("creating a new %q signature client: %w", accType, err) + } + + router[accType] = newSigClient + } + + return &SignerRouterImpl{ + strategies: router, + networkPassphrase: opts.NetworkPassphrase, + }, nil +} + +func (r *SignerRouterImpl) RouteSigner(distAcctountType schema.AccountType) (SignatureClient, error) { + sigClient, ok := r.strategies[distAcctountType] + if !ok { + return nil, fmt.Errorf("type %q is not supported by SignerRouter", distAcctountType) + } + + return sigClient, nil +} + +func (r *SignerRouterImpl) SignStellarTransaction( + ctx context.Context, + stellarTx *txnbuild.Transaction, + accounts ...schema.TransactionAccount, +) (signedStellarTx *txnbuild.Transaction, err error) { + if len(accounts) == 0 { + return nil, errors.New("no accounts provided to sign the transaction") + } + + // Get all signer types: + sigTypes := map[schema.AccountType][]string{} + for _, account := range accounts { + sigTypes[account.Type] = append(sigTypes[account.Type], account.Address) + } + + // Sort the types to ensure deterministic signing order: + sortedTypes := []schema.AccountType{} + for sigType := range sigTypes { + sortedTypes = append(sortedTypes, sigType) + } + sort.Slice(sortedTypes, func(i, j int) bool { + return sortedTypes[i] < sortedTypes[j] + }) + + signedStellarTx = stellarTx + for _, sigType := range sortedTypes { + publicKeys := sigTypes[sigType] + sigClient, err := r.RouteSigner(sigType) + if err != nil { + return nil, fmt.Errorf("routing signer: %w", err) + } + + signedStellarTx, err = sigClient.SignStellarTransaction(ctx, signedStellarTx, publicKeys...) + if err != nil { + return nil, fmt.Errorf("signing stellar transaction for strategy=%s: %w", sigType, err) + } + } + + return signedStellarTx, nil +} + +func (r *SignerRouterImpl) SignFeeBumpStellarTransaction( + ctx context.Context, + feeBumpStellarTx *txnbuild.FeeBumpTransaction, + accounts ...schema.TransactionAccount, +) (signedFeeBumpStellarTx *txnbuild.FeeBumpTransaction, err error) { + if len(accounts) == 0 { + return nil, errors.New("no accounts provided to sign the transaction") + } + + // Get all signer types: + sigTypes := map[schema.AccountType][]string{} + for _, account := range accounts { + sigTypes[account.Type] = append(sigTypes[account.Type], account.Address) + } + + // Sort the types to ensure deterministic signing order: + sortedTypes := []schema.AccountType{} + for sigType := range sigTypes { + sortedTypes = append(sortedTypes, sigType) + } + sort.Slice(sortedTypes, func(i, j int) bool { + return sortedTypes[i] < sortedTypes[j] + }) + + signedFeeBumpStellarTx = feeBumpStellarTx + for _, sigType := range sortedTypes { + publicKeys := sigTypes[sigType] + sigClient, err := r.RouteSigner(sigType) + if err != nil { + return nil, fmt.Errorf("routing signer: %w", err) + } + + signedFeeBumpStellarTx, err = sigClient.SignFeeBumpStellarTransaction(ctx, signedFeeBumpStellarTx, publicKeys...) + if err != nil { + return nil, fmt.Errorf("signing stellar fee bump transaction for strategy=%s: %w", sigType, err) + } + } + + return signedFeeBumpStellarTx, nil +} + +func (r *SignerRouterImpl) BatchInsert( + ctx context.Context, + accountType schema.AccountType, + number int, +) (stellarAccounts []schema.TransactionAccount, err error) { + if number < 1 { + return nil, errors.New("number of accounts to insert must be greater than 0") + } + + sigClient, err := r.RouteSigner(accountType) + if err != nil { + return nil, fmt.Errorf("routing signer: %w", err) + } + + publicKeys, err := sigClient.BatchInsert(ctx, number) + if err != nil && !(errors.Is(err, ErrUnsupportedCommand) && len(publicKeys) > 0) { + return nil, fmt.Errorf("batch inserting accounts for strategy=%s: %w", accountType, err) + } + + for _, publicKey := range publicKeys { + stellarAccounts = append(stellarAccounts, schema.TransactionAccount{ + Type: accountType, + Address: publicKey, + Status: schema.AccountStatusActive, + }) + } + + return stellarAccounts, err +} + +func (r *SignerRouterImpl) Delete( + ctx context.Context, + account schema.TransactionAccount, +) error { + sigClient, err := r.RouteSigner(account.Type) + if err != nil { + return fmt.Errorf("routing signer: %w", err) + } + + err = sigClient.Delete(ctx, account.Address) + if err != nil { + return fmt.Errorf("deleting account=%v for strategy=%s: %w", account, account.Type, err) + } + + return nil +} + +func (r *SignerRouterImpl) NetworkPassphrase() string { + return r.networkPassphrase +} + +func (r *SignerRouterImpl) SupportedAccountTypes() []schema.AccountType { + return maps.Keys(r.strategies) +} diff --git a/internal/transactionsubmission/engine/signing/signer_router_test.go b/internal/transactionsubmission/engine/signing/signer_router_test.go new file mode 100644 index 000000000..f897c5aec --- /dev/null +++ b/internal/transactionsubmission/engine/signing/signer_router_test.go @@ -0,0 +1,959 @@ +package signing + +import ( + "context" + "errors" + "fmt" + "testing" + + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + + "github.com/stellar/go/keypair" + "github.com/stellar/go/network" + "github.com/stellar/go/txnbuild" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" + preconditionsMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/preconditions/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" +) + +func Test_NewSignerRouter(t *testing.T) { + dbt := dbtest.OpenWithoutMigrations(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + // Create valid SignatureRouterOptions + networkPassphrase := network.TestNetworkPassphrase + chAccEncryptionPassphrase := keypair.MustRandom().Seed() + distAccEncryptionPassphrase := keypair.MustRandom().Seed() + distributionKP := keypair.MustRandom() + hostKP := keypair.MustRandom() + mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) + validOptions := SignatureRouterOptions{ + NetworkPassphrase: networkPassphrase, + DBConnectionPool: dbConnectionPool, + ChAccEncryptionPassphrase: chAccEncryptionPassphrase, + LedgerNumberTracker: mLedgerNumberTracker, + DistAccEncryptionPassphrase: distAccEncryptionPassphrase, + DistributionPrivateKey: distributionKP.Seed(), + HostPrivateKey: hostKP.Seed(), + Encrypter: &sdpUtils.DefaultPrivateKeyEncrypter{}, + } + + // Create valid SignerClients: + wantHostAccStellarEnvSigner := &AccountEnvSignatureClient{ + networkPassphrase: networkPassphrase, + distributionAccount: hostKP.Address(), + distributionKP: hostKP, + accountType: schema.HostStellarEnv, + } + wantChAccStellarDBSigner := &ChannelAccountDBSignatureClient{ + networkPassphrase: networkPassphrase, + dbConnectionPool: dbConnectionPool, + encryptionPassphrase: chAccEncryptionPassphrase, + ledgerNumberTracker: mLedgerNumberTracker, + chAccModel: store.NewChannelAccountModel(dbConnectionPool), + encrypter: &sdpUtils.DefaultPrivateKeyEncrypter{}, + } + wantDistAccStelarEnvSigner := &AccountEnvSignatureClient{ + networkPassphrase: networkPassphrase, + distributionAccount: distributionKP.Address(), + distributionKP: distributionKP, + accountType: schema.DistributionAccountStellarEnv, + } + wantDistAccStellarDBVaultSigner := &DistributionAccountDBVaultSignatureClient{ + networkPassphrase: networkPassphrase, + encryptionPassphrase: distAccEncryptionPassphrase, + dbVault: store.NewDBVaultModel(dbConnectionPool), + encrypter: &sdpUtils.DefaultPrivateKeyEncrypter{}, + } + + testCases := []struct { + name string + opts SignatureRouterOptions + accountTypes []schema.AccountType + wantErrContains string + wantSignerRouter SignerRouter + }{ + { + name: "error when HOST.STELLAR.ENV fails to be instantiated", + accountTypes: []schema.AccountType{schema.HostStellarEnv}, + opts: SignatureRouterOptions{ + NetworkPassphrase: networkPassphrase, + }, + wantErrContains: `creating a new "HOST.STELLAR.ENV" signature client`, + }, + { + name: "error when CHANNEL_ACCOUNT.STELLAR.DB fails to be instantiated", + accountTypes: []schema.AccountType{schema.ChannelAccountStellarDB}, + opts: SignatureRouterOptions{ + NetworkPassphrase: networkPassphrase, + }, + wantErrContains: `creating a new "CHANNEL_ACCOUNT.STELLAR.DB" signature client`, + }, + { + name: "error when DISTRIBUTION_ACCOUNT.STELLAR.ENV fails to be instantiated", + accountTypes: []schema.AccountType{schema.DistributionAccountStellarEnv}, + opts: SignatureRouterOptions{ + NetworkPassphrase: networkPassphrase, + }, + wantErrContains: `creating a new "DISTRIBUTION_ACCOUNT.STELLAR.ENV" signature client`, + }, + { + name: "error when DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT fails to be instantiated", + accountTypes: []schema.AccountType{schema.DistributionAccountStellarDBVault}, + opts: SignatureRouterOptions{ + NetworkPassphrase: networkPassphrase, + }, + wantErrContains: `creating a new "DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT" signature client`, + }, + { + name: "error when an invalid account type is passed", + accountTypes: []schema.AccountType{"INVALID"}, + wantErrContains: "cannot find a Stellar signature client for accountType=INVALID", + }, + { + name: "🎉 successfully instantiate new signature router with accountTypes=[HOST.STELLAR.ENV]", + accountTypes: []schema.AccountType{schema.HostStellarEnv}, + opts: validOptions, + wantSignerRouter: &SignerRouterImpl{ + strategies: map[schema.AccountType]SignatureClient{ + schema.HostStellarEnv: wantHostAccStellarEnvSigner, + }, + networkPassphrase: networkPassphrase, + }, + }, + { + name: "🎉 successfully instantiate new signature router with accountTypes=[CHANNEL_ACCOUNT.STELLAR.DB]", + accountTypes: []schema.AccountType{schema.ChannelAccountStellarDB}, + opts: validOptions, + wantSignerRouter: &SignerRouterImpl{ + strategies: map[schema.AccountType]SignatureClient{ + schema.ChannelAccountStellarDB: wantChAccStellarDBSigner, + }, + networkPassphrase: networkPassphrase, + }, + }, + { + name: "🎉 successfully instantiate new signature router with accountTypes=[DISTRIBUTION_ACCOUNT.STELLAR.ENV]", + accountTypes: []schema.AccountType{schema.DistributionAccountStellarEnv}, + opts: validOptions, + wantSignerRouter: &SignerRouterImpl{ + strategies: map[schema.AccountType]SignatureClient{ + schema.DistributionAccountStellarEnv: wantDistAccStelarEnvSigner, + }, + networkPassphrase: networkPassphrase, + }, + }, + { + name: "🎉 successfully instantiate new signature router with accountTypes=[DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT]", + accountTypes: []schema.AccountType{schema.DistributionAccountStellarDBVault}, + opts: validOptions, + wantSignerRouter: &SignerRouterImpl{ + strategies: map[schema.AccountType]SignatureClient{ + schema.DistributionAccountStellarDBVault: wantDistAccStellarDBVaultSigner, + }, + networkPassphrase: networkPassphrase, + }, + }, + { + name: "🎉 successfully instantiate new signature router with ALL types (non-empty accountTypes parameter)", + accountTypes: []schema.AccountType{ + schema.HostStellarEnv, + schema.ChannelAccountStellarDB, + schema.DistributionAccountStellarEnv, + schema.DistributionAccountStellarDBVault, + }, + opts: validOptions, + wantSignerRouter: &SignerRouterImpl{ + strategies: map[schema.AccountType]SignatureClient{ + schema.HostStellarEnv: wantHostAccStellarEnvSigner, + schema.ChannelAccountStellarDB: wantChAccStellarDBSigner, + schema.DistributionAccountStellarEnv: wantDistAccStelarEnvSigner, + schema.DistributionAccountStellarDBVault: wantDistAccStellarDBVaultSigner, + }, + networkPassphrase: networkPassphrase, + }, + }, + { + name: "🎉 successfully instantiate new signature router with ALL types (empty accountTypes parameter)", + opts: validOptions, + wantSignerRouter: &SignerRouterImpl{ + strategies: map[schema.AccountType]SignatureClient{ + schema.HostStellarEnv: wantHostAccStellarEnvSigner, + schema.ChannelAccountStellarDB: wantChAccStellarDBSigner, + schema.DistributionAccountStellarEnv: wantDistAccStelarEnvSigner, + schema.DistributionAccountStellarDBVault: wantDistAccStellarDBVaultSigner, + }, + networkPassphrase: networkPassphrase, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sigRouter, err := NewSignerRouter(tc.opts, tc.accountTypes...) + + if tc.wantErrContains != "" { + assert.ErrorContains(t, err, tc.wantErrContains) + } else { + require.NoError(t, err) + assert.Equal(t, tc.wantSignerRouter, sigRouter) + } + }) + } +} + +func Test_SignerRouterImpl_RouteSigner(t *testing.T) { + dbt := dbtest.OpenWithoutMigrations(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + // Create valid SignatureRouterOptions + networkPassphrase := network.TestNetworkPassphrase + chAccEncryptionPassphrase := keypair.MustRandom().Seed() + distAccEncryptionPassphrase := keypair.MustRandom().Seed() + distributionKP := keypair.MustRandom() + hostKP := keypair.MustRandom() + mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) + validOptions := SignatureRouterOptions{ + NetworkPassphrase: networkPassphrase, + DBConnectionPool: dbConnectionPool, + ChAccEncryptionPassphrase: chAccEncryptionPassphrase, + LedgerNumberTracker: mLedgerNumberTracker, + DistAccEncryptionPassphrase: distAccEncryptionPassphrase, + DistributionPrivateKey: distributionKP.Seed(), + HostPrivateKey: hostKP.Seed(), + Encrypter: &sdpUtils.DefaultPrivateKeyEncrypter{}, + } + + sigRouter, err := NewSignerRouter(validOptions) + require.NoError(t, err) + + sigRouterImpl, ok := sigRouter.(*SignerRouterImpl) + require.True(t, ok) + + testCases := []struct { + name string + accountType schema.AccountType + wantErrContains string + wantSignerType interface{} + }{ + { + name: "returns an error if an INVALID accountType is provided", + accountType: schema.AccountType("INVALID"), + wantErrContains: `type "INVALID" is not supported by SignerRouter`, + }, + { + name: fmt.Sprintf("🎉 successfully routes to %s", schema.HostStellarEnv), + accountType: schema.HostStellarEnv, + wantSignerType: &AccountEnvSignatureClient{}, + }, + { + name: fmt.Sprintf("🎉 successfully routes to %s", schema.ChannelAccountStellarDB), + accountType: schema.ChannelAccountStellarDB, + wantSignerType: &ChannelAccountDBSignatureClient{}, + }, + { + name: fmt.Sprintf("🎉 successfully routes to %s", schema.DistributionAccountStellarEnv), + accountType: schema.DistributionAccountStellarEnv, + wantSignerType: &AccountEnvSignatureClient{}, + }, + { + name: fmt.Sprintf("🎉 successfully routes to %s", schema.DistributionAccountStellarDBVault), + accountType: schema.DistributionAccountStellarDBVault, + wantSignerType: &DistributionAccountDBVaultSignatureClient{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sigClient, err := sigRouterImpl.RouteSigner(tc.accountType) + if tc.wantErrContains != "" { + assert.ErrorContains(t, err, tc.wantErrContains) + } else { + require.NoError(t, err) + assert.IsType(t, tc.wantSignerType, sigClient) + } + }) + } +} + +func Test_SignerRouterImpl_SignStellarTransaction(t *testing.T) { + dbt := dbtest.OpenWithoutMigrations(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + hostAccount := schema.NewDefaultHostAccount(keypair.MustRandom().Address()) + channelAccount := schema.NewDefaultChannelAccount(keypair.MustRandom().Address()) + distributionDBVaultAccount := schema.NewDefaultStellarTransactionAccount(keypair.MustRandom().Address()) + distributionEnvAccount := schema.TransactionAccount{ + Type: schema.DistributionAccountStellarEnv, + Address: keypair.MustRandom().Address(), + Status: schema.AccountStatusActive, + } + ctx := context.Background() + + testCases := []struct { + name string + accounts []schema.TransactionAccount + mockSignerRouterFn func(t *testing.T, sigRouter *SignerRouterImpl) + wantErrContains string + }{ + { + name: "returns an error if zero accounts are provided", + accounts: []schema.TransactionAccount{}, + wantErrContains: "no accounts provided to sign the transaction", + }, + { + name: "returns an error if an INVALID accountType is provided", + accounts: []schema.TransactionAccount{{ + Address: keypair.MustRandom().Address(), + Type: schema.AccountType("INVALID"), + }}, + wantErrContains: "routing signer", + }, + { + name: fmt.Sprintf("returns an error if the SigClient fails (%s)", schema.HostStellarEnv), + accounts: []schema.TransactionAccount{hostAccount}, + mockSignerRouterFn: func(t *testing.T, sigRouter *SignerRouterImpl) { + sigClient := mocks.NewMockSignatureClient(t) + sigClient. + On("SignStellarTransaction", ctx, &txnbuild.Transaction{}, hostAccount.Address). + Return(nil, fmt.Errorf("some error occurred")). + Once() + sigRouter.strategies[schema.HostStellarEnv] = sigClient + }, + wantErrContains: fmt.Sprintf("signing stellar transaction for strategy=%s", schema.HostStellarEnv), + }, + { + name: fmt.Sprintf("🎉 successfully signs for %s", schema.HostStellarEnv), + accounts: []schema.TransactionAccount{hostAccount}, + mockSignerRouterFn: func(t *testing.T, sigRouter *SignerRouterImpl) { + sigClient := mocks.NewMockSignatureClient(t) + sigClient. + On("SignStellarTransaction", ctx, &txnbuild.Transaction{}, hostAccount.Address). + Return(&txnbuild.Transaction{}, nil). + Once() + sigRouter.strategies[schema.HostStellarEnv] = sigClient + }, + }, + { + name: fmt.Sprintf("🎉 successfully signs for %s", schema.ChannelAccountStellarDB), + accounts: []schema.TransactionAccount{channelAccount}, + mockSignerRouterFn: func(t *testing.T, sigRouter *SignerRouterImpl) { + sigClient := mocks.NewMockSignatureClient(t) + sigClient. + On("SignStellarTransaction", ctx, &txnbuild.Transaction{}, channelAccount.Address). + Return(&txnbuild.Transaction{}, nil). + Once() + sigRouter.strategies[schema.ChannelAccountStellarDB] = sigClient + }, + }, + { + name: fmt.Sprintf("🎉 successfully signs for %s", schema.DistributionAccountStellarEnv), + accounts: []schema.TransactionAccount{distributionEnvAccount}, + mockSignerRouterFn: func(t *testing.T, sigRouter *SignerRouterImpl) { + sigClient := mocks.NewMockSignatureClient(t) + sigClient. + On("SignStellarTransaction", ctx, &txnbuild.Transaction{}, distributionEnvAccount.Address). + Return(&txnbuild.Transaction{}, nil). + Once() + sigRouter.strategies[schema.DistributionAccountStellarEnv] = sigClient + }, + }, + { + name: fmt.Sprintf("🎉 successfully signs for %s", schema.DistributionAccountStellarDBVault), + accounts: []schema.TransactionAccount{distributionDBVaultAccount}, + mockSignerRouterFn: func(t *testing.T, sigRouter *SignerRouterImpl) { + sigClient := mocks.NewMockSignatureClient(t) + sigClient. + On("SignStellarTransaction", ctx, &txnbuild.Transaction{}, distributionDBVaultAccount.Address). + Return(&txnbuild.Transaction{}, nil). + Once() + sigRouter.strategies[schema.DistributionAccountStellarDBVault] = sigClient + }, + }, + { + name: fmt.Sprintf("🎉 successfully signs for multiple signers [%s, %s, %s]", schema.HostStellarEnv, schema.ChannelAccountStellarDB, schema.DistributionAccountStellarDBVault), + accounts: []schema.TransactionAccount{hostAccount, channelAccount, distributionDBVaultAccount}, + mockSignerRouterFn: func(t *testing.T, sigRouter *SignerRouterImpl) { + hostSigClient := mocks.NewMockSignatureClient(t) + hostSigClient. + On("SignStellarTransaction", ctx, &txnbuild.Transaction{}, hostAccount.Address). + Return(&txnbuild.Transaction{}, nil). + Once() + sigRouter.strategies[schema.HostStellarEnv] = hostSigClient + + chAccSigClient := mocks.NewMockSignatureClient(t) + chAccSigClient. + On("SignStellarTransaction", ctx, &txnbuild.Transaction{}, channelAccount.Address). + Return(&txnbuild.Transaction{}, nil). + Once() + sigRouter.strategies[schema.ChannelAccountStellarDB] = chAccSigClient + + distAccDBVaultSigClient := mocks.NewMockSignatureClient(t) + distAccDBVaultSigClient. + On("SignStellarTransaction", ctx, &txnbuild.Transaction{}, distributionDBVaultAccount.Address). + Return(&txnbuild.Transaction{}, nil). + Once() + sigRouter.strategies[schema.DistributionAccountStellarDBVault] = distAccDBVaultSigClient + }, + }, + { + name: "returns an error when multoiple signers are used but one of them fails", + accounts: []schema.TransactionAccount{hostAccount, channelAccount}, + mockSignerRouterFn: func(t *testing.T, sigRouter *SignerRouterImpl) { + chAccSigClient := mocks.NewMockSignatureClient(t) + chAccSigClient. + On("SignStellarTransaction", ctx, &txnbuild.Transaction{}, channelAccount.Address). + Return(&txnbuild.Transaction{}, nil). // <---- SUCCESS + Once() + sigRouter.strategies[schema.ChannelAccountStellarDB] = chAccSigClient + + hostSigClient := mocks.NewMockSignatureClient(t) + hostSigClient. + On("SignStellarTransaction", ctx, &txnbuild.Transaction{}, hostAccount.Address). + Return(nil, errors.New("this one fails")). // <---- FAILS + Once() + sigRouter.strategies[schema.HostStellarEnv] = hostSigClient + }, + wantErrContains: fmt.Sprintf("signing stellar transaction for strategy=%s", schema.HostStellarEnv), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sigRouterImpl := &SignerRouterImpl{ + strategies: map[schema.AccountType]SignatureClient{}, + } + if tc.mockSignerRouterFn != nil { + tc.mockSignerRouterFn(t, sigRouterImpl) + } + + signedStellarTx, err := sigRouterImpl.SignStellarTransaction(ctx, &txnbuild.Transaction{}, tc.accounts...) + if tc.wantErrContains != "" { + assert.ErrorContains(t, err, tc.wantErrContains) + } else { + require.NoError(t, err) + assert.NotNil(t, signedStellarTx) + } + }) + } +} + +func Test_SignerRouterImpl_SignFeeBumpStellarTransaction(t *testing.T) { + dbt := dbtest.OpenWithoutMigrations(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + hostAccount := schema.NewDefaultHostAccount(keypair.MustRandom().Address()) + channelAccount := schema.NewDefaultChannelAccount(keypair.MustRandom().Address()) + distributionDBVaultAccount := schema.NewDefaultStellarTransactionAccount(keypair.MustRandom().Address()) + distributionEnvAccount := schema.TransactionAccount{ + Type: schema.DistributionAccountStellarEnv, + Address: keypair.MustRandom().Address(), + Status: schema.AccountStatusActive, + } + ctx := context.Background() + + testCases := []struct { + name string + accounts []schema.TransactionAccount + mockSignerRouterFn func(t *testing.T, sigRouter *SignerRouterImpl) + wantErrContains string + }{ + { + name: "returns an error if zero accounts are provided", + accounts: []schema.TransactionAccount{}, + wantErrContains: "no accounts provided to sign the transaction", + }, + { + name: "returns an error if an INVALID accountType is provided", + accounts: []schema.TransactionAccount{{ + Address: keypair.MustRandom().Address(), + Type: schema.AccountType("INVALID"), + }}, + wantErrContains: "routing signer", + }, + { + name: fmt.Sprintf("returns an error if the SigClient fails (%s)", schema.HostStellarEnv), + accounts: []schema.TransactionAccount{hostAccount}, + mockSignerRouterFn: func(t *testing.T, sigRouter *SignerRouterImpl) { + sigClient := mocks.NewMockSignatureClient(t) + sigClient. + On("SignFeeBumpStellarTransaction", ctx, &txnbuild.FeeBumpTransaction{}, hostAccount.Address). + Return(nil, fmt.Errorf("some error occurred")). + Once() + sigRouter.strategies[schema.HostStellarEnv] = sigClient + }, + wantErrContains: fmt.Sprintf("signing stellar fee bump transaction for strategy=%s", schema.HostStellarEnv), + }, + { + name: fmt.Sprintf("🎉 successfully signs for %s", schema.HostStellarEnv), + accounts: []schema.TransactionAccount{hostAccount}, + mockSignerRouterFn: func(t *testing.T, sigRouter *SignerRouterImpl) { + sigClient := mocks.NewMockSignatureClient(t) + sigClient. + On("SignFeeBumpStellarTransaction", ctx, &txnbuild.FeeBumpTransaction{}, hostAccount.Address). + Return(&txnbuild.FeeBumpTransaction{}, nil). + Once() + sigRouter.strategies[schema.HostStellarEnv] = sigClient + }, + }, + { + name: fmt.Sprintf("🎉 successfully signs for %s", schema.ChannelAccountStellarDB), + accounts: []schema.TransactionAccount{channelAccount}, + mockSignerRouterFn: func(t *testing.T, sigRouter *SignerRouterImpl) { + sigClient := mocks.NewMockSignatureClient(t) + sigClient. + On("SignFeeBumpStellarTransaction", ctx, &txnbuild.FeeBumpTransaction{}, channelAccount.Address). + Return(&txnbuild.FeeBumpTransaction{}, nil). + Once() + sigRouter.strategies[schema.ChannelAccountStellarDB] = sigClient + }, + }, + { + name: fmt.Sprintf("🎉 successfully signs for %s", schema.DistributionAccountStellarEnv), + accounts: []schema.TransactionAccount{distributionEnvAccount}, + mockSignerRouterFn: func(t *testing.T, sigRouter *SignerRouterImpl) { + sigClient := mocks.NewMockSignatureClient(t) + sigClient. + On("SignFeeBumpStellarTransaction", ctx, &txnbuild.FeeBumpTransaction{}, distributionEnvAccount.Address). + Return(&txnbuild.FeeBumpTransaction{}, nil). + Once() + sigRouter.strategies[schema.DistributionAccountStellarEnv] = sigClient + }, + }, + { + name: fmt.Sprintf("🎉 successfully signs for %s", schema.DistributionAccountStellarDBVault), + accounts: []schema.TransactionAccount{distributionDBVaultAccount}, + mockSignerRouterFn: func(t *testing.T, sigRouter *SignerRouterImpl) { + sigClient := mocks.NewMockSignatureClient(t) + sigClient. + On("SignFeeBumpStellarTransaction", ctx, &txnbuild.FeeBumpTransaction{}, distributionDBVaultAccount.Address). + Return(&txnbuild.FeeBumpTransaction{}, nil). + Once() + sigRouter.strategies[schema.DistributionAccountStellarDBVault] = sigClient + }, + }, + { + name: fmt.Sprintf("🎉 successfully signs for multiple signers [%s, %s, %s]", schema.HostStellarEnv, schema.ChannelAccountStellarDB, schema.DistributionAccountStellarDBVault), + accounts: []schema.TransactionAccount{hostAccount, channelAccount, distributionDBVaultAccount}, + mockSignerRouterFn: func(t *testing.T, sigRouter *SignerRouterImpl) { + hostSigClient := mocks.NewMockSignatureClient(t) + hostSigClient. + On("SignFeeBumpStellarTransaction", ctx, &txnbuild.FeeBumpTransaction{}, hostAccount.Address). + Return(&txnbuild.FeeBumpTransaction{}, nil). + Once() + sigRouter.strategies[schema.HostStellarEnv] = hostSigClient + + chAccSigClient := mocks.NewMockSignatureClient(t) + chAccSigClient. + On("SignFeeBumpStellarTransaction", ctx, &txnbuild.FeeBumpTransaction{}, channelAccount.Address). + Return(&txnbuild.FeeBumpTransaction{}, nil). + Once() + sigRouter.strategies[schema.ChannelAccountStellarDB] = chAccSigClient + + distAccDBVaultSigClient := mocks.NewMockSignatureClient(t) + distAccDBVaultSigClient. + On("SignFeeBumpStellarTransaction", ctx, &txnbuild.FeeBumpTransaction{}, distributionDBVaultAccount.Address). + Return(&txnbuild.FeeBumpTransaction{}, nil). + Once() + sigRouter.strategies[schema.DistributionAccountStellarDBVault] = distAccDBVaultSigClient + }, + }, + { + name: "returns an error when multoiple signers are used but one of them fails", + accounts: []schema.TransactionAccount{hostAccount, channelAccount}, + mockSignerRouterFn: func(t *testing.T, sigRouter *SignerRouterImpl) { + chAccSigClient := mocks.NewMockSignatureClient(t) + chAccSigClient. + On("SignFeeBumpStellarTransaction", ctx, &txnbuild.FeeBumpTransaction{}, channelAccount.Address). + Return(&txnbuild.FeeBumpTransaction{}, nil). // <---- SUCCESS + Once() + sigRouter.strategies[schema.ChannelAccountStellarDB] = chAccSigClient + + hostSigClient := mocks.NewMockSignatureClient(t) + hostSigClient. + On("SignFeeBumpStellarTransaction", ctx, &txnbuild.FeeBumpTransaction{}, hostAccount.Address). + Return(nil, errors.New("this one fails")). // <---- FAILS + Once() + sigRouter.strategies[schema.HostStellarEnv] = hostSigClient + }, + wantErrContains: fmt.Sprintf("signing stellar fee bump transaction for strategy=%s", schema.HostStellarEnv), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sigRouterImpl := &SignerRouterImpl{ + strategies: map[schema.AccountType]SignatureClient{}, + } + if tc.mockSignerRouterFn != nil { + tc.mockSignerRouterFn(t, sigRouterImpl) + } + + signedStellarTx, err := sigRouterImpl.SignFeeBumpStellarTransaction(ctx, &txnbuild.FeeBumpTransaction{}, tc.accounts...) + if tc.wantErrContains != "" { + assert.ErrorContains(t, err, tc.wantErrContains) + } else { + require.NoError(t, err) + assert.NotNil(t, signedStellarTx) + } + }) + } +} + +func Test_SignerRouterImpl_BatchInsert(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + + // Create valid SignatureRouterOptions + networkPassphrase := network.TestNetworkPassphrase + chAccEncryptionPassphrase := keypair.MustRandom().Seed() + distAccEncryptionPassphrase := keypair.MustRandom().Seed() + distributionKP := keypair.MustRandom() + hostKP := keypair.MustRandom() + mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) + mLedgerNumberTracker.On("GetLedgerNumber").Return(1, nil) + validOptions := SignatureRouterOptions{ + NetworkPassphrase: networkPassphrase, + DBConnectionPool: dbConnectionPool, + ChAccEncryptionPassphrase: chAccEncryptionPassphrase, + LedgerNumberTracker: mLedgerNumberTracker, + DistAccEncryptionPassphrase: distAccEncryptionPassphrase, + DistributionPrivateKey: distributionKP.Seed(), + HostPrivateKey: hostKP.Seed(), + Encrypter: &sdpUtils.DefaultPrivateKeyEncrypter{}, + } + + testCases := []struct { + name string + numAccounts int + accountType schema.AccountType + wantErrContains string + // mockSigRouterFn is a function that returns a SignerRouter with mocked SignatureClients. If nil, a SignerRouter (with real signer clients) is created. + mockSigRouterFn func(t *testing.T) SignerRouter + wantResponseLength int + }{ + { + name: "error when the number requestes is smaller than one", + numAccounts: 0, + wantErrContains: "number of accounts to insert must be greater than 0", + }, + { + name: "error when an invalid account type is passed", + numAccounts: 1, + accountType: "INVALID", + wantErrContains: "routing signer", + }, + { + name: "error when a signer fails to insert", + mockSigRouterFn: func(t *testing.T) SignerRouter { + sigClient := mocks.NewMockSignatureClient(t) + sigClient. + On("BatchInsert", ctx, 1). + Return(nil, errors.New("sig client could not insert account")). + Once() + + return &SignerRouterImpl{ + strategies: map[schema.AccountType]SignatureClient{ + schema.DistributionAccountStellarDBVault: sigClient, + }, + } + }, + numAccounts: 1, + accountType: schema.DistributionAccountStellarDBVault, + wantErrContains: fmt.Sprintf("batch inserting accounts for strategy=%s", schema.DistributionAccountStellarDBVault), + }, + { + name: fmt.Sprintf("🎉 successfully inserts with accountType=%s", schema.HostStellarEnv), + numAccounts: 2, + accountType: schema.HostStellarEnv, + wantErrContains: ErrUnsupportedCommand.Error(), + wantResponseLength: 2, + }, + { + name: fmt.Sprintf("🎉 successfully inserts with accountType=%s", schema.ChannelAccountStellarDB), + numAccounts: 3, + accountType: schema.ChannelAccountStellarDB, + wantResponseLength: 3, + }, + { + name: fmt.Sprintf("🎉 successfully inserts with accountType=%s", schema.DistributionAccountStellarEnv), + numAccounts: 4, + accountType: schema.DistributionAccountStellarEnv, + wantErrContains: ErrUnsupportedCommand.Error(), + wantResponseLength: 4, + }, + { + name: fmt.Sprintf("🎉 successfully inserts with accountType=%s", schema.DistributionAccountStellarDBVault), + numAccounts: 5, + accountType: schema.DistributionAccountStellarDBVault, + wantResponseLength: 5, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var sigRouter SignerRouter + var err error + if tc.mockSigRouterFn != nil { + sigRouter = tc.mockSigRouterFn(t) + } else { + sigRouter, err = NewSignerRouter(validOptions) + require.NoError(t, err) + } + + txAccounts, err := sigRouter.BatchInsert(ctx, tc.accountType, tc.numAccounts) + if tc.wantErrContains != "" { + assert.ErrorContains(t, err, tc.wantErrContains) + } else { + require.NoError(t, err) + } + assert.Len(t, txAccounts, tc.wantResponseLength) + }) + } +} + +func Test_SignerRouterImpl_Delete(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + + // Create valid SignatureRouterOptions + networkPassphrase := network.TestNetworkPassphrase + chAccEncryptionPassphrase := keypair.MustRandom().Seed() + distAccEncryptionPassphrase := keypair.MustRandom().Seed() + distributionKP := keypair.MustRandom() + hostKP := keypair.MustRandom() + mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) + mLedgerNumberTracker.On("GetLedgerNumber").Return(1, nil) + validOptions := SignatureRouterOptions{ + NetworkPassphrase: networkPassphrase, + DBConnectionPool: dbConnectionPool, + ChAccEncryptionPassphrase: chAccEncryptionPassphrase, + LedgerNumberTracker: mLedgerNumberTracker, + DistAccEncryptionPassphrase: distAccEncryptionPassphrase, + DistributionPrivateKey: distributionKP.Seed(), + HostPrivateKey: hostKP.Seed(), + Encrypter: &sdpUtils.DefaultPrivateKeyEncrypter{}, + } + sigRouter, err := NewSignerRouter(validOptions) + require.NoError(t, err) + + // Create accounts: + hostAccounts, err := sigRouter.BatchInsert(ctx, schema.HostStellarEnv, 1) + require.ErrorIs(t, err, ErrUnsupportedCommand) + require.Len(t, hostAccounts, 1) + hostAccount := hostAccounts[0] + + chAccounts, err := sigRouter.BatchInsert(ctx, schema.ChannelAccountStellarDB, 1) + require.NoError(t, err) + require.Len(t, chAccounts, 1) + chAccount := chAccounts[0] + chAccModel := store.NewChannelAccountModel(dbConnectionPool) + numChAccounts, err := chAccModel.Count(ctx) + require.NoError(t, err) + require.Equal(t, 1, numChAccounts) + + distEnvAccounts, err := sigRouter.BatchInsert(ctx, schema.DistributionAccountStellarEnv, 1) + require.ErrorIs(t, err, ErrUnsupportedCommand) + require.Len(t, distEnvAccounts, 1) + distEnvAccount := distEnvAccounts[0] + + distDBVaultAccounts, err := sigRouter.BatchInsert(ctx, schema.DistributionAccountStellarDBVault, 1) + require.NoError(t, err) + require.Len(t, distDBVaultAccounts, 1) + distDBVaultAccount := distDBVaultAccounts[0] + query := "SELECT COUNT(*) FROM vault" + var numDBVaultAccounts int + err = dbConnectionPool.GetContext(ctx, &numDBVaultAccounts, query) + require.NoError(t, err) + require.Equal(t, 1, numDBVaultAccounts) + + testCases := []struct { + name string + accountToDelete schema.TransactionAccount + wantErrContains string + // mockSigRouterFn is a function that returns a SignerRouter with mocked SignatureClients. If nil, a SignerRouter (with real signer clients) is created. + mockSigRouterFn func(t *testing.T) SignerRouter + assertAccountDeletionFn func(t *testing.T) + }{ + { + name: "error when an invalid account type is passed", + accountToDelete: schema.TransactionAccount{ + Address: keypair.MustRandom().Address(), + Type: "INVALID", + }, + wantErrContains: "routing signer", + }, + { + name: "error when a signer fails to delete", + mockSigRouterFn: func(t *testing.T) SignerRouter { + sigClient := mocks.NewMockSignatureClient(t) + sigClient. + On("Delete", ctx, distDBVaultAccount.Address). + Return(errors.New("sig client could not delete account")). + Once() + + return &SignerRouterImpl{ + strategies: map[schema.AccountType]SignatureClient{ + schema.DistributionAccountStellarDBVault: sigClient, + }, + } + }, + accountToDelete: distDBVaultAccount, + wantErrContains: fmt.Sprintf("deleting account=%v for strategy=%s", distDBVaultAccount, schema.DistributionAccountStellarDBVault), + }, + { + name: fmt.Sprintf("🎉 successfully deletes account with accpountType=%s", schema.HostStellarEnv), + accountToDelete: hostAccount, + wantErrContains: ErrUnsupportedCommand.Error(), + }, + { + name: fmt.Sprintf("🎉 successfully deletes account with accpountType=%s", schema.ChannelAccountStellarDB), + accountToDelete: chAccount, + assertAccountDeletionFn: func(t *testing.T) { + numChAccounts, err = chAccModel.Count(ctx) + assert.NoError(t, err) + assert.Equal(t, 0, numChAccounts) + }, + }, + { + name: fmt.Sprintf("🎉 successfully deletes account with accpountType=%s", schema.DistributionAccountStellarEnv), + accountToDelete: distEnvAccount, + wantErrContains: ErrUnsupportedCommand.Error(), + }, + { + name: fmt.Sprintf("🎉 successfully deletes account with accpountType=%s", schema.DistributionAccountStellarDBVault), + accountToDelete: distDBVaultAccount, + assertAccountDeletionFn: func(t *testing.T) { + query := "SELECT COUNT(*) FROM vault" + var numDBVaultAccounts int + err = dbConnectionPool.GetContext(ctx, &numDBVaultAccounts, query) + require.NoError(t, err) + require.Equal(t, 0, numDBVaultAccounts) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sRouter := sigRouter + if tc.mockSigRouterFn != nil { + sRouter = tc.mockSigRouterFn(t) + } + + err := sRouter.Delete(ctx, tc.accountToDelete) + if tc.wantErrContains != "" { + assert.ErrorContains(t, err, tc.wantErrContains) + } else { + require.NoError(t, err) + } + + if tc.assertAccountDeletionFn != nil { + tc.assertAccountDeletionFn(t) + } + }) + } +} + +func Test_SignerRouterImpl_NetworkPassphrase(t *testing.T) { + var sigRouterImpl SignerRouter = &SignerRouterImpl{ + networkPassphrase: network.TestNetworkPassphrase, + } + require.Equal(t, network.TestNetworkPassphrase, sigRouterImpl.NetworkPassphrase()) + + sigRouterImpl = &SignerRouterImpl{ + networkPassphrase: network.PublicNetworkPassphrase, + } + require.Equal(t, network.PublicNetworkPassphrase, sigRouterImpl.NetworkPassphrase()) + + sigRouterImpl = &SignerRouterImpl{ + networkPassphrase: network.FutureNetworkPassphrase, + } + require.Equal(t, network.FutureNetworkPassphrase, sigRouterImpl.NetworkPassphrase()) +} + +func Test_SignerRouterImpl_SupportedAccountTypes(t *testing.T) { + dbt := dbtest.OpenWithoutMigrations(t) + defer dbt.Close() + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + // Create valid SignatureRouterOptions + networkPassphrase := network.TestNetworkPassphrase + chAccEncryptionPassphrase := keypair.MustRandom().Seed() + distAccEncryptionPassphrase := keypair.MustRandom().Seed() + distributionKP := keypair.MustRandom() + hostKP := keypair.MustRandom() + mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) + validOptions := SignatureRouterOptions{ + NetworkPassphrase: networkPassphrase, + DBConnectionPool: dbConnectionPool, + ChAccEncryptionPassphrase: chAccEncryptionPassphrase, + LedgerNumberTracker: mLedgerNumberTracker, + DistAccEncryptionPassphrase: distAccEncryptionPassphrase, + DistributionPrivateKey: distributionKP.Seed(), + HostPrivateKey: hostKP.Seed(), + Encrypter: &sdpUtils.DefaultPrivateKeyEncrypter{}, + } + + testCases := []struct { + name string + inputTypes []schema.AccountType + wantSupported []schema.AccountType + }{ + { + name: "returns all supported account types when no input types are provided", + wantSupported: []schema.AccountType{schema.HostStellarEnv, schema.ChannelAccountStellarDB, schema.DistributionAccountStellarEnv, schema.DistributionAccountStellarDBVault}, + }, + { + name: "returns all supported account types when all input types are provided", + inputTypes: []schema.AccountType{schema.HostStellarEnv, schema.ChannelAccountStellarDB, schema.DistributionAccountStellarEnv, schema.DistributionAccountStellarDBVault}, + wantSupported: []schema.AccountType{schema.HostStellarEnv, schema.ChannelAccountStellarDB, schema.DistributionAccountStellarEnv, schema.DistributionAccountStellarDBVault}, + }, + { + name: "returns only supported account types when some input types are provided", + inputTypes: []schema.AccountType{schema.HostStellarEnv, schema.ChannelAccountStellarDB}, + wantSupported: []schema.AccountType{schema.HostStellarEnv, schema.ChannelAccountStellarDB}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sigRouter, err := NewSignerRouter(validOptions, tc.inputTypes...) + require.NoError(t, err) + + sigRouterImpl, ok := sigRouter.(*SignerRouterImpl) + require.True(t, ok) + + assert.ElementsMatch(t, tc.wantSupported, sigRouterImpl.SupportedAccountTypes()) + }) + } +} diff --git a/internal/transactionsubmission/engine/submitter_engine_test.go b/internal/transactionsubmission/engine/submitter_engine_test.go index 087d0b22f..36127712d 100644 --- a/internal/transactionsubmission/engine/submitter_engine_test.go +++ b/internal/transactionsubmission/engine/submitter_engine_test.go @@ -9,12 +9,17 @@ import ( preconditionsMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/preconditions/mocks" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" ) func Test_SubmitterEngine_Validate(t *testing.T) { hMock := &horizonclient.MockClient{} mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, _, mDistAccSigClient, _, _ := signing.NewMockSignatureService(t) + sigService, sigRouter, _ := signing.NewMockSignatureService(t) + sigRouter. + On("SupportedAccountTypes"). + Return([]schema.AccountType{schema.ChannelAccountStellarDB}). + Maybe() testCases := []struct { name string @@ -40,18 +45,6 @@ func Test_SubmitterEngine_Validate(t *testing.T) { }, wantErrContains: "signature service cannot be empty", }, - { - name: "returns an error if the max base fee is less than the minimum", - engine: SubmitterEngine{ - HorizonClient: hMock, - LedgerNumberTracker: mLedgerNumberTracker, - SignatureService: signing.SignatureService{ - DistAccountSigner: mDistAccSigClient, - }, - MaxBaseFee: 99, - }, - wantErrContains: "validating signature service: channel account signer cannot be nil", - }, { name: "returns an error if the max base fee is less than the minimum", engine: SubmitterEngine{ diff --git a/internal/transactionsubmission/manager_test.go b/internal/transactionsubmission/manager_test.go index a8e437abb..7d7c26887 100644 --- a/internal/transactionsubmission/manager_test.go +++ b/internal/transactionsubmission/manager_test.go @@ -7,6 +7,8 @@ import ( "testing" "time" + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/keypair" "github.com/stellar/go/network" @@ -29,7 +31,6 @@ import ( tssMonitor "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/monitor" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" storeMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store/mocks" - "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) @@ -43,7 +44,7 @@ func Test_SubmitterOptions_validate(t *testing.T) { mHorizonClient := &horizonclient.MockClient{} mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - signatureService, _, _, _, distAccResolver := signing.NewMockSignatureService(t) + signatureService, _, distAccResolver := signing.NewMockSignatureService(t) mSubmitterEngine := engine.SubmitterEngine{ HorizonClient: mHorizonClient, LedgerNumberTracker: mLedgerNumberTracker, @@ -107,7 +108,7 @@ func Test_SubmitterOptions_validate(t *testing.T) { }, }, }, - wantErrContains: "validating submitter engine: validating signature service: channel account signer cannot be nil", + wantErrContains: "validating submitter engine: validating signature service: signer router cannot be nil", }, { name: "validate submitter engine's Max Base Fee", @@ -205,7 +206,7 @@ func Test_NewManager(t *testing.T) { mHorizonClient := &horizonclient.MockClient{} mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, _, _, _, _ := signing.NewMockSignatureService(t) + sigService, _, _ := signing.NewMockSignatureService(t) mSubmitterEngine := engine.SubmitterEngine{ HorizonClient: mHorizonClient, LedgerNumberTracker: mLedgerNumberTracker, @@ -374,17 +375,18 @@ func Test_Manager_ProcessTransactions(t *testing.T) { defer dbConnectionPool.Close() // Signature service - encrypter := &utils.DefaultPrivateKeyEncrypter{} + encrypter := &sdpUtils.DefaultPrivateKeyEncrypter{} chAccEncryptionPassphrase := keypair.MustRandom().Seed() + distAccEncryptionPassphrase := keypair.MustRandom().Seed() distributionKP := keypair.MustRandom() + distAccount := schema.NewStellarEnvTransactionAccount(distributionKP.Address()) mDistAccResolver := sigMocks.NewMockDistributionAccountResolver(t) mDistAccResolver. On("DistributionAccount", mock.Anything, mock.AnythingOfType("string")). - Return(schema.NewDefaultStellarDistributionAccount(distributionKP.Address()), nil) + Return(distAccount, nil) sigService, err := signing.NewSignatureService(signing.SignatureServiceOptions{ - DistributionSignerType: signing.DistributionAccountEnvSignatureClientType, NetworkPassphrase: network.TestNetworkPassphrase, DistributionPrivateKey: distributionKP.Seed(), DBConnectionPool: dbConnectionPool, @@ -393,6 +395,7 @@ func Test_Manager_ProcessTransactions(t *testing.T) { Encrypter: encrypter, DistributionAccountResolver: mDistAccResolver, + DistAccEncryptionPassphrase: distAccEncryptionPassphrase, }) require.NoError(t, err) diff --git a/internal/transactionsubmission/scripts/tss_payments_loadtest.go b/internal/transactionsubmission/scripts/tss_payments_loadtest.go index 8d0fa1a96..4868a25b8 100644 --- a/internal/transactionsubmission/scripts/tss_payments_loadtest.go +++ b/internal/transactionsubmission/scripts/tss_payments_loadtest.go @@ -11,6 +11,7 @@ import ( "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/protocols/horizon" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/httpclient" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" diff --git a/internal/transactionsubmission/services/channel_accounts_service.go b/internal/transactionsubmission/services/channel_accounts_service.go index 4bdc22ba8..a15eccf41 100644 --- a/internal/transactionsubmission/services/channel_accounts_service.go +++ b/internal/transactionsubmission/services/channel_accounts_service.go @@ -3,14 +3,17 @@ package services import ( "context" "fmt" + "slices" "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/preconditions" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" ) const advisoryLockID = int(2172398390434160) @@ -66,7 +69,7 @@ func (s *ChannelAccountsService) validate() error { return fmt.Errorf("validating submitter engine: %w", err) } - if s.SubmitterEngine.HostAccountSigner == nil { + if !slices.Contains(s.SubmitterEngine.SignerRouter.SupportedAccountTypes(), schema.HostStellarEnv) { return fmt.Errorf("signature engine's host signer cannot be nil") } @@ -203,7 +206,8 @@ func (s *ChannelAccountsService) deleteChannelAccount(ctx context.Context, publi } log.Ctx(ctx).Warnf("Account %s does not exist on the network", publicKey) - err = s.SignatureService.ChAccountSigner.Delete(ctx, publicKey) + chAccToDelete := schema.NewDefaultChannelAccount(publicKey) + err = s.SignatureService.SignerRouter.Delete(ctx, chAccToDelete) if err != nil { return fmt.Errorf("deleting %s from signature service: %w", publicKey, err) } diff --git a/internal/transactionsubmission/services/channel_accounts_service_test.go b/internal/transactionsubmission/services/channel_accounts_service_test.go index 46d427a5e..30cbf166e 100644 --- a/internal/transactionsubmission/services/channel_accounts_service_test.go +++ b/internal/transactionsubmission/services/channel_accounts_service_test.go @@ -24,6 +24,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" storeMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store/mocks" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" ) func Test_ChannelAccountsService_validate(t *testing.T) { @@ -35,7 +36,7 @@ func Test_ChannelAccountsService_validate(t *testing.T) { mHorizonClient := &horizonclient.MockClient{} mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, _, _, _, _ := signing.NewMockSignatureService(t) + sigService, _, _ := signing.NewMockSignatureService(t) testCases := []struct { name string @@ -185,7 +186,7 @@ func Test_ChannelAccounts_CreateAccount_Success(t *testing.T) { defer mHorizonClient.AssertExpectations(t) mChannelAccountStore := storeMocks.NewMockChannelAccountStore(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, mChAccSigClient, _, mHostAccSigClient, mDistAccResolver := signing.NewMockSignatureService(t) + sigService, sigRouter, mDistAccResolver := signing.NewMockSignatureService(t) chAccService := ChannelAccountsService{ chAccStore: mChannelAccountStore, @@ -198,20 +199,21 @@ func Test_ChannelAccounts_CreateAccount_Success(t *testing.T) { }, } - rootAccount := keypair.MustParseFull("SBMW2WDSVTGT2N2PCBF3PV7WBOIKVTGGIEBUUYMDX3CKTDD5HY3UIHV4") + hostAccountKP := keypair.MustParseFull("SBMW2WDSVTGT2N2PCBF3PV7WBOIKVTGGIEBUUYMDX3CKTDD5HY3UIHV4") + hostAccount := schema.NewDefaultHostAccount(hostAccountKP.Address()) currLedgerNumber := 100 ledgerBounds := &txnbuild.LedgerBounds{ MaxLedger: uint32(currLedgerNumber + preconditions.IncrementForMaxLedgerBounds), } - publicKeys := []string{ - keypair.MustRandom().Address(), - keypair.MustRandom().Address(), + channelAccounts := []schema.TransactionAccount{ + schema.NewDefaultChannelAccount(keypair.MustRandom().Address()), + schema.NewDefaultChannelAccount(keypair.MustRandom().Address()), } mHorizonClient. - On("AccountDetail", horizonclient.AccountRequest{AccountID: rootAccount.Address()}). - Return(horizon.Account{AccountID: rootAccount.Address()}, nil). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccountKP.Address()}). + Return(horizon.Account{AccountID: hostAccountKP.Address()}, nil). On("SubmitTransactionWithOptions", mock.Anything, horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}). Return(horizon.Transaction{}, nil). Once() @@ -225,17 +227,13 @@ func Test_ChannelAccounts_CreateAccount_Success(t *testing.T) { Twice() mDistAccResolver. On("HostDistributionAccount"). - Return(rootAccount.Address()). - Twice() - mHostAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), mock.AnythingOfType("string")). - Return(&txnbuild.Transaction{}, nil). + Return(hostAccount). Once() - mChAccSigClient. - On("BatchInsert", ctx, 2). - Return(publicKeys, nil). + sigRouter. + On("BatchInsert", ctx, schema.ChannelAccountStellarDB, 2). + Return(channelAccounts, nil). Once(). - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), mock.AnythingOfType("string"), mock.AnythingOfType("string")). + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), mock.AnythingOfType("schema.TransactionAccount"), mock.AnythingOfType("schema.TransactionAccount"), hostAccount). Return(&txnbuild.Transaction{}, nil). Once() @@ -243,7 +241,7 @@ func Test_ChannelAccounts_CreateAccount_Success(t *testing.T) { require.NoError(t, err) } -func Test_ChannelAccounts_CreateAccount_CannotFindRootAccount_Failure(t *testing.T) { +func Test_ChannelAccounts_CreateAccount_CannotFindHostAccount_Failure(t *testing.T) { dbt := dbtest.OpenWithTSSMigrationsOnly(t) defer dbt.Close() dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) @@ -256,7 +254,7 @@ func Test_ChannelAccounts_CreateAccount_CannotFindRootAccount_Failure(t *testing defer mHorizonClient.AssertExpectations(t) mChannelAccountStore := storeMocks.NewMockChannelAccountStore(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, _, _, _, mDistAccResolver := signing.NewMockSignatureService(t) + sigService, _, mDistAccResolver := signing.NewMockSignatureService(t) cas := ChannelAccountsService{ chAccStore: mChannelAccountStore, @@ -269,19 +267,20 @@ func Test_ChannelAccounts_CreateAccount_CannotFindRootAccount_Failure(t *testing }, } - rootAccount := keypair.MustParseFull("SDL4E4RF6BHX77DBKE63QC4H4LQG7S7D2PB4TSF64LTHDIHP7UUJHH2V") + hostAccountKP := keypair.MustParseFull("SDL4E4RF6BHX77DBKE63QC4H4LQG7S7D2PB4TSF64LTHDIHP7UUJHH2V") + hostAccount := schema.NewDefaultHostAccount(hostAccountKP.Address()) currLedgerNumber := 100 mHorizonClient. - On("AccountDetail", horizonclient.AccountRequest{AccountID: rootAccount.Address()}). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccountKP.Address()}). Return(horizon.Account{}, errors.New("some random error")) mDistAccResolver. On("HostDistributionAccount"). - Return(rootAccount.Address()). + Return(hostAccount). Once() err = cas.CreateChannelAccounts(ctx, currLedgerNumber) - require.ErrorContains(t, err, "creating channel accounts onchain: failed to retrieve root account: horizon response error: some random error") + require.ErrorContains(t, err, "creating channel accounts onchain: failed to retrieve host account: horizon response error: some random error") } func Test_ChannelAccounts_CreateAccount_Insert_Failure(t *testing.T) { @@ -297,7 +296,7 @@ func Test_ChannelAccounts_CreateAccount_Insert_Failure(t *testing.T) { defer mHorizonClient.AssertExpectations(t) mChannelAccountStore := storeMocks.NewMockChannelAccountStore(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, mChAccSigClient, _, _, mDistAccResolver := signing.NewMockSignatureService(t) + sigService, sigRouter, mDistAccResolver := signing.NewMockSignatureService(t) cas := ChannelAccountsService{ chAccStore: mChannelAccountStore, @@ -310,7 +309,8 @@ func Test_ChannelAccounts_CreateAccount_Insert_Failure(t *testing.T) { }, } - rootAccount := keypair.MustParseFull("SBMW2WDSVTGT2N2PCBF3PV7WBOIKVTGGIEBUUYMDX3CKTDD5HY3UIHV4") + hostAccountKP := keypair.MustParseFull("SBMW2WDSVTGT2N2PCBF3PV7WBOIKVTGGIEBUUYMDX3CKTDD5HY3UIHV4") + hostAccount := schema.NewDefaultHostAccount(hostAccountKP.Address()) // current ledger number currLedgerNumber := 100 @@ -323,14 +323,14 @@ func Test_ChannelAccounts_CreateAccount_Insert_Failure(t *testing.T) { mLedgerNumberTracker. On("GetLedgerBounds").Return(ledgerBounds, nil).Once() mHorizonClient. - On("AccountDetail", horizonclient.AccountRequest{AccountID: rootAccount.Address()}). - Return(horizon.Account{AccountID: rootAccount.Address()}, nil) + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccountKP.Address()}). + Return(horizon.Account{AccountID: hostAccountKP.Address()}, nil) mDistAccResolver. On("HostDistributionAccount"). - Return(rootAccount.Address()). + Return(hostAccount). Once() - mChAccSigClient. - On("BatchInsert", ctx, 2). + sigRouter. + On("BatchInsert", ctx, schema.ChannelAccountStellarDB, 2). Return(nil, errors.New("failure inserting account")) err = cas.CreateChannelAccounts(ctx, 2) @@ -348,7 +348,7 @@ func Test_ChannelAccounts_VerifyAccounts_Success(t *testing.T) { defer mHorizonClient.AssertExpectations(t) mChannelAccountStore := storeMocks.NewMockChannelAccountStore(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, _, _, _, _ := signing.NewMockSignatureService(t) + sigService, _, _ := signing.NewMockSignatureService(t) cas := ChannelAccountsService{ chAccStore: mChannelAccountStore, @@ -394,7 +394,7 @@ func Test_ChannelAccounts_VerifyAccounts_LoadChannelAccountsError_Failure(t *tes defer mHorizonClient.AssertExpectations(t) mChannelAccountStore := storeMocks.NewMockChannelAccountStore(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, _, _, _, _ := signing.NewMockSignatureService(t) + sigService, _, _ := signing.NewMockSignatureService(t) cas := ChannelAccountsService{ chAccStore: mChannelAccountStore, @@ -433,7 +433,7 @@ func Test_ChannelAccounts_VerifyAccounts_NotFound(t *testing.T) { defer mHorizonClient.AssertExpectations(t) mChannelAccountStore := storeMocks.NewMockChannelAccountStore(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, _, _, _, _ := signing.NewMockSignatureService(t) + sigService, _, _ := signing.NewMockSignatureService(t) cas := ChannelAccountsService{ chAccStore: mChannelAccountStore, @@ -494,7 +494,7 @@ func Test_ChannelAccounts_DeleteAccount_Success(t *testing.T) { defer mHorizonClient.AssertExpectations(t) mChannelAccountStore := storeMocks.NewMockChannelAccountStore(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, mChAccSigClient, _, mHostAccSigClient, mDistAccResolver := signing.NewMockSignatureService(t) + sigService, sigRouter, mDistAccResolver := signing.NewMockSignatureService(t) cas := ChannelAccountsService{ chAccStore: mChannelAccountStore, @@ -512,7 +512,8 @@ func Test_ChannelAccounts_DeleteAccount_Success(t *testing.T) { PrivateKey: "YVeMG89DMl2Ku7IeGCumrvneDydfuW+2q4EKQoYhPRpKS/A1bKhNzAa7IjyLiA6UwTESsM6Hh8nactmuOfqUT38YVTx68CIgG6OuwCHPrmws57Tf", } - rootAccount := keypair.MustParseFull("SBMW2WDSVTGT2N2PCBF3PV7WBOIKVTGGIEBUUYMDX3CKTDD5HY3UIHV4") + hostAccountKP := keypair.MustParseFull("SBMW2WDSVTGT2N2PCBF3PV7WBOIKVTGGIEBUUYMDX3CKTDD5HY3UIHV4") + hostAccount := schema.NewDefaultHostAccount(hostAccountKP.Address()) currLedgerNum := 100 ledgerBounds := &txnbuild.LedgerBounds{ @@ -530,25 +531,22 @@ func Test_ChannelAccounts_DeleteAccount_Success(t *testing.T) { On("AccountDetail", horizonclient.AccountRequest{AccountID: channelAccount.PublicKey}). Return(horizon.Account{}, nil). Once(). - On("AccountDetail", horizonclient.AccountRequest{AccountID: rootAccount.Address()}). - Return(horizon.Account{AccountID: rootAccount.Address()}, nil). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccountKP.Address()}). + Return(horizon.Account{AccountID: hostAccountKP.Address()}, nil). Once(). On("SubmitTransactionWithOptions", mock.Anything, horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}). Return(horizon.Transaction{}, nil). Once() mDistAccResolver. On("HostDistributionAccount"). - Return(rootAccount.Address()). - Twice() - mHostAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), mock.AnythingOfType("string")). - Return(&txnbuild.Transaction{}, nil). + Return(hostAccount). Once() - mChAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), mock.AnythingOfType("string"), mock.AnythingOfType("string")). + chTxAccount := schema.NewDefaultChannelAccount(channelAccount.PublicKey) + sigRouter. + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), chTxAccount, hostAccount). Return(&txnbuild.Transaction{}, nil). Once(). - On("Delete", ctx, channelAccount.PublicKey). + On("Delete", ctx, chTxAccount). Return(nil). Once() @@ -572,7 +570,7 @@ func Test_ChannelAccounts_DeleteAccount_All_Success(t *testing.T) { defer mHorizonClient.AssertExpectations(t) mChannelAccountStore := storeMocks.NewMockChannelAccountStore(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, mChAccSigClient, _, mHostAccSigClient, mDistAccResolver := signing.NewMockSignatureService(t) + sigService, sigRouter, mDistAccResolver := signing.NewMockSignatureService(t) cas := ChannelAccountsService{ chAccStore: mChannelAccountStore, @@ -596,7 +594,8 @@ func Test_ChannelAccounts_DeleteAccount_All_Success(t *testing.T) { }, } - rootAccount := keypair.MustParseFull("SBMW2WDSVTGT2N2PCBF3PV7WBOIKVTGGIEBUUYMDX3CKTDD5HY3UIHV4") + hostAccountKP := keypair.MustParseFull("SBMW2WDSVTGT2N2PCBF3PV7WBOIKVTGGIEBUUYMDX3CKTDD5HY3UIHV4") + hostAccount := schema.NewDefaultHostAccount(hostAccountKP.Address()) currLedgerNum := 1000 ledgerBounds := &txnbuild.LedgerBounds{ @@ -619,25 +618,22 @@ func Test_ChannelAccounts_DeleteAccount_All_Success(t *testing.T) { On("AccountDetail", horizonclient.AccountRequest{AccountID: acc.PublicKey}). Return(horizon.Account{}, nil). Once(). - On("AccountDetail", horizonclient.AccountRequest{AccountID: rootAccount.Address()}). - Return(horizon.Account{AccountID: rootAccount.Address()}, nil). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccountKP.Address()}). + Return(horizon.Account{AccountID: hostAccountKP.Address()}, nil). Once(). On("SubmitTransactionWithOptions", mock.Anything, horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}). Return(horizon.Transaction{}, nil). Once() mDistAccResolver. On("HostDistributionAccount"). - Return(rootAccount.Address()). - Twice() - mHostAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), mock.AnythingOfType("string")). - Return(&txnbuild.Transaction{}, nil). + Return(hostAccount). Once() - mChAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), mock.AnythingOfType("string"), mock.AnythingOfType("string")). + chTxAcc := schema.NewDefaultChannelAccount(acc.PublicKey) + sigRouter. + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), chTxAcc, hostAccount). Return(&txnbuild.Transaction{}, nil). Once(). - On("Delete", ctx, acc.PublicKey). + On("Delete", ctx, chTxAcc). Return(nil). Once() } @@ -659,7 +655,7 @@ func Test_ChannelAccounts_DeleteAccount_FindByPublicKey_Failure(t *testing.T) { defer mHorizonClient.AssertExpectations(t) mChannelAccountStore := storeMocks.NewMockChannelAccountStore(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, _, _, _, _ := signing.NewMockSignatureService(t) + sigService, _, _ := signing.NewMockSignatureService(t) cas := ChannelAccountsService{ chAccStore: mChannelAccountStore, @@ -704,7 +700,7 @@ func Test_ChannelAccounts_DeleteAccount_DeleteFromSigServiceError(t *testing.T) defer mHorizonClient.AssertExpectations(t) mChannelAccountStore := storeMocks.NewMockChannelAccountStore(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, mChAccSigClient, _, _, _ := signing.NewMockSignatureService(t) + sigService, mChAccSigClient, _ := signing.NewMockSignatureService(t) cas := ChannelAccountsService{ chAccStore: mChannelAccountStore, @@ -740,8 +736,9 @@ func Test_ChannelAccounts_DeleteAccount_DeleteFromSigServiceError(t *testing.T) }, }). Once() + chTxAcc := schema.NewDefaultChannelAccount(channelAccount.PublicKey) mChAccSigClient. - On("Delete", ctx, channelAccount.PublicKey). + On("Delete", ctx, chTxAcc). Return(errors.New("sig service error")). Once() @@ -763,7 +760,7 @@ func Test_ChannelAccounts_DeleteAccount_SubmitTransaction_Failure(t *testing.T) defer mHorizonClient.AssertExpectations(t) mChannelAccountStore := storeMocks.NewMockChannelAccountStore(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, mChAccSigClient, _, mHostAccSigClient, mDistAccResolver := signing.NewMockSignatureService(t) + sigService, sigRouter, mDistAccResolver := signing.NewMockSignatureService(t) cas := ChannelAccountsService{ chAccStore: mChannelAccountStore, @@ -781,7 +778,8 @@ func Test_ChannelAccounts_DeleteAccount_SubmitTransaction_Failure(t *testing.T) PrivateKey: "SDHGNWPVZJML64GMSQFVX7RAZBJXO3SWOMEGV77IPXUMKHHEOFD2LC75", } - rootAccount := keypair.MustParseFull("SBMW2WDSVTGT2N2PCBF3PV7WBOIKVTGGIEBUUYMDX3CKTDD5HY3UIHV4") + hostAccountKP := keypair.MustParseFull("SBMW2WDSVTGT2N2PCBF3PV7WBOIKVTGGIEBUUYMDX3CKTDD5HY3UIHV4") + hostAccount := schema.NewDefaultHostAccount(hostAccountKP.Address()) currLedgerNum := 1000 ledgerBounds := &txnbuild.LedgerBounds{ @@ -799,22 +797,19 @@ func Test_ChannelAccounts_DeleteAccount_SubmitTransaction_Failure(t *testing.T) On("AccountDetail", horizonclient.AccountRequest{AccountID: channelAccount.PublicKey}). Return(horizon.Account{}, nil). Once(). - On("AccountDetail", horizonclient.AccountRequest{AccountID: rootAccount.Address()}). - Return(horizon.Account{AccountID: rootAccount.Address()}, nil). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccountKP.Address()}). + Return(horizon.Account{AccountID: hostAccountKP.Address()}, nil). Once(). On("SubmitTransactionWithOptions", mock.Anything, horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}). Return(horizon.Transaction{}, errors.New("foo bar")). Once() mDistAccResolver. On("HostDistributionAccount"). - Return(rootAccount.Address()). - Twice() - mHostAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), mock.AnythingOfType("string")). - Return(&txnbuild.Transaction{}, nil). + Return(hostAccount). Once() - mChAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), mock.AnythingOfType("string")). + chTxAcc := schema.NewDefaultChannelAccount(channelAccount.PublicKey) + sigRouter. + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), chTxAcc, hostAccount). Return(&txnbuild.Transaction{}, nil). Once() @@ -838,7 +833,7 @@ func Test_ChannelAccounts_EnsureChannelAccounts_Exact_Success(t *testing.T) { defer mHorizonClient.AssertExpectations(t) mChannelAccountStore := storeMocks.NewMockChannelAccountStore(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, _, _, _, _ := signing.NewMockSignatureService(t) + sigService, _, _ := signing.NewMockSignatureService(t) cas := ChannelAccountsService{ chAccStore: mChannelAccountStore, @@ -881,7 +876,7 @@ func Test_ChannelAccounts_EnsureChannelAccounts_Add_Success(t *testing.T) { defer mHorizonClient.AssertExpectations(t) mChannelAccountStore := storeMocks.NewMockChannelAccountStore(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, mChAccSigClient, _, mHostAccSigClient, mDistAccResolver := signing.NewMockSignatureService(t) + sigService, sigRouter, mDistAccResolver := signing.NewMockSignatureService(t) cas := ChannelAccountsService{ chAccStore: mChannelAccountStore, @@ -895,7 +890,8 @@ func Test_ChannelAccounts_EnsureChannelAccounts_Add_Success(t *testing.T) { } desiredCount := 5 - rootAccount := keypair.MustParseFull("SBMW2WDSVTGT2N2PCBF3PV7WBOIKVTGGIEBUUYMDX3CKTDD5HY3UIHV4") + hostAccountKP := keypair.MustParseFull("SBMW2WDSVTGT2N2PCBF3PV7WBOIKVTGGIEBUUYMDX3CKTDD5HY3UIHV4") + hostAccount := schema.NewDefaultHostAccount(hostAccountKP.Address()) currChannelAccountsCount := 2 currLedgerNum := 100 @@ -903,10 +899,10 @@ func Test_ChannelAccounts_EnsureChannelAccounts_Add_Success(t *testing.T) { MaxLedger: uint32(currLedgerNum + preconditions.IncrementForMaxLedgerBounds), } - publicKeys := []string{ - keypair.MustRandom().Address(), - keypair.MustRandom().Address(), - keypair.MustRandom().Address(), + chTxAccounts := []schema.TransactionAccount{ + schema.NewDefaultChannelAccount(keypair.MustRandom().Address()), + schema.NewDefaultChannelAccount(keypair.MustRandom().Address()), + schema.NewDefaultChannelAccount(keypair.MustRandom().Address()), } mChannelAccountStore. @@ -919,27 +915,24 @@ func Test_ChannelAccounts_EnsureChannelAccounts_Add_Success(t *testing.T) { mLedgerNumberTracker. On("GetLedgerBounds").Return(ledgerBounds, nil).Once() mHorizonClient. - On("AccountDetail", horizonclient.AccountRequest{AccountID: rootAccount.Address()}). - Return(horizon.Account{AccountID: rootAccount.Address()}, nil). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccountKP.Address()}). + Return(horizon.Account{AccountID: hostAccountKP.Address()}, nil). Once(). On("SubmitTransactionWithOptions", mock.Anything, horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}). Return(horizon.Transaction{}, nil). Once() mDistAccResolver. On("HostDistributionAccount"). - Return(rootAccount.Address()). - Twice() - mHostAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), mock.AnythingOfType("string")). - Return(&txnbuild.Transaction{}, nil). + Return(hostAccount). Once() - mChAccSigClient. - On("BatchInsert", ctx, desiredCount-currChannelAccountsCount). - Return(publicKeys, nil). - Once(). - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), mock.AnythingOfType("string"), mock.AnythingOfType("string"), mock.AnythingOfType("string")). + sigRouter. + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), mock.AnythingOfType("schema.TransactionAccount"), mock.AnythingOfType("schema.TransactionAccount"), mock.AnythingOfType("schema.TransactionAccount"), hostAccount). Return(&txnbuild.Transaction{}, nil). Once() + sigRouter. + On("BatchInsert", ctx, schema.ChannelAccountStellarDB, desiredCount-currChannelAccountsCount). + Return(chTxAccounts, nil). + Once() err = cas.EnsureChannelAccountsCount(ctx, desiredCount) require.NoError(t, err) @@ -958,7 +951,7 @@ func Test_ChannelAccounts_EnsureChannelAccounts_Delete_Success(t *testing.T) { defer mHorizonClient.AssertExpectations(t) mChannelAccountStore := storeMocks.NewMockChannelAccountStore(t) mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, mChAccSigClient, _, mHostAccSigClient, mDistAccResolver := signing.NewMockSignatureService(t) + sigService, sigRouter, mDistAccResolver := signing.NewMockSignatureService(t) cas := ChannelAccountsService{ chAccStore: mChannelAccountStore, @@ -971,7 +964,8 @@ func Test_ChannelAccounts_EnsureChannelAccounts_Delete_Success(t *testing.T) { }, } - rootAccount := keypair.MustParseFull("SBMW2WDSVTGT2N2PCBF3PV7WBOIKVTGGIEBUUYMDX3CKTDD5HY3UIHV4") + hostAccountKP := keypair.MustParseFull("SBMW2WDSVTGT2N2PCBF3PV7WBOIKVTGGIEBUUYMDX3CKTDD5HY3UIHV4") + hostAccount := schema.NewDefaultHostAccount(hostAccountKP.Address()) currChannelAccountsCount := 4 channelAccounts := []*store.ChannelAccount{ @@ -1003,8 +997,8 @@ func Test_ChannelAccounts_EnsureChannelAccounts_Delete_Success(t *testing.T) { Return(ledgerBounds, nil). Times(currChannelAccountsCount - wantEnsureCount) mHorizonClient. - On("AccountDetail", horizonclient.AccountRequest{AccountID: rootAccount.Address()}). - Return(horizon.Account{AccountID: rootAccount.Address()}, nil). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccountKP.Address()}). + Return(horizon.Account{AccountID: hostAccountKP.Address()}, nil). Times(currChannelAccountsCount - wantEnsureCount) for _, acc := range channelAccounts { @@ -1018,17 +1012,14 @@ func Test_ChannelAccounts_EnsureChannelAccounts_Delete_Success(t *testing.T) { Once() mDistAccResolver. On("HostDistributionAccount"). - Return(rootAccount.Address()). - Twice() - mHostAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), mock.AnythingOfType("string")). - Return(&txnbuild.Transaction{}, nil). + Return(hostAccount). Once() - mChAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), mock.AnythingOfType("string")). + chTxAcc := schema.NewDefaultChannelAccount(acc.PublicKey) + sigRouter. + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), chTxAcc, hostAccount). Return(&txnbuild.Transaction{}, nil). Once(). - On("Delete", ctx, acc.PublicKey). + On("Delete", ctx, chTxAcc). Return(nil). Once() } diff --git a/internal/transactionsubmission/services/horizon.go b/internal/transactionsubmission/services/horizon.go index a38fce456..f578c52c8 100644 --- a/internal/transactionsubmission/services/horizon.go +++ b/internal/transactionsubmission/services/horizon.go @@ -16,6 +16,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" ) var ErrInvalidNumOfChannelAccountsToCreate = errors.New("invalid number of channel accounts to create") @@ -42,15 +43,17 @@ const DefaultRevokeSponsorshipReserveAmount = "1.5" // is also a good opportunity to periodically write the generated accounts to persistent storage if generating large // amounts of channel accounts. func CreateChannelAccountsOnChain(ctx context.Context, submiterEngine engine.SubmitterEngine, numOfChanAccToCreate int) (newAccountAddresses []string, err error) { + hostAccount := submiterEngine.HostDistributionAccount() defer func() { // If we failed to create the accounts, we should delete the accounts that were added to the signature service. if err != nil { cloneOfNewAccountAddresses := slices.Clone(newAccountAddresses) for _, accountAddress := range cloneOfNewAccountAddresses { - if accountAddress == submiterEngine.HostDistributionAccount() { + if accountAddress == hostAccount.Address { continue } - deleteErr := submiterEngine.ChAccountSigner.Delete(ctx, accountAddress) + chAccToDelete := schema.NewDefaultChannelAccount(accountAddress) + deleteErr := submiterEngine.SignerRouter.Delete(ctx, chAccToDelete) if deleteErr != nil { log.Ctx(ctx).Errorf("failed to delete channel account %s: %v", accountAddress, deleteErr) } @@ -68,11 +71,11 @@ func CreateChannelAccountsOnChain(ctx context.Context, submiterEngine engine.Sub } rootAccount, err := submiterEngine.HorizonClient.AccountDetail(horizonclient.AccountRequest{ - AccountID: submiterEngine.HostDistributionAccount(), + AccountID: hostAccount.Address, }) if err != nil { err = utils.NewHorizonErrorWrapper(err) - return nil, fmt.Errorf("failed to retrieve root account: %w", err) + return nil, fmt.Errorf("failed to retrieve host account: %w", err) } var sponsoredCreateAccountOps []txnbuild.Operation @@ -82,13 +85,14 @@ func CreateChannelAccountsOnChain(ctx context.Context, submiterEngine engine.Sub return nil, fmt.Errorf("failed to get ledger bounds: %w", err) } - publicKeys, err := submiterEngine.ChAccountSigner.BatchInsert(ctx, numOfChanAccToCreate) + stellarAccounts, err := submiterEngine.BatchInsert(ctx, schema.ChannelAccountStellarDB, numOfChanAccToCreate) if err != nil { return nil, fmt.Errorf("failed to insert channel accounts into signature service: %w", err) } // Prepare Stellar operations to create the sponsored channel accounts - for _, publicKey := range publicKeys { + for _, stellarAccount := range stellarAccounts { + publicKey := stellarAccount.Address // generate random keypair for this channel account log.Ctx(ctx).Infof("⏳ Creating sponsored Stellar account with address: %s", publicKey) @@ -128,15 +132,9 @@ func CreateChannelAccountsOnChain(ctx context.Context, submiterEngine engine.Sub } // sign the transaction - // Channel account signing: - tx, err = submiterEngine.ChAccountSigner.SignStellarTransaction(ctx, tx, newAccountAddresses...) - if err != nil { - return newAccountAddresses, fmt.Errorf("signing account creation transaction for channel accounts %v: %w", newAccountAddresses, err) - } - // Host distribution account signing: - tx, err = submiterEngine.HostAccountSigner.SignStellarTransaction(ctx, tx, submiterEngine.HostDistributionAccount()) + tx, err = submiterEngine.SignerRouter.SignStellarTransaction(ctx, tx, append(stellarAccounts, hostAccount)...) if err != nil { - return newAccountAddresses, fmt.Errorf("signing account creation transaction for host distribution account %s: %w", submiterEngine.HostDistributionAccount(), err) + return newAccountAddresses, fmt.Errorf("signing account creation transaction with accounts %v: %w", newAccountAddresses, err) } _, err = submiterEngine.HorizonClient.SubmitTransactionWithOptions(tx, horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}) @@ -151,12 +149,12 @@ func CreateChannelAccountsOnChain(ctx context.Context, submiterEngine engine.Sub // DeleteChannelAccountOnChain creates, signs, and broadcasts a transaction to delete a channel account onchain. func DeleteChannelAccountOnChain(ctx context.Context, submiterEngine engine.SubmitterEngine, chAccAddress string) error { - distributionAccount := submiterEngine.HostDistributionAccount() + hostAccount := submiterEngine.HostDistributionAccount() rootAccount, err := submiterEngine.HorizonClient.AccountDetail(horizonclient.AccountRequest{ - AccountID: distributionAccount, + AccountID: hostAccount.Address, }) if err != nil { - return fmt.Errorf("retrieving root account from distribution seed: %w", err) + return fmt.Errorf("retrieving host account from distribution seed: %w", err) } ledgerBounds, err := submiterEngine.LedgerNumberTracker.GetLedgerBounds() @@ -182,7 +180,7 @@ func DeleteChannelAccountOnChain(ctx context.Context, submiterEngine engine.Subm Account: &chAccAddress, }, &txnbuild.AccountMerge{ - Destination: distributionAccount, + Destination: hostAccount.Address, SourceAccount: chAccAddress, }, }, @@ -203,15 +201,11 @@ func DeleteChannelAccountOnChain(ctx context.Context, submiterEngine engine.Subm // the root account authorizes the sponsorship revocation, while the channel account authorizes // merging into the distribution account. // Channel account signing: - tx, err = submiterEngine.ChAccountSigner.SignStellarTransaction(ctx, tx, chAccAddress) + chAccToDelete := schema.NewDefaultChannelAccount(chAccAddress) + tx, err = submiterEngine.SignerRouter.SignStellarTransaction(ctx, tx, chAccToDelete, hostAccount) if err != nil { return fmt.Errorf("signing remove account transaction for account %s: %w", chAccAddress, err) } - // Host distribution account signing: - tx, err = submiterEngine.HostAccountSigner.SignStellarTransaction(ctx, tx, submiterEngine.HostDistributionAccount()) - if err != nil { - return fmt.Errorf("signing remove account transaction for host distribution account %s: %w", submiterEngine.HostDistributionAccount(), err) - } _, err = submiterEngine.HorizonClient.SubmitTransactionWithOptions(tx, horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}) if err != nil { @@ -219,7 +213,7 @@ func DeleteChannelAccountOnChain(ctx context.Context, submiterEngine engine.Subm return fmt.Errorf("submitting remove account transaction to the network for account %s: %w", chAccAddress, hError) } - err = submiterEngine.ChAccountSigner.Delete(ctx, chAccAddress) + err = submiterEngine.Delete(ctx, chAccToDelete) if err != nil { return fmt.Errorf("deleting channel account %s from the store: %w", chAccAddress, err) } @@ -229,6 +223,7 @@ func DeleteChannelAccountOnChain(ctx context.Context, submiterEngine engine.Subm // CreateAndFundAccount creates and funds a new destination account on the Stellar network with the given amount of native asset from the source account. func CreateAndFundAccount(ctx context.Context, submitterEngine engine.SubmitterEngine, amountNativeAssetToSend int, sourceAcc, destinationAcc string) error { + hostAccount := submitterEngine.HostDistributionAccount() if sourceAcc == destinationAcc { return fmt.Errorf("funding source account and destination account cannot be the same: %s", sourceAcc) } @@ -263,7 +258,7 @@ func CreateAndFundAccount(ctx context.Context, submitterEngine engine.SubmitterE ) } // Host distribution account signing: - tx, err = submitterEngine.HostAccountSigner.SignStellarTransaction(ctx, tx, sourceAcc) + tx, err = submitterEngine.SignStellarTransaction(ctx, tx, hostAccount) if err != nil { return fmt.Errorf( "signing create account tx for account %s: %w", diff --git a/internal/transactionsubmission/services/horizon_test.go b/internal/transactionsubmission/services/horizon_test.go index c4a518432..53e15fe32 100644 --- a/internal/transactionsubmission/services/horizon_test.go +++ b/internal/transactionsubmission/services/horizon_test.go @@ -8,6 +8,8 @@ import ( "net/http" "testing" + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/keypair" "github.com/stellar/go/network" @@ -27,7 +29,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" sigMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing/mocks" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" - "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) @@ -38,41 +40,23 @@ func Test_CreateChannelAccountsOnChain(t *testing.T) { require.NoError(t, err) defer dbConnectionPool.Close() - horizonClientMock := &horizonclient.MockClient{} - privateKeyEncrypterMock := &utils.PrivateKeyEncrypterMock{} ctx := context.Background() chAccModel := store.NewChannelAccountModel(dbConnectionPool) currLedgerNumber := 100 - mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) ledgerBounds := &txnbuild.LedgerBounds{ MaxLedger: uint32(currLedgerNumber + preconditions.IncrementForMaxLedgerBounds), } - distributionKP := keypair.MustRandom() - encrypterPass := distributionKP.Seed() - - mDistAccResolver := sigMocks.NewMockDistributionAccountResolver(t) - mDistAccResolver. - On("HostDistributionAccount"). - Return(distributionKP.Address()) - - sigService, err := signing.NewSignatureService(signing.SignatureServiceOptions{ - DistributionSignerType: signing.DistributionAccountEnvSignatureClientType, - NetworkPassphrase: network.TestNetworkPassphrase, - DBConnectionPool: dbConnectionPool, - DistributionPrivateKey: distributionKP.Seed(), - ChAccEncryptionPassphrase: encrypterPass, - Encrypter: privateKeyEncrypterMock, - LedgerNumberTracker: mLedgerNumberTracker, - DistributionAccountResolver: mDistAccResolver, - }) - require.NoError(t, err) + hostAccountKP := keypair.MustRandom() + hostAccount := schema.NewDefaultHostAccount(hostAccountKP.Address()) + chAccEncrypterPass := keypair.MustRandom().Seed() + dbVaultEncrypterPass := keypair.MustRandom().Seed() testCases := []struct { name string numOfChanAccToCreate int - prepareMocksFn func() + prepareMocksFn func(horizonClientMock *horizonclient.MockClient, mLedgerNumberTracker *preconditionsMocks.MockLedgerNumberTracker, privateKeyEncrypterMock *sdpUtils.PrivateKeyEncrypterMock) wantErrContains string }{ { @@ -93,24 +77,24 @@ func Test_CreateChannelAccountsOnChain(t *testing.T) { { name: "returns error when HorizonClient fails getting AccountDetails", numOfChanAccToCreate: 2, - prepareMocksFn: func() { + prepareMocksFn: func(horizonClientMock *horizonclient.MockClient, _ *preconditionsMocks.MockLedgerNumberTracker, _ *sdpUtils.PrivateKeyEncrypterMock) { horizonClientMock. - On("AccountDetail", horizonclient.AccountRequest{AccountID: sigService.HostDistributionAccount()}). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). Return(horizon.Account{}, horizonclient.Error{ Problem: problem.NotFound, }). Once() }, - wantErrContains: "failed to retrieve root account: horizon response error: StatusCode=404, Type=not_found, Title=Resource Missing, Detail=The resource at the url requested was not found. This usually occurs for one of two reasons: The url requested is not valid, or no data in our database could be found with the parameters provided.", + wantErrContains: "failed to retrieve host account: horizon response error: StatusCode=404, Type=not_found, Title=Resource Missing, Detail=The resource at the url requested was not found. This usually occurs for one of two reasons: The url requested is not valid, or no data in our database could be found with the parameters provided.", }, { name: "returns error when fails to retrieve ledger bounds", numOfChanAccToCreate: 2, - prepareMocksFn: func() { + prepareMocksFn: func(horizonClientMock *horizonclient.MockClient, mLedgerNumberTracker *preconditionsMocks.MockLedgerNumberTracker, _ *sdpUtils.PrivateKeyEncrypterMock) { horizonClientMock. - On("AccountDetail", horizonclient.AccountRequest{AccountID: sigService.HostDistributionAccount()}). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). Return(horizon.Account{ - AccountID: sigService.HostDistributionAccount(), + AccountID: hostAccount.Address, Sequence: 1, }, nil). Once() @@ -124,11 +108,11 @@ func Test_CreateChannelAccountsOnChain(t *testing.T) { { name: "returns error when fails encrypting private key", numOfChanAccToCreate: 2, - prepareMocksFn: func() { + prepareMocksFn: func(horizonClientMock *horizonclient.MockClient, mLedgerNumberTracker *preconditionsMocks.MockLedgerNumberTracker, privateKeyEncrypterMock *sdpUtils.PrivateKeyEncrypterMock) { horizonClientMock. - On("AccountDetail", horizonclient.AccountRequest{AccountID: sigService.HostDistributionAccount()}). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). Return(horizon.Account{ - AccountID: sigService.HostDistributionAccount(), + AccountID: hostAccount.Address, Sequence: 1, }, nil). Once() @@ -136,7 +120,7 @@ func Test_CreateChannelAccountsOnChain(t *testing.T) { On("GetLedgerBounds").Return(ledgerBounds, nil).Once(). On("GetLedgerNumber").Return(currLedgerNumber, nil).Once() privateKeyEncrypterMock. - On("Encrypt", mock.AnythingOfType("string"), encrypterPass). + On("Encrypt", mock.AnythingOfType("string"), chAccEncrypterPass). Return("", errors.New("unexpected error")). Once() }, @@ -145,11 +129,11 @@ func Test_CreateChannelAccountsOnChain(t *testing.T) { { name: "returns error when fails submitting transaction to horizon", numOfChanAccToCreate: 2, - prepareMocksFn: func() { + prepareMocksFn: func(horizonClientMock *horizonclient.MockClient, mLedgerNumberTracker *preconditionsMocks.MockLedgerNumberTracker, privateKeyEncrypterMock *sdpUtils.PrivateKeyEncrypterMock) { horizonClientMock. - On("AccountDetail", horizonclient.AccountRequest{AccountID: sigService.HostDistributionAccount()}). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). Return(horizon.Account{ - AccountID: sigService.HostDistributionAccount(), + AccountID: hostAccount.Address, Sequence: 1, }, nil). Once(). @@ -170,19 +154,19 @@ func Test_CreateChannelAccountsOnChain(t *testing.T) { On("GetLedgerNumber").Return(currLedgerNumber, nil).Times(3) privateKeyEncrypterMock. - On("Encrypt", mock.AnythingOfType("string"), encrypterPass).Return("encryptedkey", nil).Twice(). - On("Decrypt", mock.AnythingOfType("string"), encrypterPass).Return(keypair.MustRandom().Seed(), nil).Twice() + On("Encrypt", mock.AnythingOfType("string"), chAccEncrypterPass).Return("encryptedkey", nil).Twice(). + On("Decrypt", mock.AnythingOfType("string"), chAccEncrypterPass).Return(keypair.MustRandom().Seed(), nil).Twice() }, wantErrContains: "creating sponsored channel accounts: horizon response error: StatusCode=408, Type=https://stellar.org/horizon-errors/timeout, Title=Timeout, Detail=Foo bar detail", }, { name: "🎉 successfully creates channel accounts on-chain (ENCRYPTED)", numOfChanAccToCreate: 3, - prepareMocksFn: func() { + prepareMocksFn: func(horizonClientMock *horizonclient.MockClient, mLedgerNumberTracker *preconditionsMocks.MockLedgerNumberTracker, privateKeyEncrypterMock *sdpUtils.PrivateKeyEncrypterMock) { horizonClientMock. - On("AccountDetail", horizonclient.AccountRequest{AccountID: distributionKP.Address()}). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). Return(horizon.Account{ - AccountID: distributionKP.Address(), + AccountID: hostAccount.Address, Sequence: 1, }, nil). Once(). @@ -195,22 +179,45 @@ func Test_CreateChannelAccountsOnChain(t *testing.T) { On("GetLedgerNumber").Return(currLedgerNumber, nil).Once() privateKeyEncrypterMock. - On("Encrypt", mock.AnythingOfType("string"), encrypterPass).Return("encryptedkey", nil).Times(3). - On("Decrypt", mock.AnythingOfType("string"), encrypterPass).Return(keypair.MustRandom().Seed(), nil).Times(3) + On("Encrypt", mock.AnythingOfType("string"), chAccEncrypterPass).Return("encryptedkey", nil).Times(3). + On("Decrypt", mock.AnythingOfType("string"), chAccEncrypterPass).Return(keypair.MustRandom().Seed(), nil).Times(3) }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + defer store.DeleteAllFromChannelAccounts(t, ctx, dbConnectionPool) + count, err := chAccModel.Count(ctx) require.NoError(t, err) assert.Equal(t, 0, count) + // Prepare mocks + mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) + horizonClientMock := &horizonclient.MockClient{} + privateKeyEncrypterMock := &sdpUtils.PrivateKeyEncrypterMock{} + mDistAccResolver := sigMocks.NewMockDistributionAccountResolver(t) + mDistAccResolver. + On("HostDistributionAccount"). + Return(hostAccount). + Maybe() if tc.prepareMocksFn != nil { - tc.prepareMocksFn() + tc.prepareMocksFn(horizonClientMock, mLedgerNumberTracker, privateKeyEncrypterMock) } + sigService, err := signing.NewSignatureService(signing.SignatureServiceOptions{ + NetworkPassphrase: network.TestNetworkPassphrase, + DBConnectionPool: dbConnectionPool, + DistributionPrivateKey: hostAccountKP.Seed(), + ChAccEncryptionPassphrase: chAccEncrypterPass, + Encrypter: privateKeyEncrypterMock, + LedgerNumberTracker: mLedgerNumberTracker, + DistAccEncryptionPassphrase: dbVaultEncrypterPass, + DistributionAccountResolver: mDistAccResolver, + }) + require.NoError(t, err) + submitterEngine := engine.SubmitterEngine{ HorizonClient: horizonClientMock, SignatureService: sigService, @@ -244,12 +251,10 @@ func Test_CreateChannelAccountsOnChain(t *testing.T) { } } - store.DeleteAllFromChannelAccounts(t, ctx, dbConnectionPool) + horizonClientMock.AssertExpectations(t) + privateKeyEncrypterMock.AssertExpectations(t) }) } - - horizonClientMock.AssertExpectations(t) - privateKeyEncrypterMock.AssertExpectations(t) } func Test_DeleteChannelAccountOnChain(t *testing.T) { @@ -259,52 +264,41 @@ func Test_DeleteChannelAccountOnChain(t *testing.T) { require.NoError(t, err) defer dbConnectionPool.Close() - horizonClientMock := &horizonclient.MockClient{} - privateKeyEncrypterMock := &utils.PrivateKeyEncrypterMock{} ctx := context.Background() - distributionKP := keypair.MustRandom() - distributionAddress := distributionKP.Address() - sigService, mChAccSigClient, _, mHostAccSigClient, mDistAccResolver := signing.NewMockSignatureService(t) + hostAccountKP := keypair.MustRandom() + hostAccount := schema.NewDefaultHostAccount(hostAccountKP.Address()) currLedger := 100 - mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) ledgerBounds := &txnbuild.LedgerBounds{ MaxLedger: uint32(currLedger + preconditions.IncrementForMaxLedgerBounds), } chAccAddress := keypair.MustRandom().Address() + chTxAcc := schema.NewDefaultChannelAccount(chAccAddress) testCases := []struct { name string - prepareMocksFn func() + prepareMocksFn func(horizonClientMock *horizonclient.MockClient, mLedgerNumberTracker *preconditionsMocks.MockLedgerNumberTracker, sigRouter *sigMocks.MockSignerRouter) chAccAddressToDelete string wantErrContains string }{ { name: "returns error when HorizonClient fails getting AccountDetails", - prepareMocksFn: func() { - mDistAccResolver. - On("HostDistributionAccount"). - Return(distributionAddress). - Once() + prepareMocksFn: func(horizonClientMock *horizonclient.MockClient, _ *preconditionsMocks.MockLedgerNumberTracker, _ *sigMocks.MockSignerRouter) { horizonClientMock. - On("AccountDetail", horizonclient.AccountRequest{AccountID: distributionAddress}). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). Return(horizon.Account{}, horizonclient.Error{Problem: problem.NotFound}). Once() }, - wantErrContains: `retrieving root account from distribution seed: horizon error: "Resource Missing" - check horizon.Error.Problem for more information`, + wantErrContains: `retrieving host account from distribution seed: horizon error: "Resource Missing" - check horizon.Error.Problem for more information`, }, { name: "returns error when GetLedgerBounds fails", - prepareMocksFn: func() { - mDistAccResolver. - On("HostDistributionAccount"). - Return(distributionAddress). - Once() + prepareMocksFn: func(horizonClientMock *horizonclient.MockClient, mLedgerNumberTracker *preconditionsMocks.MockLedgerNumberTracker, _ *sigMocks.MockSignerRouter) { horizonClientMock. - On("AccountDetail", horizonclient.AccountRequest{AccountID: distributionAddress}). - Return(horizon.Account{AccountID: distributionAddress, Sequence: 1}, nil). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). + Return(horizon.Account{AccountID: hostAccount.Address, Sequence: 1}, nil). Once() mLedgerNumberTracker. On("GetLedgerBounds"). @@ -316,18 +310,11 @@ func Test_DeleteChannelAccountOnChain(t *testing.T) { { name: "returns error when channel account doesnt exist", chAccAddressToDelete: chAccAddress, - prepareMocksFn: func() { - mDistAccResolver. - On("HostDistributionAccount"). - Return(distributionAddress). - Once() - mChAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), chAccAddress). - Return(nil, fmt.Errorf("signing remove account transaction for account")).Once() + prepareMocksFn: func(horizonClientMock *horizonclient.MockClient, mLedgerNumberTracker *preconditionsMocks.MockLedgerNumberTracker, sigRouter *sigMocks.MockSignerRouter) { horizonClientMock. - On("AccountDetail", horizonclient.AccountRequest{AccountID: distributionAddress}). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). Return(horizon.Account{ - AccountID: distributionAddress, + AccountID: hostAccount.Address, Sequence: 1, }, nil). Once() @@ -335,32 +322,32 @@ func Test_DeleteChannelAccountOnChain(t *testing.T) { On("GetLedgerBounds"). Return(ledgerBounds, nil). Once() + sigRouter. + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), chTxAcc, hostAccount). + Return(nil, fmt.Errorf("signing remove account transaction for account")). + Once() }, wantErrContains: "signing remove account transaction for account", }, { name: "returns error when fails submitting transaction to horizon", chAccAddressToDelete: chAccAddress, - prepareMocksFn: func() { - mDistAccResolver. - On("HostDistributionAccount"). - Return(distributionAddress). - Twice() - mHostAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), distributionAddress). - Return(&txnbuild.Transaction{}, nil). - Once() - mChAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), chAccAddress). - Return(&txnbuild.Transaction{}, nil). - Once() + prepareMocksFn: func(horizonClientMock *horizonclient.MockClient, mLedgerNumberTracker *preconditionsMocks.MockLedgerNumberTracker, sigRouter *sigMocks.MockSignerRouter) { horizonClientMock. - On("AccountDetail", horizonclient.AccountRequest{AccountID: distributionAddress}). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). Return(horizon.Account{ - AccountID: distributionAddress, + AccountID: hostAccount.Address, Sequence: 1, }, nil). Once() + mLedgerNumberTracker. + On("GetLedgerBounds"). + Return(ledgerBounds, nil). + Once() + sigRouter. + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), chTxAcc, hostAccount). + Return(&txnbuild.Transaction{}, nil). + Once() horizonClientMock.On("SubmitTransactionWithOptions", mock.AnythingOfType("*txnbuild.Transaction"), horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}). Return(horizon.Transaction{}, horizonclient.Error{ Problem: problem.P{ @@ -370,10 +357,6 @@ func Test_DeleteChannelAccountOnChain(t *testing.T) { }, }). Once() - mLedgerNumberTracker. - On("GetLedgerBounds"). - Return(ledgerBounds, nil). - Once() }, wantErrContains: fmt.Sprintf( `submitting remove account transaction to the network for account %s: horizon response error: StatusCode=408, Type=https://stellar.org/horizon-errors/timeout, Title=Timeout`, @@ -383,42 +366,44 @@ func Test_DeleteChannelAccountOnChain(t *testing.T) { { name: "🎉 Successfully deletes channel account on chain and database", chAccAddressToDelete: chAccAddress, - prepareMocksFn: func() { - mDistAccResolver. - On("HostDistributionAccount"). - Return(distributionAddress). - Twice() - mHostAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), distributionAddress). - Return(&txnbuild.Transaction{}, nil). - Once() - mChAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), chAccAddress). - Return(&txnbuild.Transaction{}, nil). - Once() - mChAccSigClient.On("Delete", ctx, chAccAddress).Return(nil).Once() + prepareMocksFn: func(horizonClientMock *horizonclient.MockClient, mLedgerNumberTracker *preconditionsMocks.MockLedgerNumberTracker, sigRouter *sigMocks.MockSignerRouter) { horizonClientMock. - On("AccountDetail", horizonclient.AccountRequest{AccountID: distributionAddress}). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). Return(horizon.Account{ - AccountID: distributionAddress, + AccountID: hostAccount.Address, Sequence: 1, }, nil). Once() - horizonClientMock.On("SubmitTransactionWithOptions", mock.AnythingOfType("*txnbuild.Transaction"), horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}). - Return(horizon.Transaction{}, nil). - Once() mLedgerNumberTracker. On("GetLedgerBounds"). Return(ledgerBounds, nil). Once() + sigRouter. + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), chTxAcc, hostAccount). + Return(&txnbuild.Transaction{}, nil). + Once() + horizonClientMock.On("SubmitTransactionWithOptions", mock.AnythingOfType("*txnbuild.Transaction"), horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}). + Return(horizon.Transaction{}, nil). + Once() + sigRouter.On("Delete", ctx, chTxAcc).Return(nil).Once() }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + defer store.DeleteAllFromChannelAccounts(t, ctx, dbConnectionPool) + + // prepare mocks + mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) + horizonClientMock := &horizonclient.MockClient{} + privateKeyEncrypterMock := &sdpUtils.PrivateKeyEncrypterMock{} + sigService, sigRouter, mDistAccResolver := signing.NewMockSignatureService(t) + mDistAccResolver. + On("HostDistributionAccount"). + Return(hostAccount) if tc.prepareMocksFn != nil { - tc.prepareMocksFn() + tc.prepareMocksFn(horizonClientMock, mLedgerNumberTracker, sigRouter) } submitterEngine := engine.SubmitterEngine{ @@ -436,12 +421,10 @@ func Test_DeleteChannelAccountOnChain(t *testing.T) { require.NoError(t, err) } - store.DeleteAllFromChannelAccounts(t, ctx, dbConnectionPool) + horizonClientMock.AssertExpectations(t) + privateKeyEncrypterMock.AssertExpectations(t) }) } - - horizonClientMock.AssertExpectations(t) - privateKeyEncrypterMock.AssertExpectations(t) } func Test_FundDistributionAccount(t *testing.T) { @@ -451,77 +434,74 @@ func Test_FundDistributionAccount(t *testing.T) { require.NoError(t, err) defer dbConnectionPool.Close() - horizonClientMock := &horizonclient.MockClient{} ctx := context.Background() - sigService, _, _, mHostAccSigClient, _ := signing.NewMockSignatureService(t) - mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - - srcAcc := keypair.MustRandom().Address() - dstAcc := keypair.MustRandom().Address() + srcAccAddress := keypair.MustRandom().Address() + hostAccount := schema.NewDefaultHostAccount(srcAccAddress) + dstAccAddress := keypair.MustRandom().Address() tenantDistributionFundingAmount := tenant.MinTenantDistributionAccountAmount require.NoError(t, err) testCases := []struct { name string - prepareMocksFn func() + prepareMocksFn func(horizonClientMock *horizonclient.MockClient, sigRouter *sigMocks.MockSignerRouter) amountToFund int - srcAcc string - dstAcc string + srcAccAddress string + dstAccAddress string wantErrContains string }{ { name: "source account is the same as destination account", amountToFund: tenantDistributionFundingAmount, - srcAcc: srcAcc, - dstAcc: srcAcc, + srcAccAddress: srcAccAddress, + dstAccAddress: srcAccAddress, wantErrContains: "source account and destination account cannot be the same", }, { name: "returns error when HorizonClient fails getting host distribution AccountDetails", - prepareMocksFn: func() { + prepareMocksFn: func(horizonClientMock *horizonclient.MockClient, _ *sigMocks.MockSignerRouter) { horizonClientMock. - On("AccountDetail", horizonclient.AccountRequest{AccountID: srcAcc}). - Return(horizon.Account{AccountID: srcAcc}, horizonclient.Error{Problem: problem.NotFound}). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). + Return(horizon.Account{AccountID: hostAccount.Address}, horizonclient.Error{Problem: problem.NotFound}). Times(CreateAndFundAccountRetryAttempts) }, - amountToFund: tenantDistributionFundingAmount, - srcAcc: srcAcc, - dstAcc: dstAcc, + amountToFund: tenantDistributionFundingAmount, + srcAccAddress: srcAccAddress, + dstAccAddress: dstAccAddress, wantErrContains: fmt.Sprintf( `getting details for source account: cannot find account on the network %s: horizon error: "Resource Missing" - check horizon.Error.Problem for more information`, - srcAcc, + srcAccAddress, ), }, { name: "returns error when failing to sign raw transaction", - prepareMocksFn: func() { + prepareMocksFn: func(horizonClientMock *horizonclient.MockClient, sigRouter *sigMocks.MockSignerRouter) { horizonClientMock. - On("AccountDetail", horizonclient.AccountRequest{AccountID: srcAcc}). - Return(horizon.Account{AccountID: srcAcc}, nil). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). + Return(horizon.Account{AccountID: hostAccount.Address}, nil). Times(CreateAndFundAccountRetryAttempts) - mHostAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), srcAcc). + sigRouter. + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), hostAccount). Return(&txnbuild.Transaction{}, errors.New("failed to sign raw tx")). Times(CreateAndFundAccountRetryAttempts) }, - amountToFund: tenantDistributionFundingAmount, - srcAcc: srcAcc, - dstAcc: dstAcc, + amountToFund: tenantDistributionFundingAmount, + srcAccAddress: srcAccAddress, + dstAccAddress: dstAccAddress, wantErrContains: fmt.Sprintf( `signing create account tx for account %s:`, - dstAcc, + dstAccAddress, ), }, { - name: "returns error when failing to submit tx over Horizon - maximum retries reached", - prepareMocksFn: func() { + name: "returns error when failing to submit tx over Horizon - timeout", + prepareMocksFn: func(horizonClientMock *horizonclient.MockClient, sigRouter *sigMocks.MockSignerRouter) { horizonClientMock. - On("AccountDetail", horizonclient.AccountRequest{AccountID: srcAcc}). - Return(horizon.Account{AccountID: srcAcc}, nil). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). + Return(horizon.Account{AccountID: hostAccount.Address}, nil). Times(CreateAndFundAccountRetryAttempts) - mHostAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), srcAcc). + sigRouter. + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), hostAccount). Return(&txnbuild.Transaction{}, nil). Times(CreateAndFundAccountRetryAttempts) horizonClientMock.On("SubmitTransactionWithOptions", mock.AnythingOfType("*txnbuild.Transaction"), horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}). @@ -538,19 +518,19 @@ func Test_FundDistributionAccount(t *testing.T) { Times(CreateAndFundAccountRetryAttempts) }, amountToFund: tenantDistributionFundingAmount, - srcAcc: srcAcc, - dstAcc: dstAcc, + srcAccAddress: srcAccAddress, + dstAccAddress: dstAccAddress, wantErrContains: "maximum number of retries reached or terminal error encountered", }, { - name: "returns error when failing to submit tx over Horizon - terminal error encountered", - prepareMocksFn: func() { + name: "returns error when failing to submit tx over Horizon - insufficient balance", + prepareMocksFn: func(horizonClientMock *horizonclient.MockClient, sigRouter *sigMocks.MockSignerRouter) { horizonClientMock. - On("AccountDetail", horizonclient.AccountRequest{AccountID: srcAcc}). - Return(horizon.Account{AccountID: srcAcc}, nil). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). + Return(horizon.Account{AccountID: hostAccount.Address}, nil). Once() - mHostAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), srcAcc). + sigRouter. + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), hostAccount). Return(&txnbuild.Transaction{}, nil). Once() horizonClientMock.On("SubmitTransactionWithOptions", mock.AnythingOfType("*txnbuild.Transaction"), horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}). @@ -567,39 +547,46 @@ func Test_FundDistributionAccount(t *testing.T) { Once() }, amountToFund: tenantDistributionFundingAmount, - srcAcc: srcAcc, - dstAcc: dstAcc, + srcAccAddress: srcAccAddress, + dstAccAddress: dstAccAddress, wantErrContains: "maximum number of retries reached or terminal error encountered", }, { name: "successfully creates and funds tenant distribution account", - prepareMocksFn: func() { + prepareMocksFn: func(horizonClientMock *horizonclient.MockClient, sigRouter *sigMocks.MockSignerRouter) { horizonClientMock. - On("AccountDetail", horizonclient.AccountRequest{AccountID: srcAcc}). - Return(horizon.Account{AccountID: srcAcc}, nil). + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). + Return(horizon.Account{AccountID: hostAccount.Address}, nil). Once() - mHostAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), srcAcc). + sigRouter. + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), hostAccount). Return(&txnbuild.Transaction{}, nil). Once() horizonClientMock.On("SubmitTransactionWithOptions", mock.AnythingOfType("*txnbuild.Transaction"), horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}). Return(horizon.Transaction{}, nil). Once() horizonClientMock. - On("AccountDetail", horizonclient.AccountRequest{AccountID: dstAcc}). - Return(horizon.Account{AccountID: dstAcc}, nil). + On("AccountDetail", horizonclient.AccountRequest{AccountID: dstAccAddress}). + Return(horizon.Account{AccountID: dstAccAddress}, nil). Once() }, - amountToFund: tenantDistributionFundingAmount, - srcAcc: srcAcc, - dstAcc: dstAcc, + amountToFund: tenantDistributionFundingAmount, + srcAccAddress: srcAccAddress, + dstAccAddress: dstAccAddress, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) + sigService, sigRouter, mDistAccResolver := signing.NewMockSignatureService(t) + mDistAccResolver. + On("HostDistributionAccount"). + Return(hostAccount) + horizonClientMock := &horizonclient.MockClient{} + if tc.prepareMocksFn != nil { - tc.prepareMocksFn() + tc.prepareMocksFn(horizonClientMock, sigRouter) } submitterEngine := engine.SubmitterEngine{ @@ -609,7 +596,7 @@ func Test_FundDistributionAccount(t *testing.T) { LedgerNumberTracker: mLedgerNumberTracker, } - err = CreateAndFundAccount(ctx, submitterEngine, tc.amountToFund, tc.srcAcc, tc.dstAcc) + err = CreateAndFundAccount(ctx, submitterEngine, tc.amountToFund, tc.srcAccAddress, tc.dstAccAddress) if tc.wantErrContains != "" { require.Error(t, err) assert.ErrorContains(t, err, tc.wantErrContains) diff --git a/internal/transactionsubmission/store/channel_account_test.go b/internal/transactionsubmission/store/channel_account_test.go index 91018b31a..3e3769651 100644 --- a/internal/transactionsubmission/store/channel_account_test.go +++ b/internal/transactionsubmission/store/channel_account_test.go @@ -8,10 +8,11 @@ import ( "time" "github.com/stellar/go/keypair" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) func Test_ChannelAccount_IsLocked(t *testing.T) { diff --git a/internal/transactionsubmission/store/channel_transaction_bundle.go b/internal/transactionsubmission/store/channel_transaction_bundle.go index 2a57d8aee..6913ad91d 100644 --- a/internal/transactionsubmission/store/channel_transaction_bundle.go +++ b/internal/transactionsubmission/store/channel_transaction_bundle.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/lib/pq" + "github.com/stellar/stellar-disbursement-platform-backend/db" ) diff --git a/internal/transactionsubmission/store/channel_transaction_bundle_test.go b/internal/transactionsubmission/store/channel_transaction_bundle_test.go index 4390c7a42..9cf67dd83 100644 --- a/internal/transactionsubmission/store/channel_transaction_bundle_test.go +++ b/internal/transactionsubmission/store/channel_transaction_bundle_test.go @@ -6,10 +6,11 @@ import ( "testing" "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" - "github.com/stretchr/testify/require" ) func Test_NewChannelTransactionBundleModel(t *testing.T) { diff --git a/internal/transactionsubmission/store/db_vault_test.go b/internal/transactionsubmission/store/db_vault_test.go index cdf9c8c79..5d8c6e5a7 100644 --- a/internal/transactionsubmission/store/db_vault_test.go +++ b/internal/transactionsubmission/store/db_vault_test.go @@ -7,10 +7,11 @@ import ( "testing" "time" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) // allDBVaultEntries is a test helper that returns all the dbVaultEntries from the DB. diff --git a/internal/transactionsubmission/store/fixtures.go b/internal/transactionsubmission/store/fixtures.go index 0a7b1ee0e..8412aad78 100644 --- a/internal/transactionsubmission/store/fixtures.go +++ b/internal/transactionsubmission/store/fixtures.go @@ -7,12 +7,13 @@ import ( "testing" "time" + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/lib/pq" "github.com/stellar/go/keypair" "github.com/stretchr/testify/require" "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" ) // CreateTransactionFixtures creates count number submitter transactions @@ -122,7 +123,7 @@ func CreateChannelAccountFixturesEncrypted( t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool, - encrypter utils.PrivateKeyEncrypter, + encrypter sdpUtils.PrivateKeyEncrypter, encryptionPassphrase string, count int, ) []*ChannelAccount { @@ -152,7 +153,7 @@ func CreateChannelAccountFixturesEncryptedKPs( t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool, - encrypter utils.PrivateKeyEncrypter, + encrypter sdpUtils.PrivateKeyEncrypter, encryptionPassphrase string, count int, ) []*keypair.Full { @@ -185,7 +186,7 @@ func CreateDBVaultFixturesEncrypted( t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool, - encrypter utils.PrivateKeyEncrypter, + encrypter sdpUtils.PrivateKeyEncrypter, encryptionPassphrase string, count int, ) []*DBVaultEntry { @@ -223,7 +224,7 @@ func CreateDBVaultFixturesEncryptedKPs( t *testing.T, ctx context.Context, dbConnectionPool db.DBConnectionPool, - encrypter utils.PrivateKeyEncrypter, + encrypter sdpUtils.PrivateKeyEncrypter, encryptionPassphrase string, count int, ) []*keypair.Full { diff --git a/internal/transactionsubmission/store/fixtures_test.go b/internal/transactionsubmission/store/fixtures_test.go index 1dff83aa4..94866fe23 100644 --- a/internal/transactionsubmission/store/fixtures_test.go +++ b/internal/transactionsubmission/store/fixtures_test.go @@ -5,10 +5,11 @@ import ( "testing" "github.com/google/uuid" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) func Test_Fixtures_CreateTransactionFixture(t *testing.T) { diff --git a/internal/transactionsubmission/store/mocks/channel_account_store.go b/internal/transactionsubmission/store/mocks/channel_account_store.go index b807c589d..0faded1b0 100644 --- a/internal/transactionsubmission/store/mocks/channel_account_store.go +++ b/internal/transactionsubmission/store/mocks/channel_account_store.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.27.1. DO NOT EDIT. +// Code generated by mockery v2.40.1. DO NOT EDIT. package mocks @@ -20,6 +20,10 @@ type MockChannelAccountStore struct { func (_m *MockChannelAccountStore) BatchInsert(ctx context.Context, sqlExec db.SQLExecuter, channelAccounts []*store.ChannelAccount) error { ret := _m.Called(ctx, sqlExec, channelAccounts) + if len(ret) == 0 { + panic("no return value specified for BatchInsert") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, db.SQLExecuter, []*store.ChannelAccount) error); ok { r0 = rf(ctx, sqlExec, channelAccounts) @@ -34,6 +38,10 @@ func (_m *MockChannelAccountStore) BatchInsert(ctx context.Context, sqlExec db.S func (_m *MockChannelAccountStore) BatchInsertAndLock(ctx context.Context, channelAccounts []*store.ChannelAccount, currentLedger int, nextLedgerLock int) error { ret := _m.Called(ctx, channelAccounts, currentLedger, nextLedgerLock) + if len(ret) == 0 { + panic("no return value specified for BatchInsertAndLock") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, []*store.ChannelAccount, int, int) error); ok { r0 = rf(ctx, channelAccounts, currentLedger, nextLedgerLock) @@ -48,6 +56,10 @@ func (_m *MockChannelAccountStore) BatchInsertAndLock(ctx context.Context, chann func (_m *MockChannelAccountStore) Count(ctx context.Context) (int, error) { ret := _m.Called(ctx) + if len(ret) == 0 { + panic("no return value specified for Count") + } + var r0 int var r1 error if rf, ok := ret.Get(0).(func(context.Context) (int, error)); ok { @@ -72,6 +84,10 @@ func (_m *MockChannelAccountStore) Count(ctx context.Context) (int, error) { func (_m *MockChannelAccountStore) Delete(ctx context.Context, sqlExec db.SQLExecuter, publicKey string) error { ret := _m.Called(ctx, sqlExec, publicKey) + if len(ret) == 0 { + panic("no return value specified for Delete") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, db.SQLExecuter, string) error); ok { r0 = rf(ctx, sqlExec, publicKey) @@ -86,6 +102,10 @@ func (_m *MockChannelAccountStore) Delete(ctx context.Context, sqlExec db.SQLExe func (_m *MockChannelAccountStore) DeleteIfLockedUntil(ctx context.Context, publicKey string, lockedUntilLedgerNumber int) error { ret := _m.Called(ctx, publicKey, lockedUntilLedgerNumber) + if len(ret) == 0 { + panic("no return value specified for DeleteIfLockedUntil") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, string, int) error); ok { r0 = rf(ctx, publicKey, lockedUntilLedgerNumber) @@ -100,6 +120,10 @@ func (_m *MockChannelAccountStore) DeleteIfLockedUntil(ctx context.Context, publ func (_m *MockChannelAccountStore) Get(ctx context.Context, sqlExec db.SQLExecuter, publicKey string, currentLedgerNumber int) (*store.ChannelAccount, error) { ret := _m.Called(ctx, sqlExec, publicKey, currentLedgerNumber) + if len(ret) == 0 { + panic("no return value specified for Get") + } + var r0 *store.ChannelAccount var r1 error if rf, ok := ret.Get(0).(func(context.Context, db.SQLExecuter, string, int) (*store.ChannelAccount, error)); ok { @@ -126,6 +150,10 @@ func (_m *MockChannelAccountStore) Get(ctx context.Context, sqlExec db.SQLExecut func (_m *MockChannelAccountStore) GetAll(ctx context.Context, sqlExec db.SQLExecuter, currentLedger int, limit int) ([]*store.ChannelAccount, error) { ret := _m.Called(ctx, sqlExec, currentLedger, limit) + if len(ret) == 0 { + panic("no return value specified for GetAll") + } + var r0 []*store.ChannelAccount var r1 error if rf, ok := ret.Get(0).(func(context.Context, db.SQLExecuter, int, int) ([]*store.ChannelAccount, error)); ok { @@ -152,6 +180,10 @@ func (_m *MockChannelAccountStore) GetAll(ctx context.Context, sqlExec db.SQLExe func (_m *MockChannelAccountStore) GetAndLock(ctx context.Context, publicKey string, currentLedger int, nextLedgerLock int) (*store.ChannelAccount, error) { ret := _m.Called(ctx, publicKey, currentLedger, nextLedgerLock) + if len(ret) == 0 { + panic("no return value specified for GetAndLock") + } + var r0 *store.ChannelAccount var r1 error if rf, ok := ret.Get(0).(func(context.Context, string, int, int) (*store.ChannelAccount, error)); ok { @@ -178,6 +210,10 @@ func (_m *MockChannelAccountStore) GetAndLock(ctx context.Context, publicKey str func (_m *MockChannelAccountStore) GetAndLockAll(ctx context.Context, currentLedger int, nextLedgerLock int, limit int) ([]*store.ChannelAccount, error) { ret := _m.Called(ctx, currentLedger, nextLedgerLock, limit) + if len(ret) == 0 { + panic("no return value specified for GetAndLockAll") + } + var r0 []*store.ChannelAccount var r1 error if rf, ok := ret.Get(0).(func(context.Context, int, int, int) ([]*store.ChannelAccount, error)); ok { @@ -204,6 +240,10 @@ func (_m *MockChannelAccountStore) GetAndLockAll(ctx context.Context, currentLed func (_m *MockChannelAccountStore) Insert(ctx context.Context, sqlExec db.SQLExecuter, publicKey string, privateKey string) error { ret := _m.Called(ctx, sqlExec, publicKey, privateKey) + if len(ret) == 0 { + panic("no return value specified for Insert") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, db.SQLExecuter, string, string) error); ok { r0 = rf(ctx, sqlExec, publicKey, privateKey) @@ -218,6 +258,10 @@ func (_m *MockChannelAccountStore) Insert(ctx context.Context, sqlExec db.SQLExe func (_m *MockChannelAccountStore) InsertAndLock(ctx context.Context, publicKey string, privateKey string, currentLedger int, nextLedgerLock int) error { ret := _m.Called(ctx, publicKey, privateKey, currentLedger, nextLedgerLock) + if len(ret) == 0 { + panic("no return value specified for InsertAndLock") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, string, string, int, int) error); ok { r0 = rf(ctx, publicKey, privateKey, currentLedger, nextLedgerLock) @@ -232,6 +276,10 @@ func (_m *MockChannelAccountStore) InsertAndLock(ctx context.Context, publicKey func (_m *MockChannelAccountStore) Lock(ctx context.Context, sqlExec db.SQLExecuter, publicKey string, currentLedger int32, nextLedgerLock int32) (*store.ChannelAccount, error) { ret := _m.Called(ctx, sqlExec, publicKey, currentLedger, nextLedgerLock) + if len(ret) == 0 { + panic("no return value specified for Lock") + } + var r0 *store.ChannelAccount var r1 error if rf, ok := ret.Get(0).(func(context.Context, db.SQLExecuter, string, int32, int32) (*store.ChannelAccount, error)); ok { @@ -258,6 +306,10 @@ func (_m *MockChannelAccountStore) Lock(ctx context.Context, sqlExec db.SQLExecu func (_m *MockChannelAccountStore) Unlock(ctx context.Context, sqlExec db.SQLExecuter, publicKey string) (*store.ChannelAccount, error) { ret := _m.Called(ctx, sqlExec, publicKey) + if len(ret) == 0 { + panic("no return value specified for Unlock") + } + var r0 *store.ChannelAccount var r1 error if rf, ok := ret.Get(0).(func(context.Context, db.SQLExecuter, string) (*store.ChannelAccount, error)); ok { @@ -280,13 +332,12 @@ func (_m *MockChannelAccountStore) Unlock(ctx context.Context, sqlExec db.SQLExe return r0, r1 } -type mockConstructorTestingTNewMockChannelAccountStore interface { +// NewMockChannelAccountStore creates a new instance of MockChannelAccountStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockChannelAccountStore(t interface { mock.TestingT Cleanup(func()) -} - -// NewMockChannelAccountStore creates a new instance of MockChannelAccountStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewMockChannelAccountStore(t mockConstructorTestingTNewMockChannelAccountStore) *MockChannelAccountStore { +}) *MockChannelAccountStore { mock := &MockChannelAccountStore{} mock.Mock.Test(t) diff --git a/internal/transactionsubmission/store/mocks/transaction_store.go b/internal/transactionsubmission/store/mocks/transaction_store.go index 6c2316049..c73067cf4 100644 --- a/internal/transactionsubmission/store/mocks/transaction_store.go +++ b/internal/transactionsubmission/store/mocks/transaction_store.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.27.1. DO NOT EDIT. +// Code generated by mockery v2.40.1. DO NOT EDIT. package mocks @@ -20,6 +20,10 @@ type MockTransactionStore struct { func (_m *MockTransactionStore) BulkInsert(ctx context.Context, sqlExec db.SQLExecuter, transactions []store.Transaction) ([]store.Transaction, error) { ret := _m.Called(ctx, sqlExec, transactions) + if len(ret) == 0 { + panic("no return value specified for BulkInsert") + } + var r0 []store.Transaction var r1 error if rf, ok := ret.Get(0).(func(context.Context, db.SQLExecuter, []store.Transaction) ([]store.Transaction, error)); ok { @@ -46,6 +50,10 @@ func (_m *MockTransactionStore) BulkInsert(ctx context.Context, sqlExec db.SQLEx func (_m *MockTransactionStore) Get(ctx context.Context, txID string) (*store.Transaction, error) { ret := _m.Called(ctx, txID) + if len(ret) == 0 { + panic("no return value specified for Get") + } + var r0 *store.Transaction var r1 error if rf, ok := ret.Get(0).(func(context.Context, string) (*store.Transaction, error)); ok { @@ -72,6 +80,10 @@ func (_m *MockTransactionStore) Get(ctx context.Context, txID string) (*store.Tr func (_m *MockTransactionStore) GetAllByPaymentIDs(ctx context.Context, paymentIDs []string) ([]*store.Transaction, error) { ret := _m.Called(ctx, paymentIDs) + if len(ret) == 0 { + panic("no return value specified for GetAllByPaymentIDs") + } + var r0 []*store.Transaction var r1 error if rf, ok := ret.Get(0).(func(context.Context, []string) ([]*store.Transaction, error)); ok { @@ -98,6 +110,10 @@ func (_m *MockTransactionStore) GetAllByPaymentIDs(ctx context.Context, paymentI func (_m *MockTransactionStore) GetTransactionBatchForUpdate(ctx context.Context, dbTx db.DBTransaction, batchSize int, tenantID string) ([]*store.Transaction, error) { ret := _m.Called(ctx, dbTx, batchSize, tenantID) + if len(ret) == 0 { + panic("no return value specified for GetTransactionBatchForUpdate") + } + var r0 []*store.Transaction var r1 error if rf, ok := ret.Get(0).(func(context.Context, db.DBTransaction, int, string) ([]*store.Transaction, error)); ok { @@ -124,6 +140,10 @@ func (_m *MockTransactionStore) GetTransactionBatchForUpdate(ctx context.Context func (_m *MockTransactionStore) GetTransactionPendingUpdateByID(ctx context.Context, sqlExec db.SQLExecuter, txID string) (*store.Transaction, error) { ret := _m.Called(ctx, sqlExec, txID) + if len(ret) == 0 { + panic("no return value specified for GetTransactionPendingUpdateByID") + } + var r0 *store.Transaction var r1 error if rf, ok := ret.Get(0).(func(context.Context, db.SQLExecuter, string) (*store.Transaction, error)); ok { @@ -150,6 +170,10 @@ func (_m *MockTransactionStore) GetTransactionPendingUpdateByID(ctx context.Cont func (_m *MockTransactionStore) Insert(ctx context.Context, tx store.Transaction) (*store.Transaction, error) { ret := _m.Called(ctx, tx) + if len(ret) == 0 { + panic("no return value specified for Insert") + } + var r0 *store.Transaction var r1 error if rf, ok := ret.Get(0).(func(context.Context, store.Transaction) (*store.Transaction, error)); ok { @@ -176,6 +200,10 @@ func (_m *MockTransactionStore) Insert(ctx context.Context, tx store.Transaction func (_m *MockTransactionStore) Lock(ctx context.Context, sqlExec db.SQLExecuter, transactionID string, currentLedger int32, nextLedgerLock int32) (*store.Transaction, error) { ret := _m.Called(ctx, sqlExec, transactionID, currentLedger, nextLedgerLock) + if len(ret) == 0 { + panic("no return value specified for Lock") + } + var r0 *store.Transaction var r1 error if rf, ok := ret.Get(0).(func(context.Context, db.SQLExecuter, string, int32, int32) (*store.Transaction, error)); ok { @@ -202,6 +230,10 @@ func (_m *MockTransactionStore) Lock(ctx context.Context, sqlExec db.SQLExecuter func (_m *MockTransactionStore) PrepareTransactionForReprocessing(ctx context.Context, sqlExec db.SQLExecuter, transactionID string) (*store.Transaction, error) { ret := _m.Called(ctx, sqlExec, transactionID) + if len(ret) == 0 { + panic("no return value specified for PrepareTransactionForReprocessing") + } + var r0 *store.Transaction var r1 error if rf, ok := ret.Get(0).(func(context.Context, db.SQLExecuter, string) (*store.Transaction, error)); ok { @@ -228,6 +260,10 @@ func (_m *MockTransactionStore) PrepareTransactionForReprocessing(ctx context.Co func (_m *MockTransactionStore) Unlock(ctx context.Context, sqlExec db.SQLExecuter, publicKey string) (*store.Transaction, error) { ret := _m.Called(ctx, sqlExec, publicKey) + if len(ret) == 0 { + panic("no return value specified for Unlock") + } + var r0 *store.Transaction var r1 error if rf, ok := ret.Get(0).(func(context.Context, db.SQLExecuter, string) (*store.Transaction, error)); ok { @@ -254,6 +290,10 @@ func (_m *MockTransactionStore) Unlock(ctx context.Context, sqlExec db.SQLExecut func (_m *MockTransactionStore) UpdateStatusToError(ctx context.Context, tx store.Transaction, message string) (*store.Transaction, error) { ret := _m.Called(ctx, tx, message) + if len(ret) == 0 { + panic("no return value specified for UpdateStatusToError") + } + var r0 *store.Transaction var r1 error if rf, ok := ret.Get(0).(func(context.Context, store.Transaction, string) (*store.Transaction, error)); ok { @@ -280,6 +320,10 @@ func (_m *MockTransactionStore) UpdateStatusToError(ctx context.Context, tx stor func (_m *MockTransactionStore) UpdateStatusToSuccess(ctx context.Context, tx store.Transaction) (*store.Transaction, error) { ret := _m.Called(ctx, tx) + if len(ret) == 0 { + panic("no return value specified for UpdateStatusToSuccess") + } + var r0 *store.Transaction var r1 error if rf, ok := ret.Get(0).(func(context.Context, store.Transaction) (*store.Transaction, error)); ok { @@ -306,6 +350,10 @@ func (_m *MockTransactionStore) UpdateStatusToSuccess(ctx context.Context, tx st func (_m *MockTransactionStore) UpdateStellarTransactionHashAndXDRSent(ctx context.Context, txID string, txHash string, txXDRSent string) (*store.Transaction, error) { ret := _m.Called(ctx, txID, txHash, txXDRSent) + if len(ret) == 0 { + panic("no return value specified for UpdateStellarTransactionHashAndXDRSent") + } + var r0 *store.Transaction var r1 error if rf, ok := ret.Get(0).(func(context.Context, string, string, string) (*store.Transaction, error)); ok { @@ -332,6 +380,10 @@ func (_m *MockTransactionStore) UpdateStellarTransactionHashAndXDRSent(ctx conte func (_m *MockTransactionStore) UpdateStellarTransactionXDRReceived(ctx context.Context, txID string, xdrReceived string) (*store.Transaction, error) { ret := _m.Called(ctx, txID, xdrReceived) + if len(ret) == 0 { + panic("no return value specified for UpdateStellarTransactionXDRReceived") + } + var r0 *store.Transaction var r1 error if rf, ok := ret.Get(0).(func(context.Context, string, string) (*store.Transaction, error)); ok { @@ -358,6 +410,10 @@ func (_m *MockTransactionStore) UpdateStellarTransactionXDRReceived(ctx context. func (_m *MockTransactionStore) UpdateSyncedTransactions(ctx context.Context, sqlExec db.SQLExecuter, txIDs []string) error { ret := _m.Called(ctx, sqlExec, txIDs) + if len(ret) == 0 { + panic("no return value specified for UpdateSyncedTransactions") + } + var r0 error if rf, ok := ret.Get(0).(func(context.Context, db.SQLExecuter, []string) error); ok { r0 = rf(ctx, sqlExec, txIDs) @@ -368,13 +424,12 @@ func (_m *MockTransactionStore) UpdateSyncedTransactions(ctx context.Context, sq return r0 } -type mockConstructorTestingTNewMockTransactionStore interface { +// NewMockTransactionStore creates a new instance of MockTransactionStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockTransactionStore(t interface { mock.TestingT Cleanup(func()) -} - -// NewMockTransactionStore creates a new instance of MockTransactionStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewMockTransactionStore(t mockConstructorTestingTNewMockTransactionStore) *MockTransactionStore { +}) *MockTransactionStore { mock := &MockTransactionStore{} mock.Mock.Test(t) diff --git a/internal/transactionsubmission/store/transaction_state_machine_test.go b/internal/transactionsubmission/store/transaction_state_machine_test.go index f10c5c827..2e6939821 100644 --- a/internal/transactionsubmission/store/transaction_state_machine_test.go +++ b/internal/transactionsubmission/store/transaction_state_machine_test.go @@ -4,9 +4,10 @@ import ( "fmt" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/internal/data" ) func Test_TransactionStatus_All(t *testing.T) { diff --git a/internal/transactionsubmission/store/transactions.go b/internal/transactionsubmission/store/transactions.go index 72bfcc34c..c33941a9e 100644 --- a/internal/transactionsubmission/store/transactions.go +++ b/internal/transactionsubmission/store/transactions.go @@ -13,6 +13,7 @@ import ( "github.com/lib/pq" "github.com/stellar/go/strkey" "github.com/stellar/go/xdr" + "github.com/stellar/stellar-disbursement-platform-backend/db" ) diff --git a/internal/transactionsubmission/store/transactions_test.go b/internal/transactionsubmission/store/transactions_test.go index c91c17a68..4004e9534 100644 --- a/internal/transactionsubmission/store/transactions_test.go +++ b/internal/transactionsubmission/store/transactions_test.go @@ -8,10 +8,11 @@ import ( "github.com/google/uuid" "github.com/stellar/go/keypair" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) func Test_Transaction_IsLocked(t *testing.T) { diff --git a/internal/transactionsubmission/transaction_worker.go b/internal/transactionsubmission/transaction_worker.go index 0f144a591..7f8b62181 100644 --- a/internal/transactionsubmission/transaction_worker.go +++ b/internal/transactionsubmission/transaction_worker.go @@ -507,18 +507,16 @@ func (tw *TransactionWorker) buildAndSignTransaction(ctx context.Context, txJob } } - var distributionAccountPubKey string - var distributionAccount *schema.DistributionAccount - if distributionAccount, err = tw.engine.DistributionAccountResolver.DistributionAccount(ctx, txJob.Transaction.TenantID); err != nil { + distributionAccount, err := tw.engine.DistributionAccountResolver.DistributionAccount(ctx, txJob.Transaction.TenantID) + if err != nil { return nil, fmt.Errorf("resolving distribution account for tenantID=%s: %w", txJob.Transaction.TenantID, err) } else if !distributionAccount.IsStellar() { return nil, fmt.Errorf("expected distribution account to be a STELLAR account but got %q", distributionAccount.Type) - } else { - distributionAccountPubKey = distributionAccount.Address } horizonAccount, err := tw.engine.HorizonClient.AccountDetail(horizonclient.AccountRequest{AccountID: txJob.ChannelAccount.PublicKey}) if err != nil { + err = fmt.Errorf("getting account detail: %w", err) return nil, utils.NewHorizonErrorWrapper(err) } @@ -531,7 +529,7 @@ func (tw *TransactionWorker) buildAndSignTransaction(ctx context.Context, txJob }, Operations: []txnbuild.Operation{ &txnbuild.Payment{ - SourceAccount: distributionAccountPubKey, + SourceAccount: distributionAccount.Address, Amount: strconv.FormatFloat(txJob.Transaction.Amount, 'f', 6, 32), // TODO find a better way to do this Destination: txJob.Transaction.Destination, Asset: asset, @@ -550,21 +548,20 @@ func (tw *TransactionWorker) buildAndSignTransaction(ctx context.Context, txJob } // Sign tx for the channel account: - paymentTx, err = tw.engine.ChAccountSigner.SignStellarTransaction(ctx, paymentTx, txJob.ChannelAccount.PublicKey) - if err != nil { - return nil, fmt.Errorf("signing transaction for channel account: for job %v: %w", txJob, err) + chAccount := schema.TransactionAccount{ + Address: txJob.ChannelAccount.PublicKey, + Type: schema.ChannelAccountStellarDB, } - // Sign tx for the distribution account: - paymentTx, err = tw.engine.DistAccountSigner.SignStellarTransaction(ctx, paymentTx, distributionAccountPubKey) + paymentTx, err = tw.engine.SignerRouter.SignStellarTransaction(ctx, paymentTx, chAccount, distributionAccount) if err != nil { - return nil, fmt.Errorf("signing transaction for distribution account: for job %v: %w", txJob, err) + return nil, fmt.Errorf("signing transaction in job=%v: %w", txJob, err) } // build the outer fee-bump transaction feeBumpTx, err = txnbuild.NewFeeBumpTransaction( txnbuild.FeeBumpTransactionParams{ Inner: paymentTx, - FeeAccount: distributionAccountPubKey, + FeeAccount: distributionAccount.Address, BaseFee: int64(tw.engine.MaxBaseFee), }, ) @@ -573,7 +570,7 @@ func (tw *TransactionWorker) buildAndSignTransaction(ctx context.Context, txJob } // Sign fee-bump tx for the distribution account: - feeBumpTx, err = tw.engine.DistAccountSigner.SignFeeBumpStellarTransaction(ctx, feeBumpTx, distributionAccountPubKey) + feeBumpTx, err = tw.engine.SignerRouter.SignFeeBumpStellarTransaction(ctx, feeBumpTx, distributionAccount) if err != nil { return nil, fmt.Errorf("signing fee-bump transaction for job %v: %w", txJob, err) } diff --git a/internal/transactionsubmission/transaction_worker_test.go b/internal/transactionsubmission/transaction_worker_test.go index c9464bfb0..e5020f802 100644 --- a/internal/transactionsubmission/transaction_worker_test.go +++ b/internal/transactionsubmission/transaction_worker_test.go @@ -43,7 +43,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store" storeMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/store/mocks" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/utils" - sdpUtlis "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) @@ -64,20 +64,22 @@ func getTransactionWorkerInstance(t *testing.T, dbConnectionPool db.DBConnection require.NoError(t, err) distributionKP := keypair.MustRandom() + distAccount := schema.NewStellarEnvTransactionAccount(distributionKP.Address()) mDistAccResolver := sigMocks.NewMockDistributionAccountResolver(t) mDistAccResolver. On("DistributionAccount", mock.Anything, mock.AnythingOfType("string")). - Return(schema.NewDefaultStellarDistributionAccount(distributionKP.Address()), nil). + Return(distAccount, nil). Maybe() + distAccEncryptionPassphrase := keypair.MustRandom().Seed() sigService, err := signing.NewSignatureService(signing.SignatureServiceOptions{ - DistributionSignerType: signing.DistributionAccountEnvSignatureClientType, NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, DistributionPrivateKey: distributionKP.Seed(), ChAccEncryptionPassphrase: chAccEncryptionPassphrase, LedgerNumberTracker: preconditionsMocks.NewMockLedgerNumberTracker(t), + DistAccEncryptionPassphrase: distAccEncryptionPassphrase, DistributionAccountResolver: mDistAccResolver, }) require.NoError(t, err) @@ -100,7 +102,7 @@ func getTransactionWorkerInstance(t *testing.T, dbConnectionPool db.DBConnection } var ( - encrypter = &utils.DefaultPrivateKeyEncrypter{} + encrypter = &sdpUtils.DefaultPrivateKeyEncrypter{} chAccEncryptionPassphrase = keypair.MustRandom().Seed() ) @@ -150,14 +152,15 @@ func Test_NewTransactionWorker(t *testing.T) { distributionKP := keypair.MustRandom() + distAccEncryptionPassphrase := keypair.MustRandom().Seed() wantSigService, err := signing.NewSignatureService(signing.SignatureServiceOptions{ - DistributionSignerType: signing.DistributionAccountEnvSignatureClientType, NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, DistributionPrivateKey: distributionKP.Seed(), ChAccEncryptionPassphrase: chAccEncryptionPassphrase, LedgerNumberTracker: preconditionsMocks.NewMockLedgerNumberTracker(t), + DistAccEncryptionPassphrase: distAccEncryptionPassphrase, DistributionAccountResolver: sigMocks.NewMockDistributionAccountResolver(t), }) require.NoError(t, err) @@ -1640,14 +1643,15 @@ func Test_TransactionWorker_buildAndSignTransaction(t *testing.T) { const accountSequence = 123 distributionKP := keypair.MustRandom() + distAccount := schema.NewStellarEnvTransactionAccount(distributionKP.Address()) mDistAccResolver := sigMocks.NewMockDistributionAccountResolver(t) mDistAccResolver. On("DistributionAccount", ctx, mock.AnythingOfType("string")). - Return(schema.NewDefaultStellarDistributionAccount(distributionKP.Address()), nil) + Return(distAccount, nil) + distAccEncryptionPassphrase := keypair.MustRandom().Seed() sigService, err := signing.NewSignatureService(signing.SignatureServiceOptions{ - DistributionSignerType: signing.DistributionAccountEnvSignatureClientType, NetworkPassphrase: network.TestNetworkPassphrase, DBConnectionPool: dbConnectionPool, DistributionPrivateKey: distributionKP.Seed(), @@ -1655,6 +1659,7 @@ func Test_TransactionWorker_buildAndSignTransaction(t *testing.T) { LedgerNumberTracker: preconditionsMocks.NewMockLedgerNumberTracker(t), DistributionAccountResolver: mDistAccResolver, + DistAccEncryptionPassphrase: distAccEncryptionPassphrase, }) require.NoError(t, err) @@ -1711,7 +1716,7 @@ func Test_TransactionWorker_buildAndSignTransaction(t *testing.T) { // mock horizon mockHorizon := &horizonclient.MockClient{} - if !sdpUtlis.IsEmpty(tc.getAccountResponseObj) || !sdpUtlis.IsEmpty(tc.getAccountResponseError) { + if !sdpUtils.IsEmpty(tc.getAccountResponseObj) || !sdpUtils.IsEmpty(tc.getAccountResponseError) { var hErr error if tc.getAccountResponseError != nil { hErr = tc.getAccountResponseError @@ -1776,9 +1781,8 @@ func Test_TransactionWorker_buildAndSignTransaction(t *testing.T) { }, ) require.NoError(t, err) - wantInnerTx, err = sigService.ChAccountSigner.SignStellarTransaction(ctx, wantInnerTx, txJob.ChannelAccount.PublicKey) - require.NoError(t, err) - wantInnerTx, err = sigService.DistAccountSigner.SignStellarTransaction(ctx, wantInnerTx, distributionKP.Address()) + chAccount := schema.NewDefaultChannelAccount(txJob.ChannelAccount.PublicKey) + wantInnerTx, err = sigService.SignerRouter.SignStellarTransaction(ctx, wantInnerTx, chAccount, distAccount) require.NoError(t, err) wantFeeBumpTx, err := txnbuild.NewFeeBumpTransaction( @@ -1789,7 +1793,7 @@ func Test_TransactionWorker_buildAndSignTransaction(t *testing.T) { }, ) require.NoError(t, err) - wantFeeBumpTx, err = sigService.DistAccountSigner.SignFeeBumpStellarTransaction(ctx, wantFeeBumpTx, distributionKP.Address()) + wantFeeBumpTx, err = sigService.SignerRouter.SignFeeBumpStellarTransaction(ctx, wantFeeBumpTx, distAccount) require.NoError(t, err) assert.Equal(t, wantFeeBumpTx, gotFeeBumpTx) } diff --git a/internal/transactionsubmission/utils/errors.go b/internal/transactionsubmission/utils/errors.go index c1b721b6d..94445bf46 100644 --- a/internal/transactionsubmission/utils/errors.go +++ b/internal/transactionsubmission/utils/errors.go @@ -11,6 +11,7 @@ import ( "github.com/stellar/go/protocols/horizon" "github.com/stellar/go/support/log" "github.com/stellar/go/support/render/problem" + sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) diff --git a/internal/transactionsubmission/utils/utils.go b/internal/transactionsubmission/utils/utils.go index 8c0baa8da..d4177139b 100644 --- a/internal/transactionsubmission/utils/utils.go +++ b/internal/transactionsubmission/utils/utils.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/stellar/stellar-disbursement-platform-backend/db" - sdpUtils "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" ) // AcquireAdvisoryLock attempt to acquire an advisory lock on the provided lockKey, returns true if acquired, or false @@ -19,21 +18,3 @@ func AcquireAdvisoryLock(ctx context.Context, dbConnectionPool db.DBConnectionPo } return tssAdvisoryLockAcquired, nil } - -type PrivateKeyEncrypter interface { - Encrypt(message string, passphrase string) (string, error) - Decrypt(message string, passphrase string) (string, error) -} - -type DefaultPrivateKeyEncrypter struct{} - -func (e *DefaultPrivateKeyEncrypter) Encrypt(message, passphrase string) (string, error) { - return sdpUtils.Encrypt(message, passphrase) -} - -func (e *DefaultPrivateKeyEncrypter) Decrypt(message, passphrase string) (string, error) { - return sdpUtils.Decrypt(message, passphrase) -} - -// Making sure that DefaultPrivateKeyEncrypter implements PrivateKeyEncrypter -var _ PrivateKeyEncrypter = (*DefaultPrivateKeyEncrypter)(nil) diff --git a/internal/transactionsubmission/utils/utils_test.go b/internal/transactionsubmission/utils/utils_test.go index 6b17c46d1..a2acce0e2 100644 --- a/internal/transactionsubmission/utils/utils_test.go +++ b/internal/transactionsubmission/utils/utils_test.go @@ -7,9 +7,10 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" - "github.com/stretchr/testify/require" ) func TestAdvisoryLockAndRelease(t *testing.T) { diff --git a/internal/utils/encrypter.go b/internal/utils/encrypter.go new file mode 100644 index 000000000..14e8c7e01 --- /dev/null +++ b/internal/utils/encrypter.go @@ -0,0 +1,19 @@ +package utils + +type PrivateKeyEncrypter interface { + Encrypt(message string, passphrase string) (string, error) + Decrypt(message string, passphrase string) (string, error) +} + +type DefaultPrivateKeyEncrypter struct{} + +func (e *DefaultPrivateKeyEncrypter) Encrypt(message, passphrase string) (string, error) { + return Encrypt(message, passphrase) +} + +func (e *DefaultPrivateKeyEncrypter) Decrypt(message, passphrase string) (string, error) { + return Decrypt(message, passphrase) +} + +// Making sure that DefaultPrivateKeyEncrypter implements PrivateKeyEncrypter +var _ PrivateKeyEncrypter = (*DefaultPrivateKeyEncrypter)(nil) diff --git a/internal/transactionsubmission/utils/mocks.go b/internal/utils/mocks.go similarity index 68% rename from internal/transactionsubmission/utils/mocks.go rename to internal/utils/mocks.go index a5af0b192..dbcdec2f2 100644 --- a/internal/transactionsubmission/utils/mocks.go +++ b/internal/utils/mocks.go @@ -18,3 +18,17 @@ func (pke *PrivateKeyEncrypterMock) Decrypt(message, passphrase string) (string, // Making sure that PrivateKeyEncrypterMock implements PrivateKeyEncrypter var _ PrivateKeyEncrypter = (*PrivateKeyEncrypterMock)(nil) + +type testInterface interface { + mock.TestingT + Cleanup(func()) +} + +func NewPrivateKeyEncrypterMock(t testInterface) *PrivateKeyEncrypterMock { + mock := &PrivateKeyEncrypterMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/internal/utils/network_type.go b/internal/utils/network_type.go index 7cec3f01d..86bd3de5d 100644 --- a/internal/utils/network_type.go +++ b/internal/utils/network_type.go @@ -2,6 +2,7 @@ package utils import ( "fmt" + "slices" "github.com/stellar/go/network" ) @@ -13,6 +14,20 @@ const ( TestnetNetworkType NetworkType = "testnet" ) +func AllNetworkTypes() []NetworkType { + return []NetworkType{ + TestnetNetworkType, + PubnetNetworkType, + } +} + +func (n NetworkType) Validate() error { + if !slices.Contains(AllNetworkTypes(), n) { + return fmt.Errorf("invalid network type %q", n) + } + return nil +} + func GetNetworkTypeFromNetworkPassphrase(networkPassphrase string) (NetworkType, error) { switch networkPassphrase { case network.PublicNetworkPassphrase: diff --git a/internal/utils/network_type_test.go b/internal/utils/network_type_test.go index 19d5d891d..c7b8b20dd 100644 --- a/internal/utils/network_type_test.go +++ b/internal/utils/network_type_test.go @@ -1,12 +1,49 @@ package utils import ( + "fmt" "testing" "github.com/stellar/go/network" "github.com/stretchr/testify/assert" ) +func Test_AllNetworkTypes(t *testing.T) { + expectedNetworkTypes := []NetworkType{ + TestnetNetworkType, + PubnetNetworkType, + } + + assert.Equal(t, expectedNetworkTypes, AllNetworkTypes()) +} + +func Test_NetworkType_Validate(t *testing.T) { + testCases := []struct { + networkType NetworkType + expectedErr error + }{ + { + networkType: TestnetNetworkType, + expectedErr: nil, + }, + { + networkType: PubnetNetworkType, + expectedErr: nil, + }, + { + networkType: "UNSUPPORTED", + expectedErr: fmt.Errorf(`invalid network type "UNSUPPORTED"`), + }, + } + + for _, tc := range testCases { + t.Run(string(tc.networkType), func(t *testing.T) { + err := tc.networkType.Validate() + assert.Equal(t, tc.expectedErr, err) + }) + } +} + func Test_GetNetworkTypeFromNetworkPassphrase(t *testing.T) { testCases := []struct { networkPassphrase string diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 9fb3c0afe..3bcca2950 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -6,6 +6,7 @@ import ( "net/http" "reflect" "strings" + "time" "github.com/go-chi/chi/v5" ) @@ -43,7 +44,12 @@ func UnwrapInterfaceToPointer[T any](i interface{}) *T { // IsEmpty checks if a value is empty. func IsEmpty[T any](v T) bool { - return reflect.ValueOf(&v).Elem().IsZero() + valueType := reflect.TypeOf(v) + if valueType == nil { // this condition will be true when v is nil and valueType is either `any` or `interface{}` + return true + } + + return reflect.DeepEqual(v, reflect.Zero(valueType).Interface()) } func MapSlice[T any, M any](a []T, f func(T) M) []M { @@ -83,3 +89,12 @@ func GetTypeName(v interface{}) string { return fullTypeName } + +// StringPtr returns a pointer to a string +func StringPtr(s string) *string { + return &s +} + +func TimePtr(t time.Time) *time.Time { + return &t +} diff --git a/internal/utils/utils_test.go b/internal/utils/utils_test.go index 9e597ad46..dddce9f95 100644 --- a/internal/utils/utils_test.go +++ b/internal/utils/utils_test.go @@ -101,6 +101,9 @@ func Test_IsEmpty(t *testing.T) { // Interface: {name: "Interface nil", isEmptyFn: func() bool { return IsEmpty[interface{}](nil) }, expected: true}, {name: "Interface non-nil", isEmptyFn: func() bool { return IsEmpty[interface{}](new(string)) }, expected: false}, + // Any: + {name: "Any nil", isEmptyFn: func() bool { return IsEmpty[any](nil) }, expected: true}, + {name: "Any non-nil", isEmptyFn: func() bool { return IsEmpty[any](new(string)) }, expected: false}, // Map: {name: "Map nil", isEmptyFn: func() bool { return IsEmpty[map[string]string](nil) }, expected: true}, {name: "Map empty", isEmptyFn: func() bool { return IsEmpty[map[string]string](map[string]string{}) }, expected: false}, @@ -242,3 +245,33 @@ func Test_GetTypeName(t *testing.T) { }) } } + +func TestStringPtr(t *testing.T) { + t.Run("returns a pointer to the string", func(t *testing.T) { + s := "test string" + result := StringPtr(s) + + assert.NotNil(t, result) + assert.Equal(t, s, *result) + }) + + t.Run("returns a pointer to an empty string", func(t *testing.T) { + s := "" + result := StringPtr(s) + + assert.NotNil(t, result) + assert.Equal(t, s, *result) + }) + + t.Run("changing the original string does not affect the pointer", func(t *testing.T) { + s := "initial string" + result := StringPtr(s) + + // Modify the original string + s = "modified string" + + assert.NotNil(t, result) + assert.NotEqual(t, s, *result) + assert.Equal(t, "initial string", *result) + }) +} diff --git a/internal/utils/validation.go b/internal/utils/validation.go index 979773a45..0a7591656 100644 --- a/internal/utils/validation.go +++ b/internal/utils/validation.go @@ -4,6 +4,7 @@ import ( "fmt" "regexp" "strconv" + "time" "github.com/asaskevich/govalidator" "github.com/nyaruka/phonenumbers" @@ -17,6 +18,13 @@ var ( ErrEmptyPhoneNumber = fmt.Errorf("phone number cannot be empty") ) +const ( + VerificationFieldPinMinLength = 4 + VerificationFieldPinMaxLength = 8 + + VerificationFieldMaxIdLength = 50 +) + // https://github.com/firebase/firebase-admin-go/blob/cef91acd46f2fc5d0b3408d8154a0005db5bdb0b/auth/user_mgt.go#L449-L457 func ValidatePhoneNumber(phoneNumberStr string) error { if phoneNumberStr == "" { @@ -90,3 +98,66 @@ func ValidateOTP(otp string) error { return nil } + +// ValidateDateOfBirthVerification will validate the date of birth field for receiver verification. +func ValidateDateOfBirthVerification(dob string) error { + // make sure date of birth is not empty + if dob == "" { + return fmt.Errorf("date of birth cannot be empty") + } + // make sure date of birth with format 2006-01-02 + dateOfBrith, err := time.Parse("2006-01-02", dob) + if err != nil { + return fmt.Errorf("invalid date of birth format. Correct format: 1990-01-30") + } + + // check if date of birth is in the past + if dateOfBrith.After(time.Now()) { + return fmt.Errorf("date of birth cannot be in the future") + } + + return nil +} + +// ValidateYearMonthVerification will validate the year/month field for receiver verification. +func ValidateYearMonthVerification(yearMonth string) error { + // make sure year/month is not empty + if yearMonth == "" { + return fmt.Errorf("year/month cannot be empty") + } + + // make sure year/month with format 2006-01 + ym, err := time.Parse("2006-01", yearMonth) + if err != nil { + return fmt.Errorf("invalid year/month format. Correct format: 1990-12") + } + + // check if year/month is in the past + if ym.After(time.Now()) { + return fmt.Errorf("year/month cannot be in the future") + } + + return nil +} + +// ValidatePinVerification will validate the pin field for receiver verification. +func ValidatePinVerification(pin string) error { + if len(pin) < VerificationFieldPinMinLength || len(pin) > VerificationFieldPinMaxLength { + return fmt.Errorf("invalid pin length. Cannot have less than %d or more than %d characters in pin", VerificationFieldPinMinLength, VerificationFieldPinMaxLength) + } + + return nil +} + +// ValidateNationalIDVerification will validate the national id field for receiver verification. +func ValidateNationalIDVerification(nationalID string) error { + if nationalID == "" { + return fmt.Errorf("national id cannot be empty") + } + + if len(nationalID) > VerificationFieldMaxIdLength { + return fmt.Errorf("invalid national id. Cannot have more than %d characters in national id", VerificationFieldMaxIdLength) + } + + return nil +} diff --git a/internal/utils/validation_test.go b/internal/utils/validation_test.go index 721dfb127..517d331a1 100644 --- a/internal/utils/validation_test.go +++ b/internal/utils/validation_test.go @@ -3,6 +3,7 @@ package utils import ( "fmt" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -139,3 +140,85 @@ func Test_ValidateOTP(t *testing.T) { }) } } + +func Test_ValidateDateOfBirthVerification(t *testing.T) { + tests := []struct { + name string + dob string + expectedError error + }{ + {"valid DOB", "1990-01-30", nil}, + {"invalid DOB - empty DOB", "", fmt.Errorf("date of birth cannot be empty")}, + {"invalid DOB - invalid format", "30-01-1990", fmt.Errorf("invalid date of birth format. Correct format: 1990-01-30")}, + {"invalid DOB - future date", time.Now().AddDate(1, 0, 0).Format("2006-01-02"), fmt.Errorf("date of birth cannot be in the future")}, + {"invalid DOB - invalid day", "1990-01-32", fmt.Errorf("invalid date of birth format. Correct format: 1990-01-30")}, + {"invalid DOB - invalid month", "1990-13-01", fmt.Errorf("invalid date of birth format. Correct format: 1990-01-30")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateDateOfBirthVerification(tt.dob) + assert.Equal(t, tt.expectedError, err) + }) + } +} + +func Test_ValidateYearMonthVerification(t *testing.T) { + tests := []struct { + name string + yearMonth string + expectedError error + }{ + {"valid yearMonth", "1990-12", nil}, + {"invalid yearMonth - yearMonth DOB", "", fmt.Errorf("year/month cannot be empty")}, + {"invalid yearMonth - invalid format", "01-1990", fmt.Errorf("invalid year/month format. Correct format: 1990-12")}, + {"invalid yearMonth - future date", time.Now().AddDate(1, 0, 0).Format("2006-01"), fmt.Errorf("year/month cannot be in the future")}, + {"invalid yearMonth - invalid month", "1990-13", fmt.Errorf("invalid year/month format. Correct format: 1990-12")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateYearMonthVerification(tt.yearMonth) + assert.Equal(t, tt.expectedError, err) + }) + } +} + +func Test_ValidatePinVerification(t *testing.T) { + tests := []struct { + name string + pin string + expectedError error + }{ + {"valid PIN", "1234", nil}, + {"invalid PIN - too short", "123", fmt.Errorf("invalid pin length. Cannot have less than %d or more than %d characters in pin", VerificationFieldPinMinLength, VerificationFieldPinMaxLength)}, + {"invalid PIN - too long", "12345678901", fmt.Errorf("invalid pin length. Cannot have less than %d or more than %d characters in pin", VerificationFieldPinMinLength, VerificationFieldPinMaxLength)}, + {"invalid PIN - empty", "", fmt.Errorf("invalid pin length. Cannot have less than %d or more than %d characters in pin", VerificationFieldPinMinLength, VerificationFieldPinMaxLength)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidatePinVerification(tt.pin) + assert.Equal(t, tt.expectedError, err) + }) + } +} + +func Test_ValidateNationalIDVerification(t *testing.T) { + tests := []struct { + name string + nationalID string + expectedError error + }{ + {"valid National ID", "1234567890", nil}, + {"invalid National ID - empty", "", fmt.Errorf("national id cannot be empty")}, + {"invalid National ID - too long", fmt.Sprintf("%0*d", VerificationFieldMaxIdLength+1, 0), fmt.Errorf("invalid national id. Cannot have more than %d characters in national id", VerificationFieldMaxIdLength)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateNationalIDVerification(tt.nationalID) + assert.Equal(t, tt.expectedError, err) + }) + } +} diff --git a/main.go b/main.go index e68d3fc38..b31a7d136 100644 --- a/main.go +++ b/main.go @@ -4,20 +4,26 @@ import ( "fmt" "os" + "github.com/joho/godotenv" "github.com/sirupsen/logrus" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/cmd" ) // Version is the official version of this application. Whenever it's changed // here, it also needs to be updated at the `helmchart/Chart.yaml#appVersion“. -const Version = "2.0.0" +const Version = "2.1.0" // GitCommit is populated at build time by // go build -ldflags "-X main.GitCommit=$GIT_COMMIT" var GitCommit string func main() { + if err := godotenv.Load(); err != nil { + log.Debug("No .env file found") + } + preConfigureLogger() rootCmd := cmd.SetupCLI(Version, GitCommit) diff --git a/pkg/schema/account_type.go b/pkg/schema/account_type.go new file mode 100644 index 000000000..801d9fe8c --- /dev/null +++ b/pkg/schema/account_type.go @@ -0,0 +1,104 @@ +package schema + +// AccountType represents the type of an account in the system, in the format of a string that displays it's qualifiers +// in the format of {ROLE}.{PLATFORM}.{STORAGE_METHOD}. For example, "HOST.STELLAR.ENV" represents a host account +// that is used in the Stellar platform and stored in the environment. +type AccountType string + +const ( + HostStellarEnv AccountType = "HOST.STELLAR.ENV" + ChannelAccountStellarDB AccountType = "CHANNEL_ACCOUNT.STELLAR.DB" + DistributionAccountStellarEnv AccountType = "DISTRIBUTION_ACCOUNT.STELLAR.ENV" // was "ENV_STELLAR" + DistributionAccountStellarDBVault AccountType = "DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT" // was "DB_VAULT_STELLAR" + DistributionAccountCircleDBVault AccountType = "DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT" // was "DB_VAULT_CIRCLE" +) + +func AllAccountTypes() []AccountType { + return []AccountType{ + HostStellarEnv, + ChannelAccountStellarDB, + DistributionAccountStellarEnv, + DistributionAccountStellarDBVault, + DistributionAccountCircleDBVault, + } +} + +func DistributionAccountTypes() []AccountType { + distAccountTypes := []AccountType{} + for _, accType := range AllAccountTypes() { + if accType.Role() == DistributionAccountRole { + distAccountTypes = append(distAccountTypes, accType) + } + } + return distAccountTypes +} + +func (t AccountType) IsStellar() bool { + return t.Platform() == StellarPlatform +} + +func (t AccountType) IsCircle() bool { + return t.Platform() == CirclePlatform +} + +// Role represents the role of an account in the system, e.g. HOST, CHANNEL_ACCOUNT, or DISTRIBUTION_ACCOUNT. +type Role string + +const ( + HostRole Role = "HOST" + ChannelAccountRole Role = "CHANNEL_ACCOUNT" + DistributionAccountRole Role = "DISTRIBUTION_ACCOUNT" +) + +var accRoleMap = map[AccountType]Role{ + HostStellarEnv: HostRole, + ChannelAccountStellarDB: ChannelAccountRole, + DistributionAccountStellarEnv: DistributionAccountRole, + DistributionAccountStellarDBVault: DistributionAccountRole, + DistributionAccountCircleDBVault: DistributionAccountRole, +} + +func (t AccountType) Role() Role { + return accRoleMap[t] +} + +// Platform represents the platform where the account is used, e.g. STELLAR, or CIRCLE. +type Platform string + +const ( + StellarPlatform Platform = "STELLAR" + CirclePlatform Platform = "CIRCLE" +) + +var accPlatformMap = map[AccountType]Platform{ + HostStellarEnv: StellarPlatform, + ChannelAccountStellarDB: StellarPlatform, + DistributionAccountStellarEnv: StellarPlatform, + DistributionAccountStellarDBVault: StellarPlatform, + DistributionAccountCircleDBVault: CirclePlatform, +} + +func (t AccountType) Platform() Platform { + return accPlatformMap[t] +} + +// StorageMethod represents the method used to store the account secret, e.g. ENV, DB_VAULT, or DB. +type StorageMethod string + +const ( + EnvStorageMethod StorageMethod = "ENV" + DBStorageMethod StorageMethod = "DB" + DBVaultStorageMethod StorageMethod = "DB_VAULT" +) + +var accStorageMethodMap = map[AccountType]StorageMethod{ + HostStellarEnv: EnvStorageMethod, + ChannelAccountStellarDB: DBStorageMethod, + DistributionAccountStellarEnv: EnvStorageMethod, + DistributionAccountStellarDBVault: DBVaultStorageMethod, + DistributionAccountCircleDBVault: DBVaultStorageMethod, +} + +func (t AccountType) StorageMethod() StorageMethod { + return accStorageMethodMap[t] +} diff --git a/pkg/schema/account_type_test.go b/pkg/schema/account_type_test.go new file mode 100644 index 000000000..885c6373c --- /dev/null +++ b/pkg/schema/account_type_test.go @@ -0,0 +1,124 @@ +package schema + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_AccountType_IsStellar(t *testing.T) { + testCases := []struct { + accountType AccountType + isStellar bool + }{ + {accountType: HostStellarEnv, isStellar: true}, + {accountType: ChannelAccountStellarDB, isStellar: true}, + {accountType: DistributionAccountStellarEnv, isStellar: true}, + {accountType: DistributionAccountStellarDBVault, isStellar: true}, + {accountType: DistributionAccountCircleDBVault, isStellar: false}, + } + for _, tc := range testCases { + t.Run(string(tc.accountType), func(t *testing.T) { + if tc.isStellar { + assert.True(t, tc.accountType.IsStellar()) + } else { + assert.False(t, tc.accountType.IsStellar()) + } + }) + } +} + +func Test_AccountType_IsCircle(t *testing.T) { + testCases := []struct { + accountType AccountType + isCircle bool + }{ + {accountType: HostStellarEnv, isCircle: false}, + {accountType: ChannelAccountStellarDB, isCircle: false}, + {accountType: DistributionAccountStellarEnv, isCircle: false}, + {accountType: DistributionAccountStellarDBVault, isCircle: false}, + {accountType: DistributionAccountCircleDBVault, isCircle: true}, + } + for _, tc := range testCases { + t.Run(string(tc.accountType), func(t *testing.T) { + if tc.isCircle { + assert.True(t, tc.accountType.IsCircle()) + } else { + assert.False(t, tc.accountType.IsCircle()) + } + }) + } +} + +func Test_AccountType_Role(t *testing.T) { + testCases := []struct { + accountType AccountType + wantRole Role + }{ + {accountType: HostStellarEnv, wantRole: HostRole}, + {accountType: ChannelAccountStellarDB, wantRole: ChannelAccountRole}, + {accountType: DistributionAccountStellarEnv, wantRole: DistributionAccountRole}, + {accountType: DistributionAccountStellarDBVault, wantRole: DistributionAccountRole}, + {accountType: DistributionAccountCircleDBVault, wantRole: DistributionAccountRole}, + } + for _, tc := range testCases { + t.Run(string(tc.accountType), func(t *testing.T) { + assert.Equal(t, tc.wantRole, tc.accountType.Role()) + + // Ensure the order of the qualifiers in the string is correct: + qualifiers := strings.Split(string(tc.accountType), ".") + assert.Len(t, qualifiers, 3) + firstQualifier := qualifiers[0] + assert.Equal(t, string(tc.wantRole), firstQualifier) + }) + } +} + +func Test_AccountType_Platform(t *testing.T) { + testCases := []struct { + accountType AccountType + wantPlatform Platform + }{ + {accountType: HostStellarEnv, wantPlatform: StellarPlatform}, + {accountType: ChannelAccountStellarDB, wantPlatform: StellarPlatform}, + {accountType: DistributionAccountStellarEnv, wantPlatform: StellarPlatform}, + {accountType: DistributionAccountStellarDBVault, wantPlatform: StellarPlatform}, + {accountType: DistributionAccountCircleDBVault, wantPlatform: CirclePlatform}, + } + for _, tc := range testCases { + t.Run(string(tc.accountType), func(t *testing.T) { + assert.Equal(t, tc.wantPlatform, tc.accountType.Platform()) + + // Ensure the order of the qualifiers in the string is correct: + qualifiers := strings.Split(string(tc.accountType), ".") + assert.Len(t, qualifiers, 3) + secondQualifier := qualifiers[1] + assert.Equal(t, string(tc.wantPlatform), secondQualifier) + }) + } +} + +func Test_AccountType_StorageMethod(t *testing.T) { + testCases := []struct { + accountType AccountType + wantStorageMethod StorageMethod + }{ + {accountType: HostStellarEnv, wantStorageMethod: EnvStorageMethod}, + {accountType: ChannelAccountStellarDB, wantStorageMethod: DBStorageMethod}, + {accountType: DistributionAccountStellarEnv, wantStorageMethod: EnvStorageMethod}, + {accountType: DistributionAccountStellarDBVault, wantStorageMethod: DBVaultStorageMethod}, + {accountType: DistributionAccountCircleDBVault, wantStorageMethod: DBVaultStorageMethod}, + } + for _, tc := range testCases { + t.Run(string(tc.accountType), func(t *testing.T) { + assert.Equal(t, tc.wantStorageMethod, tc.accountType.StorageMethod()) + + // Ensure the order of the qualifiers in the string is correct: + qualifiers := strings.Split(string(tc.accountType), ".") + assert.Len(t, qualifiers, 3) + thirdQualifier := qualifiers[2] + assert.Equal(t, string(tc.wantStorageMethod), thirdQualifier) + }) + } +} diff --git a/pkg/schema/distribution_account.go b/pkg/schema/distribution_account.go deleted file mode 100644 index 8d9a61ee8..000000000 --- a/pkg/schema/distribution_account.go +++ /dev/null @@ -1,58 +0,0 @@ -package schema - -import ( - "slices" -) - -type DistributionAccountType string - -const ( - DistributionAccountTypeEnvStellar DistributionAccountType = "ENV_STELLAR" - DistributionAccountTypeDBVaultStellar DistributionAccountType = "DB_VAULT_STELLAR" - DistributionAccountTypeDBVaultCircle DistributionAccountType = "DB_VAULT_CIRCLE" -) - -func (t DistributionAccountType) IsStellar() bool { - return slices.Contains([]DistributionAccountType{DistributionAccountTypeEnvStellar, DistributionAccountTypeDBVaultStellar}, t) -} - -func (t DistributionAccountType) IsCircle() bool { - return slices.Contains([]DistributionAccountType{DistributionAccountTypeDBVaultCircle}, t) -} - -type DistributionAccountStatus string - -const ( - DistributionAccountStatusActive DistributionAccountStatus = "ACTIVE" - DistributionAccountStatusPendingUserActivation DistributionAccountStatus = "PENDING_USER_ACTIVATION" -) - -type DistributionAccount struct { - Address string `json:"address" db:"address"` - Type DistributionAccountType `json:"type" db:"type"` - Status DistributionAccountStatus `json:"status" db:"status"` -} - -func (da DistributionAccount) IsStellar() bool { - return da.Type.IsStellar() -} - -func (da DistributionAccount) IsCircle() bool { - return da.Type.IsCircle() -} - -func (da DistributionAccount) IsActive() bool { - return da.Status == DistributionAccountStatusActive -} - -func (da DistributionAccount) IsPendingUserActivation() bool { - return da.Status == DistributionAccountStatusPendingUserActivation -} - -func NewDefaultStellarDistributionAccount(stellarID string) *DistributionAccount { - return &DistributionAccount{ - Address: stellarID, - Type: DistributionAccountTypeDBVaultStellar, - Status: DistributionAccountStatusActive, - } -} diff --git a/pkg/schema/transaction_account.go b/pkg/schema/transaction_account.go new file mode 100644 index 000000000..6086357b0 --- /dev/null +++ b/pkg/schema/transaction_account.go @@ -0,0 +1,85 @@ +package schema + +import ( + "fmt" + "strings" +) + +type AccountStatus string + +const ( + AccountStatusActive AccountStatus = "ACTIVE" + AccountStatusPendingUserActivation AccountStatus = "PENDING_USER_ACTIVATION" +) + +// TransactionAccount represents an account that is used for transactions, either directly with the STellar network or with Circle. +type TransactionAccount struct { + Address string `json:"address,omitempty"` + CircleWalletID string `json:"circle_wallet_id,omitempty"` + Type AccountType `json:"type"` + Status AccountStatus `json:"status"` +} + +func (da TransactionAccount) ID() string { + platform := da.Type.Platform() + switch platform { + case StellarPlatform: + return fmt.Sprintf("%s:%s", strings.ToLower(string(platform)), da.Address) + case CirclePlatform: + return fmt.Sprintf("%s:%s", strings.ToLower(string(platform)), da.CircleWalletID) + default: + panic("unsupported type!") + } +} + +func (da TransactionAccount) IsStellar() bool { + return da.Type.IsStellar() +} + +func (da TransactionAccount) IsCircle() bool { + return da.Type.IsCircle() +} + +func (da TransactionAccount) IsActive() bool { + return da.Status == AccountStatusActive +} + +func (da TransactionAccount) IsPendingUserActivation() bool { + return da.Status == AccountStatusPendingUserActivation +} + +func (da TransactionAccount) String() string { + return fmt.Sprintf("TransactionAccount{Type: %s, Status: %s, Address: %s}", da.Type, da.Status, da.Address) +} + +func NewDefaultStellarTransactionAccount(stellarAddress string) TransactionAccount { + return TransactionAccount{ + Address: stellarAddress, + Type: DistributionAccountStellarDBVault, + Status: AccountStatusActive, + } +} + +func NewStellarEnvTransactionAccount(stellarAddress string) TransactionAccount { + return TransactionAccount{ + Address: stellarAddress, + Type: DistributionAccountStellarEnv, + Status: AccountStatusActive, + } +} + +func NewDefaultChannelAccount(stellarAddress string) TransactionAccount { + return TransactionAccount{ + Address: stellarAddress, + Type: ChannelAccountStellarDB, + Status: AccountStatusActive, + } +} + +func NewDefaultHostAccount(stellarAddress string) TransactionAccount { + return TransactionAccount{ + Address: stellarAddress, + Type: HostStellarEnv, + Status: AccountStatusActive, + } +} diff --git a/pkg/schema/transaction_account_test.go b/pkg/schema/transaction_account_test.go new file mode 100644 index 000000000..19f49acbe --- /dev/null +++ b/pkg/schema/transaction_account_test.go @@ -0,0 +1,67 @@ +package schema + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_TransactionAccount_ID(t *testing.T) { + testCases := []struct { + account TransactionAccount + wantResult string + shouldPanic bool + }{ + { + account: TransactionAccount{ + Address: "GDNQ3FF7MUHK3OTZMYQ63XYMMROEIVCMEJABUGQQWUT7CTC2OUBHI32B", + Type: HostStellarEnv, + }, + wantResult: "stellar:GDNQ3FF7MUHK3OTZMYQ63XYMMROEIVCMEJABUGQQWUT7CTC2OUBHI32B", + }, + { + account: TransactionAccount{ + Address: "GDNQ3FF7MUHK3OTZMYQ63XYMMROEIVCMEJABUGQQWUT7CTC2OUBHI32B", + Type: ChannelAccountStellarDB, + }, + wantResult: "stellar:GDNQ3FF7MUHK3OTZMYQ63XYMMROEIVCMEJABUGQQWUT7CTC2OUBHI32B", + }, + { + account: TransactionAccount{ + Address: "GDNQ3FF7MUHK3OTZMYQ63XYMMROEIVCMEJABUGQQWUT7CTC2OUBHI32B", + Type: DistributionAccountStellarEnv, + }, + wantResult: "stellar:GDNQ3FF7MUHK3OTZMYQ63XYMMROEIVCMEJABUGQQWUT7CTC2OUBHI32B", + }, + { + account: TransactionAccount{ + Address: "GDNQ3FF7MUHK3OTZMYQ63XYMMROEIVCMEJABUGQQWUT7CTC2OUBHI32B", + Type: DistributionAccountStellarDBVault, + }, + wantResult: "stellar:GDNQ3FF7MUHK3OTZMYQ63XYMMROEIVCMEJABUGQQWUT7CTC2OUBHI32B", + }, + { + account: TransactionAccount{ + CircleWalletID: "1000066041", + Type: DistributionAccountCircleDBVault, + }, + wantResult: "circle:1000066041", + }, + { + account: TransactionAccount{Type: AccountType("unsupported")}, + shouldPanic: true, + }, + } + + for _, tc := range testCases { + t.Run(string(tc.account.Type), func(t *testing.T) { + if tc.shouldPanic { + assert.Panics(t, func() { + tc.account.ID() + }) + } else { + assert.Equal(t, tc.wantResult, tc.account.ID()) + } + }) + } +} diff --git a/stellar-auth/cmd/stellarauth/main.go b/stellar-auth/cmd/stellarauth/main.go index 6ee10e4a6..784b8032e 100644 --- a/stellar-auth/cmd/stellarauth/main.go +++ b/stellar-auth/cmd/stellarauth/main.go @@ -3,6 +3,7 @@ package main import ( "github.com/sirupsen/logrus" "github.com/stellar/go/support/log" + "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/cli" ) diff --git a/stellar-auth/pkg/auth/authenticator.go b/stellar-auth/pkg/auth/authenticator.go index 4d3667d53..b8b8049e2 100644 --- a/stellar-auth/pkg/auth/authenticator.go +++ b/stellar-auth/pkg/auth/authenticator.go @@ -11,6 +11,7 @@ import ( "time" "github.com/lib/pq" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/utils" ) @@ -480,6 +481,10 @@ func (a *defaultAuthenticator) GetUser(ctx context.Context, userID string) (*Use // GetUsers retrieves the respective users from a list of user IDs. func (a *defaultAuthenticator) GetUsers(ctx context.Context, userIDs []string) ([]*User, error) { + if len(userIDs) == 0 { + return nil, nil + } + const query = ` SELECT id, diff --git a/stellar-auth/pkg/auth/authenticator_test.go b/stellar-auth/pkg/auth/authenticator_test.go index 972ded0d9..14135a9f3 100644 --- a/stellar-auth/pkg/auth/authenticator_test.go +++ b/stellar-auth/pkg/auth/authenticator_test.go @@ -8,11 +8,12 @@ import ( "testing" "time" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) var errUnexpectedError = errors.New("unexpected error") @@ -661,6 +662,17 @@ func Test_DefaultAuthenticator_GetUsers(t *testing.T) { require.EqualError(t, err, "error querying user IDs: searching for 1 users, found 0 users") }) + t.Run("returns nil if called with an empty or nil slice", func(t *testing.T) { + userIDs := []string{} + users, err := authenticator.GetUsers(ctx, userIDs) + require.NoError(t, err) + assert.Empty(t, users) + + users, err = authenticator.GetUsers(ctx, nil) + require.NoError(t, err) + assert.Empty(t, users) + }) + t.Run("gets users for provided IDs successfully", func(t *testing.T) { passwordEncrypterMock. On("Encrypt", ctx, mock.AnythingOfType("string")). diff --git a/stellar-auth/pkg/auth/fixtures.go b/stellar-auth/pkg/auth/fixtures.go index 79f5020cc..724b4efe8 100644 --- a/stellar-auth/pkg/auth/fixtures.go +++ b/stellar-auth/pkg/auth/fixtures.go @@ -9,9 +9,10 @@ import ( "time" "github.com/lib/pq" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/stellar-auth/pkg/utils" - "github.com/stretchr/testify/require" ) var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") diff --git a/stellar-auth/pkg/auth/mfa_manager_test.go b/stellar-auth/pkg/auth/mfa_manager_test.go index f3c2946d4..28303a5b9 100644 --- a/stellar-auth/pkg/auth/mfa_manager_test.go +++ b/stellar-auth/pkg/auth/mfa_manager_test.go @@ -6,10 +6,11 @@ import ( "testing" "time" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) func Test_defaultMFAManager_MFADeviceRemembered(t *testing.T) { diff --git a/stellar-auth/pkg/auth/role_manager.go b/stellar-auth/pkg/auth/role_manager.go index 6cf160c6b..e8bbba5bb 100644 --- a/stellar-auth/pkg/auth/role_manager.go +++ b/stellar-auth/pkg/auth/role_manager.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/lib/pq" + "github.com/stellar/stellar-disbursement-platform-backend/db" ) diff --git a/stellar-auth/pkg/auth/role_manager_test.go b/stellar-auth/pkg/auth/role_manager_test.go index a57d49526..45992dc7d 100644 --- a/stellar-auth/pkg/auth/role_manager_test.go +++ b/stellar-auth/pkg/auth/role_manager_test.go @@ -4,10 +4,11 @@ import ( "context" "testing" - "github.com/stellar/stellar-disbursement-platform-backend/db" - "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-disbursement-platform-backend/db" + "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" ) func Test_DefaultRoleManager_getUserRolesInfo(t *testing.T) { diff --git a/stellar-multitenant/internal/httphandler/tenants_handler.go b/stellar-multitenant/internal/httphandler/tenants_handler.go index a5f23abef..9ccea2544 100644 --- a/stellar-multitenant/internal/httphandler/tenants_handler.go +++ b/stellar-multitenant/internal/httphandler/tenants_handler.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "net/http" - "strconv" "github.com/go-chi/chi/v5" "github.com/stellar/go/clients/horizonclient" @@ -21,6 +20,7 @@ import ( coreSvc "github.com/stellar/stellar-disbursement-platform-backend/internal/services" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/internal/provisioning" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/internal/services" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/internal/validators" @@ -30,6 +30,7 @@ import ( type TenantsHandler struct { Manager tenant.ManagerInterface Models *data.Models + DistributionAccountService coreSvc.DistributionAccountServiceInterface HorizonClient horizonclient.ClientInterface MessengerClient message.MessengerClient DistributionAccountResolver signing.DistributionAccountResolver @@ -106,14 +107,15 @@ func (h TenantsHandler) Post(rw http.ResponseWriter, req *http.Request) { } tnt, err := h.ProvisioningManager.ProvisionNewTenant(ctx, provisioning.ProvisionTenant{ - Name: reqBody.Name, - UserFirstName: reqBody.OwnerFirstName, - UserLastName: reqBody.OwnerLastName, - UserEmail: reqBody.OwnerEmail, - OrgName: reqBody.OrganizationName, - NetworkType: string(h.NetworkType), - UiBaseURL: tntSDPUIBaseURL, - BaseURL: tntBaseURL, + Name: reqBody.Name, + UserFirstName: reqBody.OwnerFirstName, + UserLastName: reqBody.OwnerLastName, + UserEmail: reqBody.OwnerEmail, + OrgName: reqBody.OrganizationName, + NetworkType: string(h.NetworkType), + UiBaseURL: tntSDPUIBaseURL, + BaseURL: tntBaseURL, + DistributionAccountType: schema.AccountType(reqBody.DistributionAccountType), }) if err != nil { if errors.Is(err, tenant.ErrDuplicatedTenantName) { @@ -255,35 +257,31 @@ func (t TenantsHandler) Delete(w http.ResponseWriter, r *http.Request) { return } - if tnt.DistributionAccountAddress != nil && t.DistributionAccountResolver.HostDistributionAccount() != *tnt.DistributionAccountAddress { - // TODO: Encapsulate this logic under a distribution account abstraction similar to [SDP-1177] once we add Circle custody support - distAcc, accDetailsErr := t.HorizonClient.AccountDetail(horizonclient.AccountRequest{AccountID: *tnt.DistributionAccountAddress}) - if accDetailsErr != nil { - httperror.InternalError(ctx, "Cannot get distribution account details for tenant", err, nil).Render(w) + if tnt.DistributionAccountAddress != nil && t.DistributionAccountResolver.HostDistributionAccount().Address != *tnt.DistributionAccountAddress { + tntDistributionAcc, getTntDistAccErr := t.DistributionAccountResolver.DistributionAccount(ctx, tnt.ID) + if getTntDistAccErr != nil { + httperror.InternalError(ctx, "Cannot get tenant distribution account", getTntDistAccErr, nil).Render(w) return } - if distAcc.Balances != nil { - for _, b := range distAcc.Balances { - assetBalance, getAssetBalErr := strconv.ParseFloat(b.Balance, 64) - if getAssetBalErr != nil { - errMsg := fmt.Sprintf("Cannot convert Horizon distribution account balance %s into float", b.Balance) - httperror.InternalError(ctx, errMsg, getAssetBalErr, nil).Render(w) + distAccBalances, getBalErr := t.DistributionAccountService.GetBalances(ctx, &tntDistributionAcc) + if getBalErr != nil { + httperror.InternalError(ctx, "Cannot get tenant distribution account balances", getBalErr, nil).Render(w) + return + } + + for asset, assetBalance := range distAccBalances { + if asset.IsNative() { + if assetBalance > MaxNativeAssetBalanceForDeletion { + errMsg := fmt.Sprintf("Tenant distribution account must have a balance of less than %d XLM to be eligible for deletion", MaxNativeAssetBalanceForDeletion) + httperror.BadRequest(errMsg, nil, nil).Render(w) return } - - if b.Asset.Type == "native" { - if assetBalance > MaxNativeAssetBalanceForDeletion { - errMsg := fmt.Sprintf("Tenant distribution account must have a balance of less than %d XLM to be eligible for deletion", MaxNativeAssetBalanceForDeletion) - httperror.BadRequest(errMsg, nil, nil).Render(w) - return - } - } else { - if assetBalance != 0 { - errMsg := fmt.Sprintf("Tenant distribution account must have a zero balance to be eligible for deletion. Current balance for %s: %s", b.Balance, b.Asset.Code) - httperror.BadRequest(errMsg, nil, nil).Render(w) - return - } + } else { + if assetBalance != 0 { + errMsg := fmt.Sprintf("Tenant distribution account must have a zero balance to be eligible for deletion. Current balance for (%s, %s)=%f", asset.Code, asset.Issuer, assetBalance) + httperror.BadRequest(errMsg, nil, nil).Render(w) + return } } } diff --git a/stellar-multitenant/internal/httphandler/tenants_handler_test.go b/stellar-multitenant/internal/httphandler/tenants_handler_test.go index 71f419ac9..2011ff56d 100644 --- a/stellar-multitenant/internal/httphandler/tenants_handler_test.go +++ b/stellar-multitenant/internal/httphandler/tenants_handler_test.go @@ -15,8 +15,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/stellar/go/clients/horizonclient" "github.com/stellar/go/keypair" - "github.com/stellar/go/protocols/horizon" - "github.com/stellar/go/protocols/horizon/base" "github.com/stellar/go/txnbuild" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -27,6 +25,8 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/crashtracker" "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" + "github.com/stellar/stellar-disbursement-platform-backend/internal/services/assets" + coreSvcMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/services/mocks" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine" preconditionsMocks "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/preconditions/mocks" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine/signing" @@ -40,7 +40,6 @@ import ( func Test_TenantHandler_Get(t *testing.T) { dbt := dbtest.OpenWithAdminMigrationsOnly(t) defer dbt.Close() - dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, err) defer dbConnectionPool.Close() @@ -141,11 +140,11 @@ func Test_TenantHandler_Get(t *testing.T) { ] `, tnt1.ID, tnt1.Name, tnt1.CreatedAt.Format(time.RFC3339Nano), tnt1.UpdatedAt.Format(time.RFC3339Nano), - *tnt1.DistributionAccountAddress, schema.DistributionAccountTypeDBVaultStellar, schema.DistributionAccountStatusActive, + *tnt1.DistributionAccountAddress, schema.DistributionAccountStellarDBVault, schema.AccountStatusActive, tnt2.ID, tnt2.Name, tnt2.CreatedAt.Format(time.RFC3339Nano), tnt2.UpdatedAt.Format(time.RFC3339Nano), - *tnt2.DistributionAccountAddress, schema.DistributionAccountTypeDBVaultStellar, schema.DistributionAccountStatusActive, + *tnt2.DistributionAccountAddress, schema.DistributionAccountStellarDBVault, schema.AccountStatusActive, deactivatedTnt.ID, deactivatedTnt.Name, deactivatedTnt.CreatedAt.Format(time.RFC3339Nano), deactivatedTnt.UpdatedAt.Format(time.RFC3339Nano), - *deactivatedTnt.DistributionAccountAddress, schema.DistributionAccountTypeDBVaultStellar, schema.DistributionAccountStatusActive, + *deactivatedTnt.DistributionAccountAddress, schema.DistributionAccountStellarDBVault, schema.AccountStatusActive, ) assert.JSONEq(t, expectedRespBody, string(respBody)) }) @@ -181,7 +180,7 @@ func Test_TenantHandler_Get(t *testing.T) { "distribution_account_status": %q } `, tnt1.ID, tnt1.Name, tnt1.CreatedAt.Format(time.RFC3339Nano), tnt1.UpdatedAt.Format(time.RFC3339Nano), - *tnt1.DistributionAccountAddress, schema.DistributionAccountTypeDBVaultStellar, schema.DistributionAccountStatusActive, + *tnt1.DistributionAccountAddress, schema.DistributionAccountStellarDBVault, schema.AccountStatusActive, ) assert.JSONEq(t, expectedRespBody, string(respBody)) }) @@ -217,7 +216,7 @@ func Test_TenantHandler_Get(t *testing.T) { "distribution_account_status": %q } `, tnt2.ID, tnt2.Name, tnt2.CreatedAt.Format(time.RFC3339Nano), tnt2.UpdatedAt.Format(time.RFC3339Nano), - *tnt2.DistributionAccountAddress, schema.DistributionAccountTypeDBVaultStellar, schema.DistributionAccountStatusActive, + *tnt2.DistributionAccountAddress, schema.DistributionAccountStellarDBVault, schema.AccountStatusActive, ) assert.JSONEq(t, expectedRespBody, string(respBody)) }) @@ -246,7 +245,6 @@ func Test_TenantHandler_Get(t *testing.T) { func Test_TenantHandler_Post(t *testing.T) { dbt := dbtest.OpenWithAdminMigrationsOnly(t) defer dbt.Close() - dbConnectionPool, outerErr := db.OpenDBConnectionPool(dbt.DSN) require.NoError(t, outerErr) defer dbConnectionPool.Close() @@ -258,9 +256,9 @@ func Test_TenantHandler_Post(t *testing.T) { mHorizonClient := &horizonclient.MockClient{} mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, _, distAccSigClient, _, distAccResolver := signing.NewMockSignatureService(t) + sigService, sigRouter, distAccResolver := signing.NewMockSignatureService(t) - distAcc := keypair.MustRandom().Address() + distAccAddress := keypair.MustRandom().Address() submitterEngine := engine.SubmitterEngine{ HorizonClient: mHorizonClient, @@ -287,19 +285,22 @@ func Test_TenantHandler_Post(t *testing.T) { SDPUIBaseURL: "https://sdp-ui.stellar.org", } - createMocks := func(t *testing.T, msgClientErr error) { - distAccSigClient. - On("BatchInsert", ctx, 1). - Return([]string{distAcc}, nil). - Once(). - On("Type"). - Return(string(signing.DistributionAccountEnvSignatureClientType)). + createMocks := func(t *testing.T, accountType schema.AccountType, msgClientErr error) { + distAccToReturn := schema.TransactionAccount{ + Address: distAccAddress, + Type: accountType, + Status: schema.AccountStatusActive, + } + sigRouter. + On("BatchInsert", ctx, accountType, 1). + Return([]schema.TransactionAccount{distAccToReturn}, nil). Once() + hostAccount := schema.NewDefaultHostAccount(distAccAddress) distAccResolver. On("HostDistributionAccount"). - Return(distAcc, nil). - Once() + Return(hostAccount, nil). + Maybe() messengerClientMock. On("SendMessage", mock.AnythingOfType("message.Message")). @@ -345,15 +346,17 @@ func Test_TenantHandler_Post(t *testing.T) { "auth_user_mfa_codes", "auth_user_password_reset", "auth_users", + "circle_client_config", + "circle_transfer_requests", "countries", "disbursements", - "sdp_migrations", "messages", "organizations", "payments", "receiver_verifications", "receiver_wallets", "receivers", + "sdp_migrations", "wallets", "wallets_assets", } @@ -367,7 +370,12 @@ func Test_TenantHandler_Post(t *testing.T) { require.NoError(t, err) defer tenantSchemaConnectionPool.Close() - tenant.AssertRegisteredAssetsFixture(t, ctx, tenantSchemaConnectionPool, []string{"USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", "XLM:"}) + tenant.AssertRegisteredAssetsFixture(t, ctx, tenantSchemaConnectionPool, []string{ + fmt.Sprintf("%s:%s", assets.EURCAssetCode, assets.EURCAssetIssuerTestnet), + fmt.Sprintf("%s:%s", assets.USDCAssetCode, assets.USDCAssetIssuerTestnet), + fmt.Sprintf("%s:", assets.XLMAssetCode), + }, + ) tenant.AssertRegisteredWalletsFixture(t, ctx, tenantSchemaConnectionPool, []string{"Demo Wallet", "Vibrant Assist"}) tenant.AssertRegisteredUserFixture(t, ctx, tenantSchemaConnectionPool, "Owner", "Owner", "owner@email.org") } @@ -375,7 +383,7 @@ func Test_TenantHandler_Post(t *testing.T) { t.Run("returns BadRequest with invalid request body", func(t *testing.T) { respBody := makeRequest(t, `{}`, http.StatusBadRequest) - expectedBody := ` + expectedBody := fmt.Sprintf(` { "error": "invalid request body", "extras": { @@ -383,15 +391,17 @@ func Test_TenantHandler_Post(t *testing.T) { "owner_email": "invalid email", "owner_first_name": "owner_first_name is required", "owner_last_name": "owner_last_name is required", - "organization_name": "organization_name is required" + "organization_name": "organization_name is required", + "distribution_account_type": "distribution_account_type is required. valid values are: %v" } } - ` + `, schema.DistributionAccountTypes()) assert.JSONEq(t, expectedBody, string(respBody)) }) t.Run("provisions a new tenant successfully", func(t *testing.T) { - createMocks(t, nil) + accountType := schema.DistributionAccountStellarEnv + createMocks(t, accountType, nil) orgName := "aid-org" reqBody := fmt.Sprintf(` @@ -401,11 +411,12 @@ func Test_TenantHandler_Post(t *testing.T) { "owner_first_name": "Owner", "owner_last_name": "Owner", "organization_name": "My Aid Org", + "distribution_account_type": %q, "base_url": "https://sdp-backend.stellar.org", "sdp_ui_base_url": "https://sdp-ui.stellar.org", "is_default": false } - `, orgName) + `, orgName, accountType) respBody := makeRequest(t, reqBody, http.StatusCreated) @@ -428,14 +439,15 @@ func Test_TenantHandler_Post(t *testing.T) { "distribution_account_status": %q } `, tnt.ID, orgName, tnt.CreatedAt.Format(time.RFC3339Nano), tnt.UpdatedAt.Format(time.RFC3339Nano), - distAcc, schema.DistributionAccountTypeEnvStellar, schema.DistributionAccountStatusActive) + distAccAddress, accountType, schema.AccountStatusActive) assert.JSONEq(t, expectedRespBody, string(respBody)) assertMigrations(orgName) }) t.Run("provisions a new tenant successfully - dynamically generates base URL and SDP UI base URL for tenant", func(t *testing.T) { - createMocks(t, nil) + accountType := schema.DistributionAccountStellarEnv + createMocks(t, accountType, nil) orgName := "aid-org-two" reqBody := fmt.Sprintf(` @@ -445,9 +457,10 @@ func Test_TenantHandler_Post(t *testing.T) { "owner_first_name": "Owner", "owner_last_name": "Owner", "organization_name": "My Aid Org 2", + "distribution_account_type": %q, "is_default": false } - `, orgName) + `, orgName, accountType) respBody := makeRequest(t, reqBody, http.StatusCreated) @@ -472,14 +485,15 @@ func Test_TenantHandler_Post(t *testing.T) { "distribution_account_status": %q } `, tnt.ID, orgName, generatedURL, generatedUIURL, tnt.CreatedAt.Format(time.RFC3339Nano), tnt.UpdatedAt.Format(time.RFC3339Nano), - distAcc, schema.DistributionAccountTypeEnvStellar, schema.DistributionAccountStatusActive) + distAccAddress, accountType, schema.AccountStatusActive) assert.JSONEq(t, expectedRespBody, string(respBody)) assertMigrations(orgName) }) t.Run("provisions a new tenant successfully - dynamically generates only SDP UI base URL", func(t *testing.T) { - createMocks(t, nil) + accountType := schema.DistributionAccountStellarEnv + createMocks(t, accountType, nil) orgName := "aid-org-three" reqBody := fmt.Sprintf(` @@ -489,10 +503,11 @@ func Test_TenantHandler_Post(t *testing.T) { "owner_first_name": "Owner", "owner_last_name": "Owner", "organization_name": "My Aid Org 3", + "distribution_account_type": %q, "base_url": %q, "is_default": false } - `, orgName, handler.BaseURL) + `, orgName, accountType, handler.BaseURL) respBody := makeRequest(t, reqBody, http.StatusCreated) @@ -516,14 +531,15 @@ func Test_TenantHandler_Post(t *testing.T) { "distribution_account_status": %q } `, tnt.ID, orgName, handler.BaseURL, generatedUIURL, tnt.CreatedAt.Format(time.RFC3339Nano), tnt.UpdatedAt.Format(time.RFC3339Nano), - distAcc, schema.DistributionAccountTypeEnvStellar, schema.DistributionAccountStatusActive) + distAccAddress, accountType, schema.AccountStatusActive) assert.JSONEq(t, expectedRespBody, string(respBody)) assertMigrations(orgName) }) t.Run("provisions a new tenant successfully - dynamically generates only backend base URL", func(t *testing.T) { - createMocks(t, nil) + accountType := schema.DistributionAccountStellarEnv + createMocks(t, accountType, nil) orgName := "aid-org-four" reqBody := fmt.Sprintf(` @@ -533,10 +549,11 @@ func Test_TenantHandler_Post(t *testing.T) { "owner_first_name": "Owner", "owner_last_name": "Owner", "organization_name": "My Aid Org 4", + "distribution_account_type": %q, "sdp_ui_base_url": %q, "is_default": false } - `, orgName, handler.SDPUIBaseURL) + `, orgName, accountType, handler.SDPUIBaseURL) respBody := makeRequest(t, reqBody, http.StatusCreated) @@ -560,26 +577,28 @@ func Test_TenantHandler_Post(t *testing.T) { "distribution_account_status": %q } `, tnt.ID, orgName, generatedURL, handler.SDPUIBaseURL, tnt.CreatedAt.Format(time.RFC3339Nano), tnt.UpdatedAt.Format(time.RFC3339Nano), - distAcc, schema.DistributionAccountTypeEnvStellar, schema.DistributionAccountStatusActive) + distAccAddress, accountType, schema.AccountStatusActive) assert.JSONEq(t, expectedRespBody, string(respBody)) assertMigrations(orgName) }) t.Run("returns badRequest for duplicate tenant name", func(t *testing.T) { - createMocks(t, nil) + accountType := schema.DistributionAccountStellarEnv + createMocks(t, accountType, nil) - reqBody := ` + reqBody := fmt.Sprintf(` { "name": "my-aid-org", "owner_email": "owner@email.org", "owner_first_name": "Owner", "owner_last_name": "Owner", "organization_name": "My Aid Org", + "distribution_account_type": %q, "base_url": "https://backend.sdp.org", "sdp_ui_base_url": "https://aid-org.sdp.org" } - ` + `, accountType) // make first request to create tenant _ = makeRequest(t, reqBody, http.StatusCreated) @@ -589,7 +608,8 @@ func Test_TenantHandler_Post(t *testing.T) { }) t.Run("logs and reports error when failing to send invitation message", func(t *testing.T) { - createMocks(t, errors.New("foobar")) + accountType := schema.DistributionAccountStellarEnv + createMocks(t, accountType, errors.New("foobar")) orgName := "aid-org-five" reqBody := fmt.Sprintf(` @@ -601,9 +621,10 @@ func Test_TenantHandler_Post(t *testing.T) { "organization_name": "My Aid Org", "base_url": "https://sdp-backend.stellar.org", "sdp_ui_base_url": "https://sdp-ui.stellar.org", + "distribution_account_type": %q, "is_default": false } - `, orgName) + `, orgName, accountType) respBody := makeRequest(t, reqBody, http.StatusCreated) @@ -626,17 +647,13 @@ func Test_TenantHandler_Post(t *testing.T) { "distribution_account_status": %q } `, tnt.ID, orgName, tnt.CreatedAt.Format(time.RFC3339Nano), tnt.UpdatedAt.Format(time.RFC3339Nano), - distAcc, schema.DistributionAccountTypeEnvStellar, schema.DistributionAccountStatusActive) + distAccAddress, schema.DistributionAccountStellarEnv, schema.AccountStatusActive) assert.JSONEq(t, expectedRespBody, string(respBody)) assertMigrations(orgName) }) messengerClientMock.AssertExpectations(t) - distAccSigClient.AssertExpectations(t) - distAccResolver.AssertExpectations(t) - messengerClientMock.AssertExpectations(t) - crashTrackerMock.AssertExpectations(t) } func Test_TenantHandler_Patch_error(t *testing.T) { @@ -1063,19 +1080,25 @@ func Test_TenantHandler_Delete(t *testing.T) { tenantManagerMock := tenant.TenantManagerMock{} horizonClientMock := horizonclient.MockClient{} - _, _, _, _, distAccResolver := signing.NewMockSignatureService(t) + + _, _, distAccResolver := signing.NewMockSignatureService(t) + hostAccount := schema.NewDefaultHostAccount(keypair.MustRandom().Address()) + distAccSvc := coreSvcMocks.NewMockDistributionAccountService(t) handler := TenantsHandler{ Manager: &tenantManagerMock, NetworkType: utils.TestnetNetworkType, HorizonClient: &horizonClientMock, DistributionAccountResolver: distAccResolver, + DistributionAccountService: distAccSvc, } r := chi.NewRouter() r.Delete("/tenants/{id}", handler.Delete) tntID := "tntID" - tntDistributionAcc := keypair.MustRandom().Address() + tntDistributionAccAddress := keypair.MustRandom().Address() + tntDistributionAcc := schema.NewDefaultStellarTransactionAccount(tntDistributionAccAddress) + deletedAt := time.Now() testCases := []struct { @@ -1139,47 +1162,44 @@ func Test_TenantHandler_Delete(t *testing.T) { tntManagerMock.On("GetTenant", mock.Anything, &tenant.QueryParams{ Filters: map[tenant.FilterKey]interface{}{tenant.FilterKeyID: tntID}, }). - Return(&tenant.Tenant{ID: tntID, Status: tenant.DeactivatedTenantStatus, DistributionAccountAddress: &tntDistributionAcc}, nil). - Once() - distAccResolver.On("HostDistributionAccount").Return("host-dist-account").Once() - horizonClientMock.On("AccountDetail", horizonclient.AccountRequest{AccountID: tntDistributionAcc}). - Return(horizon.Account{}, errors.New("foobar")). + Return(&tenant.Tenant{ID: tntID, Status: tenant.DeactivatedTenantStatus, DistributionAccountAddress: &tntDistributionAccAddress}, nil). Once() + distAccResolver.On("HostDistributionAccount").Return(hostAccount).Once() + distAccResolver.On("DistributionAccount", mock.Anything, tntID).Return(tntDistributionAcc, nil).Once() + distAccSvc.On("GetBalances", mock.Anything, &tntDistributionAcc).Return(nil, errors.New("foobar")).Once() }, expectedStatus: http.StatusInternalServerError, }, { - name: "tenant distribution account still has non-zero non-native balance", + name: "tenant distribution account still has non-zero non-native asset balance", id: tntID, mockTntManagerFn: func(tntManagerMock *tenant.TenantManagerMock, _ *horizonclient.MockClient) { tntManagerMock.On("GetTenant", mock.Anything, &tenant.QueryParams{ Filters: map[tenant.FilterKey]interface{}{tenant.FilterKeyID: tntID}, - }).Return(&tenant.Tenant{ID: tntID, Status: tenant.DeactivatedTenantStatus, DistributionAccountAddress: &tntDistributionAcc}, nil). + }).Return(&tenant.Tenant{ID: tntID, Status: tenant.DeactivatedTenantStatus, DistributionAccountAddress: &tntDistributionAccAddress}, nil). Once() - distAccResolver.On("HostDistributionAccount").Return("host-dist-account").Once() - horizonClientMock.On("AccountDetail", horizonclient.AccountRequest{AccountID: tntDistributionAcc}). - Return(horizon.Account{ - Balances: []horizon.Balance{ - {Asset: base.Asset{Type: "credit_alphanum4"}, Balance: "100.0000000"}, - }, + distAccResolver.On("HostDistributionAccount").Return(hostAccount).Once() + distAccResolver.On("DistributionAccount", mock.Anything, tntID).Return(tntDistributionAcc, nil).Once() + distAccSvc.On("GetBalances", mock.Anything, &tntDistributionAcc). + Return(map[data.Asset]float64{ + {Code: assets.USDCAssetCode, Issuer: assets.USDCAssetIssuerTestnet}: 100.0, }, nil).Once() }, expectedStatus: http.StatusBadRequest, }, { - name: "tenant distribution account still has native balance above the minimum threshold", + name: "tenant distribution account still has native asset balance above the minimum threshold", id: tntID, mockTntManagerFn: func(tntManagerMock *tenant.TenantManagerMock, _ *horizonclient.MockClient) { tntManagerMock.On("GetTenant", mock.Anything, &tenant.QueryParams{ Filters: map[tenant.FilterKey]interface{}{tenant.FilterKeyID: tntID}, - }).Return(&tenant.Tenant{ID: tntID, Status: tenant.DeactivatedTenantStatus, DistributionAccountAddress: &tntDistributionAcc}, nil). + }).Return(&tenant.Tenant{ID: tntID, Status: tenant.DeactivatedTenantStatus, DistributionAccountAddress: &tntDistributionAccAddress}, nil). Once() - distAccResolver.On("HostDistributionAccount").Return("host-dist-account").Once() - horizonClientMock.On("AccountDetail", horizonclient.AccountRequest{AccountID: tntDistributionAcc}). - Return(horizon.Account{ - Balances: []horizon.Balance{ - {Asset: base.Asset{Type: "native"}, Balance: "120.0000000"}, - }, + distAccResolver.On("HostDistributionAccount").Return(hostAccount).Once() + distAccResolver.On("DistributionAccount", mock.Anything, tntID).Return(tntDistributionAcc, nil).Once() + distAccSvc.On("GetBalances", mock.Anything, &tntDistributionAcc). + Return(map[data.Asset]float64{ + {Code: "XLM", Issuer: ""}: 120.0, }, nil).Once() }, expectedStatus: http.StatusBadRequest, @@ -1191,12 +1211,12 @@ func Test_TenantHandler_Delete(t *testing.T) { tntManagerMock.On("GetTenant", mock.Anything, &tenant.QueryParams{ Filters: map[tenant.FilterKey]interface{}{tenant.FilterKeyID: tntID}, }). - Return(&tenant.Tenant{ID: tntID, Status: tenant.DeactivatedTenantStatus, DistributionAccountAddress: &tntDistributionAcc}, nil). - Once() - distAccResolver.On("HostDistributionAccount").Return("host-dist-account").Once() - horizonClientMock.On("AccountDetail", horizonclient.AccountRequest{AccountID: tntDistributionAcc}). - Return(horizon.Account{Balances: make([]horizon.Balance, 0)}, nil). + Return(&tenant.Tenant{ID: tntID, Status: tenant.DeactivatedTenantStatus, DistributionAccountAddress: &tntDistributionAccAddress}, nil). Once() + distAccResolver.On("HostDistributionAccount").Return(hostAccount).Once() + distAccResolver.On("DistributionAccount", mock.Anything, tntID).Return(tntDistributionAcc, nil).Once() + distAccSvc.On("GetBalances", mock.Anything, &tntDistributionAcc). + Return(map[data.Asset]float64{}, nil).Once() tntManagerMock.On("SoftDeleteTenantByID", mock.Anything, tntID). Return(nil, errors.New("foobar")). Once() @@ -1210,14 +1230,15 @@ func Test_TenantHandler_Delete(t *testing.T) { tntManagerMock.On("GetTenant", mock.Anything, &tenant.QueryParams{ Filters: map[tenant.FilterKey]interface{}{tenant.FilterKeyID: tntID}, }). - Return(&tenant.Tenant{ID: tntID, Status: tenant.DeactivatedTenantStatus, DistributionAccountAddress: &tntDistributionAcc}, nil). + Return(&tenant.Tenant{ID: tntID, Status: tenant.DeactivatedTenantStatus, DistributionAccountAddress: &tntDistributionAccAddress}, nil). Once() - distAccResolver.On("HostDistributionAccount").Return(tntDistributionAcc).Once() + hAcc := schema.NewDefaultHostAccount(tntDistributionAccAddress) + distAccResolver.On("HostDistributionAccount").Return(hAcc).Once() tntManagerMock.On("SoftDeleteTenantByID", mock.Anything, tntID). Return(&tenant.Tenant{ ID: tntID, Status: tenant.DeactivatedTenantStatus, - DistributionAccountAddress: &tntDistributionAcc, + DistributionAccountAddress: &tntDistributionAccAddress, DeletedAt: &deletedAt, }, nil). Once() @@ -1231,17 +1252,17 @@ func Test_TenantHandler_Delete(t *testing.T) { tntManagerMock.On("GetTenant", mock.Anything, &tenant.QueryParams{ Filters: map[tenant.FilterKey]interface{}{tenant.FilterKeyID: tntID}, }). - Return(&tenant.Tenant{ID: tntID, Status: tenant.DeactivatedTenantStatus, DistributionAccountAddress: &tntDistributionAcc}, nil). - Once() - distAccResolver.On("HostDistributionAccount").Return("host-dist-account").Once() - horizonClientMock.On("AccountDetail", horizonclient.AccountRequest{AccountID: tntDistributionAcc}). - Return(horizon.Account{Balances: make([]horizon.Balance, 0)}, nil). + Return(&tenant.Tenant{ID: tntID, Status: tenant.DeactivatedTenantStatus, DistributionAccountAddress: &tntDistributionAccAddress}, nil). Once() + distAccResolver.On("HostDistributionAccount").Return(hostAccount).Once() + distAccResolver.On("DistributionAccount", mock.Anything, tntID).Return(tntDistributionAcc, nil).Once() + distAccSvc.On("GetBalances", mock.Anything, &tntDistributionAcc). + Return(map[data.Asset]float64{}, nil).Once() tntManagerMock.On("SoftDeleteTenantByID", mock.Anything, tntID). Return(&tenant.Tenant{ ID: tntID, Status: tenant.DeactivatedTenantStatus, - DistributionAccountAddress: &tntDistributionAcc, + DistributionAccountAddress: &tntDistributionAccAddress, DeletedAt: &deletedAt, }, nil). Once() diff --git a/stellar-multitenant/internal/provisioning/manager.go b/stellar-multitenant/internal/provisioning/manager.go index ec11709ff..4bcfc6100 100644 --- a/stellar-multitenant/internal/provisioning/manager.go +++ b/stellar-multitenant/internal/provisioning/manager.go @@ -30,14 +30,15 @@ type Manager struct { // ProvisionTenant contains all the metadata about a tenant to provision one type ProvisionTenant struct { - Name string - UserFirstName string - UserLastName string - UserEmail string - OrgName string - UiBaseURL string - BaseURL string - NetworkType string + Name string + UserFirstName string + UserLastName string + UserEmail string + OrgName string + UiBaseURL string + BaseURL string + NetworkType string + DistributionAccountType schema.AccountType } var ( @@ -81,7 +82,7 @@ func (m *Manager) handleProvisioningError(ctx context.Context, err error, t *ten provisioningErr := fmt.Errorf("provisioning error: %w", err) if errors.Is(err, ErrUpdateTenantFailed) { - log.Ctx(ctx).Errorf("tenant record not updated") + log.Ctx(ctx).Errorf("tenant record not updated: %v", err) } if isErrorInArray(err, deleteDistributionKeyErrors()) { @@ -135,33 +136,29 @@ func (m *Manager) provisionTenant(ctx context.Context, pt *ProvisionTenant) (*te } // Provision distribution account for tenant if necessary - if err := m.provisionDistributionAccount(ctx, t); err != nil { - return t, fmt.Errorf("provisioning distribution account: %w", err) - } - - distSignerType := signing.SignatureClientType(m.SubmitterEngine.DistAccountSigner.Type()) - distAccType, err := distSignerType.DistributionAccountType() + err := m.provisionDistributionAccount(ctx, t, pt.DistributionAccountType) if err != nil { - return t, fmt.Errorf("%w: parsing getting distribution account type: %w", ErrUpdateTenantFailed, err) + return t, fmt.Errorf("provisioning distribution account: %w", err) } tenantStatus := tenant.ProvisionedTenantStatus - updatedTenant, err := m.tenantManager.UpdateTenantConfig( - ctx, - &tenant.TenantUpdate{ - ID: t.ID, - Status: &tenantStatus, - DistributionAccountAddress: *t.DistributionAccountAddress, - DistributionAccountType: distAccType, - DistributionAccountStatus: schema.DistributionAccountStatusActive, - SDPUIBaseURL: &pt.UiBaseURL, - BaseURL: &pt.BaseURL, - }) + tenantUpdate := &tenant.TenantUpdate{ + ID: t.ID, + Status: &tenantStatus, + DistributionAccountType: t.DistributionAccountType, + DistributionAccountStatus: t.DistributionAccountStatus, + SDPUIBaseURL: &pt.UiBaseURL, + BaseURL: &pt.BaseURL, + } + if t.DistributionAccountType.IsStellar() { + tenantUpdate.DistributionAccountAddress = *t.DistributionAccountAddress + } + updatedTenant, err := m.tenantManager.UpdateTenantConfig(ctx, tenantUpdate) if err != nil { return t, fmt.Errorf("%w: updating tenant %s status to %s: %w", ErrUpdateTenantFailed, pt.Name, tenant.ProvisionedTenantStatus, err) } - err = m.fundTenantDistributionAccount(ctx, *updatedTenant.DistributionAccountAddress) + err = m.fundTenantDistributionStellarAccountIfNeeded(ctx, *updatedTenant) if err != nil { return t, fmt.Errorf("%w. funding tenant distribution account: %w", ErrUpdateTenantFailed, err) } @@ -169,40 +166,64 @@ func (m *Manager) provisionTenant(ctx context.Context, pt *ProvisionTenant) (*te return updatedTenant, nil } -func (m *Manager) fundTenantDistributionAccount(ctx context.Context, distributionAccount string) error { - hostDistributionAccPubKey := m.SubmitterEngine.HostDistributionAccount() - if distributionAccount != hostDistributionAccPubKey { +// fundTenantDistributionStellarAccountIfNeeded funds the tenant distribution account with native asset if necessary, based on the accountType provided. +func (m *Manager) fundTenantDistributionStellarAccountIfNeeded(ctx context.Context, tenant tenant.Tenant) error { + switch tenant.DistributionAccountType { + case schema.DistributionAccountStellarDBVault: + hostDistributionAccPubKey := m.SubmitterEngine.HostDistributionAccount() // Bootstrap tenant distribution account with native asset - log.Ctx(ctx).Infof("Creating and funding tenant distribution account %s with native asset", distributionAccount) - err := tssSvc.CreateAndFundAccount(ctx, m.SubmitterEngine, m.nativeAssetBootstrapAmount, hostDistributionAccPubKey, distributionAccount) + log.Ctx(ctx).Infof("Creating and funding tenant distribution account %s with %d XLM", *tenant.DistributionAccountAddress, m.nativeAssetBootstrapAmount) + err := tssSvc.CreateAndFundAccount(ctx, m.SubmitterEngine, m.nativeAssetBootstrapAmount, hostDistributionAccPubKey.Address, *tenant.DistributionAccountAddress) if err != nil { return fmt.Errorf("bootstrapping tenant distribution account with native asset: %w", err) } - } else { - log.Ctx(ctx).Info("host distribution account and tenant distribution account are the same, no need to initiate funding.") + return nil + + case schema.DistributionAccountStellarEnv: + log.Ctx(ctx).Warnf("Tenant distribution account is configured to use accountType=%s, no need to initiate funding.", tenant.DistributionAccountType) + return nil + + case schema.DistributionAccountCircleDBVault: + log.Ctx(ctx).Warnf("Tenant distribution account is configured to use accountType=%s, the tenant will need to complete the setup through the UI.", tenant.DistributionAccountType) + return nil + + default: + return fmt.Errorf("unsupported accountType=%s", tenant.DistributionAccountType) } - return nil } -func (m *Manager) provisionDistributionAccount(ctx context.Context, t *tenant.Tenant) error { - distributionAccPubKeys, err := m.SubmitterEngine.DistAccountSigner.BatchInsert(ctx, 1) - if err != nil { - if errors.Is(err, signing.ErrUnsupportedCommand) { - log.Ctx(ctx).Warnf( - "Account provisioning not needed for distribution account signature client type %s: %v", - m.SubmitterEngine.DistAccountSigner.Type(), err) - } else { - return fmt.Errorf("%w: provisioning distribution account: %w", ErrProvisionTenantDistributionAccountFailed, err) +// provisionDistributionAccount provisions a distribution account for the tenant if necessary, based on the accountType provided. +func (m *Manager) provisionDistributionAccount(ctx context.Context, t *tenant.Tenant, accountType schema.AccountType) error { + switch accountType { + case schema.DistributionAccountCircleDBVault: + log.Ctx(ctx).Warnf("Circle account cannot be automatically provisioned, the tenant %s will need to provision it through the UI.", t.Name) + t.DistributionAccountType = accountType + t.DistributionAccountStatus = schema.AccountStatusPendingUserActivation + return nil + + case schema.DistributionAccountStellarEnv, schema.DistributionAccountStellarDBVault: + distributionAccounts, err := m.SubmitterEngine.SignerRouter.BatchInsert(ctx, accountType, 1) + if err != nil { + if errors.Is(err, signing.ErrUnsupportedCommand) { + log.Ctx(ctx).Warnf("Account provisioning for distribution account of type=%s is NO-OP: %v", accountType, err) + } else { + return fmt.Errorf("%w: provisioning distribution account: %w", ErrProvisionTenantDistributionAccountFailed, err) + } } - } - // Assigning the account key to the tenant so that it can be referenced if it needs to be deleted in the vault if any subsequent errors are encountered - if len(distributionAccPubKeys) != 1 { - return fmt.Errorf("%w: expected single distribution account public key, got %d", ErrUpdateTenantFailed, len(distributionAccPubKeys)) + // Assigning the account key to the tenant so that it can be referenced if it needs to be deleted in the vault if any subsequent errors are encountered + if len(distributionAccounts) != 1 { + return fmt.Errorf("%w: expected single distribution account public key, got %d", ErrUpdateTenantFailed, len(distributionAccounts)) + } + t.DistributionAccountAddress = &distributionAccounts[0].Address + t.DistributionAccountType = accountType + t.DistributionAccountStatus = schema.AccountStatusActive + log.Ctx(ctx).Infof("distribution account for tenant %s was set to %s", t.Name, *t.DistributionAccountAddress) + return nil + + default: + return fmt.Errorf("%w: unsupported accountType=%s", ErrProvisionTenantDistributionAccountFailed, accountType) } - t.DistributionAccountAddress = &distributionAccPubKeys[0] - log.Ctx(ctx).Infof("distribution account %s created for tenant %s", *t.DistributionAccountAddress, t.Name) - return nil } func (m *Manager) setupTenantData(ctx context.Context, tenantSchemaDSN string, pt *ProvisionTenant) error { @@ -212,7 +233,7 @@ func (m *Manager) setupTenantData(ctx context.Context, tenantSchemaDSN string, p } defer tenantSchemaConnectionPool.Close() - err = services.SetupAssetsForProperNetwork(ctx, tenantSchemaConnectionPool, utils.NetworkType(pt.NetworkType), services.DefaultAssetsNetworkMap) + err = services.SetupAssetsForProperNetwork(ctx, tenantSchemaConnectionPool, utils.NetworkType(pt.NetworkType), pt.DistributionAccountType.Platform()) if err != nil { return fmt.Errorf("running setup assets for proper network: %w", err) } @@ -275,12 +296,17 @@ func (m *Manager) createSchemaAndRunMigrations(ctx context.Context, name string) } func (m *Manager) deleteDistributionAccountKey(ctx context.Context, t *tenant.Tenant) error { - sigClientDeleteKeyErr := m.SubmitterEngine.DistAccountSigner.Delete(ctx, *t.DistributionAccountAddress) + distAccToDelete := schema.TransactionAccount{ + Address: *t.DistributionAccountAddress, + Type: t.DistributionAccountType, + Status: schema.AccountStatusActive, + } + sigClientDeleteKeyErr := m.SubmitterEngine.SignerRouter.Delete(ctx, distAccToDelete) if sigClientDeleteKeyErr != nil { if errors.Is(sigClientDeleteKeyErr, signing.ErrUnsupportedCommand) { log.Ctx(ctx).Warnf( - "Private key deletion not needed for distribution account signature client type %s: %v", - m.SubmitterEngine.DistAccountSigner.Type(), sigClientDeleteKeyErr) + "Private key deletion not needed for distribution account of type=%s: %v", + t.DistributionAccountType, sigClientDeleteKeyErr) } else { return fmt.Errorf("unable to delete distribution account private key: %w", sigClientDeleteKeyErr) } diff --git a/stellar-multitenant/internal/provisioning/manager_test.go b/stellar-multitenant/internal/provisioning/manager_test.go index cd3d75f9f..e0e95f79c 100644 --- a/stellar-multitenant/internal/provisioning/manager_test.go +++ b/stellar-multitenant/internal/provisioning/manager_test.go @@ -11,6 +11,7 @@ import ( "github.com/stellar/go/keypair" "github.com/stellar/go/network" "github.com/stellar/go/protocols/horizon" + "github.com/stellar/go/support/log" "github.com/stellar/go/txnbuild" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -41,7 +42,7 @@ func Test_NewManager(t *testing.T) { mHorizonClient := &horizonclient.MockClient{} mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, _, _, _, _ := signing.NewMockSignatureService(t) + sigService, _, _ := signing.NewMockSignatureService(t) submitterEngine := engine.SubmitterEngine{ HorizonClient: mHorizonClient, SignatureService: sigService, @@ -147,31 +148,43 @@ func Test_Manager_ProvisionNewTenant(t *testing.T) { name string networkPassphrase string tenantName string - sigClientType signing.SignatureClientType + accountType schema.AccountType }{ { - name: "Testnet with sigClientType=DISTRIBUTION_ACCOUNT_ENV", + name: "[Testnet] accountType=DISTRIBUTION_ACCOUNT.STELLAR.ENV", networkPassphrase: network.TestNetworkPassphrase, - tenantName: "tenant-testnet-env", - sigClientType: signing.DistributionAccountEnvSignatureClientType, + tenantName: "testnet-stellar-env", + accountType: schema.DistributionAccountStellarEnv, }, { - name: "Testnet with sigClientType=DISTRIBUTION_ACCOUNT_DB", + name: "[Testnet] accountType=DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT", networkPassphrase: network.TestNetworkPassphrase, - tenantName: "tenant-testnet-dbvault", - sigClientType: signing.DistributionAccountDBSignatureClientType, + tenantName: "testnet-stellar-dbvault", + accountType: schema.DistributionAccountStellarDBVault, }, { - name: "Pubnet with sigClientType=DISTRIBUTION_ACCOUNT_ENV", + name: "[Testnet] accountType=DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT", + networkPassphrase: network.TestNetworkPassphrase, + tenantName: "testnet-circle-dbvault", + accountType: schema.DistributionAccountCircleDBVault, + }, + { + name: "[Pubnet] accountType=DISTRIBUTION_ACCOUNT.STELLAR.ENV", + networkPassphrase: network.PublicNetworkPassphrase, + tenantName: "pubnet-stellar-env", + accountType: schema.DistributionAccountStellarEnv, + }, + { + name: "[Pubnet] accountType=DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT", networkPassphrase: network.PublicNetworkPassphrase, - tenantName: "tenant-pubnet-env", - sigClientType: signing.DistributionAccountEnvSignatureClientType, + tenantName: "pubnet-stellar-dbvault", + accountType: schema.DistributionAccountStellarDBVault, }, { - name: "Pubnet with sigClientType=DISTRIBUTION_ACCOUNT_DB", + name: "[Pubnet] accountType=DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT", networkPassphrase: network.PublicNetworkPassphrase, - tenantName: "tenant-pubnet-dbvault", - sigClientType: signing.DistributionAccountDBSignatureClientType, + tenantName: "pubnet-circle-dbvault", + accountType: schema.DistributionAccountCircleDBVault, }, } @@ -180,8 +193,7 @@ func Test_Manager_ProvisionNewTenant(t *testing.T) { defer tenant.DeleteAllTenantsFixture(t, ctx, dbConnectionPool) hostAccountKP := keypair.MustRandom() - var distAccSigClient signing.SignatureClient - var err error + hostAccount := schema.NewDefaultHostAccount(hostAccountKP.Address()) var wantDistAccAddress string // STEP 1: create mocks: @@ -194,20 +206,30 @@ func Test_Manager_ProvisionNewTenant(t *testing.T) { hostAccSigClient.On("NetworkPassphrase").Return(tc.networkPassphrase).Maybe() distAccResolver := mocks.NewMockDistributionAccountResolver(t) - distAccResolver.On("HostDistributionAccount").Return(hostAccountKP.Address()).Once() + distAccResolver.On("HostDistributionAccount").Return(hostAccount).Maybe() + + signatureStrategies := map[schema.AccountType]signing.SignatureClient{ + schema.HostStellarEnv: hostAccSigClient, + schema.ChannelAccountStellarDB: chAccSigClient, + } - // STEP 2: create DistSigner - switch tc.sigClientType { - case signing.DistributionAccountEnvSignatureClientType: - distAccSigClient, err = signing.NewSignatureClient(signing.DistributionAccountEnvSignatureClientType, signing.SignatureClientOptions{ + // STEP 2: create sigRouter + switch tc.accountType { + case schema.DistributionAccountCircleDBVault: + t.Log(tc.accountType) + + case schema.DistributionAccountStellarEnv: + distAccSigClient, err := signing.NewSignatureClient(schema.DistributionAccountStellarEnv, signing.SignatureClientOptions{ DistributionPrivateKey: hostAccountKP.Seed(), NetworkPassphrase: tc.networkPassphrase, }) - wantDistAccAddress = hostAccountKP.Address() require.NoError(t, err) + wantDistAccAddress = hostAccountKP.Address() + + signatureStrategies[tc.accountType] = distAccSigClient - case signing.DistributionAccountDBSignatureClientType: - distAccSigClient, err = signing.NewSignatureClient(signing.DistributionAccountDBSignatureClientType, signing.SignatureClientOptions{ + case schema.DistributionAccountStellarDBVault: + distAccSigClient, err := signing.NewSignatureClient(schema.DistributionAccountStellarDBVault, signing.SignatureClientOptions{ DBConnectionPool: dbConnectionPool, DistAccEncryptionPassphrase: keypair.MustRandom().Seed(), NetworkPassphrase: tc.networkPassphrase, @@ -216,7 +238,7 @@ func Test_Manager_ProvisionNewTenant(t *testing.T) { tenantAccountKP := keypair.MustRandom() - // STEP 2.1 - Mock calls that are exclusively for DistributionAccountDBSignatureClientType + // STEP 2.1 - Mock calls that are exclusively for DistributionAccountStellarDBVault mHorizonClient. On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccountKP.Address()}). Return(horizon.Account{ @@ -245,18 +267,20 @@ func Test_Manager_ProvisionNewTenant(t *testing.T) { }, nil). Once() + signatureStrategies[tc.accountType] = distAccSigClient + default: - require.Failf(t, "invalid sigClientType=%s", string(tc.sigClientType)) + require.Failf(t, "invalid sigClientType=%s", string(tc.accountType)) } + sigRouter := signing.NewSignerRouterImpl(network.TestNetworkPassphrase, signatureStrategies) + // STEP 3: create Submitter Engine mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) submitterEngine := engine.SubmitterEngine{ HorizonClient: mHorizonClient, SignatureService: signing.SignatureService{ - ChAccountSigner: chAccSigClient, - DistAccountSigner: distAccSigClient, - HostAccountSigner: hostAccSigClient, + SignerRouter: &sigRouter, DistributionAccountResolver: distAccResolver, }, LedgerNumberTracker: mLedgerNumberTracker, @@ -275,29 +299,35 @@ func Test_Manager_ProvisionNewTenant(t *testing.T) { // STEP 5: provision the tenant networkType, err := sdpUtils.GetNetworkTypeFromNetworkPassphrase(tc.networkPassphrase) require.NoError(t, err) - tnt, err := p.ProvisionNewTenant(ctx, ProvisionTenant{ - Name: tc.tenantName, - UserFirstName: userFirstName, - UserLastName: userLastName, - UserEmail: userEmail, - OrgName: userOrgName, - NetworkType: string(networkType), - UiBaseURL: sdpUIBaseURL, - BaseURL: baseURL, + Name: tc.tenantName, + UserFirstName: userFirstName, + UserLastName: userLastName, + UserEmail: userEmail, + OrgName: userOrgName, + NetworkType: string(networkType), + UiBaseURL: sdpUIBaseURL, + BaseURL: baseURL, + DistributionAccountType: tc.accountType, }) require.NoError(t, err) // STEP 6: assert the result assert.Equal(t, tc.tenantName, tnt.Name) assert.Equal(t, tenant.ProvisionedTenantStatus, tnt.Status) - assert.Equal(t, wantDistAccAddress, *tnt.DistributionAccountAddress) assert.Equal(t, sdpUIBaseURL, *tnt.SDPUIBaseURL) assert.Equal(t, baseURL, *tnt.BaseURL) - if tc.sigClientType == signing.DistributionAccountEnvSignatureClientType { + switch tc.accountType { + case schema.DistributionAccountStellarEnv: assert.Equal(t, hostAccountKP.Address(), *tnt.DistributionAccountAddress) - } else { + assert.Equal(t, wantDistAccAddress, *tnt.DistributionAccountAddress) + case schema.DistributionAccountStellarDBVault: assert.NotEqual(t, hostAccountKP.Address(), *tnt.DistributionAccountAddress) + assert.Equal(t, wantDistAccAddress, *tnt.DistributionAccountAddress) + case schema.DistributionAccountCircleDBVault: + assert.Nil(t, tnt.DistributionAccountAddress) + default: + require.Failf(t, "invalid accountType=%s", string(tc.accountType)) } // STEP 7: assert the mocks @@ -321,7 +351,7 @@ func Test_Manager_ProvisionNewTenant(t *testing.T) { tenant.AssertRegisteredUserFixture(t, ctx, tenantDBConnectionPool, userFirstName, userLastName, userEmail) // STEP 8.4: assert the assets have been registered - assetsSlice, ok := services.DefaultAssetsNetworkMap[networkType] + assetsSlice, ok := services.AssetsNetworkByPlatformMap[tc.accountType.Platform()][networkType] require.True(t, ok) var assetsStrSlice []string for _, asset := range assetsSlice { @@ -384,7 +414,7 @@ func Test_Manager_RunMigrationsForTenant(t *testing.T) { mHorizonClient := &horizonclient.MockClient{} mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, _, _, _, _ := signing.NewMockSignatureService(t) + sigService, _, _ := signing.NewMockSignatureService(t) submitterEngine := engine.SubmitterEngine{ HorizonClient: mHorizonClient, SignatureService: sigService, @@ -423,15 +453,17 @@ func getExpectedTablesAfterMigrationsApplied() []string { "auth_user_mfa_codes", "auth_user_password_reset", "auth_users", + "circle_client_config", + "circle_transfer_requests", "countries", "disbursements", - "sdp_migrations", "messages", "organizations", "payments", "receiver_verifications", "receiver_wallets", "receivers", + "sdp_migrations", "wallets", "wallets_assets", } @@ -445,6 +477,7 @@ func Test_Manager_RollbackOnErrors(t *testing.T) { defer dbConnectionPool.Close() ctx := context.Background() + accountType := schema.DistributionAccountStellarDBVault tenantName := "myorg1" orgName := "My Org" @@ -461,12 +494,12 @@ func Test_Manager_RollbackOnErrors(t *testing.T) { testCases := []struct { name string - mockTntManagerFn func(tntManagerMock *tenant.TenantManagerMock, hostAccSigClient, distAccSigClient *mocks.MockSignatureClient, mDistAccResolver *mocks.MockDistributionAccountResolver, mHorizonClient *horizonclient.MockClient) + mockTntManagerFn func(tntManagerMock *tenant.TenantManagerMock, sigRouter *mocks.MockSignerRouter, mDistAccResolver *mocks.MockDistributionAccountResolver, mHorizonClient *horizonclient.MockClient) expectedErr error }{ { name: "when AddTenant fails return an error", - mockTntManagerFn: func(tntManagerMock *tenant.TenantManagerMock, _ *mocks.MockSignatureClient, _ *mocks.MockSignatureClient, _ *mocks.MockDistributionAccountResolver, _ *horizonclient.MockClient) { + mockTntManagerFn: func(tntManagerMock *tenant.TenantManagerMock, _ *mocks.MockSignerRouter, _ *mocks.MockDistributionAccountResolver, _ *horizonclient.MockClient) { // needed for AddTenant: tntManagerMock.On("AddTenant", ctx, tenantName).Return(nil, errors.New("foobar")).Once() }, @@ -474,7 +507,7 @@ func Test_Manager_RollbackOnErrors(t *testing.T) { }, { name: "when createSchemaAndRunMigrations fails, rollback and return an error", - mockTntManagerFn: func(tntManagerMock *tenant.TenantManagerMock, _ *mocks.MockSignatureClient, _ *mocks.MockSignatureClient, _ *mocks.MockDistributionAccountResolver, _ *horizonclient.MockClient) { + mockTntManagerFn: func(tntManagerMock *tenant.TenantManagerMock, _ *mocks.MockSignerRouter, _ *mocks.MockDistributionAccountResolver, _ *horizonclient.MockClient) { // Needed for AddTenant: tntManagerMock.On("AddTenant", ctx, tenantName).Return(&tnt, nil).Once() @@ -490,7 +523,7 @@ func Test_Manager_RollbackOnErrors(t *testing.T) { }, { name: "when UpdateTenantConfig fails, rollback and return an error", - mockTntManagerFn: func(tntManagerMock *tenant.TenantManagerMock, hostAccSigClient, distAccSigClient *mocks.MockSignatureClient, _ *mocks.MockDistributionAccountResolver, _ *horizonclient.MockClient) { + mockTntManagerFn: func(tntManagerMock *tenant.TenantManagerMock, sigRouter *mocks.MockSignerRouter, _ *mocks.MockDistributionAccountResolver, _ *horizonclient.MockClient) { // Needed for AddTenant: tntManagerMock.On("AddTenant", ctx, tenantName).Return(&tnt, nil).Once() @@ -503,21 +536,26 @@ func Test_Manager_RollbackOnErrors(t *testing.T) { require.NoError(t, err) // Needed for provisionDistributionAccount: - distAcc := keypair.MustRandom().Address() - distAccSigClient. - On("BatchInsert", ctx, 1).Return([]string{distAcc}, nil).Once(). - On("Type").Return(string(signing.DistributionAccountEnvSignatureClientType)) + distAccAddress := keypair.MustRandom().Address() + distAccount := schema.TransactionAccount{ + Address: distAccAddress, + Type: accountType, + Status: schema.AccountStatusActive, + } + sigRouter. + On("BatchInsert", ctx, accountType, 1). + Return([]schema.TransactionAccount{distAccount}, nil) // Needed for UpdateTenantConfig: tStatus := tenant.ProvisionedTenantStatus updatedTnt := tnt - updatedTnt.DistributionAccountAddress = &distAcc + updatedTnt.DistributionAccountAddress = &distAccAddress tntManagerMock. On("UpdateTenantConfig", ctx, &tenant.TenantUpdate{ ID: updatedTnt.ID, - DistributionAccountAddress: distAcc, - DistributionAccountType: schema.DistributionAccountTypeEnvStellar, - DistributionAccountStatus: schema.DistributionAccountStatusActive, + DistributionAccountAddress: distAccAddress, + DistributionAccountType: accountType, + DistributionAccountStatus: schema.AccountStatusActive, Status: &tStatus, SDPUIBaseURL: &sdpUIBaseURL, BaseURL: &baseURL, @@ -528,13 +566,13 @@ func Test_Manager_RollbackOnErrors(t *testing.T) { // ROLLBACK: [tenant_creation, schema_creation, distribution_account_creation] tntManagerMock.On("DropTenantSchema", ctx, tenantName).Return(nil).Once() tntManagerMock.On("DeleteTenantByName", ctx, tenantName).Return(nil).Once() - distAccSigClient.On("Delete", ctx, distAcc).Return(nil).Once() + sigRouter.On("Delete", ctx, distAccount).Return(nil).Once() }, expectedErr: ErrUpdateTenantFailed, }, { - name: "when fundTenantDistributionAccount fails, rollback and return an error", - mockTntManagerFn: func(tntManagerMock *tenant.TenantManagerMock, hostAccSigClient, distAccSigClient *mocks.MockSignatureClient, mDistAccResolver *mocks.MockDistributionAccountResolver, mHorizonClient *horizonclient.MockClient) { + name: "when fundTenantDistributionStellarAccountIfNeeded fails, rollback and return an error", + mockTntManagerFn: func(tntManagerMock *tenant.TenantManagerMock, sigRouter *mocks.MockSignerRouter, mDistAccResolver *mocks.MockDistributionAccountResolver, mHorizonClient *horizonclient.MockClient) { // Needed for AddTenant: tntManagerMock.On("AddTenant", ctx, tenantName).Return(&tnt, nil).Once() @@ -547,24 +585,29 @@ func Test_Manager_RollbackOnErrors(t *testing.T) { require.NoError(t, err) // Needed for provisionDistributionAccount: - distAcc := keypair.MustRandom().Address() - distAccSigClient. - On("BatchInsert", ctx, 1).Return([]string{distAcc}, nil).Once(). - On("Type").Return(string(signing.DistributionAccountEnvSignatureClientType)) + distAccAddress := keypair.MustRandom().Address() + distAccount := schema.TransactionAccount{ + Address: distAccAddress, + Type: accountType, + Status: schema.AccountStatusActive, + } + sigRouter. + On("BatchInsert", ctx, accountType, 1). + Return([]schema.TransactionAccount{distAccount}, nil) // Needed for UpdateTenantConfig: tStatus := tenant.ProvisionedTenantStatus updatedTnt := tnt - updatedTnt.DistributionAccountAddress = &distAcc - updatedTnt.DistributionAccountType = schema.DistributionAccountTypeEnvStellar - updatedTnt.DistributionAccountStatus = schema.DistributionAccountStatusActive + updatedTnt.DistributionAccountAddress = &distAccAddress + updatedTnt.DistributionAccountType = schema.DistributionAccountStellarDBVault + updatedTnt.DistributionAccountStatus = schema.AccountStatusActive updatedTnt.Status = tStatus tntManagerMock. On("UpdateTenantConfig", ctx, &tenant.TenantUpdate{ ID: updatedTnt.ID, - DistributionAccountAddress: distAcc, - DistributionAccountType: schema.DistributionAccountTypeEnvStellar, - DistributionAccountStatus: schema.DistributionAccountStatusActive, + DistributionAccountAddress: distAccAddress, + DistributionAccountType: accountType, + DistributionAccountStatus: schema.AccountStatusActive, Status: &tStatus, SDPUIBaseURL: &sdpUIBaseURL, BaseURL: &baseURL, @@ -572,9 +615,12 @@ func Test_Manager_RollbackOnErrors(t *testing.T) { Return(&updatedTnt, nil). Once() - // Needed for fundTenantDistributionAccount: + // Needed for fundTenantDistributionStellarAccountIfNeeded: hostAccountKP := keypair.MustRandom() - mDistAccResolver.On("HostDistributionAccount").Return(hostAccountKP.Address()).Once() + hostAccount := schema.NewDefaultHostAccount(hostAccountKP.Address()) + mDistAccResolver. + On("HostDistributionAccount"). + Return(hostAccount) mHorizonClient. On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccountKP.Address()}). Return(horizon.Account{}, errors.New("some horizon error")) @@ -582,13 +628,13 @@ func Test_Manager_RollbackOnErrors(t *testing.T) { // ROLLBACK: [tenant_creation, schema_creation, distribution_account_creation] tntManagerMock.On("DropTenantSchema", ctx, tenantName).Return(nil).Once() tntManagerMock.On("DeleteTenantByName", ctx, tenantName).Return(nil).Once() - distAccSigClient.On("Delete", ctx, distAcc).Return(nil).Once() + sigRouter.On("Delete", ctx, distAccount).Return(nil).Once() }, expectedErr: ErrUpdateTenantFailed, }, { name: "when provisioning succeeds, no rollback occurs", - mockTntManagerFn: func(tntManagerMock *tenant.TenantManagerMock, hostAccSigClient, distAccSigClient *mocks.MockSignatureClient, mDistAccResolver *mocks.MockDistributionAccountResolver, mHorizonClient *horizonclient.MockClient) { + mockTntManagerFn: func(tntManagerMock *tenant.TenantManagerMock, sigRouter *mocks.MockSignerRouter, mDistAccResolver *mocks.MockDistributionAccountResolver, mHorizonClient *horizonclient.MockClient) { // Needed for AddTenant: tntManagerMock.On("AddTenant", ctx, tenantName).Return(&tnt, nil).Once() @@ -601,24 +647,29 @@ func Test_Manager_RollbackOnErrors(t *testing.T) { require.NoError(t, err) // Needed for provisionDistributionAccount: - distAcc := keypair.MustRandom().Address() - distAccSigClient. - On("BatchInsert", ctx, 1).Return([]string{distAcc}, nil).Once(). - On("Type").Return(string(signing.DistributionAccountEnvSignatureClientType)) + distAccAddress := keypair.MustRandom().Address() + distAccount := schema.TransactionAccount{ + Address: distAccAddress, + Type: accountType, + Status: schema.AccountStatusActive, + } + sigRouter. + On("BatchInsert", ctx, accountType, 1). + Return([]schema.TransactionAccount{distAccount}, nil) // Needed for UpdateTenantConfig: tStatus := tenant.ProvisionedTenantStatus updatedTnt := tnt - updatedTnt.DistributionAccountAddress = &distAcc - updatedTnt.DistributionAccountType = schema.DistributionAccountTypeEnvStellar - updatedTnt.DistributionAccountStatus = schema.DistributionAccountStatusActive + updatedTnt.DistributionAccountAddress = &distAccAddress + updatedTnt.DistributionAccountType = schema.DistributionAccountStellarDBVault + updatedTnt.DistributionAccountStatus = schema.AccountStatusActive updatedTnt.Status = tStatus tntManagerMock. On("UpdateTenantConfig", ctx, &tenant.TenantUpdate{ ID: updatedTnt.ID, - DistributionAccountAddress: distAcc, - DistributionAccountType: schema.DistributionAccountTypeEnvStellar, - DistributionAccountStatus: schema.DistributionAccountStatusActive, + DistributionAccountAddress: distAccAddress, + DistributionAccountType: accountType, + DistributionAccountStatus: schema.AccountStatusActive, Status: &tStatus, SDPUIBaseURL: &sdpUIBaseURL, BaseURL: &baseURL, @@ -626,10 +677,12 @@ func Test_Manager_RollbackOnErrors(t *testing.T) { Return(&updatedTnt, nil). Once() - // Needed for fundTenantDistributionAccount: + // Needed for fundTenantDistributionStellarAccountIfNeeded: hostAccountKP := keypair.MustRandom() - tenantAccountKP := keypair.MustRandom() - mDistAccResolver.On("HostDistributionAccount").Return(hostAccountKP.Address()).Once() + hostAccount := schema.NewDefaultHostAccount(hostAccountKP.Address()) + mDistAccResolver. + On("HostDistributionAccount"). + Return(hostAccount) mHorizonClient. On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccountKP.Address()}). Return(horizon.Account{ @@ -637,8 +690,9 @@ func Test_Manager_RollbackOnErrors(t *testing.T) { Sequence: 1, }, nil). Once() - hostAccSigClient. - On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), hostAccountKP.Address()). + + sigRouter. + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), hostAccount). Return(&txnbuild.Transaction{}, nil). Once() mHorizonClient. @@ -648,7 +702,7 @@ func Test_Manager_RollbackOnErrors(t *testing.T) { mHorizonClient. On("AccountDetail", mock.AnythingOfType("horizonclient.AccountRequest")). Return(horizon.Account{ - AccountID: tenantAccountKP.Address(), + AccountID: distAccAddress, Sequence: 1, }, nil). Once() @@ -663,10 +717,10 @@ func Test_Manager_RollbackOnErrors(t *testing.T) { // Create Mocks: mHorizonClient := &horizonclient.MockClient{} mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, _, distAccSigClient, hostAccSigClient, distAccResolver := signing.NewMockSignatureService(t) + sigService, sigRouter, distAccResolver := signing.NewMockSignatureService(t) tenantManagerMock := &tenant.TenantManagerMock{} - tc.mockTntManagerFn(tenantManagerMock, hostAccSigClient, distAccSigClient, distAccResolver, mHorizonClient) + tc.mockTntManagerFn(tenantManagerMock, sigRouter, distAccResolver, mHorizonClient) // Create tenant manager provisioningManager, err := NewManager(ManagerOptions{ @@ -684,14 +738,15 @@ func Test_Manager_RollbackOnErrors(t *testing.T) { // Provision the tenant _, err = provisioningManager.ProvisionNewTenant(ctx, ProvisionTenant{ - Name: tenantName, - UserFirstName: firstName, - UserLastName: lastName, - UserEmail: email, - OrgName: orgName, - NetworkType: string(networkType), - UiBaseURL: sdpUIBaseURL, - BaseURL: baseURL, + Name: tenantName, + UserFirstName: firstName, + UserLastName: lastName, + UserEmail: email, + OrgName: orgName, + NetworkType: string(networkType), + UiBaseURL: sdpUIBaseURL, + BaseURL: baseURL, + DistributionAccountType: accountType, }) // Assertions @@ -706,3 +761,227 @@ func Test_Manager_RollbackOnErrors(t *testing.T) { }) } } + +func Test_Manager_fundTenantDistributionStellarAccountIfNeeded(t *testing.T) { + ctx := context.Background() + distAccAddress := keypair.MustRandom().Address() + hostAccount := schema.NewDefaultHostAccount(keypair.MustRandom().Address()) + + testCases := []struct { + name string + accountType schema.AccountType + prepareMocksFn func(t *testing.T, mDistAccResolver *mocks.MockDistributionAccountResolver, mHorizonClient *horizonclient.MockClient, mSigRouter *mocks.MockSignerRouter) + wantLogContains string + wantErrorContains string + }{ + { + name: "❌ HOST account type.STELLAR.ENV is not supported", + accountType: schema.HostStellarEnv, + wantErrorContains: fmt.Sprintf("unsupported accountType=%s", schema.HostStellarEnv), + }, + { + name: "❌ CHANNEL_ACCOUNT account type.STELLAR.DB is not supported", + accountType: schema.ChannelAccountStellarDB, + wantErrorContains: fmt.Sprintf("unsupported accountType=%s", schema.ChannelAccountStellarDB), + }, + { + name: "🟢✍🏽 DISTRIBUTION_ACCOUNT.STELLAR.ENV is NO-OP and logs warnings accordingly", + accountType: schema.DistributionAccountStellarEnv, + wantLogContains: fmt.Sprintf("Tenant distribution account is configured to use accountType=%s, no need to initiate funding.", schema.DistributionAccountStellarEnv), + }, + { + name: "🟢✅ DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT gets inserted in DBVault", + accountType: schema.DistributionAccountStellarDBVault, + prepareMocksFn: func(t *testing.T, mDistAccResolver *mocks.MockDistributionAccountResolver, mHorizonClient *horizonclient.MockClient, mSigRouter *mocks.MockSignerRouter) { + mDistAccResolver.On("HostDistributionAccount").Return(hostAccount) + + mHorizonClient. + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). + Return(horizon.Account{ + AccountID: hostAccount.Address, + Sequence: 1, + }, nil). + Once() + mSigRouter. + On("SignStellarTransaction", ctx, mock.AnythingOfType("*txnbuild.Transaction"), hostAccount). + Return(&txnbuild.Transaction{}, nil). + Once() + mHorizonClient. + On("SubmitTransactionWithOptions", mock.AnythingOfType("*txnbuild.Transaction"), horizonclient.SubmitTxOpts{SkipMemoRequiredCheck: true}). + Return(horizon.Transaction{}, nil). + Once() + mHorizonClient. + On("AccountDetail", horizonclient.AccountRequest{AccountID: distAccAddress}). + Return(horizon.Account{AccountID: distAccAddress, Sequence: 1}, nil). + Once() + }, + }, + { + name: "❌ DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT errors are handled accordingly", + accountType: schema.DistributionAccountStellarDBVault, + prepareMocksFn: func(t *testing.T, mDistAccResolver *mocks.MockDistributionAccountResolver, mHorizonClient *horizonclient.MockClient, mSigRouter *mocks.MockSignerRouter) { + mDistAccResolver.On("HostDistributionAccount").Return(hostAccount) + + mHorizonClient. + On("AccountDetail", horizonclient.AccountRequest{AccountID: hostAccount.Address}). + Return(horizon.Account{}, errors.New("horizon error")) + }, + wantErrorContains: "bootstrapping tenant distribution account with native asset", + }, + { + name: "🟢✍🏽 DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT is NO-OP and logs warnings accordingly", + accountType: schema.DistributionAccountCircleDBVault, + wantLogContains: fmt.Sprintf("Tenant distribution account is configured to use accountType=%s, the tenant will need to complete the setup through the UI.", schema.DistributionAccountCircleDBVault), + }, + { + name: "❌ INVALID account type will return an error", + accountType: schema.AccountType("INVALID"), + wantErrorContains: "unsupported accountType=INVALID", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + m := Manager{} + tnt := tenant.Tenant{ + ID: "foo-bar", + Name: "test", + DistributionAccountAddress: &distAccAddress, + DistributionAccountType: tc.accountType, + } + + getEntries := log.DefaultLogger.StartTest(log.WarnLevel) + + if tc.prepareMocksFn != nil { + mHorizonClient := &horizonclient.MockClient{} + defer mHorizonClient.AssertExpectations(t) + mSigRouter := mocks.NewMockSignerRouter(t) + mDistAccResolver := mocks.NewMockDistributionAccountResolver(t) + tc.prepareMocksFn(t, mDistAccResolver, mHorizonClient, mSigRouter) + + m.SubmitterEngine = engine.SubmitterEngine{ + HorizonClient: mHorizonClient, + SignatureService: signing.SignatureService{ + SignerRouter: mSigRouter, + DistributionAccountResolver: mDistAccResolver, + }, + } + } + + err := m.fundTenantDistributionStellarAccountIfNeeded(ctx, tnt) + if tc.wantErrorContains != "" { + assert.ErrorContains(t, err, tc.wantErrorContains) + } else { + require.NoError(t, err) + } + + entries := getEntries() + var aggregatedMessages []string + if tc.wantLogContains != "" { + for _, entry := range entries { + aggregatedMessages = append(aggregatedMessages, entry.Message) + } + assert.Contains(t, aggregatedMessages, tc.wantLogContains) + } + }) + } +} + +func Test_Manager_provisionDistributionAccount(t *testing.T) { + ctx := context.Background() + distAccAddress := keypair.MustRandom().Address() + + testCases := []struct { + name string + accountType schema.AccountType + prepareMocksFn func(t *testing.T, mSigRouter *mocks.MockSignerRouter) + wantErrorContains string + wantLogContains string + wantTnt tenant.Tenant + }{ + { + name: "HOST.STELLAR.ENV is not supported", + accountType: schema.HostStellarEnv, + wantTnt: tenant.Tenant{ID: "foo-bar", Name: "test"}, + wantErrorContains: fmt.Sprintf("%v: unsupported accountType=%s", ErrProvisionTenantDistributionAccountFailed, schema.HostStellarEnv), + }, + { + name: "CHANNEL_ACCOUNT.STELLAR.DB is not supported", + accountType: schema.ChannelAccountStellarDB, + wantTnt: tenant.Tenant{ID: "foo-bar", Name: "test"}, + wantErrorContains: fmt.Sprintf("%v: unsupported accountType=%s", ErrProvisionTenantDistributionAccountFailed, schema.ChannelAccountStellarDB), + }, + { + name: "DISTRIBUTION_ACCOUNT.STELLAR.ENV is NO-OP and logs warnings accordingly", + accountType: schema.DistributionAccountStellarEnv, + prepareMocksFn: func(t *testing.T, mSigRouter *mocks.MockSignerRouter) { + distAccount := schema.TransactionAccount{ + Address: distAccAddress, + Type: schema.DistributionAccountStellarEnv, + } + mSigRouter.On("BatchInsert", ctx, schema.DistributionAccountStellarEnv, 1). + Return([]schema.TransactionAccount{distAccount}, signing.ErrUnsupportedCommand). + Once() + }, + wantTnt: tenant.Tenant{ + ID: "foo-bar", + Name: "test", + DistributionAccountAddress: &distAccAddress, + DistributionAccountType: schema.DistributionAccountStellarEnv, + DistributionAccountStatus: schema.AccountStatusActive, + }, + }, + { + name: "DISTRIBUTION_ACCOUNT.STELLAR.DB_VAULT gets inserted in DBVault", + accountType: schema.DistributionAccountStellarDBVault, + prepareMocksFn: func(t *testing.T, mSigRouter *mocks.MockSignerRouter) { + distAccount := schema.TransactionAccount{ + Address: distAccAddress, + Type: schema.DistributionAccountStellarDBVault, + } + mSigRouter.On("BatchInsert", ctx, schema.DistributionAccountStellarDBVault, 1). + Return([]schema.TransactionAccount{distAccount}, nil). + Once() + }, + wantTnt: tenant.Tenant{ + ID: "foo-bar", + Name: "test", + DistributionAccountAddress: &distAccAddress, + DistributionAccountType: schema.DistributionAccountStellarDBVault, + DistributionAccountStatus: schema.AccountStatusActive, + }, + }, + { + name: "DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT is NO-OP and logs warnings accordingly", + accountType: schema.DistributionAccountCircleDBVault, + wantTnt: tenant.Tenant{ + ID: "foo-bar", + Name: "test", + DistributionAccountType: schema.DistributionAccountCircleDBVault, + DistributionAccountStatus: schema.AccountStatusPendingUserActivation, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + m := Manager{} + tnt := &tenant.Tenant{ID: "foo-bar", Name: "test"} + + if tc.prepareMocksFn != nil { + mSigRouter := mocks.NewMockSignerRouter(t) + m.SubmitterEngine.SignatureService.SignerRouter = mSigRouter + tc.prepareMocksFn(t, mSigRouter) + } + + err := m.provisionDistributionAccount(ctx, tnt, tc.accountType) + if tc.wantErrorContains != "" { + assert.ErrorContains(t, err, tc.wantErrorContains) + } else { + require.NoError(t, err) + } + + assert.Equal(t, tc.wantTnt, *tnt) + }) + } +} diff --git a/stellar-multitenant/internal/validators/tenant_validator.go b/stellar-multitenant/internal/validators/tenant_validator.go index aae54d8f4..88663b433 100644 --- a/stellar-multitenant/internal/validators/tenant_validator.go +++ b/stellar-multitenant/internal/validators/tenant_validator.go @@ -4,22 +4,25 @@ import ( "fmt" "net/url" "regexp" + "slices" "strings" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/pkg/tenant" ) var validTenantName *regexp.Regexp = regexp.MustCompile(`^[a-z-]+$`) type TenantRequest struct { - Name string `json:"name"` - OwnerEmail string `json:"owner_email"` - OwnerFirstName string `json:"owner_first_name"` - OwnerLastName string `json:"owner_last_name"` - OrganizationName string `json:"organization_name"` - BaseURL *string `json:"base_url"` - SDPUIBaseURL *string `json:"sdp_ui_base_url"` + Name string `json:"name"` + OwnerEmail string `json:"owner_email"` + OwnerFirstName string `json:"owner_first_name"` + OwnerLastName string `json:"owner_last_name"` + OrganizationName string `json:"organization_name"` + DistributionAccountType string `json:"distribution_account_type"` + BaseURL *string `json:"base_url"` + SDPUIBaseURL *string `json:"sdp_ui_base_url"` } type UpdateTenantRequest struct { @@ -60,6 +63,8 @@ func (tv *TenantValidator) ValidateCreateTenantRequest(reqBody *TenantRequest) * tv.Check(reqBody.OwnerLastName != "", "owner_last_name", "owner_last_name is required") tv.Check(reqBody.OrganizationName != "", "organization_name", "organization_name is required") + tv.validateDistributionAccountType(reqBody.DistributionAccountType) + var err error if reqBody.BaseURL != nil { if _, err = url.ParseRequestURI(*reqBody.BaseURL); err != nil { @@ -80,6 +85,14 @@ func (tv *TenantValidator) ValidateCreateTenantRequest(reqBody *TenantRequest) * return reqBody } +func (tv *TenantValidator) validateDistributionAccountType(distributionAccountType string) { + tv.Check(distributionAccountType != "", "distribution_account_type", fmt.Sprintf("distribution_account_type is required. valid values are: %v", schema.DistributionAccountTypes())) + + if distributionAccountType != "" && !slices.Contains(schema.DistributionAccountTypes(), schema.AccountType(distributionAccountType)) { + tv.Check(false, "distribution_account_type", fmt.Sprintf("invalid distribution_account_type. valid values are: %v", schema.DistributionAccountTypes())) + } +} + func (tv *TenantValidator) ValidateUpdateTenantRequest(reqBody *UpdateTenantRequest) *UpdateTenantRequest { tv.Check(reqBody != nil, "body", "request body is empty") if tv.HasErrors() { diff --git a/stellar-multitenant/internal/validators/tenant_validator_test.go b/stellar-multitenant/internal/validators/tenant_validator_test.go index d5b3b81b7..a81c22e3d 100644 --- a/stellar-multitenant/internal/validators/tenant_validator_test.go +++ b/stellar-multitenant/internal/validators/tenant_validator_test.go @@ -1,9 +1,12 @@ package validators import ( + "fmt" "testing" "github.com/stretchr/testify/assert" + + "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" ) func TestTenantValidator_ValidateCreateTenantRequest(t *testing.T) { @@ -25,11 +28,12 @@ func TestTenantValidator_ValidateCreateTenantRequest(t *testing.T) { tv.ValidateCreateTenantRequest(reqBody) assert.True(t, tv.HasErrors()) assert.Equal(t, map[string]interface{}{ - "name": "invalid tenant name. It should only contains lower case letters and dash (-)", - "owner_email": "invalid email", - "owner_first_name": "owner_first_name is required", - "owner_last_name": "owner_last_name is required", - "organization_name": "organization_name is required", + "name": "invalid tenant name. It should only contains lower case letters and dash (-)", + "owner_email": "invalid email", + "owner_first_name": "owner_first_name is required", + "owner_last_name": "owner_last_name is required", + "organization_name": "organization_name is required", + "distribution_account_type": fmt.Sprintf("distribution_account_type is required. valid values are: %v", schema.DistributionAccountTypes()), }, tv.Errors) reqBody.Name = "aid-org" @@ -37,23 +41,25 @@ func TestTenantValidator_ValidateCreateTenantRequest(t *testing.T) { tv.ValidateCreateTenantRequest(reqBody) assert.True(t, tv.HasErrors()) assert.Equal(t, map[string]interface{}{ - "owner_email": "invalid email", - "owner_first_name": "owner_first_name is required", - "owner_last_name": "owner_last_name is required", - "organization_name": "organization_name is required", + "owner_email": "invalid email", + "owner_first_name": "owner_first_name is required", + "owner_last_name": "owner_last_name is required", + "organization_name": "organization_name is required", + "distribution_account_type": fmt.Sprintf("distribution_account_type is required. valid values are: %v", schema.DistributionAccountTypes()), }, tv.Errors) }) t.Run("returns error when name is invalid", func(t *testing.T) { tv := NewTenantValidator() reqBody := &TenantRequest{ - Name: "aid org", - OwnerEmail: "owner@email.org", - OwnerFirstName: "Owner", - OwnerLastName: "Owner", - OrganizationName: "Aid Org", - SDPUIBaseURL: &sdpUIBaseURL, - BaseURL: &baseURL, + Name: "aid org", + OwnerEmail: "owner@email.org", + OwnerFirstName: "Owner", + OwnerLastName: "Owner", + OrganizationName: "Aid Org", + DistributionAccountType: string(schema.DistributionAccountStellarEnv), + SDPUIBaseURL: &sdpUIBaseURL, + BaseURL: &baseURL, } tv.ValidateCreateTenantRequest(reqBody) @@ -66,13 +72,14 @@ func TestTenantValidator_ValidateCreateTenantRequest(t *testing.T) { t.Run("returns error when owner info is invalid", func(t *testing.T) { tv := NewTenantValidator() reqBody := &TenantRequest{ - Name: "aid-org", - OwnerEmail: "invalid", - OwnerFirstName: "", - OwnerLastName: "", - OrganizationName: "", - BaseURL: &sdpUIBaseURL, - SDPUIBaseURL: &baseURL, + Name: "aid-org", + OwnerEmail: "invalid", + OwnerFirstName: "", + OwnerLastName: "", + OrganizationName: "", + DistributionAccountType: string(schema.DistributionAccountStellarEnv), + BaseURL: &sdpUIBaseURL, + SDPUIBaseURL: &baseURL, } tv.ValidateCreateTenantRequest(reqBody) @@ -94,16 +101,45 @@ func TestTenantValidator_ValidateCreateTenantRequest(t *testing.T) { assert.Equal(t, map[string]interface{}{}, tv.Errors) }) + t.Run("returns error when distribution account type is invalid", func(t *testing.T) { + tv := NewTenantValidator() + reqBody := &TenantRequest{ + Name: "aid-org", + OwnerEmail: "owner@email.org", + OwnerFirstName: "Owner", + OwnerLastName: "Owner", + OrganizationName: "Aid Org", + DistributionAccountType: "foobar", + SDPUIBaseURL: &sdpUIBaseURL, + BaseURL: &baseURL, + } + + tv.ValidateCreateTenantRequest(reqBody) + assert.True(t, tv.HasErrors()) + assert.Equal(t, map[string]interface{}{ + "distribution_account_type": fmt.Sprintf("invalid distribution_account_type. valid values are: %v", schema.DistributionAccountTypes()), + }, tv.Errors) + + for _, accountType := range []schema.AccountType{schema.DistributionAccountStellarEnv, schema.DistributionAccountStellarDBVault, schema.DistributionAccountCircleDBVault} { + reqBody.DistributionAccountType = string(accountType) + tv.Errors = map[string]interface{}{} + tv.ValidateCreateTenantRequest(reqBody) + assert.False(t, tv.HasErrors()) + assert.Equal(t, map[string]interface{}{}, tv.Errors) + } + }) + t.Run("validates the URLs successfully", func(t *testing.T) { tv := NewTenantValidator() reqBody := &TenantRequest{ - Name: "aid-org", - OwnerEmail: "owner@email.org", - OwnerFirstName: "Owner", - OwnerLastName: "Owner", - OrganizationName: "Aid Org", - SDPUIBaseURL: &invalidURL, - BaseURL: &invalidURL, + Name: "aid-org", + OwnerEmail: "owner@email.org", + OwnerFirstName: "Owner", + OwnerLastName: "Owner", + OrganizationName: "Aid Org", + DistributionAccountType: string(schema.DistributionAccountStellarEnv), + SDPUIBaseURL: &invalidURL, + BaseURL: &invalidURL, } tv.ValidateCreateTenantRequest(reqBody) @@ -117,11 +153,12 @@ func TestTenantValidator_ValidateCreateTenantRequest(t *testing.T) { t.Run("validates request successfully without URLs", func(t *testing.T) { tv := NewTenantValidator() reqBody := &TenantRequest{ - Name: "aid-org", - OwnerEmail: "owner@email.org", - OwnerFirstName: "Owner", - OwnerLastName: "Owner", - OrganizationName: "Aid Org", + Name: "aid-org", + OwnerEmail: "owner@email.org", + OwnerFirstName: "Owner", + OwnerLastName: "Owner", + OrganizationName: "Aid Org", + DistributionAccountType: string(schema.DistributionAccountStellarEnv), } tv.ValidateCreateTenantRequest(reqBody) diff --git a/stellar-multitenant/pkg/serve/serve.go b/stellar-multitenant/pkg/serve/serve.go index 6b2e2dff4..1829d50b1 100644 --- a/stellar-multitenant/pkg/serve/serve.go +++ b/stellar-multitenant/pkg/serve/serve.go @@ -15,6 +15,7 @@ import ( "github.com/stellar/stellar-disbursement-platform-backend/internal/data" "github.com/stellar/stellar-disbursement-platform-backend/internal/message" "github.com/stellar/stellar-disbursement-platform-backend/internal/serve/middleware" + coreSvc "github.com/stellar/stellar-disbursement-platform-backend/internal/services" "github.com/stellar/stellar-disbursement-platform-backend/internal/transactionsubmission/engine" "github.com/stellar/stellar-disbursement-platform-backend/internal/utils" "github.com/stellar/stellar-disbursement-platform-backend/stellar-multitenant/internal/httphandler" @@ -44,6 +45,7 @@ type ServeOptions struct { networkType utils.NetworkType Port int SubmitterEngine engine.SubmitterEngine + DistributionAccountService coreSvc.DistributionAccountServiceInterface TenantAccountNativeAssetBootstrapAmount int tenantManager tenant.ManagerInterface tenantProvisioningManager *provisioning.Manager @@ -140,10 +142,10 @@ func handleHTTP(opts *ServeOptions) *chi.Mux { AdminDBConnectionPool: opts.AdminDBConnectionPool, SingleTenantMode: opts.SingleTenantMode, Models: opts.Models, - HorizonClient: opts.SubmitterEngine.HorizonClient, DistributionAccountResolver: opts.SubmitterEngine.DistributionAccountResolver, MessengerClient: opts.EmailMessengerClient, CrashTrackerClient: opts.CrashTrackerClient, + DistributionAccountService: opts.DistributionAccountService, BaseURL: opts.BaseURL, SDPUIBaseURL: opts.SDPUIBaseURL, } diff --git a/stellar-multitenant/pkg/serve/serve_test.go b/stellar-multitenant/pkg/serve/serve_test.go index 133fa80b0..b16f63df7 100644 --- a/stellar-multitenant/pkg/serve/serve_test.go +++ b/stellar-multitenant/pkg/serve/serve_test.go @@ -45,7 +45,7 @@ func Test_SetupDependencies(t *testing.T) { mMessengerClient := &message.MessengerClientMock{} mHorizonClient := &horizonclient.MockClient{} mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, _, _, _, _ := signing.NewMockSignatureService(t) + sigService, _, _ := signing.NewMockSignatureService(t) submitterEngine := engine.SubmitterEngine{ HorizonClient: mHorizonClient, SignatureService: sigService, @@ -126,7 +126,7 @@ func Test_Serve(t *testing.T) { mHorizonClient := &horizonclient.MockClient{} mLedgerNumberTracker := preconditionsMocks.NewMockLedgerNumberTracker(t) - sigService, _, _, _, _ := signing.NewMockSignatureService(t) + sigService, _, _ := signing.NewMockSignatureService(t) submitterEngine := engine.SubmitterEngine{ HorizonClient: mHorizonClient, @@ -182,7 +182,7 @@ func Test_handleHTTP_authenticatedAdminEndpoints(t *testing.T) { handlerMux := handleHTTP(&serveOptions) // Authenticated endpoints - authenticatedEndpoints := []struct { // TODO: body to requests + authenticatedEndpoints := []struct { method string path string }{ diff --git a/stellar-multitenant/pkg/tenant/manager.go b/stellar-multitenant/pkg/tenant/manager.go index 9fe7fc858..6f8b0583c 100644 --- a/stellar-multitenant/pkg/tenant/manager.go +++ b/stellar-multitenant/pkg/tenant/manager.go @@ -42,6 +42,7 @@ type ManagerInterface interface { DropTenantSchema(ctx context.Context, tenantName string) error UpdateTenantConfig(ctx context.Context, tu *TenantUpdate) (*Tenant, error) SoftDeleteTenantByID(ctx context.Context, tenantID string) (*Tenant, error) + DeactivateTenantDistributionAccount(ctx context.Context, tenantID string) error } type Manager struct { @@ -236,6 +237,24 @@ func (m *Manager) SoftDeleteTenantByID(ctx context.Context, tenantID string) (*T return &t, nil } +// DeactivateTenantDistributionAccount sets a distribution account of status ACTIVE to PENDING_USER_ACTIVATION for the given tenant id, +// and is only used in the case where the distribution account is of type CircleDBVault. +func (m *Manager) DeactivateTenantDistributionAccount(ctx context.Context, tenantID string) error { + q := ` + UPDATE tenants t + SET + distribution_account_status = 'PENDING_USER_ACTIVATION' + WHERE id = $1 + AND distribution_account_type = 'DISTRIBUTION_ACCOUNT.CIRCLE.DB_VAULT' + ` + + if _, err := m.db.ExecContext(ctx, q, tenantID); err != nil { + return fmt.Errorf("deactivating distribution account for tenant %s: %w", tenantID, err) + } + + return nil +} + func (m *Manager) CreateTenantSchema(ctx context.Context, tenantName string) error { schemaName := fmt.Sprintf("sdp_%s", tenantName) _, err := m.db.ExecContext(ctx, fmt.Sprintf("CREATE SCHEMA %s", pq.QuoteIdentifier(schemaName))) diff --git a/stellar-multitenant/pkg/tenant/manager_test.go b/stellar-multitenant/pkg/tenant/manager_test.go index 08428277f..023314b33 100644 --- a/stellar-multitenant/pkg/tenant/manager_test.go +++ b/stellar-multitenant/pkg/tenant/manager_test.go @@ -6,11 +6,12 @@ import ( "fmt" "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" "github.com/stellar/stellar-disbursement-platform-backend/pkg/schema" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func Test_Manager_AddTenant(t *testing.T) { @@ -133,19 +134,19 @@ func Test_Manager_UpdateTenantConfig(t *testing.T) { { name: "🎉 successfully updates the tenant [DistributionAccountType]", tenantUpdateFn: func(tnt Tenant) *TenantUpdate { - return &TenantUpdate{ID: tnt.ID, DistributionAccountType: schema.DistributionAccountTypeEnvStellar} + return &TenantUpdate{ID: tnt.ID, DistributionAccountType: schema.DistributionAccountStellarEnv} }, expectedFieldsToAssert: map[string]interface{}{ - "distribution_account_type": string(schema.DistributionAccountTypeEnvStellar), + "distribution_account_type": string(schema.DistributionAccountStellarEnv), }, }, { name: "🎉 successfully updates the tenant [DistributionAccountStatus]", tenantUpdateFn: func(tnt Tenant) *TenantUpdate { - return &TenantUpdate{ID: tnt.ID, DistributionAccountStatus: schema.DistributionAccountStatusPendingUserActivation} + return &TenantUpdate{ID: tnt.ID, DistributionAccountStatus: schema.AccountStatusPendingUserActivation} }, expectedFieldsToAssert: map[string]interface{}{ - "distribution_account_status": string(schema.DistributionAccountStatusPendingUserActivation), + "distribution_account_status": string(schema.AccountStatusPendingUserActivation), }, }, { @@ -157,8 +158,8 @@ func Test_Manager_UpdateTenantConfig(t *testing.T) { SDPUIBaseURL: pointerTo("https://ui.myorg.test.com"), Status: pointerTo(DeactivatedTenantStatus), DistributionAccountAddress: "GCK6GPKFTIGJJM7OHSQH7O7ORSKTUK37ZUDEUXZRFMIQNBUBZDEPU5KS", - DistributionAccountType: schema.DistributionAccountTypeEnvStellar, - DistributionAccountStatus: schema.DistributionAccountStatusPendingUserActivation, + DistributionAccountType: schema.DistributionAccountStellarEnv, + DistributionAccountStatus: schema.AccountStatusPendingUserActivation, } }, expectedFieldsToAssert: map[string]interface{}{ @@ -166,8 +167,8 @@ func Test_Manager_UpdateTenantConfig(t *testing.T) { "sdp_ui_base_url": "https://ui.myorg.test.com", "status": string(DeactivatedTenantStatus), "distribution_account_address": "GCK6GPKFTIGJJM7OHSQH7O7ORSKTUK37ZUDEUXZRFMIQNBUBZDEPU5KS", - "distribution_account_type": string(schema.DistributionAccountTypeEnvStellar), - "distribution_account_status": string(schema.DistributionAccountStatusPendingUserActivation), + "distribution_account_type": string(schema.DistributionAccountStellarEnv), + "distribution_account_status": string(schema.AccountStatusPendingUserActivation), }, }, } @@ -362,31 +363,31 @@ func Test_Manager_GetTenantByIDOrName(t *testing.T) { require.NoError(t, err) t.Run("gets tenant by ID successfully", func(t *testing.T) { - tntDB, err := m.GetTenantByIDOrName(ctx, tnt1.ID) - require.NoError(t, err) + tntDB, dbErr := m.GetTenantByIDOrName(ctx, tnt1.ID) + require.NoError(t, dbErr) assert.Equal(t, tnt1, tntDB) }) t.Run("gets tenant by name successfully", func(t *testing.T) { - tntDB, err := m.GetTenantByIDOrName(ctx, tnt2.Name) - require.NoError(t, err) + tntDB, dbErr := m.GetTenantByIDOrName(ctx, tnt2.Name) + require.NoError(t, dbErr) assert.Equal(t, tnt2, tntDB) }) t.Run("returns error when tenant is deactivated", func(t *testing.T) { deactivateTenant(t, ctx, m, tnt2) - tntDB, err := m.GetTenantByIDOrName(ctx, tnt2.ID) - assert.ErrorIs(t, err, ErrTenantDoesNotExist) + tntDB, dbErr := m.GetTenantByIDOrName(ctx, tnt2.ID) + assert.ErrorIs(t, dbErr, ErrTenantDoesNotExist) assert.Nil(t, tntDB) - tntDB, err = m.GetTenantByIDOrName(ctx, tnt2.Name) - assert.ErrorIs(t, err, ErrTenantDoesNotExist) + tntDB, dbErr = m.GetTenantByIDOrName(ctx, tnt2.Name) + assert.ErrorIs(t, dbErr, ErrTenantDoesNotExist) assert.Nil(t, tntDB) }) t.Run("returns error when tenant is not found", func(t *testing.T) { - tntDB, err := m.GetTenantByIDOrName(ctx, "unknown") - assert.ErrorIs(t, err, ErrTenantDoesNotExist) + tntDB, dbErr := m.GetTenantByIDOrName(ctx, "unknown") + assert.ErrorIs(t, dbErr, ErrTenantDoesNotExist) assert.Nil(t, tntDB) }) } @@ -424,8 +425,8 @@ func Test_Manager_GetDefault(t *testing.T) { require.NoError(t, err) t.Run("returns error when there's no default tenant", func(t *testing.T) { - defaultTnt, err := m.GetDefault(ctx) - assert.EqualError(t, err, ErrTenantDoesNotExist.Error()) + defaultTnt, dbErr := m.GetDefault(ctx) + assert.EqualError(t, dbErr, ErrTenantDoesNotExist.Error()) assert.Nil(t, defaultTnt) }) @@ -433,8 +434,8 @@ func Test_Manager_GetDefault(t *testing.T) { updateTenantIsDefault(t, ctx, dbConnectionPool, tnt2.ID, true) t.Run("returns error when there's multiple default tenants", func(t *testing.T) { - defaultTnt, err := m.GetDefault(ctx) - assert.EqualError(t, err, ErrTooManyDefaultTenants.Error()) + defaultTnt, dbErr := m.GetDefault(ctx) + assert.EqualError(t, dbErr, ErrTooManyDefaultTenants.Error()) assert.Nil(t, defaultTnt) }) @@ -442,8 +443,8 @@ func Test_Manager_GetDefault(t *testing.T) { t.Run("returns error when default tenant is inactive", func(t *testing.T) { deactivateTenant(t, ctx, m, tnt2) - defaultTnt, err := m.GetDefault(ctx) - assert.EqualError(t, err, ErrTenantDoesNotExist.Error()) + defaultTnt, dbErr := m.GetDefault(ctx) + assert.EqualError(t, dbErr, ErrTenantDoesNotExist.Error()) assert.Nil(t, defaultTnt) }) @@ -451,8 +452,8 @@ func Test_Manager_GetDefault(t *testing.T) { activateTenant(t, ctx, m, tnt2) t.Run("gets the default tenant successfully", func(t *testing.T) { - tntDB, err := m.GetDefault(ctx) - require.NoError(t, err) + tntDB, dbErr := m.GetDefault(ctx) + require.NoError(t, dbErr) assert.Equal(t, tnt2.ID, tntDB.ID) assert.Equal(t, tnt2.Name, tntDB.Name) assert.True(t, tntDB.IsDefault) @@ -484,43 +485,43 @@ func Test_Manager_SetDefault(t *testing.T) { updateTenantIsDefault(t, ctx, dbConnectionPool, tnt1.ID, true) t.Run("ensures the default tenant is not changed when an error occurs", func(t *testing.T) { - tnt, err := db.RunInTransactionWithResult(ctx, dbConnectionPool, nil, func(dbTx db.DBTransaction) (*Tenant, error) { + tnt, dbErr := db.RunInTransactionWithResult(ctx, dbConnectionPool, nil, func(dbTx db.DBTransaction) (*Tenant, error) { dTnt, innerErr := m.SetDefault(ctx, dbTx, "some-id") return dTnt, innerErr }) - assert.ErrorIs(t, err, ErrTenantDoesNotExist) + assert.ErrorIs(t, dbErr, ErrTenantDoesNotExist) assert.Nil(t, tnt) - tnt1DB, err := m.GetTenantByID(ctx, tnt1.ID) - require.NoError(t, err) + tnt1DB, dbErr := m.GetTenantByID(ctx, tnt1.ID) + require.NoError(t, dbErr) assert.True(t, tnt1DB.IsDefault) }) t.Run("returns error when attempting to set deactivated tenant to default", func(t *testing.T) { - tnt3, err := m.AddTenant(ctx, "myorg3") - require.NoError(t, err) + tnt3, dbErr := m.AddTenant(ctx, "myorg3") + require.NoError(t, dbErr) deactivateTenant(t, ctx, m, tnt3) - tnt, err := m.SetDefault(ctx, dbConnectionPool, tnt3.Name) - assert.ErrorIs(t, err, ErrTenantDoesNotExist) + tnt, dbErr := m.SetDefault(ctx, dbConnectionPool, tnt3.Name) + assert.ErrorIs(t, dbErr, ErrTenantDoesNotExist) assert.Nil(t, tnt) - tnt3DB, err := m.GetTenant(ctx, &QueryParams{ + tnt3DB, dbErr := m.GetTenant(ctx, &QueryParams{ Filters: map[FilterKey]interface{}{FilterKeyID: tnt3.ID}, }) - require.NoError(t, err) + require.NoError(t, dbErr) assert.False(t, tnt3DB.IsDefault) }) t.Run("updates default tenant", func(t *testing.T) { - tnt2DB, err := m.SetDefault(ctx, dbConnectionPool, tnt2.ID) - require.NoError(t, err) + tnt2DB, dbErr := m.SetDefault(ctx, dbConnectionPool, tnt2.ID) + require.NoError(t, dbErr) assert.Equal(t, tnt2.ID, tnt2DB.ID) assert.True(t, tnt2DB.IsDefault) - tnt1DB, err := m.GetTenantByID(ctx, tnt1.ID) - require.NoError(t, err) + tnt1DB, dbErr := m.GetTenantByID(ctx, tnt1.ID) + require.NoError(t, dbErr) assert.Equal(t, tnt1.ID, tnt1DB.ID) assert.False(t, tnt1DB.IsDefault) }) @@ -541,7 +542,7 @@ func Test_Manager_DeleteTenantByName(t *testing.T) { require.NoError(t, err) t.Run("deletes tenant successfully", func(t *testing.T) { - err := m.DeleteTenantByName(ctx, tnt.Name) + err = m.DeleteTenantByName(ctx, tnt.Name) require.NoError(t, err) _, err = m.GetTenantByName(ctx, tnt.Name) @@ -549,7 +550,7 @@ func Test_Manager_DeleteTenantByName(t *testing.T) { }) t.Run("returns error when tenant name is empty", func(t *testing.T) { - err := m.DeleteTenantByName(ctx, "") + err = m.DeleteTenantByName(ctx, "") assert.ErrorIs(t, err, ErrEmptyTenantName) }) } @@ -569,29 +570,86 @@ func Test_Manager_SoftDeleteTenantByID(t *testing.T) { require.NoError(t, err) t.Run("returns error when tenant does not exist", func(t *testing.T) { - _, err := m.SoftDeleteTenantByID(ctx, "invalid-tnt") + _, err = m.SoftDeleteTenantByID(ctx, "invalid-tnt") require.Error(t, err) assert.ErrorIs(t, err, ErrTenantDoesNotExist) }) t.Run("returns error when tenant is not deactivated", func(t *testing.T) { - _, err := m.SoftDeleteTenantByID(ctx, tnt.ID) + _, err = m.SoftDeleteTenantByID(ctx, tnt.ID) require.Error(t, err) assert.ErrorIs(t, err, ErrTenantDoesNotExist) }) t.Run("successfully soft deletes tenant", func(t *testing.T) { deactivateTenant(t, ctx, m, tnt) - dbTnt, err := m.SoftDeleteTenantByID(ctx, tnt.ID) - require.NoError(t, err) + dbTnt, dbErr := m.SoftDeleteTenantByID(ctx, tnt.ID) + require.NoError(t, dbErr) require.NotNil(t, dbTnt.DeletedAt) - dbTnt, err = m.GetTenant(ctx, &QueryParams{Filters: map[FilterKey]interface{}{FilterKeyID: tnt.ID}}) - require.NoError(t, err) + dbTnt, dbErr = m.GetTenant(ctx, &QueryParams{Filters: map[FilterKey]interface{}{FilterKeyID: tnt.ID}}) + require.NoError(t, dbErr) assert.NotNil(t, dbTnt.DeletedAt) }) } +func TestManager_DeactivateTenantDistributionAccount(t *testing.T) { + dbt := dbtest.OpenWithAdminMigrationsOnly(t) + defer dbt.Close() + + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + ctx := context.Background() + + m := NewManager(WithDatabase(dbConnectionPool)) + tnt, err := m.AddTenant(ctx, "myorg1") + require.NoError(t, err) + + t.Run("does not deactivate distribution account if not managed by Circle", func(t *testing.T) { + err = m.DeactivateTenantDistributionAccount(ctx, tnt.ID) + require.NoError(t, err) + + dbTnt, dbErr := m.GetTenant(ctx, &QueryParams{Filters: map[FilterKey]interface{}{FilterKeyID: tnt.ID}}) + require.NoError(t, dbErr) + assert.NotEqual(t, schema.AccountStatusPendingUserActivation, dbTnt.DistributionAccountStatus) + }) + + t.Run("operation is idempotent if distribution account is already deactivated", func(t *testing.T) { + _, err = m.UpdateTenantConfig( + ctx, &TenantUpdate{ + ID: tnt.ID, + DistributionAccountType: schema.DistributionAccountCircleDBVault, + DistributionAccountStatus: schema.AccountStatusPendingUserActivation, + }) + require.NoError(t, err) + + err = m.DeactivateTenantDistributionAccount(ctx, tnt.ID) + require.NoError(t, err) + + dbTnt, dbErr := m.GetTenant(ctx, &QueryParams{Filters: map[FilterKey]interface{}{FilterKeyID: tnt.ID}}) + require.NoError(t, dbErr) + assert.Equal(t, schema.AccountStatusPendingUserActivation, dbTnt.DistributionAccountStatus) + }) + + t.Run("successfully deactivates tenant distribution account", func(t *testing.T) { + _, err = m.UpdateTenantConfig( + ctx, &TenantUpdate{ + ID: tnt.ID, + DistributionAccountType: schema.DistributionAccountCircleDBVault, + DistributionAccountStatus: schema.AccountStatusActive, + }) + require.NoError(t, err) + err = m.DeactivateTenantDistributionAccount(ctx, tnt.ID) + require.NoError(t, err) + + dbTnt, dbErr := m.GetTenant(ctx, &QueryParams{Filters: map[FilterKey]interface{}{FilterKeyID: tnt.ID}}) + require.NoError(t, dbErr) + assert.Equal(t, schema.AccountStatusPendingUserActivation, dbTnt.DistributionAccountStatus) + }) +} + func Test_Manager_DropTenantSchema(t *testing.T) { dbt := dbtest.OpenWithAdminMigrationsOnly(t) defer dbt.Close() diff --git a/stellar-multitenant/pkg/tenant/mocks.go b/stellar-multitenant/pkg/tenant/mocks.go index 8448459bd..cb84fbaa1 100644 --- a/stellar-multitenant/pkg/tenant/mocks.go +++ b/stellar-multitenant/pkg/tenant/mocks.go @@ -3,8 +3,9 @@ package tenant import ( "context" - "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stretchr/testify/mock" + + "github.com/stellar/stellar-disbursement-platform-backend/db" ) type TenantManagerMock struct { @@ -125,4 +126,26 @@ func (m *TenantManagerMock) UpdateTenantConfig(ctx context.Context, tu *TenantUp return args.Get(0).(*Tenant), args.Error(1) } +func (m *TenantManagerMock) DeactivateTenantDistributionAccount(ctx context.Context, id string) error { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return args.Error(0) + } + return args.Error(0) +} + var _ ManagerInterface = (*TenantManagerMock)(nil) + +type testInterface interface { + mock.TestingT + Cleanup(func()) +} + +func NewTenantManagerMock(t testInterface) *TenantManagerMock { + mock := &TenantManagerMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/stellar-multitenant/pkg/tenant/multitenant_data_source_router_test.go b/stellar-multitenant/pkg/tenant/multitenant_data_source_router_test.go index 2565a9dcc..9bf3c0258 100644 --- a/stellar-multitenant/pkg/tenant/multitenant_data_source_router_test.go +++ b/stellar-multitenant/pkg/tenant/multitenant_data_source_router_test.go @@ -4,9 +4,10 @@ import ( "context" "testing" + "github.com/stretchr/testify/require" + "github.com/stellar/stellar-disbursement-platform-backend/db" "github.com/stellar/stellar-disbursement-platform-backend/db/dbtest" - "github.com/stretchr/testify/require" ) func TestMultiTenantDataSourceRouter_GetDataSource(t *testing.T) { diff --git a/stellar-multitenant/pkg/tenant/tenant.go b/stellar-multitenant/pkg/tenant/tenant.go index 7f8428d65..6e50664c1 100644 --- a/stellar-multitenant/pkg/tenant/tenant.go +++ b/stellar-multitenant/pkg/tenant/tenant.go @@ -22,9 +22,9 @@ type Tenant struct { UpdatedAt time.Time `json:"updated_at" db:"updated_at"` DeletedAt *time.Time `json:"deleted_at" db:"deleted_at"` // Distribution Account fields: - DistributionAccountAddress *string `json:"distribution_account_address" db:"distribution_account_address"` - DistributionAccountType schema.DistributionAccountType `json:"distribution_account_type" db:"distribution_account_type"` - DistributionAccountStatus schema.DistributionAccountStatus `json:"distribution_account_status" db:"distribution_account_status"` + DistributionAccountAddress *string `json:"distribution_account_address" db:"distribution_account_address"` + DistributionAccountType schema.AccountType `json:"distribution_account_type" db:"distribution_account_type"` + DistributionAccountStatus schema.AccountStatus `json:"distribution_account_status" db:"distribution_account_status"` } type TenantUpdate struct { @@ -33,8 +33,8 @@ type TenantUpdate struct { SDPUIBaseURL *string Status *TenantStatus DistributionAccountAddress string - DistributionAccountType schema.DistributionAccountType - DistributionAccountStatus schema.DistributionAccountStatus + DistributionAccountType schema.AccountType + DistributionAccountStatus schema.AccountStatus } type TenantStatus string