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..3fcaf1f 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,13 @@ "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", "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/appMiddlewares/auth.middleware.ts b/src/appMiddlewares/auth.middleware.ts new file mode 100644 index 0000000..a2c583e --- /dev/null +++ b/src/appMiddlewares/auth.middleware.ts @@ -0,0 +1,50 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; + +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; + 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_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: 'Authentication failed', + details: {}, + }, + }); + } +} diff --git a/src/appMiddlewares/rateLimit.middleware.ts b/src/appMiddlewares/rateLimit.middleware.ts new file mode 100644 index 0000000..b5c23dc --- /dev/null +++ b/src/appMiddlewares/rateLimit.middleware.ts @@ -0,0 +1,41 @@ +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: {}, + }, + }); + } + 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: 'INTERNAL_ERROR', + message: 'Rate limit check failed', + details: {}, + }, + }); + } +}; diff --git a/src/components/v1/campaigns/campaign.entity.ts b/src/components/v1/campaigns/campaign.entity.ts new file mode 100644 index 0000000..4ffac48 --- /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('varchar') + campaign_id: string; + + @Column('varchar', { unique: true }) + campaign_ref: string; + + @Column('varchar') + target_amount: string; + + @Column('varchar') + donation_token: string; + + @Column('varchar') + transaction_hash: string; + + @Column('varchar') + user_id: string; + + @Column('varchar') + created_at: string; +} diff --git a/src/components/v1/campaigns/campaignRequest.entity.ts b/src/components/v1/campaigns/campaignRequest.entity.ts new file mode 100644 index 0000000..ca8396c --- /dev/null +++ b/src/components/v1/campaigns/campaignRequest.entity.ts @@ -0,0 +1,14 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; + +@Entity('campaign_requests') +export class CampaignRequest { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('varchar', { nullable: false }) + @Index() + 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 new file mode 100644 index 0000000..45fe1fe --- /dev/null +++ b/src/components/v1/campaigns/campaigns.controller.ts @@ -0,0 +1,165 @@ +import { Request, Response } from 'express'; +import { validateCampaignInput } from './campaigns.validation'; +import { createCampaignOnChain } from '../../../utils/starknetService'; +import { saveCampaignToDb, isCampaignRefUnique, saveUserCampaignRequest } 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 + 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, donation_token); + if (!balance || balance.lte(0n)) { + return res.status(400).json({ + success: false, + error: { + code: 'INSUFFICIENT_BALANCE', + message: 'Insufficient donation token balance', + 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, + }); + 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) { + 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 (error.code === 'CAMPAIGN_REF_EMPTY') { + status = 400; code = 'EMPTY_CAMPAIGN_REF'; message = 'Campaign reference is empty'; + } else if (error.code === 'CAMPAIGN_REF_EXISTS') { + status = 409; code = 'DUPLICATE_CAMPAIGN_REF'; message = 'Campaign reference already exists'; + } else if (error.code === 'ZERO_TARGET_AMOUNT') { + status = 400; code = 'ZERO_TARGET_AMOUNT'; message = 'Target amount must be greater than zero'; + } 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({ + success: false, + error: { + code, + message, + details: error.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(), + }); + + // 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, + 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/campaigns.db.ts b/src/components/v1/campaigns/campaigns.db.ts new file mode 100644 index 0000000..1d0d4bc --- /dev/null +++ b/src/components/v1/campaigns/campaigns.db.ts @@ -0,0 +1,73 @@ +// Database operations for campaigns +// Assumes TypeORM or similar ORM is used +import AppDataSource from '../../../config/persistence/data-source'; +import { Campaign } from './campaign.entity' +import { CampaignRequest } from './campaignRequest.entity'; + +// Check if campaign_ref is unique +export async function isCampaignRefUnique(campaign_ref: string): Promise { + 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 +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 { + 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 --- +// Assumes a CampaignRequest entity/table with user_id and created_at fields +import { MoreThan } from 'typeorm'; + +export async function saveUserCampaignRequest(userId: string): Promise { + 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 { + 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 new file mode 100644 index 0000000..d50c6e2 --- /dev/null +++ b/src/components/v1/campaigns/campaigns.routes.ts @@ -0,0 +1,17 @@ +import { Router, Request } from 'express'; +import { createCampaignController } from './campaigns.controller'; +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 + +router.post( + '/campaigns', + authMiddleware, + campaignRateLimit, + createCampaignController +); + +export default router; \ No newline at end of file diff --git a/src/components/v1/campaigns/campaigns.validation.ts b/src/components/v1/campaigns/campaigns.validation.ts new file mode 100644 index 0000000..a5e8e4d --- /dev/null +++ b/src/components/v1/campaigns/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: unknown): Joi.ValidationResult { + return campaignSchema.validate(input, { abortEarly: false }); +} diff --git a/src/components/v1/campaigns/routes.v1.ts b/src/components/v1/campaigns/routes.v1.ts new file mode 100644 index 0000000..a2571b4 --- /dev/null +++ b/src/components/v1/campaigns/routes.v1.ts @@ -0,0 +1,15 @@ +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/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) diff --git a/src/components/v1/routes.v1.ts b/src/components/v1/routes.v1.ts deleted file mode 100644 index 83a9bea..0000000 --- a/src/components/v1/routes.v1.ts +++ /dev/null @@ -1,14 +0,0 @@ -import EnhancedRouter from '../../utils/enhancedRouter'; - -import platformRoutes from './platform/platform.routes'; -import walletRoutes from './wallet/wallet.routes'; -import distributionRoutes from "./distribution/distrubtion.routes" - - -const routerV1 = new EnhancedRouter(); - -routerV1.use('/platform', platformRoutes); -routerV1.use('/wallets', walletRoutes); -routerV1.use("/distributions", distributionRoutes) - -export default routerV1.getRouter(); 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 new file mode 100644 index 0000000..c6aa833 --- /dev/null +++ b/src/types/express/index.d.ts @@ -0,0 +1,11 @@ +import { AuthUser } from '../../appMiddlewares/auth.middleware'; + +declare global { + namespace Express { + interface Request { + user?: AuthUser; + } + } +} + +export {}; diff --git a/src/utils/blockchainUtils.ts b/src/utils/blockchainUtils.ts new file mode 100644 index 0000000..8fe60e2 --- /dev/null +++ b/src/utils/blockchainUtils.ts @@ -0,0 +1,60 @@ +// Blockchain utility functions for wallet balance and token contract verification +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; +if (!STARKNET_RPC_URL) { + console.warn('STARKNET_RPC_URL not set, using public endpoint'); +} +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 = [ + { + "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/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..c5cc6d3 --- /dev/null +++ b/src/utils/starknetService.ts @@ -0,0 +1,124 @@ +// This service handles interaction with the Cairo smart contract using starknet.js +import { RpcProvider, Account, Contract, uint256, Abi } from 'starknet'; + +// 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'); + } +} + +// Use environment variable for RPC URL, fallback to default +const provider = new RpcProvider({ + 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 +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_STR, account); + + // 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')); +} + +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') + ); +} + +// 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): 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'); +}