diff --git a/.github/workflows/develop-pipeline.yml b/.github/workflows/develop-pipeline.yml index d42c9bf3e..ef4848203 100644 --- a/.github/workflows/develop-pipeline.yml +++ b/.github/workflows/develop-pipeline.yml @@ -68,6 +68,7 @@ jobs: SOLANA_TEST_NODE_RPC_URL: ${{ secrets.SOLANA_TEST_NODE_RPC_URL }} SOLANA_DEVNET_NODE_RPC_URL: ${{ secrets.SOLANA_DEVNET_NODE_RPC_URL }} SOLANA_MAINNET_NODE_RPC_URL: ${{ secrets.SOLANA_MAINNET_NODE_RPC_URL }} + MPETH_GRAPHQL_PRICES_URL: ${{ secrets.MPETH_GRAPHQL_PRICES_URL }} publish: needs: test diff --git a/.github/workflows/master-pipeline.yml b/.github/workflows/master-pipeline.yml index a8f93f2e1..e0d925df5 100644 --- a/.github/workflows/master-pipeline.yml +++ b/.github/workflows/master-pipeline.yml @@ -68,6 +68,7 @@ jobs: SOLANA_TEST_NODE_RPC_URL: ${{ secrets.SOLANA_TEST_NODE_RPC_URL }} SOLANA_DEVNET_NODE_RPC_URL: ${{ secrets.SOLANA_DEVNET_NODE_RPC_URL }} SOLANA_MAINNET_NODE_RPC_URL: ${{ secrets.SOLANA_MAINNET_NODE_RPC_URL }} + MPETH_GRAPHQL_PRICES_URL: ${{ secrets.MPETH_GRAPHQL_PRICES_URL }} publish: needs: test diff --git a/.github/workflows/run-tests-on-pr.yml.bck b/.github/workflows/run-tests-on-pr.yml.bck index be434d0e4..76fc70f7b 100644 --- a/.github/workflows/run-tests-on-pr.yml.bck +++ b/.github/workflows/run-tests-on-pr.yml.bck @@ -66,3 +66,4 @@ jobs: OPTIMISTIC_SCAN_API_KEY: ${{ secrets.OPTIMISTIC_SCAN_API_KEY }} CELO_SCAN_API_KEY: ${{ secrets.CELO_SCAN_API_KEY }} CELO_ALFAJORES_SCAN_API_KEY: ${{ secrets.CELO_ALFAJORES_SCAN_API_KEY }} + MPETH_GRAPHQL_PRICES_URL: ${{ secrets.MPETH_GRAPHQL_PRICES_URL }} diff --git a/.github/workflows/staging-pipeline.yml b/.github/workflows/staging-pipeline.yml index db5ea8647..fab074f89 100644 --- a/.github/workflows/staging-pipeline.yml +++ b/.github/workflows/staging-pipeline.yml @@ -106,6 +106,7 @@ jobs: SOLANA_TEST_NODE_RPC_URL: ${{ secrets.SOLANA_TEST_NODE_RPC_URL }} SOLANA_DEVNET_NODE_RPC_URL: ${{ secrets.SOLANA_DEVNET_NODE_RPC_URL }} SOLANA_MAINNET_NODE_RPC_URL: ${{ secrets.SOLANA_MAINNET_NODE_RPC_URL }} + MPETH_GRAPHQL_PRICES_URL: ${{ secrets.MPETH_GRAPHQL_PRICES_URL }} publish: needs: test diff --git a/config/example.env b/config/example.env index 892420053..d5607c64a 100644 --- a/config/example.env +++ b/config/example.env @@ -260,3 +260,13 @@ DONATION_SAVE_BACKUP_DATABASE= # Default value is saveBackup DONATION_SAVE_BACKUP_ADAPTER=saveBackup + +ENABLE_UPDATE_RECURRING_DONATION_STREAM=true + +# Default value is 1 +NUMBER_OF_UPDATE_RECURRING_DONATION_CONCURRENT_JOB=1 + +# Default value is 0 0 * * * that means one day at 00:00 +UPDATE_RECURRING_DONATIONS_STREAM=0 0 * * * + +MPETH_GRAPHQL_PRICES_URL= diff --git a/migration/1706820821887-addmpEthToDatabaseTokens.ts b/migration/1706820821887-addmpEthToDatabaseTokens.ts new file mode 100644 index 000000000..70387118a --- /dev/null +++ b/migration/1706820821887-addmpEthToDatabaseTokens.ts @@ -0,0 +1,87 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { NETWORK_IDS } from '../src/provider'; +import { ChainType } from '../src/types/network'; +import { Token } from '../src/entities/token'; + +const mpEthTokens = [ + { + name: 'mpETH', + symbol: 'mpETH', + address: '0x48afbbd342f64ef8a9ab1c143719b63c2ad81710', + decimals: 18, + isGivbackEligible: true, + networkId: NETWORK_IDS.MAIN_NET, + chainType: ChainType.EVM, + }, + { + name: 'mpETH', + symbol: 'mpETH', + address: '0x819845b60a192167ed1139040b4f8eca31834f27', + decimals: 18, + isGivbackEligible: true, + networkId: NETWORK_IDS.OPTIMISTIC, + chainType: ChainType.EVM, + }, +]; + +export class addmpEthToDatabaseTokens1706820821887 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + const exists = await queryRunner.query(` + SELECT * FROM token + WHERE + ("address" = '0x48afbbd342f64ef8a9ab1c143719b63c2ad81710' AND "networkId" = ${NETWORK_IDS.MAIN_NET}) OR + ("address" = '0x819845b60a192167ed1139040b4f8eca31834f27' AND "networkId" = ${NETWORK_IDS.OPTIMISTIC}) + `); + + if (exists && exists.length === 2) { + return; + } + + await queryRunner.manager.save(Token, mpEthTokens); + + const tokens = await queryRunner.query(` + SELECT * FROM token + WHERE + ("address" = '0x48afbbd342f64ef8a9ab1c143719b63c2ad81710' AND "networkId" = ${NETWORK_IDS.MAIN_NET}) OR + ("address" = '0x819845b60a192167ed1139040b4f8eca31834f27' AND "networkId" = ${NETWORK_IDS.OPTIMISTIC}) + `); + const givethOrganization = ( + await queryRunner.query(`SELECT * FROM organization + WHERE label='giveth'`) + )[0]; + + const traceOrganization = ( + await queryRunner.query(`SELECT * FROM organization + WHERE label='trace'`) + )[0]; + + for (const token of tokens) { + await queryRunner.query(`INSERT INTO organization_tokens_token ("tokenId","organizationId") VALUES + (${token.id}, ${givethOrganization.id}), + (${token.id}, ${traceOrganization.id}) + ;`); + } + } + + public async down(queryRunner: QueryRunner): Promise { + const tokens = await queryRunner.query(` + SELECT * FROM token + WHERE + ("address" = '0x48afbbd342f64ef8a9ab1c143719b63c2ad81710' AND "networkId" = ${NETWORK_IDS.MAIN_NET}) OR + ("address" = '0x819845b60a192167ed1139040b4f8eca31834f27' AND "networkId" = ${NETWORK_IDS.OPTIMISTIC}) + `); + await queryRunner.query( + `DELETE FROM organization_tokens_token WHERE "tokenId" IN (${tokens + .map(token => token.id) + .join(',')})`, + ); + + await queryRunner.query( + `DELETE FROM token WHERE "id" IN (${tokens + .map(token => token.id) + .join(',')})`, + ); + } +} diff --git a/src/entities/projectVerificationForm.ts b/src/entities/projectVerificationForm.ts index e38bf3213..97783fd36 100644 --- a/src/entities/projectVerificationForm.ts +++ b/src/entities/projectVerificationForm.ts @@ -97,7 +97,7 @@ export class FormRelatedAddress { address: string; @Field({ nullable: true }) networkId: number; - @Field(type => ChainType, { defaultValue: ChainType.EVM }) + @Field(type => ChainType, { defaultValue: ChainType.EVM, nullable: true }) chainType?: ChainType; } diff --git a/src/resolvers/projectVerificationFormResolver.ts b/src/resolvers/projectVerificationFormResolver.ts index 34495644f..e13431039 100644 --- a/src/resolvers/projectVerificationFormResolver.ts +++ b/src/resolvers/projectVerificationFormResolver.ts @@ -1,6 +1,10 @@ import { Arg, Ctx, Mutation, Query, Resolver } from 'type-graphql'; import { ApolloContext } from '../types/ApolloContext'; -import { i18n, translationErrorMessagesKeys } from '../utils/errorMessages'; +import { + errorMessages, + i18n, + translationErrorMessagesKeys, +} from '../utils/errorMessages'; import { createProjectVerificationRequestValidator, getCurrentProjectVerificationRequestValidator, @@ -324,10 +328,10 @@ export class ProjectVerificationFormResolver { const verificationForm = await getVerificationFormByProjectId(project.id); if (!verificationForm) { + // Because frontend use hard coded english error message, we dont translate this error message + // otherwise we need to handle all translation in frontend as well https://github.com/Giveth/giveth-dapps-v2/issues/3582#issuecomment-1913614715 throw new Error( - i18n.__( - translationErrorMessagesKeys.THERE_IS_NOT_ANY_ONGOING_PROJECT_VERIFICATION_FORM_FOR_THIS_PROJECT, - ), + errorMessages.THERE_IS_NOT_ANY_ONGOING_PROJECT_VERIFICATION_FORM_FOR_THIS_PROJECT, ); } return verificationForm; diff --git a/src/services/chains/index.test.ts b/src/services/chains/index.test.ts index 625634137..0504ce2ef 100644 --- a/src/services/chains/index.test.ts +++ b/src/services/chains/index.test.ts @@ -934,7 +934,7 @@ function getTransactionDetailTestCases() { ); }); - it('should return transaction detail for spl-token transfer on Solana #1', async () => { + it('should return transaction detail for spl-token transfer on Solana devnet #1', async () => { // https://solscan.io/tx/2tm14GVsDwXpMzxZzpEWyQnfzcUEv1DZQVQb6VdbsHcV8StoMbBtuQTkW1LJ8RhKKrAL18gbm181NgzuusiQfZ16?cluster=devnet const amount = 7; const transactionInfo = await getTransactionInfoFromNetwork({ @@ -953,7 +953,7 @@ function getTransactionDetailTestCases() { assert.equal(transactionInfo.amount, amount); }); - it('should return transaction detail for spl-token transfer on Solana #2', async () => { + it('should return transaction detail for spl-token transfer on Solana devnet #2', async () => { // https://solscan.io/tx/3m6f1g2YK6jtbfVfuYsfDbhVzNAqozF8JJyjp1VuFDduecojqeCVK4htKnLTSk3qBwSqYUvgLpBTVpeLJRvNmeTg?cluster=devnet const amount = 0.00000005; const transactionInfo = await getTransactionInfoFromNetwork({ @@ -972,6 +972,25 @@ function getTransactionDetailTestCases() { assert.equal(transactionInfo.amount, amount); }); + it('should return transaction detail for RAY spl token transfer on Solana mainnet', async () => { + // https://solscan.io/tx/4ApdD7usYH5Cp7hsaWGKjnJW3mfyNpRw4S4NJbzwa2CQfnUkjY11sR2G1W3rvXmCzXwu3yNLz2CfkCHY5sQPdWzq + const amount = 0.005; + const transactionInfo = await getTransactionInfoFromNetwork({ + txHash: + '4ApdD7usYH5Cp7hsaWGKjnJW3mfyNpRw4S4NJbzwa2CQfnUkjY11sR2G1W3rvXmCzXwu3yNLz2CfkCHY5sQPdWzq', + symbol: 'RAY', + chainType: ChainType.SOLANA, + networkId: NETWORK_IDS.SOLANA_MAINNET, + fromAddress: 'FAMREy7d73N5jPdoKowQ4QFm6DKPWuYxZh6cwjNAbpkY', + toAddress: '6U29tmuvaGsTQqamf9Vt4o15JHTNq5RdJxoRW6NJxRdx', + timestamp: 1706429516, + amount, + }); + assert.isOk(transactionInfo); + assert.equal(transactionInfo.currency, 'RAY'); + assert.equal(transactionInfo.amount, amount); + }); + it('should return error when transaction time is newer than sent timestamp for spl-token transfer on Solana', async () => { // https://explorer.solana.com/tx/2tm14GVsDwXpMzxZzpEWyQnfzcUEv1DZQVQb6VdbsHcV8StoMbBtuQTkW1LJ8RhKKrAL18gbm181NgzuusiQfZ16?cluster=devnet diff --git a/src/services/chains/solana/transactionService.ts b/src/services/chains/solana/transactionService.ts index 4908928ff..ccb9e5921 100644 --- a/src/services/chains/solana/transactionService.ts +++ b/src/services/chains/solana/transactionService.ts @@ -108,12 +108,14 @@ async function getTransactionDetailForSplTokenTransfer( balance => balance.owner === params.toAddress && balance.mint === token?.address, ); - if (!data || !toAddressPostBalance || !toAddressPreBalance) { + if (!data || !toAddressPostBalance) { return null; } + + // toAddressBalance might be null if this is first time that destination wallet receives this token const amount = toAddressPostBalance.uiTokenAmount?.uiAmount - - toAddressPreBalance.uiTokenAmount?.uiAmount; + (toAddressPreBalance?.uiTokenAmount?.uiAmount || 0); const parsedData = data as ParsedInstruction; const txInfo = parsedData.parsed.info; diff --git a/src/services/cronJobs/importLostDonationsJob.ts b/src/services/cronJobs/importLostDonationsJob.ts index 7e550fe6f..388b11bab 100644 --- a/src/services/cronJobs/importLostDonationsJob.ts +++ b/src/services/cronJobs/importLostDonationsJob.ts @@ -32,6 +32,7 @@ abiDecoder.addABI(erc20ABI); const QF_ROUND_ID = config.get('LOST_DONATIONS_QF_ROUND'); const NETWORK_ID = config.get('LOST_DONATIONS_NETWORK_ID'); +const NATIVE_NETWORK_TOKEN = config.get('LOST_DONATIONS_NATIVE_NETWORK_TOKEN'); const cronJobTime = (config.get('IMPORT_LOST_DONATIONS_CRONJOB_EXPRESSION') as string) || @@ -140,7 +141,7 @@ export const importLostDonations = async () => { ) { // it's an eth transfer native token const nativeToken = await Token.createQueryBuilder('token') - .where(`token.id = 198`) + .where(`token.id = :token`, { token: NATIVE_NETWORK_TOKEN }) .getOne(); tokenInDB = nativeToken; @@ -213,6 +214,7 @@ export const importLostDonations = async () => { toWalletAddress: donationParams.to.toLowerCase(), transactionId: tx.toLowerCase(), projectId: project.id, + userId: dbUser.id, currency: donationParams.currency, tokenAddress: tokenInDB?.address, amount: donationParams.amount, @@ -238,6 +240,7 @@ export const importLostDonations = async () => { } catch (e) { logger.debug('Error saving donation for for tx: ', tx); logger.debug('Error saving donation: ', e); + continue; } await updateUserTotalDonated(dbUser.id); @@ -245,6 +248,7 @@ export const importLostDonations = async () => { await updateTotalDonationsOfProject(project.id); } catch (e) { logger.error('importLostDonations() error'); + continue; } } diff --git a/src/services/cronJobs/syncDonationsWithNetwork.ts b/src/services/cronJobs/syncDonationsWithNetwork.ts index da2850d86..7ff03b7aa 100644 --- a/src/services/cronJobs/syncDonationsWithNetwork.ts +++ b/src/services/cronJobs/syncDonationsWithNetwork.ts @@ -54,7 +54,9 @@ const addJobToCheckPendingDonationsWithNetwork = async () => { donationId: donation.id, }, { - // jobId: `verify-donation-id-${donation.id}`, + jobId: `verify-donation-id-${donation.id}`, + removeOnComplete: true, + removeOnFail: true, }, ); }); diff --git a/src/services/donationService.test.ts b/src/services/donationService.test.ts index 5f6c7b023..dde8ae31f 100644 --- a/src/services/donationService.test.ts +++ b/src/services/donationService.test.ts @@ -818,6 +818,70 @@ function fillOldStableCoinDonationsPriceTestCases() { expect(donation.valueUsd).to.gt(0); }); + it('should fill price for mpETH donation on the MAINNET network', async () => { + const token = 'mpETH'; + const amount = 2; + let donation = await saveDonationDirectlyToDb( + { + ...createDonationData(), + currency: token, + valueUsd: undefined, + valueEth: undefined, + amount, + }, + SEED_DATA.FIRST_USER.id, + SEED_DATA.FIRST_PROJECT.id, + ); + + const project = (await Project.findOne({ + where: { id: SEED_DATA.FIRST_PROJECT.id }, + })) as Project; + + await updateDonationPricesAndValues( + donation, + project, + null, + token, + CHAIN_ID.MAINNET, + amount, + ); + donation = (await findDonationById(donation.id))!; + expect(donation.valueUsd).to.gt(0); + expect(donation.priceUsd).to.below(donation.valueUsd); + }); + + it('should fill price for mpETH donation on the OPTIMISM network', async () => { + const token = 'mpETH'; + const amount = 2; + let donation = await saveDonationDirectlyToDb( + { + ...createDonationData(), + currency: token, + valueUsd: undefined, + valueEth: undefined, + amount, + }, + SEED_DATA.FIRST_USER.id, + SEED_DATA.FIRST_PROJECT.id, + ); + + const project = (await Project.findOne({ + where: { id: SEED_DATA.FIRST_PROJECT.id }, + })) as Project; + + await updateDonationPricesAndValues( + donation, + project, + null, + token, + CHAIN_ID.OPTIMISM, + amount, + ); + donation = (await findDonationById(donation.id))!; + expect(donation.valueUsd).to.gt(0); + expect(donation.priceUsd).to.below(donation.valueUsd); + }); + it('should fill price for Celo donation on the CELO Alfajores network', async () => { const token = 'CELO'; const amount = 100; diff --git a/src/services/donationService.ts b/src/services/donationService.ts index 491f55e13..c1e2c85a4 100644 --- a/src/services/donationService.ts +++ b/src/services/donationService.ts @@ -44,6 +44,7 @@ import { fetchSafeTransactionHash } from './safeServices'; import { ChainType } from '../types/network'; import { NETWORK_IDS } from '../provider'; import { getTransactionInfoFromNetwork } from './chains'; +import { fetchMpEthPrice } from './mpEthPriceService'; export const TRANSAK_COMPLETED_STATUS = 'COMPLETED'; @@ -65,6 +66,10 @@ export const updateDonationPricesAndValues = async ( if (token?.isStableCoin) { donation.priceUsd = 1; donation.valueUsd = Number(amount); + } else if (currency === 'mpETH') { + const mpEthPriceInUsd = await fetchMpEthPrice(); + donation.priceUsd = toFixNumber(mpEthPriceInUsd, 4); + donation.valueUsd = toFixNumber(donation.amount * mpEthPriceInUsd, 4); } else if (currency === 'GIV') { const { givPriceInUsd } = await fetchGivPrice(); donation.priceUsd = toFixNumber(givPriceInUsd, 4); diff --git a/src/services/mpEthPriceService.test.ts b/src/services/mpEthPriceService.test.ts new file mode 100644 index 000000000..12ed411bf --- /dev/null +++ b/src/services/mpEthPriceService.test.ts @@ -0,0 +1,12 @@ +import { assert, expect } from 'chai'; +import { fetchMpEthPrice } from './mpEthPriceService'; + +describe('fetchMpEthPrice test cases', fetchMpEthPriceTestCases); + +function fetchMpEthPriceTestCases() { + it('should fetch the price from velodrome subgraph for mpeth', async () => { + const mpEthPrice = await fetchMpEthPrice(); + assert.isOk(mpEthPrice); + expect(mpEthPrice).to.gt(0); + }); +} diff --git a/src/services/mpEthPriceService.ts b/src/services/mpEthPriceService.ts new file mode 100644 index 000000000..c3d695cf6 --- /dev/null +++ b/src/services/mpEthPriceService.ts @@ -0,0 +1,37 @@ +import { logger } from '../utils/logger'; +import Axios, { AxiosResponse } from 'axios'; +import axiosRetry from 'axios-retry'; + +const mpEthSubgraphUrl = process.env.MPETH_GRAPHQL_PRICES_URL as string; + +// Maximum timeout of axios. +const axiosTimeout = 20000; + +const query = { + query: ` + { + tokens(where:{id:"0x819845b60a192167ed1139040b4f8eca31834f27"}) { + id + name + symbol + decimals + lastPriceUSD + } + } + `, +}; + +export const fetchMpEthPrice = async () => { + try { + const result = await Axios.post(mpEthSubgraphUrl, query, { + headers: { + 'Content-Type': 'application/json', + }, + timeout: axiosTimeout, + }); + return Number(result.data.data.tokens[0].lastPriceUSD); + } catch (e) { + logger.error('fetching Giv Price fetchGivPrice() err', e); + throw e; + } +}; diff --git a/test/testUtils.ts b/test/testUtils.ts index 83b5a0435..d81e1ddb6 100644 --- a/test/testUtils.ts +++ b/test/testUtils.ts @@ -1265,6 +1265,13 @@ export const SEED_DATA = { decimals: 18, isStableCoin: true, }, + { + name: 'mpETH', + symbol: 'mpETH', + address: '0x48afbbd342f64ef8a9ab1c143719b63c2ad81710', + decimals: 18, + isStableCoin: true, + }, ], ropsten: [ { @@ -1329,6 +1336,13 @@ export const SEED_DATA = { decimals: 18, isStableCoin: true, }, + { + name: 'mpETH', + symbol: 'mpETH', + address: '0x819845b60a192167ed1139040b4f8eca31834f27', + decimals: 18, + isStableCoin: true, + }, ], etc: [ {