diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 58d2ae234..000000000 Binary files a/.DS_Store and /dev/null differ diff --git a/migration/1727098387189-addTokenPriceToRounds.ts b/migration/1727098387189-addTokenPriceToRounds.ts new file mode 100644 index 000000000..6c80c6181 --- /dev/null +++ b/migration/1727098387189-addTokenPriceToRounds.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTokenPriceToRounds1727098387189 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Add `token_price` column to the `qf_round` table + await queryRunner.query( + `ALTER TABLE "qf_round" ADD "token_price" double precision DEFAULT NULL`, + ); + + // Add `token_price` column to the `early_access_round` table + await queryRunner.query( + `ALTER TABLE "early_access_round" ADD "token_price" double precision DEFAULT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Remove `token_price` column from both tables if needed + await queryRunner.query(`ALTER TABLE "qf_round" DROP COLUMN "token_price"`); + await queryRunner.query( + `ALTER TABLE "early_access_round" DROP COLUMN "token_price"`, + ); + } +} diff --git a/migration/1727458215571-fixAddTokenPriceToRounds.ts b/migration/1727458215571-fixAddTokenPriceToRounds.ts new file mode 100644 index 000000000..8060ad8c7 --- /dev/null +++ b/migration/1727458215571-fixAddTokenPriceToRounds.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class FixAddTokenPriceToRounds1727458215571 + implements MigrationInterface +{ + name = 'FixAddTokenPriceToRounds1727458215571'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "qf_round" RENAME COLUMN "token_price" TO "tokenPrice"`, + ); + await queryRunner.query( + `ALTER TABLE "early_access_round" RENAME COLUMN "token_price" TO "tokenPrice"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "early_access_round" RENAME COLUMN "tokenPrice" TO "token_price"`, + ); + await queryRunner.query( + `ALTER TABLE "qf_round" RENAME COLUMN "tokenPrice" TO "token_price"`, + ); + } +} diff --git a/src/adapters/price/CoingeckoPriceAdapter.ts b/src/adapters/price/CoingeckoPriceAdapter.ts index 37f8aaa06..c79a4b193 100644 --- a/src/adapters/price/CoingeckoPriceAdapter.ts +++ b/src/adapters/price/CoingeckoPriceAdapter.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import moment from 'moment'; import { GetTokenPriceAtDateParams, GetTokenPriceParams, @@ -46,9 +47,11 @@ export class CoingeckoPriceAdapter implements PriceAdapterInterface { params: GetTokenPriceAtDateParams, ): Promise { try { + const formattedDate = moment(params.date).format('DD-MM-YYYY'); + const result = await axios.get( // symbol in here means coingecko id for instance - `https://api.coingecko.com/api/v3/coins/${params.symbol}/history?date=${params.date}`, + `https://api.coingecko.com/api/v3/coins/${params.symbol}/history?date=${formattedDate}`, ); const priceUsd = result?.data?.market_data?.current_price?.usd; diff --git a/src/adapters/price/PriceAdapterInterface.ts b/src/adapters/price/PriceAdapterInterface.ts index 685cdb1dc..568db3134 100644 --- a/src/adapters/price/PriceAdapterInterface.ts +++ b/src/adapters/price/PriceAdapterInterface.ts @@ -5,7 +5,7 @@ export interface GetTokenPriceParams { export interface GetTokenPriceAtDateParams { symbol: string; - date: string; + date: Date; } export interface PriceAdapterInterface { diff --git a/src/constants/qacc.ts b/src/constants/qacc.ts new file mode 100644 index 000000000..b2a5e469e --- /dev/null +++ b/src/constants/qacc.ts @@ -0,0 +1,13 @@ +import config from '../config'; + +export const QACC_DONATION_TOKEN_ADDRESS: string = + (config.get('QACC_DONATION_TOKEN_ADDRESS') as string) || + '0xa2036f0538221a77a3937f1379699f44945018d0'; //https://zkevm.polygonscan.com/token/0xa2036f0538221a77a3937f1379699f44945018d0#readContract +export const QACC_DONATION_TOKEN_SYMBOL = + (config.get('QACC_DONATION_TOKEN_SYMBOL') as string) || 'MATIC'; +export const QACC_DONATION_TOKEN_NAME = + (config.get('QACC_DONATION_TOKEN_NAME') as string) || 'Matic token'; +export const QACC_DONATION_TOKEN_DECIMALS = + (+config.get('QACC_DONATION_TOKEN_DECIMALS') as number) || 18; +export const QACC_DONATION_TOKEN_COINGECKO_ID = + (config.get('QACC_DONATION_TOKEN_COINGECKO_ID') as string) || 'matic-network'; diff --git a/src/entities/earlyAccessRound.ts b/src/entities/earlyAccessRound.ts index 6f1283f25..336f6b914 100644 --- a/src/entities/earlyAccessRound.ts +++ b/src/entities/earlyAccessRound.ts @@ -48,4 +48,8 @@ export class EarlyAccessRound extends BaseEntity { @Field(() => Date) @UpdateDateColumn() updatedAt: Date; + + @Field({ nullable: true }) + @Column({ type: 'float', nullable: true }) + tokenPrice?: number; } diff --git a/src/entities/qfRound.ts b/src/entities/qfRound.ts index 88f5be3d0..436a75039 100644 --- a/src/entities/qfRound.ts +++ b/src/entities/qfRound.ts @@ -106,6 +106,10 @@ export class QfRound extends BaseEntity { @Column({ default: false }) isDataAnalysisDone: boolean; + @Field({ nullable: true }) + @Column({ type: 'float', nullable: true }) + tokenPrice?: number; + @UpdateDateColumn() updatedAt: Date; diff --git a/src/repositories/earlyAccessRoundRepository.test.ts b/src/repositories/earlyAccessRoundRepository.test.ts index 1bb959723..22308779a 100644 --- a/src/repositories/earlyAccessRoundRepository.test.ts +++ b/src/repositories/earlyAccessRoundRepository.test.ts @@ -1,20 +1,38 @@ import { expect } from 'chai'; +import moment from 'moment'; +import sinon from 'sinon'; import { EarlyAccessRound } from '../entities/earlyAccessRound'; import { findAllEarlyAccessRounds, findActiveEarlyAccessRound, + fillMissingTokenPriceInEarlyAccessRounds, } from './earlyAccessRoundRepository'; import { saveRoundDirectlyToDb } from '../../test/testUtils'; +import { CoingeckoPriceAdapter } from '../adapters/price/CoingeckoPriceAdapter'; +import { QACC_DONATION_TOKEN_COINGECKO_ID } from '../constants/qacc'; describe('EarlyAccessRound Repository Test Cases', () => { + let priceAdapterStub: sinon.SinonStub; + beforeEach(async () => { // Clean up data before each test case await EarlyAccessRound.delete({}); + + // Stub CoingeckoPriceAdapter to mock getTokenPriceAtDate + priceAdapterStub = sinon + .stub(CoingeckoPriceAdapter.prototype, 'getTokenPriceAtDate') + .resolves(100); + + // Reset tokenPrice to undefined for test consistency + await EarlyAccessRound.update({}, { tokenPrice: undefined }); }); afterEach(async () => { // Clean up data after each test case await EarlyAccessRound.delete({}); + + // Restore the stubbed method after each test + priceAdapterStub.restore(); }); it('should save a new Early Access Round directly to the database', async () => { @@ -125,4 +143,59 @@ describe('EarlyAccessRound Repository Test Cases', () => { const activeRound = await findActiveEarlyAccessRound(); expect(activeRound).to.be.null; }); + + it('should update token price for rounds with null tokenPrice', async () => { + // Create a EarlyAccessRound with null token price + const earlyAccessRound = EarlyAccessRound.create({ + roundNumber: Math.floor(Math.random() * 10000), + startDate: moment().subtract(3, 'days').toDate(), + endDate: moment().add(10, 'days').toDate(), + tokenPrice: undefined, + }); + await EarlyAccessRound.save(earlyAccessRound); + + const updatedCount = await fillMissingTokenPriceInEarlyAccessRounds(); + + const updatedEarlyAcccessRound = await EarlyAccessRound.findOne({ + where: { id: earlyAccessRound.id }, + }); + + // Assert that the token price fetching method was called with the correct date + sinon.assert.calledWith(priceAdapterStub, { + symbol: QACC_DONATION_TOKEN_COINGECKO_ID, + date: earlyAccessRound.startDate, + }); + + expect(updatedEarlyAcccessRound?.tokenPrice).to.equal(100); + expect(updatedCount).to.equal(1); + }); + + it('should not update token price for rounds with existing tokenPrice', async () => { + // Create a EarlyAccessRound with an existing token price + const earlyAccessRound = EarlyAccessRound.create({ + roundNumber: Math.floor(Math.random() * 10000), + startDate: moment().subtract(3, 'days').toDate(), + endDate: moment().add(10, 'days').toDate(), + tokenPrice: 50, + }); + await EarlyAccessRound.save(earlyAccessRound); + + const updatedCount = await fillMissingTokenPriceInEarlyAccessRounds(); + + const updatedEarlyAcccessRound = await EarlyAccessRound.findOne({ + where: { id: earlyAccessRound.id }, + }); + + expect(updatedEarlyAcccessRound?.tokenPrice).to.equal(50); + expect(updatedCount).to.equal(0); + }); + + it('should return zero if there are no rounds to update', async () => { + // Ensure no rounds with null token_price + await EarlyAccessRound.update({}, { tokenPrice: 100 }); + + const updatedCount = await fillMissingTokenPriceInEarlyAccessRounds(); + + expect(updatedCount).to.equal(0); + }); }); diff --git a/src/repositories/earlyAccessRoundRepository.ts b/src/repositories/earlyAccessRoundRepository.ts index e0cb57c3d..e08dc1a2d 100644 --- a/src/repositories/earlyAccessRoundRepository.ts +++ b/src/repositories/earlyAccessRoundRepository.ts @@ -1,5 +1,8 @@ +import { CoingeckoPriceAdapter } from '../adapters/price/CoingeckoPriceAdapter'; import { EarlyAccessRound } from '../entities/earlyAccessRound'; import { logger } from '../utils/logger'; +import { AppDataSource } from '../orm'; +import { QACC_DONATION_TOKEN_COINGECKO_ID } from '../constants/qacc'; export const findAllEarlyAccessRounds = async (): Promise< EarlyAccessRound[] @@ -30,3 +33,34 @@ export const findActiveEarlyAccessRound = throw new Error('Error fetching active Early Access round'); } }; + +export const fillMissingTokenPriceInEarlyAccessRounds = async (): Promise< + void | number +> => { + const priceAdapter = new CoingeckoPriceAdapter(); + + // Find all EarlyAccessRound where token_price is NULL + const roundsToUpdate = await AppDataSource.getDataSource() + .getRepository(EarlyAccessRound) + .createQueryBuilder('early_AccessRound') + .where('early_AccessRound.tokenPrice IS NULL') + .andWhere('early_AccessRound.startDate < :now', { now: new Date() }) + .getMany(); + + // Set the token price for all found rounds and save them + for (const round of roundsToUpdate) { + const tokenPrice = await priceAdapter.getTokenPriceAtDate({ + symbol: QACC_DONATION_TOKEN_COINGECKO_ID, + date: round.startDate, + }); + + if (tokenPrice) { + round.tokenPrice = tokenPrice; + await AppDataSource.getDataSource() + .getRepository(EarlyAccessRound) + .save(round); + } + } + + return roundsToUpdate.length; +}; diff --git a/src/repositories/qfRoundRepository.test.ts b/src/repositories/qfRoundRepository.test.ts index e8267e9c1..5dd119abf 100644 --- a/src/repositories/qfRoundRepository.test.ts +++ b/src/repositories/qfRoundRepository.test.ts @@ -1,5 +1,6 @@ import { assert, expect } from 'chai'; import moment from 'moment'; +import sinon from 'sinon'; import { createDonationData, createProjectData, @@ -17,10 +18,12 @@ import { getProjectDonationsSqrtRootSum, getQfRoundTotalSqrtRootSumSquared, getQfRoundStats, + fillMissingTokenPriceInQfRounds, } from './qfRoundRepository'; import { Project } from '../entities/project'; import { refreshProjectEstimatedMatchingView } from '../services/projectViewsService'; import { getProjectQfRoundStats } from './donationRepository'; +import { CoingeckoPriceAdapter } from '../adapters/price/CoingeckoPriceAdapter'; describe( 'getProjectDonationsSqrtRootSum test cases', @@ -40,6 +43,10 @@ describe( ); describe('findQfRoundById test cases', findQfRoundByIdTestCases); describe('findQfRoundBySlug test cases', findQfRoundBySlugTestCases); +describe( + 'fillMissingTokenPriceInQfRounds test cases', + fillMissingTokenPriceInQfRoundsTestCase, +); function getProjectDonationsSqrRootSumTests() { let qfRound: QfRound; @@ -506,3 +513,73 @@ function findQfRoundBySlugTestCases() { assert.isNull(result); }); } + +function fillMissingTokenPriceInQfRoundsTestCase() { + let priceAdapterStub: sinon.SinonStub; + + beforeEach(async () => { + // Stub CoingeckoPriceAdapter to mock getTokenPriceAtDate + priceAdapterStub = sinon + .stub(CoingeckoPriceAdapter.prototype, 'getTokenPriceAtDate') + .resolves(100); + + // Reset tokenPrice to undefined for test consistency + await QfRound.update({}, { tokenPrice: undefined }); + }); + + afterEach(() => { + // Restore the stubbed method after each test + priceAdapterStub.restore(); + }); + + it('should update token price for rounds with null tokenPrice', async () => { + // Create a QfRound with null token price + const qfRound = QfRound.create({ + isActive: true, + name: 'test', + allocatedFund: 100, + minimumPassportScore: 8, + slug: new Date().getTime().toString(), + beginDate: moment().subtract(3, 'days').toDate(), + endDate: moment().add(10, 'days').toDate(), + tokenPrice: undefined, + }); + await qfRound.save(); + + const updatedCount = await fillMissingTokenPriceInQfRounds(); + + const updatedQfRound = await QfRound.findOne({ where: { id: qfRound.id } }); + expect(updatedQfRound?.tokenPrice).to.equal(100); + expect(updatedCount).to.equal(1); + }); + + it('should not update token price for rounds with existing tokenPrice', async () => { + // Create a QfRound with an existing token price + const qfRound = QfRound.create({ + isActive: true, + name: 'test', + allocatedFund: 100, + minimumPassportScore: 8, + slug: new Date().getTime().toString(), + beginDate: moment().subtract(3, 'days').toDate(), + endDate: moment().add(10, 'days').toDate(), + tokenPrice: 50, + }); + await qfRound.save(); + + const updatedCount = await fillMissingTokenPriceInQfRounds(); + + const updatedQfRound = await QfRound.findOne({ where: { id: qfRound.id } }); + expect(updatedQfRound?.tokenPrice).to.equal(50); + expect(updatedCount).to.equal(0); + }); + + it('should return zero if there are no rounds to update', async () => { + // Ensure no rounds with null tokenPrice + await QfRound.update({}, { tokenPrice: 100 }); + + const updatedCount = await fillMissingTokenPriceInQfRounds(); + + expect(updatedCount).to.equal(0); + }); +} diff --git a/src/repositories/qfRoundRepository.ts b/src/repositories/qfRoundRepository.ts index 26f8c516d..a6a6325b6 100644 --- a/src/repositories/qfRoundRepository.ts +++ b/src/repositories/qfRoundRepository.ts @@ -10,6 +10,8 @@ import { Sybil } from '../entities/sybil'; import { ProjectFraud } from '../entities/projectFraud'; import config from '../config'; import { logger } from '../utils/logger'; +import { CoingeckoPriceAdapter } from '../adapters/price/CoingeckoPriceAdapter'; +import { QACC_DONATION_TOKEN_COINGECKO_ID } from '../constants/qacc'; const qfRoundEstimatedMatchingParamsCacheDuration = Number( process.env.QF_ROUND_ESTIMATED_MATCHING_CACHE_DURATION || 60000, @@ -318,3 +320,32 @@ export const retrieveActiveQfRoundUserMBDScore = async ( return null; } }; + +export const fillMissingTokenPriceInQfRounds = async (): Promise< + void | number +> => { + const priceAdapter = new CoingeckoPriceAdapter(); + + // Find all QfRounds where token_price is NULL + const roundsToUpdate = await AppDataSource.getDataSource() + .getRepository(QfRound) + .createQueryBuilder('qf_round') + .where('qf_round.tokenPrice IS NULL') + .andWhere('qf_round.beginDate < :now', { now: new Date() }) + .getMany(); + + // Set the token price for all found rounds and save them + for (const round of roundsToUpdate) { + const tokenPrice = await priceAdapter.getTokenPriceAtDate({ + symbol: QACC_DONATION_TOKEN_COINGECKO_ID, + date: round.beginDate, + }); + + if (tokenPrice) { + round.tokenPrice = tokenPrice; + await AppDataSource.getDataSource().getRepository(QfRound).save(round); + } + } + + return roundsToUpdate.length; +}; diff --git a/src/resolvers/donationResolver.test.ts b/src/resolvers/donationResolver.test.ts index d3466da7c..f4e446d34 100644 --- a/src/resolvers/donationResolver.test.ts +++ b/src/resolvers/donationResolver.test.ts @@ -58,7 +58,8 @@ import { DRAFT_DONATION_STATUS, DraftDonation, } from '../entities/draftDonation'; -import qacc, { QACC_DONATION_TOKEN_SYMBOL } from '../utils/qacc'; +import qacc from '../utils/qacc'; +import { QACC_DONATION_TOKEN_SYMBOL } from '../constants/qacc'; // eslint-disable-next-line @typescript-eslint/no-var-requires const moment = require('moment'); diff --git a/src/resolvers/draftDonationResolver.test.ts b/src/resolvers/draftDonationResolver.test.ts index 9fa87ef34..9b3eaf917 100644 --- a/src/resolvers/draftDonationResolver.test.ts +++ b/src/resolvers/draftDonationResolver.test.ts @@ -17,7 +17,7 @@ import { DRAFT_DONATION_STATUS, DraftDonation, } from '../entities/draftDonation'; -import { QACC_DONATION_TOKEN_SYMBOL } from '../utils/qacc'; +import { QACC_DONATION_TOKEN_SYMBOL } from '../constants/qacc'; describe('createDraftDonation() test cases', createDraftDonationTestCases); diff --git a/src/resolvers/projectResolver.ts b/src/resolvers/projectResolver.ts index ef1fbf0de..40d959368 100644 --- a/src/resolvers/projectResolver.ts +++ b/src/resolvers/projectResolver.ts @@ -112,7 +112,7 @@ import { import { QACC_DONATION_TOKEN_ADDRESS, QACC_DONATION_TOKEN_SYMBOL, -} from '../utils/qacc'; +} from '../constants/qacc'; import { ProjectRoundRecord } from '../entities/projectRoundRecord'; import { getProjectRoundRecord } from '../repositories/projectRoundRecordRepository'; diff --git a/src/server/adminJs/tabs/donationTab.ts b/src/server/adminJs/tabs/donationTab.ts index 907c4f8be..0a7ebfb8e 100644 --- a/src/server/adminJs/tabs/donationTab.ts +++ b/src/server/adminJs/tabs/donationTab.ts @@ -1,6 +1,5 @@ import { SelectQueryBuilder } from 'typeorm'; import { ActionContext } from 'adminjs'; -import moment from 'moment'; import { Donation, DONATION_STATUS, @@ -279,7 +278,7 @@ export const FillPricesForDonationsWithoutPrice = async () => { const token = await Token.findOneBy({ symbol: donation.currency }); if (!token || !token.coingeckoId) continue; const price = await coingeckoAdapter.getTokenPriceAtDate({ - date: moment(donation.createdAt).format('DD-MM-YYYY'), + date: donation.createdAt, symbol: token.coingeckoId, }); donation.valueUsd = donation.amount * price; diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index 7b48a4e1e..16f7567e0 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -60,7 +60,7 @@ import { QACC_DONATION_TOKEN_DECIMALS, QACC_DONATION_TOKEN_NAME, QACC_DONATION_TOKEN_SYMBOL, -} from '../utils/qacc'; +} from '../constants/qacc'; import { QACC_NETWORK_ID } from '../provider'; import { Token } from '../entities/token'; import { ChainType } from '../types/network'; diff --git a/src/services/cronJobs/importLostDonationsJob.ts b/src/services/cronJobs/importLostDonationsJob.ts index 481816dac..29b7c9d0c 100644 --- a/src/services/cronJobs/importLostDonationsJob.ts +++ b/src/services/cronJobs/importLostDonationsJob.ts @@ -183,15 +183,13 @@ export const importLostDonations = async () => { const donationDateDbFormat = moment(donationDate).format( 'YYYY-MM-DD HH:mm:ss', ); - const donationDateCoingeckoFormat = - moment(donationDate).format('DD-MM-YYYY'); const coingeckoAdapter = new CoingeckoPriceAdapter(); let ethereumPriceAtDate; try { ethereumPriceAtDate = await coingeckoAdapter.getTokenPriceAtDate({ symbol: tokenInDB!.coingeckoId, - date: donationDateCoingeckoFormat, + date: donationDate, }); } catch (e) { logger.debug('CoingeckoPrice not found for tx: ', tx); diff --git a/src/utils/qacc.ts b/src/utils/qacc.ts index 6dfbedcef..9fe10e9f2 100644 --- a/src/utils/qacc.ts +++ b/src/utils/qacc.ts @@ -1,21 +1,9 @@ import { getAbcLauncherAdapter } from '../adapters/adaptersFactory'; -import config from '../config'; import { Project } from '../entities/project'; import { i18n, translationErrorMessagesKeys } from './errorMessages'; import { QACC_NETWORK_ID } from '../provider'; import { findActiveEarlyAccessRound } from '../repositories/earlyAccessRoundRepository'; - -export const QACC_DONATION_TOKEN_ADDRESS: string = - (config.get('QACC_DONATION_TOKEN_ADDRESS') as string) || - '0xa2036f0538221a77a3937f1379699f44945018d0'; //https://zkevm.polygonscan.com/token/0xa2036f0538221a77a3937f1379699f44945018d0#readContract -export const QACC_DONATION_TOKEN_SYMBOL = - (config.get('QACC_DONATION_TOKEN_SYMBOL') as string) || 'MATIC'; -export const QACC_DONATION_TOKEN_NAME = - (config.get('QACC_DONATION_TOKEN_NAME') as string) || 'Matic token'; -export const QACC_DONATION_TOKEN_DECIMALS = - (+config.get('QACC_DONATION_TOKEN_DECIMALS') as number) || 18; -export const QACC_DONATION_TOKEN_COINGECKO_ID = - (config.get('QACC_DONATION_TOKEN_COINGECKO_ID') as string) || 'matic-network'; +import { QACC_DONATION_TOKEN_SYMBOL } from '../constants/qacc'; const isEarlyAccessRound = async () => { const earlyAccessRound = await findActiveEarlyAccessRound(); diff --git a/test/testUtils.ts b/test/testUtils.ts index 81e7bce5d..84f62d0ec 100644 --- a/test/testUtils.ts +++ b/test/testUtils.ts @@ -40,7 +40,7 @@ import { QACC_DONATION_TOKEN_DECIMALS, QACC_DONATION_TOKEN_NAME, QACC_DONATION_TOKEN_SYMBOL, -} from '../src/utils/qacc'; +} from '../src/constants/qacc'; import { EarlyAccessRound } from '../src/entities/earlyAccessRound'; // eslint-disable-next-line @typescript-eslint/no-var-requires