diff --git a/src/resolvers/draftDonationResolver.ts b/src/resolvers/draftDonationResolver.ts index 4ea66fe9e..08f313bc7 100644 --- a/src/resolvers/draftDonationResolver.ts +++ b/src/resolvers/draftDonationResolver.ts @@ -25,6 +25,7 @@ import { findRecurringDonationByProjectIdAndUserIdAndCurrency, } from '../repositories/recurringDonationRepository'; import { RecurringDonation } from '../entities/recurringDonation'; +import { checkTransactions } from '../services/cronJobs/checkQRTransactionJob'; import { findProjectById } from '../repositories/projectRepository'; const draftDonationEnabled = process.env.ENABLE_DRAFT_DONATION === 'true'; @@ -376,6 +377,16 @@ export class DraftDonationResolver { .where('draftDonation.id = :id', { id }) .getOne(); + if (!draftDonation) return null; + + if ( + draftDonation.expiresAt && + new Date(draftDonation.expiresAt).getTime < new Date().getTime + ) { + await DraftDonation.update({ id }, { status: 'failed' }); + draftDonation.status = 'failed'; + } + return draftDonation; } @@ -438,7 +449,7 @@ export class DraftDonationResolver { throw new Error(translationErrorMessagesKeys.DRAFT_DONATION_NOT_FOUND); } - await DraftDonation.update({ id }, { expiresAt }); + await DraftDonation.update({ id }, { expiresAt, status: 'pending' }); return { ...draftDonation, @@ -451,4 +462,32 @@ export class DraftDonationResolver { return null; } } + + @Query(_returns => DraftDonation, { nullable: true }) + async verifyQRDonationTransaction( + @Arg('id', _type => Int) id: number, + ): Promise { + try { + const draftDonation = await DraftDonation.createQueryBuilder( + 'draftDonation', + ) + .where('draftDonation.id = :id', { id }) + .getOne(); + + if (!draftDonation) return null; + + if (draftDonation.isQRDonation) { + await checkTransactions(draftDonation); + } + + return await DraftDonation.createQueryBuilder('draftDonation') + .where('draftDonation.id = :id', { id }) + .getOne(); + } catch (e) { + logger.error( + `Error in fetchDaftDonationWithUpdatedStatus - id: ${id} - error: ${e.message}`, + ); + return null; + } + } } diff --git a/src/services/chains/index.ts b/src/services/chains/index.ts index fb11ef0ef..acb23f2bc 100644 --- a/src/services/chains/index.ts +++ b/src/services/chains/index.ts @@ -1,6 +1,7 @@ import { ChainType } from '../../types/network'; import { getSolanaTransactionInfoFromNetwork } from './solana/transactionService'; import { getEvmTransactionInfoFromNetwork } from './evm/transactionService'; +import { getStellarTransactionInfoFromNetwork } from './stellar/transactionService'; import { i18n, translationErrorMessagesKeys } from '../../utils/errorMessages'; import { logger } from '../../utils/logger'; import { NETWORK_IDS } from '../../provider'; @@ -83,6 +84,10 @@ export async function getTransactionInfoFromNetwork( return getSolanaTransactionInfoFromNetwork(input); } + if (input.chainType === ChainType.STELLAR) { + return getStellarTransactionInfoFromNetwork(input); + } + // If chain is not Solana, it's EVM for sure return getEvmTransactionInfoFromNetwork(input); } diff --git a/src/services/chains/stellar/transactionService.ts b/src/services/chains/stellar/transactionService.ts new file mode 100644 index 000000000..c7bd62ff7 --- /dev/null +++ b/src/services/chains/stellar/transactionService.ts @@ -0,0 +1,63 @@ +import axios from 'axios'; +import { + NetworkTransactionInfo, + TransactionDetailInput, + validateTransactionWithInputData, +} from '../index'; +import { + i18n, + translationErrorMessagesKeys, +} from '../../../utils/errorMessages'; + +const STELLAR_HORIZON_API_URL = + process.env.STELLAR_HORIZON_API_URL || 'https://horizon.stellar.org'; + +const getStellarTransactionInfo = async ( + txHash: string, +): Promise => { + const NATIVE_STELLAR_ASSET_CODE = 'XLM'; + // Fetch transaction info from stellar network + + const response = await axios.get( + `${STELLAR_HORIZON_API_URL}/transactions/${txHash}/payments`, + ); + + const transaction = response.data._embedded.records[0]; + + if (!transaction) return null; + + // when a transaction is made to a newly created account, Stellar mark it as type 'create_account' + if (transaction.type === 'create_account') { + return { + hash: transaction.transaction_hash, + amount: Number(transaction.starting_balance), + from: transaction.source_account, + to: transaction.account, + currency: NATIVE_STELLAR_ASSET_CODE, + timestamp: transaction.created_at, + }; + } else if (transaction.type === 'payment') { + if (transaction.asset_type !== 'native') return null; + return { + hash: transaction.transaction_hash, + amount: Number(transaction.amount), + from: transaction.from, + to: transaction.to, + currency: NATIVE_STELLAR_ASSET_CODE, + timestamp: transaction.created_at, + }; + } else return null; +}; + +export async function getStellarTransactionInfoFromNetwork( + input: TransactionDetailInput, +): Promise { + const txData = await getStellarTransactionInfo(input.txHash); + if (!txData) { + throw new Error( + i18n.__(translationErrorMessagesKeys.TRANSACTION_NOT_FOUND), + ); + } + validateTransactionWithInputData(txData, input); + return txData; +} diff --git a/src/services/cronJobs/checkQRTransactionJob.ts b/src/services/cronJobs/checkQRTransactionJob.ts index cfe23edba..5e6bfa541 100644 --- a/src/services/cronJobs/checkQRTransactionJob.ts +++ b/src/services/cronJobs/checkQRTransactionJob.ts @@ -14,13 +14,14 @@ import { CoingeckoPriceAdapter } from '../../adapters/price/CoingeckoPriceAdapte import { findUserById } from '../../repositories/userRepository'; import { relatedActiveQfRoundForProject } from '../qfRoundService'; import { QfRound } from '../../entities/qfRound'; +import { syncDonationStatusWithBlockchainNetwork } from '../donationService'; const STELLAR_HORIZON_API = (config.get('STELLAR_HORIZON_API_URL') as string) || 'https://horizon.stellar.org'; const cronJobTime = (config.get('CHECK_QR_TRANSACTIONS_CRONJOB_EXPRESSION') as string) || - '0 */3 * * * *'; + '0 */1 * * * *'; async function getPendingDraftDonations() { return await DraftDonation.createQueryBuilder('draftDonation') @@ -41,10 +42,30 @@ const getToken = async ( }; // Check for transactions -async function checkTransactions(donation: DraftDonation): Promise { - const { toWalletAddress, amount, toWalletMemo } = donation; +export async function checkTransactions( + donation: DraftDonation, +): Promise { + const { toWalletAddress, amount, toWalletMemo, expiresAt, id } = donation; try { + if (!toWalletAddress || !amount) { + logger.debug(`Missing required fields for donation ID ${donation.id}`); + return; + } + + // Check if donation has expired + const now = new Date().getTime(); + const expiresAtDate = new Date(expiresAt!).getTime() + 1 * 60 * 1000; + + if (now > expiresAtDate) { + logger.debug(`Donation ID ${id} has expired. Updating status to expired`); + await updateDraftDonationStatus({ + donationId: id, + status: 'failed', + }); + return; + } + const response = await axios.get( `${STELLAR_HORIZON_API}/accounts/${toWalletAddress}/payments?limit=200&order=desc&join=transactions&include_failed=true`, ); @@ -54,13 +75,21 @@ async function checkTransactions(donation: DraftDonation): Promise { if (transactions.length === 0) return; for (const transaction of transactions) { - if ( - transaction.asset_type === 'native' && - transaction.type === 'payment' && - Number(transaction.amount) === amount && - transaction.to === toWalletAddress - ) { - if (toWalletMemo && transaction.transaction.memo !== toWalletMemo) { + const isMatchingTransaction = + (transaction.asset_type === 'native' && + transaction.type === 'payment' && + transaction.to === toWalletAddress && + Number(transaction.amount) === amount) || + (transaction.type === 'create_account' && + transaction.account === toWalletAddress && + Number(transaction.starting_balance) === amount); + + if (isMatchingTransaction) { + if ( + toWalletMemo && + transaction.type === 'payment' && + transaction.transaction.memo !== toWalletMemo + ) { logger.debug( `Transaction memo does not match donation memo for donation ID ${donation.id}`, ); @@ -121,7 +150,7 @@ async function checkTransactions(donation: DraftDonation): Promise { isTokenEligibleForGivback: token.isGivbackEligible, segmentNotified: false, toWalletAddress: donation.toWalletAddress, - donationAnonymous: !donation.userId, + donationAnonymous: false, transakId: '', token: donation.currency, valueUsd: donation.amount * tokenPrice, @@ -135,7 +164,7 @@ async function checkTransactions(donation: DraftDonation): Promise { if (!returnedDonation) { logger.debug( - `Error creating donation for donation ID ${donation.id}`, + `Error creating donation for draft donation ID ${donation.id}`, ); return; } @@ -148,6 +177,10 @@ async function checkTransactions(donation: DraftDonation): Promise { matchedDonationId: returnedDonation.id, }); + await syncDonationStatusWithBlockchainNetwork({ + donationId: returnedDonation.id, + }); + return; } }