From 638c9de7281a5f9f38cc8a2e034ca80f9e305a94 Mon Sep 17 00:00:00 2001 From: gelluisaac Date: Thu, 24 Jul 2025 08:19:05 +0100 Subject: [PATCH 1/5] Create Backend API Endpoint for Fundraising Campaign Creation --- package-lock.json | 200 ++++++++++++++++++++++ package.json | 1 + src/components/v1/campaigns.controller.ts | 148 ++++++++++++++++ src/components/v1/campaigns.db.ts | 37 ++++ src/components/v1/campaigns.routes.ts | 30 ++++ src/components/v1/campaigns.validation.ts | 47 +++++ src/components/v1/routes.v1.ts | 3 +- src/types/express/index.d.ts | 13 ++ src/utils/blockchainUtils.ts | 13 ++ src/utils/helper.ts | 13 ++ src/utils/starknetService.ts | 80 +++++++++ 11 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 src/components/v1/campaigns.controller.ts create mode 100644 src/components/v1/campaigns.db.ts create mode 100644 src/components/v1/campaigns.routes.ts create mode 100644 src/components/v1/campaigns.validation.ts create mode 100644 src/types/express/index.d.ts create mode 100644 src/utils/blockchainUtils.ts create mode 100644 src/utils/starknetService.ts diff --git a/package-lock.json b/package-lock.json index 7efc40e..ac6548e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "nodemailer": "^6.9.16", "pg": "^8.13.1", "reflect-metadata": "^0.2.2", + "starknet": "^7.6.4", "typeorm": "^0.3.20", "ulidx": "^2.4.1", "winston": "^3.17.0", @@ -793,6 +794,33 @@ "node": ">=12" } }, + "node_modules/@noble/curves": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.7.0.tgz", + "integrity": "sha512-UTMhXK9SeDhFJVrHeUJ5uZlI6ajXg10O6Ddocf9S6GjbSBVZsJo88HzKwXznNfGpMTRDyJkqMjNDPYgf0qFWnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.6.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.6.0.tgz", + "integrity": "sha512-YUULf0Uk4/mAA89w+k3+yUYh6NrEvxZa5T6SY3wlMvE2chHkxFUUIDI8/XW1QSC357iA5pSnqt7XEhvFOqmDyQ==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -841,12 +869,48 @@ "node": ">=14" } }, + "node_modules/@scure/base": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.1.tgz", + "integrity": "sha512-DGmGtC8Tt63J5GfHgfl5CuAXh96VF/LD8K9Hr/Gv0J2lAoRGlPOMpqMpMbCTOoOJMZCk2Xt+DskdDyn6dEFdzQ==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/starknet": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@scure/starknet/-/starknet-1.1.0.tgz", + "integrity": "sha512-83g3M6Ix2qRsPN4wqLDqiRZ2GBNbjVWfboJE/9UjfG+MHr6oDSu/CWgy8hsBSJejr09DkkL+l0Ze4KVrlCIdtQ==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.7.0", + "@noble/hashes": "~1.6.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sqltools/formatter": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", "license": "MIT" }, + "node_modules/@starknet-io/starknet-types-07": { + "name": "@starknet-io/types-js", + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.7.10.tgz", + "integrity": "sha512-1VtCqX4AHWJlRRSYGSn+4X1mqolI1Tdq62IwzoU2vUuEE72S1OlEeGhpvd6XsdqXcfHmVzYfj8k1XtKBQqwo9w==", + "license": "MIT" + }, + "node_modules/@starknet-io/starknet-types-08": { + "name": "@starknet-io/types-js", + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/@starknet-io/types-js/-/types-js-0.8.4.tgz", + "integrity": "sha512-0RZ3TZHcLsUTQaq1JhDSCM8chnzO4/XNsSCozwDET64JK5bjFDIf2ZUkta+tl5Nlbf4usoU7uZiDI/Q57kt2SQ==", + "license": "MIT" + }, "node_modules/@types/bcryptjs": { "version": "2.4.6", "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", @@ -1267,6 +1331,21 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/abi-wan-kanabi": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/abi-wan-kanabi/-/abi-wan-kanabi-2.2.4.tgz", + "integrity": "sha512-0aA81FScmJCPX+8UvkXLki3X1+yPQuWxEkqXBVKltgPAK79J+NB+Lp5DouMXa7L6f+zcRlIA/6XO7BN/q9fnvg==", + "license": "ISC", + "dependencies": { + "ansicolors": "^0.3.2", + "cardinal": "^2.1.1", + "fs-extra": "^10.0.0", + "yargs": "^17.7.2" + }, + "bin": { + "generate": "dist/generate.js" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1347,6 +1426,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansicolors": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.3.2.tgz", + "integrity": "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg==", + "license": "MIT" + }, "node_modules/ansis": { "version": "3.17.0", "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", @@ -1557,6 +1642,19 @@ "node": ">=6" } }, + "node_modules/cardinal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-2.1.1.tgz", + "integrity": "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw==", + "license": "MIT", + "dependencies": { + "ansicolors": "~0.3.2", + "redeyed": "~2.1.0" + }, + "bin": { + "cdl": "bin/cdl.js" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2131,6 +2229,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", @@ -2466,6 +2577,20 @@ "node": ">= 0.6" } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2631,6 +2756,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2910,6 +3041,18 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -3071,6 +3214,12 @@ "node": ">= 12.0.0" } }, + "node_modules/lossless-json": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lossless-json/-/lossless-json-4.1.1.tgz", + "integrity": "sha512-HusN80C0ohtT9kOHQH7EuUaqzRQsnekpa+2ot8OzvW0iC08dq/YtM/7uKwwajldQsCrHyC8q9fz3t3L+TmDltA==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -3321,6 +3470,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3648,6 +3803,15 @@ "node": ">= 6" } }, + "node_modules/redeyed": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-2.1.1.tgz", + "integrity": "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ==", + "license": "MIT", + "dependencies": { + "esprima": "~4.0.0" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -3995,6 +4159,27 @@ "node": "*" } }, + "node_modules/starknet": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/starknet/-/starknet-7.6.4.tgz", + "integrity": "sha512-FB20IaLCDbh/XomkB+19f5jmNxG+RzNdRO7QUhm7nfH81UPIt2C/MyWAlHCYkbv2wznSEb73wpxbp9tytokTgQ==", + "license": "MIT", + "dependencies": { + "@noble/curves": "1.7.0", + "@noble/hashes": "1.6.0", + "@scure/base": "1.2.1", + "@scure/starknet": "1.1.0", + "@starknet-io/starknet-types-07": "npm:@starknet-io/types-js@~0.7.10", + "@starknet-io/starknet-types-08": "npm:@starknet-io/types-js@~0.8.4", + "abi-wan-kanabi": "2.2.4", + "lossless-json": "^4.0.1", + "pako": "^2.0.4", + "ts-mixer": "^6.0.3" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -4185,6 +4370,12 @@ "typescript": ">=4.8.4" } }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4399,6 +4590,15 @@ "dev": true, "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index 10e5fc7..2790063 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "nodemailer": "^6.9.16", "pg": "^8.13.1", "reflect-metadata": "^0.2.2", + "starknet": "^7.6.4", "typeorm": "^0.3.20", "ulidx": "^2.4.1", "winston": "^3.17.0", diff --git a/src/components/v1/campaigns.controller.ts b/src/components/v1/campaigns.controller.ts new file mode 100644 index 0000000..09b88cb --- /dev/null +++ b/src/components/v1/campaigns.controller.ts @@ -0,0 +1,148 @@ +import { Request, Response } from 'express'; +import { validateCampaignInput } from './campaigns.validation'; +import { createCampaignOnChain } from '../../utils/starknetService'; +import { saveCampaignToDb, isCampaignRefUnique } from './campaigns.db'; +import logAudit from '../../utils/logger'; +import { getUserWalletBalance, verifyTokenContract } from '../../utils/blockchainUtils'; +import { u256FromString, isValidContractAddress } from '../../utils/helper'; +import dayjs from 'dayjs'; + +export const createCampaignController = async (req: Request, res: Response) => { + try { + // Input validation + const { error, value } = validateCampaignInput(req.body); + if (error) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_INPUT', + message: error.message, + details: error.details || {}, + }, + }); + } + const { campaign_ref, target_amount, donation_token } = value; + + // Check campaign_ref uniqueness + const isUnique = await isCampaignRefUnique(campaign_ref); + if (!isUnique) { + return res.status(409).json({ + success: false, + error: { + code: 'DUPLICATE_CAMPAIGN_REF', + message: 'Campaign reference already exists', + details: {}, + }, + }); + } + + // Business logic validation + const userWallet = req.user.walletAddress; + const balance = await getUserWalletBalance(userWallet); + if (!balance || balance.lte(0)) { + return res.status(400).json({ + success: false, + error: { + code: 'INSUFFICIENT_BALANCE', + message: 'Insufficient wallet balance for transaction fees', + details: {}, + }, + }); + } + if (!isValidContractAddress(donation_token)) { + return res.status(400).json({ + success: false, + error: { + code: 'INVALID_CONTRACT_ADDRESS', + message: 'Donation token contract address is invalid', + details: {}, + }, + }); + } + const tokenVerified = await verifyTokenContract(donation_token); + if (!tokenVerified) { + return res.status(400).json({ + success: false, + error: { + code: 'TOKEN_NOT_VERIFIED', + message: 'Donation token contract does not exist or is not verified', + details: {}, + }, + }); + } + + // Interact with Cairo contract + let campaign_id, transaction_hash; + try { + const result = await createCampaignOnChain({ + campaign_ref, + target_amount: u256FromString(target_amount), + donation_token, + userWallet, + }); + campaign_id = result.campaign_id; + transaction_hash = result.transaction_hash; + } catch (err: any) { + // Map contract errors to HTTP status + let status = 500, code = 'CONTRACT_ERROR', message = 'Contract interaction failed'; + if (err.code === 'CAMPAIGN_REF_EMPTY') { + status = 400; code = 'EMPTY_CAMPAIGN_REF'; message = 'Campaign reference is empty'; + } else if (err.code === 'CAMPAIGN_REF_EXISTS') { + status = 409; code = 'DUPLICATE_CAMPAIGN_REF'; message = 'Campaign reference already exists'; + } else if (err.code === 'ZERO_AMOUNT') { + status = 400; code = 'ZERO_TARGET_AMOUNT'; message = 'Target amount must be greater than zero'; + } else if (err.code === 'INVALID_CONTRACT_ADDRESS') { + status = 400; code = 'INVALID_CONTRACT_ADDRESS'; message = 'Donation token contract address is invalid'; + } + return res.status(status).json({ + success: false, + error: { + code, + message, + details: err.details || {}, + }, + }); + } + + // Store campaign metadata in DB + const campaign = await saveCampaignToDb({ + campaign_id, + campaign_ref, + target_amount, + donation_token, + transaction_hash, + user_id: req.user.id, + created_at: dayjs().toISOString(), + }); + + // Audit log + logAudit.info('CAMPAIGN_CREATED', { + user_id: req.user.id, + campaign_id, + campaign_ref, + transaction_hash, + }); + + // Response + return res.status(201).json({ + success: true, + data: { + campaign_id, + campaign_ref, + target_amount, + donation_token, + transaction_hash, + created_at: campaign.created_at, + }, + }); + } catch (err: any) { + return res.status(500).json({ + success: false, + error: { + code: 'INTERNAL_SERVER_ERROR', + message: err.message || 'Internal server error', + details: err.details || {}, + }, + }); + } +}; diff --git a/src/components/v1/campaigns.db.ts b/src/components/v1/campaigns.db.ts new file mode 100644 index 0000000..d458f44 --- /dev/null +++ b/src/components/v1/campaigns.db.ts @@ -0,0 +1,37 @@ +// Database operations for campaigns +// Assumes TypeORM or similar ORM is used +import AppDataSource from '../../config/persistence/data-source'; +// import { Campaign } from './campaign.entity'; // Define this entity as needed + +// Check if campaign_ref is unique +export async function isCampaignRefUnique(campaign_ref: string): Promise { + // Replace with actual DB query + // const repo = AppDataSource.getRepository(Campaign); + // const count = await repo.count({ where: { campaign_ref } }); + // return count === 0; + return true; // Placeholder: always unique +} + +// Save campaign metadata to DB +export async function saveCampaignToDb({ + campaign_id, + campaign_ref, + target_amount, + donation_token, + transaction_hash, + user_id, + created_at, +}: any) { + // Replace with actual DB save logic + // const repo = AppDataSource.getRepository(Campaign); + // const campaign = repo.create({ ... }); + // await repo.save(campaign); + return { + campaign_id, + campaign_ref, + target_amount, + donation_token, + transaction_hash, + created_at, + }; +} diff --git a/src/components/v1/campaigns.routes.ts b/src/components/v1/campaigns.routes.ts new file mode 100644 index 0000000..f4284ee --- /dev/null +++ b/src/components/v1/campaigns.routes.ts @@ -0,0 +1,30 @@ +import { Router } from 'express'; +import { createCampaignController } from './campaigns.controller'; +import { authenticateJWT } from '../../appMiddlewares/auth.middleware'; +import rateLimit from 'express-rate-limit'; + +const router = Router(); + +// Rate limit: max 5 campaigns per user per hour +const createCampaignLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, // 1 hour + max: 5, + keyGenerator: (req) => req.user?.id || req.ip, + message: { + success: false, + error: { + code: 'RATE_LIMIT_EXCEEDED', + message: 'Max 5 campaigns per user per hour', + details: {} + } + } +}); + +router.post( + '/campaigns', + authenticateJWT, + createCampaignLimiter, + createCampaignController +); + +export default router; diff --git a/src/components/v1/campaigns.validation.ts b/src/components/v1/campaigns.validation.ts new file mode 100644 index 0000000..7f6ab19 --- /dev/null +++ b/src/components/v1/campaigns.validation.ts @@ -0,0 +1,47 @@ +import Joi from 'joi'; + +export const campaignSchema = Joi.object({ + campaign_ref: Joi.string() + .length(5) + .trim() + .regex(/^[^\s]+$/) + .required() + .messages({ + 'string.base': 'campaign_ref must be a string', + 'string.length': 'campaign_ref must be exactly 5 characters', + 'string.empty': 'campaign_ref cannot be empty', + 'string.pattern.base': 'campaign_ref cannot contain whitespace', + 'any.required': 'campaign_ref is required', + }), + target_amount: Joi.string() + .pattern(/^\d+$/) + .custom((value, helpers) => { + if (BigInt(value) <= 0n) { + return helpers.error('any.invalid'); + } + // Optionally, check if value fits u256 + if (BigInt(value) > (2n ** 256n - 1n)) { + return helpers.error('any.invalid'); + } + return value; + }, 'u256 validation') + .required() + .messages({ + 'string.base': 'target_amount must be a string', + 'string.pattern.base': 'target_amount must be a positive integer', + 'any.invalid': 'target_amount must be a positive number and valid u256', + 'any.required': 'target_amount is required', + }), + donation_token: Joi.string() + .pattern(/^0x[0-9a-fA-F]{64}$/) + .required() + .messages({ + 'string.base': 'donation_token must be a string', + 'string.pattern.base': 'donation_token must be a valid contract address', + 'any.required': 'donation_token is required', + }), +}); + +export function validateCampaignInput(input: any) { + return campaignSchema.validate(input, { abortEarly: false }); +} diff --git a/src/components/v1/routes.v1.ts b/src/components/v1/routes.v1.ts index 83a9bea..cbf1b4b 100644 --- a/src/components/v1/routes.v1.ts +++ b/src/components/v1/routes.v1.ts @@ -3,12 +3,13 @@ import EnhancedRouter from '../../utils/enhancedRouter'; import platformRoutes from './platform/platform.routes'; import walletRoutes from './wallet/wallet.routes'; import distributionRoutes from "./distribution/distrubtion.routes" - +import campaignsRoutes from './campaigns.routes'; const routerV1 = new EnhancedRouter(); routerV1.use('/platform', platformRoutes); routerV1.use('/wallets', walletRoutes); routerV1.use("/distributions", distributionRoutes) +routerV1.use('/campaigns', campaignsRoutes); export default routerV1.getRouter(); diff --git a/src/types/express/index.d.ts b/src/types/express/index.d.ts new file mode 100644 index 0000000..2c6e9ea --- /dev/null +++ b/src/types/express/index.d.ts @@ -0,0 +1,13 @@ +import 'express'; + +declare module 'express' { + export interface User { + id: string; + walletAddress: string; + // add other user properties as needed + } + + export interface Request { + user: User; + } +} diff --git a/src/utils/blockchainUtils.ts b/src/utils/blockchainUtils.ts new file mode 100644 index 0000000..32a2754 --- /dev/null +++ b/src/utils/blockchainUtils.ts @@ -0,0 +1,13 @@ +// Blockchain utility functions for wallet balance and token contract verification +// These are placeholders and should be implemented with actual StarkNet provider logic + +export async function getUserWalletBalance(walletAddress: string) { + // TODO: Implement actual balance check using StarkNet provider + // Return a BigNumber or similar + return { lte: (n: number) => false }; // Placeholder: always has balance +} + +export async function verifyTokenContract(contractAddress: string) { + // TODO: Implement actual contract existence and verification check + return true; // Placeholder: always verified +} diff --git a/src/utils/helper.ts b/src/utils/helper.ts index 986b579..6b1fc7a 100644 --- a/src/utils/helper.ts +++ b/src/utils/helper.ts @@ -41,3 +41,16 @@ export function isValidBase64String(field: string) { return isBase64; } + +// Convert string to u256 (as {low, high} object for starknet.js) +export function u256FromString(value: string) { + const big = BigInt(value); + const low = Number(big & BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF')); + const high = Number(big >> 128n); + return { low, high }; +} + +// Validate StarkNet contract address (0x-prefixed, 64 hex chars) +export function isValidContractAddress(address: string) { + return /^0x[0-9a-fA-F]{64}$/.test(address); +} diff --git a/src/utils/starknetService.ts b/src/utils/starknetService.ts new file mode 100644 index 0000000..0c1897b --- /dev/null +++ b/src/utils/starknetService.ts @@ -0,0 +1,80 @@ +// This service handles interaction with the Cairo smart contract using starknet.js +import { RpcProvider, Account, Contract, uint256 } from 'starknet'; + +// TODO: Replace with your actual contract address and ABI +const CONTRACT_ADDRESS = process.env.CAMPAIGN_CONTRACT_ADDRESS || ''; +const CONTRACT_ABI = require('../../fundable/abi/campaign_donation.json'); // You need to generate ABI JSON + +// Adjust provider instantiation for latest starknet.js +// Use RpcProvider for mainnet-alpha (JSON-RPC endpoint) +const provider = new RpcProvider({ + nodeUrl: 'https://starknet-mainnet.public.blastapi.io/rpc/v0_6', // Example public mainnet RPC endpoint +}); + +// This function assumes you have a way to get the user's private key for signing +// Interface for campaign creation parameters +export interface U256 { + low: number; + high: number; +} + +export interface CreateCampaignParams { + campaign_ref: string; + target_amount: U256; + donation_token: string; + userWallet: string; +} + +export async function createCampaignOnChain({ campaign_ref, target_amount, donation_token, userWallet }: CreateCampaignParams) { + // You must implement secure key management for production + const privateKey = await getUserPrivateKey(userWallet); // Implement this securely + const account = new Account(provider, userWallet, privateKey); + const contract = new Contract(CONTRACT_ABI, CONTRACT_ADDRESS, account); + + try { + const tx = await contract.create_campaign( + campaign_ref, + target_amount, + donation_token + ); + // Wait for transaction to be accepted + const receipt = await provider.waitForTransaction(tx.transaction_hash); + // Extract campaign_id from events or return value + const campaign_id = extractCampaignIdFromReceipt(receipt); // Implement this + return { + campaign_id, + transaction_hash: tx.transaction_hash, + }; + } catch (err) { + // Map contract errors to codes + throw mapStarknetError(err); + } +} + +// Placeholder for extracting campaign_id from transaction receipt +interface CampaignEvent { + event_type: string; + data: { campaign_id?: string }; +} +interface TransactionReceipt { + events?: CampaignEvent[]; + [key: string]: any; +} +function extractCampaignIdFromReceipt(receipt: TransactionReceipt): string | null { + // Parse events or return value to get campaign_id + // ... + return receipt.events?.find(e => e.event_type === 'Campaign')?.data?.campaign_id || null; +} + +// Placeholder for error mapping +function mapStarknetError(err: unknown): unknown { + // Map known error messages to codes + // ... + return err; +} + +// Placeholder for secure key management +async function getUserPrivateKey(userWallet: string): Promise { + // Implement secure retrieval of user's private key + throw new Error('getUserPrivateKey not implemented'); +} From 114edac974a9fd35e44e71753547f6597b74f660 Mon Sep 17 00:00:00 2001 From: gelluisaac Date: Fri, 25 Jul 2025 13:23:06 +0100 Subject: [PATCH 2/5] Update Create Backend API Endpoint for Fundraising Campaign Creation --- package.json | 1 + src/appMiddlewares/auth.middleware.ts | 39 +++ src/appMiddlewares/rateLimit.middleware.ts | 31 ++ src/components/v1/campaigns.db.ts | 37 --- .../v1/campaigns/campaign.entity.ts | 25 ++ .../{ => campaigns}/campaigns.controller.ts | 38 ++- src/components/v1/campaigns/campaigns.db.ts | 62 ++++ .../v1/{ => campaigns}/campaigns.routes.ts | 12 +- .../{ => campaigns}/campaigns.validation.ts | 2 +- .../v1/{ => campaigns}/routes.v1.ts | 8 +- src/index.ts | 2 +- src/types/auth.ts | 6 + src/types/campaign/index.ts | 37 +++ src/types/express/index.d.ts | 18 +- src/utils/blockchainUtils.ts | 62 +++- src/utils/distributor.abi.json | 295 ++++++++++++++++++ src/utils/starknetService.ts | 150 +++++---- 17 files changed, 694 insertions(+), 131 deletions(-) create mode 100644 src/appMiddlewares/auth.middleware.ts create mode 100644 src/appMiddlewares/rateLimit.middleware.ts delete mode 100644 src/components/v1/campaigns.db.ts create mode 100644 src/components/v1/campaigns/campaign.entity.ts rename src/components/v1/{ => campaigns}/campaigns.controller.ts (78%) create mode 100644 src/components/v1/campaigns/campaigns.db.ts rename src/components/v1/{ => campaigns}/campaigns.routes.ts (64%) rename src/components/v1/{ => campaigns}/campaigns.validation.ts (95%) rename src/components/v1/{ => campaigns}/routes.v1.ts (56%) create mode 100644 src/types/auth.ts create mode 100644 src/types/campaign/index.ts create mode 100644 src/utils/distributor.abi.json diff --git a/package.json b/package.json index 2790063..3fcaf1f 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "express-rate-limit": "^7.5.0", "helmet": "^7.2.0", "inversify": "^7.5.4", + "joi": "^17.13.3", "jsonwebtoken": "^9.0.2", "ms": "^2.1.3", "nodemailer": "^6.9.16", diff --git a/src/appMiddlewares/auth.middleware.ts b/src/appMiddlewares/auth.middleware.ts new file mode 100644 index 0000000..65c4346 --- /dev/null +++ b/src/appMiddlewares/auth.middleware.ts @@ -0,0 +1,39 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; + +const JWT_SECRET = process.env.JWT_SECRET || 'your_jwt_secret'; + +export interface AuthUser { + id: string; + walletAddress: string; +} + +export function authMiddleware(req: Request, res: Response, next: NextFunction): void { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + res.status(401).json({ + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'No token provided', + details: {}, + }, + }); + return; + } + const token = authHeader.split(' ')[1]; + try { + const decoded = jwt.verify(token, JWT_SECRET) as AuthUser; + req.user = decoded; // Now properly typed + next(); + } catch (err) { + res.status(401).json({ + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'Invalid or expired token', + details: {}, + }, + }); + } +} diff --git a/src/appMiddlewares/rateLimit.middleware.ts b/src/appMiddlewares/rateLimit.middleware.ts new file mode 100644 index 0000000..2073617 --- /dev/null +++ b/src/appMiddlewares/rateLimit.middleware.ts @@ -0,0 +1,31 @@ +import { Request, Response, NextFunction } from 'express'; +import { saveUserCampaignRequest, getUserCampaignCountLastHour } from '../components/v1/campaigns/campaigns.db'; + +// Middleware to limit to 5 campaigns per user per hour +export const campaignRateLimit = async (req: Request, res: Response, next: NextFunction) => { + if (!req.user || !req.user.id) { + return res.status(401).json({ + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'User not authenticated', + details: {}, + }, + }); + } + const userId = req.user.id; + const count = await getUserCampaignCountLastHour(userId); + if (count >= 5) { + return res.status(429).json({ + success: false, + error: { + code: 'RATE_LIMIT_EXCEEDED', + message: 'Maximum 5 campaigns per user per hour exceeded', + details: {}, + }, + }); + } + // Log this request for future rate limit checks + await saveUserCampaignRequest(userId); + next(); +}; diff --git a/src/components/v1/campaigns.db.ts b/src/components/v1/campaigns.db.ts deleted file mode 100644 index d458f44..0000000 --- a/src/components/v1/campaigns.db.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Database operations for campaigns -// Assumes TypeORM or similar ORM is used -import AppDataSource from '../../config/persistence/data-source'; -// import { Campaign } from './campaign.entity'; // Define this entity as needed - -// Check if campaign_ref is unique -export async function isCampaignRefUnique(campaign_ref: string): Promise { - // Replace with actual DB query - // const repo = AppDataSource.getRepository(Campaign); - // const count = await repo.count({ where: { campaign_ref } }); - // return count === 0; - return true; // Placeholder: always unique -} - -// Save campaign metadata to DB -export async function saveCampaignToDb({ - campaign_id, - campaign_ref, - target_amount, - donation_token, - transaction_hash, - user_id, - created_at, -}: any) { - // Replace with actual DB save logic - // const repo = AppDataSource.getRepository(Campaign); - // const campaign = repo.create({ ... }); - // await repo.save(campaign); - return { - campaign_id, - campaign_ref, - target_amount, - donation_token, - transaction_hash, - created_at, - }; -} diff --git a/src/components/v1/campaigns/campaign.entity.ts b/src/components/v1/campaigns/campaign.entity.ts new file mode 100644 index 0000000..e0cd212 --- /dev/null +++ b/src/components/v1/campaigns/campaign.entity.ts @@ -0,0 +1,25 @@ +import { Entity, PrimaryColumn, Column } from 'typeorm'; + +@Entity('campaigns') +export class Campaign { + @PrimaryColumn() + campaign_id: string; + + @Column({ unique: true }) + campaign_ref: string; + + @Column() + target_amount: string; + + @Column() + donation_token: string; + + @Column() + transaction_hash: string; + + @Column() + user_id: string; + + @Column() + created_at: string; +} diff --git a/src/components/v1/campaigns.controller.ts b/src/components/v1/campaigns/campaigns.controller.ts similarity index 78% rename from src/components/v1/campaigns.controller.ts rename to src/components/v1/campaigns/campaigns.controller.ts index 09b88cb..8401cc0 100644 --- a/src/components/v1/campaigns.controller.ts +++ b/src/components/v1/campaigns/campaigns.controller.ts @@ -1,10 +1,10 @@ import { Request, Response } from 'express'; import { validateCampaignInput } from './campaigns.validation'; -import { createCampaignOnChain } from '../../utils/starknetService'; +import { createCampaignOnChain } from '../../../utils/starknetService'; import { saveCampaignToDb, isCampaignRefUnique } from './campaigns.db'; -import logAudit from '../../utils/logger'; -import { getUserWalletBalance, verifyTokenContract } from '../../utils/blockchainUtils'; -import { u256FromString, isValidContractAddress } from '../../utils/helper'; +import logAudit from '../../../utils/logger'; +import { getUserWalletBalance, verifyTokenContract } from '../../../utils/blockchainUtils'; +import { u256FromString, isValidContractAddress } from '../../../utils/helper'; import dayjs from 'dayjs'; export const createCampaignController = async (req: Request, res: Response) => { @@ -37,9 +37,19 @@ export const createCampaignController = async (req: Request, res: Response) => { } // Business logic validation + if (!req.user || !req.user.walletAddress) { + return res.status(401).json({ + success: false, + error: { + code: 'UNAUTHORIZED', + message: 'User not authenticated', + details: {}, + }, + }); + } const userWallet = req.user.walletAddress; - const balance = await getUserWalletBalance(userWallet); - if (!balance || balance.lte(0)) { + const balance = await getUserWalletBalance(userWallet, donation_token); + if (!balance || balance.lte(0n)) { return res.status(400).json({ success: false, error: { @@ -80,18 +90,22 @@ export const createCampaignController = async (req: Request, res: Response) => { donation_token, userWallet, }); + if (!result.campaign_id) { + throw new Error('Failed to retrieve campaign_id from transaction'); + } campaign_id = result.campaign_id; transaction_hash = result.transaction_hash; - } catch (err: any) { + } catch (err) { + const error = err as { code?: string; message?: string; details?: any }; // Map contract errors to HTTP status let status = 500, code = 'CONTRACT_ERROR', message = 'Contract interaction failed'; - if (err.code === 'CAMPAIGN_REF_EMPTY') { + if (error.code === 'CAMPAIGN_REF_EMPTY') { status = 400; code = 'EMPTY_CAMPAIGN_REF'; message = 'Campaign reference is empty'; - } else if (err.code === 'CAMPAIGN_REF_EXISTS') { + } else if (error.code === 'CAMPAIGN_REF_EXISTS') { status = 409; code = 'DUPLICATE_CAMPAIGN_REF'; message = 'Campaign reference already exists'; - } else if (err.code === 'ZERO_AMOUNT') { + } else if (error.code === 'ZERO_TARGET_AMOUNT') { status = 400; code = 'ZERO_TARGET_AMOUNT'; message = 'Target amount must be greater than zero'; - } else if (err.code === 'INVALID_CONTRACT_ADDRESS') { + } else if (error.code === 'INVALID_CONTRACT_ADDRESS') { status = 400; code = 'INVALID_CONTRACT_ADDRESS'; message = 'Donation token contract address is invalid'; } return res.status(status).json({ @@ -99,7 +113,7 @@ export const createCampaignController = async (req: Request, res: Response) => { error: { code, message, - details: err.details || {}, + details: error.details || {}, }, }); } diff --git a/src/components/v1/campaigns/campaigns.db.ts b/src/components/v1/campaigns/campaigns.db.ts new file mode 100644 index 0000000..3a8f476 --- /dev/null +++ b/src/components/v1/campaigns/campaigns.db.ts @@ -0,0 +1,62 @@ +// Database operations for campaigns +// Assumes TypeORM or similar ORM is used +import AppDataSource from '../../../config/persistence/data-source'; +import { Campaign} from './campaign.entity' + +// Check if campaign_ref is unique +export async function isCampaignRefUnique(campaign_ref: string): Promise { + const repo = AppDataSource.getRepository(Campaign); + const count = await repo.count({ where: { campaign_ref } }); + return count === 0; +} + +// Save campaign metadata to DB +import { CampaignData } from '../../../types/campaign'; + +export async function saveCampaignToDb({ + campaign_id, + campaign_ref, + target_amount, + donation_token, + transaction_hash, + user_id, + created_at, +}: CampaignData): Promise { + const repo = AppDataSource.getRepository(Campaign); + const campaign = repo.create({ + campaign_id, + campaign_ref, + target_amount, + donation_token, + transaction_hash, + user_id, + created_at, + }); + await repo.save(campaign); + return { + campaign_id, + campaign_ref, + target_amount, + donation_token, + transaction_hash, + user_id, + created_at, + }; +} + +// --- Rate limiting support --- +// Assumes a CampaignRequest entity/table with user_id and created_at fields +import { MoreThan } from 'typeorm'; + +export async function saveUserCampaignRequest(userId: string): Promise { + // You need a CampaignRequest entity for this to work + const repo = AppDataSource.getRepository('CampaignRequest'); + await repo.insert({ user_id: userId, created_at: new Date().toISOString() }); +} + +export async function getUserCampaignCountLastHour(userId: string): Promise { + // You need a CampaignRequest entity for this to work + const repo = AppDataSource.getRepository('CampaignRequest'); + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + return await repo.count({ where: { user_id: userId, created_at: MoreThan(oneHourAgo) } }); +} diff --git a/src/components/v1/campaigns.routes.ts b/src/components/v1/campaigns/campaigns.routes.ts similarity index 64% rename from src/components/v1/campaigns.routes.ts rename to src/components/v1/campaigns/campaigns.routes.ts index f4284ee..8d624cb 100644 --- a/src/components/v1/campaigns.routes.ts +++ b/src/components/v1/campaigns/campaigns.routes.ts @@ -1,15 +1,17 @@ -import { Router } from 'express'; +import { Router, Request } from 'express'; import { createCampaignController } from './campaigns.controller'; -import { authenticateJWT } from '../../appMiddlewares/auth.middleware'; import rateLimit from 'express-rate-limit'; +import { authMiddleware } from 'src/appMiddlewares/auth.middleware'; const router = Router(); +// Express Request is globally augmented for user property via types/express/index.d.ts + // Rate limit: max 5 campaigns per user per hour const createCampaignLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1 hour max: 5, - keyGenerator: (req) => req.user?.id || req.ip, + keyGenerator: (req) => req.user?.id || req.ip || '', message: { success: false, error: { @@ -22,9 +24,9 @@ const createCampaignLimiter = rateLimit({ router.post( '/campaigns', - authenticateJWT, + authMiddleware, createCampaignLimiter, createCampaignController ); -export default router; +export default router; \ No newline at end of file diff --git a/src/components/v1/campaigns.validation.ts b/src/components/v1/campaigns/campaigns.validation.ts similarity index 95% rename from src/components/v1/campaigns.validation.ts rename to src/components/v1/campaigns/campaigns.validation.ts index 7f6ab19..a5e8e4d 100644 --- a/src/components/v1/campaigns.validation.ts +++ b/src/components/v1/campaigns/campaigns.validation.ts @@ -42,6 +42,6 @@ export const campaignSchema = Joi.object({ }), }); -export function validateCampaignInput(input: any) { +export function validateCampaignInput(input: unknown): Joi.ValidationResult { return campaignSchema.validate(input, { abortEarly: false }); } diff --git a/src/components/v1/routes.v1.ts b/src/components/v1/campaigns/routes.v1.ts similarity index 56% rename from src/components/v1/routes.v1.ts rename to src/components/v1/campaigns/routes.v1.ts index cbf1b4b..a2571b4 100644 --- a/src/components/v1/routes.v1.ts +++ b/src/components/v1/campaigns/routes.v1.ts @@ -1,8 +1,8 @@ -import EnhancedRouter from '../../utils/enhancedRouter'; +import EnhancedRouter from '../../../utils/enhancedRouter'; -import platformRoutes from './platform/platform.routes'; -import walletRoutes from './wallet/wallet.routes'; -import distributionRoutes from "./distribution/distrubtion.routes" +import platformRoutes from '../platform/platform.routes'; +import walletRoutes from '../wallet/wallet.routes'; +import distributionRoutes from "../distribution/distrubtion.routes" import campaignsRoutes from './campaigns.routes'; const routerV1 = new EnhancedRouter(); diff --git a/src/index.ts b/src/index.ts index 6545f5b..4b3a4cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,7 @@ import cors from 'cors'; import fingerprintMiddleware from './appMiddlewares/fingerprint.middleware'; import { verifyAllowedMethods } from './appMiddlewares'; -import routerV1 from './components/v1/routes.v1'; +import routerV1 from './components/v1/campaigns/routes.v1'; import { AppError, diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..3c0e374 --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,6 @@ +// src/types/auth.ts +export interface AuthUser { + id: string; + walletAddress: string; + // add more fields if needed +} diff --git a/src/types/campaign/index.ts b/src/types/campaign/index.ts new file mode 100644 index 0000000..91f3591 --- /dev/null +++ b/src/types/campaign/index.ts @@ -0,0 +1,37 @@ +export interface CampaignData { + campaign_id: string; + campaign_ref: string; + target_amount: string; + donation_token: string; + transaction_hash: string; + user_id: string; + created_at: string; +} + +export interface U256 { + low: number; + high: number; +} + +export interface CreateCampaignParams { + campaign_ref: string; + target_amount: U256; + donation_token: string; + userWallet: string; +} + +export interface CampaignEvent { + event_type: string; + data: { campaign_id?: string }; +} + +export interface TransactionReceipt { + events?: CampaignEvent[]; + [key: string]: unknown; +} + +export interface StarknetError { + code?: string; + message?: string; + details?: unknown; +} diff --git a/src/types/express/index.d.ts b/src/types/express/index.d.ts index 2c6e9ea..c6aa833 100644 --- a/src/types/express/index.d.ts +++ b/src/types/express/index.d.ts @@ -1,13 +1,11 @@ -import 'express'; +import { AuthUser } from '../../appMiddlewares/auth.middleware'; -declare module 'express' { - export interface User { - id: string; - walletAddress: string; - // add other user properties as needed - } - - export interface Request { - user: User; +declare global { + namespace Express { + interface Request { + user?: AuthUser; + } } } + +export {}; diff --git a/src/utils/blockchainUtils.ts b/src/utils/blockchainUtils.ts index 32a2754..3f5930a 100644 --- a/src/utils/blockchainUtils.ts +++ b/src/utils/blockchainUtils.ts @@ -1,13 +1,59 @@ // Blockchain utility functions for wallet balance and token contract verification -// These are placeholders and should be implemented with actual StarkNet provider logic +import { RpcProvider, Contract, uint256 } from 'starknet'; -export async function getUserWalletBalance(walletAddress: string) { - // TODO: Implement actual balance check using StarkNet provider - // Return a BigNumber or similar - return { lte: (n: number) => false }; // Placeholder: always has balance +// You may want to move these to a config file +const STARKNET_RPC_URL = process.env.STARKNET_RPC_URL || 'https://starknet-mainnet.public.blastapi.io/rpc/v0_6'; +if (!STARKNET_RPC_URL) { + throw new Error('STARKNET_RPC_URL environment variable is required'); } +const provider = new RpcProvider({ nodeUrl: STARKNET_RPC_URL }); -export async function verifyTokenContract(contractAddress: string) { - // TODO: Implement actual contract existence and verification check - return true; // Placeholder: always verified +// Standard ERC20 ABI fragment for balanceOf and symbol +const ERC20_ABI = [ + { + "inputs": [{ "name": "account", "type": "felt" }], + "name": "balanceOf", + "outputs": [{ "name": "balance", "type": "Uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "symbol", + "outputs": [{ "name": "symbol", "type": "felt" }], + "stateMutability": "view", + "type": "function" + } +]; + +// Returns a BigInt balance (in u256) for the given wallet and ERC20 contract +export async function getUserWalletBalance(walletAddress: string, tokenAddress: string): Promise<{ lte: (n: bigint) => boolean, value: bigint }> { + try { + const contract = new Contract(ERC20_ABI, tokenAddress, provider); + const { balance } = await contract.balanceOf(walletAddress); + // Convert StarkNet Uint256 to BigInt + const value = uint256.uint256ToBN(balance); + return { + lte: (n: bigint) => value <= n, + value + }; + } catch (error) { + // If contract call fails, treat as zero balance + return { + lte: () => true, + value: 0n + }; + } +} + +// Verifies if a contract exists and is a valid ERC20 (has symbol method) +export async function verifyTokenContract(contractAddress: string): Promise { + try { + const contract = new Contract(ERC20_ABI, contractAddress, provider); + // Try calling symbol (should not throw if valid ERC20) + await contract.symbol(); + return true; + } catch (error) { + return false; + } } diff --git a/src/utils/distributor.abi.json b/src/utils/distributor.abi.json new file mode 100644 index 0000000..6235618 --- /dev/null +++ b/src/utils/distributor.abi.json @@ -0,0 +1,295 @@ +[ + { + "name": "DistributorImpl", + "type": "impl", + "interface_name": "fundable::interfaces::IDistributor::IDistributor" + }, + { + "name": "core::integer::u256", + "type": "struct", + "members": [ + { "name": "low", "type": "core::integer::u128" }, + { "name": "high", "type": "core::integer::u128" } + ] + }, + { + "name": "fundable::base::types::TokenStats", + "type": "struct", + "members": [ + { "name": "total_amount", "type": "core::integer::u256" }, + { "name": "distribution_count", "type": "core::integer::u256" }, + { "name": "last_distribution_time", "type": "core::integer::u64" }, + { "name": "unique_recipients", "type": "core::integer::u256" } + ] + }, + { + "name": "fundable::base::types::UserStats", + "type": "struct", + "members": [ + { "name": "distributions_initiated", "type": "core::integer::u256" }, + { "name": "total_amount_distributed", "type": "core::integer::u256" }, + { "name": "last_distribution_time", "type": "core::integer::u64" }, + { "name": "unique_tokens_used", "type": "core::integer::u256" } + ] + }, + { + "name": "fundable::base::types::DistributionHistory", + "type": "struct", + "members": [ + { "name": "caller", "type": "core::starknet::contract_address::ContractAddress" }, + { "name": "token", "type": "core::starknet::contract_address::ContractAddress" }, + { "name": "amount", "type": "core::integer::u256" }, + { "name": "recipients_count", "type": "core::integer::u32" }, + { "name": "timestamp", "type": "core::integer::u64" } + ] + }, + { + "name": "fundable::interfaces::IDistributor::IDistributor", + "type": "interface", + "items": [ + { + "name": "distribute", + "type": "function", + "inputs": [ + { "name": "amount", "type": "core::integer::u256" }, + { "name": "recipients", "type": "core::array::Array::" }, + { "name": "token", "type": "core::starknet::contract_address::ContractAddress" } + ], + "outputs": [], + "state_mutability": "external" + }, + { + "name": "distribute_weighted", + "type": "function", + "inputs": [ + { "name": "amounts", "type": "core::array::Array::" }, + { "name": "recipients", "type": "core::array::Array::" }, + { "name": "token", "type": "core::starknet::contract_address::ContractAddress" } + ], + "outputs": [], + "state_mutability": "external" + }, + { + "name": "get_protocol_fee_percent", + "type": "function", + "inputs": [], + "outputs": [ { "type": "core::integer::u256" } ], + "state_mutability": "view" + }, + { + "name": "set_protocol_fee_percent", + "type": "function", + "inputs": [ { "name": "new_fee_percent", "type": "core::integer::u256" } ], + "outputs": [], + "state_mutability": "external" + }, + { + "name": "get_protocol_fee_address", + "type": "function", + "inputs": [], + "outputs": [ { "type": "core::starknet::contract_address::ContractAddress" } ], + "state_mutability": "view" + }, + { + "name": "set_protocol_fee_address", + "type": "function", + "inputs": [ { "name": "new_fee_address", "type": "core::starknet::contract_address::ContractAddress" } ], + "outputs": [], + "state_mutability": "external" + }, + { + "name": "get_balance", + "type": "function", + "inputs": [], + "outputs": [ { "type": "core::integer::u256" } ], + "state_mutability": "view" + }, + { + "name": "get_total_distributions", + "type": "function", + "inputs": [], + "outputs": [ { "type": "core::integer::u256" } ], + "state_mutability": "view" + }, + { + "name": "get_total_distributed_amount", + "type": "function", + "inputs": [], + "outputs": [ { "type": "core::integer::u256" } ], + "state_mutability": "view" + }, + { + "name": "get_token_stats", + "type": "function", + "inputs": [ { "name": "token", "type": "core::starknet::contract_address::ContractAddress" } ], + "outputs": [ { "type": "fundable::base::types::TokenStats" } ], + "state_mutability": "view" + }, + { + "name": "get_user_stats", + "type": "function", + "inputs": [ { "name": "user", "type": "core::starknet::contract_address::ContractAddress" } ], + "outputs": [ { "type": "fundable::base::types::UserStats" } ], + "state_mutability": "view" + }, + { + "name": "get_distribution_history", + "type": "function", + "inputs": [ + { "name": "start_id", "type": "core::integer::u256" }, + { "name": "limit", "type": "core::integer::u256" } + ], + "outputs": [ { "type": "core::array::Array::" } ], + "state_mutability": "view" + } + ] + }, + { + "name": "UpgradeableImpl", + "type": "impl", + "interface_name": "openzeppelin_upgrades::interface::IUpgradeable" + }, + { + "name": "openzeppelin_upgrades::interface::IUpgradeable", + "type": "interface", + "items": [ + { + "name": "upgrade", + "type": "function", + "inputs": [ { "name": "new_class_hash", "type": "core::starknet::class_hash::ClassHash" } ], + "outputs": [], + "state_mutability": "external" + } + ] + }, + { + "name": "OwnableMixinImpl", + "type": "impl", + "interface_name": "openzeppelin_access::ownable::interface::OwnableABI" + }, + { + "name": "openzeppelin_access::ownable::interface::OwnableABI", + "type": "interface", + "items": [ + { + "name": "owner", + "type": "function", + "inputs": [], + "outputs": [ { "type": "core::starknet::contract_address::ContractAddress" } ], + "state_mutability": "view" + }, + { + "name": "transfer_ownership", + "type": "function", + "inputs": [ { "name": "new_owner", "type": "core::starknet::contract_address::ContractAddress" } ], + "outputs": [], + "state_mutability": "external" + }, + { + "name": "renounce_ownership", + "type": "function", + "inputs": [], + "outputs": [], + "state_mutability": "external" + }, + { + "name": "transferOwnership", + "type": "function", + "inputs": [ { "name": "newOwner", "type": "core::starknet::contract_address::ContractAddress" } ], + "outputs": [], + "state_mutability": "external" + }, + { + "name": "renounceOwnership", + "type": "function", + "inputs": [], + "outputs": [], + "state_mutability": "external" + } + ] + }, + { + "name": "constructor", + "type": "constructor", + "inputs": [ + { "name": "protocol_fee_address", "type": "core::starknet::contract_address::ContractAddress" }, + { "name": "owner", "type": "core::starknet::contract_address::ContractAddress" } + ] + }, + { + "kind": "struct", + "name": "fundable::base::types::Distribution", + "type": "event", + "members": [ + { "kind": "key", "name": "caller", "type": "core::starknet::contract_address::ContractAddress" }, + { "kind": "key", "name": "token", "type": "core::starknet::contract_address::ContractAddress" }, + { "kind": "key", "name": "amount", "type": "core::integer::u256" }, + { "kind": "key", "name": "recipients_count", "type": "core::integer::u32" } + ] + }, + { + "kind": "struct", + "name": "fundable::base::types::WeightedDistribution", + "type": "event", + "members": [ + { "kind": "key", "name": "caller", "type": "core::starknet::contract_address::ContractAddress" }, + { "kind": "key", "name": "token", "type": "core::starknet::contract_address::ContractAddress" }, + { "kind": "key", "name": "recipient", "type": "core::starknet::contract_address::ContractAddress" }, + { "kind": "key", "name": "amount", "type": "core::integer::u256" } + ] + }, + { + "kind": "struct", + "name": "openzeppelin_access::ownable::ownable::OwnableComponent::OwnershipTransferred", + "type": "event", + "members": [ + { "kind": "key", "name": "previous_owner", "type": "core::starknet::contract_address::ContractAddress" }, + { "kind": "key", "name": "new_owner", "type": "core::starknet::contract_address::ContractAddress" } + ] + }, + { + "kind": "struct", + "name": "openzeppelin_access::ownable::ownable::OwnableComponent::OwnershipTransferStarted", + "type": "event", + "members": [ + { "kind": "key", "name": "previous_owner", "type": "core::starknet::contract_address::ContractAddress" }, + { "kind": "key", "name": "new_owner", "type": "core::starknet::contract_address::ContractAddress" } + ] + }, + { + "kind": "enum", + "name": "openzeppelin_access::ownable::ownable::OwnableComponent::Event", + "type": "event", + "variants": [ + { "kind": "nested", "name": "OwnershipTransferred", "type": "openzeppelin_access::ownable::ownable::OwnableComponent::OwnershipTransferred" }, + { "kind": "nested", "name": "OwnershipTransferStarted", "type": "openzeppelin_access::ownable::ownable::OwnableComponent::OwnershipTransferStarted" } + ] + }, + { + "kind": "struct", + "name": "openzeppelin_upgrades::upgradeable::UpgradeableComponent::Upgraded", + "type": "event", + "members": [ + { "kind": "data", "name": "class_hash", "type": "core::starknet::class_hash::ClassHash" } + ] + }, + { + "kind": "enum", + "name": "openzeppelin_upgrades::upgradeable::UpgradeableComponent::Event", + "type": "event", + "variants": [ + { "kind": "nested", "name": "Upgraded", "type": "openzeppelin_upgrades::upgradeable::UpgradeableComponent::Upgraded" } + ] + }, + { + "kind": "enum", + "name": "fundable::distribute::Distributor::Event", + "type": "event", + "variants": [ + { "kind": "nested", "name": "Distribution", "type": "fundable::base::types::Distribution" }, + { "kind": "nested", "name": "WeightedDistribution", "type": "fundable::base::types::WeightedDistribution" }, + { "kind": "flat", "name": "OwnableEvent", "type": "openzeppelin_access::ownable::ownable::OwnableComponent::Event" }, + { "kind": "flat", "name": "UpgradeableEvent", "type": "openzeppelin_upgrades::upgradeable::UpgradeableComponent::Event" } + ] + } +] diff --git a/src/utils/starknetService.ts b/src/utils/starknetService.ts index 0c1897b..c5cc6d3 100644 --- a/src/utils/starknetService.ts +++ b/src/utils/starknetService.ts @@ -1,80 +1,124 @@ // This service handles interaction with the Cairo smart contract using starknet.js -import { RpcProvider, Account, Contract, uint256 } from 'starknet'; +import { RpcProvider, Account, Contract, uint256, Abi } from 'starknet'; -// TODO: Replace with your actual contract address and ABI -const CONTRACT_ADDRESS = process.env.CAMPAIGN_CONTRACT_ADDRESS || ''; -const CONTRACT_ABI = require('../../fundable/abi/campaign_donation.json'); // You need to generate ABI JSON +// Use the new distributor ABI +const CONTRACT_ADDRESS = process.env.CAMPAIGN_CONTRACT_ADDRESS; +if (!CONTRACT_ADDRESS) { + throw new Error('CAMPAIGN_CONTRACT_ADDRESS environment variable is required'); +} +const CONTRACT_ADDRESS_STR: string = CONTRACT_ADDRESS; +let CONTRACT_ABI: Abi; +try { + CONTRACT_ABI = require('./distributor.abi.json') as Abi; +} catch (error) { + if (error instanceof Error) { + throw new Error('Failed to load contract ABI: ' + error.message); + } else { + throw new Error('Failed to load contract ABI: Unknown error'); + } +} -// Adjust provider instantiation for latest starknet.js -// Use RpcProvider for mainnet-alpha (JSON-RPC endpoint) +// Use environment variable for RPC URL, fallback to default const provider = new RpcProvider({ - nodeUrl: 'https://starknet-mainnet.public.blastapi.io/rpc/v0_6', // Example public mainnet RPC endpoint + nodeUrl: process.env.STARKNET_RPC_URL || 'https://starknet-mainnet.public.blastapi.io/rpc/v0_6', }); // This function assumes you have a way to get the user's private key for signing -// Interface for campaign creation parameters -export interface U256 { - low: number; - high: number; -} - -export interface CreateCampaignParams { - campaign_ref: string; - target_amount: U256; - donation_token: string; - userWallet: string; -} +import { U256, CreateCampaignParams, CampaignEvent, TransactionReceipt, StarknetError } from '../types/campaign'; export async function createCampaignOnChain({ campaign_ref, target_amount, donation_token, userWallet }: CreateCampaignParams) { // You must implement secure key management for production const privateKey = await getUserPrivateKey(userWallet); // Implement this securely + if (!userWallet) throw new Error('userWallet is required'); + if (!privateKey) throw new Error('privateKey is required'); const account = new Account(provider, userWallet, privateKey); - const contract = new Contract(CONTRACT_ABI, CONTRACT_ADDRESS, account); + const contract = new Contract(CONTRACT_ABI, CONTRACT_ADDRESS_STR, account); - try { - const tx = await contract.create_campaign( - campaign_ref, - target_amount, - donation_token - ); - // Wait for transaction to be accepted - const receipt = await provider.waitForTransaction(tx.transaction_hash); - // Extract campaign_id from events or return value - const campaign_id = extractCampaignIdFromReceipt(receipt); // Implement this - return { - campaign_id, - transaction_hash: tx.transaction_hash, - }; - } catch (err) { - // Map contract errors to codes - throw mapStarknetError(err); + // Retry logic with exponential backoff + const maxRetries = 3; + let attempt = 0; + let lastError: Error | undefined; + while (attempt < maxRetries) { + try { + const tx = await contract.create_campaign( + campaign_ref, + target_amount, + donation_token + ); + // Wait for transaction to be accepted + const receipt = await provider.waitForTransaction(tx.transaction_hash); + // Extract campaign_id from events or return value + const campaign_id = extractCampaignIdFromReceipt(receipt); // Implement this + return { + campaign_id, + transaction_hash: tx.transaction_hash, + }; + } catch (error) { + if (error instanceof Error) { + lastError = error; + // Only retry on network/temporary errors + if (isRetriableError(error)) { + const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500; + await new Promise(res => setTimeout(res, delay)); + attempt++; + continue; + } else { + throw mapStarknetError(error); + } + } else { + // If error is not an instance of Error, throw a generic error + throw new Error('Unknown error occurred during contract interaction'); + } + } } + throw mapStarknetError(lastError || new Error('Unknown error after retries')); } -// Placeholder for extracting campaign_id from transaction receipt -interface CampaignEvent { - event_type: string; - data: { campaign_id?: string }; -} -interface TransactionReceipt { - events?: CampaignEvent[]; - [key: string]: any; +function isRetriableError(err: any): boolean { + const message = err?.message || ''; + // Add more retriable error patterns as needed + return ( + message.includes('timeout') || + message.includes('ECONNRESET') || + message.includes('network') || + message.includes('temporarily unavailable') + ); } -function extractCampaignIdFromReceipt(receipt: TransactionReceipt): string | null { - // Parse events or return value to get campaign_id - // ... - return receipt.events?.find(e => e.event_type === 'Campaign')?.data?.campaign_id || null; + +// Placeholder for extracting campaign_id from transaction receipt +// (Removed local CampaignEvent and TransactionReceipt interfaces; using imported ones) +function extractCampaignIdFromReceipt(receipt: TransactionReceipt): string { + if (!receipt.events) { + throw new Error('No events found in transaction receipt'); + } + const campaignEvent = receipt.events.find(e => e.event_type === 'CampaignCreated'); + if (!campaignEvent || !campaignEvent.data?.campaign_id) { + throw new Error('Campaign ID not found in transaction events'); + } + return campaignEvent.data.campaign_id as string; } // Placeholder for error mapping -function mapStarknetError(err: unknown): unknown { - // Map known error messages to codes - // ... - return err; +function mapStarknetError(err: unknown): StarknetError { + const error = err as StarknetError; + const message = error.message || ''; + // Map common StarkNet error patterns to application error codes + if (message.includes('Contract not found')) { + return { code: 'INVALID_CONTRACT_ADDRESS', message: 'Contract address not found', details: error }; + } + if (message.includes('Invalid transaction')) { + return { code: 'INVALID_TRANSACTION', message: 'Transaction validation failed', details: error }; + } + if (message.includes('Insufficient balance')) { + return { code: 'INSUFFICIENT_BALANCE', message: 'Insufficient balance for transaction', details: error }; + } + // Return original error if no mapping found + return { code: 'CONTRACT_ERROR', message: error.message || 'Unknown contract error', details: error }; } // Placeholder for secure key management async function getUserPrivateKey(userWallet: string): Promise { // Implement secure retrieval of user's private key + console.warn('getUserPrivateKey is not implemented. This must be securely implemented before production.'); throw new Error('getUserPrivateKey not implemented'); } From e0797922297fa46addc467a4b175f7d8c240699b Mon Sep 17 00:00:00 2001 From: gelluisaac Date: Fri, 25 Jul 2025 14:42:41 +0100 Subject: [PATCH 3/5] Implement codeRabbit review --- src/appMiddlewares/auth.middleware.ts | 29 +++++--- src/appMiddlewares/rateLimit.middleware.ts | 28 ++++--- .../v1/campaigns/campaignRequest.entity.ts | 13 ++++ .../v1/campaigns/campaigns.controller.ts | 7 +- src/components/v1/campaigns/campaigns.db.ts | 73 +++++++++++-------- .../v1/campaigns/campaigns.routes.ts | 19 +---- 6 files changed, 101 insertions(+), 68 deletions(-) create mode 100644 src/components/v1/campaigns/campaignRequest.entity.ts diff --git a/src/appMiddlewares/auth.middleware.ts b/src/appMiddlewares/auth.middleware.ts index 65c4346..a2c583e 100644 --- a/src/appMiddlewares/auth.middleware.ts +++ b/src/appMiddlewares/auth.middleware.ts @@ -1,12 +1,13 @@ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; -const JWT_SECRET = process.env.JWT_SECRET || 'your_jwt_secret'; - -export interface AuthUser { - id: string; - walletAddress: string; +const JWT_SECRET = process.env.JWT_SECRET; +if (!JWT_SECRET) { + throw new Error('JWT_SECRET environment variable is required'); } +const JWT_SECRET_STR: string = JWT_SECRET; + +import { AuthUser } from '../types/auth'; export function authMiddleware(req: Request, res: Response, next: NextFunction): void { const authHeader = req.headers.authorization; @@ -23,15 +24,25 @@ export function authMiddleware(req: Request, res: Response, next: NextFunction): } const token = authHeader.split(' ')[1]; try { - const decoded = jwt.verify(token, JWT_SECRET) as AuthUser; - req.user = decoded; // Now properly typed - next(); + const decoded = jwt.verify(token, JWT_SECRET_STR); + // Ensure decoded is an object and has required fields + if ( + typeof decoded === 'object' && + decoded !== null && + 'id' in decoded && + 'walletAddress' in decoded + ) { + req.user = decoded as AuthUser; + next(); + } else { + throw new Error('Invalid token payload'); + } } catch (err) { res.status(401).json({ success: false, error: { code: 'UNAUTHORIZED', - message: 'Invalid or expired token', + message: 'Authentication failed', details: {}, }, }); diff --git a/src/appMiddlewares/rateLimit.middleware.ts b/src/appMiddlewares/rateLimit.middleware.ts index 2073617..b5c23dc 100644 --- a/src/appMiddlewares/rateLimit.middleware.ts +++ b/src/appMiddlewares/rateLimit.middleware.ts @@ -13,19 +13,29 @@ export const campaignRateLimit = async (req: Request, res: Response, next: NextF }, }); } - const userId = req.user.id; - const count = await getUserCampaignCountLastHour(userId); - if (count >= 5) { - return res.status(429).json({ + try { + const userId = req.user.id; + const count = await getUserCampaignCountLastHour(userId); + if (count >= 5) { + return res.status(429).json({ + success: false, + error: { + code: 'RATE_LIMIT_EXCEEDED', + message: 'Maximum 5 campaigns per user per hour exceeded', + details: {}, + }, + }); + } + // Do not log here - let the controller log after successful campaign creation + next(); + } catch (error) { + return res.status(500).json({ success: false, error: { - code: 'RATE_LIMIT_EXCEEDED', - message: 'Maximum 5 campaigns per user per hour exceeded', + code: 'INTERNAL_ERROR', + message: 'Rate limit check failed', details: {}, }, }); } - // Log this request for future rate limit checks - await saveUserCampaignRequest(userId); - next(); }; diff --git a/src/components/v1/campaigns/campaignRequest.entity.ts b/src/components/v1/campaigns/campaignRequest.entity.ts new file mode 100644 index 0000000..0e3ae7d --- /dev/null +++ b/src/components/v1/campaigns/campaignRequest.entity.ts @@ -0,0 +1,13 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +@Entity('campaign_requests') +export class CampaignRequest { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + user_id: string; + + @CreateDateColumn({ type: 'timestamptz' }) + created_at: Date; +} diff --git a/src/components/v1/campaigns/campaigns.controller.ts b/src/components/v1/campaigns/campaigns.controller.ts index 8401cc0..45fe1fe 100644 --- a/src/components/v1/campaigns/campaigns.controller.ts +++ b/src/components/v1/campaigns/campaigns.controller.ts @@ -1,7 +1,7 @@ import { Request, Response } from 'express'; import { validateCampaignInput } from './campaigns.validation'; import { createCampaignOnChain } from '../../../utils/starknetService'; -import { saveCampaignToDb, isCampaignRefUnique } from './campaigns.db'; +import { saveCampaignToDb, isCampaignRefUnique, saveUserCampaignRequest } from './campaigns.db'; import logAudit from '../../../utils/logger'; import { getUserWalletBalance, verifyTokenContract } from '../../../utils/blockchainUtils'; import { u256FromString, isValidContractAddress } from '../../../utils/helper'; @@ -54,7 +54,7 @@ export const createCampaignController = async (req: Request, res: Response) => { success: false, error: { code: 'INSUFFICIENT_BALANCE', - message: 'Insufficient wallet balance for transaction fees', + message: 'Insufficient donation token balance', details: {}, }, }); @@ -129,6 +129,9 @@ export const createCampaignController = async (req: Request, res: Response) => { created_at: dayjs().toISOString(), }); + // Log this request for rate limiting (after successful creation) + await saveUserCampaignRequest(req.user.id); + // Audit log logAudit.info('CAMPAIGN_CREATED', { user_id: req.user.id, diff --git a/src/components/v1/campaigns/campaigns.db.ts b/src/components/v1/campaigns/campaigns.db.ts index 3a8f476..1d0d4bc 100644 --- a/src/components/v1/campaigns/campaigns.db.ts +++ b/src/components/v1/campaigns/campaigns.db.ts @@ -1,13 +1,19 @@ // Database operations for campaigns // Assumes TypeORM or similar ORM is used import AppDataSource from '../../../config/persistence/data-source'; -import { Campaign} from './campaign.entity' +import { Campaign } from './campaign.entity' +import { CampaignRequest } from './campaignRequest.entity'; // Check if campaign_ref is unique export async function isCampaignRefUnique(campaign_ref: string): Promise { - const repo = AppDataSource.getRepository(Campaign); - const count = await repo.count({ where: { campaign_ref } }); - return count === 0; + try { + const repo = AppDataSource.getRepository(Campaign); + const count = await repo.count({ where: { campaign_ref } }); + return count === 0; + } catch (error) { + console.error('Error checking campaign reference uniqueness:', error); + throw new Error('Failed to verify campaign reference uniqueness'); + } } // Save campaign metadata to DB @@ -22,26 +28,23 @@ export async function saveCampaignToDb({ user_id, created_at, }: CampaignData): Promise { - const repo = AppDataSource.getRepository(Campaign); - const campaign = repo.create({ - campaign_id, - campaign_ref, - target_amount, - donation_token, - transaction_hash, - user_id, - created_at, - }); - await repo.save(campaign); - return { - campaign_id, - campaign_ref, - target_amount, - donation_token, - transaction_hash, - user_id, - created_at, - }; + try { + const repo = AppDataSource.getRepository(Campaign); + const campaign = repo.create({ + campaign_id, + campaign_ref, + target_amount, + donation_token, + transaction_hash, + user_id, + created_at, + }); + const saved = await repo.save(campaign); + return saved; + } catch (error) { + console.error('Error saving campaign to database:', error); + throw new Error('Failed to save campaign'); + } } // --- Rate limiting support --- @@ -49,14 +52,22 @@ export async function saveCampaignToDb({ import { MoreThan } from 'typeorm'; export async function saveUserCampaignRequest(userId: string): Promise { - // You need a CampaignRequest entity for this to work - const repo = AppDataSource.getRepository('CampaignRequest'); - await repo.insert({ user_id: userId, created_at: new Date().toISOString() }); + try { + const repo = AppDataSource.getRepository(CampaignRequest); + await repo.insert({ user_id: userId }); // created_at will be auto-set + } catch (error) { + console.error('Error logging campaign request:', error); + throw new Error('Failed to log campaign request'); + } } export async function getUserCampaignCountLastHour(userId: string): Promise { - // You need a CampaignRequest entity for this to work - const repo = AppDataSource.getRepository('CampaignRequest'); - const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); - return await repo.count({ where: { user_id: userId, created_at: MoreThan(oneHourAgo) } }); + try { + const repo = AppDataSource.getRepository(CampaignRequest); + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + return await repo.count({ where: { user_id: userId, created_at: MoreThan(oneHourAgo) } }); + } catch (error) { + console.error('Error counting user campaign requests:', error); + throw new Error('Failed to count user campaign requests'); + } } diff --git a/src/components/v1/campaigns/campaigns.routes.ts b/src/components/v1/campaigns/campaigns.routes.ts index 8d624cb..d50c6e2 100644 --- a/src/components/v1/campaigns/campaigns.routes.ts +++ b/src/components/v1/campaigns/campaigns.routes.ts @@ -1,31 +1,16 @@ import { Router, Request } from 'express'; import { createCampaignController } from './campaigns.controller'; -import rateLimit from 'express-rate-limit'; import { authMiddleware } from 'src/appMiddlewares/auth.middleware'; +import { campaignRateLimit } from '../../../appMiddlewares/rateLimit.middleware'; const router = Router(); // Express Request is globally augmented for user property via types/express/index.d.ts -// Rate limit: max 5 campaigns per user per hour -const createCampaignLimiter = rateLimit({ - windowMs: 60 * 60 * 1000, // 1 hour - max: 5, - keyGenerator: (req) => req.user?.id || req.ip || '', - message: { - success: false, - error: { - code: 'RATE_LIMIT_EXCEEDED', - message: 'Max 5 campaigns per user per hour', - details: {} - } - } -}); - router.post( '/campaigns', authMiddleware, - createCampaignLimiter, + campaignRateLimit, createCampaignController ); From 0110857f278484d429355ae41ec814dbf73b0d96 Mon Sep 17 00:00:00 2001 From: gelluisaac Date: Fri, 25 Jul 2025 14:42:41 +0100 Subject: [PATCH 4/5] Implement codeRabbit review --- src/components/v1/campaigns/campaignRequest.entity.ts | 5 +++-- src/utils/blockchainUtils.ts | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/components/v1/campaigns/campaignRequest.entity.ts b/src/components/v1/campaigns/campaignRequest.entity.ts index 0e3ae7d..652149e 100644 --- a/src/components/v1/campaigns/campaignRequest.entity.ts +++ b/src/components/v1/campaigns/campaignRequest.entity.ts @@ -1,11 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; @Entity('campaign_requests') export class CampaignRequest { @PrimaryGeneratedColumn('uuid') id: string; - @Column() + @Column({ nullable: false }) + @Index() user_id: string; @CreateDateColumn({ type: 'timestamptz' }) diff --git a/src/utils/blockchainUtils.ts b/src/utils/blockchainUtils.ts index 3f5930a..8fe60e2 100644 --- a/src/utils/blockchainUtils.ts +++ b/src/utils/blockchainUtils.ts @@ -2,11 +2,12 @@ import { RpcProvider, Contract, uint256 } from 'starknet'; // You may want to move these to a config file -const STARKNET_RPC_URL = process.env.STARKNET_RPC_URL || 'https://starknet-mainnet.public.blastapi.io/rpc/v0_6'; +const STARKNET_RPC_URL = process.env.STARKNET_RPC_URL; if (!STARKNET_RPC_URL) { - throw new Error('STARKNET_RPC_URL environment variable is required'); + console.warn('STARKNET_RPC_URL not set, using public endpoint'); } -const provider = new RpcProvider({ nodeUrl: STARKNET_RPC_URL }); +const RPC_URL = STARKNET_RPC_URL || 'https://starknet-mainnet.public.blastapi.io/rpc/v0_6'; +const provider = new RpcProvider({ nodeUrl: RPC_URL }); // Standard ERC20 ABI fragment for balanceOf and symbol const ERC20_ABI = [ From 869eb65d79accd378ff8877dd40588201ce20294 Mon Sep 17 00:00:00 2001 From: gelluisaac Date: Fri, 25 Jul 2025 14:42:41 +0100 Subject: [PATCH 5/5] Implement codeRabbit review --- src/components/v1/campaigns/campaign.entity.ts | 14 +++++++------- .../v1/campaigns/campaignRequest.entity.ts | 2 +- .../v1/distribution/distribution.controller.ts | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/v1/campaigns/campaign.entity.ts b/src/components/v1/campaigns/campaign.entity.ts index e0cd212..4ffac48 100644 --- a/src/components/v1/campaigns/campaign.entity.ts +++ b/src/components/v1/campaigns/campaign.entity.ts @@ -2,24 +2,24 @@ import { Entity, PrimaryColumn, Column } from 'typeorm'; @Entity('campaigns') export class Campaign { - @PrimaryColumn() + @PrimaryColumn('varchar') campaign_id: string; - @Column({ unique: true }) + @Column('varchar', { unique: true }) campaign_ref: string; - @Column() + @Column('varchar') target_amount: string; - @Column() + @Column('varchar') donation_token: string; - @Column() + @Column('varchar') transaction_hash: string; - @Column() + @Column('varchar') user_id: string; - @Column() + @Column('varchar') created_at: string; } diff --git a/src/components/v1/campaigns/campaignRequest.entity.ts b/src/components/v1/campaigns/campaignRequest.entity.ts index 652149e..ca8396c 100644 --- a/src/components/v1/campaigns/campaignRequest.entity.ts +++ b/src/components/v1/campaigns/campaignRequest.entity.ts @@ -5,7 +5,7 @@ export class CampaignRequest { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ nullable: false }) + @Column('varchar', { nullable: false }) @Index() user_id: string; diff --git a/src/components/v1/distribution/distribution.controller.ts b/src/components/v1/distribution/distribution.controller.ts index 5e68d98..ffb6fb9 100644 --- a/src/components/v1/distribution/distribution.controller.ts +++ b/src/components/v1/distribution/distribution.controller.ts @@ -1,7 +1,7 @@ import type { Request, Response } from "express" import AppDataSource from "../../../config/persistence/data-source" import { DistributionEntity } from "./distribution.entity" -import { DistributionService } from "./Distribution.service" +import { DistributionService } from "./distribution.service" import type { ApiResponse, DistributionResponseDto, CreateDistributionDto } from "./distribution.dto" const distributionRepository = AppDataSource.getRepository(DistributionEntity)