diff --git a/config/example.env b/config/example.env index ad5426562..8ced2d861 100644 --- a/config/example.env +++ b/config/example.env @@ -27,7 +27,7 @@ BCRYPT_SALT=$2b$10$44gNUOnBXavOBMPOqzd48e XDAI_NODE_HTTP_URL=https://xxxxxx.xdai.quiknode.pro/ ETHERSCAN_MAINNET_API_URL=https://api.etherscan.io/api ETHERSCAN_ROPSTEN_API_URL=https://api-ropsten.etherscan.io/api -ETHERSCAN_GOERLI_API_URL=https://api-goerli.etherscan.io/api +ETHERSCAN_SEPOLIA_API_URL=https://api-sepolia.etherscan.io/api POLYGON_SCAN_API_URL=https://api.polygonscan.com/api POLYGON_SCAN_API_KEY=0000000000000000000000000000000000 OPTIMISTIC_SCAN_API_URL=https://api-optimistic.etherscan.io/api @@ -321,4 +321,8 @@ ZKEVM_MAINNET_NODE_HTTP_URL= # ZKEVM CARDONA we should fill it as Infura doesnt support polygon zkevm ZKEVM_CARDONA_NODE_HTTP_URL= +# STELLAR +STELLAR_HORIZON_API_URL=https://horizon.stellar.org +STELLAR_SCAN_API_URL=https://stellar.expert/explorer/public + ENDAOMENT_ADMIN_WALLET_ADDRESS=0xfE3524e04E4e564F9935D34bB5e80c5CaB07F5b4 diff --git a/config/test.env b/config/test.env index d67d2171d..81a76ad6e 100644 --- a/config/test.env +++ b/config/test.env @@ -37,7 +37,7 @@ MAINNET_NODE_WS_URL=xxx INFURA_ID=xxx ETHERSCAN_MAINNET_API_URL=https://api.etherscan.io/api ETHERSCAN_ROPSTEN_API_URL=https://api-ropsten.etherscan.io/api -ETHERSCAN_GOERLI_API_URL=https://api-goerli.etherscan.io/api +ETHERSCAN_SEPOLIA_API_URL=https://api-sepolia.etherscan.io/api POLYGON_SCAN_API_URL=https://api.polygonscan.com/api OPTIMISTIC_SCAN_API_URL=https://api-optimistic.etherscan.io/api OPTIMISTIC_SEPOLIA_SCAN_API_URL=https://api-sepolia-optimistic.etherscan.io/api @@ -252,4 +252,8 @@ ZKEVM_MAINNET_NODE_HTTP_URL=https://polygon-zkevm.drpc.org # ZKEVM CARDONA we should fill it as Infura doesnt support polygon zkevm, I found this rpc link from https://chainlist.org/chain/2442 ZKEVM_CARDONA_NODE_HTTP_URL=https://rpc.cardona.zkevm-rpc.com +# STELLAR +STELLAR_HORIZON_API_URL=https://horizon.stellar.org +STELLAR_SCAN_API_URL=https://stellar.expert/explorer/public + ENDAOMENT_ADMIN_WALLET_ADDRESS=0xfE3524e04E4e564F9935D34bB5e80c5CaB07F5b4 diff --git a/migration/1722379846122-AddMemoToProjectAddressFields.ts b/migration/1722379846122-AddMemoToProjectAddressFields.ts new file mode 100644 index 000000000..15d2867f7 --- /dev/null +++ b/migration/1722379846122-AddMemoToProjectAddressFields.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddMemoToProjectAddressFields1722379846122 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "project_address" ADD COLUMN IF NOT EXISTS "memo" VARCHAR`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "project_address" DROP COLUMN IF EXISTS "memo"`, + ); + } +} diff --git a/migration/1722475689162-AddStellarTokens.ts b/migration/1722475689162-AddStellarTokens.ts new file mode 100644 index 000000000..c30643780 --- /dev/null +++ b/migration/1722475689162-AddStellarTokens.ts @@ -0,0 +1,69 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { Token } from '../src/entities/token'; +import seedTokens from './data/seedTokens'; +import { NETWORK_IDS } from '../src/provider'; + +export class AddStellarTokens1722475689162 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const networkId = NETWORK_IDS.STELLAR_MAINNET; + + //add isQR code to token + await queryRunner.query( + `ALTER TABLE token ADD COLUMN IF NOT EXISTS "isQR" BOOLEAN DEFAULT FALSE NOT NULL`, + ); + + await queryRunner.manager.save( + Token, + seedTokens + .filter(token => token.networkId === networkId) + .map(token => { + const t = { + ...token, + }; + t.address = t.address?.toUpperCase(); + return t; + }), + ); + const tokens = await queryRunner.query(` + SELECT * FROM token + WHERE "networkId" = ${networkId} + `); + 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) { + // Add all Stellar tokens to Giveth organization + 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 networkId = NETWORK_IDS.STELLAR_MAINNET; + + await queryRunner.query(`ALTER TABLE token DROP COLUMN IF EXISTS "isQR"`); + + const tokens = await queryRunner.query(` + SELECT * FROM token + WHERE "networkId" = ${networkId} + `); + + for (const token of tokens) { + await queryRunner.query( + `DELETE FROM organization_tokens_token WHERE "tokenId" = ${token.id}`, + ); + } + await queryRunner.query( + `DELETE FROM token WHERE "networkId" = ${networkId}`, + ); + } +} diff --git a/migration/1722800845343-AddDraftDonationQRFields.ts b/migration/1722800845343-AddDraftDonationQRFields.ts new file mode 100644 index 000000000..1791b5e48 --- /dev/null +++ b/migration/1722800845343-AddDraftDonationQRFields.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDraftDonationQRFields1722800845343 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE draft_donation + ADD COLUMN IF NOT EXISTS "toWalletMemo" VARCHAR NULL, + ADD COLUMN IF NOT EXISTS "qrCodeDataUrl" TEXT NULL, + ADD COLUMN IF NOT EXISTS "isQRDonation" BOOLEAN DEFAULT FALSE; + `); + + // update enum draft_donation_chaintype_enum (add 'STELLAR'); + await queryRunner.query( + `ALTER TYPE public.draft_donation_chaintype_enum ADD VALUE IF NOT EXISTS 'STELLAR';`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE draft_donation + DROP COLUMN IF EXISTS "toWalletMemo", + DROP COLUMN IF EXISTS "qrCodeDataUrl", + DROP COLUMN IF EXISTS "isQRDonation"; + `); + + // update enum draft_donation_chaintype_enum (remove 'STELLAR'); + await queryRunner.query(` + DELETE FROM pg_enum + WHERE enumlabel = 'STELLAR' + AND enumtypid = ( + SELECT oid + FROM pg_type + WHERE typname = 'draft_donation_chaintype_enum' + ); + `); + } +} diff --git a/migration/1722860378721-UpdateDraftDonationIndex.ts b/migration/1722860378721-UpdateDraftDonationIndex.ts new file mode 100644 index 000000000..31605e625 --- /dev/null +++ b/migration/1722860378721-UpdateDraftDonationIndex.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateDraftDonationIndex1722860378721 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_af180374473ea402e7595196a6"`); + await queryRunner.query(` + 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 AND "isQRDonation" = false; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_af180374473ea402e7595196a6"`); + await queryRunner.query(` + 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; + `); + } +} diff --git a/migration/1722963339892-UpdateDonationIndex.ts b/migration/1722963339892-UpdateDonationIndex.ts new file mode 100644 index 000000000..70511da62 --- /dev/null +++ b/migration/1722963339892-UpdateDonationIndex.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UpdateDonationIndex1722963339892 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // update donation table + await queryRunner.query(` + ALTER TABLE "donation" + ADD COLUMN IF NOT EXISTS "isQRDonation" boolean DEFAULT false, + ADD COLUMN IF NOT EXISTS "toWalletMemo" text; + `); + + await queryRunner.query(`DROP INDEX "idx_donation_project_user"`); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_donation_project_user ON donation("projectId", "userId", "valueUsd") WHERE "status" = 'verified' AND "recurringDonationId" IS NULL AND "isQRDonation" = false AND "userId" IS NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "idx_donation_project_user"`); + await queryRunner.query( + `CREATE INDEX IF NOT EXISTS idx_donation_project_user ON donation("projectId", "userId", "valueUsd") WHERE "status" = 'verified' AND "recurringDonationId" IS NULL`, + ); + + await queryRunner.query(` + ALTER TABLE "donation" + DROP COLUMN IF EXISTS "isQRDonation", + DROP COLUMN IF EXISTS "toWalletMemo"; + `); + } +} diff --git a/migration/1723025859680-AddExpirationDateToDraftDonation.ts b/migration/1723025859680-AddExpirationDateToDraftDonation.ts new file mode 100644 index 000000000..1f8913236 --- /dev/null +++ b/migration/1723025859680-AddExpirationDateToDraftDonation.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddExpirationDateToDraftDonation1723025859680 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE draft_donation ADD COLUMN IF NOT EXISTS "expiresAt" TIMESTAMP`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE draft_donation DROP COLUMN IF EXISTS "expiresAt"`, + ); + } +} diff --git a/migration/1724166731604-addSepoliaToken.ts b/migration/1724166731604-addSepoliaToken.ts new file mode 100644 index 000000000..bb15e94c8 --- /dev/null +++ b/migration/1724166731604-addSepoliaToken.ts @@ -0,0 +1,65 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { Token } from '../src/entities/token'; +import { NETWORK_IDS } from '../src/provider'; +import seedTokens from './data/seedTokens'; +import config from '../src/config'; + +export class AddSepoliaToken1724166731604 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + const environment = config.get('ENVIRONMENT') as string; + // We don't add sepolia tokens in production ENV + if (environment === 'production') return; + + await queryRunner.manager.save( + Token, + seedTokens + .filter(token => token.networkId === NETWORK_IDS.SEPOLIA) + .map(token => { + const t = { + ...token, + }; + t.address = t.address?.toLowerCase(); + delete t.chainType; + return t; + }), + ); + + const tokens = await queryRunner.query(` + SELECT * FROM token + WHERE "networkId" = ${NETWORK_IDS.SEPOLIA} + `); + + 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}) + ;`); + } + } + + async down(queryRunner: QueryRunner): Promise { + const environment = config.get('ENVIRONMENT') as string; + // We don't add sepolia tokens in production ENV + if (environment === 'production') return; + await queryRunner.query(` + DELETE FROM organization_tokens_token + WHERE "tokenId" IN ( + SELECT id FROM token WHERE "networkId" = ${NETWORK_IDS.SEPOLIA} + ); + `); + + await queryRunner.query(` + DELETE FROM token WHERE "networkId" = ${NETWORK_IDS.SEPOLIA}; + `); + } +} diff --git a/migration/1724168597216-ChangeProjectAddressToSepolia.ts b/migration/1724168597216-ChangeProjectAddressToSepolia.ts new file mode 100644 index 000000000..2b046e890 --- /dev/null +++ b/migration/1724168597216-ChangeProjectAddressToSepolia.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ChangeProjectAddressToSepolia1724168597216 + implements MigrationInterface +{ + async up(queryRunner: QueryRunner): Promise { + const projectAddressTableExists = + await queryRunner.hasTable('project_address'); + if (!projectAddressTableExists) { + // eslint-disable-next-line no-console + console.log('The project_address table doesn’t exist'); + return; + } + await queryRunner.query(` + UPDATE project_address + SET "networkId" = 11155111 + WHERE "networkId" = 5 + `); + } + + async down(queryRunner: QueryRunner): Promise { + const projectAddressTableExists = + await queryRunner.hasTable('project_address'); + if (!projectAddressTableExists) { + // eslint-disable-next-line no-console + console.log('The project_address table doesn’t exist'); + return; + } + await queryRunner.query(` + UPDATE project_address + SET "networkId" = 5 + WHERE "networkId" = 11155111 + `); + } +} diff --git a/migration/data/seedTokens.ts b/migration/data/seedTokens.ts index 5e91d3e2f..c01b513ea 100644 --- a/migration/data/seedTokens.ts +++ b/migration/data/seedTokens.ts @@ -14,6 +14,7 @@ interface ITokenData { chainType?: ChainType; coingeckoId?: string; isStableCoin?: boolean; + isQR?: boolean; } const seedTokens: ITokenData[] = [ @@ -1022,27 +1023,20 @@ const seedTokens: ITokenData[] = [ networkId: 100, }, - // Goerli tokens + // Sepolia tokens { name: 'Ethereum native token', symbol: 'ETH', address: '0x0000000000000000000000000000000000000000', decimals: 18, - networkId: 5, + networkId: 11155111, }, { - address: '0xdc31Ee1784292379Fbb2964b3B9C4124D8F89C60', - symbol: 'DAI', - name: 'DAI Goerli', - decimals: 18, - networkId: 5, - }, - { - address: '0xA2470F25bb8b53Bd3924C7AC0C68d32BF2aBd5be', - symbol: 'DRGIV3', - name: 'GIV test', + address: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + symbol: 'WETH', + name: 'Wrapped Ether', decimals: 18, - networkId: 5, + networkId: 11155111, }, // POLYGON tokens @@ -2076,6 +2070,18 @@ const seedTokens: ITokenData[] = [ isGivbackEligible: false, networkId: NETWORK_IDS.ZKEVM_MAINNET, }, + + // Stellar Mainnet + { + name: 'Stellar Lumens', + symbol: 'XLM', + decimals: 7, + address: 'CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA', + coingeckoId: 'stellar', + networkId: NETWORK_IDS.STELLAR_MAINNET, + chainType: ChainType.STELLAR, + isQR: true, + }, ]; export default seedTokens; diff --git a/package-lock.json b/package-lock.json index 885c9b9b4..a3a11ed57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "giveth-graphql-api", - "version": "1.24.3", + "version": "1.25.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "giveth-graphql-api", - "version": "1.24.3", + "version": "1.25.3", "hasInstallScript": true, "license": "ISC", "dependencies": { @@ -22,6 +22,7 @@ "@sentry/node": "6.16.1", "@sentry/tracing": "^6.2.0", "@solana/web3.js": "^1.87.7", + "@stellar/stellar-sdk": "^12.2.0", "@uniswap/sdk": "^3.0.3", "abi-decoder": "^2.4.0", "adminjs": "6.8.3", @@ -3196,6 +3197,26 @@ "ws": "7.4.6" } }, + "node_modules/@ethersproject/providers/node_modules/ws": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", + "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@ethersproject/random": { "version": "5.7.0", "resolved": "https://registry.npmjs.org/@ethersproject/random/-/random-5.7.0.tgz", @@ -5527,6 +5548,64 @@ "resolved": "https://registry.npmjs.org/@stablelib/wipe/-/wipe-1.0.1.tgz", "integrity": "sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==" }, + "node_modules/@stellar/js-xdr": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", + "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==" + }, + "node_modules/@stellar/stellar-base": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-12.1.0.tgz", + "integrity": "sha512-pWwn+XWP5NotmIteZNuJzHeNn9DYSqH3lsYbtFUoSYy1QegzZdi9D8dK6fJ2fpBAnf/rcDjHgHOw3gtHaQFVbg==", + "dependencies": { + "@stellar/js-xdr": "^3.1.2", + "base32.js": "^0.1.0", + "bignumber.js": "^9.1.2", + "buffer": "^6.0.3", + "sha.js": "^2.3.6", + "tweetnacl": "^1.0.3" + }, + "optionalDependencies": { + "sodium-native": "^4.1.1" + } + }, + "node_modules/@stellar/stellar-base/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@stellar/stellar-sdk": { + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-12.2.0.tgz", + "integrity": "sha512-Wy5sDOqb5JvAC76f4sQIV6Pe3JNyZb0PuyVNjwt3/uWsjtxRkFk6s2yTHTefBLWoR+mKxDjO7QfzhycF1v8FXQ==", + "dependencies": { + "@stellar/stellar-base": "^12.1.0", + "axios": "^1.7.2", + "bignumber.js": "^9.1.2", + "eventsource": "^2.0.2", + "randombytes": "^2.1.0", + "toml": "^3.0.0", + "urijs": "^1.19.1" + } + }, "node_modules/@styled-system/background": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@styled-system/background/-/background-5.1.2.tgz", @@ -7883,11 +7962,11 @@ "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==" }, "node_modules/axios": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", - "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", "dependencies": { - "follow-redirects": "^1.15.4", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -7996,6 +8075,14 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/base32.js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", + "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -8098,9 +8185,9 @@ } }, "node_modules/bignumber.js": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.1.tgz", - "integrity": "sha512-pHm4LsMJ6lzgNGVfZHjMoO8sdoRhOzOH4MLmY65Jg70bpxCKu5iOHNJyfF6OyvYw7t8Fpf35RuzUyqnQsj8Vig==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", + "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==", "engines": { "node": "*" } @@ -10810,6 +10897,14 @@ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/evp_bytestokey": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", @@ -12492,6 +12587,27 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "peer": true }, + "node_modules/hardhat/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "peer": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/has-bigints": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", @@ -13756,6 +13872,26 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, + "node_modules/jayson/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/jest-worker": { "version": "26.6.2", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", @@ -15534,9 +15670,9 @@ } }, "node_modules/node-gyp-build": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.5.0.tgz", - "integrity": "sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.1.tgz", + "integrity": "sha512-OSs33Z9yWr148JZcbZd5WiAXhh/n9z8TxQcdMhIOlpN9AhWpLfvVFO73+m77bBABQMaY9XSvIa+qk0jlI7Gcaw==", "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", @@ -17054,6 +17190,26 @@ "node": ">=8" } }, + "node_modules/puppeteer/node_modules/ws": { + "version": "7.4.6", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", + "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/qs": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", @@ -17849,26 +18005,6 @@ "utf-8-validate": "^5.0.2" } }, - "node_modules/rpc-websockets/node_modules/ws": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.15.0.tgz", - "integrity": "sha512-H/Z3H55mrcrgjFwI+5jKavgXvwQLtfPCUEp6pi35VhoB0pfcHnSoyuTzkBEZpzq49g1193CUEwIvmsjcotenYw==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -18411,6 +18547,16 @@ "resolved": "https://registry.npmjs.org/ip/-/ip-2.0.1.tgz", "integrity": "sha512-lJUL9imLTNi1ZfXT+DU6rBBdbiKGBuay9B6xGSPVjUeQwaH1RIGqef8RZkUtHioLmSNpPR5M4HVKJGm1j8FWVQ==" }, + "node_modules/sodium-native": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.1.1.tgz", + "integrity": "sha512-LXkAfRd4FHtkQS4X6g+nRcVaN7mWVNepV06phIsC6+IZFvGh1voW5TNQiQp2twVaMf05gZqQjuS+uWLM6gHhNQ==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "node-gyp-build": "^4.8.0" + } + }, "node_modules/solc": { "version": "0.7.3", "resolved": "https://registry.npmjs.org/solc/-/solc-0.7.3.tgz", @@ -19424,6 +19570,11 @@ "node": ">=0.6" } }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" + }, "node_modules/tough-cookie": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", @@ -19630,8 +19781,7 @@ "node_modules/tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", - "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==", - "peer": true + "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==" }, "node_modules/tweetnacl-util": { "version": "0.15.1", @@ -20275,6 +20425,11 @@ "punycode": "^2.1.0" } }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==" + }, "node_modules/url-set-query": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/url-set-query/-/url-set-query-1.0.0.tgz", @@ -21123,15 +21278,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.4.6.tgz", - "integrity": "sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "engines": { - "node": ">=8.3.0" + "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { diff --git a/package.json b/package.json index d03e0e145..075cfa477 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "giveth-graphql-api", - "version": "1.24.4", + "version": "1.25.0", "description": "Backend GraphQL server for Giveth originally forked from Topia", "main": "./dist/index.js", "dependencies": { @@ -16,6 +16,7 @@ "@sentry/node": "6.16.1", "@sentry/tracing": "^6.2.0", "@solana/web3.js": "^1.87.7", + "@stellar/stellar-sdk": "^12.2.0", "@uniswap/sdk": "^3.0.3", "abi-decoder": "^2.4.0", "adminjs": "6.8.3", diff --git a/src/entities/donation.ts b/src/entities/donation.ts index 1cf3b028b..b8fe75a25 100644 --- a/src/entities/donation.ts +++ b/src/entities/donation.ts @@ -287,6 +287,14 @@ export class Donation extends BaseEntity { @Column({ nullable: true }) relevantDonationTxHash?: string; + @Field() + @Column({ default: false }) + isQRDonation: boolean; + + @Field({ nullable: true }) + @Column({ nullable: true }) + toWalletMemo?: string; + @Field({ nullable: true }) @Column('decimal', { precision: 5, scale: 2, nullable: true }) donationPercentage?: number; diff --git a/src/entities/draftDonation.ts b/src/entities/draftDonation.ts index 9fc0e1f3f..e25c716f3 100644 --- a/src/entities/draftDonation.ts +++ b/src/entities/draftDonation.ts @@ -21,7 +21,7 @@ export const DRAFT_DONATION_STATUS = { @Index( ['fromWalletAddress', 'toWalletAddress', 'networkId', 'amount', 'currency'], { - where: `status = '${DRAFT_DONATION_STATUS.PENDING}'`, + where: `status = '${DRAFT_DONATION_STATUS.PENDING}' AND "isQRDonation" = false`, unique: true, }, ) @@ -84,7 +84,7 @@ export class DraftDonation extends BaseEntity { @Column({ nullable: true }) projectId: number; - @Field() + @Field({ nullable: true }) @Column({ nullable: true }) @Index({ where: `status = '${DRAFT_DONATION_STATUS.PENDING}'` }) userId: number; @@ -108,7 +108,7 @@ export class DraftDonation extends BaseEntity { @Column({ nullable: true }) errorMessage?: string; - @Field() + @Field({ nullable: true }) @Column({ nullable: true }) matchedDonationId?: number; @@ -119,4 +119,20 @@ export class DraftDonation extends BaseEntity { @Field() @Column({ nullable: true }) relevantDonationTxHash?: string; + + @Field() + @Column({ nullable: true }) + toWalletMemo?: string; + + @Field() + @Column({ nullable: true }) + qrCodeDataUrl?: string; + + @Field() + @Column({ nullable: true, default: false }) + isQRDonation?: boolean; + + @Field(_type => Date) + @Column({ nullable: true }) + expiresAt?: Date; } diff --git a/src/entities/project.ts b/src/entities/project.ts index 83adb224c..c0b02bcf6 100644 --- a/src/entities/project.ts +++ b/src/entities/project.ts @@ -50,6 +50,7 @@ import { Campaign } from './campaign'; import { ProjectEstimatedMatchingView } from './ProjectEstimatedMatchingView'; import { AnchorContractAddress } from './anchorContractAddress'; import { ProjectSocialMedia } from './projectSocialMedia'; + // eslint-disable-next-line @typescript-eslint/no-var-requires const moment = require('moment'); @@ -91,6 +92,7 @@ export enum FilterField { AcceptFundOnZKEVM = 'acceptFundOnZKEVM', AcceptFundOnOptimism = 'acceptFundOnOptimism', AcceptFundOnSolana = 'acceptFundOnSolana', + AcceptFundOnStellar = 'acceptFundOnStellar', Endaoment = 'fromEndaoment', BoostedWithGivPower = 'boostedWithGivPower', ActiveQfRound = 'ActiveQfRound', diff --git a/src/entities/projectAddress.ts b/src/entities/projectAddress.ts index 02134207e..4e6adac45 100644 --- a/src/entities/projectAddress.ts +++ b/src/entities/projectAddress.ts @@ -67,6 +67,10 @@ export class ProjectAddress extends BaseEntity { @Column('boolean', { default: false }) isRecipient: boolean; + @Field({ nullable: true }) + @Column({ nullable: true }) + memo: string; + @UpdateDateColumn() updatedAt: Date; diff --git a/src/entities/projectVerificationForm.ts b/src/entities/projectVerificationForm.ts index acaf0eebb..172e0f4c4 100644 --- a/src/entities/projectVerificationForm.ts +++ b/src/entities/projectVerificationForm.ts @@ -96,6 +96,8 @@ export class FormRelatedAddress { @Field({ nullable: true }) address: string; @Field({ nullable: true }) + memo?: string; + @Field({ nullable: true }) networkId: number; @Field(_type => ChainType, { defaultValue: ChainType.EVM, nullable: true }) chainType?: ChainType; diff --git a/src/entities/token.ts b/src/entities/token.ts index ceefec96b..fc53dbb58 100644 --- a/src/entities/token.ts +++ b/src/entities/token.ts @@ -65,6 +65,10 @@ export class Token extends BaseEntity { @Column({ nullable: true, default: false }) isStableCoin: boolean; + @Field(_type => Boolean) + @Column({ default: false }) + isQR: boolean; + @Field(_type => String, { nullable: true }) @Column({ nullable: true }) // If we fill that, we will get price of this token from coingecko diff --git a/src/provider.ts b/src/provider.ts index aa8062928..93b190f0c 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -8,7 +8,7 @@ const INFURA_ID = config.get('INFURA_ID'); export const NETWORK_IDS = { MAIN_NET: 1, ROPSTEN: 3, - GOERLI: 5, + SEPOLIA: 11155111, XDAI: 100, POLYGON: 137, OPTIMISTIC: 10, @@ -28,6 +28,8 @@ export const NETWORK_IDS = { ZKEVM_MAINNET: 1101, ZKEVM_CARDONA: 2442, + STELLAR_MAINNET: 1500, + // https://docs.particle.network/developers/other-services/node-service/solana-api SOLANA_MAINNET: 101, SOLANA_TESTNET: 102, @@ -132,7 +134,7 @@ export const superTokens = [ export const NETWORKS_IDS_TO_NAME = { 1: 'MAIN_NET', 3: 'ROPSTEN', - 5: 'GOERLI', + 11155111: 'SEPOLIA', 100: 'GNOSIS', 56: 'BSC', 137: 'POLYGON', @@ -151,6 +153,8 @@ export const NETWORKS_IDS_TO_NAME = { 1101: 'ZKEVM_MAINNET', 2442: 'ZKEVM_CARDONA', + 1500: 'STELLAR_MAINNET', + 101: 'SOLANA_MAINNET', 102: 'SOLANA_TESTNET', 103: 'SOLANA_DEVNET', @@ -161,7 +165,7 @@ const NETWORK_NAMES = { XDAI: 'xdaichain', MAINNET: 'mainnet', ROPSTEN: 'ropsten', - GOERLI: 'goerli', + SEPOLIA: 'sepolia', POLYGON: 'polygon-mainnet', OPTIMISTIC: 'optimistic-mainnet', OPTIMISM_SEPOLIA: 'optimism-sepolia-testnet', @@ -176,6 +180,8 @@ const NETWORK_NAMES = { ZKEVM_CARDONA: 'ZKEVM Cardona', ZKEVM_MAINNET: 'ZKEVM Mainnet', + + STELLAR_MAINNET: 'Stellar Mainnet', }; const NETWORK_NATIVE_TOKENS = { @@ -183,7 +189,7 @@ const NETWORK_NATIVE_TOKENS = { XDAI: 'XDAI', MAINNET: 'ETH', ROPSTEN: 'ETH', - GOERLI: 'ETH', + SEPOLIA: 'ETH', POLYGON: 'MATIC', OPTIMISTIC: 'ETH', OPTIMISM_SEPOLIA: 'ETH', @@ -197,6 +203,7 @@ const NETWORK_NATIVE_TOKENS = { BASE_SEPOLIA: 'ETH', ZKEVM_MAINNET: 'ETH', ZKEVM_CARDONA: 'ETH', + STELLAR_MAINNET: 'XLM', }; const networkNativeTokensList = [ @@ -221,9 +228,9 @@ const networkNativeTokensList = [ nativeToken: NETWORK_NATIVE_TOKENS.ROPSTEN, }, { - networkName: NETWORK_NAMES.GOERLI, - networkId: NETWORK_IDS.GOERLI, - nativeToken: NETWORK_NATIVE_TOKENS.GOERLI, + networkName: NETWORK_NAMES.SEPOLIA, + networkId: NETWORK_IDS.SEPOLIA, + nativeToken: NETWORK_NATIVE_TOKENS.SEPOLIA, }, { networkName: NETWORK_NAMES.POLYGON, @@ -290,6 +297,11 @@ const networkNativeTokensList = [ networkId: NETWORK_IDS.ZKEVM_CARDONA, nativeToken: NETWORK_NATIVE_TOKENS.ZKEVM_CARDONA, }, + { + networkName: NETWORK_NAMES.STELLAR_MAINNET, + networkId: NETWORK_IDS.STELLAR_MAINNET, + nativeToken: NETWORK_NATIVE_TOKENS.STELLAR_MAINNET, + }, ]; export function getNetworkNameById(networkId: number): string { @@ -396,6 +408,10 @@ export function getProvider(networkId: number) { url = process.env.ZKEVM_CARDONA_NODE_HTTP_URL as string; break; + case NETWORK_IDS.STELLAR_MAINNET: + url = process.env.STELLAR_HORIZON_API_URL as string; + break; + default: { // Use infura const connectionInfo = ethers.providers.InfuraProvider.getUrl( @@ -437,8 +453,8 @@ export function getBlockExplorerApiUrl(networkId: number): string { apiUrl = config.get('ETHERSCAN_ROPSTEN_API_URL'); apiKey = config.get('ETHERSCAN_API_KEY'); break; - case NETWORK_IDS.GOERLI: - apiUrl = config.get('ETHERSCAN_GOERLI_API_URL'); + case NETWORK_IDS.SEPOLIA: + apiUrl = config.get('ETHERSCAN_SEPOLIA_API_URL'); apiKey = config.get('ETHERSCAN_API_KEY'); break; case NETWORK_IDS.POLYGON: @@ -491,6 +507,9 @@ export function getBlockExplorerApiUrl(networkId: number): string { apiUrl = config.get('ZKEVM_CARDONA_SCAN_API_URL'); apiKey = config.get('ZKEVM_CARDONA_SCAN_API_KEY'); break; + case NETWORK_IDS.STELLAR_MAINNET: + // Stellar network doesn't need API key + return config.get('STELLAR_SCAN_API_URL') as string; default: logger.error( 'getBlockExplorerApiUrl() no url found for networkId', diff --git a/src/repositories/donationRepository.ts b/src/repositories/donationRepository.ts index 5edb37660..7ea52c932 100644 --- a/src/repositories/donationRepository.ts +++ b/src/repositories/donationRepository.ts @@ -5,6 +5,7 @@ import { Donation, DONATION_STATUS } from '../entities/donation'; import { ResourcesTotalPerMonthAndYear } from '../resolvers/donationResolver'; import { logger } from '../utils/logger'; import { QfRound } from '../entities/qfRound'; +import { ChainType } from '../types/network'; export const fillQfRoundDonationsUserScores = async (): Promise => { await Donation.query(` @@ -76,6 +77,13 @@ export const createDonation = async (data: { donationAnonymous: boolean; transakId: string; token: string; + chainType?: ChainType; + valueUsd?: number; + priceUsd?: number; + status?: string; + isQRDonation?: boolean; + toWalletMemo?: string; + qfRound?: QfRound; }): Promise => { const { amount, @@ -91,7 +99,15 @@ export const createDonation = async (data: { fromWalletAddress, transakId, token, + chainType, + valueUsd, + priceUsd, + status, + isQRDonation, + toWalletMemo, + qfRound, } = data; + const donation = await Donation.create({ amount: Number(amount), transactionId: transactionId?.toLowerCase() || transakId, @@ -108,7 +124,15 @@ export const createDonation = async (data: { toWalletAddress, fromWalletAddress, anonymous: donationAnonymous, + chainType, + valueUsd, + priceUsd, + status, + isQRDonation, + toWalletMemo, + qfRound, }).save(); + return donation; }; diff --git a/src/repositories/draftDonationRepository.ts b/src/repositories/draftDonationRepository.ts index ff4dc4ae7..bd9674b4f 100644 --- a/src/repositories/draftDonationRepository.ts +++ b/src/repositories/draftDonationRepository.ts @@ -68,3 +68,22 @@ export async function countPendingDraftDonations(): Promise { const res = await AppDataSource.getDataSource().query(query, values); return parseInt(res[0].count, 10); } + +export const updateDraftDonationStatus = async (params: { + donationId: number; + status: string; + fromWalletAddress?: string; + matchedDonationId?: number; +}) => { + try { + const { donationId, status, fromWalletAddress, matchedDonationId } = params; + await DraftDonation.update( + { id: donationId }, + { status, fromWalletAddress, matchedDonationId }, + ); + } catch (e) { + logger.error( + `Error in updateDraftDonationStatus - params: ${params} - error: ${e.message}`, + ); + } +}; diff --git a/src/repositories/projectAddressRepository.ts b/src/repositories/projectAddressRepository.ts index 69adcfc76..b2c3078e2 100644 --- a/src/repositories/projectAddressRepository.ts +++ b/src/repositories/projectAddressRepository.ts @@ -49,6 +49,11 @@ export const findRelatedAddressByWalletAddress = async ( walletAddress, }); break; + case ChainType.STELLAR: + query = query.where(`UPPER(address) = :walletAddress`, { + walletAddress: walletAddress.toUpperCase(), + }); + break; case ChainType.EVM: default: query = query.where(`LOWER(address) = :walletAddress`, { @@ -93,6 +98,7 @@ export const addNewProjectAddress = async (params: { isRecipient?: boolean; networkId: number; chainType?: ChainType; + memo?: string; }): Promise => { const projectAddress = ProjectAddress.create(params as ProjectAddress); return projectAddress.save(); @@ -107,6 +113,7 @@ export const addBulkNewProjectAddress = async ( isRecipient?: boolean; networkId: number; chainType?: ChainType; + memo?: string; }[], ): Promise => { const queryBuilder = ProjectAddress.createQueryBuilder() @@ -122,6 +129,7 @@ export const addBulkNewProjectAddress = async ( isRecipient: item.isRecipient, networkId: item.networkId, chainType: item.chainType, + memo: item.memo, })); await queryBuilder.values(values).execute(); diff --git a/src/repositories/projectRepository.test.ts b/src/repositories/projectRepository.test.ts index cec4c5a14..6af02d8ea 100644 --- a/src/repositories/projectRepository.test.ts +++ b/src/repositories/projectRepository.test.ts @@ -8,18 +8,25 @@ import { findProjectsByIdArray, findProjectsBySlugArray, projectsWithoutUpdateAfterTimeFrame, + removeProjectAndRelatedEntities, updateProjectWithVerificationForm, verifyMultipleProjects, verifyProject, } from './projectRepository'; import { + createDonationData, createProjectData, generateRandomEtheriumAddress, + saveAnchorContractDirectlyToDb, + saveDonationDirectlyToDb, saveProjectDirectlyToDb, saveUserDirectlyToDb, } from '../../test/testUtils'; import { createProjectVerificationForm } from './projectVerificationRepository'; -import { PROJECT_VERIFICATION_STATUSES } from '../entities/projectVerificationForm'; +import { + PROJECT_VERIFICATION_STATUSES, + ProjectVerificationForm, +} from '../entities/projectVerificationForm'; import { NETWORK_IDS } from '../provider'; import { setPowerRound } from './powerRoundRepository'; import { refreshProjectPowerView } from './projectPowerViewRepository'; @@ -27,7 +34,7 @@ import { insertSinglePowerBoosting, takePowerBoostingSnapshot, } from './powerBoostingRepository'; -import { Project } from '../entities/project'; +import { Project, ProjectUpdate } from '../entities/project'; import { User } from '../entities/user'; import { PowerBalanceSnapshot } from '../entities/powerBalanceSnapshot'; import { PowerBoostingSnapshot } from '../entities/powerBoostingSnapshot'; @@ -37,6 +44,15 @@ import { getHtmlTextSummary } from '../utils/utils'; import { generateRandomString } from '../utils/utils'; import { addOrUpdatePowerSnapshotBalances } from './powerBalanceSnapshotRepository'; import { findPowerSnapshots } from './powerSnapshotRepository'; +import { AnchorContractAddress } from '../entities/anchorContractAddress'; +import { Donation } from '../entities/donation'; +import { FeaturedUpdate } from '../entities/featuredUpdate'; +import { ProjectAddress } from '../entities/projectAddress'; +import { ProjectSocialMedia } from '../entities/projectSocialMedia'; +import { ProjectStatusHistory } from '../entities/projectStatusHistory'; +import { Reaction } from '../entities/reaction'; +import { SocialProfile } from '../entities/socialProfile'; +import { ProjectSocialMediaType } from '../types/projectSocialMediaType'; describe( 'findProjectByWalletAddress test cases', @@ -71,6 +87,11 @@ describe( findProjectsBySlugArrayTestCases, ); +describe( + 'removeProjectAndRelatedEntities test cases', + removeProjectAndRelatedEntitiesTestCase, +); + function projectsWithoutUpdateAfterTimeFrameTestCases() { it('should return projects created a long time ago', async () => { const superExpiredProject = await saveProjectDirectlyToDb({ @@ -250,7 +271,7 @@ function updateProjectWithVerificationFormTestCases() { ...createProjectData(), adminUserId: user.id, verified: false, - networkId: NETWORK_IDS.GOERLI, + networkId: NETWORK_IDS.SEPOLIA, }); const fetchedProject = await findProjectById(project.id); assert.equal(fetchedProject?.addresses?.length, 1); @@ -498,3 +519,88 @@ function updateDescriptionSummaryTestCases() { ); }); } + +function removeProjectAndRelatedEntitiesTestCase() { + it('should remove project and related entities', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const projectData = createProjectData(); + projectData.adminUserId = user.id; + //It creates a project, projectUpdate, and ProjectAddress + const project = await saveProjectDirectlyToDb(projectData); + + await Promise.all([ + saveDonationDirectlyToDb( + { + ...createDonationData(), + }, + user.id, + project.id, + ), + saveAnchorContractDirectlyToDb({ + creatorId: user.id, + projectId: project.id, + }), + Reaction.create({ + projectId: project.id, + userId: user.id, + reaction: '', + }).save(), + ProjectSocialMedia.create({ + projectId: project.id, + type: ProjectSocialMediaType.FACEBOOK, + link: 'https://facebook.com', + }).save(), + ProjectStatusHistory.create({ + projectId: project.id, + createdAt: new Date(), + }).save(), + ProjectVerificationForm.create({ projectId: project.id }).save(), + FeaturedUpdate.create({ projectId: project.id }).save(), + SocialProfile.create({ projectId: project.id }).save(), + ]); + + const relatedEntitiesBefore = await Promise.all([ + Donation.findOne({ where: { projectId: project.id } }), + Reaction.findOne({ where: { projectId: project.id } }), + ProjectAddress.findOne({ where: { projectId: project.id } }), + ProjectSocialMedia.findOne({ where: { projectId: project.id } }), + AnchorContractAddress.findOne({ where: { projectId: project.id } }), + ProjectStatusHistory.findOne({ where: { projectId: project.id } }), + ProjectVerificationForm.findOne({ + where: { projectId: project.id }, + }), + FeaturedUpdate.findOne({ where: { projectId: project.id } }), + SocialProfile.findOne({ where: { projectId: project.id } }), + ProjectUpdate.findOne({ where: { projectId: project.id } }), + ]); + + relatedEntitiesBefore.forEach(entity => { + assert.isNotNull(entity); + }); + + await removeProjectAndRelatedEntities(project.id); + + const relatedEntities = await Promise.all([ + Donation.findOne({ where: { projectId: project.id } }), + Reaction.findOne({ where: { projectId: project.id } }), + ProjectAddress.findOne({ where: { projectId: project.id } }), + ProjectSocialMedia.findOne({ where: { projectId: project.id } }), + AnchorContractAddress.findOne({ where: { projectId: project.id } }), + ProjectStatusHistory.findOne({ where: { projectId: project.id } }), + ProjectVerificationForm.findOne({ + where: { projectId: project.id }, + }), + FeaturedUpdate.findOne({ where: { projectId: project.id } }), + SocialProfile.findOne({ where: { projectId: project.id } }), + ProjectUpdate.findOne({ where: { projectId: project.id } }), + ]); + + const fetchedProject = await Project.findOne({ where: { id: project.id } }); + + assert.isNull(fetchedProject); + + relatedEntities.forEach(entity => { + assert.isNull(entity); + }); + }); +} diff --git a/src/repositories/projectRepository.ts b/src/repositories/projectRepository.ts index a00fedd7c..b314051ef 100644 --- a/src/repositories/projectRepository.ts +++ b/src/repositories/projectRepository.ts @@ -2,6 +2,7 @@ import { UpdateResult } from 'typeorm'; import { FilterField, Project, + ProjectUpdate, ProjStatus, ReviewStatus, RevokeSteps, @@ -14,6 +15,13 @@ import { publicSelectionFields } from '../entities/user'; import { ResourcesTotalPerMonthAndYear } from '../resolvers/donationResolver'; import { OrderDirection, ProjectResolver } from '../resolvers/projectResolver'; import { getAppropriateNetworkId } from '../services/chains'; +import { AnchorContractAddress } from '../entities/anchorContractAddress'; +import { Donation } from '../entities/donation'; +import { FeaturedUpdate } from '../entities/featuredUpdate'; +import { ProjectSocialMedia } from '../entities/projectSocialMedia'; +import { ProjectStatusHistory } from '../entities/projectStatusHistory'; +import { Reaction } from '../entities/reaction'; +import { SocialProfile } from '../entities/socialProfile'; export const findProjectById = (projectId: number): Promise => { // return Project.findOne({ id: projectId }); @@ -424,6 +432,8 @@ export const totalProjectsPerDate = async ( ): Promise => { const query = Project.createQueryBuilder('project'); + query.andWhere(`project.statusId = ${ProjStatus.active}`); + if (fromDate) { query.andWhere(`project."creationDate" >= '${fromDate}'`); } @@ -437,7 +447,9 @@ export const totalProjectsPerDate = async ( } if (onlyListed) { - query.andWhere(`project."reviewStatus" = 'Listed'`); + query.andWhere(`project.reviewStatus = :reviewStatus`, { + reviewStatus: ReviewStatus.Listed, + }); } if (networkId) { @@ -533,3 +545,63 @@ export const findProjectsBySlugArray = async ( }); return projects; }; + +export const removeProjectAndRelatedEntities = async ( + projectId: number, +): Promise => { + // Delete related entities + await Donation.createQueryBuilder() + .delete() + .where('projectId = :projectId', { projectId }) + .execute(); + + await Reaction.createQueryBuilder() + .delete() + .where('projectId = :projectId', { projectId }) + .execute(); + + await ProjectAddress.createQueryBuilder() + .delete() + .where('projectId = :projectId', { projectId }) + .execute(); + + await ProjectSocialMedia.createQueryBuilder() + .delete() + .where('projectId = :projectId', { projectId }) + .execute(); + + await AnchorContractAddress.createQueryBuilder() + .delete() + .where('projectId = :projectId', { projectId }) + .execute(); + + await ProjectStatusHistory.createQueryBuilder() + .delete() + .where('projectId = :projectId', { projectId }) + .execute(); + + await ProjectVerificationForm.createQueryBuilder() + .delete() + .where('projectId = :projectId', { projectId }) + .execute(); + + await FeaturedUpdate.createQueryBuilder() + .delete() + .where('projectId = :projectId', { projectId }) + .execute(); + + await SocialProfile.createQueryBuilder() + .delete() + .where('projectId = :projectId', { projectId }) + .execute(); + + await ProjectUpdate.createQueryBuilder() + .delete() + .where('projectId = :projectId', { projectId }) + .execute(); + + await Project.createQueryBuilder() + .delete() + .where('id = :id', { id: projectId }) + .execute(); +}; diff --git a/src/resolvers/donationResolver.test.ts b/src/resolvers/donationResolver.test.ts index 15faa2d43..643ccc944 100644 --- a/src/resolvers/donationResolver.test.ts +++ b/src/resolvers/donationResolver.test.ts @@ -36,6 +36,7 @@ import { fetchNewDonorsCount, fetchNewDonorsDonationTotalUsd, fetchDonationMetricsQuery, + getDonationByIdQuery, } from '../../test/graphqlQueries'; import { NETWORK_IDS } from '../provider'; import { User } from '../entities/user'; @@ -99,6 +100,7 @@ describe( ); describe('recentDonations() test cases', recentDonationsTestCases); describe('donationMetrics() test cases', donationMetricsTestCases); +describe('getDonationById() test cases', getDonationByIdTestCases); // // describe('tokens() test cases', tokensTestCases); @@ -901,6 +903,81 @@ function donationsTestCases() { } function createDonationTestCases() { + it('should not create a donation if user donates to his/her own project', async () => { + const firstUser = await User.findOne({ + where: { id: SEED_DATA.FIRST_USER.id }, + }); + const project = await saveProjectDirectlyToDb( + createProjectData(), + firstUser!, + ); + const accessToken = await generateTestAccessToken(firstUser!.id); + const saveDonationResponse = await axios.post( + graphqlUrl, + { + query: createDonationMutation, + variables: { + projectId: project.id, + transactionNetworkId: NETWORK_IDS.XDAI, + transactionId: generateRandomEvmTxHash(), + nonce: 1, + amount: 10, + token: 'GIV', + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + assert.equal( + saveDonationResponse.data.errors[0].message, + "Donor can't donate to his/her own project.", + ); + }); + + it('should create a donation successfully if user creates a donation but is not the project creator', async () => { + const firstUser = await User.findOne({ + where: { id: SEED_DATA.FIRST_USER.id }, + }); + const secondUser = await User.findOne({ + where: { id: SEED_DATA.SECOND_USER.id }, + }); + const project = await saveProjectDirectlyToDb( + createProjectData(), + secondUser!, + ); + const accessToken = await generateTestAccessToken(firstUser!.id); + const saveDonationResponse = await axios.post( + graphqlUrl, + { + query: createDonationMutation, + variables: { + projectId: project.id, + transactionNetworkId: NETWORK_IDS.XDAI, + transactionId: generateRandomEvmTxHash(), + nonce: 1, + amount: 10, + token: 'GIV', + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + assert.isOk(saveDonationResponse.data.data.createDonation); + const donation = await Donation.findOne({ + where: { + id: saveDonationResponse.data.data.createDonation, + }, + }); + assert.isNotNull(donation); + assert.equal(donation?.projectId, project.id); + }); + it('do not save referrer wallet if user refers himself', async () => { const project = await saveProjectDirectlyToDb(createProjectData()); const referrerId = generateRandomString(); @@ -1913,7 +1990,7 @@ function createDonationTestCases() { ); assert.isOk(saveDonationResponse.data.data.createDonation); }); - it('should create ETH donation for CHANGE project on goerli successfully', async () => { + it('should create ETH donation for CHANGE project on sepolia successfully', async () => { const project = await saveProjectDirectlyToDb({ ...createProjectData(), organizationLabel: ORGANIZATION_LABELS.CHANGE, @@ -1926,7 +2003,7 @@ function createDonationTestCases() { query: createDonationMutation, variables: { projectId: project.id, - transactionNetworkId: NETWORK_IDS.GOERLI, + transactionNetworkId: NETWORK_IDS.SEPOLIA, transactionId: generateRandomEvmTxHash(), amount: 10, nonce: 11, @@ -2614,7 +2691,7 @@ function createDonationTestCases() { ); assert.equal( saveDonationResponse.data.errors[0].message, - '"transactionNetworkId" must be one of [1, 3, 5, 100, 137, 10, 11155420, 56, 42220, 44787, 61, 63, 42161, 421614, 8453, 84532, 1101, 2442, 101, 102, 103]', + '"transactionNetworkId" must be one of [1, 3, 5, 100, 137, 10, 11155420, 56, 42220, 44787, 61, 63, 42161, 421614, 8453, 84532, 1101, 2442, 1500, 101, 102, 103]', ); }); it('should not throw exception when currency is not valid when currency is USDC.e', async () => { @@ -4442,8 +4519,8 @@ function donationsToWalletsTestCases() { // errorMessages.TRANSACTION_FROM_ADDRESS_IS_DIFFERENT_FROM_SENT_FROM_ADDRESS, // ); // }); -// // ROPSTEN CHAIN DECOMMISSIONED use goerli -// // TODO: Rewrite this test with goerli. +// // ROPSTEN CHAIN DECOMMISSIONED use sepolia +// // TODO: Rewrite this test with sepolia. // // it('should update donation status to failed when tx is failed on network ', async () => { // // // https://ropsten.etherscan.io/tx/0x66a7902f3dad318e8d075454e26ee829e9832db0b20922cfd9d916fb792ff724 // // const transactionInfo = { @@ -4886,3 +4963,45 @@ async function donationMetricsTestCases() { await User.remove([user1, user2]); }); } + +async function getDonationByIdTestCases() { + it('should return donation by id', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const project = await saveProjectDirectlyToDb(createProjectData()); + const donation = await saveDonationDirectlyToDb( + createDonationData({ status: DONATION_STATUS.VERIFIED }), + user.id, + project.id, + ); + + const result = await axios.post( + graphqlUrl, + { + query: getDonationByIdQuery, + variables: { + id: donation.id, + }, + }, + {}, + ); + + assert.isOk(result); + assert.equal(result.data.data.getDonationById.id, donation.id); + }); + + it('should return null if donation not found', async () => { + const result = await axios.post( + graphqlUrl, + { + query: getDonationByIdQuery, + variables: { + id: 99999999, + }, + }, + {}, + ); + + assert.isOk(result); + assert.isNull(result.data.data.getDonationById); + }); +} diff --git a/src/resolvers/donationResolver.ts b/src/resolvers/donationResolver.ts index 18b9b28dd..f972f47a3 100644 --- a/src/resolvers/donationResolver.ts +++ b/src/resolvers/donationResolver.ts @@ -781,6 +781,10 @@ export class DonationResolver { ), ); } + const ownProject = project.adminUserId === donorUser.id; + if (ownProject) { + throw new Error("Donor can't donate to his/her own project."); + } const tokenInDb = await Token.findOne({ where: { networkId, @@ -915,7 +919,7 @@ export class DonationResolver { case NETWORK_IDS.ROPSTEN: priceChainId = NETWORK_IDS.MAIN_NET; break; - case NETWORK_IDS.GOERLI: + case NETWORK_IDS.SEPOLIA: priceChainId = NETWORK_IDS.MAIN_NET; break; case NETWORK_IDS.OPTIMISM_SEPOLIA: @@ -1065,4 +1069,15 @@ export class DonationResolver { throw e; } } + + @Query(_returns => Donation, { nullable: true }) + async getDonationById( + @Arg('id', _type => Int) id: number, + ): Promise { + try { + return findDonationById(id); + } catch (e) { + return null; + } + } } diff --git a/src/resolvers/draftDonationResolver.test.ts b/src/resolvers/draftDonationResolver.test.ts index 81abe98b1..2512eeec2 100644 --- a/src/resolvers/draftDonationResolver.test.ts +++ b/src/resolvers/draftDonationResolver.test.ts @@ -6,12 +6,17 @@ import { saveProjectDirectlyToDb, createProjectData, generateRandomEvmTxHash, + generateRandomStellarTxHash, generateRandomEtheriumAddress, saveRecurringDonationDirectlyToDb, + generateRandomStellarAddress, + saveDonationDirectlyToDb, } from '../../test/testUtils'; import { createDraftDonationMutation, createDraftRecurringDonationMutation, + markDraftDonationAsFailedDateMutation, + renewDraftDonationExpirationDateMutation, } from '../../test/graphqlQueries'; import { NETWORK_IDS } from '../provider'; import { User } from '../entities/user'; @@ -25,12 +30,26 @@ import { DRAFT_RECURRING_DONATION_STATUS, DraftRecurringDonation, } from '../entities/draftRecurringDonation'; +import { ProjectAddress } from '../entities/projectAddress'; +import { i18n, translationErrorMessagesKeys } from '../utils/errorMessages'; describe('createDraftDonation() test cases', createDraftDonationTestCases); describe( 'createDraftRecurringDonation() test cases', createDraftRecurringDonationTestCases, ); +describe( + 'createQRCodeDraftDonation() test cases', + createQRCodeDraftDonationTestCases, +); +describe( + 'renewDraftDonationExpirationDate() test cases', + renewDraftDonationExpirationDateTestCases, +); +describe( + 'markDraftDonationAsFailed() test cases', + markDraftDonationAsFailedTestCases, +); function createDraftDonationTestCases() { let project; @@ -65,6 +84,46 @@ function createDraftDonationTestCases() { toAddress: project.walletAddress, }; }); + it('should throw an error while creating draft donate to an invalid Project ID', async () => { + const saveDonationResponse = await axios.post( + graphqlUrl, + { + query: createDraftDonationMutation, + variables: { ...donationData, projectId: 1000000 }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + assert.equal( + saveDonationResponse.data.errors[0].message, + i18n.__(translationErrorMessagesKeys.PROJECT_NOT_FOUND), + ); + }); + it('should throw an error while creating draft donating to his/her own project', async () => { + const copyProjectSecondUser = await saveProjectDirectlyToDb( + createProjectData(), + user, + ); + const saveDonationResponse = await axios.post( + graphqlUrl, + { + query: createDraftDonationMutation, + variables: { ...donationData, projectId: copyProjectSecondUser.id }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + assert.equal( + saveDonationResponse.data.errors[0].message, + "Donor can't create a draft to donate to his/her own project.", + ); + }); it('create simple draft donation', async () => { const saveDonationResponse = await axios.post( graphqlUrl, @@ -177,6 +236,145 @@ function createDraftDonationTestCases() { }); } +function createQRCodeDraftDonationTestCases() { + let project; + let user; + let accessToken; + let donationData; + let stellarAddress; + + beforeEach(async () => { + project = await saveProjectDirectlyToDb(createProjectData()); + + stellarAddress = ProjectAddress.create({ + project, + title: 'stellar address', + address: generateRandomStellarAddress(), + chainType: ChainType.STELLAR, + networkId: 0, + isRecipient: true, + }); + await stellarAddress.save(); + + user = await User.create({ + walletAddress: generateRandomEtheriumAddress(), + loginType: 'wallet', + firstName: 'first name', + }).save(); + accessToken = await generateTestAccessToken(user.id); + + donationData = { + projectId: project.id, + networkId: NETWORK_IDS.STELLAR_MAINNET, + amount: 10, + token: 'XLM', + toAddress: stellarAddress.address, + toWalletMemo: '123321', + qrCodeDataUrl: 'data:image/png;base64,123', + isQRDonation: true, + }; + }); + + it('create simple draft donation (user authenticated)', async () => { + const saveDonationResponse = await axios.post( + graphqlUrl, + { + query: createDraftDonationMutation, + variables: donationData, + }, + { + 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({ + userId: user.id, + networkId: NETWORK_IDS.STELLAR_MAINNET, + chainType: ChainType.STELLAR, + status: DRAFT_DONATION_STATUS.PENDING, + fromWalletAddress: '', + toWalletAddress: stellarAddress.address, + currency: 'XLM', + anonymous: false, + amount: 10, + projectId: project.id, + toWalletMemo: '123321', + qrCodeDataUrl: 'data:image/png;base64,123', + isQRDonation: true, + matchedDonationId: null, + }); + }); + + it('create simple draft donation (user not authenticated)', async () => { + const saveDonationResponse = await axios.post(graphqlUrl, { + query: createDraftDonationMutation, + variables: donationData, + }); + assert.isOk(saveDonationResponse.data.data.createDraftDonation); + const draftDonation = await DraftDonation.findOne({ + where: { + id: saveDonationResponse.data.data.createDraftDonation, + }, + }); + + expect(draftDonation).deep.contain({ + userId: null, + networkId: NETWORK_IDS.STELLAR_MAINNET, + chainType: ChainType.STELLAR, + status: DRAFT_DONATION_STATUS.PENDING, + fromWalletAddress: '', + toWalletAddress: stellarAddress.address, + currency: 'XLM', + anonymous: false, + amount: 10, + projectId: project.id, + toWalletMemo: '123321', + qrCodeDataUrl: 'data:image/png;base64,123', + isQRDonation: true, + matchedDonationId: null, + }); + }); + + it('should throw an error if QR code data is not provided', async () => { + try { + await axios.post(graphqlUrl, { + query: createDraftDonationMutation, + variables: { + ...donationData, + qrCodeDataUrl: undefined, + }, + }); + } catch (error) { + expect(error.response.data.errors[0].message).to.be.equal( + 'QR code data URL is required', + ); + } + }); + + it('should throw an error if QR code data is not a valid URL', async () => { + try { + await axios.post(graphqlUrl, { + query: createDraftDonationMutation, + variables: { + ...donationData, + qrCodeDataUrl: 'invalid-url', + }, + }); + } catch (error) { + expect(error.response.data.errors[0].message).to.be.equal( + 'QR code data URL is not a valid URL', + ); + } + }); +} + function createDraftRecurringDonationTestCases() { let project; let user; @@ -201,6 +399,7 @@ function createDraftRecurringDonationTestCases() { toAddress: project.walletAddress, }; }); + it('create simple draft recurring donation', async () => { const saveDonationResponse = await axios.post( graphqlUrl, @@ -354,3 +553,236 @@ function createDraftRecurringDonationTestCases() { ); }); } + +function renewDraftDonationExpirationDateTestCases() { + it('should renew the expiration date of the draft donation', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + + const donationData = { + projectId: project.id, + transactionId: generateRandomStellarTxHash(), + transactionNetworkId: NETWORK_IDS.STELLAR_MAINNET, + amount: 10, + currency: 'XLM', + anonymous: false, + fromWalletAddress: generateRandomStellarAddress(), + toWalletAddress: generateRandomStellarAddress(), + toWalletMemo: '123321', + qrCodeDataUrl: 'data:image/png;base64,123', + isQRDonation: true, + expiresAt: new Date(), + createdAt: new Date(), + }; + + const saveDonationResponse = await saveDonationDirectlyToDb(donationData); + const draftDonationId = saveDonationResponse.id; + + const draftDonation = await DraftDonation.findOne({ + where: { + id: draftDonationId, + }, + }); + const expirationDate = draftDonation!.expiresAt; + + const { + data: { + data: { renewDraftDonationExpirationDate }, + }, + } = await axios.post(graphqlUrl, { + query: renewDraftDonationExpirationDateMutation, + variables: { + id: draftDonationId, + }, + }); + + const renewedExpirationDate = new Date( + renewDraftDonationExpirationDate!.expiresAt, + ).getTime(); + const originalExpirationDate = new Date(expirationDate!).getTime(); + + expect(draftDonation).to.be.not.null; + expect(expirationDate).to.be.not.null; + expect(renewedExpirationDate).to.be.not.null; + expect(renewedExpirationDate).to.be.greaterThan(originalExpirationDate); + }); +} + +function markDraftDonationAsFailedTestCases() { + it('should only mark the draft donation with (isQRDonation == true) as failed', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + + const draftDonationData = { + projectId: project.id, + networkId: NETWORK_IDS.STELLAR_MAINNET, + amount: 10, + token: 'XLM', + toAddress: generateRandomStellarAddress(), + toWalletMemo: '123321', + qrCodeDataUrl: 'data:image/png;base64,123', + isQRDonation: true, + }; + + const draftDonationResponse = await axios.post(graphqlUrl, { + query: createDraftDonationMutation, + variables: draftDonationData, + }); + const draftDonationId = draftDonationResponse.data.data.createDraftDonation; + + expect(draftDonationId).to.be.not.null; + + const draftDonation = await DraftDonation.findOne({ + where: { + id: draftDonationId, + }, + }); + + expect(draftDonation).to.be.not.null; + expect(draftDonation!.status).to.be.equal(DRAFT_DONATION_STATUS.PENDING); + + const { + data: { + data: { markDraftDonationAsFailed }, + }, + } = await axios.post(graphqlUrl, { + query: markDraftDonationAsFailedDateMutation, + variables: { + id: draftDonationId, + }, + }); + + const updatedDraftDonation = await DraftDonation.findOne({ + where: { + id: draftDonationId, + }, + }); + + expect(markDraftDonationAsFailed).to.be.true; + expect(updatedDraftDonation).to.be.not.null; + expect(updatedDraftDonation!.status).to.be.equal( + DRAFT_DONATION_STATUS.FAILED, + ); + }); + + it('should not mark the draft donation with (isQRDonation == false) as failed', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + const user = await User.create({ + walletAddress: generateRandomEtheriumAddress(), + loginType: 'wallet', + firstName: 'first name', + }).save(); + const accessToken = await generateTestAccessToken(user.id); + const draftDonationData = { + projectId: project.id, + networkId: NETWORK_IDS.OPTIMISM_SEPOLIA, + amount: 10, + token: 'ETH', + toAddress: generateRandomEtheriumAddress(), + }; + + const draftDonationResponse = await axios.post( + graphqlUrl, + { + query: createDraftDonationMutation, + variables: draftDonationData, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + const draftDonationId = draftDonationResponse.data.data.createDraftDonation; + + expect(draftDonationId).to.be.not.null; + + const draftDonation = await DraftDonation.findOne({ + where: { + id: draftDonationId, + }, + }); + + expect(draftDonation).to.be.not.null; + expect(draftDonation!.status).to.be.equal(DRAFT_DONATION_STATUS.PENDING); + + const { + data: { + data: { markDraftDonationAsFailed }, + }, + } = await axios.post(graphqlUrl, { + query: markDraftDonationAsFailedDateMutation, + variables: { + id: draftDonationId, + }, + }); + + const updatedDraftDonation = await DraftDonation.findOne({ + where: { + id: draftDonationId, + }, + }); + + expect(markDraftDonationAsFailed).to.be.false; + expect(updatedDraftDonation).to.be.not.null; + expect(updatedDraftDonation!.status).to.be.equal( + DRAFT_DONATION_STATUS.PENDING, + ); + }); + + it('should not mark the draft donation as failed if it is already matched', async () => { + const project = await saveProjectDirectlyToDb(createProjectData()); + + const draftDonationData = { + projectId: project.id, + networkId: NETWORK_IDS.STELLAR_MAINNET, + amount: 10, + token: 'XLM', + toAddress: generateRandomStellarAddress(), + toWalletMemo: '123321', + qrCodeDataUrl: 'data:image/png;base64,123', + isQRDonation: true, + }; + + const draftDonationResponse = await axios.post(graphqlUrl, { + query: createDraftDonationMutation, + variables: draftDonationData, + }); + const draftDonationId = draftDonationResponse.data.data.createDraftDonation; + + expect(draftDonationId).to.be.not.null; + + const draftDonation = await DraftDonation.findOne({ + where: { + id: draftDonationId, + }, + }); + + expect(draftDonation).to.be.not.null; + expect(draftDonation!.status).to.be.equal(DRAFT_DONATION_STATUS.PENDING); + + draftDonation!.status = DRAFT_DONATION_STATUS.MATCHED; + await draftDonation!.save(); + + const { + data: { + data: { markDraftDonationAsFailed }, + }, + } = await axios.post(graphqlUrl, { + query: markDraftDonationAsFailedDateMutation, + variables: { + id: draftDonationId, + }, + }); + + const updatedDraftDonation = await DraftDonation.findOne({ + where: { + id: draftDonationId, + }, + }); + + expect(markDraftDonationAsFailed).to.be.false; + expect(updatedDraftDonation).to.be.not.null; + expect(updatedDraftDonation!.status).to.be.equal( + DRAFT_DONATION_STATUS.MATCHED, + ); + }); +} diff --git a/src/resolvers/draftDonationResolver.ts b/src/resolvers/draftDonationResolver.ts index 7d0c585bc..8df3f9dac 100644 --- a/src/resolvers/draftDonationResolver.ts +++ b/src/resolvers/draftDonationResolver.ts @@ -1,4 +1,4 @@ -import { Arg, Ctx, Mutation, Resolver } from 'type-graphql'; +import { Arg, Ctx, Mutation, Query, Resolver, Int } from 'type-graphql'; import { Repository } from 'typeorm'; import { ApolloContext } from '../types/ApolloContext'; import { User } from '../entities/user'; @@ -25,6 +25,9 @@ import { findRecurringDonationByProjectIdAndUserIdAndCurrency, } from '../repositories/recurringDonationRepository'; import { RecurringDonation } from '../entities/recurringDonation'; +import { checkTransactions } from '../services/cronJobs/checkQRTransactionJob'; +import { findProjectById } from '../repositories/projectRepository'; +import { notifyDonationFailed } from '../services/sse/sse'; const draftDonationEnabled = process.env.ENABLE_DRAFT_DONATION === 'true'; const draftRecurringDonationEnabled = @@ -56,6 +59,10 @@ export class DraftDonationResolver { useDonationBox?: boolean, @Arg('relevantDonationTxHash', { nullable: true }) relevantDonationTxHash?: string, + @Arg('toWalletMemo', { nullable: true }) toWalletMemo?: string, + @Arg('qrCodeDataUrl', { nullable: true }) qrCodeDataUrl?: string, + @Arg('isQRDonation', { nullable: true, defaultValue: false }) + isQRDonation?: boolean, ): Promise { const logData = { amount, @@ -65,6 +72,7 @@ export class DraftDonationResolver { token, projectId, referrerId, + toWalletMemo, userId: ctx?.req?.user?.userId, }; logger.debug( @@ -79,10 +87,34 @@ export class DraftDonationResolver { try { const userId = ctx?.req?.user?.userId; const donorUser = await findUserById(userId); - if (!donorUser) { + const project = await findProjectById(projectId); + + if (!donorUser && !isQRDonation) { throw new Error(i18n.__(translationErrorMessagesKeys.UN_AUTHORIZED)); } - const chainType = detectAddressChainType(donorUser.walletAddress!); + + if (!!isQRDonation && !qrCodeDataUrl) { + throw new Error( + i18n.__(translationErrorMessagesKeys.QR_CODE_DATA_URL_REQUIRED), + ); + } + + if (!project) + throw new Error( + i18n.__(translationErrorMessagesKeys.PROJECT_NOT_FOUND), + ); + + const ownProject = project.adminUserId === donorUser?.id; + if (ownProject) { + throw new Error( + "Donor can't create a draft to donate to his/her own project.", + ); + } + + const chainType = isQRDonation + ? detectAddressChainType(toAddress) + : detectAddressChainType(donorUser?.walletAddress ?? ''); + const _networkId = getAppropriateNetworkId({ networkId, chainType, @@ -100,6 +132,9 @@ export class DraftDonationResolver { chainType, useDonationBox, relevantDonationTxHash, + toWalletMemo, + qrCodeDataUrl, + isQRDonation, }; try { validateWithJoiSchema( @@ -114,14 +149,28 @@ export class DraftDonationResolver { throw e; // Rethrow the original error } - let fromAddress = donorUser.walletAddress!; + let fromAddress = isQRDonation ? '' : donorUser?.walletAddress; - if (chainType !== ChainType.EVM) { - throw new Error(i18n.__(translationErrorMessagesKeys.EVM_SUPPORT_ONLY)); + if (chainType !== ChainType.EVM && chainType !== ChainType.STELLAR) { + throw new Error( + i18n.__(translationErrorMessagesKeys.EVM_AND_STELLAR_SUPPORT_ONLY), + ); } - toAddress = toAddress?.toLowerCase(); - fromAddress = fromAddress?.toLowerCase(); + toAddress = + chainType === ChainType.STELLAR + ? toAddress?.toUpperCase() + : toAddress.toLowerCase(); + + if (fromAddress) + fromAddress = + chainType === ChainType.STELLAR + ? fromAddress.toUpperCase() + : fromAddress.toLowerCase(); + + const expiresAt = isQRDonation + ? new Date(Date.now() + 15 * 60 * 1000) + : undefined; const draftDonationId = await DraftDonation.createQueryBuilder( 'draftDonation', @@ -131,16 +180,21 @@ export class DraftDonationResolver { amount: Number(amount), networkId: _networkId, currency: token, - userId: donorUser.id, + userId: donorUser?.id, tokenAddress, projectId, toWalletAddress: toAddress, - fromWalletAddress: fromAddress, + fromWalletAddress: fromAddress ?? '', anonymous: Boolean(anonymous), chainType: chainType as ChainType, referrerId, useDonationBox, relevantDonationTxHash, + toWalletMemo, + qrCodeDataUrl, + isQRDonation, + expiresAt, + createdAt: new Date(), }) .orIgnore() .returning('id') @@ -312,4 +366,138 @@ export class DraftDonationResolver { throw e; } } + + // get draft donation by id + @Query(_returns => DraftDonation, { nullable: true }) + async getDraftDonationById( + @Arg('id', _type => Int) id: number, + ): Promise { + const draftDonation = await DraftDonation.createQueryBuilder( + 'draftDonation', + ) + .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; + } + + @Mutation(_returns => Boolean) + async markDraftDonationAsFailed( + @Arg('id', _type => Int) id: number, + ): Promise { + try { + const draftDonation = await DraftDonation.createQueryBuilder( + 'draftDonation', + ) + .where('draftDonation.id = :id', { id }) + .getOne(); + + if (!draftDonation) return false; + + if (draftDonation.status === DRAFT_DONATION_STATUS.FAILED) { + return true; + } + + if ( + !draftDonation.isQRDonation || + draftDonation.status === DRAFT_DONATION_STATUS.MATCHED + ) + return false; + + await DraftDonation.update( + { id }, + { status: DRAFT_DONATION_STATUS.FAILED }, + ); + + // Notify clients of new donation + notifyDonationFailed({ + type: 'draft-donation-failed', + data: { + draftDonationId: id, + expiresAt: draftDonation.expiresAt, + }, + }); + + return true; + } catch (e) { + logger.error( + `Error in markDraftDonationAsFailed - id: ${id} - error: ${e.message}`, + ); + return false; + } + } + + @Mutation(_returns => DraftDonation) + async renewDraftDonationExpirationDate( + @Arg('id', _type => Int) id: number, + ): Promise { + try { + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + const draftDonation = await DraftDonation.createQueryBuilder( + 'draftDonation', + ) + .where('draftDonation.id = :id', { id }) + .andWhere('draftDonation.isQRDonation = :isQRDonation', { + isQRDonation: true, + }) + .andWhere('draftDonation.status != :status', { + status: DRAFT_DONATION_STATUS.MATCHED, + }) + .getOne(); + + if (!draftDonation) { + throw new Error(translationErrorMessagesKeys.DRAFT_DONATION_NOT_FOUND); + } + + await DraftDonation.update({ id }, { expiresAt, status: 'pending' }); + + return { + ...draftDonation, + expiresAt, + } as DraftDonation; + } catch (e) { + logger.error( + `Error in renewDraftDonationExpirationDate - id: ${id} - error: ${e.message}`, + ); + 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/resolvers/projectResolver.allProject.test.ts b/src/resolvers/projectResolver.allProject.test.ts index b20bd5e97..080bcd417 100644 --- a/src/resolvers/projectResolver.allProject.test.ts +++ b/src/resolvers/projectResolver.allProject.test.ts @@ -7,6 +7,7 @@ import { createProjectData, generateRandomEtheriumAddress, generateRandomSolanaAddress, + generateRandomStellarAddress, graphqlUrl, REACTION_SEED_DATA, saveDonationDirectlyToDb, @@ -1219,7 +1220,7 @@ function allProjectsTestCases() { address => address.isRecipient === true && (address.networkId === NETWORK_IDS.MAIN_NET || - address.networkId === NETWORK_IDS.GOERLI) && + address.networkId === NETWORK_IDS.SEPOLIA) && address.chainType === ChainType.EVM, ), ); @@ -1330,12 +1331,12 @@ function allProjectsTestCases() { ); }); - it('should return projects, filter by accept donation on GOERLI', async () => { + it('should return projects, filter by accept donation on SEPOLIA', async () => { const savedProject = await saveProjectDirectlyToDb({ ...createProjectData(), title: String(new Date().getTime()), slug: String(new Date().getTime()), - networkId: NETWORK_IDS.GOERLI, + networkId: NETWORK_IDS.SEPOLIA, }); const result = await axios.post(graphqlUrl, { query: fetchMultiFilterAllProjectsQuery, @@ -1351,7 +1352,7 @@ function allProjectsTestCases() { address => (address.isRecipient === true && address.networkId === NETWORK_IDS.MAIN_NET) || - address.networkId === NETWORK_IDS.GOERLI, + address.networkId === NETWORK_IDS.SEPOLIA, ), ); }); @@ -1610,6 +1611,44 @@ function allProjectsTestCases() { ), ); }); + it('should return projects, filter by accept donation on Stellar', async () => { + const savedProject = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + }); + await ProjectAddress.delete({ projectId: savedProject.id }); + const stellarAddress = ProjectAddress.create({ + project: savedProject, + title: 'first address', + address: generateRandomStellarAddress(), + chainType: ChainType.STELLAR, + networkId: 0, + isRecipient: true, + }); + await stellarAddress.save(); + const result = await axios.post(graphqlUrl, { + query: fetchMultiFilterAllProjectsQuery, + variables: { + filters: ['AcceptFundOnStellar'], + sortingBy: SortingField.Newest, + }, + }); + result.data.data.allProjects.projects.forEach(project => { + assert.isOk( + project.addresses.find( + address => + address.isRecipient === true && + address.chainType === ChainType.STELLAR, + ), + ); + }); + assert.isOk( + result.data.data.allProjects.projects.find( + project => Number(project.id) === Number(savedProject.id), + ), + ); + }); it('should return projects, filter by accept fund on two Ethereum networks', async () => { const project = await saveProjectDirectlyToDb({ ...createProjectData(), @@ -1691,6 +1730,89 @@ function allProjectsTestCases() { assert.include(projectIds, String(projectWithMainnet.id)); assert.include(projectIds, String(projectWithSolana.id)); }); + it('should return projects, filter by accept donation on Solana, Stellar, and an expected Ethereum network', async () => { + const projectWithMainnet = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + }); + const projectWithSolana = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + }); + const projectWithStellar = await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + }); + + const mainnetAddress = ProjectAddress.create({ + project: projectWithMainnet, + title: 'first address', + address: generateRandomEtheriumAddress(), + networkId: 1, + isRecipient: true, + }); + await mainnetAddress.save(); + + const solanaAddress = ProjectAddress.create({ + project: projectWithSolana, + title: 'secnod address', + address: generateRandomSolanaAddress(), + chainType: ChainType.SOLANA, + networkId: 0, + isRecipient: true, + }); + await solanaAddress.save(); + + const stellarAddress = ProjectAddress.create({ + project: projectWithStellar, + title: 'third address', + address: generateRandomStellarAddress(), + chainType: ChainType.STELLAR, + networkId: 0, + isRecipient: true, + }); + await stellarAddress.save(); + + const result = await axios.post(graphqlUrl, { + query: fetchMultiFilterAllProjectsQuery, + variables: { + filters: [ + 'AcceptFundOnMainnet', + 'AcceptFundOnSolana', + 'AcceptFundOnStellar', + ], + sortingBy: SortingField.Newest, + }, + }); + const { projects } = result.data.data.allProjects; + const projectIds = projects.map(project => project.id); + assert.include(projectIds, String(projectWithMainnet.id)); + assert.include(projectIds, String(projectWithSolana.id)); + assert.include(projectIds, String(projectWithStellar.id)); + }); + it('should not return a project when it does not accept donation on Stellar', async () => { + // Delete all project addresses + await ProjectAddress.delete({ chainType: ChainType.STELLAR }); + + await saveProjectDirectlyToDb({ + ...createProjectData(), + title: String(new Date().getTime()), + slug: String(new Date().getTime()), + }); + + const result = await axios.post(graphqlUrl, { + query: fetchMultiFilterAllProjectsQuery, + variables: { + filters: ['AcceptFundOnStellar'], + sortingBy: SortingField.Newest, + }, + }); + const { projects } = result.data.data.allProjects; + assert.lengthOf(projects, 0); + }); it('should not return a project when it does not accept donation on Solana', async () => { // Delete all project addresses await ProjectAddress.delete({ chainType: ChainType.SOLANA }); @@ -2009,7 +2131,6 @@ function allProjectsTestCases() { qfRound.isActive = false; await qfRound.save(); }); - it('should return projects, filter by ActiveQfRound', async () => { const project = await saveProjectDirectlyToDb({ ...createProjectData(), diff --git a/src/resolvers/projectResolver.test.ts b/src/resolvers/projectResolver.test.ts index bc131740a..6279dcce1 100644 --- a/src/resolvers/projectResolver.test.ts +++ b/src/resolvers/projectResolver.test.ts @@ -20,6 +20,7 @@ import { addRecipientAddressToProjectQuery, createProjectQuery, deactivateProjectQuery, + deleteDraftProjectQuery, deleteProjectUpdateQuery, editProjectUpdateQuery, fetchFeaturedProjects, @@ -33,6 +34,7 @@ import { fetchSimilarProjectsBySlugQuery, getProjectsAcceptTokensQuery, getPurpleList, + getTokensDetailsQuery, projectByIdQuery, projectsByUserIdQuery, updateProjectQuery, @@ -106,6 +108,7 @@ import { findPowerSnapshots } from '../repositories/powerSnapshotRepository'; import { cacheProjectCampaigns } from '../services/campaignService'; import { ChainType } from '../types/network'; import { QfRound } from '../entities/qfRound'; +import seedTokens from '../../migration/data/seedTokens'; const ARGUMENT_VALIDATION_ERROR_MESSAGE = new ArgumentValidationError([ { property: '' }, @@ -173,6 +176,12 @@ describe( // describe('activateProject test cases --->', activateProjectTestCases); describe('projectsPerDate() test cases --->', projectsPerDateTestCases); +describe.only( + 'getTokensDetailsTestCases() test cases --->', + getTokensDetailsTestCases, +); + +describe('deleteDraftProject test cases --->', deleteDraftProjectTestCases); function projectsPerDateTestCases() { it('should projects created in a time range', async () => { @@ -5668,3 +5677,151 @@ function deleteProjectUpdateTestCases() { ); }); } + +function getTokensDetailsTestCases() { + it('should return token details', async () => { + const tokenDetails = seedTokens.find( + token => token.address === '0x6b175474e89094c44da98b954eedeac495271d0f', + ); + + const result = await axios.post(graphqlUrl, { + query: getTokensDetailsQuery, + variables: { + address: tokenDetails?.address, + networkId: tokenDetails?.networkId, + }, + }); + + const token = result.data.data.getTokensDetails; + + assert.equal(token.address, tokenDetails?.address); + assert.equal(token.symbol, tokenDetails?.symbol); + assert.equal(token.decimals, tokenDetails?.decimals); + assert.equal(token.name, tokenDetails?.name); + assert.equal(token.networkId, tokenDetails?.networkId); + assert.equal(token.chainType, ChainType.EVM); + }); + + it('should return null if token not found', async () => { + const result = await axios.post(graphqlUrl, { + query: getTokensDetailsQuery, + variables: { + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + networkId: NETWORK_IDS.POLYGON, + }, + }); + + const error = result.data.errors[0]; + assert.equal(error.message, 'Token not found'); + }); +} + +function deleteDraftProjectTestCases() { + it('should delete draft project successfully ', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const accessToken = await generateTestAccessToken(user.id); + const project = await saveProjectDirectlyToDb({ + ...createProjectData(), + adminUserId: user.id, + statusId: ProjStatus.drafted, + }); + + const result = await axios.post( + graphqlUrl, + { + query: deleteDraftProjectQuery, + variables: { + projectId: project.id, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + assert.equal(result.data.data.deleteDraftProject, true); + }); + it('should can not delete draft project because of ownerShip ', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const user1 = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const project = await saveProjectDirectlyToDb({ + ...createProjectData(), + adminUserId: user.id, + statusId: ProjStatus.drafted, + }); + const accessTokenUser1 = await generateTestAccessToken(user1.id); + + // Add projectUpdate with accessToken user1 + const result = await axios.post( + graphqlUrl, + { + query: deleteDraftProjectQuery, + variables: { + projectId: project.id, + }, + }, + { + headers: { + Authorization: `Bearer ${accessTokenUser1}`, + }, + }, + ); + assert.equal( + result.data.errors[0].message, + errorMessages.YOU_ARE_NOT_THE_OWNER_OF_PROJECT, + ); + }); + it('should can not delete draft project because of not found project ', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const accessToken = await generateTestAccessToken(user.id); + const projectCount = await Project.count(); + const result = await axios.post( + graphqlUrl, + { + query: deleteDraftProjectQuery, + variables: { + projectId: Number(projectCount + 10), + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + assert.equal( + result.data.errors[0].message, + errorMessages.PROJECT_NOT_FOUND, + ); + }); + + it('should can not delete draft project because status ', async () => { + const user = await saveUserDirectlyToDb(generateRandomEtheriumAddress()); + const accessToken = await generateTestAccessToken(user.id); + const project = await saveProjectDirectlyToDb({ + ...createProjectData(), + adminUserId: user.id, + statusId: ProjStatus.active, + }); + + const result = await axios.post( + graphqlUrl, + { + query: deleteDraftProjectQuery, + variables: { + projectId: project.id, + }, + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + assert.equal( + result.data.errors[0].message, + errorMessages.ONLY_DRAFTED_PROJECTS_CAN_BE_DELETED, + ); + }); +} diff --git a/src/resolvers/projectResolver.ts b/src/resolvers/projectResolver.ts index a1f7e5692..088a70bfe 100644 --- a/src/resolvers/projectResolver.ts +++ b/src/resolvers/projectResolver.ts @@ -81,6 +81,7 @@ import { filterProjectsQuery, findProjectById, findProjectIdBySlug, + removeProjectAndRelatedEntities, totalProjectsPerDate, totalProjectsPerDateByMonthAndYear, } from '../repositories/projectRepository'; @@ -487,6 +488,7 @@ export class ProjectResolver { if (!filtersArray || filtersArray.length === 0) return query; const networkIds: number[] = []; let acceptFundOnSolanaSeen = false; + let acceptFundOnStellarSeen = false; filtersArray.forEach(filter => { switch (filter) { @@ -515,7 +517,7 @@ export class ProjectResolver { networkIds.push(NETWORK_IDS.MAIN_NET); // Add this to make sure works on Staging - networkIds.push(NETWORK_IDS.GOERLI); + networkIds.push(NETWORK_IDS.SEPOLIA); return; case FilterField.AcceptFundOnCelo: networkIds.push(NETWORK_IDS.CELO); @@ -554,6 +556,9 @@ export class ProjectResolver { // Add this to make sure works on Staging networkIds.push(NETWORK_IDS.MORDOR_ETC_TESTNET); return; + case FilterField.AcceptFundOnStellar: + acceptFundOnStellarSeen = true; + return; case FilterField.AcceptFundOnSolana: acceptFundOnSolanaSeen = true; return; @@ -563,7 +568,11 @@ export class ProjectResolver { } }); - if (networkIds.length > 0 || acceptFundOnSolanaSeen) { + if ( + networkIds.length > 0 || + acceptFundOnSolanaSeen || + acceptFundOnStellarSeen + ) { // TODO: This logic seems wrong! since only one of the following filters can be true at the same time query.andWhere( new Brackets(subQuery => { @@ -589,6 +598,17 @@ export class ProjectResolver { )`, ); } + if (acceptFundOnStellarSeen) { + subQuery.orWhere( + `EXISTS ( + SELECT * + FROM project_address + WHERE "isRecipient" = true AND + "projectId" = project.id AND + "chainType" = '${ChainType.STELLAR}' + )`, + ); + } }), ); } @@ -1183,6 +1203,7 @@ export class ProjectResolver { user: adminUser, address: relatedAddress.address, chainType: relatedAddress.chainType, + memo: relatedAddress.memo, // Frontend doesn't send networkId for solana addresses so we set it to default solana chain id networkId: getAppropriateNetworkId({ @@ -1213,6 +1234,8 @@ export class ProjectResolver { @Arg('networkId') networkId: number, @Arg('address') address: string, @Arg('chainType', _type => ChainType, { defaultValue: ChainType.EVM }) + @Arg('memo', { nullable: true }) + memo: string, chainType: ChainType, @Ctx() { req: { user } }: ApolloContext, ) { @@ -1242,6 +1265,7 @@ export class ProjectResolver { networkId, isRecipient: true, chainType, + memo: chainType === ChainType.STELLAR ? memo : undefined, }); project.adminUser = adminUser; @@ -1463,7 +1487,7 @@ export class ProjectResolver { // newProject.adminUser = adminUser; await addBulkNewProjectAddress( projectInput?.addresses.map(relatedAddress => { - const { networkId, address, chainType } = relatedAddress; + const { networkId, address, chainType, memo } = relatedAddress; return { project, user, @@ -1475,6 +1499,7 @@ export class ProjectResolver { chainType, }), isRecipient: true, + memo, }; }), ); @@ -2198,4 +2223,60 @@ export class ProjectResolver { throw error; } } + + @Query(_returns => Token) + async getTokensDetails( + @Arg('address') address: string, + @Arg('networkId') networkId: number, + ): Promise { + try { + const token = await Token.findOne({ + where: { address, networkId }, + }); + if (!token) { + throw new Error(i18n.__(translationErrorMessagesKeys.TOKEN_NOT_FOUND)); + } + return token; + } catch (e) { + logger.error('getTokensDetails error', e); + throw e; + } + } + + @Mutation(_returns => Boolean) + async deleteDraftProject( + @Arg('projectId') projectId: number, + @Ctx() ctx: ApolloContext, + ): Promise { + const user = await getLoggedInUser(ctx); + const project = await findProjectById(projectId); + + if (!project) { + throw new Error(i18n.__(translationErrorMessagesKeys.PROJECT_NOT_FOUND)); + } + + if (project.adminUserId !== user.id) { + throw new Error( + i18n.__(translationErrorMessagesKeys.YOU_ARE_NOT_THE_OWNER_OF_PROJECT), + ); + } + + if (project.statusId !== ProjStatus.drafted) { + throw new Error( + i18n.__( + translationErrorMessagesKeys.ONLY_DRAFTED_PROJECTS_CAN_BE_DELETED, + ), + ); + } + + try { + await removeProjectAndRelatedEntities(project.id); + } catch (error) { + logger.error('projectResolver.deleteDraftProject() error', error); + SentryLogger.captureException(error); + throw error; + } + + return true; + } } diff --git a/src/resolvers/projectVerificationFormResolver.test.ts b/src/resolvers/projectVerificationFormResolver.test.ts index d06773e09..f13c94e67 100644 --- a/src/resolvers/projectVerificationFormResolver.test.ts +++ b/src/resolvers/projectVerificationFormResolver.test.ts @@ -5,6 +5,7 @@ import { generateConfirmationEmailToken, generateRandomEtheriumAddress, generateRandomSolanaAddress, + generateRandomStellarAddress, generateTestAccessToken, graphqlUrl, saveProjectDirectlyToDb, @@ -278,7 +279,7 @@ function updateProjectVerificationFormMutationTestCases() { }, { address: generateRandomEtheriumAddress(), - networkId: NETWORK_IDS.GOERLI, + networkId: NETWORK_IDS.SEPOLIA, title: 'test title', }, { @@ -352,6 +353,13 @@ function updateProjectVerificationFormMutationTestCases() { title: 'test title', chainType: ChainType.EVM, }, + { + address: generateRandomStellarAddress(), + networkId: NETWORK_IDS.STELLAR_MAINNET, + title: 'test title', + chainType: ChainType.STELLAR, + memo: '123123', + }, // { // address: generateRandomSolanaAddress(), // networkId: NETWORK_IDS.SOLANA_MAINNET, diff --git a/src/resolvers/types/ProjectVerificationUpdateInput.ts b/src/resolvers/types/ProjectVerificationUpdateInput.ts index b02ece474..8d512b0d1 100644 --- a/src/resolvers/types/ProjectVerificationUpdateInput.ts +++ b/src/resolvers/types/ProjectVerificationUpdateInput.ts @@ -48,6 +48,8 @@ export class RelatedAddressInputType { networkId: number; @Field(_type => ChainType, { defaultValue: ChainType.EVM }) chainType?: ChainType; + @Field({ nullable: true }) + memo?: string; } @InputType() diff --git a/src/server/adminJs/tabs/donationTab.ts b/src/server/adminJs/tabs/donationTab.ts index a2eb3779d..e99fa5f9b 100644 --- a/src/server/adminJs/tabs/donationTab.ts +++ b/src/server/adminJs/tabs/donationTab.ts @@ -664,7 +664,7 @@ export const donationTab = { availableValues: [ { value: NETWORK_IDS.MAIN_NET, label: 'Mainnet' }, { value: NETWORK_IDS.XDAI, label: 'Xdai' }, - { value: NETWORK_IDS.GOERLI, label: 'Goerli' }, + { value: NETWORK_IDS.SEPOLIA, label: 'sepolia' }, { value: NETWORK_IDS.POLYGON, label: 'Polygon' }, { value: NETWORK_IDS.CELO, label: 'Celo' }, { value: NETWORK_IDS.CELO_ALFAJORES, label: 'Alfajores' }, diff --git a/src/server/adminJs/tabs/qfRoundTab.ts b/src/server/adminJs/tabs/qfRoundTab.ts index c570ce37f..5555f9cb1 100644 --- a/src/server/adminJs/tabs/qfRoundTab.ts +++ b/src/server/adminJs/tabs/qfRoundTab.ts @@ -202,7 +202,7 @@ async function validateQfRound(payload: { const availableNetworkValues = [ { value: NETWORK_IDS.MAIN_NET, label: 'MAINNET' }, { value: NETWORK_IDS.ROPSTEN, label: 'ROPSTEN' }, - { value: NETWORK_IDS.GOERLI, label: 'GOERLI' }, + { value: NETWORK_IDS.SEPOLIA, label: 'SEPOLIA' }, { value: NETWORK_IDS.POLYGON, label: 'POLYGON' }, { value: NETWORK_IDS.OPTIMISTIC, label: 'OPTIMISTIC' }, { value: NETWORK_IDS.ETC, label: 'ETC' }, diff --git a/src/server/adminJs/tabs/tokenTab.ts b/src/server/adminJs/tabs/tokenTab.ts index 937e4ecbf..9c328557b 100644 --- a/src/server/adminJs/tabs/tokenTab.ts +++ b/src/server/adminJs/tabs/tokenTab.ts @@ -188,7 +188,7 @@ export const generateTokenTab = async () => { availableValues: [ { value: NETWORK_IDS.MAIN_NET, label: 'MAINNET' }, { value: NETWORK_IDS.ROPSTEN, label: 'ROPSTEN' }, - { value: NETWORK_IDS.GOERLI, label: 'GOERLI' }, + { value: NETWORK_IDS.SEPOLIA, label: 'SEPOLIA' }, { value: NETWORK_IDS.POLYGON, label: 'POLYGON' }, { value: NETWORK_IDS.OPTIMISTIC, label: 'OPTIMISTIC' }, { value: NETWORK_IDS.OPTIMISM_SEPOLIA, label: 'OPTIMISM SEPOLIA' }, diff --git a/src/server/bootstrap.ts b/src/server/bootstrap.ts index ae5458ef1..3ca543943 100644 --- a/src/server/bootstrap.ts +++ b/src/server/bootstrap.ts @@ -7,7 +7,7 @@ import { ApolloServer } from '@apollo/server'; import { expressMiddleware } from '@apollo/server/express4'; import { ApolloServerPluginSchemaReporting } from '@apollo/server/plugin/schemaReporting'; import { ApolloServerPluginLandingPageGraphQLPlayground } from '@apollo/server-plugin-landing-page-graphql-playground'; -import express, { json, Request } from 'express'; +import express, { json, Request, Response } from 'express'; import { Container } from 'typedi'; import { Resource } from '@adminjs/typeorm'; import { validate } from 'class-validator'; @@ -67,6 +67,8 @@ import { runUpdateRecurringDonationStream } from '../services/cronJobs/updateStr import { runDraftDonationMatchWorkerJob } from '../services/cronJobs/draftDonationMatchingJob'; import { runCheckUserSuperTokenBalancesJob } from '../services/cronJobs/checkUserSuperTokenBalancesJob'; import { runCheckPendingRecurringDonationsCronJob } from '../services/cronJobs/syncRecurringDonationsWithNetwork'; +import { runCheckQRTransactionJob } from '../services/cronJobs/checkQRTransactionJob'; +import { addClient } from '../services/sse/sse'; Resource.validate = validate; @@ -288,6 +290,11 @@ export async function bootstrap() { app.post('/fiat_webhook', onramperWebhookHandler); app.post('/transak_webhook', webhookHandler); + // Route to handle SSE connections + app.get('/events', (_req: Request, res: Response) => { + addClient(res); + }); + const httpServer = http.createServer(app); await new Promise((resolve, reject) => { @@ -428,6 +435,8 @@ export async function bootstrap() { 'initializeCronJobs() after runUpdateProjectCampaignsCacheJob() ', new Date(), ); + + runCheckQRTransactionJob(); } async function performPostStartTasks() { 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 new file mode 100644 index 000000000..fa0ea2730 --- /dev/null +++ b/src/services/cronJobs/checkQRTransactionJob.ts @@ -0,0 +1,216 @@ +import { schedule } from 'node-cron'; +import axios from 'axios'; +import config from '../../config'; +import { logger } from '../../utils/logger'; +import { DraftDonation } from '../../entities/draftDonation'; +import { Token } from '../../entities/token'; +import { + createDonation, + findDonationsByTransactionId, +} from '../../repositories/donationRepository'; +import { findProjectById } from '../../repositories/projectRepository'; +import { updateDraftDonationStatus } from '../../repositories/draftDonationRepository'; +import { CoingeckoPriceAdapter } from '../../adapters/price/CoingeckoPriceAdapter'; +import { findUserById } from '../../repositories/userRepository'; +import { relatedActiveQfRoundForProject } from '../qfRoundService'; +import { QfRound } from '../../entities/qfRound'; +import { syncDonationStatusWithBlockchainNetwork } from '../donationService'; +import { notifyClients } from '../sse/sse'; + +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 */1 * * * *'; + +async function getPendingDraftDonations() { + return await DraftDonation.createQueryBuilder('draftDonation') + .where('draftDonation.status = :status', { status: 'pending' }) + .andWhere('draftDonation.isQRDonation = true') + .getMany(); +} + +const getToken = async ( + chainType: string, + symbol: string, +): Promise => { + return await Token.createQueryBuilder('token') + .where('token.chainType = :chainType', { chainType }) + .andWhere('token.isQR = true') + .andWhere('token.symbol = :symbol', { symbol }) + .getOne(); +}; + +// Check for transactions +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`, + ); + + const transactions = response.data._embedded.records; + + if (transactions.length === 0) return; + + for (const transaction of transactions) { + 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}`, + ); + return; + } + + // Check if donation already exists + const existingDonation = await findDonationsByTransactionId( + transaction.transaction_hash?.toLowerCase(), + ); + if (existingDonation) return; + + // Retrieve token object + const token = await getToken('STELLAR', 'XLM'); + if (!token) { + logger.debug('Token not found for donation ID', donation.id); + return; + } + + // Retrieve project object + const project = await findProjectById(donation.projectId); + if (!project) { + logger.debug(`Project not found for donation ID ${donation.id}`); + return; + } + + // Get token price + const tokenPrice = await new CoingeckoPriceAdapter().getTokenPrice({ + symbol: token.coingeckoId, + networkId: token.networkId, + }); + + // Retrieve donor object + const donor = await findUserById(donation.userId); + + // Check if there is an active QF round for the project and check if the token is eligible for the network + const activeQfRoundForProject = await relatedActiveQfRoundForProject( + project.id, + ); + + let qfRound: QfRound | undefined; + if ( + activeQfRoundForProject && + activeQfRoundForProject.isEligibleNetwork(token.networkId) + ) { + qfRound = activeQfRoundForProject; + } + + const returnedDonation = await createDonation({ + amount: donation.amount, + project: project, + transactionNetworkId: donation.networkId, + fromWalletAddress: transaction.source_account, + transactionId: transaction.transaction_hash, + tokenAddress: donation.tokenAddress, + isProjectVerified: project.verified, + donorUser: donor, + isTokenEligibleForGivback: token.isGivbackEligible, + segmentNotified: false, + toWalletAddress: donation.toWalletAddress, + donationAnonymous: false, + transakId: '', + token: donation.currency, + valueUsd: donation.amount * tokenPrice, + priceUsd: tokenPrice, + status: transaction.transaction_successful ? 'verified' : 'failed', + isQRDonation: true, + toWalletMemo, + qfRound, + chainType: token.chainType, + }); + + if (!returnedDonation) { + logger.debug( + `Error creating donation for draft donation ID ${donation.id}`, + ); + return; + } + + // Update draft donation status to matched and add matched donation ID with source address + await updateDraftDonationStatus({ + donationId: donation.id, + status: transaction.transaction_successful ? 'matched' : 'failed', + fromWalletAddress: transaction.source_account, + matchedDonationId: returnedDonation.id, + }); + + await syncDonationStatusWithBlockchainNetwork({ + donationId: returnedDonation.id, + }); + + // Notify clients of new donation + notifyClients({ + type: 'new-donation', + data: { + donationId: returnedDonation.id, + draftDonationId: donation.id, + }, + }); + + return; + } + } + } catch (error) { + logger.debug( + `Error checking transactions for donation ID ${donation.id}:`, + error, + ); + } +} + +// Cron job to check pending draft donations every 5 minutes +export const runCheckQRTransactionJob = () => { + logger.debug('checkQRTransactionJob() has been called', { cronJobTime }); + + schedule(cronJobTime, async () => { + const pendingDonations = await getPendingDraftDonations(); + + for (const donation of pendingDonations) { + await checkTransactions(donation); + } + }); +}; diff --git a/src/services/sse/sse.ts b/src/services/sse/sse.ts new file mode 100644 index 000000000..3c0cdf211 --- /dev/null +++ b/src/services/sse/sse.ts @@ -0,0 +1,54 @@ +import { Response } from 'express'; + +let clients: Response[] = []; + +type TNewDonation = { + type: 'new-donation'; + data: { + donationId: number; + draftDonationId: number; + }; +}; + +type TDraftDonationFailed = { + type: 'draft-donation-failed'; + data: { + draftDonationId: number; + expiresAt?: Date; + }; +}; + +// Add a new client to the SSE stream +export function addClient(res: Response) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + + res.flushHeaders(); + + clients.push(res); + + // Send a welcome message to the newly connected client + const data = { + type: 'initial', + data: 'Welcome to the server', + }; + res.write(`data: ${JSON.stringify(data)}\n\n`); + + // Remove the client on disconnect + res.on('close', () => { + clients = clients.filter(client => client !== res); + res.end(); + }); +} + +// Notify all connected clients about a new donation +export function notifyClients(data: TNewDonation) { + clients.forEach(client => client.write(`data: ${JSON.stringify(data)}\n\n`)); +} + +// Notify all connected clients about a failed donation +export function notifyDonationFailed(data: TDraftDonationFailed) { + clients.forEach(client => client.write(`data: ${JSON.stringify(data)}\n\n`)); +} diff --git a/src/types/network.ts b/src/types/network.ts index de3cc0dcc..40e7f1902 100644 --- a/src/types/network.ts +++ b/src/types/network.ts @@ -1,4 +1,5 @@ export enum ChainType { EVM = 'EVM', SOLANA = 'SOLANA', + STELLAR = 'STELLAR', } diff --git a/src/utils/errorMessages.ts b/src/utils/errorMessages.ts index 31e835498..a6584da2a 100644 --- a/src/utils/errorMessages.ts +++ b/src/utils/errorMessages.ts @@ -29,6 +29,7 @@ export const errorMessages = { CHAINVINE_REFERRER_NOT_FOUND: 'Chainvine referrer not found', ONRAMPER_SIGNATURE_INVALID: 'Onramper signature invalid', ONRAMPER_SIGNATURE_MISSING: 'Onramper signature missing', + ONLY_DRAFTED_PROJECTS_CAN_BE_DELETED: 'Only drafted projects can be deleted', UPLOAD_FAILED: 'Upload file failed', SPECIFY_GIV_POWER_ADAPTER: 'Specify givPower adapter', CHANGE_API_INVALID_TITLE_OR_EIN: @@ -200,6 +201,10 @@ export const errorMessages = { INVALID_PROJECT_OWNER: 'Project owner is invalid', PROJECT_DOESNT_ACCEPT_RECURRING_DONATION: 'Project does not accept recurring donation', + DRAFT_DONATION_NOT_FOUND: 'Draft donation not found', + DRAFT_DONATION_CANNOT_BE_MARKED_AS_FAILED: + 'Draft donation cannot be marked as failed', + QR_CODE_DATA_URL_REQUIRED: 'QR code data URL is required', }; export const translationErrorMessagesKeys = { @@ -212,6 +217,7 @@ export const translationErrorMessagesKeys = { FIAT_DONATION_ALREADY_EXISTS: 'FIAT_DONATION_ALREADY_EXISTS', ONRAMPER_SIGNATURE_INVALID: 'ONRAMPER_SIGNATURE_INVALID', ONRAMPER_SIGNATURE_MISSING: 'ONRAMPER_SIGNATURE_MISSING', + ONLY_DRAFTED_PROJECTS_CAN_BE_DELETED: 'ONLY_DRAFTED_PROJECTS_CAN_BE_DELETED', UPLOAD_FAILED: 'UPLOAD_FAILED', SPECIFY_GIV_POWER_ADAPTER: 'SPECIFY_GIV_POWER_ADAPTER', CHANGE_API_INVALID_TITLE_OR_EIN: 'SPECIFY_GIV_POWER_ADAPTER', @@ -368,4 +374,9 @@ export const translationErrorMessagesKeys = { DRAFT_DONATION_DISABLED: 'DRAFT_DONATION_DISABLED', DRAFT_RECURRING_DONATION_DISABLED: 'DRAFT_RECURRING_DONATION_DISABLED', EVM_SUPPORT_ONLY: 'EVM_SUPPORT_ONLY', + EVM_AND_STELLAR_SUPPORT_ONLY: 'EVM_AND_STELLAR_SUPPORT_ONLY', + DRAFT_DONATION_NOT_FOUND: 'DRAFT_DONATION_NOT_FOUND', + DRAFT_DONATION_CANNOT_BE_MARKED_AS_FAILED: + 'DRAFT_DONATION_CANNOT_BE_MARKED_AS_FAILED', + QR_CODE_DATA_URL_REQUIRED: 'QR_CODE_DATA_URL_REQUIRED', }; diff --git a/src/utils/locales/en.json b/src/utils/locales/en.json index 743b7ecbc..70645329d 100644 --- a/src/utils/locales/en.json +++ b/src/utils/locales/en.json @@ -5,6 +5,7 @@ "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", + "ONLY_DRAFTED_PROJECTS_CAN_BE_DELETED": "Only drafted projects can be deleted", "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", @@ -73,7 +74,7 @@ "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", + "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", @@ -109,10 +110,12 @@ "There is not anchor address for this project": "There is not anchor address for this project", "DRAFT_DONATION_DISABLED": "Draft donation is disabled", "EVM_SUPPORT_ONLY": "Only EVM support", + "EVM_AND_STELLAR_SUPPORT_ONLY": "Only EVM and Stellar support", "Recurring donation not found": "Recurring donation not found", "Recurring donation not found.": "Recurring donation not found.", "INVALID_PROJECT_ID": "INVALID_PROJECT_ID", "TX_NOT_FOUND": "TX_NOT_FOUND", "PROJECT_DOESNT_ACCEPT_RECURRING_DONATION": "PROJECT_DOESNT_ACCEPT_RECURRING_DONATION", - "Project does not accept recurring donation": "Project does not accept recurring donation" + "Project does not accept recurring donation": "Project does not accept recurring donation", + "QR_CODE_DATA_URL_REQUIRED": "QR_CODE_DATA_URL_REQUIRED" } \ No newline at end of file diff --git a/src/utils/locales/es.json b/src/utils/locales/es.json index 1292916d6..7f8d184f5 100644 --- a/src/utils/locales/es.json +++ b/src/utils/locales/es.json @@ -5,6 +5,7 @@ "FIAT_DONATION_ALREADY_EXISTS": "La donación Fiat ya existe", "ONRAMPER_SIGNATURE_INVALID": "El cuerpo o firma son invalidos", "ONRAMPER_SIGNATURE_MISSING": "El encabezado no continue la firma", + "ONLY_DRAFTED_PROJECTS_CAN_BE_DELETED": "Solo los proyectos en borrador pueden ser eliminados", "UPLOAD_FAILED": "No fue posible subir el archivo", "SPECIFY_GIV_POWER_ADAPTER": "Especificar adaptador givPower", "CHANGE_API_INVALID_TITLE_OR_EIN": "ChangeAPI título de API o EIN no encontrado o no válido", @@ -104,5 +105,6 @@ "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", "DRAFT_DONATION_DISABLED": "El borrador de donación está deshabilitado", - "EVM_SUPPORT_ONLY": "Solo se admite EVM" + "EVM_SUPPORT_ONLY": "Solo se admite EVM", + "EVM_AND_STELLAR_SUPPORT_ONLY": "Solo se admite EVM y Stellar" } diff --git a/src/utils/networks.ts b/src/utils/networks.ts index 53f26a561..1bc6fe4f2 100644 --- a/src/utils/networks.ts +++ b/src/utils/networks.ts @@ -1,5 +1,6 @@ import { ethers } from 'ethers'; import { PublicKey } from '@solana/web3.js'; +import { StrKey } from '@stellar/stellar-sdk'; import { ChainType } from '../types/network'; import networksConfig from './networksConfig'; @@ -18,6 +19,9 @@ export const isEvmAddress = (address: string): boolean => { return ethers.utils.isAddress(address); }; +export const isStellarAddress = (address: string): boolean => + StrKey.isValidEd25519PublicKey(address.trim()); + export const detectAddressChainType = ( address: string, ): ChainType | undefined => { @@ -26,6 +30,8 @@ export const detectAddressChainType = ( return ChainType.SOLANA; case isEvmAddress(address): return ChainType.EVM; + case isStellarAddress(address): + return ChainType.STELLAR; default: return undefined; } diff --git a/src/utils/networksConfig.ts b/src/utils/networksConfig.ts index 1069d967d..785e70178 100644 --- a/src/utils/networksConfig.ts +++ b/src/utils/networksConfig.ts @@ -2,8 +2,8 @@ const networksConfig = { '1': { blockExplorer: 'https://etherscan.io/', }, - '5': { - blockExplorer: 'https://goerli.etherscan.io/', + '11155111': { + blockExplorer: 'https://sepolia.etherscan.io/', }, '10': { blockExplorer: 'https://optimistic.etherscan.io/', diff --git a/src/utils/validators/graphqlQueryValidators.ts b/src/utils/validators/graphqlQueryValidators.ts index 0d0f6f0a0..069b760f0 100644 --- a/src/utils/validators/graphqlQueryValidators.ts +++ b/src/utils/validators/graphqlQueryValidators.ts @@ -19,6 +19,7 @@ const resourcePerDateRegex = new RegExp( const ethereumWalletAddressRegex = /^0x[a-fA-F0-9]{40}$/; const solanaWalletAddressRegex = /^[A-Za-z0-9]{43,44}$/; +const stellarWalletAddressRegex = /^[A-Za-z0-9]{56}$/; const solanaProgramIdRegex = /^(11111111111111111111111111111111|[1-9A-HJ-NP-Za-km-z]{43,44})$/; const txHashRegex = /^0x[a-fA-F0-9]{64}$/; @@ -26,6 +27,8 @@ const solanaTxRegex = /^[A-Za-z0-9]{86,88}$/; // TODO: Is this enough? We are us // const tokenSymbolRegex = /^[a-zA-Z0-9]{2,10}$/; // OPTIMISTIC OP token is 2 chars long // const tokenSymbolRegex = /^[a-zA-Z0-9]{2,10}$/; +const dateURLRegex = /^data:image\/png;base64,/; + export const validateWithJoiSchema = (data: any, schema: ObjectSchema) => { const validationResult = schema.validate(data); throwHttpErrorIfJoiValidatorFails(validationResult); @@ -130,9 +133,20 @@ export const createDraftDonationQueryValidator = Joi.object({ .required() .valid(...Object.values(NETWORK_IDS)), tokenAddress: Joi.when('chainType', { - is: ChainType.SOLANA, - then: Joi.string().pattern(solanaProgramIdRegex), - otherwise: Joi.string().pattern(ethereumWalletAddressRegex), + switch: [ + { + is: ChainType.SOLANA, + then: Joi.string().pattern(solanaProgramIdRegex), + }, + { + is: ChainType.STELLAR, + then: Joi.string().pattern(stellarWalletAddressRegex), + }, + { + is: ChainType.EVM, + then: Joi.string().pattern(ethereumWalletAddressRegex), + }, + ], }).messages({ 'string.pattern.base': i18n.__( translationErrorMessagesKeys.INVALID_TOKEN_ADDRESS, @@ -149,6 +163,9 @@ export const createDraftDonationQueryValidator = Joi.object({ chainType: Joi.string().required(), useDonationBox: Joi.boolean(), relevantDonationTxHash: Joi.string().allow(null, ''), + toWalletMemo: Joi.string().allow(null, ''), + qrCodeDataUrl: Joi.string().allow(null, '').pattern(dateURLRegex), + isQRDonation: Joi.boolean(), }); export const createDraftRecurringDonationQueryValidator = Joi.object({ @@ -222,7 +239,9 @@ const managingFundsValidator = Joi.object({ address: Joi.alternatives().try( Joi.string().required().pattern(ethereumWalletAddressRegex), Joi.string().required().pattern(solanaWalletAddressRegex), + Joi.string().required().pattern(stellarWalletAddressRegex), ), + memo: Joi.string().allow('', null).max(24), networkId: Joi.number()?.valid( 0, // frontend may send 0 as a network id for solana, so we should allow it NETWORK_IDS.SOLANA_MAINNET, // Solana @@ -230,7 +249,7 @@ const managingFundsValidator = Joi.object({ NETWORK_IDS.SOLANA_TESTNET, // Solana NETWORK_IDS.MAIN_NET, NETWORK_IDS.ROPSTEN, - NETWORK_IDS.GOERLI, + NETWORK_IDS.SEPOLIA, NETWORK_IDS.POLYGON, NETWORK_IDS.CELO, NETWORK_IDS.CELO_ALFAJORES, @@ -245,9 +264,10 @@ const managingFundsValidator = Joi.object({ NETWORK_IDS.XDAI, NETWORK_IDS.ETC, NETWORK_IDS.MORDOR_ETC_TESTNET, + NETWORK_IDS.STELLAR_MAINNET, ), chainType: Joi.string() - .valid(ChainType.EVM, ChainType.SOLANA) + .valid(ChainType.EVM, ChainType.SOLANA, ChainType.STELLAR) .default(ChainType.EVM), }), ), diff --git a/test/graphqlQueries.ts b/test/graphqlQueries.ts index 5064f07bb..c05b84047 100644 --- a/test/graphqlQueries.ts +++ b/test/graphqlQueries.ts @@ -52,6 +52,11 @@ export const createDraftDonationMutation = ` $anonymous: Boolean $referrerId: String $safeTransactionId: String + $useDonationBox: Boolean + $relevantDonationTxHash: String + $toWalletMemo: String + $qrCodeDataUrl: String + $isQRDonation: Boolean ) { createDraftDonation( networkId: $networkId @@ -63,6 +68,11 @@ export const createDraftDonationMutation = ` anonymous: $anonymous referrerId: $referrerId safeTransactionId: $safeTransactionId + useDonationBox: $useDonationBox + relevantDonationTxHash: $relevantDonationTxHash + toWalletMemo: $toWalletMemo + qrCodeDataUrl: $qrCodeDataUrl + isQRDonation: $isQRDonation ) } `; @@ -120,6 +130,27 @@ export const updateRecurringDonationStatusMutation = ` } } `; +export const renewDraftDonationExpirationDateMutation = ` + mutation ( + $id: Int! + ) { + renewDraftDonationExpirationDate( + id: $id + ) { + expiresAt + } + } +`; + +export const markDraftDonationAsFailedDateMutation = ` + mutation ( + $id: Int! + ) { + markDraftDonationAsFailed( + id: $id + ) + } +`; export const createProjectQuery = ` mutation ($project: CreateProjectInput!) { @@ -1700,6 +1731,27 @@ export const deleteProjectUpdateQuery = ` ) }`; +export const getTokensDetailsQuery = ` + query ( + $address: String! + $networkId: Float! + ) { + getTokensDetails( + address: $address + networkId: $networkId + ) { + id + address + symbol + networkId + chainType + decimals + mainnetAddress + name + } + } +`; + export const editProjectUpdateQuery = ` mutation editProjectUpdate($updateId: Float! $content: String! $title: String!){ @@ -2454,6 +2506,51 @@ export const fetchDonationMetricsQuery = ` } } `; +export const deleteDraftProjectQuery = ` + mutation ($projectId: Float!) { + deleteDraftProject(projectId: $projectId) + } +`; + +export const getDonationByIdQuery = ` + query ( + $id: Int! + ) { + getDonationById( + id: $id + ) { + id + transactionId + transactionNetworkId + toWalletAddress + fromWalletAddress + currency + anonymous + valueUsd + amount + recurringDonation{ + id + txHash + } + user { + id + walletAddress + email + firstName + } + project { + id + } + qfRound { + id + name + isActive + } + createdAt + status + } + } +`; export const fetchRecurringDonationsByDateQuery = ` query ( diff --git a/test/pre-test-scripts.ts b/test/pre-test-scripts.ts index e5316236c..4f9c21347 100644 --- a/test/pre-test-scripts.ts +++ b/test/pre-test-scripts.ts @@ -99,7 +99,7 @@ async function seedTokens() { } await Token.create(tokenData as Token).save(); } - for (const token of SEED_DATA.TOKENS.goerli) { + for (const token of SEED_DATA.TOKENS.sepolia) { const tokenData = { ...token, networkId: 5, @@ -386,7 +386,7 @@ async function relateOrganizationsToTokens() { where: [ { symbol: 'ETH', networkId: NETWORK_IDS.MAIN_NET }, { symbol: 'ETH', networkId: NETWORK_IDS.ROPSTEN }, - { symbol: 'ETH', networkId: NETWORK_IDS.GOERLI }, + { symbol: 'ETH', networkId: NETWORK_IDS.SEPOLIA }, ], }); change.tokens = changeTokens; diff --git a/test/testUtils.ts b/test/testUtils.ts index 99c5aee6e..74fc6c447 100644 --- a/test/testUtils.ts +++ b/test/testUtils.ts @@ -1,6 +1,7 @@ import { assert } from 'chai'; import * as jwt from 'jsonwebtoken'; import { Keypair } from '@solana/web3.js'; +import { Keypair as StellarKeypair } from '@stellar/stellar-sdk'; import config from '../src/config'; import { NETWORK_IDS } from '../src/provider'; import { User } from '../src/entities/user'; @@ -1525,25 +1526,20 @@ export const SEED_DATA = { decimals: 9, }, ], - goerli: [ + sepolia: [ { name: 'Ethereum native token', symbol: 'ETH', address: '0x0000000000000000000000000000000000000000', decimals: 18, + networkId: 11155111, }, { - address: '0xdc31Ee1784292379Fbb2964b3B9C4124D8F89C60', - symbol: 'DAI', - name: 'DAI Goerli', - decimals: 18, - isStableCoin: true, - }, - { - address: '0xA2470F25bb8b53Bd3924C7AC0C68d32BF2aBd5be', - symbol: 'DRGIV3', - name: 'GIV test', + address: '0xfff9976782d46cc05630d1f6ebab18b2324d6b14', + symbol: 'WETH', + name: 'Wrapped Ether', decimals: 18, + networkId: 11155111, }, ], xdai: [ @@ -2049,10 +2045,18 @@ export function generateRandomSolanaAddress(): string { return Keypair.generate().publicKey.toString(); } +export function generateRandomStellarAddress(): string { + return StellarKeypair.random().publicKey(); +} + export function generateRandomEvmTxHash(): string { return `0x${generateHexNumber(64)}`; } +export function generateRandomStellarTxHash(): string { + return generateRandomAlphanumeric(64); +} + export function generateHexNumber(len): string { const hex = '0123456789abcdef'; let output = '';