From f5d43192cce47b036648ef26fbf8cc05cc379ee1 Mon Sep 17 00:00:00 2001 From: Amin Latifi Date: Tue, 13 Feb 2024 11:00:05 +0000 Subject: [PATCH] Feat/draft donation (#1308) * Added draftDonation resolver and entity * Added few tests Incomplete matchDraftDoantions * Implemented draft donation matching logic * Prepared draft donation matching worker draft * Added support for draft donation matching worker run * Updated delete expired draft donations Integrated delete expired draft donation to draft donation matcher job * Added enum creation to migration file * Added sequence to migration file * Reorder sql qury run * Fixed id colum creation issue in migration file * Removed settings from test.env * rename draft worker --------- Co-authored-by: Carlos --- config/example.env | 6 + config/test.env | 5 +- .../1707738577647-addDraftDonationTable.ts | 83 ++++++ package.json | 3 +- src/entities/donation.ts | 3 +- src/entities/draftDonation.ts | 110 ++++++++ src/entities/entities.ts | 2 + src/orm.ts | 5 +- .../draftDonationRepository.test.ts | 84 ++++++ src/repositories/draftDonationRepository.ts | 50 ++++ src/resolvers/donationResolver.test.ts | 55 ++++ src/resolvers/donationResolver.ts | 9 + src/resolvers/draftDonationResolver.test.ts | 129 +++++++++ src/resolvers/draftDonationResolver.ts | 185 +++++++++++++ src/resolvers/projectResolver.ts | 1 + src/resolvers/resolvers.ts | 2 + src/server/bootstrap.ts | 5 + src/services/Idriss/contractDonations.ts | 4 +- .../chains/evm/draftDonationService.test.ts | 245 ++++++++++++++++++ .../chains/evm/draftDonationService.ts | 241 +++++++++++++++++ src/services/chains/evm/transactionService.ts | 63 ++--- src/services/chains/index.ts | 7 +- .../cronJobs/draftDonationMatchingJob.ts | 38 +++ src/services/cronJobs/fillSnapshotBalances.ts | 1 - src/types/etherscan.ts | 22 ++ src/utils/errorMessages.ts | 1 + src/utils/locales/en.json | 219 ++++++++-------- src/utils/locales/es.json | 3 +- src/utils/tokenUtils.ts | 2 + .../validators/graphqlQueryValidators.ts | 25 ++ src/workers/draftDonationMatchWorker.test.ts | 82 ++++++ src/workers/draftDonationMatchWorker.ts | 69 +++++ test/graphqlQueries.ts | 24 ++ 33 files changed, 1629 insertions(+), 154 deletions(-) create mode 100644 migration/1707738577647-addDraftDonationTable.ts create mode 100644 src/entities/draftDonation.ts create mode 100644 src/repositories/draftDonationRepository.test.ts create mode 100644 src/repositories/draftDonationRepository.ts create mode 100644 src/resolvers/draftDonationResolver.test.ts create mode 100644 src/resolvers/draftDonationResolver.ts create mode 100644 src/services/chains/evm/draftDonationService.test.ts create mode 100644 src/services/chains/evm/draftDonationService.ts create mode 100644 src/services/cronJobs/draftDonationMatchingJob.ts create mode 100644 src/types/etherscan.ts create mode 100644 src/workers/draftDonationMatchWorker.test.ts create mode 100644 src/workers/draftDonationMatchWorker.ts diff --git a/config/example.env b/config/example.env index 381cf45a7..bd3766181 100644 --- a/config/example.env +++ b/config/example.env @@ -271,3 +271,9 @@ UPDATE_RECURRING_DONATIONS_STREAM=0 0 * * * MPETH_GRAPHQL_PRICES_URL= MPETH_GRAPHQL_PRICES_URL= + +# Draft donation match expiration hours, they will be deleted after to lessen dabase size +ENABLE_DRAFT_DONATION=true +DRAFT_DONATION_MATCH_EXPIRATION_HOURS=24 +MATCH_DRAFT_DONATION_CRONJOB_EXPRESSION = '0 */5 * * * *'; + diff --git a/config/test.env b/config/test.env index e3bb93c6d..4ead8266c 100644 --- a/config/test.env +++ b/config/test.env @@ -212,4 +212,7 @@ DISABLE_NOTIFICATION_CENTER=false DONATION_SAVE_BACKUP_CRONJOB_EXPRESSION= ENABLE_IMPORT_DONATION_BACKUP=false DONATION_SAVE_BACKUP_API_URL= -DONATION_SAVE_BACKUP_API_SECRET= \ No newline at end of file +DONATION_SAVE_BACKUP_API_SECRET= + +DRAFT_DONATION_MATCH_EXPIRATION_HOURS=24 +ENABLE_DRAFT_DONATION=true \ No newline at end of file diff --git a/migration/1707738577647-addDraftDonationTable.ts b/migration/1707738577647-addDraftDonationTable.ts new file mode 100644 index 000000000..b378c3165 --- /dev/null +++ b/migration/1707738577647-addDraftDonationTable.ts @@ -0,0 +1,83 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDraftDonationTable1707738577647 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'draft_donation_chaintype_enum') THEN + CREATE TYPE public.draft_donation_chaintype_enum AS ENUM + ('EVM', 'SOLANA'); + END IF; + + ALTER TYPE public.draft_donation_chaintype_enum + OWNER TO postgres; + END$$;`); + + await queryRunner.query(` + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'draft_donation_status_enum') THEN + CREATE TYPE public.draft_donation_status_enum AS ENUM + ('pending', 'matched', 'failed'); + END IF; + + ALTER TYPE public.draft_donation_status_enum + OWNER TO postgres; + END$$; + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.draft_donation + ( + "id" SERIAL NOT NULL, + "networkId" integer NOT NULL, + "chainType" draft_donation_chaintype_enum NOT NULL DEFAULT 'EVM'::draft_donation_chaintype_enum, + status draft_donation_status_enum NOT NULL DEFAULT 'pending'::draft_donation_status_enum, + "toWalletAddress" character varying COLLATE pg_catalog."default" NOT NULL, + "fromWalletAddress" character varying COLLATE pg_catalog."default" NOT NULL, + "tokenAddress" character varying COLLATE pg_catalog."default", + currency character varying COLLATE pg_catalog."default" NOT NULL, + anonymous boolean, + amount real NOT NULL, + "projectId" integer, + "userId" integer, + "createdAt" timestamp without time zone NOT NULL DEFAULT now(), + "referrerId" character varying COLLATE pg_catalog."default", + "expectedCallData" character varying COLLATE pg_catalog."default", + "errorMessage" character varying COLLATE pg_catalog."default", + CONSTRAINT "PK_4f2eb58b84fb470edcd483c78af" PRIMARY KEY (id) + ) + + TABLESPACE pg_default; + + ALTER TABLE IF EXISTS public.draft_donation + OWNER to postgres; + + CREATE INDEX IF NOT EXISTS "IDX_287bf9818fca5b436122847223" + ON public.draft_donation USING btree + ("userId" ASC NULLS LAST) + TABLESPACE pg_default + WHERE status = 'pending'::draft_donation_status_enum; + + CREATE UNIQUE INDEX IF NOT EXISTS "IDX_af180374473ea402e7595196a6" + ON public.draft_donation USING btree + ("fromWalletAddress" COLLATE pg_catalog."default" ASC NULLS LAST, "toWalletAddress" COLLATE pg_catalog."default" ASC NULLS LAST, "networkId" ASC NULLS LAST, amount ASC NULLS LAST, currency COLLATE pg_catalog."default" ASC NULLS LAST) + TABLESPACE pg_default + WHERE status = 'pending'::draft_donation_status_enum; + + CREATE INDEX IF NOT EXISTS "IDX_029453ee31e092317f7f96ee3b" + ON public.draft_donation USING btree + ("createdAt" ASC NULLS LAST) + TABLESPACE pg_default; + + CREATE INDEX IF NOT EXISTS "IDX_ff4b8666a0090d059f00c59216" + ON public.draft_donation USING btree + (status ASC NULLS LAST) + TABLESPACE pg_default + WHERE status = 'pending'::draft_donation_status_enum; + `); + } + + public async down(queryRunner: QueryRunner): Promise {} +} diff --git a/package.json b/package.json index 19abfc69a..fb5885ac0 100644 --- a/package.json +++ b/package.json @@ -161,7 +161,7 @@ "test:projectAddressRepository": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/projectAddressRepository.test.ts", "test:anchorContractAddressRepository": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/anchorContractAddressRepository.test.ts", "test:recurringDonationRepository": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/recurringDonationRepository.test.ts", - "test:dbCronRepository": "NODE_ENV=test mocha -t 90000 ./test/pre-test-scripts.ts ./src/repositories/dbCronRepository.test.ts", + "test:dbCronRepository": "NODE_ENV=test mocha -t 90000 ./test/pre-test-scripts.ts ./src/repositories/dbCronRepository.test.ts", "test:powerBoostingResolver": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/resolvers/powerBoostingResolver.test.ts", "test:userProjectPowerResolver": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/resolvers/userProjectPowerResolver.test.ts", "test:projectPowerResolver": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/resolvers/projectPowerResolver.test.ts", @@ -170,6 +170,7 @@ "test:recurringDonationResolver": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/resolvers/recurringDonationResolver.test.ts", "test:fillSnapshotBalance": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/services/cronJobs/fillSnapshotBalances.test.ts", "test:donationService": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/repositories/qfRoundHistoryRepository.test.ts ./src/services/donationService.test.ts", + "test:draftDonationService": "NODE_ENV=test mocha -t 99999 ./test/pre-test-scripts.ts src/services/chains/evm/draftDonationService.test.ts src/repositories/draftDonationRepository.test.ts src/workers/draftDonationMatchWorker.test.ts", "test:userService": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/services/userService.test.ts", "test:lostDonations": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/services/cronJobs/importLostDonationsJob.test.ts", "test:reactionsService": "NODE_ENV=test mocha ./test/pre-test-scripts.ts ./src/services/reactionsService.test.ts", diff --git a/src/entities/donation.ts b/src/entities/donation.ts index bf33f1494..2238dfe58 100644 --- a/src/entities/donation.ts +++ b/src/entities/donation.ts @@ -22,8 +22,9 @@ export const DONATION_STATUS = { FAILED: 'failed', }; -export const DONATION_EXTERNAL_SOURCES = { +export const DONATION_ORIGINS = { IDRISS_TWITTER: 'Idriss', + DRAFT_DONATION_MATCHING: 'DraftDonationMatching', }; export const DONATION_TYPES = { diff --git a/src/entities/draftDonation.ts b/src/entities/draftDonation.ts new file mode 100644 index 000000000..5075166b3 --- /dev/null +++ b/src/entities/draftDonation.ts @@ -0,0 +1,110 @@ +import { Field, ID, Int, ObjectType } from 'type-graphql'; +import { + PrimaryGeneratedColumn, + Column, + Entity, + BaseEntity, + Index, + CreateDateColumn, +} from 'typeorm'; +import { ChainType } from '../types/network'; + +export const DRAFT_DONATION_STATUS = { + PENDING: 'pending', + MATCHED: 'matched', + FAILED: 'failed', +}; + +@Entity() +@ObjectType() +// To mark the draft donation as matched, when the donation is created in DonationResolver +@Index( + ['fromWalletAddress', 'toWalletAddress', 'networkId', 'amount', 'currency'], + { + where: `status = '${DRAFT_DONATION_STATUS.PENDING}'`, + unique: true, + }, +) +export class DraftDonation extends BaseEntity { + @Field(type => ID) + @PrimaryGeneratedColumn() + id: number; + + @Field() + @Column({ nullable: false }) + networkId: number; + + // // TODO: support safeTransactionId + // @Field() + // @Column({ nullable: true }) + // safeTransactionId?: string; + + @Field(type => String) + @Column({ + type: 'enum', + enum: ChainType, + default: ChainType.EVM, + }) + chainType: ChainType; + + @Field() + @Column({ + type: 'enum', + enum: DRAFT_DONATION_STATUS, + default: DRAFT_DONATION_STATUS.PENDING, + }) + @Index({ where: `status = '${DRAFT_DONATION_STATUS.PENDING}'` }) + status: string; + + @Field() + @Column() + toWalletAddress: string; + + @Field() + @Column() + fromWalletAddress: string; + + @Field({ nullable: true }) + @Column({ nullable: true }) + tokenAddress: string; + + @Field() + @Column() + currency: string; + + @Field({ nullable: true }) + @Column({ nullable: true }) + anonymous: boolean; + + @Field() + @Column({ type: 'real' }) + amount: number; + + @Field() + @Column({ nullable: true }) + projectId: number; + + @Field() + @Column({ nullable: true }) + @Index({ where: `status = '${DRAFT_DONATION_STATUS.PENDING}'` }) + userId: number; + + @Index() + @Field(type => Date) + @CreateDateColumn() + createdAt: Date; + + @Field(type => String, { nullable: true }) + @Column({ nullable: true }) + referrerId?: string; + + // Expected call data used only for matching ERC20 transfers + // Is calculated and saved once during the matching time, and will be used in next iterations + @Field(type => String, { nullable: true }) + @Column({ nullable: true }) + expectedCallData?: string; + + @Field({ nullable: true }) + @Column({ nullable: true }) + errorMessage?: string; +} diff --git a/src/entities/entities.ts b/src/entities/entities.ts index 594ea5c47..6f4fd4797 100644 --- a/src/entities/entities.ts +++ b/src/entities/entities.ts @@ -44,6 +44,7 @@ import { QfRoundHistory } from './qfRoundHistory'; import { ProjectEstimatedMatchingView } from './ProjectEstimatedMatchingView'; import { AnchorContractAddress } from './anchorContractAddress'; import { RecurringDonation } from './recurringDonation'; +import { DraftDonation } from './draftDonation'; export const getEntities = (): DataSourceOptions['entities'] => { return [ @@ -59,6 +60,7 @@ export const getEntities = (): DataSourceOptions['entities'] => { FeaturedUpdate, Reaction, Donation, + DraftDonation, Token, Wallet, ProjectStatus, diff --git a/src/orm.ts b/src/orm.ts index c704c34f5..baaf36fa9 100644 --- a/src/orm.ts +++ b/src/orm.ts @@ -7,9 +7,10 @@ import { redis, redisConfig } from './redis'; export class AppDataSource { private static datasource: DataSource; - static async initialize() { + static async initialize(_overrideDrop?: boolean) { if (!AppDataSource.datasource) { - const dropSchema = config.get('DROP_DATABASE') === 'true'; + const dropSchema = + _overrideDrop ?? config.get('DROP_DATABASE') === 'true'; const synchronize = (config.get('ENVIRONMENT') as string) === 'test'; const entities = getEntities(); AppDataSource.datasource = new DataSource({ diff --git a/src/repositories/draftDonationRepository.test.ts b/src/repositories/draftDonationRepository.test.ts new file mode 100644 index 000000000..961280110 --- /dev/null +++ b/src/repositories/draftDonationRepository.test.ts @@ -0,0 +1,84 @@ +// Create a draft donation + +import { expect } from 'chai'; +import { generateRandomEtheriumAddress } from '../../test/testUtils'; +import { + DRAFT_DONATION_STATUS, + DraftDonation, +} from '../entities/draftDonation'; +import { + delecteExpiredDraftDonations, + markDraftDonationStatusMatched, +} from './draftDonationRepository'; + +// Mark the draft donation as matched +describe('draftDonationRepository', () => { + beforeEach(async () => { + await DraftDonation.clear(); + }); + + it('should mark a draft donation as matched', async () => { + // Setup + const draftDonation = await DraftDonation.create({ + networkId: 1, + status: DRAFT_DONATION_STATUS.PENDING, + toWalletAddress: generateRandomEtheriumAddress(), + fromWalletAddress: generateRandomEtheriumAddress(), + tokenAddress: generateRandomEtheriumAddress(), + currency: 'GIV', + anonymous: false, + amount: 0.01, + }); + + await draftDonation.save(); + + await markDraftDonationStatusMatched({ + fromWalletAddress: draftDonation.fromWalletAddress, + toWalletAddress: draftDonation.toWalletAddress, + networkId: draftDonation.networkId, + currency: draftDonation.currency, + amount: draftDonation.amount, + }); + + const updatedDraftDonation = await DraftDonation.findOne({ + where: { + id: draftDonation.id, + }, + }); + + expect(updatedDraftDonation?.status).equal(DRAFT_DONATION_STATUS.MATCHED); + }); + + it('should clear expired draft donations', async () => { + // create a draft donation with createdAt two hours ago, and one with createdAt one hour ago + await DraftDonation.create({ + networkId: 1, + status: DRAFT_DONATION_STATUS.PENDING, + toWalletAddress: generateRandomEtheriumAddress(), + fromWalletAddress: generateRandomEtheriumAddress(), + tokenAddress: generateRandomEtheriumAddress(), + currency: 'GIV', + anonymous: false, + amount: 1, + createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000), + }).save(); + + await DraftDonation.create({ + networkId: 1, + status: DRAFT_DONATION_STATUS.PENDING, + toWalletAddress: generateRandomEtheriumAddress(), + fromWalletAddress: generateRandomEtheriumAddress(), + tokenAddress: generateRandomEtheriumAddress(), + currency: 'GIV', + anonymous: false, + amount: 1, + createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000), + }).save(); + + await delecteExpiredDraftDonations(1.5); + + const count = await DraftDonation.createQueryBuilder().getCount(); + + expect(count).equal(1); + }); +}); diff --git a/src/repositories/draftDonationRepository.ts b/src/repositories/draftDonationRepository.ts new file mode 100644 index 000000000..6c9554c32 --- /dev/null +++ b/src/repositories/draftDonationRepository.ts @@ -0,0 +1,50 @@ +import { + DRAFT_DONATION_STATUS, + DraftDonation, +} from '../entities/draftDonation'; +import { logger } from '../utils/logger'; + +// mark donation status matched based on fromWalletAddress, toWalletAddress, networkId, tokenAddress and amount +export async function markDraftDonationStatusMatched(params: { + fromWalletAddress: string; + toWalletAddress: string; + networkId: number; + currency: string; + amount: number; +}): Promise { + try { + const { fromWalletAddress, toWalletAddress, networkId, currency, amount } = + params; + await DraftDonation.update( + { + fromWalletAddress, + toWalletAddress, + networkId, + currency, + amount, + status: DRAFT_DONATION_STATUS.PENDING, + }, + { status: DRAFT_DONATION_STATUS.MATCHED }, + ); + } catch (e) { + logger.error( + `Error in markDraftDonationStatusMatched - params: ${params} - error: ${e.message}`, + ); + } +} + +export async function delecteExpiredDraftDonations(hours: number) { + try { + const expiredTime = new Date(Date.now() - hours * 60 * 60 * 1000); + + // donation is expired if it'screated before expiredTime + const result = await DraftDonation.createQueryBuilder() + .delete() + .where('createdAt < :expiredTime', { expiredTime }) + .execute(); + + logger.debug(`Expired draft donations removed: ${result.affected}`); + } catch (e) { + logger.error(`Error in removing expired draft donations, ${e.message}`); + } +} diff --git a/src/resolvers/donationResolver.test.ts b/src/resolvers/donationResolver.test.ts index aa00908de..1f434a127 100644 --- a/src/resolvers/donationResolver.test.ts +++ b/src/resolvers/donationResolver.test.ts @@ -58,6 +58,10 @@ import { addOrUpdatePowerSnapshotBalances } from '../repositories/powerBalanceSn import { findPowerSnapshots } from '../repositories/powerSnapshotRepository'; import { ChainType } from '../types/network'; import { getDefaultSolanaChainId } from '../services/chains'; +import { + DRAFT_DONATION_STATUS, + DraftDonation, +} from '../entities/draftDonation'; // tslint:disable-next-line:no-var-requires const moment = require('moment'); @@ -2517,6 +2521,57 @@ function createDonationTestCases() { errorMessages.INVALID_TOKEN_ADDRESS, ); }); + + it('should mark draft donation as matched after donation creation', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + const user = await User.create({ + walletAddress: generateRandomEtheriumAddress(), + loginType: 'wallet', + firstName: 'first name', + }).save(); + + // clear all draft donations + await DraftDonation.clear(); + // create draft donation + const draftDonation = await DraftDonation.create({ + projectId: project.id, + fromWalletAddress: user.walletAddress, + toWalletAddress: project.walletAddress, + networkId: NETWORK_IDS.MAIN_NET, + amount: 10, + currency: 'GIV', + status: DRAFT_DONATION_STATUS.PENDING, + }).save(); + + const accessToken = await generateTestAccessToken(user.id); + const saveDonationResponse = await axios.post( + graphqlUrl, + { + query: createDonationMutation, + variables: { + projectId: project.id, + transactionNetworkId: NETWORK_IDS.MAIN_NET, + transactionId: generateRandomEvmTxHash(), + anonymous: false, + nonce: 3, + amount: 10, + token: 'GIV', + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + assert.isOk(saveDonationResponse.data.data.createDonation); + const updatedDraftDonation = await DraftDonation.findOne({ + where: { + id: draftDonation.id, + }, + }); + assert.equal(updatedDraftDonation?.status, DRAFT_DONATION_STATUS.MATCHED); + }); } function donationsFromWalletsTestCases() { diff --git a/src/resolvers/donationResolver.ts b/src/resolvers/donationResolver.ts index e1f0f0bd9..d59eaf5d7 100644 --- a/src/resolvers/donationResolver.ts +++ b/src/resolvers/donationResolver.ts @@ -64,6 +64,7 @@ import { getAppropriateNetworkId, getDefaultSolanaChainId, } from '../services/chains'; +import { markDraftDonationStatusMatched } from '../repositories/draftDonationRepository'; @ObjectType() class PaginateDonations { @@ -809,6 +810,14 @@ export class DonationResolver { amount, ); + await markDraftDonationStatusMatched({ + fromWalletAddress: fromAddress, + toWalletAddress: toAddress, + currency: token, + amount: Number(amount), + networkId, + }); + return donation.id; } catch (e) { SentryLogger.captureException(e); diff --git a/src/resolvers/draftDonationResolver.test.ts b/src/resolvers/draftDonationResolver.test.ts new file mode 100644 index 000000000..e836d6d5e --- /dev/null +++ b/src/resolvers/draftDonationResolver.test.ts @@ -0,0 +1,129 @@ +import { assert, expect } from 'chai'; +import { + generateTestAccessToken, + graphqlUrl, + SEED_DATA, + DONATION_SEED_DATA, + saveProjectDirectlyToDb, + createProjectData, + generateRandomEvmTxHash, + generateRandomEtheriumAddress, + saveDonationDirectlyToDb, + createDonationData, + saveUserDirectlyToDb, + generateUserIdLessAccessToken, + generateRandomSolanaAddress, + generateRandomSolanaTxHash, +} from '../../test/testUtils'; +import axios from 'axios'; +import { errorMessages } from '../utils/errorMessages'; +import { Donation, DONATION_STATUS } from '../entities/donation'; +import { + fetchDonationsByUserIdQuery, + fetchDonationsByDonorQuery, + fetchDonationsByProjectIdQuery, + fetchAllDonationsQuery, + donationsToWallets, + donationsFromWallets, + createDonationMutation, + updateDonationStatusMutation, + fetchTotalDonationsUsdAmount, + fetchTotalDonors, + fetchTotalDonationsPerCategoryPerDate, + fetchRecentDonations, + fetchTotalDonationsNumberPerDateRange, + doesDonatedToProjectInQfRoundQuery, + createDraftDonationMutation, +} from '../../test/graphqlQueries'; +import { NETWORK_IDS } from '../provider'; +import { User } from '../entities/user'; +import { Organization, ORGANIZATION_LABELS } from '../entities/organization'; +import { ProjStatus, ReviewStatus } from '../entities/project'; +import { Token } from '../entities/token'; +import { + insertSinglePowerBoosting, + takePowerBoostingSnapshot, +} from '../repositories/powerBoostingRepository'; +import { setPowerRound } from '../repositories/powerRoundRepository'; +import { refreshProjectPowerView } from '../repositories/projectPowerViewRepository'; +import { PowerBalanceSnapshot } from '../entities/powerBalanceSnapshot'; +import { PowerBoostingSnapshot } from '../entities/powerBoostingSnapshot'; +import { AppDataSource } from '../orm'; +import { generateRandomString } from '../utils/utils'; +import { ChainvineMockAdapter } from '../adapters/chainvine/chainvineMockAdapter'; +import { getChainvineAdapter } from '../adapters/adaptersFactory'; +import { firstOrCreateReferredEventByUserId } from '../repositories/referredEventRepository'; +import { QfRound } from '../entities/qfRound'; +import { findProjectById } from '../repositories/projectRepository'; +import { addOrUpdatePowerSnapshotBalances } from '../repositories/powerBalanceSnapshotRepository'; +import { findPowerSnapshots } from '../repositories/powerSnapshotRepository'; +import { ChainType } from '../types/network'; +import { getDefaultSolanaChainId } from '../services/chains'; +import { + DRAFT_DONATION_STATUS, + DraftDonation, +} from '../entities/draftDonation'; + +// tslint:disable-next-line:no-var-requires +const moment = require('moment'); + +describe('createDonation() test cases', createDonationTestCases); + +function createDonationTestCases() { + it('create simple draft donation', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + const referrerId = generateRandomString(); + + const user = await User.create({ + walletAddress: generateRandomEtheriumAddress(), + loginType: 'wallet', + firstName: 'first name', + }).save(); + + const tokenAddress = generateRandomEtheriumAddress(); + const accessToken = await generateTestAccessToken(user.id); + const safeTransactionId = generateRandomEvmTxHash(); + + const saveDonationResponse = await axios.post( + graphqlUrl, + { + query: createDraftDonationMutation, + variables: { + projectId: project.id, + networkId: NETWORK_IDS.XDAI, + amount: 10, + token: 'GIV', + referrerId, + tokenAddress, + safeTransactionId, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + assert.isOk(saveDonationResponse.data.data.createDraftDonation); + const draftDonation = await DraftDonation.findOne({ + where: { + id: saveDonationResponse.data.data.createDraftDonation, + }, + }); + + expect(draftDonation).deep.contain({ + networkId: NETWORK_IDS.XDAI, + chainType: ChainType.EVM, + status: DRAFT_DONATION_STATUS.PENDING, + toWalletAddress: project.walletAddress!, + fromWalletAddress: user.walletAddress!, + tokenAddress, + currency: 'GIV', + anonymous: false, + amount: 10, + referrerId, + projectId: project.id, + userId: user.id, + }); + }); +} diff --git a/src/resolvers/draftDonationResolver.ts b/src/resolvers/draftDonationResolver.ts new file mode 100644 index 000000000..f868c2b9d --- /dev/null +++ b/src/resolvers/draftDonationResolver.ts @@ -0,0 +1,185 @@ +import { Arg, Ctx, Mutation, Resolver } from 'type-graphql'; +import { ApolloContext } from '../types/ApolloContext'; +import { ProjStatus } from '../entities/project'; +import { Token } from '../entities/token'; +import { Repository } from 'typeorm'; +import { User } from '../entities/user'; +import SentryLogger from '../sentryLogger'; +import { i18n, translationErrorMessagesKeys } from '../utils/errorMessages'; +import { isTokenAcceptableForProject } from '../services/donationService'; +import { + createDraftDonationQueryValidator, + validateWithJoiSchema, +} from '../utils/validators/graphqlQueryValidators'; +import { logger } from '../utils/logger'; +import { findUserById } from '../repositories/userRepository'; +import { findProjectRecipientAddressByNetworkId } from '../repositories/projectAddressRepository'; +import { findProjectById } from '../repositories/projectRepository'; +import { AppDataSource } from '../orm'; +import { detectAddressChainType } from '../utils/networks'; +import { ChainType } from '../types/network'; +import { getAppropriateNetworkId } from '../services/chains'; +import { DraftDonation } from '../entities/draftDonation'; + +const draftDonationEnabled = process.env.ENABLE_DRAFT_DONATION === 'true'; + +@Resolver(of => User) +export class DraftDonationResolver { + private readonly donationRepository: Repository; + constructor() { + this.donationRepository = + AppDataSource.getDataSource().getRepository(DraftDonation); + } + + @Mutation(returns => Number) + async createDraftDonation( + @Arg('amount') amount: number, + @Arg('networkId') networkId: number, + @Arg('tokenAddress', { nullable: true }) tokenAddress: string, + @Arg('anonymous', { nullable: true }) anonymous: boolean, + @Arg('token') token: string, + @Arg('projectId') projectId: number, + @Ctx() ctx: ApolloContext, + @Arg('referrerId', { nullable: true }) referrerId?: string, + @Arg('safeTransactionId', { nullable: true }) safeTransactionId?: string, + ): Promise { + const logData = { + amount, + networkId, + tokenAddress, + anonymous, + token, + projectId, + referrerId, + userId: ctx?.req?.user?.userId, + }; + logger.debug( + 'createDraftDonation() resolver has been called with this data', + logData, + ); + if (!draftDonationEnabled) { + throw new Error( + i18n.__(translationErrorMessagesKeys.DRAFT_DONATION_DISABLED), + ); + } + try { + const userId = ctx?.req?.user?.userId; + const donorUser = await findUserById(userId); + if (!donorUser) { + throw new Error(i18n.__(translationErrorMessagesKeys.UN_AUTHORIZED)); + } + const chainType = detectAddressChainType(donorUser.walletAddress!); + const _networkId = getAppropriateNetworkId({ + networkId, + chainType, + }); + + const validaDataInput = { + amount, + networkId: _networkId, + anonymous, + tokenAddress, + token, + projectId, + referrerId, + safeTransactionId, + chainType, + }; + try { + validateWithJoiSchema( + validaDataInput, + createDraftDonationQueryValidator, + ); + } catch (e) { + logger.error( + 'Error on validating createDraftDonation input', + validaDataInput, + ); + throw e; // Rethrow the original error + } + + const project = await findProjectById(projectId); + + if (!project) + throw new Error( + i18n.__(translationErrorMessagesKeys.PROJECT_NOT_FOUND), + ); + if (project.status.id !== ProjStatus.active) { + throw new Error( + i18n.__( + translationErrorMessagesKeys.JUST_ACTIVE_PROJECTS_ACCEPT_DONATION, + ), + ); + } + const tokenInDb = await Token.findOne({ + where: { + networkId, + symbol: token, + }, + }); + const isCustomToken = !Boolean(tokenInDb); + let isTokenEligibleForGivback = false; + if (isCustomToken && !project.organization.supportCustomTokens) { + throw new Error(i18n.__(translationErrorMessagesKeys.TOKEN_NOT_FOUND)); + } else if (tokenInDb) { + const acceptsToken = await isTokenAcceptableForProject({ + projectId, + tokenId: tokenInDb.id, + }); + if (!acceptsToken && !project.organization.supportCustomTokens) { + throw new Error( + i18n.__( + translationErrorMessagesKeys.PROJECT_DOES_NOT_SUPPORT_THIS_TOKEN, + ), + ); + } + isTokenEligibleForGivback = tokenInDb.isGivbackEligible; + } + const projectRelatedAddress = + await findProjectRecipientAddressByNetworkId({ + projectId, + networkId, + }); + if (!projectRelatedAddress) { + throw new Error( + i18n.__( + translationErrorMessagesKeys.THERE_IS_NO_RECIPIENT_ADDRESS_FOR_THIS_NETWORK_ID_AND_PROJECT, + ), + ); + } + let toAddress = projectRelatedAddress?.address; + let fromAddress = donorUser.walletAddress!; + + // Keep the flow the same as before if it's EVM + if (chainType === ChainType.EVM) { + toAddress = toAddress?.toLowerCase(); + fromAddress = fromAddress?.toLowerCase(); + } + + const draftDonation = await DraftDonation.create({ + amount: Number(amount), + networkId: _networkId, + currency: token, + userId: donorUser.id, + tokenAddress, + projectId, + toWalletAddress: toAddress, + fromWalletAddress: fromAddress, + anonymous: Boolean(anonymous), + chainType: chainType as ChainType, + referrerId, + }); + + await draftDonation.save(); + + return draftDonation.id; + } catch (e) { + SentryLogger.captureException(e); + logger.error('createDraftDonation() error', { + error: e, + inputData: logData, + }); + throw e; + } + } +} diff --git a/src/resolvers/projectResolver.ts b/src/resolvers/projectResolver.ts index 3cdbf8e75..089375f44 100644 --- a/src/resolvers/projectResolver.ts +++ b/src/resolvers/projectResolver.ts @@ -742,6 +742,7 @@ export class ProjectResolver { const projectsQuery = filterProjectsQuery(filterQueryParams); + projectsFiltersThreadPool.completed(); const projectsQueryCacheKey = await projectsFiltersThreadPool.queue( hasher => hasher.hashProjectFilters({ diff --git a/src/resolvers/resolvers.ts b/src/resolvers/resolvers.ts index ec06953ad..fa8f8e846 100644 --- a/src/resolvers/resolvers.ts +++ b/src/resolvers/resolvers.ts @@ -21,6 +21,7 @@ import { QfRoundHistoryResolver } from './qfRoundHistoryResolver'; import { ProjectUserInstantPowerViewResolver } from './instantPowerResolver'; import { AnchorContractAddressResolver } from './anchorContractAddressResolver'; import { RecurringDonationResolver } from './recurringDonationResolver'; +import { DraftDonationResolver } from './draftDonationResolver'; export const getResolvers = (): Function[] => { return [ @@ -35,6 +36,7 @@ export const getResolvers = (): Function[] => { UploadResolver, CategoryResolver, DonationResolver, + DraftDonationResolver, ReactionResolver, ProjectVerificationFormResolver, SocialProfilesResolver, diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 94af2dcaa..7a300ba58 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -71,6 +71,7 @@ import { corsOptions } from './cors'; import { runSyncLostDonations } from '../services/cronJobs/importLostDonationsJob'; import { runSyncBackupServiceDonations } from '../services/cronJobs/backupDonationImportJob'; import { runUpdateRecurringDonationStream } from '../services/cronJobs/updateStreamOldRecurringDonationsJob'; +import { runDraftDonationMatchWorkerJob } from '../services/cronJobs/draftDonationMatchingJob'; Resource.validate = validate; @@ -346,6 +347,10 @@ export async function bootstrap() { runSyncBackupServiceDonations(); } + if (process.env.ENABLE_DRAFT_DONATION === 'true') { + runDraftDonationMatchWorkerJob(); + } + if (process.env.FILL_POWER_SNAPSHOT_BALANCE_SERVICE_ACTIVE === 'true') { runFillPowerSnapshotBalanceCronJob(); } diff --git a/src/services/Idriss/contractDonations.ts b/src/services/Idriss/contractDonations.ts index 9edca7bc1..c997bd559 100644 --- a/src/services/Idriss/contractDonations.ts +++ b/src/services/Idriss/contractDonations.ts @@ -5,7 +5,7 @@ import { getLatestBlockNumberFromDonations, isTransactionHashStored, } from '../../repositories/donationRepository'; -import { DONATION_EXTERNAL_SOURCES, Donation } from '../../entities/donation'; +import { DONATION_ORIGINS, Donation } from '../../entities/donation'; import { findProjectByWalletAddress, findProjectByWalletAddressAndNetwork, @@ -220,7 +220,7 @@ export const createIdrissTwitterDonation = async ( project, status: 'verified', blockNumber: idrissDonation.blockNumber, - origin: DONATION_EXTERNAL_SOURCES.IDRISS_TWITTER, + origin: DONATION_ORIGINS.IDRISS_TWITTER, isTokenEligibleForGivback, isCustomToken: false, isProjectVerified: project.verified, diff --git a/src/services/chains/evm/draftDonationService.test.ts b/src/services/chains/evm/draftDonationService.test.ts new file mode 100644 index 000000000..72369a527 --- /dev/null +++ b/src/services/chains/evm/draftDonationService.test.ts @@ -0,0 +1,245 @@ +import { expect } from 'chai'; +import { + saveProjectDirectlyToDb, + createProjectData, + saveUserDirectlyToDb, +} from '../../../../test/testUtils'; +import { + DRAFT_DONATION_STATUS, + DraftDonation, +} from '../../../entities/draftDonation'; +import { NETWORK_IDS } from '../../../provider'; +import { ProjectAddress } from '../../../entities/projectAddress'; +import { matchDraftDonations } from './draftDonationService'; +import { findUserByWalletAddress } from '../../../repositories/userRepository'; +import { + DONATION_ORIGINS, + DONATION_STATUS, + Donation, +} from '../../../entities/donation'; +import { Project } from '../../../entities/project'; +import { User } from '../../../entities/user'; + +describe('draftDonationMatching', draftDonationMatchingTests); + +const RandomAddress1 = '0xf3ddeb5022a6f06b61488b48c90315087ca2beef'; +const RandomAddress2 = '0xc42a4791735ae1253c50c6226832e37ede3669f5'; + +// Native Token Donation Tx exact Time 1707567330 +// 0x0643e7008a76feb3c4aa4d127360982eb130163da57cbd9f11e8ce9d5ef828c0 +const nativeDonationDraftSaveTime = 1707567300 * 1000; +// Erc20 Donation Tx exact Time 1707567455 +// 0x6fb99692292673e24523d832ac805c4438aa23b753e105ce673bc9ceb96d20d2 +const erc20DonationDraftSaveTime = 1707567400 * 1000; // a point of time between two transactions +const draftSaveTimeStampMS = Math.min( + nativeDonationDraftSaveTime, + erc20DonationDraftSaveTime, +); +const networkId = NETWORK_IDS.XDAI; +const anonymous = false; + +const sharedDonationData: Partial = { + networkId, + status: DRAFT_DONATION_STATUS.PENDING, + fromWalletAddress: RandomAddress1, + toWalletAddress: RandomAddress2, + anonymous, + createdAt: new Date(draftSaveTimeStampMS), +}; +let erc20DonationData: DraftDonation; +let nativeTokenDonationData: DraftDonation; +let project: Project; +let user: User; + +function draftDonationMatchingTests() { + beforeEach(async () => { + const _user = await findUserByWalletAddress(RandomAddress1); + // delete all user donations + if (_user) { + Donation.delete({ userId: _user.id }); + } + await DraftDonation.clear(); + + const projectAddress = await ProjectAddress.findOne({ + where: { address: RandomAddress2 }, + }); + if (projectAddress) { + await ProjectAddress.delete({ address: RandomAddress2 }); + await Project.delete(projectAddress.projectId); + } + + user = await saveUserDirectlyToDb(RandomAddress1); + project = await saveProjectDirectlyToDb({ + ...createProjectData(), + walletAddress: RandomAddress2, + }); + sharedDonationData.projectId = project.id; + sharedDonationData.userId = user.id; + + erc20DonationData = { + ...sharedDonationData, + tokenAddress: '0x4f4f9b8d5b4d0dc10506e5551b0513b61fd59e75', + amount: 0.01, + currency: 'GIV', + anonymous: false, + } as DraftDonation; + nativeTokenDonationData = { + ...sharedDonationData, + tokenAddress: '0x0000000000000000000000000000000000000000', + amount: 0.001, + currency: 'XDAI', + anonymous: false, + } as DraftDonation; + }); + + it('should make an ERC20 donatiom by matching draft donation', async () => { + const draftDonationData = erc20DonationData; + const draftDonation = await DraftDonation.create(draftDonationData).save(); + + expect(draftDonation).to.be.ok; + + await matchDraftDonations({ [RandomAddress1]: [draftDonation!] }); + + const { tokenAddress, amount, currency } = draftDonationData; + const donation = await Donation.findOne({ + where: { + userId: user.id, + tokenAddress, + amount, + currency, + projectId: project.id, + anonymous, + transactionNetworkId: networkId, + }, + }); + + expect(donation).to.be.ok; + + expect(donation?.transactionId).to.be.ok; + expect(donation?.status).to.equal(DONATION_STATUS.PENDING); + expect(donation?.origin).to.equal(DONATION_ORIGINS.DRAFT_DONATION_MATCHING); + }); + + it('should make an native token donatiom by matching draft donation', async () => { + const draftDonationData = nativeTokenDonationData; + const draftDonation = await DraftDonation.create(draftDonationData); + await draftDonation.save(); + + expect(draftDonation).to.be.ok; + + await matchDraftDonations({ [RandomAddress1]: [draftDonation!] }); + + const { tokenAddress, amount, currency } = draftDonationData; + const donation = await Donation.findOne({ + where: { + userId: user.id, + tokenAddress, + amount, + currency, + projectId: project.id, + anonymous, + transactionNetworkId: networkId, + }, + }); + + expect(donation).to.be.ok; + + expect(donation?.transactionId).to.be.ok; + expect(donation?.status).to.equal(DONATION_STATUS.PENDING); + expect(donation?.origin).to.equal(DONATION_ORIGINS.DRAFT_DONATION_MATCHING); + }); + + it('should match multiple draft donations', async () => { + const draftDonation1 = await DraftDonation.create(erc20DonationData).save(); + const draftDonation2 = await DraftDonation.create( + nativeTokenDonationData, + ).save(); + + await matchDraftDonations({ + [RandomAddress1]: [draftDonation1, draftDonation2], + }); + + const donation1 = await Donation.findOne({ + where: { + userId: user.id, + tokenAddress: erc20DonationData.tokenAddress, + amount: erc20DonationData.amount, + currency: erc20DonationData.currency, + projectId: project.id, + anonymous, + transactionNetworkId: networkId, + }, + }); + + const donation2 = await Donation.findOne({ + where: { + userId: user.id, + tokenAddress: nativeTokenDonationData.tokenAddress, + amount: nativeTokenDonationData.amount, + currency: nativeTokenDonationData.currency, + projectId: project.id, + anonymous, + transactionNetworkId: networkId, + }, + }); + + expect(donation1).to.be.ok; + expect(donation2).to.be.ok; + }); + + it('should not make a donation if the draft donation is already matched', async () => { + const draftDonationData = erc20DonationData; + let draftDonation = await DraftDonation.create(draftDonationData).save(); + await matchDraftDonations({ [RandomAddress1]: [draftDonation!] }); + + // make draft donation again + draftDonation = await DraftDonation.create(draftDonationData).save(); + await matchDraftDonations({ [RandomAddress1]: [draftDonation!] }); + + await draftDonation.reload(); + + expect(draftDonation.status).to.equal(DRAFT_DONATION_STATUS.FAILED); + }); + + it('should not try to match transaction older than minimum created at', async () => { + const draftDonation1 = await DraftDonation.create({ + ...erc20DonationData, + createdAt: new Date(), + }).save(); + const draftDonation2 = await DraftDonation.create({ + ...nativeTokenDonationData, + createdAt: new Date(erc20DonationDraftSaveTime), + }).save(); + + await matchDraftDonations({ + [RandomAddress1]: [draftDonation1, draftDonation2], + }); + + const erc20Donation = await Donation.findOne({ + where: { + userId: user.id, + tokenAddress: erc20DonationData.tokenAddress, + amount: erc20DonationData.amount, + currency: erc20DonationData.currency, + projectId: project.id, + anonymous, + transactionNetworkId: networkId, + }, + }); + + const donation2 = await Donation.findOne({ + where: { + userId: user.id, + tokenAddress: nativeTokenDonationData.tokenAddress, + amount: nativeTokenDonationData.amount, + currency: nativeTokenDonationData.currency, + projectId: project.id, + anonymous, + transactionNetworkId: networkId, + }, + }); + + expect(erc20Donation).to.be.ok; + expect(donation2).to.not.be.ok; + }); +} diff --git a/src/services/chains/evm/draftDonationService.ts b/src/services/chains/evm/draftDonationService.ts new file mode 100644 index 000000000..3c0c01929 --- /dev/null +++ b/src/services/chains/evm/draftDonationService.ts @@ -0,0 +1,241 @@ +import _ from 'lodash'; +import { + DRAFT_DONATION_STATUS, + DraftDonation, +} from '../../../entities/draftDonation'; +import { getNetworkNativeToken } from '../../../provider'; +import { getListOfTransactionsByAddress } from './transactionService'; +import { ethers } from 'ethers'; +import { closeTo } from '..'; +import { findTokenByNetworkAndAddress } from '../../../utils/tokenUtils'; +import { ITxInfo } from '../../../types/etherscan'; +import { DONATION_ORIGINS, Donation } from '../../../entities/donation'; +import { DonationResolver } from '../../../resolvers/donationResolver'; +import { ApolloContext } from '../../../types/ApolloContext'; +import { logger } from '../../../utils/logger'; +import { ModuleThread, Pool, spawn, Worker } from 'threads'; +import { DraftDonationWorker } from '../../../workers/draftDonationMatchWorker'; + +const transferErc20CallData = (to: string, amount: number, decimals = 18) => { + const iface = new ethers.utils.Interface([ + 'function transfer(address to, uint256 value) external', + ]); + return iface.encodeFunctionData('transfer', [ + to, + ethers.utils.parseUnits(amount.toString(), decimals), + ]); +}; + +export async function matchDraftDonations( + userDraftDonationsMap: Record, +) { + for (const user of Object.keys(userDraftDonationsMap)) { + // group by networkId + const userDraftDonations = userDraftDonationsMap[user]; + const userDraftDonationsByNetwork: Record = + _.groupBy(userDraftDonations, 'networkId'); + + // Iterate over networks + for (const networkId of Object.keys(userDraftDonationsByNetwork).map( + _networkId => +_networkId, + )) { + const nativeTokenLowerCase = + getNetworkNativeToken(networkId).toLocaleLowerCase(); + + // The earliest time we need to check for transactions of this user + let minCreatedAt = Number.MAX_SAFE_INTEGER; + // Map of target to address, token address in ERC20 case, designated native token address in native token case + const targetTxAddrToDraftDonationMap = new Map(); + + for (const draftDonation of userDraftDonationsByNetwork[networkId]) { + const targetAddress = + draftDonation.currency.toLocaleLowerCase() === nativeTokenLowerCase + ? draftDonation.toWalletAddress + : draftDonation.tokenAddress!; + + const _array = targetTxAddrToDraftDonationMap.get(targetAddress); + if (!_array) { + targetTxAddrToDraftDonationMap.set(targetAddress, [draftDonation]); + } else { + _array.push(draftDonation); + } + + if (draftDonation.createdAt.getTime() < minCreatedAt) { + minCreatedAt = draftDonation.createdAt.getTime(); + } + } + + minCreatedAt = Math.floor(minCreatedAt / 1000); // convert to seconds + + let _exit = false; + let _page = 1; + while (_exit === false) { + const { userRecentTransactions, lastPage } = + await getListOfTransactionsByAddress({ + address: user, + networkId: Number(networkId), + page: _page, + }); + + for (const transaction of userRecentTransactions) { + if (+transaction.timeStamp < minCreatedAt) { + _exit = true; + break; + } + + const targetAddress = transaction.to.toLowerCase(); + const draftDonations = + targetTxAddrToDraftDonationMap.get(targetAddress); + + if (draftDonations) { + // doantions with same target address + for (const draftDonation of draftDonations!) { + const nativeTokenTransfer = + draftDonation.currency.toLowerCase() === nativeTokenLowerCase; + if (nativeTokenTransfer) { + // native transfer + const amount = ethers.utils.formatEther(transaction.value); + if (!closeTo(+amount, draftDonation.amount)) { + continue; + } + await submitMatchedDraftDonation(draftDonation, transaction); + } else { + // ERC20 transfer + let transferCallData = draftDonation.expectedCallData; + if (!transferCallData) { + const token = await findTokenByNetworkAndAddress( + networkId, + targetAddress, + ); + transferCallData = transferErc20CallData( + draftDonation.toWalletAddress, + draftDonation.amount, + token.decimals, + ); + await DraftDonation.update(draftDonation.id, { + expectedCallData: transferCallData, + }); + draftDonation.expectedCallData = transferCallData; + } + + if (transaction.input.toLowerCase() !== transferCallData) { + continue; + } + + await submitMatchedDraftDonation(draftDonation, transaction); + } + } + } + } + + if (lastPage) break; + + _page++; + } + } + } +} + +async function submitMatchedDraftDonation( + draftDonation: DraftDonation, + tx: ITxInfo, +) { + // Check whether a donation with same networkId and txHash already exists + const existingDonation = await Donation.findOne({ + where: { + transactionNetworkId: draftDonation.networkId, + transactionId: tx.hash, + }, + }); + + if (existingDonation) { + // Check whether the donation has not been saved during matching procedure + await draftDonation.reload(); + if (draftDonation.status === DRAFT_DONATION_STATUS.PENDING) { + draftDonation.status = DRAFT_DONATION_STATUS.FAILED; + draftDonation.errorMessage = `Donation with same networkId and txHash with ID ${existingDonation.id} already exists`; + await draftDonation.save(); + } + return; + } + + const donationResolver = new DonationResolver(); + + const { + amount, + networkId, + tokenAddress, + anonymous, + currency, + projectId, + referrerId, + } = draftDonation; + + try { + const donationId = await donationResolver.createDonation( + amount, + tx.hash, + networkId, + tokenAddress, + anonymous, + currency, + projectId, + +tx.nonce, + '', + { + req: { user: { userId: draftDonation.userId }, auth: {} }, + } as ApolloContext, + referrerId, + '', + ); + + await Donation.update(Number(donationId), { + origin: DONATION_ORIGINS.DRAFT_DONATION_MATCHING, + }); + + logger.debug( + `Donation with ID ${donationId} has been created for draftDonation with ID ${draftDonation.id}`, + ); + draftDonation.status = DRAFT_DONATION_STATUS.MATCHED; + } catch (e) { + logger.error( + `Error on creating donation for draftDonation with ID ${draftDonation.id}`, + e, + ); + draftDonation.status = DRAFT_DONATION_STATUS.FAILED; + draftDonation.errorMessage = e.message; + } finally { + await draftDonation.save(); + } +} + +let workerIsIdle = true; +let pool: Pool>; +export async function runDraftDonationMatchWorker() { + if (!workerIsIdle) { + logger.debug('Draft donation matching worker is already running'); + return; + } + workerIsIdle = false; + + if (!pool) { + pool = Pool( + () => spawn(new Worker('./../../../workers/draftDonationMatchWorker')), + { + name: 'draftDonationMatchWorker', + concurrency: 4, + size: 2, + }, + ); + } + try { + await pool.queue(draftDonationWorker => + draftDonationWorker.matchDraftDonations(), + ); + await pool.settled(true); + } catch (e) { + logger.error(`error in calling draft match worker: ${e.message}`); + } finally { + workerIsIdle = true; + } +} diff --git a/src/services/chains/evm/transactionService.ts b/src/services/chains/evm/transactionService.ts index bb9fd2cbd..2c7ff085d 100644 --- a/src/services/chains/evm/transactionService.ts +++ b/src/services/chains/evm/transactionService.ts @@ -21,6 +21,8 @@ import { gnosisSafeL2ABI } from '../../../assets/gnosisSafeL2ABI'; import { NetworkTransactionInfo, TransactionDetailInput } from '../index'; import { normalizeAmount } from '../../../utils/utils'; import { ONE_HOUR, validateTransactionWithInputData } from '../index'; +import _ from 'lodash'; +import { ITxInfo } from '../../../types/etherscan'; // tslint:disable-next-line:no-var-requires const ethers = require('ethers'); @@ -104,18 +106,29 @@ async function findEvmTransactionByNonce(data: { logger.debug('findTransactionByNonce called', data); const { input, page = 1 } = data; const nonce = input.nonce as number; - const { userRecentTransactions, isTransactionListEmpty } = + const { userRecentTransactions, lastPage } = await getListOfTransactionsByAddress({ address: input.fromAddress, page, networkId: input.networkId, }); - if (isTransactionListEmpty) { + const foundTransaction = userRecentTransactions.find( + tx => +tx.nonce === input.nonce, + ); + + if (foundTransaction) { + return findEvmTransactionByHash({ + ...input, + txHash: foundTransaction.hash, + }); + } + + if (lastPage) { // we know that we reached to end of transactions logger.debug( 'findEvmTransactionByNonce, no more found donations for address', { - page, + lastPage: page, address: input.fromAddress, }, ); @@ -123,23 +136,13 @@ async function findEvmTransactionByNonce(data: { i18n.__(translationErrorMessagesKeys.TRANSACTION_NOT_FOUND), ); } - const foundTransaction = userRecentTransactions.find( - tx => tx.nonce === input.nonce, - ); - - if (foundTransaction) { - return findEvmTransactionByHash({ - ...input, - txHash: foundTransaction.hash, - }); - } // userRecentTransactions just includes the transactions that source is our fromAddress // so if the lowest nonce in this array is smaller than the sent nonce we would know that we should not // check latest transactions const smallestNonce = userRecentTransactions.length > 0 - ? userRecentTransactions[userRecentTransactions.length - 1].nonce + ? +userRecentTransactions[userRecentTransactions.length - 1].nonce : undefined; if (smallestNonce !== undefined && smallestNonce < nonce) { @@ -162,19 +165,16 @@ async function findEvmTransactionByNonce(data: { }); } -async function getListOfTransactionsByAddress(input: { +export async function getListOfTransactionsByAddress(input: { networkId: number; address: string; page?: number; offset?: number; }): Promise<{ - userRecentTransactions: { - hash: string; - nonce: number; - }[]; - isTransactionListEmpty: boolean; + userRecentTransactions: ITxInfo[]; + lastPage: boolean; }> { - const { address, page, offset, networkId } = input; + const { address, page = 1, offset = 1000, networkId } = input; // https://docs.etherscan.io/api-endpoints/accounts#get-a-list-of-normal-transactions-by-address // https://blockscout.com/xdai/mainnet/api-docs#account logger.debug( @@ -185,26 +185,19 @@ async function getListOfTransactionsByAddress(input: { params: { module: 'account', action: 'txlist', - page: page || 1, - offset: offset || 1000, + page, + offset, address, sort: 'desc', }, }); - const userRecentTransactions = result.data.result - .filter(tx => { - return tx.from.toLowerCase() === input.address.toLowerCase(); - }) - .map(tx => { - // in this case we know it's a token transfer (smart contract call) - return { - hash: tx.hash, - nonce: Number(tx.nonce), - }; - }); + const userRecentTransactions = result.data.result.filter(tx => { + return tx.from.toLowerCase() === input.address.toLowerCase(); + }); + return { userRecentTransactions, - isTransactionListEmpty: result.data.result.length === 0, + lastPage: result.data.result.length < offset, }; } diff --git a/src/services/chains/index.ts b/src/services/chains/index.ts index db635f13a..25ab4c4a9 100644 --- a/src/services/chains/index.ts +++ b/src/services/chains/index.ts @@ -49,7 +49,7 @@ export function validateTransactionWithInputData( ), ); } - if (Math.abs(transaction.amount - input.amount) > 0.001) { + if (!closeTo(transaction.amount, input.amount)) { // We ignore small conflicts but for bigger amount we throw exception https://github.com/Giveth/impact-graph/issues/289 throw new Error( i18n.__( @@ -99,3 +99,8 @@ export function getAppropriateNetworkId(params: { ? getDefaultSolanaChainId() : params.networkId; } + +// This function is used to compare two numbers with a delta as a margin of error +export const closeTo = (a: number, b: number, delta = 0.001) => { + return a / b < 1 + delta && a / b > 1 - delta; +}; diff --git a/src/services/cronJobs/draftDonationMatchingJob.ts b/src/services/cronJobs/draftDonationMatchingJob.ts new file mode 100644 index 000000000..fac1a4840 --- /dev/null +++ b/src/services/cronJobs/draftDonationMatchingJob.ts @@ -0,0 +1,38 @@ +import { schedule } from 'node-cron'; +import config from '../../config'; +import { logger } from '../../utils/logger'; +import { runDraftDonationMatchWorker } from '../chains/evm/draftDonationService'; +import { + DRAFT_DONATION_STATUS, + DraftDonation, +} from '../../entities/draftDonation'; +import { delecteExpiredDraftDonations } from '../../repositories/draftDonationRepository'; + +const cronJobTime = + (config.get('MATCH_DRAFT_DONATION_CRONJOB_EXPRESSION') as string) || + '0 */5 * * *'; + +const TWO_MINUTES = 1000 * 60 * 2; + +// Queue for filling snapshot balances + +// Periodically log the queue count + +export const runDraftDonationMatchWorkerJob = () => { + logger.debug('runDraftDonationMatchWorkerJob', cronJobTime); + + schedule(cronJobTime, async () => { + const hours = Number( + process.env.DRAFT_DONATION_MATCH_EXPIRATION_HOURS || 48, + ); + await delecteExpiredDraftDonations(hours); + await runDraftDonationMatchWorker(); + }); + + setInterval(async () => { + const count = await DraftDonation.countBy({ + status: DRAFT_DONATION_STATUS.PENDING, + }); + logger.debug('Pending Draft Donations count:', { count }); + }, TWO_MINUTES); +}; diff --git a/src/services/cronJobs/fillSnapshotBalances.ts b/src/services/cronJobs/fillSnapshotBalances.ts index 1eb1d15ca..969e9c068 100644 --- a/src/services/cronJobs/fillSnapshotBalances.ts +++ b/src/services/cronJobs/fillSnapshotBalances.ts @@ -9,7 +9,6 @@ import { GetPowerBoostingSnapshotWithoutBalanceOutput, } from '../../repositories/powerSnapshotRepository'; import { addOrUpdatePowerSnapshotBalances } from '../../repositories/powerBalanceSnapshotRepository'; -import { convertTimeStampToSeconds } from '../../utils/utils'; import _ from 'lodash'; // Constants diff --git a/src/types/etherscan.ts b/src/types/etherscan.ts new file mode 100644 index 000000000..c2f8ebffa --- /dev/null +++ b/src/types/etherscan.ts @@ -0,0 +1,22 @@ +export interface ITxInfo { + blockNumber: string; + timeStamp: string; + hash: string; + nonce: string; + blockHash: string; + transactionIndex: string; + from: string; + to: string; + value: string; + gas: string; + gasPrice: string; + isError: string; + txreceipt_status: string; + input: string; + contractAddress: string; + cumulativeGasUsed: string; + gasUsed: string; + confirmations: string; + methodId: string; + functionName: string; +} diff --git a/src/utils/errorMessages.ts b/src/utils/errorMessages.ts index 2ce2847f3..ebd315a86 100644 --- a/src/utils/errorMessages.ts +++ b/src/utils/errorMessages.ts @@ -348,4 +348,5 @@ export const translationErrorMessagesKeys = { 'REGISTERED_NON_PROFITS_CATEGORY_DOESNT_EXIST', PROJECT_UPDATE_CONTENT_LENGTH_SIZE_EXCEEDED: 'PROJECT_UPDATE_CONTENT_LENGTH_SIZE_EXCEEDED', + DRAFT_DONATION_DISABLED: 'DRAFT_DONATION_DISABLED', }; diff --git a/src/utils/locales/en.json b/src/utils/locales/en.json index ddd253bc4..e42c7fe3f 100644 --- a/src/utils/locales/en.json +++ b/src/utils/locales/en.json @@ -1,110 +1,111 @@ { - "GITCOIN_ERROR_FETCHING_DATA": "Unable to fetch gitcoin data", - "CHAINVINE_CLICK_EVENT_ERROR": "Unable to register click event or link donor", - "CHAINVINE_REGISTRATION_ERROR": "Chainvine ID failed to be generated for the user", - "FIAT_DONATION_ALREADY_EXISTS": "Fiat donation already exists", - "ONRAMPER_SIGNATURE_INVALID": "Request payload or signature is invalid", - "ONRAMPER_SIGNATURE_MISSING": "Request headers does not contain signature", - "UPLOAD_FAILED": "Upload file failed", - "SPECIFY_GIV_POWER_ADAPTER": "Specify givPower adapter", - "CHANGE_API_INVALID_TITLE_OR_EIN": "ChangeAPI title or EIN not found or invalid", - "INVALID_SOCIAL_NETWORK": "Invalid social network", - "RECIPIENT_ADDRESSES_CANT_BE_EMPTY": "Recipient addresses can't be empty", - "NOT_IMPLEMENTED": "Not implemented", - "SHOULD_SEND_AT_LEAST_ONE_OF_PROJECT_ID_AND_USER_ID": "Should send at least on of userId or projectId", - "YOU_JUST_CAN_VERIFY_REJECTED_AND_SUBMITTED_FORMS": "You just can verify rejected and submitted forms", - "YOU_JUST_CAN_MAKE_DRAFT_REJECTED_AND_SUBMITTED_FORMS": "You just can make draft rejected and submitted forms", - "YOU_JUST_CAN_REJECT_SUBMITTED_FORMS": "You just can reject submitted forms", - "INVALID_TRACK_ID_FOR_OAUTH2_LOGIN": "Invalid trackId for oauth2 login", - "SOCIAL_NETWORK_IS_DIFFERENT_WITH_CLAIMED_ONE": "Social network is different with claimed one", - "SOCIAL_PROFILE_NOT_FOUND": "Social profile not gound", - "CHANGE_API_TITLE_OR_EIN_NOT_PRECISE": "Please query the exact project title or EIN ID from the ChangeAPI site", - "YOU_ARE_NOT_OWNER_OF_THIS_DONATION": "You are not owner of this donation", - "NOT_SUPPORTED_THIRD_PARTY_API": "Third Party API not supported", - "IPFS_IMAGE_UPLOAD_FAILED": "Image upload failed", - "YOU_SHOULD_FILL_EMAIL_PERSONAL_INFO_BEFORE_CONFIRMING_EMAIL": "You should fill email in personal info step before confirming it", - "YOU_ALREADY_VERIFIED_THIS_EMAIL": "You already verified this email", - "INVALID_FROM_DATE": "Invalid fromDate", - "INVALID_TO_DATE": "Invalid toDate", - "VERIFIED_USERNAME_IS_DIFFERENT_WITH_CLAIMED_ONE": "Username is not the claimed one", - "INVALID_AUTHORIZATION_VERSION": "Authorization version is not valid", - "INVALID_STEP": "Invalid step", - "DONOR_REPORTED_IT_AS_FAILED": "Donor reported it as failed", - "INVALID_DATE_FORMAT": "Date format should be YYYYMMDD HH:mm:ss", - "INTERNAL_SERVER_ERROR": "Internal server error", - "ERROR_CONNECTING_DB": "Error in connecting DB", - "YOU_DONT_HAVE_ACCESS_TO_VIEW_THIS_PROJECT": "You dont have access to view this project", - "JUST_ACTIVE_PROJECTS_ACCEPT_DONATION": "Just active projects accept donation", - "CATEGORIES_LENGTH_SHOULD_NOT_BE_MORE_THAN_FIVE": "Please select no more than 5 categories", - "CATEGORIES_MUST_BE_FROM_THE_FRONTEND_SUBSELECTION": "This category is not valid", - "INVALID_TX_HASH": "Invalid txHash", - "INVALID_TRANSACTION_ID": "Invalid transactionId", - "DUPLICATE_TX_HASH": "There is a donation with this txHash in our DB", - "YOU_ARE_NOT_THE_OWNER_OF_PROJECT": "You are not the owner of this project.", - "YOU_ARE_NOT_THE_OWNER_OF_PROJECT_VERIFICATION_FORM": "You are not the owner of this project verification form.", - "YOU_ARE_NOT_THE_OWNER_OF_SOCIAL_PROFILE": "You are not the owner of this social profile project verification form.", - "PROJECT_VERIFICATION_FORM_IS_NOT_DRAFT_SO_YOU_CANT_MODIFY_SOCIAL_PROFILES": "project verification form is not draft, so you cant modify social profiles", - "YOU_ALREADY_ADDED_THIS_SOCIAL_PROFILE_FOR_THIS_VERIFICATION_FORM": "You already have added this social profile for this verification form", - "PROJECT_VERIFICATION_FORM_NOT_FOUND": "Project verification form not found", - "PROJECT_IS_ALREADY_VERIFIED": "Project is already verified.", - "YOU_JUST_CAN_EDIT_DRAFT_REQUESTS": "Project is already verified.", - "EMAIL_CONFIRMATION_CANNOT_BE_SENT_IN_THIS_STEP": "Email confirmation cannot be sent in this step", - "THERE_IS_AN_ONGOING_VERIFICATION_REQUEST_FOR_THIS_PROJECT": "There is an ongoing project verification request for this project", - "THERE_IS_NOT_ANY_ONGOING_PROJECT_VERIFICATION_FORM_FOR_THIS_PROJECT": "There is not any project verification form for this project", - "PROJECT_STATUS_NOT_FOUND": "No project status found, this should be impossible", - "YOU_DONT_HAVE_ACCESS_TO_DEACTIVATE_THIS_PROJECT": "You dont have access to deactivate this project", - "PROJECT_NOT_FOUND": "Project not found.", - "PROJECT_IS_NOT_ACTIVE": "Project is not active.", - "INVALID_FUNCTION": "Invalid function name of transaction", - "PROJECT_UPDATE_NOT_FOUND": "Project update not found.", - "DONATION_NOT_FOUND": "donation not found", - "THIS_PROJECT_IS_CANCELLED_OR_DEACTIVATED_ALREADY": "This project has been cancelled by an Admin for inappropriate content or a violation of the Terms of Use", - "DONATION_VIEWING_LOGIN_REQUIRED": "You must be signed-in in order to register project donations", - "TRANSACTION_NOT_FOUND": "Transaction not found.", - "TRANSACTION_FROM_ADDRESS_IS_DIFFERENT_FROM_SENT_FROM_ADDRESS": "FromAddress of Transaction is different from sent fromAddress", - "TRANSACTION_STATUS_IS_FAILED_IN_NETWORK": "Transaction status is failed in network", - "INVALID_VERIFICATION_REVOKE_STATUS": "Invalid revoke status updated", - "TRANSACTION_NOT_FOUND_AND_NONCE_IS_USED": "Transaction not found and nonce is used", - "TRANSACTION_AMOUNT_IS_DIFFERENT_WITH_SENT_AMOUNT": "Transaction amount is different with sent amount", - "TRANSACTION_CANT_BE_OLDER_THAN_DONATION": "Transaction can not be older than donation", - "TRANSACTION_TO_ADDRESS_IS_DIFFERENT_FROM_SENT_TO_ADDRESS": "ToAddress of Transaction is different to sent toAddress", - "TRANSACTION_SMART_CONTRACT_CONFLICTS_WITH_CURRENCY": "Smart contract address is not equal to transaction.to", - "USER_NOT_FOUND": "User not found.", - "INVALID_NETWORK_ID": "Network Id is invalid", - "INVALID_TOKEN_SYMBOL": "Token symbol is invalid", - "TOKEN_SYMBOL_IS_REQUIRED": "Token symbol is required", - "TOKEN_NOT_FOUND": "Token Not found", - "TRANSACTION_NOT_FOUNT_IN_USER_HISTORY": "TRANSACTION_NOT_FOUNT_IN_USER_HISTORY", - "TRANSACTION_WITH_THIS_NONCE_IS_NOT_MINED_ALREADY": "Transaction with this nonce is not mined already", - "TO_ADDRESS_OF_DONATION_SHOULD_BE_PROJECT_WALLET_ADDRESS": "toAddress of donation should be equal to project wallet address", - "INVALID_WALLET_ADDRESS": "Address not valid", - "INVALID_EMAIL": "Email not valid", - "UN_AUTHORIZED": "unAuthorized", - "BOTH_FIRST_NAME_AND_LAST_NAME_CANT_BE_EMPTY": "Both firstName and lastName cant be empty", - "FIRSTNAME_CANT_BE_EMPTY_STRING": "firstName cant be empty string", - "LASTNAME_CANT_BE_EMPTY_STRING": "lastName cant be empty string", - "PROJECT_WITH_THIS_TITLE_EXISTS": "There is a project with this title, please use another title", - "INVALID_PROJECT_TITLE": "Your project name isnt valid, please only use letters and numbers", - "ACCESS_DENIED": "Access denied", - "AUTHENTICATION_REQUIRED": "Authentication required.", - "SOMETHING_WENT_WRONG": "Something went wrong.", - "PROJECT_DOES_NOT_SUPPORT_THIS_TOKEN": "Project doesnt support this token", - "THERE_IS_NO_RECIPIENT_ADDRESS_FOR_THIS_NETWORK_ID_AND_PROJECT": "There is no recipient address for this project and networkId", - "AMOUNT_IS_INVALID": "Amount is not valid", - "CURRENCY_IS_INVALID": "Currency is not valid", - "SHOULD_HAVE_AT_LEAST_ONE_CONNECTED_SOCIAL_NETWORK_BEFORE_SUBMIT": "Should have one connected social network before submit", - "SOCIAL_PROFILE_IS_ALREADY_VERIFIED": "Social profile is already verified", - "YOU_ARE_NOT_THE_OWNER_OF_THIS_SOCIAL_PROFILE": "You are not the owner of social profile", - "ERROR_IN_GETTING_ACCESS_TOKEN_BY_AUTHORIZATION_CODE": "Error in getting accessToken by authorization code", - "ERROR_GIVPOWER_BOOSTING_FIRST_PROJECT_100_PERCENT": "First project boosting value must be 100%", - "ERROR_GIVPOWER_BOOSTING_INVALID_DATA": "Invalid data", - "ERROR_GIV_POWER_BOOSTING_SUM_IS_GREATER_THAN_MAXIMUM": "Giv power boosting summation is greater than 100", - "ERROR_GIVPOWER_BOOSTING_MAX_PROJECT_LIMIT": "Number of boosted projects exceeds limit", - "REGISTERED_NON_PROFITS_CATEGORY_DOESNT_EXIST": "There is not any category with name registered-non-profits, probably you forgot to run migrations", - "PROJECT_UPDATE_CONTENT_LENGTH_SIZE_EXCEEDED": "Content length exceeded", - "INVALID_TOKEN_ADDRESS": "Invalid tokenAddress", - "There is already an anchor address for this project, only project owner can change it": "There is already an anchor address for this project, only project owner can change it", - "Project doesnt have recipient address on this network": "Project doesnt have recipient address on this network", - "There is already an anchor address for this project": "There is already an anchor address for this project", - "There is not anchor address for this project": "There is not anchor address for this project" -} \ No newline at end of file + "GITCOIN_ERROR_FETCHING_DATA": "Unable to fetch gitcoin data", + "CHAINVINE_CLICK_EVENT_ERROR": "Unable to register click event or link donor", + "CHAINVINE_REGISTRATION_ERROR": "Chainvine ID failed to be generated for the user", + "FIAT_DONATION_ALREADY_EXISTS": "Fiat donation already exists", + "ONRAMPER_SIGNATURE_INVALID": "Request payload or signature is invalid", + "ONRAMPER_SIGNATURE_MISSING": "Request headers does not contain signature", + "UPLOAD_FAILED": "Upload file failed", + "SPECIFY_GIV_POWER_ADAPTER": "Specify givPower adapter", + "CHANGE_API_INVALID_TITLE_OR_EIN": "ChangeAPI title or EIN not found or invalid", + "INVALID_SOCIAL_NETWORK": "Invalid social network", + "RECIPIENT_ADDRESSES_CANT_BE_EMPTY": "Recipient addresses can't be empty", + "NOT_IMPLEMENTED": "Not implemented", + "SHOULD_SEND_AT_LEAST_ONE_OF_PROJECT_ID_AND_USER_ID": "Should send at least on of userId or projectId", + "YOU_JUST_CAN_VERIFY_REJECTED_AND_SUBMITTED_FORMS": "You just can verify rejected and submitted forms", + "YOU_JUST_CAN_MAKE_DRAFT_REJECTED_AND_SUBMITTED_FORMS": "You just can make draft rejected and submitted forms", + "YOU_JUST_CAN_REJECT_SUBMITTED_FORMS": "You just can reject submitted forms", + "INVALID_TRACK_ID_FOR_OAUTH2_LOGIN": "Invalid trackId for oauth2 login", + "SOCIAL_NETWORK_IS_DIFFERENT_WITH_CLAIMED_ONE": "Social network is different with claimed one", + "SOCIAL_PROFILE_NOT_FOUND": "Social profile not gound", + "CHANGE_API_TITLE_OR_EIN_NOT_PRECISE": "Please query the exact project title or EIN ID from the ChangeAPI site", + "YOU_ARE_NOT_OWNER_OF_THIS_DONATION": "You are not owner of this donation", + "NOT_SUPPORTED_THIRD_PARTY_API": "Third Party API not supported", + "IPFS_IMAGE_UPLOAD_FAILED": "Image upload failed", + "YOU_SHOULD_FILL_EMAIL_PERSONAL_INFO_BEFORE_CONFIRMING_EMAIL": "You should fill email in personal info step before confirming it", + "YOU_ALREADY_VERIFIED_THIS_EMAIL": "You already verified this email", + "INVALID_FROM_DATE": "Invalid fromDate", + "INVALID_TO_DATE": "Invalid toDate", + "VERIFIED_USERNAME_IS_DIFFERENT_WITH_CLAIMED_ONE": "Username is not the claimed one", + "INVALID_AUTHORIZATION_VERSION": "Authorization version is not valid", + "INVALID_STEP": "Invalid step", + "DONOR_REPORTED_IT_AS_FAILED": "Donor reported it as failed", + "INVALID_DATE_FORMAT": "Date format should be YYYYMMDD HH:mm:ss", + "INTERNAL_SERVER_ERROR": "Internal server error", + "ERROR_CONNECTING_DB": "Error in connecting DB", + "YOU_DONT_HAVE_ACCESS_TO_VIEW_THIS_PROJECT": "You dont have access to view this project", + "JUST_ACTIVE_PROJECTS_ACCEPT_DONATION": "Just active projects accept donation", + "CATEGORIES_LENGTH_SHOULD_NOT_BE_MORE_THAN_FIVE": "Please select no more than 5 categories", + "CATEGORIES_MUST_BE_FROM_THE_FRONTEND_SUBSELECTION": "This category is not valid", + "INVALID_TX_HASH": "Invalid txHash", + "INVALID_TRANSACTION_ID": "Invalid transactionId", + "DUPLICATE_TX_HASH": "There is a donation with this txHash in our DB", + "YOU_ARE_NOT_THE_OWNER_OF_PROJECT": "You are not the owner of this project.", + "YOU_ARE_NOT_THE_OWNER_OF_PROJECT_VERIFICATION_FORM": "You are not the owner of this project verification form.", + "YOU_ARE_NOT_THE_OWNER_OF_SOCIAL_PROFILE": "You are not the owner of this social profile project verification form.", + "PROJECT_VERIFICATION_FORM_IS_NOT_DRAFT_SO_YOU_CANT_MODIFY_SOCIAL_PROFILES": "project verification form is not draft, so you cant modify social profiles", + "YOU_ALREADY_ADDED_THIS_SOCIAL_PROFILE_FOR_THIS_VERIFICATION_FORM": "You already have added this social profile for this verification form", + "PROJECT_VERIFICATION_FORM_NOT_FOUND": "Project verification form not found", + "PROJECT_IS_ALREADY_VERIFIED": "Project is already verified.", + "YOU_JUST_CAN_EDIT_DRAFT_REQUESTS": "Project is already verified.", + "EMAIL_CONFIRMATION_CANNOT_BE_SENT_IN_THIS_STEP": "Email confirmation cannot be sent in this step", + "THERE_IS_AN_ONGOING_VERIFICATION_REQUEST_FOR_THIS_PROJECT": "There is an ongoing project verification request for this project", + "THERE_IS_NOT_ANY_ONGOING_PROJECT_VERIFICATION_FORM_FOR_THIS_PROJECT": "There is not any project verification form for this project", + "PROJECT_STATUS_NOT_FOUND": "No project status found, this should be impossible", + "YOU_DONT_HAVE_ACCESS_TO_DEACTIVATE_THIS_PROJECT": "You dont have access to deactivate this project", + "PROJECT_NOT_FOUND": "Project not found.", + "PROJECT_IS_NOT_ACTIVE": "Project is not active.", + "INVALID_FUNCTION": "Invalid function name of transaction", + "PROJECT_UPDATE_NOT_FOUND": "Project update not found.", + "DONATION_NOT_FOUND": "donation not found", + "THIS_PROJECT_IS_CANCELLED_OR_DEACTIVATED_ALREADY": "This project has been cancelled by an Admin for inappropriate content or a violation of the Terms of Use", + "DONATION_VIEWING_LOGIN_REQUIRED": "You must be signed-in in order to register project donations", + "TRANSACTION_NOT_FOUND": "Transaction not found.", + "TRANSACTION_FROM_ADDRESS_IS_DIFFERENT_FROM_SENT_FROM_ADDRESS": "FromAddress of Transaction is different from sent fromAddress", + "TRANSACTION_STATUS_IS_FAILED_IN_NETWORK": "Transaction status is failed in network", + "INVALID_VERIFICATION_REVOKE_STATUS": "Invalid revoke status updated", + "TRANSACTION_NOT_FOUND_AND_NONCE_IS_USED": "Transaction not found and nonce is used", + "TRANSACTION_AMOUNT_IS_DIFFERENT_WITH_SENT_AMOUNT": "Transaction amount is different with sent amount", + "TRANSACTION_CANT_BE_OLDER_THAN_DONATION": "Transaction can not be older than donation", + "TRANSACTION_TO_ADDRESS_IS_DIFFERENT_FROM_SENT_TO_ADDRESS": "ToAddress of Transaction is different to sent toAddress", + "TRANSACTION_SMART_CONTRACT_CONFLICTS_WITH_CURRENCY": "Smart contract address is not equal to transaction.to", + "USER_NOT_FOUND": "User not found.", + "INVALID_NETWORK_ID": "Network Id is invalid", + "INVALID_TOKEN_SYMBOL": "Token symbol is invalid", + "TOKEN_SYMBOL_IS_REQUIRED": "Token symbol is required", + "TOKEN_NOT_FOUND": "Token Not found", + "TRANSACTION_NOT_FOUNT_IN_USER_HISTORY": "TRANSACTION_NOT_FOUNT_IN_USER_HISTORY", + "TRANSACTION_WITH_THIS_NONCE_IS_NOT_MINED_ALREADY": "Transaction with this nonce is not mined already", + "TO_ADDRESS_OF_DONATION_SHOULD_BE_PROJECT_WALLET_ADDRESS": "toAddress of donation should be equal to project wallet address", + "INVALID_WALLET_ADDRESS": "Address not valid", + "INVALID_EMAIL": "Email not valid", + "UN_AUTHORIZED": "unAuthorized", + "BOTH_FIRST_NAME_AND_LAST_NAME_CANT_BE_EMPTY": "Both firstName and lastName cant be empty", + "FIRSTNAME_CANT_BE_EMPTY_STRING": "firstName cant be empty string", + "LASTNAME_CANT_BE_EMPTY_STRING": "lastName cant be empty string", + "PROJECT_WITH_THIS_TITLE_EXISTS": "There is a project with this title, please use another title", + "INVALID_PROJECT_TITLE": "Your project name isnt valid, please only use letters and numbers", + "ACCESS_DENIED": "Access denied", + "AUTHENTICATION_REQUIRED": "Authentication required.", + "SOMETHING_WENT_WRONG": "Something went wrong.", + "PROJECT_DOES_NOT_SUPPORT_THIS_TOKEN": "Project doesnt support this token", + "THERE_IS_NO_RECIPIENT_ADDRESS_FOR_THIS_NETWORK_ID_AND_PROJECT": "There is no recipient address for this project and networkId", + "AMOUNT_IS_INVALID": "Amount is not valid", + "CURRENCY_IS_INVALID": "Currency is not valid", + "SHOULD_HAVE_AT_LEAST_ONE_CONNECTED_SOCIAL_NETWORK_BEFORE_SUBMIT": "Should have one connected social network before submit", + "SOCIAL_PROFILE_IS_ALREADY_VERIFIED": "Social profile is already verified", + "YOU_ARE_NOT_THE_OWNER_OF_THIS_SOCIAL_PROFILE": "You are not the owner of social profile", + "ERROR_IN_GETTING_ACCESS_TOKEN_BY_AUTHORIZATION_CODE": "Error in getting accessToken by authorization code", + "ERROR_GIVPOWER_BOOSTING_FIRST_PROJECT_100_PERCENT": "First project boosting value must be 100%", + "ERROR_GIVPOWER_BOOSTING_INVALID_DATA": "Invalid data", + "ERROR_GIV_POWER_BOOSTING_SUM_IS_GREATER_THAN_MAXIMUM": "Giv power boosting summation is greater than 100", + "ERROR_GIVPOWER_BOOSTING_MAX_PROJECT_LIMIT": "Number of boosted projects exceeds limit", + "REGISTERED_NON_PROFITS_CATEGORY_DOESNT_EXIST": "There is not any category with name registered-non-profits, probably you forgot to run migrations", + "PROJECT_UPDATE_CONTENT_LENGTH_SIZE_EXCEEDED": "Content length exceeded", + "INVALID_TOKEN_ADDRESS": "Invalid tokenAddress", + "There is already an anchor address for this project, only project owner can change it": "There is already an anchor address for this project, only project owner can change it", + "Project doesnt have recipient address on this network": "Project doesnt have recipient address on this network", + "There is already an anchor address for this project": "There is already an anchor address for this project", + "There is not anchor address for this project": "There is not anchor address for this project", + "DRAFT_DONATION_DISABLED": "Draft donation is disabled" +} diff --git a/src/utils/locales/es.json b/src/utils/locales/es.json index c5a876683..29b5cf14c 100644 --- a/src/utils/locales/es.json +++ b/src/utils/locales/es.json @@ -102,5 +102,6 @@ "ERROR_GIV_POWER_BOOSTING_SUM_IS_GREATER_THAN_MAXIMUM": "La suma de aumento de potencia de Giv es mayor que 100", "ERROR_GIVPOWER_BOOSTING_MAX_PROJECT_LIMIT": "Número de proyectos impulsados excede el límite", "REGISTERED_NON_PROFITS_CATEGORY_DOESNT_EXIST": "No hay ninguna categoría con nombre registrado-sin fines de lucro, probablemente se olvidó de ejecutar las migraciones", - "PROJECT_UPDATE_CONTENT_LENGTH_SIZE_EXCEEDED": "El contenido es demasiado largo" + "PROJECT_UPDATE_CONTENT_LENGTH_SIZE_EXCEEDED": "El contenido es demasiado largo", + "DRAFT_DONATION_DISABLED": "El borrador de donación está deshabilitado" } diff --git a/src/utils/tokenUtils.ts b/src/utils/tokenUtils.ts index b85c2f9e7..0aae18b0b 100644 --- a/src/utils/tokenUtils.ts +++ b/src/utils/tokenUtils.ts @@ -22,6 +22,7 @@ export const findTokenByNetworkAndSymbol = async ( symbol, networkId, }, + cache: { id: `token_${networkId}_${symbol}`, milliseconds: 1000 * 60 * 60 }, }); if (!token) { throw new Error(i18n.__(translationErrorMessagesKeys.TOKEN_NOT_FOUND)); @@ -51,6 +52,7 @@ export const findTokenByNetworkAndAddress = async ( address, networkId, }) + .cache(`token_${networkId}_${address}`, 1000 * 60 * 60) .getOne(); if (!token) { throw new Error(i18n.__(translationErrorMessagesKeys.TOKEN_NOT_FOUND)); diff --git a/src/utils/validators/graphqlQueryValidators.ts b/src/utils/validators/graphqlQueryValidators.ts index 3672956ff..358dfaad5 100644 --- a/src/utils/validators/graphqlQueryValidators.ts +++ b/src/utils/validators/graphqlQueryValidators.ts @@ -126,6 +126,31 @@ export const createDonationQueryValidator = Joi.object({ chainType: Joi.string().required(), }); +export const createDraftDonationQueryValidator = Joi.object({ + amount: Joi.number()?.greater(0).required(), + networkId: Joi.number() + .required() + .valid(...Object.values(NETWORK_IDS)), + tokenAddress: Joi.when('chainType', { + is: ChainType.SOLANA, + then: Joi.string().pattern(solanaProgramIdRegex), + otherwise: Joi.string().pattern(ethereumWalletAddressRegex), + }).messages({ + 'string.pattern.base': i18n.__( + translationErrorMessagesKeys.INVALID_TOKEN_ADDRESS, + ), + 'string.disallow': i18n.__( + translationErrorMessagesKeys.INVALID_TOKEN_ADDRESS, + ), + }), + token: Joi.string().required(), + projectId: Joi.number().integer().min(0).required(), + anonymous: Joi.boolean(), + referrerId: Joi.string().allow(null, ''), + safeTransactionId: Joi.string().allow(null, ''), + chainType: Joi.string().required(), +}); + export const updateDonationQueryValidator = Joi.object({ donationId: Joi.number().integer().min(0).required(), status: Joi.string().valid(DONATION_STATUS.VERIFIED, DONATION_STATUS.FAILED), diff --git a/src/workers/draftDonationMatchWorker.test.ts b/src/workers/draftDonationMatchWorker.test.ts new file mode 100644 index 000000000..a4dbb9616 --- /dev/null +++ b/src/workers/draftDonationMatchWorker.test.ts @@ -0,0 +1,82 @@ +import { + DraftDonation, + DRAFT_DONATION_STATUS, +} from '../entities/draftDonation'; +import { Project } from '../entities/project'; +import { NETWORK_IDS } from '../provider'; +import { runDraftDonationMatchWorker } from '../services/chains/evm/draftDonationService'; +import { + saveUserDirectlyToDb, + saveProjectDirectlyToDb, + createProjectData, +} from '../../test/testUtils'; +import { Donation } from '../entities/donation'; +import { ProjectAddress } from '../entities/projectAddress'; +import { findUserByWalletAddress } from '../repositories/userRepository'; +import { User } from '../entities/user'; + +const RandomAddress1 = '0xf3ddeb5022a6f06b61488b48c90315087ca2beef'; +const RandomAddress2 = '0xc42a4791735ae1253c50c6226832e37ede3669f5'; +const draftSaveTimeStampMS = 1707567300 * 1000; +const networkId = NETWORK_IDS.XDAI; +const anonymous = false; + +const sharedDonationData: Partial = { + networkId, + status: DRAFT_DONATION_STATUS.PENDING, + fromWalletAddress: RandomAddress1, + toWalletAddress: RandomAddress2, + anonymous, + createdAt: new Date(draftSaveTimeStampMS), +}; +let erc20DonationData: DraftDonation; +let nativeTokenDonationData: DraftDonation; +let project: Project; +let user: User; + +describe('draftDonationMatchWorker', () => { + beforeEach(async () => { + const _user = await findUserByWalletAddress(RandomAddress1); + // delete all user donations + if (_user) { + Donation.delete({ userId: _user.id }); + } + await DraftDonation.clear(); + + const projectAddress = await ProjectAddress.findOne({ + where: { address: RandomAddress2 }, + }); + if (projectAddress) { + await ProjectAddress.delete({ address: RandomAddress2 }); + await Project.delete(projectAddress.projectId); + } + + user = await saveUserDirectlyToDb(RandomAddress1); + project = await saveProjectDirectlyToDb({ + ...createProjectData(), + walletAddress: RandomAddress2, + }); + sharedDonationData.projectId = project.id; + sharedDonationData.userId = user.id; + + erc20DonationData = { + ...sharedDonationData, + tokenAddress: '0x4f4f9b8d5b4d0dc10506e5551b0513b61fd59e75', + amount: 0.01, + currency: 'GIV', + anonymous: false, + } as DraftDonation; + nativeTokenDonationData = { + ...sharedDonationData, + tokenAddress: '0x0000000000000000000000000000000000000000', + amount: 0.001, + currency: 'XDAI', + anonymous: false, + } as DraftDonation; + }); + it('should match draft donations', async () => { + await DraftDonation.create(erc20DonationData).save(); + await DraftDonation.create(nativeTokenDonationData).save(); + await runDraftDonationMatchWorker(); + }); +}); diff --git a/src/workers/draftDonationMatchWorker.ts b/src/workers/draftDonationMatchWorker.ts new file mode 100644 index 000000000..0143f5d99 --- /dev/null +++ b/src/workers/draftDonationMatchWorker.ts @@ -0,0 +1,69 @@ +import { expose } from 'threads/worker'; +import { WorkerModule } from 'threads/dist/types/worker'; +import { + DRAFT_DONATION_STATUS, + DraftDonation, +} from '../entities/draftDonation'; +import { matchDraftDonations } from '../services/chains/evm/draftDonationService'; +import { logger } from '../utils/logger'; +import { AppDataSource } from '../orm'; + +type DraftDonationWorkerFunctions = 'matchDraftDonations'; + +export type DraftDonationWorker = WorkerModule; + +const TAKE_USER = 100; +const TAKE_DRAFT_DONATION = 1000; + +const worker: DraftDonationWorker = { + async matchDraftDonations() { + await AppDataSource.initialize(false); + // const dataSource = await AppDataSource.getDataSource(); + try { + let userIdSkip = 0; + while (true) { + const userIds = await DraftDonation.createQueryBuilder('draftDonation') + .select('DISTINCT(draftDonation.userId)', 'userId') + .where('draftDonation.status = :status', { + status: DRAFT_DONATION_STATUS.PENDING, + }) + .orderBy('draftDonation.userId') + .skip(userIdSkip) + .take(TAKE_USER) + .getRawMany(); + for (const { userId } of userIds) { + let draftDonationSkip = 0; + logger.debug('match draft donation of user: ', userId); + const draftDonations = await DraftDonation.find({ + where: { + userId, + status: DRAFT_DONATION_STATUS.PENDING, + }, + order: { networkId: 'ASC' }, + take: TAKE_DRAFT_DONATION, + skip: draftDonationSkip, + }); + + if (draftDonations.length === 0) continue; + + await matchDraftDonations({ + [draftDonations[0].fromWalletAddress]: draftDonations, + }); + if (draftDonations.length < TAKE_DRAFT_DONATION) { + break; + } else { + draftDonationSkip += draftDonations.length; + } + } + if (userIds.length < TAKE_USER) { + break; + } + userIdSkip += userIds.length; + } + } catch (e) { + logger.error('Error in matchDraftDonations worker', e); + } + }, +}; + +expose(worker); diff --git a/test/graphqlQueries.ts b/test/graphqlQueries.ts index 26124ac2c..58fabe283 100644 --- a/test/graphqlQueries.ts +++ b/test/graphqlQueries.ts @@ -28,6 +28,30 @@ export const createDonationMutation = ` } `; +export const createDraftDonationMutation = ` + mutation ( + $networkId: Float! + $amount: Float! + $token: String! + $projectId: Float! + $tokenAddress: String + $anonymous: Boolean + $referrerId: String + $safeTransactionId: String + ) { + createDraftDonation( + networkId: $networkId + amount: $amount + token: $token + projectId: $projectId + tokenAddress: $tokenAddress + anonymous: $anonymous + referrerId: $referrerId + safeTransactionId: $safeTransactionId + ) + } +`; + export const updateDonationStatusMutation = ` mutation ( $status: String