From abd66dd5e4fffa31379f58569979a5231dab0f2f Mon Sep 17 00:00:00 2001 From: CarlosQ96 <92376054+CarlosQ96@users.noreply.github.com> Date: Tue, 17 Dec 2024 10:43:52 -0500 Subject: [PATCH] Release: SuperFluid Anchor Contract (#1895) * add includeUnlisted to FilterProjectQueryInputParams * return proper projects * add recipient address to streams when nonexistent (#1890) * Superfluid Base Support (#1893) * finish project endpoint for superfluid to read * add filters for network and tokens for recurringdonations * fix verification logic and emails for base network streams * Update src/resolvers/recurringDonationResolver.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * comment network test and add cbBTC to seeds --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: Cherik Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- migration/data/seedTokens.ts | 10 ++ src/provider.ts | 68 +++++++++++++ .../recurringDonationResolver.test.ts | 84 ++++++++++++++++ src/resolvers/recurringDonationResolver.ts | 95 +++++++++++++++++++ src/services/chains/index.test.ts | 34 +++---- .../checkUserSuperTokenBalancesQueue.ts | 23 ++++- src/services/recurringDonationService.ts | 14 ++- 7 files changed, 301 insertions(+), 27 deletions(-) diff --git a/migration/data/seedTokens.ts b/migration/data/seedTokens.ts index c01b513ea..4eeeb597b 100644 --- a/migration/data/seedTokens.ts +++ b/migration/data/seedTokens.ts @@ -1695,6 +1695,16 @@ const seedTokens: ITokenData[] = [ coingeckoId: 'degen-base', isGivbackEligible: false, }, + // cbBTC - https://basescan.org/token/0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf + { + name: 'Coinbase Wrapped BTC', + symbol: 'cbBTC', + address: '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf', + decimals: 8, + networkId: NETWORK_IDS.BASE_MAINNET, + coingeckoId: 'coinbase-wrapped-btc', + isGivbackEligible: false, + }, // Osaka Protocol - https://basescan.org/token/0xbFd5206962267c7b4b4A8B3D76AC2E1b2A5c4d5e { name: 'Osaka Protocol', diff --git a/src/provider.ts b/src/provider.ts index 85a9c66aa..95d4c8bf5 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -42,8 +42,76 @@ export const superTokensToToken = { DAIx: 'DAI', OPx: 'OP', GIVx: 'GIV', + DEGENx: 'DEGEN', + cbBTCx: 'cbBTC', }; +export const superTokensBase = [ + { + underlyingToken: { + decimals: 6, + id: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + name: 'USD Coin', + symbol: 'USDC', + coingeckoId: 'usd-coin', + }, + decimals: 18, + id: '0xD04383398dD2426297da660F9CCA3d439AF9ce1b', + name: 'Super USD Coin', + symbol: 'USDCx', + isSuperToken: true, + coingeckoId: 'usd-coin', + }, + { + underlyingToken: { + name: 'Ethereum', + symbol: 'ETH', + decimals: 18, + id: '0x0000000000000000000000000000000000000000', + }, + decimals: 18, + id: '0x46fd5cfB4c12D87acD3a13e92BAa53240C661D93', + name: 'Super ETH', + symbol: 'ETHx', + }, + { + underlyingToken: { + decimals: 8, + id: '0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf', + name: 'Coinbase Wrapped BTC', + symbol: 'cbBTC', + }, + decimals: 18, + id: '0xDFd428908909CB5E24F5e79E6aD6BDE10bdf2327', + name: 'Super Coinbase Wrapped BTC', + symbol: 'cbBTCx', + }, + { + underlyingToken: { + decimals: 18, + id: '0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb', + name: 'DAI Stablecoin', + symbol: 'DAI', + }, + decimals: 18, + id: '0x708169c8C87563Ce904E0a7F3BFC1F3b0b767f41', + name: 'Super DAI Stablecoin', + symbol: 'DAIx', + }, + { + underlyingToken: { + decimals: 18, + id: '0x4ed4E862860beD51a9570b96d89aF5E1B0Efefed', + name: 'Degen', + symbol: 'DEGEN', + }, + decimals: 18, + id: '0x1efF3Dd78F4A14aBfa9Fa66579bD3Ce9E1B30529', + name: 'Super Degen', + symbol: 'DEGENx', + }, +]; + export const superTokens = [ { underlyingToken: { diff --git a/src/resolvers/recurringDonationResolver.test.ts b/src/resolvers/recurringDonationResolver.test.ts index e89a92edc..ed0d76537 100644 --- a/src/resolvers/recurringDonationResolver.test.ts +++ b/src/resolvers/recurringDonationResolver.test.ts @@ -29,6 +29,11 @@ import { QfRound } from '../entities/qfRound'; import { generateRandomString } from '../utils/utils'; import { ORGANIZATION_LABELS } from '../entities/organization'; +describe( + 'recurringDonationEligibleProjects test cases', + recurringDonationEligibleProjectsTestCases, +); + describe( 'createRecurringDonation test cases', createRecurringDonationTestCases, @@ -63,6 +68,85 @@ describe( recurringDonationsByProjectDateTestCases, ); +function recurringDonationEligibleProjectsTestCases() { + it('should return eligible projects with their anchor contracts', async () => { + const projectOwner = await saveUserDirectlyToDb( + generateRandomEtheriumAddress(), + ); + + const project = await saveProjectDirectlyToDb( + { + ...createProjectData(), + isGivbackEligible: true, + }, + projectOwner, + ); + + const anchorContractAddress = await addNewAnchorAddress({ + project, + owner: projectOwner, + creator: projectOwner, + address: generateRandomEtheriumAddress(), + networkId: NETWORK_IDS.OPTIMISTIC, + txHash: generateRandomEvmTxHash(), + }); + + const anchorContractAddressBASE = await addNewAnchorAddress({ + project, + owner: projectOwner, + creator: projectOwner, + address: generateRandomEtheriumAddress(), + networkId: NETWORK_IDS.BASE_MAINNET, + txHash: generateRandomEvmTxHash(), + }); + + const result = await axios.post(graphqlUrl, { + query: ` + query { + recurringDonationEligibleProjects { + id + slug + title + anchorContracts { + address + networkId + isActive + } + } + } + `, + }); + + assert.isNotNull(result.data.data.recurringDonationEligibleProjects); + const foundProject = + result.data.data.recurringDonationEligibleProjects.find( + p => p.id === project.id, + ); + + assert.isNotNull( + foundProject, + 'Project should be found in eligible projects', + ); + assert.equal(foundProject.slug, project.slug); + assert.equal(foundProject.title, project.title); + assert.equal(foundProject.anchorContracts.length, 2); + + // Assert Optimistic anchor contract + const optimisticContract = foundProject.anchorContracts.find( + contract => contract.networkId === NETWORK_IDS.OPTIMISTIC, + ); + assert.isNotNull(optimisticContract); + assert.equal(optimisticContract.address, anchorContractAddress.address); + + // Assert BASE anchor contract + const baseContract = foundProject.anchorContracts.find( + contract => contract.networkId === NETWORK_IDS.BASE_MAINNET, + ); + assert.isNotNull(baseContract); + assert.equal(baseContract.address, anchorContractAddressBASE.address); + }); +} + function createRecurringDonationTestCases() { it('should create recurringDonation successfully', async () => { const projectOwner = await saveUserDirectlyToDb( diff --git a/src/resolvers/recurringDonationResolver.ts b/src/resolvers/recurringDonationResolver.ts index f3c8318c4..2f1bd6dc3 100644 --- a/src/resolvers/recurringDonationResolver.ts +++ b/src/resolvers/recurringDonationResolver.ts @@ -56,6 +56,23 @@ import { } from '../services/recurringDonationService'; import { markDraftRecurringDonationStatusMatched } from '../repositories/draftRecurringDonationRepository'; import { ResourcePerDateRange } from './donationResolver'; +import { Project } from '../entities/project'; + +@ObjectType() +class RecurringDonationEligibleProject { + @Field() + id: number; + + @Field() + slug: string; + + @Field() + title: string; + + @Field(_type => [AnchorContractAddress]) + anchorContracts: AnchorContractAddress[]; +} + @InputType() class RecurringDonationSortBy { @Field(_type => RecurringDonationSortField) @@ -167,6 +184,9 @@ class UserRecurringDonationsArgs { @Field(_type => [String], { nullable: true, defaultValue: [] }) filteredTokens: string[]; + + @Field(_type => Int, { nullable: true }) + networkId?: number; } @ObjectType() @@ -411,6 +431,12 @@ export class RecurringDonationResolver { }, }) orderBy: RecurringDonationSortBy, + @Arg('networkId', _type => Int, { nullable: true }) networkId: number, + @Arg('filteredTokens', _type => [String], { + nullable: true, + defaultValue: [], + }) + filteredTokens: string[], ) { const project = await findProjectById(projectId); if (!project) { @@ -464,6 +490,12 @@ export class RecurringDonationResolver { }); } + if (filteredTokens && filteredTokens.length > 0) { + query.andWhere(`recurringDonation.currency IN (:...filteredTokens)`, { + filteredTokens, + }); + } + if (searchTerm) { query.andWhere( new Brackets(qb => { @@ -491,6 +523,13 @@ export class RecurringDonationResolver { }), ); } + + if (networkId) { + query.andWhere(`recurringDonation.networkId = :networkId`, { + networkId, + }); + } + const [recurringDonations, donationsCount] = await query .take(take) .skip(skip) @@ -559,6 +598,7 @@ export class RecurringDonationResolver { includeArchived, finishStatus, filteredTokens, + networkId, }: UserRecurringDonationsArgs, @Ctx() ctx: ApolloContext, ) { @@ -622,6 +662,12 @@ export class RecurringDonationResolver { }); } + if (networkId) { + query.andWhere(`recurringDonation.networkId = :networkId`, { + networkId, + }); + } + const [recurringDonations, totalCount] = await query .take(take) .skip(skip) @@ -708,6 +754,55 @@ export class RecurringDonationResolver { } } + @Query(_return => [RecurringDonationEligibleProject], { nullable: true }) + async recurringDonationEligibleProjects( + @Arg('networkId', { nullable: true }) networkId?: number, + @Arg('page', _type => Int, { nullable: true, defaultValue: 1 }) + page: number = 1, + @Arg('limit', _type => Int, { nullable: true, defaultValue: 50 }) + limit: number = 50, + ): Promise { + try { + const offset = (page - 1) * limit; + + const queryParams = [offset, limit]; + let networkFilter = ''; + let paramIndex = 3; + if (networkId) { + networkFilter = `AND anchor_contract_address."networkId" = $${paramIndex}`; + queryParams.push(networkId); + paramIndex++; + } + + return await Project.getRepository().query( + ` + SELECT + project.id, + project.slug, + project.title, + array_agg(json_build_object( + 'address', anchor_contract_address.address, + 'networkId', anchor_contract_address."networkId", + 'isActive', anchor_contract_address."isActive" + )) as "anchorContracts" + FROM project + INNER JOIN anchor_contract_address ON project.id = anchor_contract_address."projectId" + WHERE project."isGivbackEligible" = true + AND anchor_contract_address."isActive" = true + ${networkFilter} + GROUP BY project.id, project.slug, project.title + OFFSET $1 + LIMIT $2 + `, + queryParams, + ); + } catch (error) { + throw new Error( + `Error fetching eligible projects for donation: ${error}`, + ); + } + } + @Query(_returns => RDRessourcePerDateRange, { nullable: true }) async recurringDonationsCountPerDate( // fromDate and toDate should be in this format YYYYMMDD HH:mm:ss diff --git a/src/services/chains/index.test.ts b/src/services/chains/index.test.ts index d17889ea0..e28cce26f 100644 --- a/src/services/chains/index.test.ts +++ b/src/services/chains/index.test.ts @@ -597,24 +597,24 @@ function getTransactionDetailTestCases() { // assert.equal(transactionInfo.amount, amount); // }); - it('should return transaction detail for normal transfer on ZKEVM Cardano', async () => { - // https://cardona-zkevm.polygonscan.com/tx/0x5cadef5d2ee803ff78718deb926964c14d83575ccebf477d48b0c3c768a4152a + // it('should return transaction detail for normal transfer on ZKEVM Cardano', async () => { + // // https://cardona-zkevm.polygonscan.com/tx/0x5cadef5d2ee803ff78718deb926964c14d83575ccebf477d48b0c3c768a4152a - const amount = 0.00001; - const transactionInfo = await getTransactionInfoFromNetwork({ - txHash: - '0x5cadef5d2ee803ff78718deb926964c14d83575ccebf477d48b0c3c768a4152a', - symbol: 'ETH', - networkId: NETWORK_IDS.ZKEVM_CARDONA, - fromAddress: '0x9AF3049dD15616Fd627A35563B5282bEA5C32E20', - toAddress: '0x417a7BA2d8d0060ae6c54fd098590DB854B9C1d5', - amount, - timestamp: 1718267581, - }); - assert.isOk(transactionInfo); - assert.equal(transactionInfo.currency, 'ETH'); - assert.equal(transactionInfo.amount, amount); - }); + // const amount = 0.00001; + // const transactionInfo = await getTransactionInfoFromNetwork({ + // txHash: + // '0x5cadef5d2ee803ff78718deb926964c14d83575ccebf477d48b0c3c768a4152a', + // symbol: 'ETH', + // networkId: NETWORK_IDS.ZKEVM_CARDONA, + // fromAddress: '0x9AF3049dD15616Fd627A35563B5282bEA5C32E20', + // toAddress: '0x417a7BA2d8d0060ae6c54fd098590DB854B9C1d5', + // amount, + // timestamp: 1718267581, + // }); + // assert.isOk(transactionInfo); + // assert.equal(transactionInfo.currency, 'ETH'); + // assert.equal(transactionInfo.amount, amount); + // }); it('should return transaction detail for OP token transfer on optimistic', async () => { // https://optimistic.etherscan.io/tx/0xf11be189d967831bb8a76656882eeeac944a799bd222acbd556f2156fdc02db4 diff --git a/src/services/cronJobs/checkUserSuperTokenBalancesQueue.ts b/src/services/cronJobs/checkUserSuperTokenBalancesQueue.ts index 567ad9671..c034bf91d 100644 --- a/src/services/cronJobs/checkUserSuperTokenBalancesQueue.ts +++ b/src/services/cronJobs/checkUserSuperTokenBalancesQueue.ts @@ -13,7 +13,12 @@ import { findRecurringDonationById, } from '../../repositories/recurringDonationRepository'; import { getCurrentDateFormatted } from '../../utils/utils'; -import { getNetworkNameById, superTokens } from '../../provider'; +import { + getNetworkNameById, + NETWORK_IDS, + superTokens, + superTokensBase, +} from '../../provider'; import { NOTIFICATIONS_EVENT_NAMES } from '../../analytics/analytics'; const runCheckUserSuperTokenBalancesQueue = new Bull( @@ -112,10 +117,24 @@ export const validateDonorSuperTokenBalance = async ( if (!accountBalances || accountBalances.length === 0) return; + let superTokenDataArray = superTokens; + + if ( + recurringDonation.networkId === NETWORK_IDS.BASE_SEPOLIA || + recurringDonation.networkId === NETWORK_IDS.BASE_MAINNET + ) { + superTokenDataArray = superTokensBase; + } else if ( + recurringDonation.networkId === NETWORK_IDS.OPTIMISM_SEPOLIA || + recurringDonation.networkId === NETWORK_IDS.OPTIMISTIC + ) { + superTokenDataArray = superTokens; + } + for (const tokenBalance of accountBalances) { const { maybeCriticalAtTimestamp, token } = tokenBalance; if (!user!.email) continue; - const tokenSymbol = superTokens.find(t => t.id === token.id) + const tokenSymbol = superTokenDataArray.find(t => t.id === token.id) ?.underlyingToken.symbol; // We shouldn't notify the user if the token is not the same as the recurring donation if (tokenSymbol !== recurringDonation.currency) continue; diff --git a/src/services/recurringDonationService.ts b/src/services/recurringDonationService.ts index cb1f286e3..3a4c3ff06 100644 --- a/src/services/recurringDonationService.ts +++ b/src/services/recurringDonationService.ts @@ -33,7 +33,6 @@ import { } from './donationService'; import { calculateGivbackFactor } from './givbackService'; import { updateUserTotalDonated, updateUserTotalReceived } from './userService'; -import config from '../config'; import { User } from '../entities/user'; import { NOTIFICATIONS_EVENT_NAMES } from '../analytics/analytics'; import { relatedActiveQfRoundForProject } from './qfRoundService'; @@ -122,18 +121,17 @@ export const createRelatedDonationsToStream = async ( }); } } + let networkId: number = recurringDonation.networkId; + + if (networkId === NETWORK_IDS.BASE_SEPOLIA) { + networkId = NETWORK_IDS.BASE_MAINNET; + } + // create donation if any virtual period is missing if (uniquePeriods.length === 0) return; for (const streamPeriod of uniquePeriods) { try { - const environment = config.get('ENVIRONMENT') as string; - - const networkId: number = - environment !== 'production' - ? NETWORK_IDS.OPTIMISM_SEPOLIA - : NETWORK_IDS.OPTIMISTIC; - const symbolCurrency = recurringDonation.currency.includes('x') ? superTokensToToken[recurringDonation.currency] : recurringDonation.currency;